diff --git a/.env b/.env index 3ba3d65d..8d5e5a54 100644 --- a/.env +++ b/.env @@ -71,6 +71,17 @@ DISABLE_WEB_UPDATES=1 # Restoring backups is a destructive operation that could overwrite your database. DISABLE_BACKUP_RESTORE=1 +# Disable backup download from the Update Manager UI (0=enabled, 1=disabled). +# Backups contain sensitive data including password hashes and secrets. +# When enabled, users must confirm their password before downloading. +DISABLE_BACKUP_DOWNLOAD=1 + +# Watchtower integration for Docker-based updates. +# Set these to enable one-click updates via the Update Manager UI. +# See https://containrrr.dev/watchtower/ for Watchtower setup. +WATCHTOWER_API_URL= +WATCHTOWER_API_TOKEN= + ################################################################################### # SAML Single sign on-settings ################################################################################### @@ -116,6 +127,10 @@ SAML_SP_PRIVATE_KEY="MIIE..." # In demo mode things it is not possible for a user to change his password and his settings. DEMO_MODE=0 +# When this is set to 1, users can make Part-DB directly download a file specified as a URL from the local network and create it as a local file. +# This allows users access to all resources available in the local network, which could be a security risk, so use this only if you trust your users and have a secure local network. +ALLOW_ATTACHMENT_DOWNLOADS_FROM_LOCALNETWORK=0 + # Change this to true, if no url rewriting (like mod_rewrite for Apache) is available # In that case all URL contains the index.php front controller in URL NO_URL_REWRITE_AVAILABLE=0 @@ -146,3 +161,11 @@ APP_ENV=prod APP_SECRET=a03498528f5a5fc089273ec9ae5b2849 APP_SHARE_DIR=var/share ###< symfony/framework-bundle ### + +###> symfony/ai-generic-platform ### +# GENERIC_BASE_URL=https://api.example.com/v1 +###< symfony/ai-generic-platform ### + +###> symfony/ai-open-router-platform ### +OPENROUTER_API_KEY= +###< symfony/ai-open-router-platform ### diff --git a/.github/workflows/assets_artifact_build.yml b/.github/workflows/assets_artifact_build.yml index 3409b7fd..a74ae7cc 100644 --- a/.github/workflows/assets_artifact_build.yml +++ b/.github/workflows/assets_artifact_build.yml @@ -8,6 +8,9 @@ on: branches: - '*' - "!l10n_*" # Dont test localization branches + tags: + - 'v*.*.*' + - 'v*.*.*-**' pull_request: branches: - '*' @@ -17,6 +20,8 @@ jobs: assets_artifact_build: name: Build assets artifact runs-on: ubuntu-22.04 + permissions: + contents: write env: APP_ENV: prod @@ -62,7 +67,7 @@ jobs: - name: Setup node uses: actions/setup-node@v6 with: - node-version: '20' + node-version: '22' - name: Install yarn dependencies run: yarn install @@ -80,13 +85,20 @@ jobs: run: zip -r /tmp/partdb_assets.zip public/build/ vendor/ - name: Upload assets artifact - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: Only dependencies and built assets path: /tmp/partdb_assets.zip - name: Upload full artifact - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: Full Part-DB including dependencies and built assets path: /tmp/partdb_with_assets.zip + + - name: Upload assets as release attachment + if: startsWith(github.ref, 'refs/tags/') + run: | + gh release upload "${{ github.ref_name }}" /tmp/partdb_assets.zip /tmp/partdb_with_assets.zip --clobber + env: + GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/docker_build.yml b/.github/workflows/docker_build.yml index 1eff846e..210dbc18 100644 --- a/.github/workflows/docker_build.yml +++ b/.github/workflows/docker_build.yml @@ -36,7 +36,7 @@ jobs: - name: Docker meta id: docker_meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: # list of Docker images to use as base name for tags images: | @@ -66,11 +66,11 @@ jobs: - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Login to DockerHub if: github.event_name != 'pull_request' - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -78,7 +78,7 @@ jobs: - name: Build and push by digest id: build - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . platforms: ${{ matrix.platform }} @@ -98,7 +98,7 @@ jobs: - name: Upload digest if: github.event_name != 'pull_request' - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: digests-${{ matrix.platform-slug }} path: /tmp/digests/* @@ -113,7 +113,7 @@ jobs: steps: - name: Download digests - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: path: /tmp/digests pattern: digests-* @@ -121,12 +121,12 @@ jobs: - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Docker meta id: docker_meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: | jbtronics/part-db1 @@ -142,7 +142,7 @@ jobs: - name: Login to DockerHub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} diff --git a/.github/workflows/docker_frankenphp.yml b/.github/workflows/docker_frankenphp.yml index 8acb5c22..36ec322d 100644 --- a/.github/workflows/docker_frankenphp.yml +++ b/.github/workflows/docker_frankenphp.yml @@ -36,7 +36,7 @@ jobs: - name: Docker meta id: docker_meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: # list of Docker images to use as base name for tags images: | @@ -66,11 +66,11 @@ jobs: - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Login to DockerHub if: github.event_name != 'pull_request' - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -78,7 +78,7 @@ jobs: - name: Build and push by digest id: build - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . file: Dockerfile-frankenphp @@ -99,7 +99,7 @@ jobs: - name: Upload digest if: github.event_name != 'pull_request' - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: digests-${{ matrix.platform-slug }} path: /tmp/digests/* @@ -114,7 +114,7 @@ jobs: steps: - name: Download digests - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: path: /tmp/digests pattern: digests-* @@ -122,12 +122,12 @@ jobs: - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Docker meta id: docker_meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: | partdborg/part-db @@ -143,7 +143,7 @@ jobs: - name: Login to DockerHub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3df1955a..5b756228 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -106,7 +106,7 @@ jobs: - name: Setup node uses: actions/setup-node@v6 with: - node-version: '20' + node-version: '22' - name: Install yarn dependencies run: yarn install @@ -129,7 +129,7 @@ jobs: run: ./bin/phpunit --coverage-clover=coverage.xml - name: Upload coverage - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v6 with: env_vars: PHP_VERSION,DB_TYPE token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore index dd5c43db..704d6202 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,10 @@ uploads/* !uploads/.keep +# Some people use Certbot or similar tools to make SSL certificates. +# Also see https://www.rfc-editor.org/rfc/rfc5785 +public/.well-known/ + # Do not keep cache files .php_cs.cache .phpcs-cache @@ -50,4 +54,11 @@ phpstan.neon ###< phpstan/phpstan ### .claude/ -CLAUDE.md \ No newline at end of file +CLAUDE.md + +.codex +migrations/.codex +docker-data/ +scripts/ +db/ +docker-compose.yaml diff --git a/Dockerfile-frankenphp b/Dockerfile-frankenphp index 4bf9eeeb..bdf9c1fd 100644 --- a/Dockerfile-frankenphp +++ b/Dockerfile-frankenphp @@ -62,7 +62,7 @@ RUN yarn build RUN yarn cache clean && rm -rf node_modules/ # FrankenPHP base stage -FROM dunglas/frankenphp:1-php8.4 AS frankenphp_upstream +FROM dunglas/frankenphp:1-php8.4-bookworm AS frankenphp_upstream ARG TARGETARCH RUN --mount=type=cache,id=apt-cache-$TARGETARCH,target=/var/cache/apt \ --mount=type=cache,id=apt-lists-$TARGETARCH,target=/var/lib/apt/lists \ diff --git a/README.md b/README.md index 993a1a9c..644f7fa4 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,6 +61,7 @@ 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 * 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. @@ -74,11 +74,11 @@ Part-DB is also used by small companies and universities for managing their inve ## Requirements * A **web server** (like Apache2 or nginx) that is capable of - running [Symfony 6](https://symfony.com/doc/current/reference/requirements.html), + running [Symfony 7](https://symfony.com/doc/current/reference/requirements.html), this includes a minimum PHP version of **PHP 8.2** * A **MySQL** (at least 5.7) /**MariaDB** (at least 10.4) database server, or **PostgreSQL** 10+ if you do not want to use SQLite. * Shell access to your server is highly recommended! -* For building the client-side assets **yarn** and **nodejs** (>= 20.0) is needed. +* For building the client-side assets **yarn** and **nodejs** (>= 22.0) is needed. ## Installation diff --git a/VERSION b/VERSION index 834f2629..6ceb272e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.8.0 +2.11.1 diff --git a/assets/controllers/bulk_import_controller.js b/assets/controllers/bulk_import_controller.js index 49e4d60f..a04ff13e 100644 --- a/assets/controllers/bulk_import_controller.js +++ b/assets/controllers/bulk_import_controller.js @@ -3,14 +3,16 @@ import { generateCsrfHeaders } from "./csrf_protection_controller" export default class extends Controller { static targets = ["progressBar", "progressText"] - static values = { + static values = { jobId: Number, partId: Number, researchUrl: String, researchAllUrl: String, markCompletedUrl: String, markSkippedUrl: String, - markPendingUrl: String + markPendingUrl: String, + quickApplyUrl: String, + quickApplyAllUrl: String } connect() { @@ -119,13 +121,11 @@ export default class extends Controller { async markSkipped(event) { const partId = event.currentTarget.dataset.partId - const reason = prompt('Reason for skipping (optional):') || '' - + try { const url = this.markSkippedUrlValue.replace('__PART_ID__', partId) const data = await this.fetchWithErrorHandling(url, { - method: 'POST', - body: JSON.stringify({ reason }) + method: 'POST' }) if (data.success) { @@ -321,6 +321,94 @@ export default class extends Controller { } } + async quickApply(event) { + event.preventDefault() + event.stopPropagation() + + const partId = event.currentTarget.dataset.partId + const providerKey = event.currentTarget.dataset.providerKey + const providerId = event.currentTarget.dataset.providerId + const button = event.currentTarget + const originalHtml = button.innerHTML + + button.disabled = true + button.innerHTML = ' Applying...' + + try { + const url = this.quickApplyUrlValue.replace('__PART_ID__', partId) + const data = await this.fetchWithErrorHandling(url, { + method: 'POST', + body: JSON.stringify({ providerKey, providerId }) + }, 60000) + + if (data.success) { + this.updateProgressDisplay(data) + this.showSuccessMessage(data.message || 'Part updated successfully') + sessionStorage.setItem('bulkImportScrollPosition', window.scrollY.toString()) + window.location.reload() + } else { + this.showErrorMessage(data.error || 'Quick apply failed') + button.innerHTML = originalHtml + button.disabled = false + } + } catch (error) { + console.error('Error in quick apply:', error) + this.showErrorMessage(error.message || 'Quick apply failed') + button.innerHTML = originalHtml + button.disabled = false + } + } + + async quickApplyAll(event) { + event.preventDefault() + event.stopPropagation() + + if (!confirm('This will apply the top search result to all pending parts without individual review. Continue?')) { + return + } + + const button = event.currentTarget + const spinner = document.getElementById('quick-apply-all-spinner') + const originalHtml = button.innerHTML + + button.disabled = true + if (spinner) { + spinner.style.display = 'inline-block' + } + + try { + const data = await this.fetchWithErrorHandling(this.quickApplyAllUrlValue, { + method: 'POST' + }, 300000) + + if (data.success) { + this.updateProgressDisplay(data) + + let message = data.message || 'Bulk apply completed' + if (data.errors && data.errors.length > 0) { + message += '\nErrors:\n' + data.errors.join('\n') + } + + this.showSuccessMessage(message) + sessionStorage.setItem('bulkImportScrollPosition', window.scrollY.toString()) + window.location.reload() + } else { + this.showErrorMessage(data.error || 'Bulk apply failed') + button.innerHTML = originalHtml + button.disabled = false + } + } catch (error) { + console.error('Error in quick apply all:', error) + this.showErrorMessage(error.message || 'Bulk apply failed') + button.innerHTML = originalHtml + button.disabled = false + } finally { + if (spinner) { + spinner.style.display = 'none' + } + } + } + showSuccessMessage(message) { this.showToast('success', message) } diff --git a/assets/controllers/docker_update_progress_controller.js b/assets/controllers/docker_update_progress_controller.js new file mode 100644 index 00000000..bc4c6ff3 --- /dev/null +++ b/assets/controllers/docker_update_progress_controller.js @@ -0,0 +1,377 @@ +/* + * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony). + * + * Copyright (C) 2019 - 2026 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'; + +/** + * Stimulus controller for Docker update progress tracking. + * + * Polls the health check endpoint to detect when the container restarts + * after a Watchtower-triggered update. Drives the step timeline UI + * with timestamps, matching the git update progress style. + */ +export default class extends Controller { + static values = { + healthUrl: String, + previousVersion: { type: String, default: 'unknown' }, + pollInterval: { type: Number, default: 5000 }, + maxWaitTime: { type: Number, default: 600000 }, // 10 minutes + // Translated UI strings (passed from Twig template) + textPulling: { type: String, default: 'Waiting for Watchtower to pull the new image...' }, + textPullingDetail: { type: String, default: 'Watchtower is checking for and downloading the latest Docker image...' }, + textRestarting: { type: String, default: 'Container is restarting with the new image...' }, + textRestartingDetail: { type: String, default: 'The container is being recreated with the updated image. This may take a moment...' }, + textSuccess: { type: String, default: 'Update Complete!' }, + textSuccessDetail: { type: String, default: 'Part-DB has been updated successfully via Docker.' }, + textTimeout: { type: String, default: 'Update Taking Longer Than Expected' }, + textTimeoutDetail: { type: String, default: 'The update may still be in progress. Check your Docker logs for details.' }, + textStepPull: { type: String, default: 'Pull Image' }, + textStepRestart: { type: String, default: 'Restart Container' }, + }; + + static targets = [ + // Header + 'headerWhale', 'titleIcon', + 'statusText', 'statusSubtext', + 'progressBar', 'elapsedTime', + // Alerts + 'stepAlert', 'stepName', 'stepMessage', + 'successAlert', 'timeoutAlert', 'errorAlert', 'errorMessage', 'warningAlert', + // Step timeline (multi-target arrays) + 'stepRow', 'stepIcon', 'stepDetail', 'stepTime', + // Version display + 'newVersion', 'previousVersion', + // Actions + 'actions', + ]; + + // Step definitions: name -> { index, progress% } + static STEPS = { + trigger: { index: 0, progress: 15 }, + pull: { index: 1, progress: 30 }, + stop: { index: 2, progress: 50 }, + restart: { index: 3, progress: 65 }, + health: { index: 4, progress: 80 }, + verify: { index: 5, progress: 100 }, + }; + + connect() { + this.serverWentDown = false; + this.serverCameBack = false; + this.startTime = Date.now(); + this.timer = null; + this.currentStep = 'pull'; // trigger is already done + this.stepTimestamps = { trigger: this.formatTime(new Date()) }; + this.consecutiveSuccessCount = 0; + + // Set the trigger step timestamp + this.setStepTimestamp(0, this.stepTimestamps.trigger); + + this.poll(); + } + + disconnect() { + if (this.timer) { + clearTimeout(this.timer); + } + } + + createTimeoutSignal(ms) { + if (typeof AbortSignal.timeout === 'function') { + return AbortSignal.timeout(ms); + } + const controller = new AbortController(); + setTimeout(() => controller.abort(), ms); + return controller.signal; + } + + async poll() { + const elapsed = Date.now() - this.startTime; + this.updateElapsedTime(elapsed); + + if (elapsed > this.maxWaitTimeValue) { + this.showTimeout(); + return; + } + + try { + const response = await fetch(this.healthUrlValue, { + cache: 'no-store', + signal: this.createTimeoutSignal(4000), + }); + + if (response.ok) { + let data; + try { + data = await response.json(); + } catch (parseError) { + this.schedulePoll(); + return; + } + + if (this.serverWentDown) { + // Server came back! Move through health check -> verify + if (!this.serverCameBack) { + this.serverCameBack = true; + this.advanceToStep('health'); + } + + this.consecutiveSuccessCount++; + + // Wait for 2 consecutive successes to confirm stability + if (this.consecutiveSuccessCount >= 2) { + this.showSuccess(data.version); + return; + } + } else { + // Server still up - Watchtower pulling image + this.showPulling(); + } + } else if (response.status === 503) { + // Maintenance mode or shutting down + this.serverWentDown = true; + this.consecutiveSuccessCount = 0; + this.advanceToStep('stop'); + } else { + if (this.serverWentDown) { + this.showRestarting(); + } else { + this.showPulling(); + } + } + } catch (e) { + // Connection refused = container is down + if (!this.serverWentDown) { + this.serverWentDown = true; + this.advanceToStep('stop'); + } + this.consecutiveSuccessCount = 0; + this.showRestarting(); + } + + this.schedulePoll(); + } + + schedulePoll() { + this.timer = setTimeout(() => this.poll(), this.pollIntervalValue); + } + + /** + * Advance the step timeline to a specific step. + * Marks all previous steps as complete with timestamps. + */ + advanceToStep(stepName) { + const steps = this.constructor.STEPS; + const targetIndex = steps[stepName]?.index; + if (targetIndex === undefined) return; + + const stepNames = Object.keys(steps); + const now = this.formatTime(new Date()); + + for (let i = 0; i < stepNames.length; i++) { + const name = stepNames[i]; + + if (i < targetIndex) { + // Completed step + this.markStepComplete(i, this.stepTimestamps[name] || now); + if (!this.stepTimestamps[name]) { + this.stepTimestamps[name] = now; + } + } else if (i === targetIndex) { + // Current active step + this.markStepActive(i); + this.stepTimestamps[name] = now; + this.setStepTimestamp(i, now); + this.currentStep = name; + } + // Steps after targetIndex remain pending (no change needed) + } + + // Update progress bar + this.updateProgressBar(steps[stepName].progress); + } + + showPulling() { + if (this.hasStatusTextTarget) { + this.statusTextTarget.textContent = this.textPullingValue; + } + if (this.hasStepNameTarget) { + this.stepNameTarget.textContent = this.textStepPullValue; + } + if (this.hasStepMessageTarget) { + this.stepMessageTarget.textContent = this.textPullingDetailValue; + } + this.updateProgressBar(30); + } + + showRestarting() { + // Advance to restart step if we haven't already + if (this.currentStep !== 'restart' && this.currentStep !== 'health' && this.currentStep !== 'verify') { + this.advanceToStep('restart'); + } + + if (this.hasStatusTextTarget) { + this.statusTextTarget.textContent = this.textRestartingValue; + } + if (this.hasStepNameTarget) { + this.stepNameTarget.textContent = this.textStepRestartValue; + } + if (this.hasStepMessageTarget) { + this.stepMessageTarget.textContent = this.textRestartingDetailValue; + } + } + + showSuccess(newVersion) { + // Advance all steps to complete + const steps = this.constructor.STEPS; + const stepNames = Object.keys(steps); + const now = this.formatTime(new Date()); + + for (let i = 0; i < stepNames.length; i++) { + const name = stepNames[i]; + this.markStepComplete(i, this.stepTimestamps[name] || now); + } + + this.updateProgressBar(100); + + // Update whale animation + if (this.hasHeaderWhaleTarget) { + this.headerWhaleTarget.classList.add('success'); + } + if (this.hasTitleIconTarget) { + this.titleIconTarget.className = 'fas fa-check-circle text-success'; + } + + if (this.hasStatusTextTarget) { + this.statusTextTarget.textContent = this.textSuccessValue; + } + if (this.hasStatusSubtextTarget) { + this.statusSubtextTarget.textContent = this.textSuccessDetailValue; + } + + // Hide step alert, show success alert + this.toggleTarget('stepAlert', false); + this.toggleTarget('successAlert', true); + this.toggleTarget('warningAlert', false); + this.toggleTarget('actions', true); + + if (this.hasNewVersionTarget) { + this.newVersionTarget.textContent = newVersion || 'latest'; + } + if (this.hasPreviousVersionTarget) { + this.previousVersionTarget.textContent = this.previousVersionValue; + } + } + + showTimeout() { + this.updateProgressBar(0); + + if (this.hasHeaderWhaleTarget) { + this.headerWhaleTarget.classList.add('timeout'); + } + if (this.hasTitleIconTarget) { + this.titleIconTarget.className = 'fas fa-exclamation-triangle text-warning'; + } + + if (this.hasStatusTextTarget) { + this.statusTextTarget.textContent = this.textTimeoutValue; + } + if (this.hasStatusSubtextTarget) { + this.statusSubtextTarget.textContent = this.textTimeoutDetailValue; + } + + this.toggleTarget('stepAlert', false); + this.toggleTarget('timeoutAlert', true); + this.toggleTarget('warningAlert', false); + this.toggleTarget('actions', true); + } + + // --- Step timeline helpers --- + + markStepComplete(index, timestamp) { + if (this.stepIconTargets[index]) { + this.stepIconTargets[index].className = 'fas fa-check-circle text-success me-3'; + } + if (this.stepRowTargets[index]) { + this.stepRowTargets[index].classList.remove('text-muted'); + } + if (timestamp) { + this.setStepTimestamp(index, timestamp); + } + } + + markStepActive(index) { + if (this.stepIconTargets[index]) { + this.stepIconTargets[index].className = 'fas fa-spinner fa-spin text-primary me-3'; + } + if (this.stepRowTargets[index]) { + this.stepRowTargets[index].classList.remove('text-muted'); + } + } + + setStepTimestamp(index, time) { + if (this.stepTimeTargets[index]) { + this.stepTimeTargets[index].textContent = time; + } + } + + // --- UI helpers --- + + toggleTarget(name, show) { + const hasMethod = 'has' + name.charAt(0).toUpperCase() + name.slice(1) + 'Target'; + if (this[hasMethod]) { + this[name + 'Target'].classList.toggle('d-none', !show); + } + } + + updateProgressBar(percent) { + if (this.hasProgressBarTarget) { + const bar = this.progressBarTarget; + // Remove all width classes + bar.classList.remove('progress-w-0', 'progress-w-15', 'progress-w-30', 'progress-w-50', 'progress-w-65', 'progress-w-80', 'progress-w-100'); + bar.classList.add('progress-w-' + percent); + bar.textContent = percent + '%'; + bar.setAttribute('aria-valuenow', percent); + + bar.classList.remove('bg-success', 'bg-danger', 'progress-bar-striped', 'progress-bar-animated'); + if (percent === 100) { + bar.classList.add('bg-success'); + } else if (percent === 0) { + bar.classList.add('bg-danger'); + } else { + bar.classList.add('progress-bar-striped', 'progress-bar-animated'); + } + } + } + + updateElapsedTime(elapsed) { + if (this.hasElapsedTimeTarget) { + const seconds = Math.floor(elapsed / 1000); + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + this.elapsedTimeTarget.textContent = minutes > 0 + ? `${minutes}m ${remainingSeconds}s` + : `${remainingSeconds}s`; + } + } + + formatTime(date) { + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + } +} diff --git a/assets/controllers/elements/ai_model_autocomplete_controller.js b/assets/controllers/elements/ai_model_autocomplete_controller.js new file mode 100644 index 00000000..e36e6b1f --- /dev/null +++ b/assets/controllers/elements/ai_model_autocomplete_controller.js @@ -0,0 +1,152 @@ +/* + * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony). + * + * Copyright (C) 2019 - 2022 Jan Böhmer (https://github.com/jbtronics) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import {Controller} from "@hotwired/stimulus"; + +import "tom-select/dist/css/tom-select.bootstrap5.css"; +import '../../css/components/tom-select_extensions.css'; +import TomSelect from "tom-select"; + +import TomSelect_click_to_edit from '../../tomselect/click_to_edit/click_to_edit' +import TomSelect_autoselect_typed from '../../tomselect/autoselect_typed/autoselect_typed' + +TomSelect.define('click_to_edit', TomSelect_click_to_edit) +TomSelect.define('autoselect_typed', TomSelect_autoselect_typed) + +export default class extends Controller { + _tomSelect; + + _platformSelector; + + connect() { + + let dropdownParent = "body"; + if (this.element.closest('.modal')) { + dropdownParent = null + } + + //Try to find the platform selector + const platformSelector = document.querySelector("select[data-platform-selector-label='" + this.element.dataset.platformSelector + "']"); + //Clear tomselect options, if the platform selector changes + if (platformSelector) { + this.platformSelector = platformSelector; + platformSelector.addEventListener('change', () => { + //Force reload of options by clearing the cache and options of TomSelect and triggering a search with an empty string + this._tomSelect.clearOptions(); + this._tomSelect.clearCache(); + this._tomSelect.load(''); + }); + } + + let settings = { + persistent: false, + create: true, + maxItems: 1, + preload: 'focus', + createOnBlur: true, + selectOnTab: true, + clearAfterSelect: true, + shouldLoad: ((query) => true), + maxOptions: null, + //This a an ugly solution to disable the delimiter parsing of the TomSelect plugin + delimiter: 'VERY_L0NG_D€LIMITER_WHICH_WILL_NEVER_BE_ENCOUNTERED_IN_A_STRING', + dropdownParent: dropdownParent, + render: { + item: (data, escape) => { + return '' + escape(data.label) + ''; + }, + option: (data, escape) => { + if (data.image) { + return "
" + data.label + "
" + } + return '
' + escape(data.label) + '
'; + } + }, + plugins: { + 'autoselect_typed': {}, + 'click_to_edit': {}, + 'clear_button': {}, + "restore_on_backspace": {} + } + }; + + if(this.element.dataset.urlTemplate) { + const base_url = this.element.dataset.urlTemplate; + settings.searchField = "label"; + settings.sortField = "label"; + settings.valueField = "label"; + settings.load = (query, callback) => { + + + if (!this.platformSelector) { + console.error("Platform selector not found for AI model autocomplete"); + callback(); + return; + } + + //Platform is the selected option + const platform = this.platformSelector.value; + if (!platform) { + callback(); + return; + } + + const self = this; + + //Only fetch each platform once + if(self.platformLoaded === platform) { + callback(); + } + + + const url = base_url.replace('__PLATFORM__', encodeURIComponent(platform)); + + fetch(url) + .then(response => response.json()) + .then(json => { + + self.platformLoaded = platform; + + var data = []; + + for (const name in json) { + data.push({ + "label": name, + "capabilities": json[name].capabilities, + }); + } + + callback(data); + }).catch(()=>{ + callback(); + }); + }; + } + this._tomSelect = new TomSelect(this.element, settings); + } + + disconnect() { + super.disconnect(); + //Destroy the TomSelect instance + this._tomSelect.destroy(); + } + +} + + diff --git a/assets/controllers/elements/attachment_autocomplete_controller.js b/assets/controllers/elements/attachment_autocomplete_controller.js index 94b01136..86975c0c 100644 --- a/assets/controllers/elements/attachment_autocomplete_controller.js +++ b/assets/controllers/elements/attachment_autocomplete_controller.js @@ -45,6 +45,7 @@ export default class extends Controller { maxItems: 1, createOnBlur: true, selectOnTab: true, + clearAfterSelect: true, //This a an ugly solution to disable the delimiter parsing of the TomSelect plugin delimiter: 'VERY_L0NG_D€LIMITER_WHICH_WILL_NEVER_BE_ENCOUNTERED_IN_A_STRING', dropdownParent: dropdownParent, diff --git a/assets/controllers/elements/ckeditor_controller.js b/assets/controllers/elements/ckeditor_controller.js index b7c87dab..17aa9214 100644 --- a/assets/controllers/elements/ckeditor_controller.js +++ b/assets/controllers/elements/ckeditor_controller.js @@ -29,7 +29,7 @@ import "ckeditor5/ckeditor5.css";; import "../../css/components/ckeditor.css"; const translationContext = require.context( - 'ckeditor5/translations', + 'ckeditor5-translations', //Alias defined in webpack.config.js false, //Only load the translation files we will really need /(de|it|fr|ru|ja|cs|da|zh|pl|hu)\.js$/ diff --git a/assets/controllers/elements/datatables/datatables_controller.js b/assets/controllers/elements/datatables/datatables_controller.js index 9ac23483..d945004b 100644 --- a/assets/controllers/elements/datatables/datatables_controller.js +++ b/assets/controllers/elements/datatables/datatables_controller.js @@ -83,8 +83,6 @@ export default class extends Controller { if (data) { //Do not save the start value (current page), as we want to always start at the first page on a page reload delete data.start; - //Reset the data length to the default value by deleting the length property - delete data.length; } return data; @@ -113,8 +111,16 @@ export default class extends Controller { return null; } + //The saved order index is visual (post-reorder). If colReorder state + //exists, map it back to the original column index so the server sorts + //the correct column. colReorder[visualIndex] == originalIndex. + let columnIndex = order[0]; + if (saved_state.colReorder) { + columnIndex = saved_state.colReorder[columnIndex]; + } + return { - column: order[0], + column: columnIndex, dir: order[1] } }); diff --git a/assets/controllers/elements/part_select_controller.js b/assets/controllers/elements/part_select_controller.js index b69acbbc..1edbdf67 100644 --- a/assets/controllers/elements/part_select_controller.js +++ b/assets/controllers/elements/part_select_controller.js @@ -23,6 +23,8 @@ export default class extends Controller { valueField: "id", labelField: "name", dropdownParent: dropdownParent, + selectOnTab: true, + clearAfterSelect: true, preload: "focus", render: { item: (data, escape) => { diff --git a/assets/controllers/elements/select_controller.js b/assets/controllers/elements/select_controller.js index d70e588c..11e29280 100644 --- a/assets/controllers/elements/select_controller.js +++ b/assets/controllers/elements/select_controller.js @@ -49,6 +49,7 @@ export default class extends Controller { selectOnTab: true, maxOptions: null, dropdownParent: dropdownParent, + clearAfterSelect: true, render: { item: this.renderItem.bind(this), diff --git a/assets/controllers/elements/select_multiple_controller.js b/assets/controllers/elements/select_multiple_controller.js index 17e85fae..01bbd24b 100644 --- a/assets/controllers/elements/select_multiple_controller.js +++ b/assets/controllers/elements/select_multiple_controller.js @@ -35,6 +35,8 @@ export default class extends Controller { maxItems: 1000, allowEmptyOption: true, dropdownParent: dropdownParent, + selectOnTab: true, + clearAfterSelect: true, plugins: ['remove_button'], }); } diff --git a/assets/controllers/elements/sidebar_tree_controller.js b/assets/controllers/elements/sidebar_tree_controller.js index d50cf900..e0f012be 100644 --- a/assets/controllers/elements/sidebar_tree_controller.js +++ b/assets/controllers/elements/sidebar_tree_controller.js @@ -19,6 +19,7 @@ import {Controller} from "@hotwired/stimulus"; import {default as TreeController} from "./tree_controller"; +import {EVENT_INITIALIZED} from "@jbtronics/bs-treeview"; export default class extends TreeController { static targets = [ "tree", 'sourceText' ]; @@ -40,6 +41,8 @@ export default class extends TreeController { //Check if we have a saved mode const stored_mode = localStorage.getItem(this._storage_key); + this._frame = this.element.dataset.frame || "content"; //By default, navigate in the content frame, if a frame is defined + //Use stored mode if possible, otherwise use default if(stored_mode) { try { @@ -55,6 +58,39 @@ export default class extends TreeController { //Register an event listener which checks if the tree needs to be updated document.addEventListener('turbo:render', this.doUpdateIfNeeded.bind(this)); + + //Register an event listener, to check if we end up on a page we can highlight in the tree, if so then higlight it + document.addEventListener('turbo:load', this._onTurboLoad.bind(this)); + //On initial page load the tree is not available yet, so do another check after the tree is initialized + this.treeTarget.addEventListener(EVENT_INITIALIZED, (event) => { + this.selectNodeWithURL(document.location) + }); + } + + _onTurboLoad(event) { + this.selectNodeWithURL(event.detail.url); + } + + selectNodeWithURL(url) { + //Get path from url + const path = new URL(url).pathname; + + if (!this._tree) { + return; + } + + //Unselect all nodes + this._tree.unselectAll({silent: true, ignorePreventUnselect: true}); + + //Try to find a node with this path as data-path + const nodes = this._tree.findNodes(path, "href"); + if (nodes.length !== 1) { + return; //We can only work with exactly one node, if there are multiple nodes with the same path, we cannot know which one to select, so we do nothing + } + const node = nodes[0]; + + node.setSelected(true, {ignorePreventUnselect: true, silent: true}); + this._tree.revealNode(node); } doUpdateIfNeeded() diff --git a/assets/controllers/elements/static_file_autocomplete_controller.js b/assets/controllers/elements/static_file_autocomplete_controller.js index 9703c618..bd01246a 100644 --- a/assets/controllers/elements/static_file_autocomplete_controller.js +++ b/assets/controllers/elements/static_file_autocomplete_controller.js @@ -56,6 +56,7 @@ export default class extends Controller { searchField: 'text', orderField: 'text', dropdownParent: dropdownParent, + clearAfterSelect: true, //This a an ugly solution to disable the delimiter parsing of the TomSelect plugin delimiter: 'VERY_L0NG_D€LIMITER_WHICH_WILL_NEVER_BE_ENCOUNTERED_IN_A_STRING', diff --git a/assets/controllers/elements/structural_entity_select_controller.js b/assets/controllers/elements/structural_entity_select_controller.js index 2666530b..5c462e51 100644 --- a/assets/controllers/elements/structural_entity_select_controller.js +++ b/assets/controllers/elements/structural_entity_select_controller.js @@ -58,6 +58,7 @@ export default class extends Controller { delimiter: "$$VERY_LONG_DELIMITER_THAT_SHOULD_NEVER_APPEAR$$", splitOn: null, dropdownParent: dropdownParent, + clearAfterSelect: true, searchField: [ {field: "text", weight : 2}, diff --git a/assets/controllers/elements/tagsinput_controller.js b/assets/controllers/elements/tagsinput_controller.js index 14725227..a4b1f175 100644 --- a/assets/controllers/elements/tagsinput_controller.js +++ b/assets/controllers/elements/tagsinput_controller.js @@ -49,6 +49,7 @@ export default class extends Controller { createOnBlur: true, create: true, dropdownParent: dropdownParent, + clearAfterSelect: true, }; if(this.element.dataset.autocomplete) { diff --git a/assets/controllers/elements/tree_controller.js b/assets/controllers/elements/tree_controller.js index bb64839c..80a06bc3 100644 --- a/assets/controllers/elements/tree_controller.js +++ b/assets/controllers/elements/tree_controller.js @@ -39,6 +39,8 @@ export default class extends Controller { */ _tree = null; + _frame = "frame"; + connect() { const treeElement = this.treeTarget; if (!treeElement) { @@ -48,6 +50,7 @@ export default class extends Controller { this._url = this.element.dataset.treeUrl; this._data = this.element.dataset.treeData; + this._frame = this.element.dataset.frame || "content"; //By default, navigate in the content frame, if a frame is defined if(this.element.dataset.treeShowTags === "true") { this._showTags = true; @@ -99,8 +102,7 @@ export default class extends Controller { onNodeSelected: (event) => { const node = event.detail.node; if (node.href) { - window.Turbo.visit(node.href, {action: "advance"}); - this._registerURLWatcher(node); + window.Turbo.visit(node.href, {action: "advance", frame: this._frame}); } }, }, [BS5Theme, BS53Theme, FAIconTheme]); @@ -110,41 +112,12 @@ export default class extends Controller { const treeView = event.detail.treeView; treeView.revealNode(treeView.getSelected()); - //Add the url watcher to all selected nodes - for (const node of treeView.getSelected()) { - this._registerURLWatcher(node); - } - //Add contextmenu event listener to the tree, which allows us to open the links in a new tab with a right click treeView.getTreeElement().addEventListener("contextmenu", this._onContextMenu.bind(this)); }); } - _registerURLWatcher(node) - { - //Register a watcher for a location change, which will unselect the node, if the location changes - const desired_url = node.href; - - //Ensure that the node is unselected, if the location changes - const unselectNode = () => { - //Parse url so we can properly compare them - const desired = new URL(node.href, window.location.origin); - - //We only compare the pathname, because the hash and parameters should not matter - if(window.location.pathname !== desired.pathname) { - //The ignore parameter is important here, otherwise the node will not be unselected - node.setSelected(false, {silent: true, ignorePreventUnselect: true}); - - //Unregister the watcher - document.removeEventListener('turbo:load', unselectNode); - } - }; - - //Register the watcher via hotwire turbo - //We must just load to have the new url in window.location - document.addEventListener('turbo:load', unselectNode); - } _onContextMenu(event) { @@ -198,4 +171,4 @@ export default class extends Controller { return myResolve(this._data); }); } -} \ No newline at end of file +} diff --git a/assets/controllers/field_mapping_controller.js b/assets/controllers/field_mapping_controller.js index 9c9c8ac6..50c19a0d 100644 --- a/assets/controllers/field_mapping_controller.js +++ b/assets/controllers/field_mapping_controller.js @@ -70,6 +70,13 @@ export default class extends Controller { newFieldSelect.addEventListener('change', this.updateFieldOptions.bind(this)) } + // Auto-increment priority based on existing mappings + const nextPriority = this.getNextPriority() + const priorityInput = newRow.querySelector('input[name*="[priority]"]') + if (priorityInput) { + priorityInput.value = nextPriority + } + this.updateFieldOptions() this.updateAddButtonState() } @@ -119,6 +126,18 @@ export default class extends Controller { } } + getNextPriority() { + const priorityInputs = this.tbodyTarget.querySelectorAll('input[name*="[priority]"]') + let maxPriority = 0 + priorityInputs.forEach(input => { + const val = parseInt(input.value, 10) + if (!isNaN(val) && val > maxPriority) { + maxPriority = val + } + }) + return Math.min(maxPriority + 1, 10) + } + handleFormSubmit(event) { if (this.hasSubmitButtonTarget) { this.submitButtonTarget.disabled = true diff --git a/assets/controllers/pages/parameters_autocomplete_controller.js b/assets/controllers/pages/parameters_autocomplete_controller.js index e187aa42..4abea969 100644 --- a/assets/controllers/pages/parameters_autocomplete_controller.js +++ b/assets/controllers/pages/parameters_autocomplete_controller.js @@ -75,6 +75,7 @@ export default class extends Controller searchField: "name", //labelField: "name", valueField: "name", + clearAfterSelect: true, onItemAdd: this.onItemAdd.bind(this), render: { option: (data, escape) => { @@ -136,4 +137,4 @@ export default class extends Controller //Destroy the TomSelect instance this._tomSelect.destroy(); } -} \ No newline at end of file +} diff --git a/composer.json b/composer.json index 89e0f19b..b23ea92b 100644 --- a/composer.json +++ b/composer.json @@ -33,6 +33,7 @@ "jbtronics/dompdf-font-loader-bundle": "^1.0.0", "jbtronics/settings-bundle": "^3.0.0", "jfcherng/php-diff": "^6.14", + "jkphl/micrometa": "^v3.4.0", "knpuniversity/oauth2-client-bundle": "^2.15", "league/commonmark": "^2.7", "league/csv": "^9.8.0", @@ -56,6 +57,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/apache-pack": "^1.0", "symfony/asset": "7.4.*", "symfony/console": "7.4.*", @@ -177,6 +181,11 @@ "allow-contrib": false, "require": "7.4.*", "docker": true + }, + "phpstan/extension-installer": { + "ignore" : [ + "ekino/phpstan-banned-code" + ] } } } diff --git a/composer.lock b/composer.lock index 71af9169..9af25525 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": "32c5677a31185e0ed124904012500154", + "content-hash": "31a276e9a2b45a04facbe2d88f4a042f", "packages": [ { "name": "amphp/amp", @@ -318,16 +318,16 @@ }, { "name": "amphp/hpack", - "version": "v3.2.1", + "version": "v3.2.2", "source": { "type": "git", "url": "https://github.com/amphp/hpack.git", - "reference": "4f293064b15682a2b178b1367ddf0b8b5feb0239" + "reference": "291da27078e7e149a9bad4d08ff05bf7d81c89f4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amphp/hpack/zipball/4f293064b15682a2b178b1367ddf0b8b5feb0239", - "reference": "4f293064b15682a2b178b1367ddf0b8b5feb0239", + "url": "https://api.github.com/repos/amphp/hpack/zipball/291da27078e7e149a9bad4d08ff05bf7d81c89f4", + "reference": "291da27078e7e149a9bad4d08ff05bf7d81c89f4", "shasum": "" }, "require": { @@ -336,7 +336,7 @@ "require-dev": { "amphp/php-cs-fixer-config": "^2", "http2jp/hpack-test-case": "^1", - "nikic/php-fuzzer": "^0.0.10", + "nikic/php-fuzzer": "^0.0.11", "phpunit/phpunit": "^7 | ^8 | ^9" }, "type": "library", @@ -380,7 +380,7 @@ ], "support": { "issues": "https://github.com/amphp/hpack/issues", - "source": "https://github.com/amphp/hpack/tree/v3.2.1" + "source": "https://github.com/amphp/hpack/tree/v3.2.2" }, "funding": [ { @@ -388,7 +388,7 @@ "type": "github" } ], - "time": "2024-03-21T19:00:16+00:00" + "time": "2026-05-03T19:28:59+00:00" }, { "name": "amphp/http", @@ -616,16 +616,16 @@ }, { "name": "amphp/pipeline", - "version": "v1.2.3", + "version": "v1.2.4", "source": { "type": "git", "url": "https://github.com/amphp/pipeline.git", - "reference": "7b52598c2e9105ebcddf247fc523161581930367" + "reference": "a044733e080940d1483f56caff0c412ad6982776" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amphp/pipeline/zipball/7b52598c2e9105ebcddf247fc523161581930367", - "reference": "7b52598c2e9105ebcddf247fc523161581930367", + "url": "https://api.github.com/repos/amphp/pipeline/zipball/a044733e080940d1483f56caff0c412ad6982776", + "reference": "a044733e080940d1483f56caff0c412ad6982776", "shasum": "" }, "require": { @@ -637,7 +637,7 @@ "amphp/php-cs-fixer-config": "^2", "amphp/phpunit-util": "^3", "phpunit/phpunit": "^9", - "psalm/phar": "^5.18" + "psalm/phar": "6.16.1" }, "type": "library", "autoload": { @@ -671,7 +671,7 @@ ], "support": { "issues": "https://github.com/amphp/pipeline/issues", - "source": "https://github.com/amphp/pipeline/tree/v1.2.3" + "source": "https://github.com/amphp/pipeline/tree/v1.2.4" }, "funding": [ { @@ -679,7 +679,7 @@ "type": "github" } ], - "time": "2025-03-16T16:33:53+00:00" + "time": "2026-05-06T05:37:57+00:00" }, { "name": "amphp/process", @@ -751,24 +751,27 @@ }, { "name": "amphp/serialization", - "version": "v1.0.0", + "version": "v1.1.0", "source": { "type": "git", "url": "https://github.com/amphp/serialization.git", - "reference": "693e77b2fb0b266c3c7d622317f881de44ae94a1" + "reference": "fdf2834d78cebb0205fb2672676c1b1eb84371f0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amphp/serialization/zipball/693e77b2fb0b266c3c7d622317f881de44ae94a1", - "reference": "693e77b2fb0b266c3c7d622317f881de44ae94a1", + "url": "https://api.github.com/repos/amphp/serialization/zipball/fdf2834d78cebb0205fb2672676c1b1eb84371f0", + "reference": "fdf2834d78cebb0205fb2672676c1b1eb84371f0", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=7.4" }, "require-dev": { - "amphp/php-cs-fixer-config": "dev-master", - "phpunit/phpunit": "^9 || ^8 || ^7" + "amphp/php-cs-fixer-config": "^2", + "ext-json": "*", + "ext-zlib": "*", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" }, "type": "library", "autoload": { @@ -803,22 +806,28 @@ ], "support": { "issues": "https://github.com/amphp/serialization/issues", - "source": "https://github.com/amphp/serialization/tree/master" + "source": "https://github.com/amphp/serialization/tree/v1.1.0" }, - "time": "2020-03-25T21:39:07+00:00" + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-04-05T15:59:53+00:00" }, { "name": "amphp/socket", - "version": "v2.3.1", + "version": "v2.4.0", "source": { "type": "git", "url": "https://github.com/amphp/socket.git", - "reference": "58e0422221825b79681b72c50c47a930be7bf1e1" + "reference": "dadb63c5d3179fd83803e29dfeac27350e619314" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amphp/socket/zipball/58e0422221825b79681b72c50c47a930be7bf1e1", - "reference": "58e0422221825b79681b72c50c47a930be7bf1e1", + "url": "https://api.github.com/repos/amphp/socket/zipball/dadb63c5d3179fd83803e29dfeac27350e619314", + "reference": "dadb63c5d3179fd83803e29dfeac27350e619314", "shasum": "" }, "require": { @@ -827,17 +836,17 @@ "amphp/dns": "^2", "ext-openssl": "*", "kelunik/certificate": "^1.1", - "league/uri": "^6.5 | ^7", - "league/uri-interfaces": "^2.3 | ^7", + "league/uri": "^7", + "league/uri-interfaces": "^7", "php": ">=8.1", - "revolt/event-loop": "^1 || ^0.2" + "revolt/event-loop": "^1" }, "require-dev": { "amphp/php-cs-fixer-config": "^2", "amphp/phpunit-util": "^3", "amphp/process": "^2", "phpunit/phpunit": "^9", - "psalm/phar": "5.20" + "psalm/phar": "6.16.1" }, "type": "library", "autoload": { @@ -881,7 +890,7 @@ ], "support": { "issues": "https://github.com/amphp/socket/issues", - "source": "https://github.com/amphp/socket/tree/v2.3.1" + "source": "https://github.com/amphp/socket/tree/v2.4.0" }, "funding": [ { @@ -889,7 +898,7 @@ "type": "github" } ], - "time": "2024-04-21T14:33:03+00:00" + "time": "2026-04-19T15:09:56+00:00" }, { "name": "amphp/sync", @@ -968,22 +977,22 @@ }, { "name": "api-platform/doctrine-common", - "version": "v4.2.19", + "version": "v4.3.5", "source": { "type": "git", "url": "https://github.com/api-platform/doctrine-common.git", - "reference": "566acb646b001f21bc6aa7bd36a109e075f5c131" + "reference": "089b196c2f8e4d14333aaa3c6db33356e8fd8be0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/doctrine-common/zipball/566acb646b001f21bc6aa7bd36a109e075f5c131", - "reference": "566acb646b001f21bc6aa7bd36a109e075f5c131", + "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" @@ -995,7 +1004,7 @@ "doctrine/mongodb-odm": "^2.10", "doctrine/orm": "^2.17 || ^3.0", "phpspec/prophecy-phpunit": "^2.2", - "phpunit/phpunit": "^12.2", + "phpunit/phpunit": "^11.5 || ^12.2", "symfony/type-info": "^7.3 || ^8.0" }, "suggest": { @@ -1019,7 +1028,7 @@ "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev", "dev-4.2": "4.2.x-dev", - "dev-main": "4.3.x-dev" + "dev-main": "4.4.x-dev" } }, "autoload": { @@ -1052,35 +1061,37 @@ "rest" ], "support": { - "source": "https://github.com/api-platform/doctrine-common/tree/v4.3.0-alpha.2" + "source": "https://github.com/api-platform/doctrine-common/tree/v4.3.5" }, - "time": "2026-02-13T15:07:33+00:00" + "time": "2026-05-04T13:25:58+00:00" }, { "name": "api-platform/doctrine-orm", - "version": "v4.2.19", + "version": "v4.3.5", "source": { "type": "git", "url": "https://github.com/api-platform/doctrine-orm.git", - "reference": "a7d4c255519ac0438f9293b3e97d2b3bd9ca43d7" + "reference": "095a4c56cdd9986208100dedd5d28be50a4830ba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/doctrine-orm/zipball/a7d4c255519ac0438f9293b3e97d2b3bd9ca43d7", - "reference": "a7d4c255519ac0438f9293b3e97d2b3bd9ca43d7", + "url": "https://api.github.com/repos/api-platform/doctrine-orm/zipball/095a4c56cdd9986208100dedd5d28be50a4830ba", + "reference": "095a4c56cdd9986208100dedd5d28be50a4830ba", "shasum": "" }, "require": { - "api-platform/doctrine-common": "^4.2.9", + "api-platform/doctrine-common": "^4.2.23", "api-platform/metadata": "^4.2", + "api-platform/serializer": "^4.2.16", "api-platform/state": "^4.2.4", - "doctrine/orm": "^2.17 || ^3.0", + "composer/semver": "^3.4", + "doctrine/orm": "^2.17 || ^3.0.1", "php": ">=8.2" }, "require-dev": { "doctrine/doctrine-bundle": "^2.11 || ^3.1", "phpspec/prophecy-phpunit": "^2.2", - "phpunit/phpunit": "^12.2", + "phpunit/phpunit": "^11.5 || ^12.2", "ramsey/uuid": "^4.7", "ramsey/uuid-doctrine": "^2.0", "symfony/cache": "^6.4 || ^7.0 || ^8.0", @@ -1106,7 +1117,7 @@ "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev", "dev-4.2": "4.2.x-dev", - "dev-main": "4.3.x-dev" + "dev-main": "4.4.x-dev" } }, "autoload": { @@ -1139,30 +1150,30 @@ "rest" ], "support": { - "source": "https://github.com/api-platform/doctrine-orm/tree/v4.2.19" + "source": "https://github.com/api-platform/doctrine-orm/tree/v4.3.5" }, - "time": "2026-02-25T15:52:40+00:00" + "time": "2026-05-07T11:45:31+00:00" }, { "name": "api-platform/documentation", - "version": "v4.2.19", + "version": "v4.3.5", "source": { "type": "git", "url": "https://github.com/api-platform/documentation.git", - "reference": "873543a827df5c25b008bd730f2096701e1943b8" + "reference": "f07b444aef1f75bb07beb9f8d799213f05070e5f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/documentation/zipball/873543a827df5c25b008bd730f2096701e1943b8", - "reference": "873543a827df5c25b008bd730f2096701e1943b8", + "url": "https://api.github.com/repos/api-platform/documentation/zipball/f07b444aef1f75bb07beb9f8d799213f05070e5f", + "reference": "f07b444aef1f75bb07beb9f8d799213f05070e5f", "shasum": "" }, "require": { - "api-platform/metadata": "^4.2", + "api-platform/metadata": "^4.3", "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^12.2" + "phpunit/phpunit": "^11.5 || ^12.2" }, "type": "project", "extra": { @@ -1177,7 +1188,7 @@ "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev", "dev-4.2": "4.2.x-dev", - "dev-main": "4.3.x-dev" + "dev-main": "4.4.x-dev" } }, "autoload": { @@ -1202,34 +1213,34 @@ ], "description": "API Platform documentation controller.", "support": { - "source": "https://github.com/api-platform/documentation/tree/v4.3.0-alpha.2" + "source": "https://github.com/api-platform/documentation/tree/v4.3.5" }, - "time": "2025-12-27T22:15:57+00:00" + "time": "2026-04-30T12:21:24+00:00" }, { "name": "api-platform/http-cache", - "version": "v4.2.19", + "version": "v4.3.5", "source": { "type": "git", "url": "https://github.com/api-platform/http-cache.git", - "reference": "ec5f9068d3d66be63db4d80acaf518868dea1321" + "reference": "dd7c092b9abee06e72fd58544fe714b6c2a61efa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/http-cache/zipball/ec5f9068d3d66be63db4d80acaf518868dea1321", - "reference": "ec5f9068d3d66be63db4d80acaf518868dea1321", + "url": "https://api.github.com/repos/api-platform/http-cache/zipball/dd7c092b9abee06e72fd58544fe714b6c2a61efa", + "reference": "dd7c092b9abee06e72fd58544fe714b6c2a61efa", "shasum": "" }, "require": { - "api-platform/metadata": "^4.2", - "api-platform/state": "^4.2.4", + "api-platform/metadata": "^4.3", + "api-platform/state": "^4.3", "php": ">=8.2", "symfony/http-foundation": "^6.4.14 || ^7.0 || ^8.0" }, "require-dev": { "guzzlehttp/guzzle": "^6.0 || ^7.0 || ^8.0", "phpspec/prophecy-phpunit": "^2.2", - "phpunit/phpunit": "^12.2", + "phpunit/phpunit": "^11.5 || ^12.2", "symfony/dependency-injection": "^6.4 || ^7.0 || ^8.0", "symfony/http-client": "^6.4 || ^7.0 || ^8.0", "symfony/type-info": "^7.3 || ^8.0" @@ -1247,7 +1258,7 @@ "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev", "dev-4.2": "4.2.x-dev", - "dev-main": "4.3.x-dev" + "dev-main": "4.4.x-dev" } }, "autoload": { @@ -1282,42 +1293,42 @@ "rest" ], "support": { - "source": "https://github.com/api-platform/http-cache/tree/v4.3.0-alpha.2" + "source": "https://github.com/api-platform/http-cache/tree/v4.3.5" }, - "time": "2026-02-13T15:07:33+00:00" + "time": "2026-04-30T12:21:24+00:00" }, { "name": "api-platform/hydra", - "version": "v4.2.19", + "version": "v4.3.5", "source": { "type": "git", "url": "https://github.com/api-platform/hydra.git", - "reference": "3f2587cc3b98f46247ca458ba557c03f62e19905" + "reference": "317a696e396b80ba87de2560679c362923ef0a14" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/hydra/zipball/3f2587cc3b98f46247ca458ba557c03f62e19905", - "reference": "3f2587cc3b98f46247ca458ba557c03f62e19905", + "url": "https://api.github.com/repos/api-platform/hydra/zipball/317a696e396b80ba87de2560679c362923ef0a14", + "reference": "317a696e396b80ba87de2560679c362923ef0a14", "shasum": "" }, "require": { - "api-platform/documentation": "^4.2", - "api-platform/json-schema": "^4.2", - "api-platform/jsonld": "^4.2", - "api-platform/metadata": "^4.2", - "api-platform/serializer": "^4.2.4", - "api-platform/state": "^4.2.4", + "api-platform/documentation": "^4.3", + "api-platform/json-schema": "^4.3", + "api-platform/jsonld": "^4.3", + "api-platform/metadata": "^4.3", + "api-platform/serializer": "^4.3", + "api-platform/state": "^4.3", "php": ">=8.2", "symfony/type-info": "^7.3 || ^8.0", "symfony/web-link": "^6.4 || ^7.1 || ^8.0" }, "require-dev": { - "api-platform/doctrine-common": "^4.2", - "api-platform/doctrine-odm": "^4.2", - "api-platform/doctrine-orm": "^4.2", + "api-platform/doctrine-common": "^4.3", + "api-platform/doctrine-odm": "^4.3", + "api-platform/doctrine-orm": "^4.3", "phpspec/prophecy": "^1.19", "phpspec/prophecy-phpunit": "^2.2", - "phpunit/phpunit": "^12.2" + "phpunit/phpunit": "^11.5 || ^12.2" }, "type": "library", "extra": { @@ -1332,7 +1343,7 @@ "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev", "dev-4.2": "4.2.x-dev", - "dev-main": "4.3.x-dev" + "dev-main": "4.4.x-dev" } }, "autoload": { @@ -1369,30 +1380,30 @@ "rest" ], "support": { - "source": "https://github.com/api-platform/hydra/tree/v4.2.19" + "source": "https://github.com/api-platform/hydra/tree/v4.3.5" }, - "time": "2026-02-27T10:31:31+00:00" + "time": "2026-05-11T11:50:19+00:00" }, { "name": "api-platform/json-api", - "version": "v4.2.19", + "version": "v4.3.5", "source": { "type": "git", "url": "https://github.com/api-platform/json-api.git", - "reference": "d28b51d78c50451e6714ed7a0c673ec6d9070900" + "reference": "30e399ea2266403d04fd93df83c6983cf0a30e5d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/json-api/zipball/d28b51d78c50451e6714ed7a0c673ec6d9070900", - "reference": "d28b51d78c50451e6714ed7a0c673ec6d9070900", + "url": "https://api.github.com/repos/api-platform/json-api/zipball/30e399ea2266403d04fd93df83c6983cf0a30e5d", + "reference": "30e399ea2266403d04fd93df83c6983cf0a30e5d", "shasum": "" }, "require": { - "api-platform/documentation": "^4.2", - "api-platform/json-schema": "^4.2", - "api-platform/metadata": "^4.2", - "api-platform/serializer": "^4.2.18", - "api-platform/state": "^4.2.4", + "api-platform/documentation": "^4.3", + "api-platform/json-schema": "^4.3", + "api-platform/metadata": "^4.3", + "api-platform/serializer": "^4.3", + "api-platform/state": "^4.3", "php": ">=8.2", "symfony/error-handler": "^6.4 || ^7.0 || ^8.0", "symfony/http-foundation": "^6.4.14 || ^7.0 || ^8.0", @@ -1401,7 +1412,7 @@ "require-dev": { "phpspec/prophecy": "^1.19", "phpspec/prophecy-phpunit": "^2.2", - "phpunit/phpunit": "^12.2", + "phpunit/phpunit": "^11.5 || ^12.2", "symfony/type-info": "^7.3 || ^8.0" }, "type": "library", @@ -1417,7 +1428,7 @@ "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev", "dev-4.2": "4.2.x-dev", - "dev-main": "4.3.x-dev" + "dev-main": "4.4.x-dev" } }, "autoload": { @@ -1451,26 +1462,26 @@ "rest" ], "support": { - "source": "https://github.com/api-platform/json-api/tree/v4.2.19" + "source": "https://github.com/api-platform/json-api/tree/v4.3.5" }, - "time": "2026-02-27T16:03:48+00:00" + "time": "2026-04-30T12:21:24+00:00" }, { "name": "api-platform/json-schema", - "version": "v4.2.19", + "version": "v4.3.5", "source": { "type": "git", "url": "https://github.com/api-platform/json-schema.git", - "reference": "adc464d8240ac411ff8ed65ac8614b16d11f5544" + "reference": "23dc2c388a08f2006b9189a0883a08f8837d7249" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/json-schema/zipball/adc464d8240ac411ff8ed65ac8614b16d11f5544", - "reference": "adc464d8240ac411ff8ed65ac8614b16d11f5544", + "url": "https://api.github.com/repos/api-platform/json-schema/zipball/23dc2c388a08f2006b9189a0883a08f8837d7249", + "reference": "23dc2c388a08f2006b9189a0883a08f8837d7249", "shasum": "" }, "require": { - "api-platform/metadata": "^4.2", + "api-platform/metadata": "^4.3", "php": ">=8.2", "symfony/console": "^6.4 || ^7.0 || ^8.0", "symfony/property-info": "^6.4 || ^7.1 || ^8.0", @@ -1480,7 +1491,7 @@ }, "require-dev": { "phpspec/prophecy-phpunit": "^2.2", - "phpunit/phpunit": "^12.2" + "phpunit/phpunit": "^11.5 || ^12.2" }, "type": "library", "extra": { @@ -1495,7 +1506,7 @@ "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev", "dev-4.2": "4.2.x-dev", - "dev-main": "4.3.x-dev" + "dev-main": "4.4.x-dev" } }, "autoload": { @@ -1532,32 +1543,32 @@ "swagger" ], "support": { - "source": "https://github.com/api-platform/json-schema/tree/v4.2.19" + "source": "https://github.com/api-platform/json-schema/tree/v4.3.5" }, - "time": "2026-02-25T15:52:40+00:00" + "time": "2026-04-30T12:21:24+00:00" }, { "name": "api-platform/jsonld", - "version": "v4.2.19", + "version": "v4.3.5", "source": { "type": "git", "url": "https://github.com/api-platform/jsonld.git", - "reference": "08593fc073466badae67b8f4999ec19e3ade9eab" + "reference": "20ca6d7b5c11674c3046d710aaa0c9bc1795e54b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/jsonld/zipball/08593fc073466badae67b8f4999ec19e3ade9eab", - "reference": "08593fc073466badae67b8f4999ec19e3ade9eab", + "url": "https://api.github.com/repos/api-platform/jsonld/zipball/20ca6d7b5c11674c3046d710aaa0c9bc1795e54b", + "reference": "20ca6d7b5c11674c3046d710aaa0c9bc1795e54b", "shasum": "" }, "require": { - "api-platform/metadata": "^4.2", - "api-platform/serializer": "^4.2.4", - "api-platform/state": "^4.2.4", + "api-platform/metadata": "^4.3", + "api-platform/serializer": "^4.3", + "api-platform/state": "^4.3", "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^12.2", + "phpunit/phpunit": "^11.5 || ^12.2", "symfony/type-info": "^7.3 || ^8.0" }, "type": "library", @@ -1573,7 +1584,7 @@ "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev", "dev-4.2": "4.2.x-dev", - "dev-main": "4.3.x-dev" + "dev-main": "4.4.x-dev" } }, "autoload": { @@ -1612,22 +1623,22 @@ "rest" ], "support": { - "source": "https://github.com/api-platform/jsonld/tree/v4.2.19" + "source": "https://github.com/api-platform/jsonld/tree/v4.3.5" }, - "time": "2026-02-13T17:30:49+00:00" + "time": "2026-04-30T12:21:24+00:00" }, { "name": "api-platform/metadata", - "version": "v4.2.19", + "version": "v4.3.5", "source": { "type": "git", "url": "https://github.com/api-platform/metadata.git", - "reference": "af91b0d349b2aa8afffe100cce544b4d72add3eb" + "reference": "52b367f046c5d202629e9441aece39b0e6b37838" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/metadata/zipball/af91b0d349b2aa8afffe100cce544b4d72add3eb", - "reference": "af91b0d349b2aa8afffe100cce544b4d72add3eb", + "url": "https://api.github.com/repos/api-platform/metadata/zipball/52b367f046c5d202629e9441aece39b0e6b37838", + "reference": "52b367f046c5d202629e9441aece39b0e6b37838", "shasum": "" }, "require": { @@ -1640,12 +1651,12 @@ "symfony/type-info": "^7.3 || ^8.0" }, "require-dev": { - "api-platform/json-schema": "^4.2", - "api-platform/openapi": "^4.2", - "api-platform/state": "^4.2.4", + "api-platform/json-schema": "^4.3", + "api-platform/openapi": "^4.3", + "api-platform/state": "^4.3", "phpspec/prophecy-phpunit": "^2.2", "phpstan/phpdoc-parser": "^1.29 || ^2.0", - "phpunit/phpunit": "^12.2", + "phpunit/phpunit": "^11.5 || ^12.2", "symfony/config": "^6.4 || ^7.0 || ^8.0", "symfony/routing": "^6.4 || ^7.0 || ^8.0", "symfony/var-dumper": "^6.4 || ^7.0 || ^8.0", @@ -1670,7 +1681,7 @@ "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev", "dev-4.2": "4.2.x-dev", - "dev-main": "4.3.x-dev" + "dev-main": "4.4.x-dev" } }, "autoload": { @@ -1710,28 +1721,28 @@ "swagger" ], "support": { - "source": "https://github.com/api-platform/metadata/tree/v4.2.19" + "source": "https://github.com/api-platform/metadata/tree/v4.3.5" }, - "time": "2026-02-25T15:52:40+00:00" + "time": "2026-05-06T12:07:59+00:00" }, { "name": "api-platform/openapi", - "version": "v4.2.19", + "version": "v4.3.5", "source": { "type": "git", "url": "https://github.com/api-platform/openapi.git", - "reference": "59c13717f63e21f98d4ed4e4d7122b0bade72e2e" + "reference": "1562617e7500a50c2b6e6f43a0fb29a6a47e83a2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/openapi/zipball/59c13717f63e21f98d4ed4e4d7122b0bade72e2e", - "reference": "59c13717f63e21f98d4ed4e4d7122b0bade72e2e", + "url": "https://api.github.com/repos/api-platform/openapi/zipball/1562617e7500a50c2b6e6f43a0fb29a6a47e83a2", + "reference": "1562617e7500a50c2b6e6f43a0fb29a6a47e83a2", "shasum": "" }, "require": { - "api-platform/json-schema": "^4.2", - "api-platform/metadata": "^4.2", - "api-platform/state": "^4.2.4", + "api-platform/json-schema": "^4.3", + "api-platform/metadata": "^4.3", + "api-platform/state": "^4.3", "php": ">=8.2", "symfony/console": "^6.4 || ^7.0 || ^8.0", "symfony/filesystem": "^6.4 || ^7.0 || ^8.0", @@ -1740,11 +1751,12 @@ "symfony/type-info": "^7.3 || ^8.0" }, "require-dev": { - "api-platform/doctrine-common": "^4.2", - "api-platform/doctrine-odm": "^4.2", - "api-platform/doctrine-orm": "^4.2", + "api-platform/doctrine-common": "^4.3", + "api-platform/doctrine-odm": "^4.3", + "api-platform/doctrine-orm": "^4.3", + "api-platform/serializer": "^4.3", "phpspec/prophecy-phpunit": "^2.2", - "phpunit/phpunit": "^12.2", + "phpunit/phpunit": "^11.5 || ^12.2", "symfony/type-info": "^7.3 || ^8.0" }, "type": "library", @@ -1760,7 +1772,7 @@ "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev", "dev-4.2": "4.2.x-dev", - "dev-main": "4.3.x-dev" + "dev-main": "4.4.x-dev" } }, "autoload": { @@ -1800,27 +1812,27 @@ "swagger" ], "support": { - "source": "https://github.com/api-platform/openapi/tree/v4.2.19" + "source": "https://github.com/api-platform/openapi/tree/v4.3.5" }, - "time": "2026-01-26T15:38:30+00:00" + "time": "2026-04-30T12:21:24+00:00" }, { "name": "api-platform/serializer", - "version": "v4.2.19", + "version": "v4.3.5", "source": { "type": "git", "url": "https://github.com/api-platform/serializer.git", - "reference": "bd627b86c0cb37bd2c2ca6b7f996d5301627f627" + "reference": "bd7c26cc8e6858abc9661d677c15eaf4c61e08e3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/serializer/zipball/bd627b86c0cb37bd2c2ca6b7f996d5301627f627", - "reference": "bd627b86c0cb37bd2c2ca6b7f996d5301627f627", + "url": "https://api.github.com/repos/api-platform/serializer/zipball/bd7c26cc8e6858abc9661d677c15eaf4c61e08e3", + "reference": "bd7c26cc8e6858abc9661d677c15eaf4c61e08e3", "shasum": "" }, "require": { - "api-platform/metadata": "^4.2", - "api-platform/state": "^4.2.4", + "api-platform/metadata": "^4.3", + "api-platform/state": "^4.3", "php": ">=8.2", "symfony/property-access": "^6.4 || ^7.0 || ^8.0", "symfony/property-info": "^6.4 || ^7.1 || ^8.0", @@ -1828,14 +1840,15 @@ "symfony/validator": "^6.4.11 || ^7.0 || ^8.0" }, "require-dev": { - "api-platform/doctrine-common": "^4.2", - "api-platform/doctrine-odm": "^4.2", - "api-platform/doctrine-orm": "^4.2", - "api-platform/json-schema": "^4.2", - "api-platform/openapi": "^4.2", + "api-platform/doctrine-common": "^4.3", + "api-platform/doctrine-odm": "^4.3", + "api-platform/doctrine-orm": "^4.3", + "api-platform/json-schema": "^4.3", + "api-platform/openapi": "^4.3", "doctrine/collections": "^2.1", "phpspec/prophecy-phpunit": "^2.2", - "phpunit/phpunit": "^12.2", + "phpunit/phpunit": "^11.5 || ^12.2", + "sebastian/exporter": "^6.3.2 || ^7.0.2", "symfony/mercure-bundle": "*", "symfony/type-info": "^7.3 || ^8.0", "symfony/var-dumper": "^6.4 || ^7.0 || ^8.0", @@ -1858,7 +1871,7 @@ "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev", "dev-4.2": "4.2.x-dev", - "dev-main": "4.3.x-dev" + "dev-main": "4.4.x-dev" } }, "autoload": { @@ -1893,37 +1906,37 @@ "serializer" ], "support": { - "source": "https://github.com/api-platform/serializer/tree/v4.2.19" + "source": "https://github.com/api-platform/serializer/tree/v4.3.5" }, - "time": "2026-02-27T16:03:48+00:00" + "time": "2026-04-30T12:21:24+00:00" }, { "name": "api-platform/state", - "version": "v4.2.19", + "version": "v4.3.5", "source": { "type": "git", "url": "https://github.com/api-platform/state.git", - "reference": "1b6f69c75579ab0f132cd45e45d5f43ed19a15a5" + "reference": "d0ac7188ae58acae4c1406f3d0a6977c50342324" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/state/zipball/1b6f69c75579ab0f132cd45e45d5f43ed19a15a5", - "reference": "1b6f69c75579ab0f132cd45e45d5f43ed19a15a5", + "url": "https://api.github.com/repos/api-platform/state/zipball/d0ac7188ae58acae4c1406f3d0a6977c50342324", + "reference": "d0ac7188ae58acae4c1406f3d0a6977c50342324", "shasum": "" }, "require": { - "api-platform/metadata": "^4.2.3", + "api-platform/metadata": "^4.3", "php": ">=8.2", "psr/container": "^1.0 || ^2.0", "symfony/deprecation-contracts": "^3.1", - "symfony/http-kernel": "^6.4 || ^7.0 || ^8.0", + "symfony/http-kernel": "^6.4.13 || ^7.0 || ^8.0", "symfony/serializer": "^6.4 || ^7.0 || ^8.0", "symfony/translation-contracts": "^3.0" }, "require-dev": { - "api-platform/serializer": "^4.2.4", - "api-platform/validator": "^4.2.4", - "phpunit/phpunit": "^12.2", + "api-platform/serializer": "^4.3", + "api-platform/validator": "^4.3.1", + "phpunit/phpunit": "^11.5 || ^12.2", "symfony/http-foundation": "^6.4.14 || ^7.0 || ^8.0", "symfony/object-mapper": "^7.4 || ^8.0", "symfony/type-info": "^7.4 || ^8.0", @@ -1950,7 +1963,7 @@ "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev", "dev-4.2": "4.2.x-dev", - "dev-main": "4.3.x-dev" + "dev-main": "4.4.x-dev" } }, "autoload": { @@ -1990,38 +2003,39 @@ "swagger" ], "support": { - "source": "https://github.com/api-platform/state/tree/v4.2.19" + "source": "https://github.com/api-platform/state/tree/v4.3.5" }, - "time": "2026-02-17T09:18:17+00:00" + "time": "2026-05-07T11:45:31+00:00" }, { "name": "api-platform/symfony", - "version": "v4.2.19", + "version": "v4.3.5", "source": { "type": "git", "url": "https://github.com/api-platform/symfony.git", - "reference": "3ed112cd9e278a5ba2d7b663df04861a3c4ba905" + "reference": "32de5d330c9c9f647eba07981780162977b52de5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/symfony/zipball/3ed112cd9e278a5ba2d7b663df04861a3c4ba905", - "reference": "3ed112cd9e278a5ba2d7b663df04861a3c4ba905", + "url": "https://api.github.com/repos/api-platform/symfony/zipball/32de5d330c9c9f647eba07981780162977b52de5", + "reference": "32de5d330c9c9f647eba07981780162977b52de5", "shasum": "" }, "require": { - "api-platform/documentation": "^4.2.12", - "api-platform/http-cache": "^4.2.12", - "api-platform/hydra": "^4.2.12", - "api-platform/json-schema": "^4.2.12", - "api-platform/jsonld": "^4.2.12", - "api-platform/metadata": "^4.2.12", - "api-platform/openapi": "^4.2.12", - "api-platform/serializer": "^4.2.12", - "api-platform/state": "^4.2.12", - "api-platform/validator": "^4.2.12", + "api-platform/documentation": "^4.3", + "api-platform/http-cache": "^4.3", + "api-platform/hydra": "^4.3", + "api-platform/json-schema": "^4.3", + "api-platform/jsonld": "^4.3", + "api-platform/metadata": "^4.3", + "api-platform/openapi": "^4.3", + "api-platform/serializer": "^4.3", + "api-platform/state": "^4.3", + "api-platform/validator": "^4.3.1", "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", @@ -2029,14 +2043,14 @@ "willdurand/negotiation": "^3.1" }, "require-dev": { - "api-platform/doctrine-common": "^4.2.12", - "api-platform/doctrine-odm": "^4.2.12", - "api-platform/doctrine-orm": "^4.2.12", - "api-platform/elasticsearch": "^4.2.12", - "api-platform/graphql": "^4.2.12", - "api-platform/hal": "^4.2.12", + "api-platform/doctrine-common": "^4.3", + "api-platform/doctrine-odm": "^4.3", + "api-platform/doctrine-orm": "^4.3", + "api-platform/elasticsearch": "^4.3", + "api-platform/graphql": "^4.3", + "api-platform/hal": "^4.3", "phpspec/prophecy-phpunit": "^2.2", - "phpunit/phpunit": "^12.2", + "phpunit/phpunit": "^11.5 || ^12.2", "symfony/expression-language": "^6.4 || ^7.0 || ^8.0", "symfony/intl": "^6.4 || ^7.0 || ^8.0", "symfony/mercure-bundle": "*", @@ -2077,7 +2091,7 @@ "require": "^6.4 || ^7.0 || ^8.0" }, "branch-alias": { - "dev-main": "4.3.x-dev" + "dev-main": "4.4.x-dev" } }, "autoload": { @@ -2118,28 +2132,28 @@ "symfony" ], "support": { - "source": "https://github.com/api-platform/symfony/tree/v4.2.19" + "source": "https://github.com/api-platform/symfony/tree/v4.3.5" }, - "time": "2026-02-27T10:22:56+00:00" + "time": "2026-05-07T11:45:31+00:00" }, { "name": "api-platform/validator", - "version": "v4.2.19", + "version": "v4.3.5", "source": { "type": "git", "url": "https://github.com/api-platform/validator.git", - "reference": "22968964145b3fe542b5885f6a2e74d77e7e28c3" + "reference": "6df6804799f8831469d2602d0845a0316e81fbab" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/validator/zipball/22968964145b3fe542b5885f6a2e74d77e7e28c3", - "reference": "22968964145b3fe542b5885f6a2e74d77e7e28c3", + "url": "https://api.github.com/repos/api-platform/validator/zipball/6df6804799f8831469d2602d0845a0316e81fbab", + "reference": "6df6804799f8831469d2602d0845a0316e81fbab", "shasum": "" }, "require": { - "api-platform/metadata": "^4.2", + "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", @@ -2147,7 +2161,7 @@ }, "require-dev": { "phpspec/prophecy-phpunit": "^2.2", - "phpunit/phpunit": "^12.2" + "phpunit/phpunit": "^11.5 || ^12.2" }, "type": "library", "extra": { @@ -2162,7 +2176,7 @@ "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev", "dev-4.2": "4.2.x-dev", - "dev-main": "4.3.x-dev" + "dev-main": "4.4.x-dev" } }, "autoload": { @@ -2194,9 +2208,9 @@ "validator" ], "support": { - "source": "https://github.com/api-platform/validator/tree/v4.2.19" + "source": "https://github.com/api-platform/validator/tree/v4.3.5" }, - "time": "2026-01-26T15:45:40+00:00" + "time": "2026-05-07T11:45:31+00:00" }, { "name": "beberlei/assert", @@ -2500,16 +2514,16 @@ }, { "name": "composer/ca-bundle", - "version": "1.5.10", + "version": "1.5.11", "source": { "type": "git", "url": "https://github.com/composer/ca-bundle.git", - "reference": "961a5e4056dd2e4a2eedcac7576075947c28bf63" + "reference": "68ff39175e8e94a4bb1d259407ce51a6a60f09e6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/ca-bundle/zipball/961a5e4056dd2e4a2eedcac7576075947c28bf63", - "reference": "961a5e4056dd2e4a2eedcac7576075947c28bf63", + "url": "https://api.github.com/repos/composer/ca-bundle/zipball/68ff39175e8e94a4bb1d259407ce51a6a60f09e6", + "reference": "68ff39175e8e94a4bb1d259407ce51a6a60f09e6", "shasum": "" }, "require": { @@ -2556,7 +2570,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.10" + "source": "https://github.com/composer/ca-bundle/tree/1.5.11" }, "funding": [ { @@ -2568,7 +2582,7 @@ "type": "github" } ], - "time": "2025-12-08T15:06:51+00:00" + "time": "2026-03-30T09:16:10+00:00" }, { "name": "composer/package-versions-deprecated", @@ -2722,6 +2736,83 @@ ], "time": "2024-11-12T16:29:46+00:00" }, + { + "name": "composer/semver", + "version": "3.4.4", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.4.4" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2025-08-20T19:15:30+00:00" + }, { "name": "daverandom/libdns", "version": "v2.1.0", @@ -3020,16 +3111,16 @@ }, { "name": "doctrine/data-fixtures", - "version": "2.2.0", + "version": "2.2.1", "source": { "type": "git", "url": "https://github.com/doctrine/data-fixtures.git", - "reference": "7a615ba135e45d67674bb623d90f34f6c7b6bd97" + "reference": "bf7ac3a050b54b261cedfb3d0a44733819062275" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/data-fixtures/zipball/7a615ba135e45d67674bb623d90f34f6c7b6bd97", - "reference": "7a615ba135e45d67674bb623d90f34f6c7b6bd97", + "url": "https://api.github.com/repos/doctrine/data-fixtures/zipball/bf7ac3a050b54b261cedfb3d0a44733819062275", + "reference": "bf7ac3a050b54b261cedfb3d0a44733819062275", "shasum": "" }, "require": { @@ -3047,12 +3138,14 @@ "doctrine/dbal": "^3.5 || ^4", "doctrine/mongodb-odm": "^1.3.0 || ^2.0.0", "doctrine/orm": "^2.14 || ^3", + "doctrine/phpcr-odm": "^1.8 || ^2.0", "ext-sqlite3": "*", "fig/log-test": "^1", - "phpstan/phpstan": "2.1.31", - "phpunit/phpunit": "10.5.45 || 12.4.0", - "symfony/cache": "^6.4 || ^7", - "symfony/var-exporter": "^6.4 || ^7" + "jackalope/jackalope-fs": "*", + "phpstan/phpstan": "2.1.46", + "phpunit/phpunit": "10.5.63 || 12.5.12", + "symfony/cache": "^6.4 || ^7 || ^8", + "symfony/var-exporter": "^6.4 || ^7 || ^8" }, "suggest": { "alcaeus/mongo-php-adapter": "For using MongoDB ODM 1.3 with PHP 7 (deprecated)", @@ -3083,7 +3176,7 @@ ], "support": { "issues": "https://github.com/doctrine/data-fixtures/issues", - "source": "https://github.com/doctrine/data-fixtures/tree/2.2.0" + "source": "https://github.com/doctrine/data-fixtures/tree/2.2.1" }, "funding": [ { @@ -3099,20 +3192,20 @@ "type": "tidelift" } ], - "time": "2025-10-17T20:06:20+00:00" + "time": "2026-04-01T13:56:01+00:00" }, { "name": "doctrine/dbal", - "version": "4.4.2", + "version": "4.4.3", "source": { "type": "git", "url": "https://github.com/doctrine/dbal.git", - "reference": "476f7f0fa6ea4aa5364926db7fabdf6049075722" + "reference": "61e730f1658814821a85f2402c945f3883407dec" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/dbal/zipball/476f7f0fa6ea4aa5364926db7fabdf6049075722", - "reference": "476f7f0fa6ea4aa5364926db7fabdf6049075722", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/61e730f1658814821a85f2402c945f3883407dec", + "reference": "61e730f1658814821a85f2402c945f3883407dec", "shasum": "" }, "require": { @@ -3189,7 +3282,7 @@ ], "support": { "issues": "https://github.com/doctrine/dbal/issues", - "source": "https://github.com/doctrine/dbal/tree/4.4.2" + "source": "https://github.com/doctrine/dbal/tree/4.4.3" }, "funding": [ { @@ -3205,7 +3298,7 @@ "type": "tidelift" } ], - "time": "2026-02-26T12:12:19+00:00" + "time": "2026-03-20T08:52:12+00:00" }, { "name": "doctrine/deprecations", @@ -3791,16 +3884,16 @@ }, { "name": "doctrine/migrations", - "version": "3.9.6", + "version": "3.9.7", "source": { "type": "git", "url": "https://github.com/doctrine/migrations.git", - "reference": "ffd8355cdd8505fc650d9604f058bf62aedd80a1" + "reference": "96cb2a89b56c9efb0bac38e606dc0b0f13e650ec" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/migrations/zipball/ffd8355cdd8505fc650d9604f058bf62aedd80a1", - "reference": "ffd8355cdd8505fc650d9604f058bf62aedd80a1", + "url": "https://api.github.com/repos/doctrine/migrations/zipball/96cb2a89b56c9efb0bac38e606dc0b0f13e650ec", + "reference": "96cb2a89b56c9efb0bac38e606dc0b0f13e650ec", "shasum": "" }, "require": { @@ -3874,7 +3967,7 @@ ], "support": { "issues": "https://github.com/doctrine/migrations/issues", - "source": "https://github.com/doctrine/migrations/tree/3.9.6" + "source": "https://github.com/doctrine/migrations/tree/3.9.7" }, "funding": [ { @@ -3890,20 +3983,20 @@ "type": "tidelift" } ], - "time": "2026-02-11T06:46:11+00:00" + "time": "2026-04-23T19:33:20+00:00" }, { "name": "doctrine/orm", - "version": "3.6.2", + "version": "3.6.5", "source": { "type": "git", "url": "https://github.com/doctrine/orm.git", - "reference": "4262eb495b4d2a53b45de1ac58881e0091f2970f" + "reference": "7e88b416153dceeb563352ca2b12465f09eea173" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/orm/zipball/4262eb495b4d2a53b45de1ac58881e0091f2970f", - "reference": "4262eb495b4d2a53b45de1ac58881e0091f2970f", + "url": "https://api.github.com/repos/doctrine/orm/zipball/7e88b416153dceeb563352ca2b12465f09eea173", + "reference": "7e88b416153dceeb563352ca2b12465f09eea173", "shasum": "" }, "require": { @@ -3976,25 +4069,26 @@ ], "support": { "issues": "https://github.com/doctrine/orm/issues", - "source": "https://github.com/doctrine/orm/tree/3.6.2" + "source": "https://github.com/doctrine/orm/tree/3.6.5" }, - "time": "2026-01-30T21:41:41+00:00" + "time": "2026-05-11T06:47:19+00:00" }, { "name": "doctrine/persistence", - "version": "4.1.1", + "version": "4.2.0", "source": { "type": "git", "url": "https://github.com/doctrine/persistence.git", - "reference": "b9c49ad3558bb77ef973f4e173f2e9c2eca9be09" + "reference": "49ab73e0d3e2ac8d1f5ecda3dd8acd5503781e8b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/persistence/zipball/b9c49ad3558bb77ef973f4e173f2e9c2eca9be09", - "reference": "b9c49ad3558bb77ef973f4e173f2e9c2eca9be09", + "url": "https://api.github.com/repos/doctrine/persistence/zipball/49ab73e0d3e2ac8d1f5ecda3dd8acd5503781e8b", + "reference": "49ab73e0d3e2ac8d1f5ecda3dd8acd5503781e8b", "shasum": "" }, "require": { + "doctrine/deprecations": "^1", "doctrine/event-manager": "^1 || ^2", "php": "^8.1", "psr/cache": "^1.0 || ^2.0 || ^3.0" @@ -4005,13 +4099,13 @@ "phpstan/phpstan-phpunit": "^2", "phpstan/phpstan-strict-rules": "^2", "phpunit/phpunit": "^10.5.58 || ^12", - "symfony/cache": "^4.4 || ^5.4 || ^6.0 || ^7.0", - "symfony/finder": "^4.4 || ^5.4 || ^6.0 || ^7.0" + "symfony/cache": "^4.4 || ^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/finder": "^4.4 || ^5.4 || ^6.0 || ^7.0 || ^8.0" }, "type": "library", "autoload": { "psr-4": { - "Doctrine\\Persistence\\": "src/Persistence" + "Doctrine\\Persistence\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -4055,7 +4149,7 @@ ], "support": { "issues": "https://github.com/doctrine/persistence/issues", - "source": "https://github.com/doctrine/persistence/tree/4.1.1" + "source": "https://github.com/doctrine/persistence/tree/4.2.0" }, "funding": [ { @@ -4071,7 +4165,7 @@ "type": "tidelift" } ], - "time": "2025-10-16T20:13:18+00:00" + "time": "2026-04-26T12:12:52+00:00" }, { "name": "doctrine/sql-formatter", @@ -4130,16 +4224,16 @@ }, { "name": "dompdf/dompdf", - "version": "v3.1.4", + "version": "v3.1.5", "source": { "type": "git", "url": "https://github.com/dompdf/dompdf.git", - "reference": "db712c90c5b9868df3600e64e68da62e78a34623" + "reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/dompdf/dompdf/zipball/db712c90c5b9868df3600e64e68da62e78a34623", - "reference": "db712c90c5b9868df3600e64e68da62e78a34623", + "url": "https://api.github.com/repos/dompdf/dompdf/zipball/f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496", + "reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496", "shasum": "" }, "require": { @@ -4188,9 +4282,9 @@ "homepage": "https://github.com/dompdf/dompdf", "support": { "issues": "https://github.com/dompdf/dompdf/issues", - "source": "https://github.com/dompdf/dompdf/tree/v3.1.4" + "source": "https://github.com/dompdf/dompdf/tree/v3.1.5" }, - "time": "2025-10-29T12:43:30+00:00" + "time": "2026-03-03T13:54:37+00:00" }, { "name": "dompdf/php-font-lib", @@ -4753,16 +4847,16 @@ }, { "name": "guzzlehttp/psr7", - "version": "2.8.0", + "version": "2.9.0", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "21dc724a0583619cd1652f673303492272778051" + "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/21dc724a0583619cd1652f673303492272778051", - "reference": "21dc724a0583619cd1652f673303492272778051", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/7d0ed42f28e42d61352a7a79de682e5e67fec884", + "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884", "shasum": "" }, "require": { @@ -4778,6 +4872,7 @@ "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", "http-interop/http-factory-tests": "0.9.0", + "jshttp/mime-db": "1.54.0.1", "phpunit/phpunit": "^8.5.44 || ^9.6.25" }, "suggest": { @@ -4849,7 +4944,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.8.0" + "source": "https://github.com/guzzle/psr7/tree/2.9.0" }, "funding": [ { @@ -4865,7 +4960,7 @@ "type": "tidelift" } ], - "time": "2025-08-23T21:21:41+00:00" + "time": "2026-03-10T16:41:02+00:00" }, { "name": "hshn/base64-encoded-file", @@ -5108,16 +5203,16 @@ }, { "name": "jbtronics/settings-bundle", - "version": "v3.2.1", + "version": "v3.3.1", "source": { "type": "git", "url": "https://github.com/jbtronics/settings-bundle.git", - "reference": "9cce5f59482e66417166354072c7e24790495b9b" + "reference": "ca90a8b2255482d11f10cb30c49791a7dabd5d40" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/jbtronics/settings-bundle/zipball/9cce5f59482e66417166354072c7e24790495b9b", - "reference": "9cce5f59482e66417166354072c7e24790495b9b", + "url": "https://api.github.com/repos/jbtronics/settings-bundle/zipball/ca90a8b2255482d11f10cb30c49791a7dabd5d40", + "reference": "ca90a8b2255482d11f10cb30c49791a7dabd5d40", "shasum": "" }, "require": { @@ -5146,11 +5241,13 @@ "symfony/console": "^6.4|^7.0|^8.0", "symfony/phpunit-bridge": "^6.4|^7.0|^8.0", "symfony/security-csrf": "^7.0|^6.4|^8.0", - "symfony/twig-bridge": "^6.4|^7.0|^8.0" + "symfony/twig-bridge": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" }, "suggest": { "doctrine/doctrine-bundle": "To use the doctrine ORM storage", - "symfony/twig-bridge": "Allows to access settings in twig templates" + "symfony/twig-bridge": "Allows to access settings in twig templates", + "symfony/yaml": "To use the YAML metadata driver for settings configuration" }, "type": "symfony-bundle", "autoload": { @@ -5165,20 +5262,30 @@ "authors": [ { "name": "Jan Böhmer", - "email": "mail@jan-boehmer.de" + "email": "mail@jan-boehmer.de", + "role": "Maintainer" + }, + { + "name": "Github Contributors", + "homepage": "https://github.com/jbtronics/settings-bundle/graphs/contributors" } ], "description": "A symfony bundle to easily create typesafe, user-configurable settings for symfony applications", "keywords": [ "Settings", "config", + "configuration", + "dynamic-settings", + "options", + "preferences", "symfony", "symfony-bundle", + "user-config", "user-configurable" ], "support": { "issues": "https://github.com/jbtronics/settings-bundle/issues", - "source": "https://github.com/jbtronics/settings-bundle/tree/v3.2.1" + "source": "https://github.com/jbtronics/settings-bundle/tree/v3.3.1" }, "funding": [ { @@ -5190,7 +5297,7 @@ "type": "github" } ], - "time": "2026-02-28T16:30:47+00:00" + "time": "2026-04-28T10:57:15+00:00" }, { "name": "jfcherng/php-color-output", @@ -5429,6 +5536,174 @@ ], "time": "2023-05-21T07:57:08+00:00" }, + { + "name": "jkphl/dom-factory", + "version": "v1.0.1", + "source": { + "type": "git", + "url": "https://github.com/jkphl/dom-factory.git", + "reference": "dd32b8b2cc800f065c0eff8bb621d9f80147d45e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jkphl/dom-factory/zipball/dd32b8b2cc800f065c0eff8bb621d9f80147d45e", + "reference": "dd32b8b2cc800f065c0eff8bb621d9f80147d45e", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "guzzlehttp/guzzle": "^6.0||^7.0", + "masterminds/html5": "^2.7", + "php": ">=7.2" + }, + "require-dev": { + "clue/graph-composer": "^1.1", + "php-coveralls/php-coveralls": "^2.2", + "phpunit/phpunit": "^8.0||^9.0", + "squizlabs/php_codesniffer": "^3.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Jkphl\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Joschi Kuphal", + "email": "joschi@kuphal.net", + "homepage": "https://jkphl.is", + "role": "Developer" + } + ], + "description": "Simple HTML5/XML DOM factory", + "homepage": "https://github.com/jkphl/dom-factory", + "support": { + "email": "joschi@kuphal.net", + "issues": "https://github.com/jkphl/dom-factory/issues", + "source": "https://github.com/jkphl/dom-factory" + }, + "time": "2021-06-28T11:49:36+00:00" + }, + { + "name": "jkphl/micrometa", + "version": "v3.4.0", + "source": { + "type": "git", + "url": "https://github.com/jkphl/micrometa.git", + "reference": "003583fa91eab9c62e5a47e9d4f909b2fad44de2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jkphl/micrometa/zipball/003583fa91eab9c62e5a47e9d4f909b2fad44de2", + "reference": "003583fa91eab9c62e5a47e9d4f909b2fad44de2", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "jkphl/dom-factory": "^1", + "jkphl/rdfa-lite-microdata": "^0.4.4", + "league/uri": "^5.0|^6.5|^7.0", + "mf2/mf2": "^0.4", + "ml/json-ld": "^1.2", + "monolog/monolog": "^1.24 || ^2 || ^3", + "php": ">=7.1.3", + "psr/cache": "^1.0|^2|^3", + "psr/log": "^1.1|^2|^3", + "symfony/cache": "^4.0|^5.0|^6.0|^7.0|^8.0" + }, + "require-dev": { + "clue/graph-composer": "^1.1", + "mf2/tests": "@dev", + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^7.0 || ^8.5", + "squizlabs/php_codesniffer": "^3.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Jkphl\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Joschi Kuphal", + "email": "joschi@tollwerk.de", + "homepage": "https://jkphl.is", + "role": "Developer" + } + ], + "description": "A meta parser for extracting micro information out of web documents, currently supporting Microformats 1+2, HTML Microdata, RDFa Lite 1.1 and JSON-LD", + "homepage": "https://jkphl.is/projects/micrometa/", + "support": { + "email": "joschi@tollwerk.de", + "issues": "https://github.com/jkphl/micrometa/issues", + "source": "https://github.com/jkphl/micrometa" + }, + "time": "2026-04-28T07:20:59+00:00" + }, + { + "name": "jkphl/rdfa-lite-microdata", + "version": "v0.4.7", + "source": { + "type": "git", + "url": "https://github.com/jkphl/rdfa-lite-microdata.git", + "reference": "ffc4940e8be55798257a03da7ed7d4506a13c3e5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jkphl/rdfa-lite-microdata/zipball/ffc4940e8be55798257a03da7ed7d4506a13c3e5", + "reference": "ffc4940e8be55798257a03da7ed7d4506a13c3e5", + "shasum": "" + }, + "require": { + "jkphl/dom-factory": "^1", + "php": ">=5.5" + }, + "require-dev": { + "clue/graph-composer": "dev-master", + "codeclimate/php-test-reporter": "^0.4.4", + "phpunit/phpunit": "^4.8", + "satooshi/php-coveralls": "^1.0", + "squizlabs/php_codesniffer": "^2.8" + }, + "type": "library", + "autoload": { + "psr-4": { + "Jkphl\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Joschi Kuphal", + "email": "joschi@tollwerk.de", + "homepage": "https://jkphl.is", + "role": "Developer" + } + ], + "description": "RDFa Lite 1.1 and HTML Microdata parser for web documents (HTML, SVG, XML)", + "homepage": "https://github.com/jkphl/rdfa-lite-microdata", + "support": { + "email": "joschi@tollwerk.de", + "issues": "https://github.com/jkphl/rdfa-lite-microdata/issues", + "source": "https://github.com/jkphl/rdfa-lite-microdata" + }, + "time": "2023-01-27T13:29:45+00:00" + }, { "name": "kelunik/certificate", "version": "v1.1.3", @@ -5686,16 +5961,16 @@ }, { "name": "league/commonmark", - "version": "2.8.0", + "version": "2.8.2", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "4efa10c1e56488e658d10adf7b7b7dcd19940bfb" + "reference": "59fb075d2101740c337c7216e3f32b36c204218b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/4efa10c1e56488e658d10adf7b7b7dcd19940bfb", - "reference": "4efa10c1e56488e658d10adf7b7b7dcd19940bfb", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/59fb075d2101740c337c7216e3f32b36c204218b", + "reference": "59fb075d2101740c337c7216e3f32b36c204218b", "shasum": "" }, "require": { @@ -5720,9 +5995,9 @@ "phpstan/phpstan": "^1.8.2", "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0", "scrutinizer/ocular": "^1.8.1", - "symfony/finder": "^5.3 | ^6.0 | ^7.0", - "symfony/process": "^5.4 | ^6.0 | ^7.0", - "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0", + "symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0", + "symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0", + "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0 || ^8.0", "unleashedtech/php-coding-standard": "^3.1.1", "vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0" }, @@ -5789,7 +6064,7 @@ "type": "tidelift" } ], - "time": "2025-11-26T21:48:24+00:00" + "time": "2026-03-19T13:16:38+00:00" }, { "name": "league/config", @@ -6120,20 +6395,20 @@ }, { "name": "league/uri", - "version": "7.8.0", + "version": "7.8.1", "source": { "type": "git", "url": "https://github.com/thephpleague/uri.git", - "reference": "4436c6ec8d458e4244448b069cc572d088230b76" + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri/zipball/4436c6ec8d458e4244448b069cc572d088230b76", - "reference": "4436c6ec8d458e4244448b069cc572d088230b76", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/08cf38e3924d4f56238125547b5720496fac8fd4", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4", "shasum": "" }, "require": { - "league/uri-interfaces": "^7.8", + "league/uri-interfaces": "^7.8.1", "php": "^8.1", "psr/http-factory": "^1" }, @@ -6206,7 +6481,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri/tree/7.8.0" + "source": "https://github.com/thephpleague/uri/tree/7.8.1" }, "funding": [ { @@ -6214,24 +6489,24 @@ "type": "github" } ], - "time": "2026-01-14T17:24:56+00:00" + "time": "2026-03-15T20:22:25+00:00" }, { "name": "league/uri-components", - "version": "7.8.0", + "version": "7.8.1", "source": { "type": "git", "url": "https://github.com/thephpleague/uri-components.git", - "reference": "8b5ffcebcc0842b76eb80964795bd56a8333b2ba" + "reference": "848ff9db2f0be06229d6034b7c2e33d41b4fd675" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri-components/zipball/8b5ffcebcc0842b76eb80964795bd56a8333b2ba", - "reference": "8b5ffcebcc0842b76eb80964795bd56a8333b2ba", + "url": "https://api.github.com/repos/thephpleague/uri-components/zipball/848ff9db2f0be06229d6034b7c2e33d41b4fd675", + "reference": "848ff9db2f0be06229d6034b7c2e33d41b4fd675", "shasum": "" }, "require": { - "league/uri": "^7.8", + "league/uri": "^7.8.1", "php": "^8.1" }, "suggest": { @@ -6290,7 +6565,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri-components/tree/7.8.0" + "source": "https://github.com/thephpleague/uri-components/tree/7.8.1" }, "funding": [ { @@ -6298,20 +6573,20 @@ "type": "github" } ], - "time": "2026-01-14T17:24:56+00:00" + "time": "2026-03-15T20:22:25+00:00" }, { "name": "league/uri-interfaces", - "version": "7.8.0", + "version": "7.8.1", "source": { "type": "git", "url": "https://github.com/thephpleague/uri-interfaces.git", - "reference": "c5c5cd056110fc8afaba29fa6b72a43ced42acd4" + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/c5c5cd056110fc8afaba29fa6b72a43ced42acd4", - "reference": "c5c5cd056110fc8afaba29fa6b72a43ced42acd4", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/85d5c77c5d6d3af6c54db4a78246364908f3c928", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928", "shasum": "" }, "require": { @@ -6374,7 +6649,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.0" + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.1" }, "funding": [ { @@ -6382,7 +6657,7 @@ "type": "github" } ], - "time": "2026-01-15T06:54:53+00:00" + "time": "2026-03-08T20:05:35+00:00" }, { "name": "liip/imagine-bundle", @@ -6794,6 +7069,170 @@ }, "time": "2025-07-25T09:04:22+00:00" }, + { + "name": "mf2/mf2", + "version": "0.4.6", + "source": { + "type": "git", + "url": "https://github.com/microformats/php-mf2.git", + "reference": "00b70ee7eb7f5b0585b1bd467f6c9cbd75055d23" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/microformats/php-mf2/zipball/00b70ee7eb7f5b0585b1bd467f6c9cbd75055d23", + "reference": "00b70ee7eb7f5b0585b1bd467f6c9cbd75055d23", + "shasum": "" + }, + "require": { + "php": ">=5.4.0" + }, + "require-dev": { + "mf2/tests": "@dev", + "phpdocumentor/phpdocumentor": "v2.8.4", + "phpunit/phpunit": "4.8.*" + }, + "suggest": { + "barnabywalters/mf-cleaner": "To more easily handle the canonical data php-mf2 gives you", + "masterminds/html5": "Alternative HTML parser for PHP, for better HTML5 support." + }, + "bin": [ + "bin/fetch-mf2", + "bin/parse-mf2" + ], + "type": "library", + "autoload": { + "files": [ + "Mf2/Parser.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "CC0-1.0" + ], + "authors": [ + { + "name": "Barnaby Walters", + "homepage": "http://waterpigs.co.uk" + } + ], + "description": "A pure, generic microformats2 parser — makes HTML as easy to consume as a JSON API", + "keywords": [ + "html", + "microformats", + "microformats 2", + "parser", + "semantic" + ], + "support": { + "issues": "https://github.com/microformats/php-mf2/issues", + "source": "https://github.com/microformats/php-mf2/tree/master" + }, + "time": "2018-08-24T14:47:04+00:00" + }, + { + "name": "ml/iri", + "version": "1.1.4", + "target-dir": "ML/IRI", + "source": { + "type": "git", + "url": "https://github.com/lanthaler/IRI.git", + "reference": "cbd44fa913e00ea624241b38cefaa99da8d71341" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lanthaler/IRI/zipball/cbd44fa913e00ea624241b38cefaa99da8d71341", + "reference": "cbd44fa913e00ea624241b38cefaa99da8d71341", + "shasum": "" + }, + "require": { + "lib-pcre": ">=4.0", + "php": ">=5.3.0" + }, + "type": "library", + "autoload": { + "psr-0": { + "ML\\IRI": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Markus Lanthaler", + "email": "mail@markus-lanthaler.com", + "homepage": "http://www.markus-lanthaler.com", + "role": "Developer" + } + ], + "description": "IRI handling for PHP", + "homepage": "http://www.markus-lanthaler.com", + "keywords": [ + "URN", + "iri", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/lanthaler/IRI/issues", + "source": "https://github.com/lanthaler/IRI/tree/master" + }, + "time": "2014-01-21T13:43:39+00:00" + }, + { + "name": "ml/json-ld", + "version": "1.2.1", + "source": { + "type": "git", + "url": "https://github.com/lanthaler/JsonLD.git", + "reference": "537e68e87a6bce23e57c575cd5dcac1f67ce25d8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lanthaler/JsonLD/zipball/537e68e87a6bce23e57c575cd5dcac1f67ce25d8", + "reference": "537e68e87a6bce23e57c575cd5dcac1f67ce25d8", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ml/iri": "^1.1.1", + "php": ">=5.3.0" + }, + "require-dev": { + "json-ld/tests": "1.0", + "phpunit/phpunit": "^4" + }, + "type": "library", + "autoload": { + "psr-4": { + "ML\\JsonLD\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Markus Lanthaler", + "email": "mail@markus-lanthaler.com", + "homepage": "http://www.markus-lanthaler.com", + "role": "Developer" + } + ], + "description": "JSON-LD Processor for PHP", + "homepage": "http://www.markus-lanthaler.com", + "keywords": [ + "JSON-LD", + "jsonld" + ], + "support": { + "issues": "https://github.com/lanthaler/JsonLD/issues", + "source": "https://github.com/lanthaler/JsonLD/tree/1.2.1" + }, + "time": "2022-09-29T08:45:17+00:00" + }, { "name": "monolog/monolog", "version": "3.10.0", @@ -7716,6 +8155,58 @@ ], "time": "2025-12-09T10:50:49+00:00" }, + { + "name": "oskarstark/enum-helper", + "version": "1.8.4", + "source": { + "type": "git", + "url": "https://github.com/OskarStark/enum-helper.git", + "reference": "14e185f1cc259d7cd3f61eea17f9b174a886a6da" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/OskarStark/enum-helper/zipball/14e185f1cc259d7cd3f61eea17f9b174a886a6da", + "reference": "14e185f1cc259d7cd3f61eea17f9b174a886a6da", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "conflict": { + "phpunit/phpunit": "<10" + }, + "require-dev": { + "ergebnis/php-cs-fixer-config": "^6.58", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^10.5", + "rector/rector": "^2.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "OskarStark\\Enum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Oskar Stark", + "email": "oskarstark@googlemail.com" + } + ], + "description": "This library provides helpers for several enum operations", + "keywords": [ + "enum" + ], + "support": { + "issues": "https://github.com/OskarStark/enum-helper/issues", + "source": "https://github.com/OskarStark/enum-helper/tree/1.8.4" + }, + "time": "2026-02-05T08:59:09+00:00" + }, { "name": "paragonie/constant_time_encoding", "version": "v3.1.3", @@ -8341,16 +8832,16 @@ }, { "name": "phpdocumentor/reflection-docblock", - "version": "5.6.6", + "version": "6.0.3", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8" + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/5cee1d3dfc2d2aa6599834520911d246f656bcb8", - "reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/7bae67520aa9f5ecc506d646810bd40d9da54582", + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582", "shasum": "" }, "require": { @@ -8358,8 +8849,8 @@ "ext-filter": "*", "php": "^7.4 || ^8.0", "phpdocumentor/reflection-common": "^2.2", - "phpdocumentor/type-resolver": "^1.7", - "phpstan/phpdoc-parser": "^1.7|^2.0", + "phpdocumentor/type-resolver": "^2.0", + "phpstan/phpdoc-parser": "^2.0", "webmozart/assert": "^1.9.1 || ^2" }, "require-dev": { @@ -8369,7 +8860,8 @@ "phpstan/phpstan-mockery": "^1.1", "phpstan/phpstan-webmozart-assert": "^1.2", "phpunit/phpunit": "^9.5", - "psalm/phar": "^5.26" + "psalm/phar": "^5.26", + "shipmonk/dead-code-detector": "^0.5.1" }, "type": "library", "extra": { @@ -8399,44 +8891,44 @@ "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", "support": { "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", - "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.6" + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/6.0.3" }, - "time": "2025-12-22T21:13:58+00:00" + "time": "2026-03-18T20:49:53+00:00" }, { "name": "phpdocumentor/type-resolver", - "version": "1.12.0", + "version": "2.0.0", "source": { "type": "git", "url": "https://github.com/phpDocumentor/TypeResolver.git", - "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195" + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/92a98ada2b93d9b201a613cb5a33584dde25f195", - "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/327a05bbee54120d4786a0dc67aad30226ad4cf9", + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9", "shasum": "" }, "require": { "doctrine/deprecations": "^1.0", - "php": "^7.3 || ^8.0", + "php": "^7.4 || ^8.0", "phpdocumentor/reflection-common": "^2.0", - "phpstan/phpdoc-parser": "^1.18|^2.0" + "phpstan/phpdoc-parser": "^2.0" }, "require-dev": { "ext-tokenizer": "*", "phpbench/phpbench": "^1.2", - "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "^1.8", - "phpstan/phpstan-phpunit": "^1.1", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", "phpunit/phpunit": "^9.5", - "rector/rector": "^0.13.9", - "vimeo/psalm": "^4.25" + "psalm/phar": "^4" }, "type": "library", "extra": { "branch-alias": { - "dev-1.x": "1.x-dev" + "dev-1.x": "1.x-dev", + "dev-2.x": "2.x-dev" } }, "autoload": { @@ -8457,22 +8949,22 @@ "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", "support": { "issues": "https://github.com/phpDocumentor/TypeResolver/issues", - "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.12.0" + "source": "https://github.com/phpDocumentor/TypeResolver/tree/2.0.0" }, - "time": "2025-11-21T15:09:14+00:00" + "time": "2026-01-06T21:53:42+00:00" }, { "name": "phpoffice/phpspreadsheet", - "version": "5.5.0", + "version": "5.7.0", "source": { "type": "git", "url": "https://github.com/PHPOffice/PhpSpreadsheet.git", - "reference": "eecd31b885a1c8192f12738130f85bbc6e8906ba" + "reference": "9f55d3b9b7bcb1084fda8340e4b7ce4ed10cd0c8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/eecd31b885a1c8192f12738130f85bbc6e8906ba", - "reference": "eecd31b885a1c8192f12738130f85bbc6e8906ba", + "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/9f55d3b9b7bcb1084fda8340e4b7ce4ed10cd0c8", + "reference": "9f55d3b9b7bcb1084fda8340e4b7ce4ed10cd0c8", "shasum": "" }, "require": { @@ -8566,9 +9058,9 @@ ], "support": { "issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues", - "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.5.0" + "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.7.0" }, - "time": "2026-03-01T00:58:56+00:00" + "time": "2026-04-20T02:42:17+00:00" }, { "name": "phpstan/phpdoc-parser", @@ -9252,16 +9744,16 @@ }, { "name": "rhukster/dom-sanitizer", - "version": "1.0.8", + "version": "1.0.11", "source": { "type": "git", "url": "https://github.com/rhukster/dom-sanitizer.git", - "reference": "757e4d6ac03afe9afa4f97cbef453fc5c25f0729" + "reference": "02d08ec8b36b93b04517d74fe82b715ef06273bd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rhukster/dom-sanitizer/zipball/757e4d6ac03afe9afa4f97cbef453fc5c25f0729", - "reference": "757e4d6ac03afe9afa4f97cbef453fc5c25f0729", + "url": "https://api.github.com/repos/rhukster/dom-sanitizer/zipball/02d08ec8b36b93b04517d74fe82b715ef06273bd", + "reference": "02d08ec8b36b93b04517d74fe82b715ef06273bd", "shasum": "" }, "require": { @@ -9291,22 +9783,22 @@ "description": "A simple but effective DOM/SVG/MathML Sanitizer for PHP 7.4+", "support": { "issues": "https://github.com/rhukster/dom-sanitizer/issues", - "source": "https://github.com/rhukster/dom-sanitizer/tree/1.0.8" + "source": "https://github.com/rhukster/dom-sanitizer/tree/1.0.11" }, - "time": "2024-04-15T08:48:55+00:00" + "time": "2026-04-23T22:56:32+00:00" }, { "name": "robrichards/xmlseclibs", - "version": "3.1.4", + "version": "3.1.5", "source": { "type": "git", "url": "https://github.com/robrichards/xmlseclibs.git", - "reference": "bc87389224c6de95802b505e5265b0ec2c5bcdbd" + "reference": "03062be78178cbb5e8f605cd255dc32a14981f92" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/robrichards/xmlseclibs/zipball/bc87389224c6de95802b505e5265b0ec2c5bcdbd", - "reference": "bc87389224c6de95802b505e5265b0ec2c5bcdbd", + "url": "https://api.github.com/repos/robrichards/xmlseclibs/zipball/03062be78178cbb5e8f605cd255dc32a14981f92", + "reference": "03062be78178cbb5e8f605cd255dc32a14981f92", "shasum": "" }, "require": { @@ -9333,9 +9825,9 @@ ], "support": { "issues": "https://github.com/robrichards/xmlseclibs/issues", - "source": "https://github.com/robrichards/xmlseclibs/tree/3.1.4" + "source": "https://github.com/robrichards/xmlseclibs/tree/3.1.5" }, - "time": "2025-12-08T11:57:53+00:00" + "time": "2026-03-13T10:31:56+00:00" }, { "name": "s9e/regexp-builder", @@ -9502,16 +9994,16 @@ }, { "name": "sabberworm/php-css-parser", - "version": "v9.2.0", + "version": "v9.3.0", "source": { "type": "git", "url": "https://github.com/MyIntervals/PHP-CSS-Parser.git", - "reference": "59373045e11ad47b5c18fc615feee0219e42f6d3" + "reference": "88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/59373045e11ad47b5c18fc615feee0219e42f6d3", - "reference": "59373045e11ad47b5c18fc615feee0219e42f6d3", + "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949", + "reference": "88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949", "shasum": "" }, "require": { @@ -9538,7 +10030,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "9.3.x-dev" + "dev-main": "9.4.x-dev" } }, "autoload": { @@ -9576,34 +10068,35 @@ ], "support": { "issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues", - "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.2.0" + "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.3.0" }, - "time": "2026-02-21T17:12:03+00:00" + "time": "2026-03-03T17:31:43+00:00" }, { "name": "sabre/uri", - "version": "3.0.2", + "version": "3.1.0", "source": { "type": "git", "url": "https://github.com/sabre-io/uri.git", - "reference": "38eeab6ed9eec435a2188db489d4649c56272c51" + "reference": "a926c749dddfb289b8a9b5218d16ac06affdc631" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sabre-io/uri/zipball/38eeab6ed9eec435a2188db489d4649c56272c51", - "reference": "38eeab6ed9eec435a2188db489d4649c56272c51", + "url": "https://api.github.com/repos/sabre-io/uri/zipball/a926c749dddfb289b8a9b5218d16ac06affdc631", + "reference": "a926c749dddfb289b8a9b5218d16ac06affdc631", "shasum": "" }, "require": { - "php": "^7.4 || ^8.0" + "php": "^8.2" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.64", + "friendsofphp/php-cs-fixer": "^3.95", "phpstan/extension-installer": "^1.4", - "phpstan/phpstan": "^1.12", - "phpstan/phpstan-phpunit": "^1.4", - "phpstan/phpstan-strict-rules": "^1.6", - "phpunit/phpunit": "^9.6" + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^10.5", + "rector/rector": "^2.4" }, "type": "library", "autoload": { @@ -9638,7 +10131,7 @@ "issues": "https://github.com/sabre-io/uri/issues", "source": "https://github.com/fruux/sabre-uri" }, - "time": "2024-09-04T15:30:08+00:00" + "time": "2026-04-26T04:19:03+00:00" }, { "name": "scheb/2fa-backup-code", @@ -9863,30 +10356,30 @@ }, { "name": "shivas/versioning-bundle", - "version": "4.1.1", + "version": "4.2.0", "source": { "type": "git", "url": "https://github.com/shivas/versioning-bundle.git", - "reference": "fd89e3501ff1b0d3e6abe61eb7a878d1d4746868" + "reference": "5013ef49951cb8be3846eb77bf3f096a51ea66d1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/shivas/versioning-bundle/zipball/fd89e3501ff1b0d3e6abe61eb7a878d1d4746868", - "reference": "fd89e3501ff1b0d3e6abe61eb7a878d1d4746868", + "url": "https://api.github.com/repos/shivas/versioning-bundle/zipball/5013ef49951cb8be3846eb77bf3f096a51ea66d1", + "reference": "5013ef49951cb8be3846eb77bf3f096a51ea66d1", "shasum": "" }, "require": { "nikolaposa/version": "^4", "php": "^7.2.5 || ^8", - "symfony/console": "^5.4 || ^6 || ^7", - "symfony/framework-bundle": "^5.4 || ^6 || ^7", - "symfony/process": "^5.4 || ^6 || ^7" + "symfony/console": "^5.4 || ^6 || ^7 || ^8", + "symfony/framework-bundle": "^5.4 || ^6 || ^7 || ^8", + "symfony/process": "^5.4 || ^6 || ^7 || ^8" }, "require-dev": { "mikey179/vfsstream": "^2", - "nyholm/symfony-bundle-test": "^3.0", + "nyholm/symfony-bundle-test": "^3.1", "phpunit/phpunit": "^8.5.27", - "symfony/phpunit-bridge": "^5.4 || ^6 || ^7", + "symfony/phpunit-bridge": "^5.4 || ^6 || ^7 || ^8", "twig/twig": "^2 || ^3" }, "type": "symfony-bundle", @@ -9916,10 +10409,10 @@ ], "support": { "issues": "https://github.com/shivas/versioning-bundle/issues", - "source": "https://github.com/shivas/versioning-bundle/tree/4.1.1", + "source": "https://github.com/shivas/versioning-bundle/tree/4.2.0", "wiki": "https://github.com/shivas/versioning-bundle/wiki" }, - "time": "2024-08-14T19:33:15+00:00" + "time": "2026-03-30T07:38:31+00:00" }, { "name": "spatie/db-dumper", @@ -9986,20 +10479,20 @@ }, { "name": "spomky-labs/cbor-php", - "version": "3.2.2", + "version": "3.2.3", "source": { "type": "git", "url": "https://github.com/Spomky-Labs/cbor-php.git", - "reference": "2a5fb86aacfe1004611370ead6caa2bfc88435d0" + "reference": "dd6eb84e6d92f7b8bd0da56b4b4dd7235aed0c32" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Spomky-Labs/cbor-php/zipball/2a5fb86aacfe1004611370ead6caa2bfc88435d0", - "reference": "2a5fb86aacfe1004611370ead6caa2bfc88435d0", + "url": "https://api.github.com/repos/Spomky-Labs/cbor-php/zipball/dd6eb84e6d92f7b8bd0da56b4b4dd7235aed0c32", + "reference": "dd6eb84e6d92f7b8bd0da56b4b4dd7235aed0c32", "shasum": "" }, "require": { - "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14", + "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17", "ext-mbstring": "*", "php": ">=8.0" }, @@ -10041,7 +10534,7 @@ ], "support": { "issues": "https://github.com/Spomky-Labs/cbor-php/issues", - "source": "https://github.com/Spomky-Labs/cbor-php/tree/3.2.2" + "source": "https://github.com/Spomky-Labs/cbor-php/tree/3.2.3" }, "funding": [ { @@ -10053,7 +10546,7 @@ "type": "patreon" } ], - "time": "2025-11-13T13:00:34+00:00" + "time": "2026-04-01T12:15:20+00:00" }, { "name": "spomky-labs/otphp", @@ -10127,40 +10620,41 @@ }, { "name": "spomky-labs/pki-framework", - "version": "1.4.1", + "version": "1.4.2", "source": { "type": "git", "url": "https://github.com/Spomky-Labs/pki-framework.git", - "reference": "f0e9a548df4e3942886adc9b7830581a46334631" + "reference": "aa576cbd07128075bef97ac2f8af9854e67513d8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/f0e9a548df4e3942886adc9b7830581a46334631", - "reference": "f0e9a548df4e3942886adc9b7830581a46334631", + "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/aa576cbd07128075bef97ac2f8af9854e67513d8", + "reference": "aa576cbd07128075bef97ac2f8af9854e67513d8", "shasum": "" }, "require": { - "brick/math": "^0.10|^0.11|^0.12|^0.13|^0.14", + "brick/math": "^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17", "ext-mbstring": "*", - "php": ">=8.1" + "php": ">=8.1", + "psr/clock": "^1.0" }, "require-dev": { "ekino/phpstan-banned-code": "^1.0|^2.0|^3.0", "ext-gmp": "*", "ext-openssl": "*", - "infection/infection": "^0.28|^0.29|^0.31", + "infection/infection": "^0.28|^0.29|^0.31|^0.32", "php-parallel-lint/php-parallel-lint": "^1.3", "phpstan/extension-installer": "^1.3|^2.0", "phpstan/phpstan": "^1.8|^2.0", "phpstan/phpstan-deprecation-rules": "^1.0|^2.0", "phpstan/phpstan-phpunit": "^1.1|^2.0", "phpstan/phpstan-strict-rules": "^1.3|^2.0", - "phpunit/phpunit": "^10.1|^11.0|^12.0", + "phpunit/phpunit": "^10.1|^11.0|^12.0|^13.0", "rector/rector": "^1.0|^2.0", "roave/security-advisories": "dev-latest", "symfony/string": "^6.4|^7.0|^8.0", "symfony/var-dumper": "^6.4|^7.0|^8.0", - "symplify/easy-coding-standard": "^12.0" + "symplify/easy-coding-standard": "^12.0|^13.0" }, "suggest": { "ext-bcmath": "For better performance (or GMP)", @@ -10220,7 +10714,7 @@ ], "support": { "issues": "https://github.com/Spomky-Labs/pki-framework/issues", - "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.4.1" + "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.4.2" }, "funding": [ { @@ -10232,7 +10726,553 @@ "type": "patreon" } ], - "time": "2025-12-20T12:57:40+00:00" + "time": "2026-03-23T22:56:56+00:00" + }, + { + "name": "symfony/ai-bundle", + "version": "v0.8.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/ai-bundle.git", + "reference": "847365e0f885f8814421e9c94f03ce19e0b54bbc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/ai-bundle/zipball/847365e0f885f8814421e9c94f03ce19e0b54bbc", + "reference": "847365e0f885f8814421e9c94f03ce19e0b54bbc", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/ai-platform": "^0.8", + "symfony/clock": "^7.3|^8.0", + "symfony/config": "^7.3|^8.0", + "symfony/console": "^7.3|^8.0", + "symfony/dependency-injection": "^7.3|^8.0", + "symfony/framework-bundle": "^7.3|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.3|^8.0" + }, + "require-dev": { + "google/auth": "^1.47", + "phpstan/phpstan": "^2.1", + "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/expression-language": "^7.3|^8.0", + "symfony/security-core": "^7.3|^8.0", + "symfony/translation": "^7.3|^8.0", + "symfony/validator": "^7.3|^8.0" + }, + "type": "symfony-bundle", + "extra": { + "thanks": { + "url": "https://github.com/symfony/ai", + "name": "symfony/ai" + } + }, + "autoload": { + "psr-4": { + "Symfony\\AI\\AiBundle\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christopher Hertel", + "email": "mail@christopher-hertel.de" + }, + { + "name": "Oskar Stark", + "email": "oskarstark@googlemail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Integration bundle for Symfony AI components", + "support": { + "source": "https://github.com/symfony/ai-bundle/tree/v0.8.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-20T21:23:24+00:00" + }, + { + "name": "symfony/ai-generic-platform", + "version": "v0.8.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/ai-generic-platform.git", + "reference": "2e358c0e88c676fad0b61b3df715f9822d29a7e3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/ai-generic-platform/zipball/2e358c0e88c676fad0b61b3df715f9822d29a7e3", + "reference": "2e358c0e88c676fad0b61b3df715f9822d29a7e3", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/ai-platform": "^0.8", + "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" + }, + "type": "symfony-ai-platform", + "extra": { + "thanks": { + "url": "https://github.com/symfony/ai", + "name": "symfony/ai" + } + }, + "autoload": { + "psr-4": { + "Symfony\\AI\\Platform\\Bridge\\Generic\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christopher Hertel", + "email": "mail@christopher-hertel.de" + }, + { + "name": "Oskar Stark", + "email": "oskarstark@googlemail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic platform bridge for Symfony AI", + "keywords": [ + "Bridge", + "ai", + "generic", + "platform" + ], + "support": { + "source": "https://github.com/symfony/ai-generic-platform/tree/v0.8.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-20T21:23:24+00:00" + }, + { + "name": "symfony/ai-lm-studio-platform", + "version": "v0.8.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/ai-lm-studio-platform.git", + "reference": "ad1c046dd9e7d6e474bc86554443e2d9400a7826" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/ai-lm-studio-platform/zipball/ad1c046dd9e7d6e474bc86554443e2d9400a7826", + "reference": "ad1c046dd9e7d6e474bc86554443e2d9400a7826", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/ai-generic-platform": "^0.8", + "symfony/ai-platform": "^0.8", + "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" + }, + "type": "symfony-ai-platform", + "extra": { + "thanks": { + "url": "https://github.com/symfony/ai", + "name": "symfony/ai" + } + }, + "autoload": { + "psr-4": { + "Symfony\\AI\\Platform\\Bridge\\LmStudio\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christopher Hertel", + "email": "mail@christopher-hertel.de" + }, + { + "name": "Oskar Stark", + "email": "oskarstark@googlemail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "LM Studio platform bridge for Symfony AI", + "keywords": [ + "Bridge", + "ai", + "lmstudio", + "local", + "platform" + ], + "support": { + "source": "https://github.com/symfony/ai-lm-studio-platform/tree/v0.8.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-20T21:23:24+00:00" + }, + { + "name": "symfony/ai-open-router-platform", + "version": "v0.8.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/ai-open-router-platform.git", + "reference": "eb5ed3176b78bc489bf325c5d6bc4efc255804be" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/ai-open-router-platform/zipball/eb5ed3176b78bc489bf325c5d6bc4efc255804be", + "reference": "eb5ed3176b78bc489bf325c5d6bc4efc255804be", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/ai-generic-platform": "^0.8", + "symfony/ai-platform": "^0.8", + "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" + }, + "type": "symfony-ai-platform", + "extra": { + "thanks": { + "url": "https://github.com/symfony/ai", + "name": "symfony/ai" + } + }, + "autoload": { + "psr-4": { + "Symfony\\AI\\Platform\\Bridge\\OpenRouter\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christopher Hertel", + "email": "mail@christopher-hertel.de" + }, + { + "name": "Oskar Stark", + "email": "oskarstark@googlemail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "OpenRouter platform bridge for Symfony AI", + "keywords": [ + "Bridge", + "OpenRouter", + "ai", + "platform" + ], + "support": { + "source": "https://github.com/symfony/ai-open-router-platform/tree/v0.8.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-20T21:23:24+00:00" + }, + { + "name": "symfony/ai-platform", + "version": "v0.8.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/ai-platform.git", + "reference": "86ed9396f53cad02b5d1ca8092956ea74f65823f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/ai-platform/zipball/86ed9396f53cad02b5d1ca8092956ea74f65823f", + "reference": "86ed9396f53cad02b5d1ca8092956ea74f65823f", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "oskarstark/enum-helper": "^1.5", + "php": ">=8.2", + "phpdocumentor/reflection-docblock": "^5.4|^6.0", + "phpstan/phpdoc-parser": "^2.1", + "psr/log": "^3.0", + "symfony/clock": "^7.3|^8.0", + "symfony/event-dispatcher": "^7.3|^8.0", + "symfony/property-access": "^7.3|^8.0", + "symfony/property-info": "^7.3|^8.0", + "symfony/serializer": "^7.3|^8.0", + "symfony/type-info": "^7.3|^8.0", + "symfony/uid": "^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", + "symfony/cache": "^7.3|^8.0", + "symfony/console": "^7.3|^8.0", + "symfony/dotenv": "^7.3|^8.0", + "symfony/expression-language": "^7.3|^8.0", + "symfony/finder": "^7.3|^8.0", + "symfony/http-client": "^7.3|^8.0", + "symfony/http-client-contracts": "^3.5", + "symfony/intl": "^7.3|^8.0", + "symfony/process": "^7.3|^8.0", + "symfony/validator": "^7.3|^8.0", + "symfony/var-dumper": "^7.3|^8.0", + "symfony/yaml": "^7.3|^8.0" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/ai", + "name": "symfony/ai" + } + }, + "autoload": { + "psr-4": { + "Symfony\\AI\\Platform\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christopher Hertel", + "email": "mail@christopher-hertel.de" + }, + { + "name": "Oskar Stark", + "email": "oskarstark@googlemail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "PHP library for interacting with AI platform provider.", + "keywords": [ + "Gemini", + "OpenRouter", + "ai", + "aimlapi", + "albert", + "amazeeai", + "anthropic", + "azure", + "bedrock", + "cerebras", + "dockermodelrunner", + "elevenlabs", + "huggingface", + "inference", + "litellm", + "llama", + "lmstudio", + "meta", + "mistral", + "nova", + "ollama", + "openai", + "ovh", + "perplexity", + "replicate", + "speech", + "transformers", + "vertexai", + "voyage" + ], + "support": { + "source": "https://github.com/symfony/ai-platform/tree/v0.8.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-20T21:28:38+00:00" }, { "name": "symfony/apache-pack", @@ -10262,16 +11302,16 @@ }, { "name": "symfony/asset", - "version": "v7.4.6", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/asset.git", - "reference": "d944ae87e4697af05aadeacfc5e603c3c18ef4fb" + "reference": "d2e2f014ccd6ec9fae8dbe6336a4164346a2a856" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/asset/zipball/d944ae87e4697af05aadeacfc5e603c3c18ef4fb", - "reference": "d944ae87e4697af05aadeacfc5e603c3c18ef4fb", + "url": "https://api.github.com/repos/symfony/asset/zipball/d2e2f014ccd6ec9fae8dbe6336a4164346a2a856", + "reference": "d2e2f014ccd6ec9fae8dbe6336a4164346a2a856", "shasum": "" }, "require": { @@ -10311,7 +11351,7 @@ "description": "Manages URL generation and versioning of web assets such as CSS stylesheets, JavaScript files and image files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/asset/tree/v7.4.6" + "source": "https://github.com/symfony/asset/tree/v7.4.8" }, "funding": [ { @@ -10331,20 +11371,20 @@ "type": "tidelift" } ], - "time": "2026-02-09T09:33:46+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/cache", - "version": "v7.4.6", + "version": "v7.4.10", "source": { "type": "git", "url": "https://github.com/symfony/cache.git", - "reference": "1d06192e8f164e2729b0031e6807d72a6195b8bb" + "reference": "8c5fbb4b5bc7a878f7ce66f1b7e29653c404984b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache/zipball/1d06192e8f164e2729b0031e6807d72a6195b8bb", - "reference": "1d06192e8f164e2729b0031e6807d72a6195b8bb", + "url": "https://api.github.com/repos/symfony/cache/zipball/8c5fbb4b5bc7a878f7ce66f1b7e29653c404984b", + "reference": "8c5fbb4b5bc7a878f7ce66f1b7e29653c404984b", "shasum": "" }, "require": { @@ -10415,7 +11455,7 @@ "psr6" ], "support": { - "source": "https://github.com/symfony/cache/tree/v7.4.6" + "source": "https://github.com/symfony/cache/tree/v7.4.10" }, "funding": [ { @@ -10435,20 +11475,20 @@ "type": "tidelift" } ], - "time": "2026-02-21T23:29:27+00:00" + "time": "2026-05-05T08:23:16+00:00" }, { "name": "symfony/cache-contracts", - "version": "v3.6.0", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/cache-contracts.git", - "reference": "5d68a57d66910405e5c0b63d6f0af941e66fc868" + "reference": "225e8a254166bd3442e370c6f50145465db63831" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/5d68a57d66910405e5c0b63d6f0af941e66fc868", - "reference": "5d68a57d66910405e5c0b63d6f0af941e66fc868", + "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/225e8a254166bd3442e370c6f50145465db63831", + "reference": "225e8a254166bd3442e370c6f50145465db63831", "shasum": "" }, "require": { @@ -10462,7 +11502,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -10495,7 +11535,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/cache-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/cache-contracts/tree/v3.7.0" }, "funding": [ { @@ -10506,25 +11546,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-03-13T15:25:07+00:00" + "time": "2026-05-05T15:33:14+00:00" }, { "name": "symfony/clock", - "version": "v7.4.0", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/clock.git", - "reference": "9169f24776edde469914c1e7a1442a50f7a4e110" + "reference": "674fa3b98e21531dd040e613479f5f6fa8f32111" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/clock/zipball/9169f24776edde469914c1e7a1442a50f7a4e110", - "reference": "9169f24776edde469914c1e7a1442a50f7a4e110", + "url": "https://api.github.com/repos/symfony/clock/zipball/674fa3b98e21531dd040e613479f5f6fa8f32111", + "reference": "674fa3b98e21531dd040e613479f5f6fa8f32111", "shasum": "" }, "require": { @@ -10569,7 +11613,7 @@ "time" ], "support": { - "source": "https://github.com/symfony/clock/tree/v7.4.0" + "source": "https://github.com/symfony/clock/tree/v7.4.8" }, "funding": [ { @@ -10589,20 +11633,20 @@ "type": "tidelift" } ], - "time": "2025-11-12T15:39:26+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/config", - "version": "v7.4.6", + "version": "v7.4.10", "source": { "type": "git", "url": "https://github.com/symfony/config.git", - "reference": "9400e2f9226b3b64ebb0a8ae967ae84e54e39640" + "reference": "d91b6c7cd2a8c9a9c2b8d26c8f5ed48edf99ef57" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/config/zipball/9400e2f9226b3b64ebb0a8ae967ae84e54e39640", - "reference": "9400e2f9226b3b64ebb0a8ae967ae84e54e39640", + "url": "https://api.github.com/repos/symfony/config/zipball/d91b6c7cd2a8c9a9c2b8d26c8f5ed48edf99ef57", + "reference": "d91b6c7cd2a8c9a9c2b8d26c8f5ed48edf99ef57", "shasum": "" }, "require": { @@ -10648,7 +11692,7 @@ "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/config/tree/v7.4.6" + "source": "https://github.com/symfony/config/tree/v7.4.10" }, "funding": [ { @@ -10668,20 +11712,20 @@ "type": "tidelift" } ], - "time": "2026-02-25T16:50:00+00:00" + "time": "2026-05-03T14:20:49+00:00" }, { "name": "symfony/console", - "version": "v7.4.6", + "version": "v7.4.9", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "6d643a93b47398599124022eb24d97c153c12f27" + "reference": "d7d2b64a45a89d607865927b176fa51c33ddbb58" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/6d643a93b47398599124022eb24d97c153c12f27", - "reference": "6d643a93b47398599124022eb24d97c153c12f27", + "url": "https://api.github.com/repos/symfony/console/zipball/d7d2b64a45a89d607865927b176fa51c33ddbb58", + "reference": "d7d2b64a45a89d607865927b176fa51c33ddbb58", "shasum": "" }, "require": { @@ -10746,7 +11790,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.6" + "source": "https://github.com/symfony/console/tree/v7.4.9" }, "funding": [ { @@ -10766,20 +11810,20 @@ "type": "tidelift" } ], - "time": "2026-02-25T17:02:47+00:00" + "time": "2026-04-22T15:21:55+00:00" }, { "name": "symfony/css-selector", - "version": "v7.4.6", + "version": "v7.4.9", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "2e7c52c647b406e2107dd867db424a4dbac91864" + "reference": "b75663ed96cf4756e28e3105476f220f92886cc4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/2e7c52c647b406e2107dd867db424a4dbac91864", - "reference": "2e7c52c647b406e2107dd867db424a4dbac91864", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/b75663ed96cf4756e28e3105476f220f92886cc4", + "reference": "b75663ed96cf4756e28e3105476f220f92886cc4", "shasum": "" }, "require": { @@ -10815,7 +11859,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v7.4.6" + "source": "https://github.com/symfony/css-selector/tree/v7.4.9" }, "funding": [ { @@ -10835,20 +11879,20 @@ "type": "tidelift" } ], - "time": "2026-02-17T07:53:42+00:00" + "time": "2026-04-18T13:18:21+00:00" }, { "name": "symfony/dependency-injection", - "version": "v7.4.6", + "version": "v7.4.10", "source": { "type": "git", "url": "https://github.com/symfony/dependency-injection.git", - "reference": "a3f7d594ca53a34a7d39ae683fbca09408b0c598" + "reference": "4eb0d9dfa9d4f7c59216baf49b3ed6b1fb72293d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/a3f7d594ca53a34a7d39ae683fbca09408b0c598", - "reference": "a3f7d594ca53a34a7d39ae683fbca09408b0c598", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/4eb0d9dfa9d4f7c59216baf49b3ed6b1fb72293d", + "reference": "4eb0d9dfa9d4f7c59216baf49b3ed6b1fb72293d", "shasum": "" }, "require": { @@ -10899,7 +11943,7 @@ "description": "Allows you to standardize and centralize the way objects are constructed in your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dependency-injection/tree/v7.4.6" + "source": "https://github.com/symfony/dependency-injection/tree/v7.4.10" }, "funding": [ { @@ -10919,20 +11963,20 @@ "type": "tidelift" } ], - "time": "2026-02-25T16:50:00+00:00" + "time": "2026-05-06T11:55:30+00:00" }, { "name": "symfony/deprecation-contracts", - "version": "v3.6.0", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", "shasum": "" }, "require": { @@ -10945,7 +11989,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -10970,7 +12014,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" }, "funding": [ { @@ -10981,25 +12025,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2026-04-13T15:52:40+00:00" }, { "name": "symfony/doctrine-bridge", - "version": "v7.4.6", + "version": "v7.4.9", "source": { "type": "git", "url": "https://github.com/symfony/doctrine-bridge.git", - "reference": "710cb7313446aa5ce67e2da06c01f1640dfbdcc6" + "reference": "7a87c85853f3069e3657a823c62b02952de46b0a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/710cb7313446aa5ce67e2da06c01f1640dfbdcc6", - "reference": "710cb7313446aa5ce67e2da06c01f1640dfbdcc6", + "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/7a87c85853f3069e3657a823c62b02952de46b0a", + "reference": "7a87c85853f3069e3657a823c62b02952de46b0a", "shasum": "" }, "require": { @@ -11079,7 +12127,7 @@ "description": "Provides integration for Doctrine with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/doctrine-bridge/tree/v7.4.6" + "source": "https://github.com/symfony/doctrine-bridge/tree/v7.4.9" }, "funding": [ { @@ -11099,20 +12147,20 @@ "type": "tidelift" } ], - "time": "2026-02-17T08:07:48+00:00" + "time": "2026-04-29T14:19:39+00:00" }, { "name": "symfony/dom-crawler", - "version": "v7.4.6", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/dom-crawler.git", - "reference": "487ba8fa43da9a8e6503fe939b45ecd96875410e" + "reference": "2918e7c2ba964defca1f5b69c6f74886529e2dc8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/487ba8fa43da9a8e6503fe939b45ecd96875410e", - "reference": "487ba8fa43da9a8e6503fe939b45ecd96875410e", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/2918e7c2ba964defca1f5b69c6f74886529e2dc8", + "reference": "2918e7c2ba964defca1f5b69c6f74886529e2dc8", "shasum": "" }, "require": { @@ -11151,7 +12199,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.6" + "source": "https://github.com/symfony/dom-crawler/tree/v7.4.8" }, "funding": [ { @@ -11171,20 +12219,20 @@ "type": "tidelift" } ], - "time": "2026-02-17T07:53:42+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/dotenv", - "version": "v7.4.6", + "version": "v7.4.9", "source": { "type": "git", "url": "https://github.com/symfony/dotenv.git", - "reference": "db374255a1c99511d34d5e009dce5be75d0d9c23" + "reference": "ba757a8564a0ccac1a26a859b83295645020ea68" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dotenv/zipball/db374255a1c99511d34d5e009dce5be75d0d9c23", - "reference": "db374255a1c99511d34d5e009dce5be75d0d9c23", + "url": "https://api.github.com/repos/symfony/dotenv/zipball/ba757a8564a0ccac1a26a859b83295645020ea68", + "reference": "ba757a8564a0ccac1a26a859b83295645020ea68", "shasum": "" }, "require": { @@ -11229,7 +12277,7 @@ "environment" ], "support": { - "source": "https://github.com/symfony/dotenv/tree/v7.4.6" + "source": "https://github.com/symfony/dotenv/tree/v7.4.9" }, "funding": [ { @@ -11249,20 +12297,20 @@ "type": "tidelift" } ], - "time": "2026-02-13T11:43:08+00:00" + "time": "2026-04-29T13:21:53+00:00" }, { "name": "symfony/error-handler", - "version": "v7.4.4", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "8da531f364ddfee53e36092a7eebbbd0b775f6b8" + "reference": "8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/8da531f364ddfee53e36092a7eebbbd0b775f6b8", - "reference": "8da531f364ddfee53e36092a7eebbbd0b775f6b8", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa", + "reference": "8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa", "shasum": "" }, "require": { @@ -11311,7 +12359,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.4.4" + "source": "https://github.com/symfony/error-handler/tree/v7.4.8" }, "funding": [ { @@ -11331,20 +12379,20 @@ "type": "tidelift" } ], - "time": "2026-01-20T16:42:42+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v7.4.4", + "version": "v7.4.9", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "dc2c0eba1af673e736bb851d747d266108aea746" + "reference": "e4a2e29753c7801f7a8340e066cfa788f3bc8101" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/dc2c0eba1af673e736bb851d747d266108aea746", - "reference": "dc2c0eba1af673e736bb851d747d266108aea746", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/e4a2e29753c7801f7a8340e066cfa788f3bc8101", + "reference": "e4a2e29753c7801f7a8340e066cfa788f3bc8101", "shasum": "" }, "require": { @@ -11396,7 +12444,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.4" + "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.9" }, "funding": [ { @@ -11416,20 +12464,20 @@ "type": "tidelift" } ], - "time": "2026-01-05T11:45:34+00:00" + "time": "2026-04-18T13:18:21+00:00" }, { "name": "symfony/event-dispatcher-contracts", - "version": "v3.6.0", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "59eb412e93815df44f05f342958efa9f46b1e586" + "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586", - "reference": "59eb412e93815df44f05f342958efa9f46b1e586", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/ccba7060602b7fed0b03c85bf025257f76d9ef32", + "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32", "shasum": "" }, "require": { @@ -11443,7 +12491,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -11476,7 +12524,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.0" }, "funding": [ { @@ -11487,25 +12535,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2026-01-05T13:30:16+00:00" }, { "name": "symfony/expression-language", - "version": "v7.4.4", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/expression-language.git", - "reference": "f3a6497eb6573e185f2ec41cd3b3f0cd68ddf667" + "reference": "87ff95687748f4af65e4d5a6e917d448ec52aa83" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/expression-language/zipball/f3a6497eb6573e185f2ec41cd3b3f0cd68ddf667", - "reference": "f3a6497eb6573e185f2ec41cd3b3f0cd68ddf667", + "url": "https://api.github.com/repos/symfony/expression-language/zipball/87ff95687748f4af65e4d5a6e917d448ec52aa83", + "reference": "87ff95687748f4af65e4d5a6e917d448ec52aa83", "shasum": "" }, "require": { @@ -11540,7 +12592,7 @@ "description": "Provides an engine that can compile and evaluate expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/expression-language/tree/v7.4.4" + "source": "https://github.com/symfony/expression-language/tree/v7.4.8" }, "funding": [ { @@ -11560,20 +12612,20 @@ "type": "tidelift" } ], - "time": "2026-01-05T08:47:25+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/filesystem", - "version": "v7.4.6", + "version": "v7.4.9", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "3ebc794fa5315e59fd122561623c2e2e4280538e" + "reference": "dcd8f96bcdc0f128ec406c765cc066c6035d1be3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/3ebc794fa5315e59fd122561623c2e2e4280538e", - "reference": "3ebc794fa5315e59fd122561623c2e2e4280538e", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/dcd8f96bcdc0f128ec406c765cc066c6035d1be3", + "reference": "dcd8f96bcdc0f128ec406c765cc066c6035d1be3", "shasum": "" }, "require": { @@ -11610,7 +12662,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v7.4.6" + "source": "https://github.com/symfony/filesystem/tree/v7.4.9" }, "funding": [ { @@ -11630,20 +12682,20 @@ "type": "tidelift" } ], - "time": "2026-02-25T16:50:00+00:00" + "time": "2026-04-18T13:18:21+00:00" }, { "name": "symfony/finder", - "version": "v7.4.6", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf" + "reference": "e0be088d22278583a82da281886e8c3592fbf149" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/8655bf1076b7a3a346cb11413ffdabff50c7ffcf", - "reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf", + "url": "https://api.github.com/repos/symfony/finder/zipball/e0be088d22278583a82da281886e8c3592fbf149", + "reference": "e0be088d22278583a82da281886e8c3592fbf149", "shasum": "" }, "require": { @@ -11678,7 +12730,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.6" + "source": "https://github.com/symfony/finder/tree/v7.4.8" }, "funding": [ { @@ -11698,7 +12750,7 @@ "type": "tidelift" } ], - "time": "2026-01-29T09:40:50+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/flex", @@ -11775,16 +12827,16 @@ }, { "name": "symfony/form", - "version": "v7.4.6", + "version": "v7.4.9", "source": { "type": "git", "url": "https://github.com/symfony/form.git", - "reference": "1ec55f7b1a6152760a670415c334f70a08d264f9" + "reference": "b6c107af659106abec1771d9d7d22da528644d3a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/form/zipball/1ec55f7b1a6152760a670415c334f70a08d264f9", - "reference": "1ec55f7b1a6152760a670415c334f70a08d264f9", + "url": "https://api.github.com/repos/symfony/form/zipball/b6c107af659106abec1771d9d7d22da528644d3a", + "reference": "b6c107af659106abec1771d9d7d22da528644d3a", "shasum": "" }, "require": { @@ -11854,7 +12906,7 @@ "description": "Allows to easily create, process and reuse HTML forms", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/form/tree/v7.4.6" + "source": "https://github.com/symfony/form/tree/v7.4.9" }, "funding": [ { @@ -11874,20 +12926,20 @@ "type": "tidelift" } ], - "time": "2026-02-25T16:50:00+00:00" + "time": "2026-04-29T14:39:19+00:00" }, { "name": "symfony/framework-bundle", - "version": "v7.4.6", + "version": "v7.4.10", "source": { "type": "git", "url": "https://github.com/symfony/framework-bundle.git", - "reference": "a4022da7530f794aa64cea34b388439afb6323a3" + "reference": "4b9cb207d72b2e4793f28a3c62ea0865098bea20" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/a4022da7530f794aa64cea34b388439afb6323a3", - "reference": "a4022da7530f794aa64cea34b388439afb6323a3", + "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/4b9cb207d72b2e4793f28a3c62ea0865098bea20", + "reference": "4b9cb207d72b2e4793f28a3c62ea0865098bea20", "shasum": "" }, "require": { @@ -11923,7 +12975,7 @@ "symfony/lock": "<6.4", "symfony/mailer": "<6.4", "symfony/messenger": "<7.4", - "symfony/mime": "<6.4", + "symfony/mime": "<6.4.37|>=7.0,<7.4.9|>=8.0,<8.0.9", "symfony/property-access": "<6.4", "symfony/property-info": "<6.4", "symfony/runtime": "<6.4.13|>=7.0,<7.1.6", @@ -11961,7 +13013,7 @@ "symfony/lock": "^6.4|^7.0|^8.0", "symfony/mailer": "^6.4|^7.0|^8.0", "symfony/messenger": "^7.4|^8.0", - "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4.37|^7.4.9|^8.0.9", "symfony/notifier": "^6.4|^7.0|^8.0", "symfony/object-mapper": "^7.3|^8.0", "symfony/polyfill-intl-icu": "~1.0", @@ -12012,7 +13064,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.6" + "source": "https://github.com/symfony/framework-bundle/tree/v7.4.10" }, "funding": [ { @@ -12032,20 +13084,20 @@ "type": "tidelift" } ], - "time": "2026-02-25T16:50:00+00:00" + "time": "2026-05-05T11:48:54+00:00" }, { "name": "symfony/http-client", - "version": "v7.4.6", + "version": "v7.4.9", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "2bde8afd5ab2fe0b05a9c2d4c3c0e28ceb98a154" + "reference": "7e941c6abf4e3bf7dca160bf0e11ef36a9f832f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/2bde8afd5ab2fe0b05a9c2d4c3c0e28ceb98a154", - "reference": "2bde8afd5ab2fe0b05a9c2d4c3c0e28ceb98a154", + "url": "https://api.github.com/repos/symfony/http-client/zipball/7e941c6abf4e3bf7dca160bf0e11ef36a9f832f6", + "reference": "7e941c6abf4e3bf7dca160bf0e11ef36a9f832f6", "shasum": "" }, "require": { @@ -12113,7 +13165,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.4.6" + "source": "https://github.com/symfony/http-client/tree/v7.4.9" }, "funding": [ { @@ -12133,20 +13185,20 @@ "type": "tidelift" } ], - "time": "2026-02-18T09:46:18+00:00" + "time": "2026-04-29T13:25:15+00:00" }, { "name": "symfony/http-client-contracts", - "version": "v3.6.0", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/http-client-contracts.git", - "reference": "75d7043853a42837e68111812f4d964b01e5101c" + "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c", - "reference": "75d7043853a42837e68111812f4d964b01e5101c", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d", + "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d", "shasum": "" }, "require": { @@ -12159,7 +13211,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -12195,7 +13247,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.0" }, "funding": [ { @@ -12206,25 +13258,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-04-29T11:18:49+00:00" + "time": "2026-03-06T13:17:50+00:00" }, { "name": "symfony/http-foundation", - "version": "v7.4.6", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "fd97d5e926e988a363cef56fbbf88c5c528e9065" + "reference": "9381209597ec66c25be154cbf2289076e64d1eab" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/fd97d5e926e988a363cef56fbbf88c5c528e9065", - "reference": "fd97d5e926e988a363cef56fbbf88c5c528e9065", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/9381209597ec66c25be154cbf2289076e64d1eab", + "reference": "9381209597ec66c25be154cbf2289076e64d1eab", "shasum": "" }, "require": { @@ -12273,7 +13329,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.4.6" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.8" }, "funding": [ { @@ -12293,20 +13349,20 @@ "type": "tidelift" } ], - "time": "2026-02-21T16:25:55+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.4.6", + "version": "v7.4.10", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "002ac0cf4cd972a7fd0912dcd513a95e8a81ce83" + "reference": "23486f59234c6fd6e8f1bec97124f3829d686627" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/002ac0cf4cd972a7fd0912dcd513a95e8a81ce83", - "reference": "002ac0cf4cd972a7fd0912dcd513a95e8a81ce83", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/23486f59234c6fd6e8f1bec97124f3829d686627", + "reference": "23486f59234c6fd6e8f1bec97124f3829d686627", "shasum": "" }, "require": { @@ -12392,7 +13448,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.6" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.10" }, "funding": [ { @@ -12412,20 +13468,20 @@ "type": "tidelift" } ], - "time": "2026-02-26T08:30:57+00:00" + "time": "2026-05-06T12:07:34+00:00" }, { "name": "symfony/intl", - "version": "v7.4.6", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/intl.git", - "reference": "6d6a398b18f73b3110140dbb030dcee2ae4ea81f" + "reference": "7cfb7792d580dea833647420afd5f2f98df8457b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/intl/zipball/6d6a398b18f73b3110140dbb030dcee2ae4ea81f", - "reference": "6d6a398b18f73b3110140dbb030dcee2ae4ea81f", + "url": "https://api.github.com/repos/symfony/intl/zipball/7cfb7792d580dea833647420afd5f2f98df8457b", + "reference": "7cfb7792d580dea833647420afd5f2f98df8457b", "shasum": "" }, "require": { @@ -12482,7 +13538,7 @@ "localization" ], "support": { - "source": "https://github.com/symfony/intl/tree/v7.4.6" + "source": "https://github.com/symfony/intl/tree/v7.4.8" }, "funding": [ { @@ -12502,20 +13558,20 @@ "type": "tidelift" } ], - "time": "2026-02-09T09:33:46+00:00" + "time": "2026-03-30T12:55:43+00:00" }, { "name": "symfony/mailer", - "version": "v7.4.6", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "b02726f39a20bc65e30364f5c750c4ddbf1f58e9" + "reference": "f6ea532250b476bfc1b56699b388a1bdbf168f62" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/b02726f39a20bc65e30364f5c750c4ddbf1f58e9", - "reference": "b02726f39a20bc65e30364f5c750c4ddbf1f58e9", + "url": "https://api.github.com/repos/symfony/mailer/zipball/f6ea532250b476bfc1b56699b388a1bdbf168f62", + "reference": "f6ea532250b476bfc1b56699b388a1bdbf168f62", "shasum": "" }, "require": { @@ -12566,7 +13622,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.4.6" + "source": "https://github.com/symfony/mailer/tree/v7.4.8" }, "funding": [ { @@ -12586,20 +13642,20 @@ "type": "tidelift" } ], - "time": "2026-02-25T16:50:00+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/mime", - "version": "v7.4.6", + "version": "v7.4.9", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "9fc881d95feae4c6c48678cb6372bd8a7ba04f5f" + "reference": "2d550c4758ba4c47519a6667c36553d535705b0c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/9fc881d95feae4c6c48678cb6372bd8a7ba04f5f", - "reference": "9fc881d95feae4c6c48678cb6372bd8a7ba04f5f", + "url": "https://api.github.com/repos/symfony/mime/zipball/2d550c4758ba4c47519a6667c36553d535705b0c", + "reference": "2d550c4758ba4c47519a6667c36553d535705b0c", "shasum": "" }, "require": { @@ -12655,7 +13711,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.4.6" + "source": "https://github.com/symfony/mime/tree/v7.4.9" }, "funding": [ { @@ -12675,20 +13731,20 @@ "type": "tidelift" } ], - "time": "2026-02-05T15:57:06+00:00" + "time": "2026-04-29T13:21:53+00:00" }, { "name": "symfony/monolog-bridge", - "version": "v7.4.6", + "version": "v7.4.9", "source": { "type": "git", "url": "https://github.com/symfony/monolog-bridge.git", - "reference": "69e98e7e10dae3daa896ef0f20e17a3928362d88" + "reference": "20366bceee51838144a14805204bb792cb3d09f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/69e98e7e10dae3daa896ef0f20e17a3928362d88", - "reference": "69e98e7e10dae3daa896ef0f20e17a3928362d88", + "url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/20366bceee51838144a14805204bb792cb3d09f2", + "reference": "20366bceee51838144a14805204bb792cb3d09f2", "shasum": "" }, "require": { @@ -12738,7 +13794,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.6" + "source": "https://github.com/symfony/monolog-bridge/tree/v7.4.9" }, "funding": [ { @@ -12758,20 +13814,20 @@ "type": "tidelift" } ], - "time": "2026-02-17T07:53:42+00:00" + "time": "2026-04-29T13:21:53+00:00" }, { "name": "symfony/monolog-bundle", - "version": "v4.0.1", + "version": "v4.0.2", "source": { "type": "git", "url": "https://github.com/symfony/monolog-bundle.git", - "reference": "3b4ee2717ee56c5e1edb516140a175eb2a72bc66" + "reference": "c012c6aba13129eb02aa7dd61e66e720911d8598" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/monolog-bundle/zipball/3b4ee2717ee56c5e1edb516140a175eb2a72bc66", - "reference": "3b4ee2717ee56c5e1edb516140a175eb2a72bc66", + "url": "https://api.github.com/repos/symfony/monolog-bundle/zipball/c012c6aba13129eb02aa7dd61e66e720911d8598", + "reference": "c012c6aba13129eb02aa7dd61e66e720911d8598", "shasum": "" }, "require": { @@ -12817,7 +13873,7 @@ ], "support": { "issues": "https://github.com/symfony/monolog-bundle/issues", - "source": "https://github.com/symfony/monolog-bundle/tree/v4.0.1" + "source": "https://github.com/symfony/monolog-bundle/tree/v4.0.2" }, "funding": [ { @@ -12837,20 +13893,20 @@ "type": "tidelift" } ], - "time": "2025-12-08T08:00:13+00:00" + "time": "2026-04-02T18:27:21+00:00" }, { "name": "symfony/options-resolver", - "version": "v7.4.0", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/options-resolver.git", - "reference": "b38026df55197f9e39a44f3215788edf83187b80" + "reference": "2888fcdc4dc2fd5f7c7397be78631e8af12e02b4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/options-resolver/zipball/b38026df55197f9e39a44f3215788edf83187b80", - "reference": "b38026df55197f9e39a44f3215788edf83187b80", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/2888fcdc4dc2fd5f7c7397be78631e8af12e02b4", + "reference": "2888fcdc4dc2fd5f7c7397be78631e8af12e02b4", "shasum": "" }, "require": { @@ -12888,7 +13944,7 @@ "options" ], "support": { - "source": "https://github.com/symfony/options-resolver/tree/v7.4.0" + "source": "https://github.com/symfony/options-resolver/tree/v7.4.8" }, "funding": [ { @@ -12908,20 +13964,20 @@ "type": "tidelift" } ], - "time": "2025-11-12T15:39:26+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/password-hasher", - "version": "v7.4.6", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/password-hasher.git", - "reference": "376755eb9c9857d78aedb68341ad2f46d1908b29" + "reference": "18a7d92126c95962f7efbcc9e421ba710a366847" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/password-hasher/zipball/376755eb9c9857d78aedb68341ad2f46d1908b29", - "reference": "376755eb9c9857d78aedb68341ad2f46d1908b29", + "url": "https://api.github.com/repos/symfony/password-hasher/zipball/18a7d92126c95962f7efbcc9e421ba710a366847", + "reference": "18a7d92126c95962f7efbcc9e421ba710a366847", "shasum": "" }, "require": { @@ -12964,7 +14020,7 @@ "password" ], "support": { - "source": "https://github.com/symfony/password-hasher/tree/v7.4.6" + "source": "https://github.com/symfony/password-hasher/tree/v7.4.8" }, "funding": [ { @@ -12984,20 +14040,20 @@ "type": "tidelift" } ], - "time": "2026-02-11T16:03:16+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/polyfill-ctype", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", "shasum": "" }, "require": { @@ -13047,7 +14103,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" }, "funding": [ { @@ -13067,20 +14123,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70" + "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70", - "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/4864388bfbd3001ce88e234fab652acd91fdc57e", + "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e", "shasum": "" }, "require": { @@ -13129,7 +14185,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.37.0" }, "funding": [ { @@ -13149,20 +14205,20 @@ "type": "tidelift" } ], - "time": "2025-06-27T09:58:17+00:00" + "time": "2026-04-26T13:13:48+00:00" }, { "name": "symfony/polyfill-intl-icu", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-icu.git", - "reference": "bfc8fa13dbaf21d69114b0efcd72ab700fb04d0c" + "reference": "3510b63d07376b04e57e27e82607d468bb134f78" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-icu/zipball/bfc8fa13dbaf21d69114b0efcd72ab700fb04d0c", - "reference": "bfc8fa13dbaf21d69114b0efcd72ab700fb04d0c", + "url": "https://api.github.com/repos/symfony/polyfill-intl-icu/zipball/3510b63d07376b04e57e27e82607d468bb134f78", + "reference": "3510b63d07376b04e57e27e82607d468bb134f78", "shasum": "" }, "require": { @@ -13217,7 +14273,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-icu/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-intl-icu/tree/v1.37.0" }, "funding": [ { @@ -13237,11 +14293,11 @@ "type": "tidelift" } ], - "time": "2025-06-20T22:24:30+00:00" + "time": "2026-04-10T16:50:15+00:00" }, { "name": "symfony/polyfill-intl-idn", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-idn.git", @@ -13304,7 +14360,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.37.0" }, "funding": [ { @@ -13328,7 +14384,7 @@ }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", @@ -13389,7 +14445,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.37.0" }, "funding": [ { @@ -13413,16 +14469,16 @@ }, { "name": "symfony/polyfill-php83", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5" + "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5", - "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/3600c2cb22399e25bb226e4a135ce91eeb2a6149", + "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149", "shasum": "" }, "require": { @@ -13469,7 +14525,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.37.0" }, "funding": [ { @@ -13489,20 +14545,20 @@ "type": "tidelift" } ], - "time": "2025-07-08T02:45:35+00:00" + "time": "2026-04-10T17:25:58+00:00" }, { "name": "symfony/polyfill-php84", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php84.git", - "reference": "d8ced4d875142b6a7426000426b8abc631d6b191" + "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/d8ced4d875142b6a7426000426b8abc631d6b191", - "reference": "d8ced4d875142b6a7426000426b8abc631d6b191", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/88486db2c389b290bf87ff1de7ebc1e13e42bb06", + "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06", "shasum": "" }, "require": { @@ -13549,7 +14605,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php84/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php84/tree/v1.37.0" }, "funding": [ { @@ -13569,20 +14625,20 @@ "type": "tidelift" } ], - "time": "2025-06-24T13:30:11+00:00" + "time": "2026-04-10T18:47:49+00:00" }, { "name": "symfony/polyfill-php85", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php85.git", - "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91" + "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", - "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/fcfa4973a9917cef23f2e38774da74a2b7d115ee", + "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee", "shasum": "" }, "require": { @@ -13629,7 +14685,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.37.0" }, "funding": [ { @@ -13649,20 +14705,20 @@ "type": "tidelift" } ], - "time": "2025-06-23T16:12:55+00:00" + "time": "2026-04-26T13:10:57+00:00" }, { "name": "symfony/polyfill-uuid", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-uuid.git", - "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2" + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/21533be36c24be3f4b1669c4725c7d1d2bab4ae2", - "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94", "shasum": "" }, "require": { @@ -13712,7 +14768,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/polyfill-uuid/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.37.0" }, "funding": [ { @@ -13732,20 +14788,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/process", - "version": "v7.4.5", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "608476f4604102976d687c483ac63a79ba18cc97" + "reference": "60f19cd3badc8de688421e21e4305eba50f8089a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/608476f4604102976d687c483ac63a79ba18cc97", - "reference": "608476f4604102976d687c483ac63a79ba18cc97", + "url": "https://api.github.com/repos/symfony/process/zipball/60f19cd3badc8de688421e21e4305eba50f8089a", + "reference": "60f19cd3badc8de688421e21e4305eba50f8089a", "shasum": "" }, "require": { @@ -13777,7 +14833,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.4.5" + "source": "https://github.com/symfony/process/tree/v7.4.8" }, "funding": [ { @@ -13797,20 +14853,20 @@ "type": "tidelift" } ], - "time": "2026-01-26T15:07:59+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/property-access", - "version": "v7.4.4", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/property-access.git", - "reference": "fa49bf1ca8fce1ba0e2dba4e4658554cfb9364b1" + "reference": "b7dad9dae8b8a47ef7ecc76c8569e7d8c7d90cfc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/property-access/zipball/fa49bf1ca8fce1ba0e2dba4e4658554cfb9364b1", - "reference": "fa49bf1ca8fce1ba0e2dba4e4658554cfb9364b1", + "url": "https://api.github.com/repos/symfony/property-access/zipball/b7dad9dae8b8a47ef7ecc76c8569e7d8c7d90cfc", + "reference": "b7dad9dae8b8a47ef7ecc76c8569e7d8c7d90cfc", "shasum": "" }, "require": { @@ -13858,7 +14914,7 @@ "reflection" ], "support": { - "source": "https://github.com/symfony/property-access/tree/v7.4.4" + "source": "https://github.com/symfony/property-access/tree/v7.4.8" }, "funding": [ { @@ -13878,27 +14934,27 @@ "type": "tidelift" } ], - "time": "2026-01-05T08:47:25+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/property-info", - "version": "v7.4.6", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/property-info.git", - "reference": "6396b28f44d7c28b209a1bd73acf0dd985a0a4ef" + "reference": "ac5e82528b986c4f7cfccbf7764b5d2e824d6175" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/property-info/zipball/6396b28f44d7c28b209a1bd73acf0dd985a0a4ef", - "reference": "6396b28f44d7c28b209a1bd73acf0dd985a0a4ef", + "url": "https://api.github.com/repos/symfony/property-info/zipball/ac5e82528b986c4f7cfccbf7764b5d2e824d6175", + "reference": "ac5e82528b986c4f7cfccbf7764b5d2e824d6175", "shasum": "" }, "require": { "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", "symfony/string": "^6.4|^7.0|^8.0", - "symfony/type-info": "~7.3.10|^7.4.4|^8.0.4" + "symfony/type-info": "^7.4.7|^8.0.7" }, "conflict": { "phpdocumentor/reflection-docblock": "<5.2|>=7", @@ -13948,7 +15004,7 @@ "validator" ], "support": { - "source": "https://github.com/symfony/property-info/tree/v7.4.6" + "source": "https://github.com/symfony/property-info/tree/v7.4.8" }, "funding": [ { @@ -13968,20 +15024,20 @@ "type": "tidelift" } ], - "time": "2026-02-13T11:51:31+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/psr-http-message-bridge", - "version": "v7.4.4", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/psr-http-message-bridge.git", - "reference": "929ffe10bbfbb92e711ac3818d416f9daffee067" + "reference": "76f1a57719a4a04c0ea18678a6c9305b5dcb9da8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/929ffe10bbfbb92e711ac3818d416f9daffee067", - "reference": "929ffe10bbfbb92e711ac3818d416f9daffee067", + "url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/76f1a57719a4a04c0ea18678a6c9305b5dcb9da8", + "reference": "76f1a57719a4a04c0ea18678a6c9305b5dcb9da8", "shasum": "" }, "require": { @@ -14036,7 +15092,7 @@ "psr-7" ], "support": { - "source": "https://github.com/symfony/psr-http-message-bridge/tree/v7.4.4" + "source": "https://github.com/symfony/psr-http-message-bridge/tree/v7.4.8" }, "funding": [ { @@ -14056,20 +15112,20 @@ "type": "tidelift" } ], - "time": "2026-01-03T23:30:35+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/rate-limiter", - "version": "v7.4.6", + "version": "v7.4.10", "source": { "type": "git", "url": "https://github.com/symfony/rate-limiter.git", - "reference": "7219be81396041c24c1d12241ca7ef1f88b80783" + "reference": "778c5239c7fd6bf9b886dedf3d84ddb156ddb888" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/rate-limiter/zipball/7219be81396041c24c1d12241ca7ef1f88b80783", - "reference": "7219be81396041c24c1d12241ca7ef1f88b80783", + "url": "https://api.github.com/repos/symfony/rate-limiter/zipball/778c5239c7fd6bf9b886dedf3d84ddb156ddb888", + "reference": "778c5239c7fd6bf9b886dedf3d84ddb156ddb888", "shasum": "" }, "require": { @@ -14110,7 +15166,7 @@ "rate-limiter" ], "support": { - "source": "https://github.com/symfony/rate-limiter/tree/v7.4.6" + "source": "https://github.com/symfony/rate-limiter/tree/v7.4.10" }, "funding": [ { @@ -14130,20 +15186,20 @@ "type": "tidelift" } ], - "time": "2026-02-25T16:50:00+00:00" + "time": "2026-05-04T13:25:50+00:00" }, { "name": "symfony/routing", - "version": "v7.4.6", + "version": "v7.4.9", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "238d749c56b804b31a9bf3e26519d93b65a60938" + "reference": "287771d8bc86eacb30678dd10eda6c64a859951f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/238d749c56b804b31a9bf3e26519d93b65a60938", - "reference": "238d749c56b804b31a9bf3e26519d93b65a60938", + "url": "https://api.github.com/repos/symfony/routing/zipball/287771d8bc86eacb30678dd10eda6c64a859951f", + "reference": "287771d8bc86eacb30678dd10eda6c64a859951f", "shasum": "" }, "require": { @@ -14195,7 +15251,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.4.6" + "source": "https://github.com/symfony/routing/tree/v7.4.9" }, "funding": [ { @@ -14215,20 +15271,20 @@ "type": "tidelift" } ], - "time": "2026-02-25T16:50:00+00:00" + "time": "2026-04-22T15:21:55+00:00" }, { "name": "symfony/runtime", - "version": "v7.4.1", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/runtime.git", - "reference": "876f902a6cb6b26c003de244188c06b2ba1c172f" + "reference": "6d792a64fec1eae2f011cfe9ab5978a9eab3071e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/runtime/zipball/876f902a6cb6b26c003de244188c06b2ba1c172f", - "reference": "876f902a6cb6b26c003de244188c06b2ba1c172f", + "url": "https://api.github.com/repos/symfony/runtime/zipball/6d792a64fec1eae2f011cfe9ab5978a9eab3071e", + "reference": "6d792a64fec1eae2f011cfe9ab5978a9eab3071e", "shasum": "" }, "require": { @@ -14278,7 +15334,7 @@ "runtime" ], "support": { - "source": "https://github.com/symfony/runtime/tree/v7.4.1" + "source": "https://github.com/symfony/runtime/tree/v7.4.8" }, "funding": [ { @@ -14298,20 +15354,20 @@ "type": "tidelift" } ], - "time": "2025-12-05T14:04:53+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/security-bundle", - "version": "v7.4.6", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/security-bundle.git", - "reference": "d79c6d9a373fe8585e85bcfca4c24b9783214263" + "reference": "6f73fdfd9ad23bf24b6f6c8d35be3ea6853d91af" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-bundle/zipball/d79c6d9a373fe8585e85bcfca4c24b9783214263", - "reference": "d79c6d9a373fe8585e85bcfca4c24b9783214263", + "url": "https://api.github.com/repos/symfony/security-bundle/zipball/6f73fdfd9ad23bf24b6f6c8d35be3ea6853d91af", + "reference": "6f73fdfd9ad23bf24b6f6c8d35be3ea6853d91af", "shasum": "" }, "require": { @@ -14390,7 +15446,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.6" + "source": "https://github.com/symfony/security-bundle/tree/v7.4.8" }, "funding": [ { @@ -14410,20 +15466,20 @@ "type": "tidelift" } ], - "time": "2026-02-22T22:01:45+00:00" + "time": "2026-03-30T13:54:39+00:00" }, { "name": "symfony/security-core", - "version": "v7.4.4", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/security-core.git", - "reference": "958a70725a8d669bec6721f4cd318d209712e944" + "reference": "23e0cd6615661e33e53faf714bf6a130c2f75c25" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-core/zipball/958a70725a8d669bec6721f4cd318d209712e944", - "reference": "958a70725a8d669bec6721f4cd318d209712e944", + "url": "https://api.github.com/repos/symfony/security-core/zipball/23e0cd6615661e33e53faf714bf6a130c2f75c25", + "reference": "23e0cd6615661e33e53faf714bf6a130c2f75c25", "shasum": "" }, "require": { @@ -14481,7 +15537,7 @@ "description": "Symfony Security Component - Core Library", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-core/tree/v7.4.4" + "source": "https://github.com/symfony/security-core/tree/v7.4.8" }, "funding": [ { @@ -14501,20 +15557,20 @@ "type": "tidelift" } ], - "time": "2026-01-14T09:36:49+00:00" + "time": "2026-03-31T07:00:19+00:00" }, { "name": "symfony/security-csrf", - "version": "v7.4.6", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/security-csrf.git", - "reference": "d01adcd3141bec95e4cfd338f6b4482f1dd6a42b" + "reference": "16b3aa2f67d02fb0dbd013a8759bbe90daaa9c5d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-csrf/zipball/d01adcd3141bec95e4cfd338f6b4482f1dd6a42b", - "reference": "d01adcd3141bec95e4cfd338f6b4482f1dd6a42b", + "url": "https://api.github.com/repos/symfony/security-csrf/zipball/16b3aa2f67d02fb0dbd013a8759bbe90daaa9c5d", + "reference": "16b3aa2f67d02fb0dbd013a8759bbe90daaa9c5d", "shasum": "" }, "require": { @@ -14555,7 +15611,7 @@ "description": "Symfony Security Component - CSRF Library", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-csrf/tree/v7.4.6" + "source": "https://github.com/symfony/security-csrf/tree/v7.4.8" }, "funding": [ { @@ -14575,20 +15631,20 @@ "type": "tidelift" } ], - "time": "2026-02-11T16:03:16+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/security-http", - "version": "v7.4.6", + "version": "v7.4.9", "source": { "type": "git", "url": "https://github.com/symfony/security-http.git", - "reference": "52ce5ef5708900dcab9f55750cf81250a0ebba9f" + "reference": "a34991b13899de1f953df245395aa2196f9bc113" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-http/zipball/52ce5ef5708900dcab9f55750cf81250a0ebba9f", - "reference": "52ce5ef5708900dcab9f55750cf81250a0ebba9f", + "url": "https://api.github.com/repos/symfony/security-http/zipball/a34991b13899de1f953df245395aa2196f9bc113", + "reference": "a34991b13899de1f953df245395aa2196f9bc113", "shasum": "" }, "require": { @@ -14647,7 +15703,7 @@ "description": "Symfony Security Component - HTTP Integration", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-http/tree/v7.4.6" + "source": "https://github.com/symfony/security-http/tree/v7.4.9" }, "funding": [ { @@ -14667,20 +15723,20 @@ "type": "tidelift" } ], - "time": "2026-02-18T09:46:18+00:00" + "time": "2026-04-22T15:21:55+00:00" }, { "name": "symfony/serializer", - "version": "v7.4.6", + "version": "v7.4.10", "source": { "type": "git", "url": "https://github.com/symfony/serializer.git", - "reference": "83c3cbd6dcb96c1dbe197499a0714f8dceb0f274" + "reference": "268c5aa6c4bd675eddd89348e7ecac292a843ddd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/serializer/zipball/83c3cbd6dcb96c1dbe197499a0714f8dceb0f274", - "reference": "83c3cbd6dcb96c1dbe197499a0714f8dceb0f274", + "url": "https://api.github.com/repos/symfony/serializer/zipball/268c5aa6c4bd675eddd89348e7ecac292a843ddd", + "reference": "268c5aa6c4bd675eddd89348e7ecac292a843ddd", "shasum": "" }, "require": { @@ -14693,7 +15749,7 @@ "phpdocumentor/reflection-docblock": "<5.2|>=7", "phpdocumentor/type-resolver": "<1.5.1", "symfony/dependency-injection": "<6.4", - "symfony/property-access": "<6.4", + "symfony/property-access": "<6.4.31|>=7.0,<7.4.2|>=8.0,<8.0.2", "symfony/property-info": "<6.4", "symfony/type-info": "<7.2.5", "symfony/uid": "<6.4", @@ -14715,7 +15771,7 @@ "symfony/http-kernel": "^6.4|^7.0|^8.0", "symfony/messenger": "^6.4|^7.0|^8.0", "symfony/mime": "^6.4|^7.0|^8.0", - "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4.31|^7.4.2|^8.0.2", "symfony/property-info": "^6.4|^7.0|^8.0", "symfony/translation-contracts": "^2.5|^3", "symfony/type-info": "^7.2.5|^8.0", @@ -14751,7 +15807,7 @@ "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/serializer/tree/v7.4.6" + "source": "https://github.com/symfony/serializer/tree/v7.4.10" }, "funding": [ { @@ -14771,20 +15827,20 @@ "type": "tidelift" } ], - "time": "2026-02-25T16:50:00+00:00" + "time": "2026-05-03T13:03:28+00:00" }, { "name": "symfony/service-contracts", - "version": "v3.6.1", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", - "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", "shasum": "" }, "require": { @@ -14802,7 +15858,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -14838,7 +15894,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.0" }, "funding": [ { @@ -14858,20 +15914,20 @@ "type": "tidelift" } ], - "time": "2025-07-15T11:30:57+00:00" + "time": "2026-03-28T09:44:51+00:00" }, { "name": "symfony/stimulus-bundle", - "version": "v2.32.0", + "version": "v2.35.0", "source": { "type": "git", "url": "https://github.com/symfony/stimulus-bundle.git", - "reference": "dfbf6b443bb381cb611e06f64dc23603b614b575" + "reference": "05af0259f201dbbd15c103bea289989a4b483b5b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/stimulus-bundle/zipball/dfbf6b443bb381cb611e06f64dc23603b614b575", - "reference": "dfbf6b443bb381cb611e06f64dc23603b614b575", + "url": "https://api.github.com/repos/symfony/stimulus-bundle/zipball/05af0259f201dbbd15c103bea289989a4b483b5b", + "reference": "05af0259f201dbbd15c103bea289989a4b483b5b", "shasum": "" }, "require": { @@ -14911,7 +15967,7 @@ "symfony-ux" ], "support": { - "source": "https://github.com/symfony/stimulus-bundle/tree/v2.32.0" + "source": "https://github.com/symfony/stimulus-bundle/tree/v2.35.0" }, "funding": [ { @@ -14931,20 +15987,20 @@ "type": "tidelift" } ], - "time": "2025-12-02T07:12:06+00:00" + "time": "2026-03-22T22:21:50+00:00" }, { "name": "symfony/stopwatch", - "version": "v7.4.0", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/stopwatch.git", - "reference": "8a24af0a2e8a872fb745047180649b8418303084" + "reference": "70a852d72fec4d51efb1f48dcd968efcaf5ccb89" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/stopwatch/zipball/8a24af0a2e8a872fb745047180649b8418303084", - "reference": "8a24af0a2e8a872fb745047180649b8418303084", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/70a852d72fec4d51efb1f48dcd968efcaf5ccb89", + "reference": "70a852d72fec4d51efb1f48dcd968efcaf5ccb89", "shasum": "" }, "require": { @@ -14977,7 +16033,7 @@ "description": "Provides a way to profile code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/stopwatch/tree/v7.4.0" + "source": "https://github.com/symfony/stopwatch/tree/v7.4.8" }, "funding": [ { @@ -14997,20 +16053,20 @@ "type": "tidelift" } ], - "time": "2025-08-04T07:05:15+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/string", - "version": "v7.4.6", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "9f209231affa85aa930a5e46e6eb03381424b30b" + "reference": "114ac57257d75df748eda23dd003878080b8e688" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/9f209231affa85aa930a5e46e6eb03381424b30b", - "reference": "9f209231affa85aa930a5e46e6eb03381424b30b", + "url": "https://api.github.com/repos/symfony/string/zipball/114ac57257d75df748eda23dd003878080b8e688", + "reference": "114ac57257d75df748eda23dd003878080b8e688", "shasum": "" }, "require": { @@ -15068,7 +16124,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.4.6" + "source": "https://github.com/symfony/string/tree/v7.4.8" }, "funding": [ { @@ -15088,20 +16144,20 @@ "type": "tidelift" } ], - "time": "2026-02-09T09:33:46+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/translation", - "version": "v7.4.6", + "version": "v7.4.10", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "1888cf064399868af3784b9e043240f1d89d25ce" + "reference": "ada7578c30dd5feaa8259cff3e885069ea81ddde" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/1888cf064399868af3784b9e043240f1d89d25ce", - "reference": "1888cf064399868af3784b9e043240f1d89d25ce", + "url": "https://api.github.com/repos/symfony/translation/zipball/ada7578c30dd5feaa8259cff3e885069ea81ddde", + "reference": "ada7578c30dd5feaa8259cff3e885069ea81ddde", "shasum": "" }, "require": { @@ -15168,7 +16224,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v7.4.6" + "source": "https://github.com/symfony/translation/tree/v7.4.10" }, "funding": [ { @@ -15188,20 +16244,20 @@ "type": "tidelift" } ], - "time": "2026-02-17T07:53:42+00:00" + "time": "2026-05-06T11:19:24+00:00" }, { "name": "symfony/translation-contracts", - "version": "v3.6.1", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/translation-contracts.git", - "reference": "65a8bc82080447fae78373aa10f8d13b38338977" + "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/65a8bc82080447fae78373aa10f8d13b38338977", - "reference": "65a8bc82080447fae78373aa10f8d13b38338977", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/0ab302977a952b42fd51475c4ebac81f8da0a95d", + "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d", "shasum": "" }, "require": { @@ -15214,7 +16270,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -15250,7 +16306,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/translation-contracts/tree/v3.6.1" + "source": "https://github.com/symfony/translation-contracts/tree/v3.7.0" }, "funding": [ { @@ -15270,20 +16326,20 @@ "type": "tidelift" } ], - "time": "2025-07-15T13:41:35+00:00" + "time": "2026-01-05T13:30:16+00:00" }, { "name": "symfony/twig-bridge", - "version": "v7.4.6", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/twig-bridge.git", - "reference": "8903bc9a64cf624ffe522893f3626d5a0b97175c" + "reference": "ac43e7e59298ed1ce98c8d228b651d46e907d02c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/8903bc9a64cf624ffe522893f3626d5a0b97175c", - "reference": "8903bc9a64cf624ffe522893f3626d5a0b97175c", + "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/ac43e7e59298ed1ce98c8d228b651d46e907d02c", + "reference": "ac43e7e59298ed1ce98c8d228b651d46e907d02c", "shasum": "" }, "require": { @@ -15299,7 +16355,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", + "symfony/mime": "<6.4.36|>7,<7.4.8|>8.0,<8.0.8", "symfony/serializer": "<6.4", "symfony/translation": "<6.4", "symfony/workflow": "<6.4" @@ -15320,7 +16376,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|^7.0|^8.0", + "symfony/mime": "^6.4.36|^7.4.8|^8.0.8", "symfony/polyfill-intl-icu": "~1.0", "symfony/property-info": "^6.4|^7.0|^8.0", "symfony/routing": "^6.4|^7.0|^8.0", @@ -15365,7 +16421,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.6" + "source": "https://github.com/symfony/twig-bridge/tree/v7.4.8" }, "funding": [ { @@ -15385,20 +16441,20 @@ "type": "tidelift" } ], - "time": "2026-02-25T16:50:00+00:00" + "time": "2026-03-30T15:17:09+00:00" }, { "name": "symfony/twig-bundle", - "version": "v7.4.4", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/twig-bundle.git", - "reference": "e8829e02ff96a391ed0703bac9e7ff0537480b6b" + "reference": "ba1e06d7ff1ebb1d1799b6608d925f4eaba88d95" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/twig-bundle/zipball/e8829e02ff96a391ed0703bac9e7ff0537480b6b", - "reference": "e8829e02ff96a391ed0703bac9e7ff0537480b6b", + "url": "https://api.github.com/repos/symfony/twig-bundle/zipball/ba1e06d7ff1ebb1d1799b6608d925f4eaba88d95", + "reference": "ba1e06d7ff1ebb1d1799b6608d925f4eaba88d95", "shasum": "" }, "require": { @@ -15455,7 +16511,7 @@ "description": "Provides a tight integration of Twig into the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/twig-bundle/tree/v7.4.4" + "source": "https://github.com/symfony/twig-bundle/tree/v7.4.8" }, "funding": [ { @@ -15475,20 +16531,20 @@ "type": "tidelift" } ], - "time": "2026-01-06T12:34:24+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/type-info", - "version": "v7.4.6", + "version": "v7.4.9", "source": { "type": "git", "url": "https://github.com/symfony/type-info.git", - "reference": "4855ceea609b2c09e48ff76e12a97a3955531735" + "reference": "cafeedbf157b890e94ac5b83eaed85595106d5d6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/type-info/zipball/4855ceea609b2c09e48ff76e12a97a3955531735", - "reference": "4855ceea609b2c09e48ff76e12a97a3955531735", + "url": "https://api.github.com/repos/symfony/type-info/zipball/cafeedbf157b890e94ac5b83eaed85595106d5d6", + "reference": "cafeedbf157b890e94ac5b83eaed85595106d5d6", "shasum": "" }, "require": { @@ -15538,7 +16594,7 @@ "type" ], "support": { - "source": "https://github.com/symfony/type-info/tree/v7.4.6" + "source": "https://github.com/symfony/type-info/tree/v7.4.9" }, "funding": [ { @@ -15558,20 +16614,20 @@ "type": "tidelift" } ], - "time": "2026-02-17T14:00:31+00:00" + "time": "2026-04-22T15:21:55+00:00" }, { "name": "symfony/uid", - "version": "v7.4.4", + "version": "v7.4.9", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "7719ce8aba76be93dfe249192f1fbfa52c588e36" + "reference": "2676b524340abcfe4d6151ec698463cebafee439" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/7719ce8aba76be93dfe249192f1fbfa52c588e36", - "reference": "7719ce8aba76be93dfe249192f1fbfa52c588e36", + "url": "https://api.github.com/repos/symfony/uid/zipball/2676b524340abcfe4d6151ec698463cebafee439", + "reference": "2676b524340abcfe4d6151ec698463cebafee439", "shasum": "" }, "require": { @@ -15616,7 +16672,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v7.4.4" + "source": "https://github.com/symfony/uid/tree/v7.4.9" }, "funding": [ { @@ -15636,20 +16692,20 @@ "type": "tidelift" } ], - "time": "2026-01-03T23:30:35+00:00" + "time": "2026-04-30T15:19:22+00:00" }, { "name": "symfony/ux-translator", - "version": "v2.32.0", + "version": "v2.35.0", "source": { "type": "git", "url": "https://github.com/symfony/ux-translator.git", - "reference": "fde719a87903d9bc6fe60abf7581c1143532c918" + "reference": "5a56d25237393e865e3df94a39d2c8f0ce94b50c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/ux-translator/zipball/fde719a87903d9bc6fe60abf7581c1143532c918", - "reference": "fde719a87903d9bc6fe60abf7581c1143532c918", + "url": "https://api.github.com/repos/symfony/ux-translator/zipball/5a56d25237393e865e3df94a39d2c8f0ce94b50c", + "reference": "5a56d25237393e865e3df94a39d2c8f0ce94b50c", "shasum": "" }, "require": { @@ -15697,7 +16753,7 @@ "symfony-ux" ], "support": { - "source": "https://github.com/symfony/ux-translator/tree/v2.32.0" + "source": "https://github.com/symfony/ux-translator/tree/v2.35.0" }, "funding": [ { @@ -15717,25 +16773,25 @@ "type": "tidelift" } ], - "time": "2025-12-26T17:37:51+00:00" + "time": "2026-03-22T22:21:50+00:00" }, { "name": "symfony/ux-turbo", - "version": "v2.32.0", + "version": "v2.35.0", "source": { "type": "git", "url": "https://github.com/symfony/ux-turbo.git", - "reference": "0deaa8abef20933d11f8bbe9899d950b4333ca1e" + "reference": "4309a4299f5f1b9b7ce4c13ed6d1b77a5472c216" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/ux-turbo/zipball/0deaa8abef20933d11f8bbe9899d950b4333ca1e", - "reference": "0deaa8abef20933d11f8bbe9899d950b4333ca1e", + "url": "https://api.github.com/repos/symfony/ux-turbo/zipball/4309a4299f5f1b9b7ce4c13ed6d1b77a5472c216", + "reference": "4309a4299f5f1b9b7ce4c13ed6d1b77a5472c216", "shasum": "" }, "require": { "php": ">=8.1", - "symfony/stimulus-bundle": "^2.9.1" + "symfony/stimulus-bundle": "^2.9.1|^3.0" }, "conflict": { "symfony/flex": "<1.13" @@ -15760,7 +16816,7 @@ "symfony/security-core": "^5.4|^6.0|^7.0|^8.0", "symfony/stopwatch": "^5.4|^6.0|^7.0|^8.0", "symfony/twig-bundle": "^6.4|^7.0|^8.0", - "symfony/ux-twig-component": "^2.21", + "symfony/ux-twig-component": "^2.21|^3.0", "symfony/web-profiler-bundle": "^5.4|^6.0|^7.0|^8.0" }, "type": "symfony-bundle", @@ -15800,7 +16856,7 @@ "turbo-stream" ], "support": { - "source": "https://github.com/symfony/ux-turbo/tree/v2.32.0" + "source": "https://github.com/symfony/ux-turbo/tree/v2.35.0" }, "funding": [ { @@ -15820,20 +16876,20 @@ "type": "tidelift" } ], - "time": "2025-12-17T06:03:34+00:00" + "time": "2026-04-03T05:13:59+00:00" }, { "name": "symfony/validator", - "version": "v7.4.6", + "version": "v7.4.10", "source": { "type": "git", "url": "https://github.com/symfony/validator.git", - "reference": "a1ceaf285712ed8034819a76b5fbba23eaf3e54d" + "reference": "c76458623af9a3fe3b2e5b09b36453f334c2a361" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/validator/zipball/a1ceaf285712ed8034819a76b5fbba23eaf3e54d", - "reference": "a1ceaf285712ed8034819a76b5fbba23eaf3e54d", + "url": "https://api.github.com/repos/symfony/validator/zipball/c76458623af9a3fe3b2e5b09b36453f334c2a361", + "reference": "c76458623af9a3fe3b2e5b09b36453f334c2a361", "shasum": "" }, "require": { @@ -15904,7 +16960,7 @@ "description": "Provides tools to validate values", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/validator/tree/v7.4.6" + "source": "https://github.com/symfony/validator/tree/v7.4.10" }, "funding": [ { @@ -15924,20 +16980,20 @@ "type": "tidelift" } ], - "time": "2026-02-25T16:50:00+00:00" + "time": "2026-05-05T15:30:56+00:00" }, { "name": "symfony/var-dumper", - "version": "v7.4.6", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "045321c440ac18347b136c63d2e9bf28a2dc0291" + "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/045321c440ac18347b136c63d2e9bf28a2dc0291", - "reference": "045321c440ac18347b136c63d2e9bf28a2dc0291", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/9510c3966f749a1d1ff0059e1eabef6cc621e7fd", + "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd", "shasum": "" }, "require": { @@ -15991,7 +17047,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.4.6" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.8" }, "funding": [ { @@ -16011,20 +17067,20 @@ "type": "tidelift" } ], - "time": "2026-02-15T10:53:20+00:00" + "time": "2026-03-30T13:44:50+00:00" }, { "name": "symfony/var-exporter", - "version": "v7.4.0", + "version": "v7.4.9", "source": { "type": "git", "url": "https://github.com/symfony/var-exporter.git", - "reference": "03a60f169c79a28513a78c967316fbc8bf17816f" + "reference": "22e03a49c95ef054a43601cd159b222bfab1c701" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-exporter/zipball/03a60f169c79a28513a78c967316fbc8bf17816f", - "reference": "03a60f169c79a28513a78c967316fbc8bf17816f", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/22e03a49c95ef054a43601cd159b222bfab1c701", + "reference": "22e03a49c95ef054a43601cd159b222bfab1c701", "shasum": "" }, "require": { @@ -16072,7 +17128,7 @@ "serialize" ], "support": { - "source": "https://github.com/symfony/var-exporter/tree/v7.4.0" + "source": "https://github.com/symfony/var-exporter/tree/v7.4.9" }, "funding": [ { @@ -16092,20 +17148,20 @@ "type": "tidelift" } ], - "time": "2025-09-11T10:15:23+00:00" + "time": "2026-04-18T13:18:21+00:00" }, { "name": "symfony/web-link", - "version": "v7.4.4", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/web-link.git", - "reference": "9ff1f19069e3d2d341d60729392a4a6dfc45052a" + "reference": "0711009963009e7d6d59149327f3ad633ee3fe25" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/web-link/zipball/9ff1f19069e3d2d341d60729392a4a6dfc45052a", - "reference": "9ff1f19069e3d2d341d60729392a4a6dfc45052a", + "url": "https://api.github.com/repos/symfony/web-link/zipball/0711009963009e7d6d59149327f3ad633ee3fe25", + "reference": "0711009963009e7d6d59149327f3ad633ee3fe25", "shasum": "" }, "require": { @@ -16159,7 +17215,7 @@ "push" ], "support": { - "source": "https://github.com/symfony/web-link/tree/v7.4.4" + "source": "https://github.com/symfony/web-link/tree/v7.4.8" }, "funding": [ { @@ -16179,7 +17235,7 @@ "type": "tidelift" } ], - "time": "2026-01-01T22:13:48+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/webpack-encore-bundle", @@ -16259,16 +17315,16 @@ }, { "name": "symfony/yaml", - "version": "v7.4.6", + "version": "v7.4.10", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "58751048de17bae71c5aa0d13cb19d79bca26391" + "reference": "c660d6538545a3e8e65a5621ee3d7a6d352892c7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/58751048de17bae71c5aa0d13cb19d79bca26391", - "reference": "58751048de17bae71c5aa0d13cb19d79bca26391", + "url": "https://api.github.com/repos/symfony/yaml/zipball/c660d6538545a3e8e65a5621ee3d7a6d352892c7", + "reference": "c660d6538545a3e8e65a5621ee3d7a6d352892c7", "shasum": "" }, "require": { @@ -16311,7 +17367,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.4.6" + "source": "https://github.com/symfony/yaml/tree/v7.4.10" }, "funding": [ { @@ -16331,29 +17387,29 @@ "type": "tidelift" } ], - "time": "2026-02-09T09:33:46+00:00" + "time": "2026-05-05T08:01:55+00:00" }, { "name": "symplify/easy-coding-standard", - "version": "13.0.4", + "version": "13.1.3", "source": { "type": "git", - "url": "https://github.com/easy-coding-standard/easy-coding-standard.git", - "reference": "5c7e7a07e5d6a98b9dd2e6fc0a9155efb7c166c8" + "url": "https://github.com/easy-coding-standard/ecs.git", + "reference": "d894d088d7ebb9326f9eed28bf251481c813b89f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/easy-coding-standard/easy-coding-standard/zipball/5c7e7a07e5d6a98b9dd2e6fc0a9155efb7c166c8", - "reference": "5c7e7a07e5d6a98b9dd2e6fc0a9155efb7c166c8", + "url": "https://api.github.com/repos/easy-coding-standard/ecs/zipball/d894d088d7ebb9326f9eed28bf251481c813b89f", + "reference": "d894d088d7ebb9326f9eed28bf251481c813b89f", "shasum": "" }, "require": { "php": ">=7.2" }, "conflict": { - "friendsofphp/php-cs-fixer": "<3.92.4", + "friendsofphp/php-cs-fixer": "<3.95.1", "phpcsstandards/php_codesniffer": "<4.0.1", - "symplify/coding-standard": "<12.1" + "symplify/coding-standard": "<13.0" }, "suggest": { "ext-dom": "Needed to support checkstyle output format in class CheckstyleOutputFormatter" @@ -16379,33 +17435,22 @@ "static analysis" ], "support": { - "issues": "https://github.com/easy-coding-standard/easy-coding-standard/issues", - "source": "https://github.com/easy-coding-standard/easy-coding-standard/tree/13.0.4" + "source": "https://github.com/easy-coding-standard/ecs/tree/13.1.3" }, - "funding": [ - { - "url": "https://www.paypal.me/rectorphp", - "type": "custom" - }, - { - "url": "https://github.com/tomasvotruba", - "type": "github" - } - ], - "time": "2026-01-05T09:10:04+00:00" + "time": "2026-05-04T21:45:57+00:00" }, { "name": "tecnickcom/tc-lib-barcode", - "version": "2.4.27", + "version": "2.4.39", "source": { "type": "git", "url": "https://github.com/tecnickcom/tc-lib-barcode.git", - "reference": "8d754e2cb6001114ff7669982739245078346d8f" + "reference": "11886fb5a44ec0f6e77302439e9ebf55034383fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/tecnickcom/tc-lib-barcode/zipball/8d754e2cb6001114ff7669982739245078346d8f", - "reference": "8d754e2cb6001114ff7669982739245078346d8f", + "url": "https://api.github.com/repos/tecnickcom/tc-lib-barcode/zipball/11886fb5a44ec0f6e77302439e9ebf55034383fa", + "reference": "11886fb5a44ec0f6e77302439e9ebf55034383fa", "shasum": "" }, "require": { @@ -16414,14 +17459,14 @@ "ext-gd": "*", "ext-pcre": "*", "php": ">=8.1", - "tecnickcom/tc-lib-color": "^2.3" + "tecnickcom/tc-lib-color": "^2.5" }, "require-dev": { - "pdepend/pdepend": "2.16.2", + "pdepend/pdepend": "^2.16", "phpcompatibility/php-compatibility": "^10.0.0@dev", - "phpmd/phpmd": "2.15.0", - "phpunit/phpunit": "13.0.5 || 12.5.14 || 11.5.55 || 10.5.63", - "squizlabs/php_codesniffer": "4.0.1" + "phpmd/phpmd": "^2.15", + "phpunit/phpunit": "^13.1 || ^12.5 || ^11.5 || ^10.5", + "squizlabs/php_codesniffer": "^4.0" }, "type": "library", "autoload": { @@ -16441,7 +17486,7 @@ } ], "description": "PHP library to generate linear and bidimensional barcodes", - "homepage": "http://www.tecnick.com", + "homepage": "https://tcpdf.org", "keywords": [ "3 of 9", "ANSI MH10.8M-1983", @@ -16485,28 +17530,28 @@ ], "support": { "issues": "https://github.com/tecnickcom/tc-lib-barcode/issues", - "source": "https://github.com/tecnickcom/tc-lib-barcode/tree/2.4.27" + "source": "https://github.com/tecnickcom/tc-lib-barcode" }, "funding": [ { - "url": "https://www.paypal.com/donate/?hosted_button_id=NZUEC5XS8MFBJ", - "type": "custom" + "url": "https://github.com/sponsors/tecnickcom", + "type": "github" } ], - "time": "2026-02-28T10:33:16+00:00" + "time": "2026-05-01T19:04:12+00:00" }, { "name": "tecnickcom/tc-lib-color", - "version": "2.3.9", + "version": "2.5.3", "source": { "type": "git", "url": "https://github.com/tecnickcom/tc-lib-color.git", - "reference": "7eed5344ed57a3d55b56bebbd1329bd0e8fe597a" + "reference": "136d522f1640723e490b79171e910e647403d971" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/tecnickcom/tc-lib-color/zipball/7eed5344ed57a3d55b56bebbd1329bd0e8fe597a", - "reference": "7eed5344ed57a3d55b56bebbd1329bd0e8fe597a", + "url": "https://api.github.com/repos/tecnickcom/tc-lib-color/zipball/136d522f1640723e490b79171e910e647403d971", + "reference": "136d522f1640723e490b79171e910e647403d971", "shasum": "" }, "require": { @@ -16514,11 +17559,11 @@ "php": ">=8.1" }, "require-dev": { - "pdepend/pdepend": "2.16.2", + "pdepend/pdepend": "^2.16", "phpcompatibility/php-compatibility": "^10.0.0@dev", - "phpmd/phpmd": "2.15.0", - "phpunit/phpunit": "13.0.5 || 12.5.14 || 11.5.55 || 10.5.63", - "squizlabs/php_codesniffer": "4.0.1" + "phpmd/phpmd": "^2.15", + "phpunit/phpunit": "^13.1 || ^12.5 || ^11.5 || ^10.5", + "squizlabs/php_codesniffer": "^4.0" }, "type": "library", "autoload": { @@ -16538,7 +17583,7 @@ } ], "description": "PHP library to manipulate various color representations", - "homepage": "http://www.tecnick.com", + "homepage": "https://tcpdf.org", "keywords": [ "cmyk", "color", @@ -16555,15 +17600,15 @@ ], "support": { "issues": "https://github.com/tecnickcom/tc-lib-color/issues", - "source": "https://github.com/tecnickcom/tc-lib-color/tree/2.3.9" + "source": "https://github.com/tecnickcom/tc-lib-color" }, "funding": [ { - "url": "https://www.paypal.com/donate/?hosted_button_id=NZUEC5XS8MFBJ", - "type": "custom" + "url": "https://github.com/sponsors/tecnickcom", + "type": "github" } ], - "time": "2026-02-23T20:00:30+00:00" + "time": "2026-05-01T19:02:25+00:00" }, { "name": "thecodingmachine/safe", @@ -16813,7 +17858,7 @@ }, { "name": "twig/cssinliner-extra", - "version": "v3.23.0", + "version": "v3.24.0", "source": { "type": "git", "url": "https://github.com/twigphp/cssinliner-extra.git", @@ -16866,7 +17911,7 @@ "twig" ], "support": { - "source": "https://github.com/twigphp/cssinliner-extra/tree/v3.23.0" + "source": "https://github.com/twigphp/cssinliner-extra/tree/v3.24.0" }, "funding": [ { @@ -16882,16 +17927,16 @@ }, { "name": "twig/extra-bundle", - "version": "v3.23.0", + "version": "v3.24.0", "source": { "type": "git", "url": "https://github.com/twigphp/twig-extra-bundle.git", - "reference": "7a27e784dc56eddfef5e9295829b290ce06f1682" + "reference": "6a621fcb1f28aa9ea7b34a99047ae0cdf5b834c9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/twig-extra-bundle/zipball/7a27e784dc56eddfef5e9295829b290ce06f1682", - "reference": "7a27e784dc56eddfef5e9295829b290ce06f1682", + "url": "https://api.github.com/repos/twigphp/twig-extra-bundle/zipball/6a621fcb1f28aa9ea7b34a99047ae0cdf5b834c9", + "reference": "6a621fcb1f28aa9ea7b34a99047ae0cdf5b834c9", "shasum": "" }, "require": { @@ -16940,7 +17985,7 @@ "twig" ], "support": { - "source": "https://github.com/twigphp/twig-extra-bundle/tree/v3.23.0" + "source": "https://github.com/twigphp/twig-extra-bundle/tree/v3.24.0" }, "funding": [ { @@ -16952,20 +17997,20 @@ "type": "tidelift" } ], - "time": "2025-12-18T20:46:15+00:00" + "time": "2026-02-07T08:07:38+00:00" }, { "name": "twig/html-extra", - "version": "v3.23.0", + "version": "v3.24.0", "source": { "type": "git", "url": "https://github.com/twigphp/html-extra.git", - "reference": "2ef1d0ccaa06d4f4405b330fe6c4b6f7b50fbbc3" + "reference": "313900fb98b371b006a55b1a29241a192634be13" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/html-extra/zipball/2ef1d0ccaa06d4f4405b330fe6c4b6f7b50fbbc3", - "reference": "2ef1d0ccaa06d4f4405b330fe6c4b6f7b50fbbc3", + "url": "https://api.github.com/repos/twigphp/html-extra/zipball/313900fb98b371b006a55b1a29241a192634be13", + "reference": "313900fb98b371b006a55b1a29241a192634be13", "shasum": "" }, "require": { @@ -17008,7 +18053,7 @@ "twig" ], "support": { - "source": "https://github.com/twigphp/html-extra/tree/v3.23.0" + "source": "https://github.com/twigphp/html-extra/tree/v3.24.0" }, "funding": [ { @@ -17020,11 +18065,11 @@ "type": "tidelift" } ], - "time": "2025-12-02T14:45:16+00:00" + "time": "2026-03-17T07:24:08+00:00" }, { "name": "twig/inky-extra", - "version": "v3.23.0", + "version": "v3.24.0", "source": { "type": "git", "url": "https://github.com/twigphp/inky-extra.git", @@ -17078,7 +18123,7 @@ "twig" ], "support": { - "source": "https://github.com/twigphp/inky-extra/tree/v3.23.0" + "source": "https://github.com/twigphp/inky-extra/tree/v3.24.0" }, "funding": [ { @@ -17094,7 +18139,7 @@ }, { "name": "twig/intl-extra", - "version": "v3.23.0", + "version": "v3.24.0", "source": { "type": "git", "url": "https://github.com/twigphp/intl-extra.git", @@ -17142,7 +18187,7 @@ "twig" ], "support": { - "source": "https://github.com/twigphp/intl-extra/tree/v3.23.0" + "source": "https://github.com/twigphp/intl-extra/tree/v3.24.0" }, "funding": [ { @@ -17158,16 +18203,16 @@ }, { "name": "twig/markdown-extra", - "version": "v3.23.0", + "version": "v3.24.0", "source": { "type": "git", "url": "https://github.com/twigphp/markdown-extra.git", - "reference": "faf069b259e2d3930c73c2f53e2dec8440bd90a2" + "reference": "67a11120356e034a5bbc70c5b9b9a4d0f31ca06e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/markdown-extra/zipball/faf069b259e2d3930c73c2f53e2dec8440bd90a2", - "reference": "faf069b259e2d3930c73c2f53e2dec8440bd90a2", + "url": "https://api.github.com/repos/twigphp/markdown-extra/zipball/67a11120356e034a5bbc70c5b9b9a4d0f31ca06e", + "reference": "67a11120356e034a5bbc70c5b9b9a4d0f31ca06e", "shasum": "" }, "require": { @@ -17214,7 +18259,7 @@ "twig" ], "support": { - "source": "https://github.com/twigphp/markdown-extra/tree/v3.23.0" + "source": "https://github.com/twigphp/markdown-extra/tree/v3.24.0" }, "funding": [ { @@ -17226,11 +18271,11 @@ "type": "tidelift" } ], - "time": "2025-12-02T14:45:16+00:00" + "time": "2026-02-07T08:07:38+00:00" }, { "name": "twig/string-extra", - "version": "v3.23.0", + "version": "v3.24.0", "source": { "type": "git", "url": "https://github.com/twigphp/string-extra.git", @@ -17281,7 +18326,7 @@ "unicode" ], "support": { - "source": "https://github.com/twigphp/string-extra/tree/v3.23.0" + "source": "https://github.com/twigphp/string-extra/tree/v3.24.0" }, "funding": [ { @@ -17297,16 +18342,16 @@ }, { "name": "twig/twig", - "version": "v3.23.0", + "version": "v3.24.0", "source": { "type": "git", "url": "https://github.com/twigphp/Twig.git", - "reference": "a64dc5d2cc7d6cafb9347f6cd802d0d06d0351c9" + "reference": "a6769aefb305efef849dc25c9fd1653358c148f0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/Twig/zipball/a64dc5d2cc7d6cafb9347f6cd802d0d06d0351c9", - "reference": "a64dc5d2cc7d6cafb9347f6cd802d0d06d0351c9", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/a6769aefb305efef849dc25c9fd1653358c148f0", + "reference": "a6769aefb305efef849dc25c9fd1653358c148f0", "shasum": "" }, "require": { @@ -17316,7 +18361,8 @@ "symfony/polyfill-mbstring": "^1.3" }, "require-dev": { - "phpstan/phpstan": "^2.0", + "php-cs-fixer/shim": "^3.0@stable", + "phpstan/phpstan": "^2.0@stable", "psr/container": "^1.0|^2.0", "symfony/phpunit-bridge": "^5.4.9|^6.4|^7.0" }, @@ -17360,7 +18406,7 @@ ], "support": { "issues": "https://github.com/twigphp/Twig/issues", - "source": "https://github.com/twigphp/Twig/tree/v3.23.0" + "source": "https://github.com/twigphp/Twig/tree/v3.24.0" }, "funding": [ { @@ -17372,7 +18418,7 @@ "type": "tidelift" } ], - "time": "2026-01-23T21:00:41+00:00" + "time": "2026-03-17T21:31:11+00:00" }, { "name": "ua-parser/uap-php", @@ -17439,20 +18485,20 @@ }, { "name": "web-auth/cose-lib", - "version": "4.5.0", + "version": "4.5.2", "source": { "type": "git", "url": "https://github.com/web-auth/cose-lib.git", - "reference": "5adac6fe126994a3ee17ed9950efb4947ab132a9" + "reference": "5b38660f90070a8e45f3dbc9528ade3b608dd77d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/web-auth/cose-lib/zipball/5adac6fe126994a3ee17ed9950efb4947ab132a9", - "reference": "5adac6fe126994a3ee17ed9950efb4947ab132a9", + "url": "https://api.github.com/repos/web-auth/cose-lib/zipball/5b38660f90070a8e45f3dbc9528ade3b608dd77d", + "reference": "5b38660f90070a8e45f3dbc9528ade3b608dd77d", "shasum": "" }, "require": { - "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14", + "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17", "ext-json": "*", "ext-openssl": "*", "php": ">=8.1", @@ -17494,7 +18540,7 @@ ], "support": { "issues": "https://github.com/web-auth/cose-lib/issues", - "source": "https://github.com/web-auth/cose-lib/tree/4.5.0" + "source": "https://github.com/web-auth/cose-lib/tree/4.5.2" }, "funding": [ { @@ -17506,20 +18552,20 @@ "type": "patreon" } ], - "time": "2026-01-03T14:43:18+00:00" + "time": "2026-05-03T09:49:50+00:00" }, { "name": "web-auth/webauthn-lib", - "version": "5.2.3", + "version": "5.3.2", "source": { "type": "git", "url": "https://github.com/web-auth/webauthn-lib.git", - "reference": "8782f575032fedc36e2eb27c39c736054e2b6867" + "reference": "a272f254c056fb3d6c80a4801d3c7c5fedc6a08d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/8782f575032fedc36e2eb27c39c736054e2b6867", - "reference": "8782f575032fedc36e2eb27c39c736054e2b6867", + "url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/a272f254c056fb3d6c80a4801d3c7c5fedc6a08d", + "reference": "a272f254c056fb3d6c80a4801d3c7c5fedc6a08d", "shasum": "" }, "require": { @@ -17527,18 +18573,18 @@ "ext-openssl": "*", "paragonie/constant_time_encoding": "^2.6|^3.0", "php": ">=8.2", - "phpdocumentor/reflection-docblock": "^5.3", + "phpdocumentor/reflection-docblock": "^5.3|^6.0", "psr/clock": "^1.0", "psr/event-dispatcher": "^1.0", "psr/log": "^1.0|^2.0|^3.0", "spomky-labs/cbor-php": "^3.0", "spomky-labs/pki-framework": "^1.0", - "symfony/clock": "^6.4|^7.0", + "symfony/clock": "^6.4|^7.0|^8.0", "symfony/deprecation-contracts": "^3.2", - "symfony/property-access": "^6.4|^7.0", - "symfony/property-info": "^6.4|^7.0", - "symfony/serializer": "^6.4|^7.0", - "symfony/uid": "^6.4|^7.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", "web-auth/cose-lib": "^4.2.3" }, "suggest": { @@ -17580,7 +18626,7 @@ "webauthn" ], "support": { - "source": "https://github.com/web-auth/webauthn-lib/tree/5.2.3" + "source": "https://github.com/web-auth/webauthn-lib/tree/5.3.2" }, "funding": [ { @@ -17592,34 +18638,35 @@ "type": "patreon" } ], - "time": "2025-12-20T10:54:02+00:00" + "time": "2026-05-01T12:14:37+00:00" }, { "name": "web-auth/webauthn-symfony-bundle", - "version": "5.2.3", + "version": "5.3.2", "source": { "type": "git", "url": "https://github.com/web-auth/webauthn-symfony-bundle.git", - "reference": "91f0aff70703e20d84251c83e238da1f8fc53b24" + "reference": "1d20af98b50810e8776c52b671201b6bb73ea981" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/web-auth/webauthn-symfony-bundle/zipball/91f0aff70703e20d84251c83e238da1f8fc53b24", - "reference": "91f0aff70703e20d84251c83e238da1f8fc53b24", + "url": "https://api.github.com/repos/web-auth/webauthn-symfony-bundle/zipball/1d20af98b50810e8776c52b671201b6bb73ea981", + "reference": "1d20af98b50810e8776c52b671201b6bb73ea981", "shasum": "" }, "require": { "php": ">=8.2", "psr/event-dispatcher": "^1.0", - "symfony/config": "^6.4|^7.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/framework-bundle": "^6.4|^7.0", - "symfony/http-client": "^6.4|^7.0", - "symfony/security-bundle": "^6.4|^7.0", - "symfony/security-core": "^6.4|^7.0", - "symfony/security-http": "^6.4|^7.0", - "symfony/serializer": "^6.4|^7.0", - "symfony/validator": "^6.4|^7.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/security-bundle": "^6.4|^7.0|^8.0", + "symfony/security-core": "^6.4|^7.0|^8.0", + "symfony/security-http": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^3.0", + "symfony/validator": "^6.4|^7.0|^8.0", "web-auth/webauthn-lib": "self.version" }, "suggest": { @@ -17662,7 +18709,7 @@ "webauthn" ], "support": { - "source": "https://github.com/web-auth/webauthn-symfony-bundle/tree/5.2.3" + "source": "https://github.com/web-auth/webauthn-symfony-bundle/tree/5.3.2" }, "funding": [ { @@ -17674,20 +18721,20 @@ "type": "patreon" } ], - "time": "2025-12-20T10:20:41+00:00" + "time": "2026-05-04T08:08:16+00:00" }, { "name": "webmozart/assert", - "version": "2.1.6", + "version": "2.3.0", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "ff31ad6efc62e66e518fbab1cde3453d389bcdc8" + "reference": "eb0d790f735ba6cff25c683a85a1da0eadeff9e4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/ff31ad6efc62e66e518fbab1cde3453d389bcdc8", - "reference": "ff31ad6efc62e66e518fbab1cde3453d389bcdc8", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/eb0d790f735ba6cff25c683a85a1da0eadeff9e4", + "reference": "eb0d790f735ba6cff25c683a85a1da0eadeff9e4", "shasum": "" }, "require": { @@ -17734,9 +18781,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/2.1.6" + "source": "https://github.com/webmozarts/assert/tree/2.3.0" }, - "time": "2026-02-27T10:28:38+00:00" + "time": "2026-04-11T10:33:05+00:00" }, { "name": "willdurand/negotiation", @@ -17953,29 +19000,29 @@ }, { "name": "ekino/phpstan-banned-code", - "version": "v3.0.0", + "version": "v3.2.0", "source": { "type": "git", "url": "https://github.com/ekino/phpstan-banned-code.git", - "reference": "27122aa1783d6521e500c0c397c53244cfbde26f" + "reference": "3356fb9dae03c8759a61fee39dab4728dcc16d74" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ekino/phpstan-banned-code/zipball/27122aa1783d6521e500c0c397c53244cfbde26f", - "reference": "27122aa1783d6521e500c0c397c53244cfbde26f", + "url": "https://api.github.com/repos/ekino/phpstan-banned-code/zipball/3356fb9dae03c8759a61fee39dab4728dcc16d74", + "reference": "3356fb9dae03c8759a61fee39dab4728dcc16d74", "shasum": "" }, "require": { - "php": "^8.1", + "php": "^8.2", "phpstan/phpstan": "^2.0" }, "require-dev": { "ergebnis/composer-normalize": "^2.6", "friendsofphp/php-cs-fixer": "^3.0", - "nikic/php-parser": "^4.3", + "nikic/php-parser": "^5.4", "phpstan/phpstan-phpunit": "^2.0", - "phpunit/phpunit": "^9.5", - "symfony/var-dumper": "^5.0" + "phpunit/phpunit": "^10.5", + "symfony/var-dumper": "^6.4" }, "type": "phpstan-extension", "extra": { @@ -18013,9 +19060,9 @@ ], "support": { "issues": "https://github.com/ekino/phpstan-banned-code/issues", - "source": "https://github.com/ekino/phpstan-banned-code/tree/v3.0.0" + "source": "https://github.com/ekino/phpstan-banned-code/tree/v3.2.0" }, - "time": "2024-11-13T09:57:22+00:00" + "time": "2026-03-13T12:47:55+00:00" }, { "name": "jbtronics/translation-editor-bundle", @@ -18373,11 +19420,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.40", + "version": "2.1.54", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9b2c7aeb83a75d8680ea5e7c9b7fca88052b766b", - "reference": "9b2c7aeb83a75d8680ea5e7c9b7fca88052b766b", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/8be50c3992107dc837b17da4d140fbbdf9a5c5bd", + "reference": "8be50c3992107dc837b17da4d140fbbdf9a5c5bd", "shasum": "" }, "require": { @@ -18422,20 +19469,20 @@ "type": "github" } ], - "time": "2026-02-23T15:04:35+00:00" + "time": "2026-04-29T13:31:09+00:00" }, { "name": "phpstan/phpstan-doctrine", - "version": "2.0.18", + "version": "2.0.22", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-doctrine.git", - "reference": "44a216a5cd9fe52be489dcf1e2d565c473daa1ca" + "reference": "e87516b034749432d51653c0147e053e476e8c53" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-doctrine/zipball/44a216a5cd9fe52be489dcf1e2d565c473daa1ca", - "reference": "44a216a5cd9fe52be489dcf1e2d565c473daa1ca", + "url": "https://api.github.com/repos/phpstan/phpstan-doctrine/zipball/e87516b034749432d51653c0147e053e476e8c53", + "reference": "e87516b034749432d51653c0147e053e476e8c53", "shasum": "" }, "require": { @@ -18469,6 +19516,7 @@ "phpstan/phpstan-strict-rules": "^2.0", "phpunit/phpunit": "^9.6.20", "ramsey/uuid": "^4.2", + "shipmonk/name-collision-detector": "^2.1", "symfony/cache": "^5.4", "symfony/uid": "^5.4 || ^6.4 || ^7.3" }, @@ -18496,22 +19544,22 @@ ], "support": { "issues": "https://github.com/phpstan/phpstan-doctrine/issues", - "source": "https://github.com/phpstan/phpstan-doctrine/tree/2.0.18" + "source": "https://github.com/phpstan/phpstan-doctrine/tree/2.0.22" }, - "time": "2026-02-24T10:01:00+00:00" + "time": "2026-05-09T08:10:48+00:00" }, { "name": "phpstan/phpstan-strict-rules", - "version": "2.0.10", + "version": "2.0.11", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-strict-rules.git", - "reference": "1aba28b697c1e3b6bbec8a1725f8b11b6d3e5a5f" + "reference": "9b000a578b85b32945b358b172c7b20e91189024" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-strict-rules/zipball/1aba28b697c1e3b6bbec8a1725f8b11b6d3e5a5f", - "reference": "1aba28b697c1e3b6bbec8a1725f8b11b6d3e5a5f", + "url": "https://api.github.com/repos/phpstan/phpstan-strict-rules/zipball/9b000a578b85b32945b358b172c7b20e91189024", + "reference": "9b000a578b85b32945b358b172c7b20e91189024", "shasum": "" }, "require": { @@ -18547,22 +19595,22 @@ ], "support": { "issues": "https://github.com/phpstan/phpstan-strict-rules/issues", - "source": "https://github.com/phpstan/phpstan-strict-rules/tree/2.0.10" + "source": "https://github.com/phpstan/phpstan-strict-rules/tree/2.0.11" }, - "time": "2026-02-11T14:17:32+00:00" + "time": "2026-05-02T06:54:10+00:00" }, { "name": "phpstan/phpstan-symfony", - "version": "2.0.15", + "version": "2.0.17", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-symfony.git", - "reference": "9b85ab476969b87bbe2253b69e265a9359b2f395" + "reference": "fdd0cb5f08d1980c612d6f259d825ea644ed03f4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/9b85ab476969b87bbe2253b69e265a9359b2f395", - "reference": "9b85ab476969b87bbe2253b69e265a9359b2f395", + "url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/fdd0cb5f08d1980c612d6f259d825ea644ed03f4", + "reference": "fdd0cb5f08d1980c612d6f259d825ea644ed03f4", "shasum": "" }, "require": { @@ -18621,9 +19669,9 @@ ], "support": { "issues": "https://github.com/phpstan/phpstan-symfony/issues", - "source": "https://github.com/phpstan/phpstan-symfony/tree/2.0.15" + "source": "https://github.com/phpstan/phpstan-symfony/tree/2.0.17" }, - "time": "2026-02-26T10:15:59+00:00" + "time": "2026-05-10T08:14:07+00:00" }, { "name": "phpunit/php-code-coverage", @@ -19084,21 +20132,21 @@ }, { "name": "rector/rector", - "version": "2.3.8", + "version": "2.4.2", "source": { "type": "git", "url": "https://github.com/rectorphp/rector.git", - "reference": "bbd37aedd8df749916cffa2a947cfc4714d1ba2c" + "reference": "e645b6463c6a88ea5b44b17d3387d35a912c7946" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/bbd37aedd8df749916cffa2a947cfc4714d1ba2c", - "reference": "bbd37aedd8df749916cffa2a947cfc4714d1ba2c", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/e645b6463c6a88ea5b44b17d3387d35a912c7946", + "reference": "e645b6463c6a88ea5b44b17d3387d35a912c7946", "shasum": "" }, "require": { "php": "^7.4|^8.0", - "phpstan/phpstan": "^2.1.38" + "phpstan/phpstan": "^2.1.48" }, "conflict": { "rector/rector-doctrine": "*", @@ -19132,7 +20180,7 @@ ], "support": { "issues": "https://github.com/rectorphp/rector/issues", - "source": "https://github.com/rectorphp/rector/tree/2.3.8" + "source": "https://github.com/rectorphp/rector/tree/2.4.2" }, "funding": [ { @@ -19140,7 +20188,7 @@ "type": "github" } ], - "time": "2026-02-22T09:45:50+00:00" + "time": "2026-04-16T13:07:34+00:00" }, { "name": "roave/security-advisories", @@ -19148,18 +20196,18 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "89525190c449738e468ee27e77f9fdc1bc160e08" + "reference": "d7895ee3af79168d03a6b7d37caf0f746d79bfb1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/89525190c449738e468ee27e77f9fdc1bc160e08", - "reference": "89525190c449738e468ee27e77f9fdc1bc160e08", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/d7895ee3af79168d03a6b7d37caf0f746d79bfb1", + "reference": "d7895ee3af79168d03a6b7d37caf0f746d79bfb1", "shasum": "" }, "conflict": { "3f/pygmentize": "<1.2", "adaptcms/adaptcms": "<=1.3", - "admidio/admidio": "<=4.3.16", + "admidio/admidio": "<=5.0.8", "adodb/adodb-php": "<=5.22.9", "aheinze/cockpit": "<2.2", "aimeos/ai-admin-graphql": ">=2022.04.1,<2022.10.10|>=2023.04.1,<2023.10.6|>=2024.04.1,<2024.07.2", @@ -19176,6 +20224,7 @@ "alextselegidis/easyappointments": "<=1.5.2", "alexusmai/laravel-file-manager": "<=3.3.1", "algolia/algoliasearch-magento-2": "<=3.16.1|>=3.17.0.0-beta1,<=3.17.1", + "almirhodzic/nova-toggle-5": "<1.3", "alt-design/alt-redirect": "<1.6.4", "altcha-org/altcha": "<1.3.1", "alterphp/easyadmin-extension-bundle": ">=1.2,<1.2.11|>=1.3,<1.3.1", @@ -19201,28 +20250,30 @@ "athlon1600/php-proxy": "<=5.1", "athlon1600/php-proxy-app": "<=3", "athlon1600/youtube-downloader": "<=4", + "aureuserp/aureuserp": "<1.3.0.0-beta1", "austintoddj/canvas": "<=3.4.2", - "auth0/auth0-php": ">=3.3,<8.18", - "auth0/login": "<7.20", - "auth0/symfony": "<=5.5", - "auth0/wordpress": "<=5.4", + "auth0/auth0-php": ">=3.3,<=8.18", + "auth0/login": "<=7.20", + "auth0/symfony": "<=5.7", + "auth0/wordpress": "<=5.5", "automad/automad": "<2.0.0.0-alpha5", "automattic/jetpack": "<9.8", "awesome-support/awesome-support": "<=6.0.7", - "aws/aws-sdk-php": "<3.368", - "azuracast/azuracast": "<=0.23.1", + "aws/aws-sdk-php": "<=3.371.3", + "ayacoo/redirect-tab": "<2.1.2|>=3,<3.1.7|>=4,<4.0.5", + "azuracast/azuracast": "<=0.23.5", "b13/seo_basics": "<0.8.2", "backdrop/backdrop": "<=1.32", "backpack/crud": "<3.4.9", "backpack/filemanager": "<2.0.2|>=3,<3.0.9", "bacula-web/bacula-web": "<9.7.1", "badaso/core": "<=2.9.11", - "bagisto/bagisto": "<2.3.10", + "bagisto/bagisto": "<=2.3.15", "barrelstrength/sprout-base-email": "<1.2.7", "barrelstrength/sprout-forms": "<3.9", "barryvdh/laravel-translation-manager": "<0.6.8", "barzahlen/barzahlen-php": "<2.0.1", - "baserproject/basercms": "<=5.1.1", + "baserproject/basercms": "<=5.2.2", "bassjobsen/bootstrap-3-typeahead": ">4.0.2", "bbpress/bbpress": "<2.6.5", "bcit-ci/codeigniter": "<3.1.3", @@ -19265,13 +20316,13 @@ "cesnet/simplesamlphp-module-proxystatistics": "<3.1", "chriskacerguis/codeigniter-restserver": "<=2.7.1", "chrome-php/chrome": "<1.14", - "ci4-cms-erp/ci4ms": "<0.28.5", + "ci4-cms-erp/ci4ms": "<=0.31.7", "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.11.4", - "code16/sharp": "<9.11.1", + "cockpit-hq/cockpit": "<2.14", + "code16/sharp": "<9.20", "codeception/codeception": "<3.1.3|>=4,<4.1.22", "codeigniter/framework": "<3.1.10", "codeigniter4/framework": "<4.6.2", @@ -19281,8 +20332,8 @@ "codingms/modules": "<4.3.11|>=5,<5.7.4|>=6,<6.4.2|>=7,<7.5.5", "commerceteam/commerce": ">=0.9.6,<0.9.9", "components/jquery": ">=1.0.3,<3.5", - "composer/composer": "<1.10.27|>=2,<2.2.26|>=2.3,<2.9.3", - "concrete5/concrete5": "<9.4.3", + "composer/composer": "<2.2.27|>=2.3,<2.9.6", + "concrete5/concrete5": "<9.4.8", "concrete5/core": "<8.5.8|>=9,<9.1", "contao-components/mediaelement": ">=2.14.2,<2.21.1", "contao/comments-bundle": ">=2,<4.13.40|>=5.0.0.0-RC1-dev,<5.3.4", @@ -19295,11 +20346,15 @@ "corveda/phpsandbox": "<1.3.5", "cosenary/instagram": "<=2.3", "couleurcitron/tarteaucitron-wp": "<0.3", - "cpsit/typo3-mailqueue": "<0.4.3|>=0.5,<0.5.1", - "craftcms/cms": "<4.17.0.0-beta1|>=5,<5.9.0.0-beta1", - "craftcms/commerce": ">=4.0.0.0-RC1-dev,<=4.10|>=5,<=5.5.1", + "cpsit/typo3-mailqueue": "<0.4.5|>=0.5,<0.5.2", + "craftcms/aws-s3": ">=2.0.2,<=2.2.4", + "craftcms/azure-blob": ">=2.0.0.0-beta1,<=2.1", + "craftcms/cms": "<4.17.12|>=5,<5.9.18", + "craftcms/commerce": ">=4,<4.11|>=5,<5.6", "craftcms/composer": ">=4.0.0.0-RC1-dev,<=4.10|>=5.0.0.0-RC1-dev,<=5.5.1", "craftcms/craft": ">=3.5,<=4.16.17|>=5.0.0.0-RC1-dev,<=5.8.21", + "craftcms/google-cloud": ">=2.0.0.0-beta1,<=2.2", + "craftcms/webhooks": ">=3,<3.2", "croogo/croogo": "<=4.0.7", "cuyz/valinor": "<0.12", "czim/file-handling": "<1.5|>=2,<2.3", @@ -19313,11 +20368,12 @@ "david-garcia/phpwhois": "<=4.3.1", "dbrisinajumi/d2files": "<1", "dcat/laravel-admin": "<=2.1.3|==2.2.0.0-beta|==2.2.2.0-beta", + "dedoc/scramble": ">=0.13.2,<0.13.22", "derhansen/fe_change_pwd": "<2.0.5|>=3,<3.0.3", "derhansen/sf_event_mgt": "<4.3.1|>=5,<5.1.1|>=7,<7.4", "desperado/xml-bundle": "<=0.1.7", "dev-lancer/minecraft-motd-parser": "<=1.0.5", - "devcode-it/openstamanager": "<=2.9.8", + "devcode-it/openstamanager": "<=2.10.1", "devgroup/dotplant": "<2020.09.14-dev", "digimix/wp-svg-upload": "<=1", "directmailteam/direct-mail": "<6.0.3|>=7,<7.0.3|>=8,<9.5.2", @@ -19334,9 +20390,10 @@ "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": "<21.0.3", + "dolibarr/dolibarr": "<=23.0.2", "dompdf/dompdf": "<2.0.4", "doublethreedigital/guest-entries": "<3.1.2", + "dreamfactory/df-core": "<1.0.4", "drupal-pattern-lab/unified-twig-extensions": "<=0.1", "drupal/access_code": "<2.0.5", "drupal/acquia_dam": "<1.1.5", @@ -19375,7 +20432,7 @@ "drupal/umami_analytics": "<1.0.1", "duncanmcclean/guest-entries": "<3.1.2", "dweeves/magmi": "<=0.7.24", - "ec-cube/ec-cube": "<2.4.4|>=2.11,<=2.17.1|>=3,<=3.0.18.0-patch4|>=4,<=4.1.2", + "ec-cube/ec-cube": "<2.4.4|>=2.11,<=2.17.1|>=3,<=3.0.18.0-patch4|>=4,<=4.3.1", "ecodev/newsletter": "<=4", "ectouch/ectouch": "<=2.7.2", "egroupware/egroupware": "<23.1.20260113|>=26.0.20251208,<26.0.20260113", @@ -19412,7 +20469,7 @@ "ezsystems/repository-forms": ">=2.3,<2.3.2.1-dev|>=2.5,<2.5.15", "ezyang/htmlpurifier": "<=4.2", "facade/ignition": "<1.16.15|>=2,<2.4.2|>=2.5,<2.5.2", - "facturascripts/facturascripts": "<2025.81", + "facturascripts/facturascripts": "<=2025.92|>=2026,<=2026.1", "fastly/magento2": "<1.2.26", "feehi/cms": "<=2.1.1", "feehi/feehicms": "<=2.1.1", @@ -19420,7 +20477,7 @@ "filament/actions": ">=3.2,<3.2.123", "filament/filament": ">=4,<4.3.1", "filament/infolists": ">=3,<3.2.115", - "filament/tables": ">=3,<3.2.115", + "filament/tables": ">=3,<3.2.115|>=4,<4.8.5|>=5,<5.3.5", "filegator/filegator": "<7.8", "filp/whoops": "<2.1.13", "fineuploader/php-traditional-server": "<=1.2.2", @@ -19428,12 +20485,14 @@ "fisharebest/webtrees": "<=2.1.18", "fixpunkt/fp-masterquiz": "<2.2.1|>=3,<3.5.2", "fixpunkt/fp-newsletter": "<1.1.1|>=1.2,<2.1.2|>=2.2,<3.2.6", - "flarum/core": "<1.8.10", + "flarum/core": "<=1.8.15|>=2.0.0.0-beta1,<=2.0.0.0-beta8", "flarum/flarum": "<0.1.0.0-beta8", "flarum/framework": "<1.8.10", "flarum/mentions": "<1.6.3", + "flarum/nicknames": "<1.8.3", "flarum/sticky": ">=0.1.0.0-beta14,<=0.1.0.0-beta15", "flarum/tags": "<=0.1.0.0-beta13", + "flightphp/core": "<3.18.1", "floriangaerber/magnesium": "<0.3.1", "fluidtypo3/vhs": "<5.1.1", "fof/byobu": ">=0.3.0.0-beta2,<1.1.7", @@ -19454,17 +20513,19 @@ "friendsoftypo3/openid": ">=4.5,<4.5.31|>=4.7,<4.7.16|>=6,<6.0.11|>=6.1,<6.1.6", "froala/wysiwyg-editor": "<=4.3", "frosh/adminer-platform": "<2.2.1", - "froxlor/froxlor": "<=2.2.5", + "froxlor/froxlor": "<2.3.6", "frozennode/administrator": "<=5.0.12", "fuel/core": "<1.8.1", - "funadmin/funadmin": "<=7.1.0.0-RC4", + "funadmin/funadmin": "<=7.1.0.0-RC6", "gaoming13/wechat-php-sdk": "<=1.10.2", "genix/cms": "<=1.1.11", "georgringer/news": "<1.3.3", "geshi/geshi": "<=1.0.9.1", "getformwork/formwork": "<=2.3.3", - "getgrav/grav": "<1.11.0.0-beta1", - "getkirby/cms": "<3.9.8.3-dev|>=3.10,<3.10.1.2-dev|>=4,<4.7.1|>=5,<=5.2.1", + "getgrav/grav": "<2.0.0.0-beta4", + "getgrav/grav-plugin-api": "<1.0.0.0-beta15", + "getgrav/grav-plugin-form": "<9.1", + "getkirby/cms": "<4.9|>=5,<5.4", "getkirby/kirby": "<3.9.8.3-dev|>=3.10,<3.10.1.2-dev|>=4,<4.7.1", "getkirby/panel": "<2.5.14", "getkirby/starterkit": "<=3.7.0.2", @@ -19473,12 +20534,13 @@ "globalpayments/php-sdk": "<2", "goalgorilla/open_social": "<12.3.11|>=12.4,<12.4.10|>=13.0.0.0-alpha1,<13.0.0.0-alpha11", "gogentooss/samlbase": "<1.2.7", - "google/protobuf": "<3.4", + "goodoneuz/pay-uz": "<=2.2.24", + "google/protobuf": "<4.33.6", "gos/web-socket-bundle": "<1.10.4|>=2,<2.6.1|>=3,<3.3", "gp247/core": "<1.1.24", "gree/jose": "<2.2.1", "gregwar/rst": "<1.0.3", - "grumpydictator/firefly-iii": "<6.1.17", + "grumpydictator/firefly-iii": "<6.1.17|>=6.4.23,<=6.5", "gugoan/economizzer": "<=0.9.0.0-beta1", "guzzlehttp/guzzle": "<6.5.8|>=7,<7.4.5", "guzzlehttp/oauth-subscriber": "<0.8.1", @@ -19493,6 +20555,7 @@ "hjue/justwriting": "<=1", "hov/jobfair": "<1.0.13|>=2,<2.0.2", "httpsoft/http-message": "<1.0.12", + "hybridauth/hybridauth": "<=3.12.2", "hyn/multi-tenant": ">=5.6,<5.7.2", "ibexa/admin-ui": ">=4.2,<4.2.3|>=4.6,<4.6.25|>=5,<5.0.3", "ibexa/admin-ui-assets": ">=4.6.0.0-alpha1,<4.6.21", @@ -19504,7 +20567,7 @@ "ibexa/solr": ">=4.5,<4.5.4", "ibexa/user": ">=4,<4.4.3|>=5,<5.0.4", "icecoder/icecoder": "<=8.1", - "idno/known": "<=1.6.2", + "idno/known": "<1.6.4", "ilicmiljan/secure-props": ">=1.2,<1.2.2", "illuminate/auth": "<5.5.10", "illuminate/cookie": ">=4,<=4.0.11|>=4.1,<6.18.31|>=7,<7.22.4", @@ -19521,10 +20584,13 @@ "innologi/typo3-appointments": "<2.0.6", "intelliants/subrion": "<4.2.2", "inter-mediator/inter-mediator": "==5.5", - "ipl/web": "<0.10.1", + "intercom/intercom-php": "==5.0.2", + "invoiceninja/invoiceninja": "<5.13.4", + "ipl/web": "<=0.13", "islandora/crayfish": "<4.1", "islandora/islandora": ">=2,<2.4.1", "ivankristianto/phpwhois": "<=4.3", + "j0k3r/graby": "<=2.5", "jackalope/jackalope-doctrine-dbal": "<1.7.4", "jambagecom/div2007": "<0.10.2", "james-heinrich/getid3": "<1.9.21", @@ -19532,7 +20598,9 @@ "jasig/phpcas": "<1.3.3", "jbartels/wec-map": "<3.0.3", "jcbrand/converse.js": "<3.3.3", + "joedolson/my-calendar": "<3.7.7", "joelbutcher/socialstream": "<5.6|>=6,<6.2", + "johnbillion/query-monitor": "<3.20.4", "johnbillion/wp-crontrol": "<1.16.2|>=1.17,<1.19.2", "joomla/application": "<1.0.13", "joomla/archive": "<1.1.12|>=2,<2.0.1", @@ -19550,17 +20618,19 @@ "juzaweb/cms": "<=3.4.2", "jweiland/events2": "<8.3.8|>=9,<9.0.6", "jweiland/kk-downloader": "<1.2.2", + "kantorge/yaffa": "<=2", "kazist/phpwhois": "<=4.2.6", + "kelvinmo/simplejwt": "<=1.1", "kelvinmo/simplexrd": "<3.1.1", "kevinpapst/kimai2": "<1.16.7", - "khodakhah/nodcms": "<=3", - "kimai/kimai": "<2.46", + "khodakhah/nodcms": "<=3.4.1", + "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", "kohana/core": "<3.3.3", "koillection/koillection": "<1.6.12", - "krayin/laravel-crm": "<=1.3", + "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", @@ -19572,6 +20642,7 @@ "laravel/fortify": "<1.11.1", "laravel/framework": "<10.48.29|>=11,<11.44.1|>=12,<12.1.1", "laravel/laravel": ">=5.4,<5.4.22", + "laravel/passport": ">=13,<13.7.1", "laravel/pulse": "<1.3.1", "laravel/reverb": "<1.7", "laravel/socialite": ">=1,<2.0.10", @@ -19579,16 +20650,16 @@ "lavalite/cms": "<=10.1", "lavitto/typo3-form-to-database": "<2.2.5|>=3,<3.2.2|>=4,<4.2.3|>=5,<5.0.2", "lcobucci/jwt": ">=3.4,<3.4.6|>=4,<4.0.4|>=4.1,<4.1.5", - "league/commonmark": "<2.7", + "league/commonmark": "<=2.8.1", "league/flysystem": "<1.1.4|>=2,<2.1.1", "league/oauth2-server": ">=8.3.2,<8.4.2|>=8.5,<8.5.3", "leantime/leantime": "<3.3", "lexik/jwt-authentication-bundle": "<2.10.7|>=2.11,<2.11.3", "libreform/libreform": ">=2,<=2.0.8", - "librenms/librenms": "<26.2", + "librenms/librenms": "<26.3", "liftkit/database": "<2.13.2", "lightsaml/lightsaml": "<1.3.5", - "limesurvey/limesurvey": "<6.5.12", + "limesurvey/limesurvey": "<6.15.4", "livehelperchat/livehelperchat": "<=3.91", "livewire-filemanager/filemanager": "<=1.0.4", "livewire/livewire": "<2.12.7|>=3.0.0.0-beta1,<3.6.4", @@ -19611,8 +20682,9 @@ "maikuolan/phpmussel": ">=1,<1.6", "mainwp/mainwp": "<=4.4.3.3", "manogi/nova-tiptap": "<=3.2.6", - "mantisbt/mantisbt": "<2.27.2", + "mantisbt/mantisbt": "<2.28.1", "marcwillmann/turn": "<0.3.3", + "markhuot/craftql": "<=1.3.7", "marshmallow/nova-tiptap": "<5.7", "matomo/matomo": "<1.11", "matyhtf/framework": "<3.0.6", @@ -19620,6 +20692,7 @@ "mautic/core-lib": ">=1.0.0.0-beta,<4.4.13|>=5.0.0.0-alpha,<5.1.1", "mautic/grapes-js-builder-bundle": ">=4,<4.4.18|>=5,<5.2.9|>=6,<6.0.7", "maximebf/debugbar": "<1.19", + "mckenziearts/livewire-markdown-editor": "<1.3", "mdanter/ecc": "<2", "mediawiki/abuse-filter": "<1.39.9|>=1.40,<1.41.3|>=1.42,<1.42.2", "mediawiki/cargo": "<3.8.3", @@ -19642,7 +20715,9 @@ "mikehaertl/php-shellcommand": "<1.6.1", "mineadmin/mineadmin": "<=3.0.9", "miniorange/miniorange-saml": "<1.4.3", + "miraheze/ts-portal": "<=33", "mittwald/typo3_forum": "<1.2.1", + "mix/mix": ">=2,<=2.2.17", "mobiledetect/mobiledetectlib": "<2.8.32", "modx/revolution": "<=3.1", "mojo42/jirafeau": "<4.4", @@ -19662,6 +20737,7 @@ "munkireport/softwareupdate": "<1.6", "mustache/mustache": ">=2,<2.14.1", "mwdelaney/wp-enable-svg": "<=0.2", + "nabeel/phpvms": "<7.0.6", "namshi/jose": "<2.2", "nasirkhan/laravel-starter": "<11.11", "nategood/httpful": "<1", @@ -19692,9 +20768,9 @@ "nzo/url-encryptor-bundle": ">=4,<4.3.2|>=5,<5.0.1", "october/backend": "<1.1.2", "october/cms": "<1.0.469|==1.0.469|==1.0.471|==1.1.1", - "october/october": "<3.7.5", - "october/rain": "<1.0.472|>=1.1,<1.1.2", - "october/system": "<=3.7.12|>=4,<=4.0.11", + "october/october": "<3.7.14|>=4,<4.1.10", + "october/rain": "<=3.7.13|>=4,<=4.1.9", + "october/system": "<3.7.16|>=4,<4.1.16", "oliverklee/phpunit": "<3.5.15", "omeka/omeka-s": "<4.0.3", "onelogin/php-saml": "<2.21.1|>=3,<3.8.1|>=4,<4.3.1", @@ -19702,9 +20778,9 @@ "open-web-analytics/open-web-analytics": "<1.8.1", "opencart/opencart": ">=0", "openid/php-openid": "<2.3", - "openmage/magento-lts": "<20.16.1", + "openmage/magento-lts": "<=20.17", "opensolutions/vimbadmin": "<=3.0.15", - "opensource-workshop/connect-cms": "<1.8.7|>=2,<2.4.7", + "opensource-workshop/connect-cms": "<1.41.1|>=2,<2.41.1", "orchid/platform": ">=8,<14.43", "oro/calendar-bundle": ">=4.2,<=4.2.6|>=5,<=5.0.6|>=5.1,<5.1.1", "oro/commerce": ">=4.1,<5.0.11|>=5.1,<5.1.1", @@ -19745,16 +20821,16 @@ "phpmailer/phpmailer": "<6.5", "phpmussel/phpmussel": ">=1,<1.6", "phpmyadmin/phpmyadmin": "<5.2.2", - "phpmyfaq/phpmyfaq": "<=4.0.16", + "phpmyfaq/phpmyfaq": "<=4.1.1", "phpoffice/common": "<0.2.9", "phpoffice/math": "<=0.2", "phpoffice/phpexcel": "<=1.8.2", - "phpoffice/phpspreadsheet": "<1.30|>=2,<2.1.12|>=2.2,<2.4|>=3,<3.10|>=4,<5", + "phpoffice/phpspreadsheet": "<=1.30.3|>=2,<=2.1.15|>=2.2,<=2.4.4|>=3,<=3.10.4|>=4,<=5.6", "phppgadmin/phppgadmin": "<=7.13", - "phpseclib/phpseclib": "<2.0.47|>=3,<3.0.36", + "phpseclib/phpseclib": "<=2.0.53|>=3,<=3.0.51", "phpservermon/phpservermon": "<3.6", "phpsysinfo/phpsysinfo": "<3.4.3", - "phpunit/phpunit": "<8.5.52|>=9,<9.6.33|>=10,<10.5.62|>=11,<11.5.50|>=12,<12.5.8", + "phpunit/phpunit": "<8.5.52|>=9,<9.6.33|>=10,<10.5.62|>=11,<11.5.50|>=12,<12.5.8|>=12.5.21,<12.5.22|>=13.1.5,<13.1.6", "phpwhois/phpwhois": "<=4.2.5", "phpxmlrpc/extras": "<0.6.1", "phpxmlrpc/phpxmlrpc": "<4.9.2", @@ -19767,13 +20843,13 @@ "pimcore/demo": "<10.3", "pimcore/ecommerce-framework-bundle": "<1.0.10", "pimcore/perspective-editor": "<1.5.1", - "pimcore/pimcore": "<=11.5.14.1|>=12,<12.3.3", + "pimcore/pimcore": "<=11.5.14.1|>=12,<12.3.3|==12.3.3", "pimcore/web2print-tools-bundle": "<=5.2.1|>=6.0.0.0-RC1-dev,<=6.1", "piwik/piwik": "<1.11", "pixelfed/pixelfed": "<0.12.5", "plotly/plotly.js": "<2.25.2", "pocketmine/bedrock-protocol": "<8.0.2", - "pocketmine/pocketmine-mp": "<5.32.1", + "pocketmine/pocketmine-mp": "<5.42.1", "pocketmine/raklib": ">=0.14,<0.14.6|>=0.15,<0.15.1", "pressbooks/pressbooks": "<5.18", "prestashop/autoupgrade": ">=4,<4.10.1", @@ -19781,15 +20857,15 @@ "prestashop/blockwishlist": ">=2,<2.1.1", "prestashop/contactform": ">=1.0.1,<4.3", "prestashop/gamification": "<2.3.2", - "prestashop/prestashop": "<8.2.4|>=9.0.0.0-alpha1,<9.0.3", + "prestashop/prestashop": "<8.2.6|>=9,<9.1.1", "prestashop/productcomments": "<5.0.2", - "prestashop/ps_checkout": "<4.4.1|>=5,<5.0.5", + "prestashop/ps_checkout": "<5.3", "prestashop/ps_contactinfo": "<=3.3.2", "prestashop/ps_emailsubscription": "<2.6.1", "prestashop/ps_facetedsearch": "<3.4.1", "prestashop/ps_linklist": "<3.1", "privatebin/privatebin": "<1.4|>=1.5,<1.7.4|>=1.7.7,<2.0.3", - "processwire/processwire": "<=3.0.246", + "processwire/processwire": "<=3.0.255", "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", @@ -19799,6 +20875,7 @@ "pubnub/pubnub": "<6.1", "punktde/pt_extbase": "<1.5.1", "pusher/pusher-php-server": "<2.2.1", + "putyourlightson/craft-sprig": ">=2,<2.15.2|>=3,<3.7.2", "pwweb/laravel-core": "<=0.3.6.0-beta", "pxlrbt/filament-excel": "<1.1.14|>=2.0.0.0-alpha,<2.3.3", "pyrocms/pyrocms": "<=3.9.1", @@ -19807,25 +20884,30 @@ "rainlab/blog-plugin": "<1.4.1", "rainlab/debugbar-plugin": "<3.1", "rainlab/user-plugin": "<=1.4.5", + "ralffreit/mfa-email": "<1.0.7|==2", "rankmath/seo-by-rank-math": "<=1.0.95", "rap2hpoutre/laravel-log-viewer": "<0.13", "react/http": ">=0.7,<1.9", "really-simple-plugins/complianz-gdpr": "<6.4.2", - "redaxo/source": "<=5.20.1", + "redaxo/source": "<5.21", "remdex/livehelperchat": "<4.29", "renolit/reint-downloadmanager": "<4.0.2|>=5,<5.0.1", "reportico-web/reportico": "<=8.1", - "rhukster/dom-sanitizer": "<1.0.7", + "rhukster/dom-sanitizer": "<1.0.10", "rmccue/requests": ">=1.6,<1.8", - "robrichards/xmlseclibs": "<=3.1.3", + "roadiz/documents": "<2.3.42|>=2.4,<2.5.44|>=2.6,<2.6.28|>=2.7,<2.7.9", + "roadiz/openid": "<2.3.43|>=2.5,<2.5.45|>=2.6,<2.6.31|>=2.7,<2.7.18", + "robrichards/xmlseclibs": "<3.1.5", "roots/soil": "<4.1", - "roundcube/roundcubemail": "<1.5.10|>=1.6,<1.6.11", + "roundcube/roundcubemail": "<1.5.10|>=1.6,<1.6.11|>=1.7.0.0-beta,<1.7.0.0-RC5-dev", "rudloff/alltube": "<3.0.3", "rudloff/rtmpdump-bin": "<=2.3.1", "s-cart/core": "<=9.0.5", "s-cart/s-cart": "<6.9", + "s9y/serendipity": "<2.6", "sabberworm/php-css-parser": ">=1,<1.0.1|>=2,<2.0.1|>=3,<3.0.1|>=4,<4.0.1|>=5,<5.0.9|>=5.1,<5.1.3|>=5.2,<5.2.1|>=6,<6.0.2|>=7,<7.0.4|>=8,<8.0.1|>=8.1,<8.1.1|>=8.2,<8.2.1|>=8.3,<8.3.1", "sabre/dav": ">=1.6,<1.7.11|>=1.8,<1.8.9", + "saloonphp/saloon": "<4", "samwilson/unlinked-wikibase": "<1.42", "scheb/two-factor-bundle": "<3.26|>=4,<4.11", "sensiolabs/connect": "<4.2.3", @@ -19833,17 +20915,17 @@ "setasign/fpdi": "<2.6.4", "sfroemken/url_redirect": "<=1.2.1", "sheng/yiicms": "<1.2.1", - "shopware/core": "<6.6.10.9-dev|>=6.7,<6.7.6.1-dev", - "shopware/platform": "<6.6.10.7-dev|>=6.7,<6.7.3.1-dev", + "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", "shopware/shopware": "<=5.7.17|>=6.4.6,<6.6.10.10-dev|>=6.7,<6.7.6.1-dev", "shopware/storefront": "<6.6.10.10-dev|>=6.7,<6.7.5.1-dev", "shopxo/shopxo": "<=6.4", - "showdoc/showdoc": "<2.10.4", + "showdoc/showdoc": "<3.8.1", "shuchkin/simplexlsx": ">=1.0.12,<1.1.13", "silverstripe-australia/advancedreports": ">=1,<=2", "silverstripe/admin": "<1.13.19|>=2,<2.1.8", - "silverstripe/assets": ">=1,<1.11.1", + "silverstripe/assets": "<2.4.5|>=3,<3.1.3", "silverstripe/cms": "<4.11.3", "silverstripe/comments": ">=1.3,<3.1.1", "silverstripe/forum": "<=0.6.1|>=0.7,<=0.7.3", @@ -19868,7 +20950,7 @@ "simplesamlphp/simplesamlphp-module-openid": "<1", "simplesamlphp/simplesamlphp-module-openidprovider": "<0.9", "simplesamlphp/xml-common": "<1.20", - "simplesamlphp/xml-security": "==1.6.11", + "simplesamlphp/xml-security": "<1.13.9|>=2,<2.3.1", "simplito/elliptic-php": "<1.0.6", "sitegeist/fluid-components": "<3.5", "sjbr/sr-feuser-register": "<2.6.2|>=5.1,<12.5", @@ -19878,7 +20960,7 @@ "slim/slim": "<2.6", "slub/slub-events": "<3.0.3", "smarty/smarty": "<4.5.3|>=5,<5.1.1", - "snipe/snipe-it": "<=8.3.4", + "snipe/snipe-it": "<8.4.1", "socalnick/scn-social-auth": "<1.15.2", "socialiteproviders/steam": "<1.1", "solspace/craft-freeform": "<4.1.29|>=5,<=5.14.6", @@ -19896,14 +20978,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.11|>=6,<6.4", + "statamic/cms": "<5.73.21|>=6,<6.15", "stormpath/sdk": "<9.9.99", - "studio-42/elfinder": "<=2.1.64", + "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": "<1.6.44|>=2,<2.5.25|>=2.6,<2.6.9|>=3.0.0.0-alpha1,<3.0.0.0-alpha3", + "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", @@ -19915,7 +20997,7 @@ "sylius/grid-bundle": "<1.10.1", "sylius/paypal-plugin": "<1.6.2|>=1.7,<1.7.2|>=2,<2.0.2", "sylius/resource-bundle": ">=1,<1.3.14|>=1.4,<1.4.7|>=1.5,<1.5.2|>=1.6,<1.6.4", - "sylius/sylius": "<1.12.19|>=1.13.0.0-alpha1,<1.13.4", + "sylius/sylius": "<1.9.12|>=1.10,<1.10.16|>=1.11,<1.11.17|>=1.12,<=1.12.22|>=1.13,<=1.13.14|>=1.14,<=1.14.17|>=2,<=2.0.15|>=2.1,<=2.1.11|>=2.2,<=2.2.2", "symbiote/silverstripe-multivaluefield": ">=3,<3.1", "symbiote/silverstripe-queuedjobs": ">=3,<3.0.2|>=3.1,<3.1.4|>=4,<4.0.7|>=4.1,<4.1.2|>=4.2,<4.2.4|>=4.3,<4.3.3|>=4.4,<4.4.3|>=4.5,<4.5.1|>=4.6,<4.6.4", "symbiote/silverstripe-seed": "<6.0.3", @@ -19970,7 +21052,7 @@ "thelia/thelia": ">=2.1,<2.1.3", "theonedemon/phpwhois": "<=4.2.5", "thinkcmf/thinkcmf": "<6.0.8", - "thorsten/phpmyfaq": "<4.0.18|>=4.1.0.0-alpha,<=4.1.0.0-beta2", + "thorsten/phpmyfaq": "<=4.1.1", "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", @@ -19990,7 +21072,7 @@ "twig/twig": "<3.11.2|>=3.12,<3.14.1|>=3.16,<3.19", "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", + "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", "typo3/cms-belog": ">=10,<=10.4.47|>=11,<=11.5.41|>=12,<=12.4.24|>=13,<=13.4.2", "typo3/cms-beuser": ">=9,<9.5.55|>=10,<10.4.54|>=11,<11.5.48|>=12,<12.4.37|>=13,<13.4.18", "typo3/cms-core": "<=8.7.56|>=9,<9.5.55|>=10,<=10.4.54|>=11,<=11.5.48|>=12,<=12.4.40|>=13,<=13.4.22|>=14,<=14.0.1", @@ -20043,20 +21125,22 @@ "wallabag/wallabag": "<2.6.11", "wanglelecc/laracms": "<=1.0.3", "wapplersystems/a21glossary": "<=0.4.10", - "web-auth/webauthn-framework": ">=3.3,<3.3.4|>=4.5,<4.9", - "web-auth/webauthn-lib": ">=4.5,<4.9", + "web-auth/webauthn-framework": ">=3.3,<3.3.4|>=4.5,<4.9|>=5.2,<5.2.4|>=5.3,<5.3.1", + "web-auth/webauthn-lib": ">=4.5,<4.9|>=5.2,<5.2.4", + "web-auth/webauthn-symfony-bundle": ">=5.2,<5.2.4", "web-feet/coastercms": "==5.5", "web-tp3/wec_map": "<3.0.3", "webbuilders-group/silverstripe-kapost-bridge": "<0.4", "webcoast/deferred-image-processing": "<1.0.2", "webklex/laravel-imap": "<5.3", "webklex/php-imap": "<5.3", + "webonyx/graphql-php": "<=15.32.2", "webpa/webpa": "<3.1.2", "webreinvent/vaahcms": "<=2.3.1", "wikibase/wikibase": "<=1.39.3", "wikimedia/parsoid": "<0.12.2", "willdurand/js-translation-bundle": "<2.1.1", - "winter/wn-backend-module": "<1.2.4", + "winter/wn-backend-module": "<1.2.12", "winter/wn-cms-module": "<=1.2.9", "winter/wn-dusk-plugin": "<2.1", "winter/wn-system-module": "<1.2.4", @@ -20069,11 +21153,13 @@ "wpanel/wpanel4-cms": "<=4.3.1", "wpcloud/wp-stateless": "<3.2", "wpglobus/wpglobus": "<=1.9.6", - "wwbn/avideo": "<=21", + "wpmetabox/meta-box": "<5.11.2", + "wwbn/avideo": "<=29", "xataface/xataface": "<3", "xpressengine/xpressengine": "<3.0.15", "yab/quarx": "<2.4.5", - "yeswiki/yeswiki": "<=4.5.4", + "yansongda/pay": "<=3.7.19", + "yeswiki/yeswiki": "<=4.6", "yetiforce/yetiforce-crm": "<6.5", "yidashi/yii2cmf": "<=2", "yii2mod/yii2-cms": "<1.9.2", @@ -20088,6 +21174,7 @@ "yiisoft/yii2-redis": "<2.0.20", "yikesinc/yikes-inc-easy-mailchimp-extender": "<6.8.6", "yoast-seo-for-typo3/yoast_seo": "<7.2.3", + "yoast/duplicate-post": "<=4.5", "yourls/yourls": "<=1.10.2", "yuan1994/tpadmin": "<=1.3.12", "yungifez/skuul": "<=2.6.5", @@ -20167,7 +21254,7 @@ "type": "tidelift" } ], - "time": "2026-03-01T01:36:02+00:00" + "time": "2026-05-11T16:56:44+00:00" }, { "name": "sebastian/cli-parser", @@ -21209,16 +22296,16 @@ }, { "name": "symfony/browser-kit", - "version": "v7.4.4", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/browser-kit.git", - "reference": "bed167eadaaba641f51fc842c9227aa5e251309e" + "reference": "41850d8f8ddef9a9cd7314fa9f4902cf48885521" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/browser-kit/zipball/bed167eadaaba641f51fc842c9227aa5e251309e", - "reference": "bed167eadaaba641f51fc842c9227aa5e251309e", + "url": "https://api.github.com/repos/symfony/browser-kit/zipball/41850d8f8ddef9a9cd7314fa9f4902cf48885521", + "reference": "41850d8f8ddef9a9cd7314fa9f4902cf48885521", "shasum": "" }, "require": { @@ -21258,7 +22345,7 @@ "description": "Simulates the behavior of a web browser, allowing you to make requests, click on links and submit forms programmatically", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/browser-kit/tree/v7.4.4" + "source": "https://github.com/symfony/browser-kit/tree/v7.4.8" }, "funding": [ { @@ -21278,20 +22365,20 @@ "type": "tidelift" } ], - "time": "2026-01-13T10:40:19+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/debug-bundle", - "version": "v7.4.0", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/debug-bundle.git", - "reference": "329383fb895353e3c8ab792cc35c4a7e7b17881b" + "reference": "3eb18c1e6cd16da2cea1f1b5162e442af4afee44" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/debug-bundle/zipball/329383fb895353e3c8ab792cc35c4a7e7b17881b", - "reference": "329383fb895353e3c8ab792cc35c4a7e7b17881b", + "url": "https://api.github.com/repos/symfony/debug-bundle/zipball/3eb18c1e6cd16da2cea1f1b5162e442af4afee44", + "reference": "3eb18c1e6cd16da2cea1f1b5162e442af4afee44", "shasum": "" }, "require": { @@ -21333,7 +22420,7 @@ "description": "Provides a tight integration of the Symfony VarDumper component and the ServerLogCommand from MonologBridge into the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/debug-bundle/tree/v7.4.0" + "source": "https://github.com/symfony/debug-bundle/tree/v7.4.8" }, "funding": [ { @@ -21353,23 +22440,24 @@ "type": "tidelift" } ], - "time": "2025-10-24T13:56:35+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/maker-bundle", - "version": "v1.66.0", + "version": "v1.67.0", "source": { "type": "git", "url": "https://github.com/symfony/maker-bundle.git", - "reference": "b5b4afa2a570b926682e9f34615a6766dd560ff4" + "reference": "6ce8b313845f16bcf385ee3cb31d8b24e30d5516" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/maker-bundle/zipball/b5b4afa2a570b926682e9f34615a6766dd560ff4", - "reference": "b5b4afa2a570b926682e9f34615a6766dd560ff4", + "url": "https://api.github.com/repos/symfony/maker-bundle/zipball/6ce8b313845f16bcf385ee3cb31d8b24e30d5516", + "reference": "6ce8b313845f16bcf385ee3cb31d8b24e30d5516", "shasum": "" }, "require": { + "composer-runtime-api": "^2.1", "doctrine/inflector": "^2.0", "nikic/php-parser": "^5.0", "php": ">=8.1", @@ -21431,7 +22519,7 @@ ], "support": { "issues": "https://github.com/symfony/maker-bundle/issues", - "source": "https://github.com/symfony/maker-bundle/tree/v1.66.0" + "source": "https://github.com/symfony/maker-bundle/tree/v1.67.0" }, "funding": [ { @@ -21451,20 +22539,20 @@ "type": "tidelift" } ], - "time": "2026-02-09T08:55:54+00:00" + "time": "2026-03-18T13:39:06+00:00" }, { "name": "symfony/phpunit-bridge", - "version": "v7.4.3", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/phpunit-bridge.git", - "reference": "f933e68bb9df29d08077a37e1515a23fea8562ab" + "reference": "140bbbe1cd1c21a084494ccddeee33f3c3381d7d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/phpunit-bridge/zipball/f933e68bb9df29d08077a37e1515a23fea8562ab", - "reference": "f933e68bb9df29d08077a37e1515a23fea8562ab", + "url": "https://api.github.com/repos/symfony/phpunit-bridge/zipball/140bbbe1cd1c21a084494ccddeee33f3c3381d7d", + "reference": "140bbbe1cd1c21a084494ccddeee33f3c3381d7d", "shasum": "" }, "require": { @@ -21516,7 +22604,7 @@ "testing" ], "support": { - "source": "https://github.com/symfony/phpunit-bridge/tree/v7.4.3" + "source": "https://github.com/symfony/phpunit-bridge/tree/v7.4.8" }, "funding": [ { @@ -21536,20 +22624,20 @@ "type": "tidelift" } ], - "time": "2025-12-09T15:33:45+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/web-profiler-bundle", - "version": "v7.4.6", + "version": "v7.4.9", "source": { "type": "git", "url": "https://github.com/symfony/web-profiler-bundle.git", - "reference": "952fbb5ea12e101e05510069eacf01e169955100" + "reference": "36dd8b8c05da059925c5804641aad9159e5b73e8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/952fbb5ea12e101e05510069eacf01e169955100", - "reference": "952fbb5ea12e101e05510069eacf01e169955100", + "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/36dd8b8c05da059925c5804641aad9159e5b73e8", + "reference": "36dd8b8c05da059925c5804641aad9159e5b73e8", "shasum": "" }, "require": { @@ -21606,7 +22694,7 @@ "dev" ], "support": { - "source": "https://github.com/symfony/web-profiler-bundle/tree/v7.4.6" + "source": "https://github.com/symfony/web-profiler-bundle/tree/v7.4.9" }, "funding": [ { @@ -21626,7 +22714,7 @@ "type": "tidelift" } ], - "time": "2026-02-11T16:03:16+00:00" + "time": "2026-04-22T15:21:55+00:00" }, { "name": "theseer/tokenizer", @@ -21701,5 +22789,5 @@ "platform-overrides": { "php": "8.2.0" }, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/config/bundles.php b/config/bundles.php index ae7dc9cc..000c58a1 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -33,4 +33,5 @@ return [ Jbtronics\SettingsBundle\JbtronicsSettingsBundle::class => ['all' => true], Jbtronics\TranslationEditorBundle\JbtronicsTranslationEditorBundle::class => ['dev' => true], ApiPlatform\Symfony\Bundle\ApiPlatformBundle::class => ['all' => true], + Symfony\AI\AiBundle\AiBundle::class => ['all' => true], ]; diff --git a/config/packages/ai.yaml b/config/packages/ai.yaml new file mode 100644 index 00000000..89f8e7ae --- /dev/null +++ b/config/packages/ai.yaml @@ -0,0 +1,27 @@ +ai: + platform: + # Inference Platform configuration + # see https://github.com/symfony/ai/tree/main/src/platform#platform-bridges + + # openai: + # api_key: '%env(OPENAI_API_KEY)%' + + agent: + # Agent configuration + # see https://symfony.com/doc/current/ai/bundles/ai-bundle.html + + # default: + # platform: 'ai.platform.openai' + # model: 'gpt-5-mini' + # prompt: | + # You are a pirate and you write funny. + # tools: + # - 'Symfony\AI\Agent\Bridge\Clock\Clock' + + store: + # Store configuration + + # chromadb: + # default: + # client: 'client.service.id' + # collection: 'my_collection' diff --git a/config/packages/ai_generic_platform.yaml b/config/packages/ai_generic_platform.yaml new file mode 100644 index 00000000..c2c2e133 --- /dev/null +++ b/config/packages/ai_generic_platform.yaml @@ -0,0 +1,5 @@ +ai: + platform: + generic: + default: + base_url: '%env(GENERIC_BASE_URL)%' diff --git a/config/packages/ai_lm_studio_platform.yaml b/config/packages/ai_lm_studio_platform.yaml new file mode 100644 index 00000000..0e4287e0 --- /dev/null +++ b/config/packages/ai_lm_studio_platform.yaml @@ -0,0 +1,4 @@ +ai: + platform: + lmstudio: + host_url: '%env(string:settings:ai_lmstudio:hostURL)%' diff --git a/config/packages/ai_open_router_platform.yaml b/config/packages/ai_open_router_platform.yaml new file mode 100644 index 00000000..d34de592 --- /dev/null +++ b/config/packages/ai_open_router_platform.yaml @@ -0,0 +1,4 @@ +ai: + platform: + openrouter: + api_key: '%env(string:settings:ai_openrouter:apiKey)%' diff --git a/config/packages/cache.yaml b/config/packages/cache.yaml index 846033d6..c1816aa2 100644 --- a/config/packages/cache.yaml +++ b/config/packages/cache.yaml @@ -25,5 +25,5 @@ framework: adapter: cache.app cache.settings: - adapter: cache.app + adapter: cache.system tags: true diff --git a/config/packages/datatables.yaml b/config/packages/datatables.yaml index f1ea4715..fe238a1e 100644 --- a/config/packages/datatables.yaml +++ b/config/packages/datatables.yaml @@ -8,7 +8,7 @@ datatables: # Set options, as documented at https://datatables.net/reference/option/ options: - lengthMenu : [[10, 25, 50, 100], [10, 25, 50, 100]] # We add the "All" option, when part tables are generated + lengthMenu : [[10, 25, 50, 100, 250, 500], [10, 25, 50, 100, 250, 500]] # We add the "All" option, when part tables are generated #pageLength: '%partdb.table.default_page_size%' # Set to -1 to disable pagination (i.e. show all rows) by default pageLength: 50 #TODO dom: " <'row' <'col mb-2 input-group flex-nowrap' B l > <'col-auto mb-2' < p >>> diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index 5261c295..164ac717 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -56,6 +56,7 @@ doctrine: natsort: App\Doctrine\Functions\Natsort array_position: App\Doctrine\Functions\ArrayPosition ilike: App\Doctrine\Functions\ILike + si_value_sort: App\Doctrine\Functions\SiValueSort when@test: doctrine: diff --git a/config/packages/twig.yaml b/config/packages/twig.yaml index 95ae4f3b..860cef42 100644 --- a/config/packages/twig.yaml +++ b/config/packages/twig.yaml @@ -18,6 +18,11 @@ twig: saml_enabled: '%partdb.saml.enabled%' part_preview_generator: '@App\Services\Attachments\PartPreviewGenerator' + # Bootstrap grid classes used for horizontal form layouts + col_label: 'col-sm-3 col-lg-2' # The column classes for form labels + col_input: 'col-sm-9 col-lg-10' # The column classes for form input fields + offset_label: 'offset-sm-3 offset-lg-2' # Offset classes for elements that should be aligned with the input fields (e.g., submit buttons) + when@test: twig: strict_variables: true diff --git a/config/parameters.yaml b/config/parameters.yaml index b79e2b88..b1aa5314 100644 --- a/config/parameters.yaml +++ b/config/parameters.yaml @@ -105,6 +105,8 @@ parameters: env(DATABASE_EMULATE_NATURAL_SORT): 0 + env(ALLOW_ATTACHMENT_DOWNLOADS_FROM_LOCALNETWORK): 0 + ###################################################################################################################### # Bulk Info Provider Import Configuration ###################################################################################################################### diff --git a/config/reference.php b/config/reference.php index bfac5a46..b561084b 100644 --- a/config/reference.php +++ b/config/reference.php @@ -128,7 +128,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * @psalm-type FrameworkConfig = array{ * secret?: scalar|Param|null, * http_method_override?: bool|Param, // Set true to enable support for the '_method' request parameter to determine the intended HTTP method on POST requests. // Default: false - * allowed_http_method_override?: list|null, + * allowed_http_method_override?: null|list, * trust_x_sendfile_type_header?: scalar|Param|null, // Set true to enable support for xsendfile in binary file responses. // Default: "%env(bool:default::SYMFONY_TRUST_X_SENDFILE_TYPE_HEADER)%" * ide?: scalar|Param|null, // Default: "%env(default::SYMFONY_IDE)%" * test?: bool|Param, @@ -136,9 +136,9 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * set_locale_from_accept_language?: bool|Param, // Whether to use the Accept-Language HTTP header to set the Request locale (only when the "_locale" request attribute is not passed). // Default: false * set_content_language_from_locale?: bool|Param, // Whether to set the Content-Language HTTP header on the Response using the Request locale. // Default: false * enabled_locales?: list, - * trusted_hosts?: list, + * trusted_hosts?: string|list, * trusted_proxies?: mixed, // Default: ["%env(default::SYMFONY_TRUSTED_PROXIES)%"] - * trusted_headers?: list, + * trusted_headers?: string|list, * error_controller?: scalar|Param|null, // Default: "error_controller" * handle_all_throwables?: bool|Param, // HttpKernel will handle all kinds of \Throwable. // Default: true * csrf_protection?: bool|array{ @@ -202,23 +202,23 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * property?: scalar|Param|null, * service?: scalar|Param|null, * }, - * supports?: list, + * supports?: string|list, * definition_validators?: list, * support_strategy?: scalar|Param|null, - * initial_marking?: list, - * events_to_dispatch?: list|null, - * places?: list, + * events_to_dispatch?: null|list, + * places?: string|list, * }>, * transitions?: list, - * to?: list, @@ -271,7 +271,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * version_format?: scalar|Param|null, // Default: "%%s?%%s" * json_manifest_path?: scalar|Param|null, // Default: null * base_path?: scalar|Param|null, // Default: "" - * base_urls?: list, + * base_urls?: string|list, * packages?: array, + * base_urls?: string|list, * }>, * }, * asset_mapper?: bool|array{ // Asset Mapper configuration * enabled?: bool|Param, // Default: false - * paths?: array, + * paths?: string|array, * excluded_patterns?: list, * exclude_dotfiles?: bool|Param, // If true, any files starting with "." will be excluded from the asset mapper. // Default: true * server?: bool|Param, // If true, a "dev server" will return the assets from the public directory (true in "debug" mode only by default). // Default: true @@ -303,7 +303,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * }, * translator?: bool|array{ // Translator configuration * enabled?: bool|Param, // Default: true - * fallbacks?: list, + * fallbacks?: string|list, * logging?: bool|Param, // Default: false * formatter?: scalar|Param|null, // Default: "translator.formatter.default" * cache_dir?: scalar|Param|null, // Default: "%kernel.cache_dir%/translations" @@ -333,7 +333,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * enabled?: bool|Param, // Default: true * cache?: scalar|Param|null, // Deprecated: Setting the "framework.validation.cache.cache" configuration option is deprecated. It will be removed in version 8.0. * enable_attributes?: bool|Param, // Default: true - * static_method?: list, + * static_method?: string|list, * translation_domain?: scalar|Param|null, // Default: "validators" * email_validation_mode?: "html5"|"html5-allow-no-tld"|"strict"|"loose"|Param, // Default: "html5" * mapping?: array{ @@ -396,7 +396,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * default_doctrine_dbal_provider?: scalar|Param|null, // Default: "database_connection" * default_pdo_provider?: scalar|Param|null, // Default: null * pools?: array, + * adapters?: string|list, * tags?: scalar|Param|null, // Default: null * public?: bool|Param, // Default: false * default_lifetime?: scalar|Param|null, // Default lifetime of the pool. @@ -419,11 +419,11 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * }, * lock?: bool|string|array{ // Lock configuration * enabled?: bool|Param, // Default: false - * resources?: array>, + * resources?: string|array>, * }, * semaphore?: bool|string|array{ // Semaphore configuration * enabled?: bool|Param, // Default: false - * resources?: array, + * resources?: string|array, * }, * messenger?: bool|array{ // Messenger configuration * enabled?: bool|Param, // Default: false @@ -453,7 +453,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * rate_limiter?: scalar|Param|null, // Rate limiter name to use when processing messages. // Default: null * }>, * failure_transport?: scalar|Param|null, // Transport name to send failed messages to (after all retries have failed). // Default: null - * stop_worker_on_signals?: list, + * stop_worker_on_signals?: int|string|list, * default_bus?: scalar|Param|null, // Default: null * buses?: array, * }>, @@ -510,9 +510,9 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * retry_failed?: bool|array{ * enabled?: bool|Param, // Default: false * retry_strategy?: scalar|Param|null, // service id to override the retry strategy. // Default: null - * http_codes?: array, + * methods?: string|list, * }>, * max_retries?: int|Param, // Default: 3 * delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000 @@ -563,9 +563,9 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * retry_failed?: bool|array{ * enabled?: bool|Param, // Default: false * retry_strategy?: scalar|Param|null, // service id to override the retry strategy. // Default: null - * http_codes?: array, + * methods?: string|list, * }>, * max_retries?: int|Param, // Default: 3 * delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000 @@ -582,8 +582,8 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * transports?: array, * envelope?: array{ // Mailer Envelope configuration * sender?: scalar|Param|null, - * recipients?: list, - * allowed_recipients?: list, + * recipients?: string|list, + * allowed_recipients?: string|list, * }, * headers?: array, + * limiters?: string|list, * limit?: int|Param, // The maximum allowed hits in a fixed interval or burst. * interval?: scalar|Param|null, // Configures the fixed interval if "policy" is set to "fixed_window" or "sliding_window". The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent). * rate?: array{ // Configures the fill rate if "policy" is set to "token_bucket". @@ -658,20 +658,20 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * allow_safe_elements?: bool|Param, // Allows "safe" elements and attributes. // Default: false * allow_static_elements?: bool|Param, // Allows all static elements and attributes from the W3C Sanitizer API standard. // Default: false * allow_elements?: array, - * block_elements?: list, - * drop_elements?: list, + * block_elements?: string|list, + * drop_elements?: string|list, * allow_attributes?: array, * drop_attributes?: array, * force_attributes?: array>, * force_https_urls?: bool|Param, // Transforms URLs using the HTTP scheme to use the HTTPS scheme instead. // Default: false - * allowed_link_schemes?: list, - * allowed_link_hosts?: list|null, + * allowed_link_schemes?: string|list, + * allowed_link_hosts?: null|string|list, * allow_relative_links?: bool|Param, // Allows relative URLs to be used in links href attributes. // Default: false - * allowed_media_schemes?: list, - * allowed_media_hosts?: list|null, + * allowed_media_schemes?: string|list, + * allowed_media_hosts?: null|string|list, * allow_relative_medias?: bool|Param, // Allows relative URLs to be used in media source attributes (img, audio, video, ...). // Default: false - * with_attribute_sanitizers?: list, - * without_attribute_sanitizers?: list, + * with_attribute_sanitizers?: string|list, + * without_attribute_sanitizers?: string|list, * max_input_length?: int|Param, // The maximum length allowed for the sanitized input. // Default: 0 * }>, * }, @@ -958,7 +958,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * }, * password_hashers?: array, + * migrate_from?: string|list, * hash_algorithm?: scalar|Param|null, // Name of hashing algorithm for PBKDF2 (i.e. sha256, sha512, etc..) See hash_algos() for a list of supported algorithms. // Default: "sha512" * key_length?: scalar|Param|null, // Default: 40 * ignore_case?: bool|Param, // Default: false @@ -972,7 +972,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * providers?: array, + * providers?: string|list, * }, * entity?: array{ * class?: scalar|Param|null, // The full entity class name of your user class. @@ -982,7 +982,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * memory?: array{ * users?: array, + * roles?: string|list, * }>, * }, * ldap?: array{ @@ -991,7 +991,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * search_dn?: scalar|Param|null, // Default: null * search_password?: scalar|Param|null, // Default: null * extra_fields?: list, - * default_roles?: list, + * default_roles?: string|list, * role_fetcher?: scalar|Param|null, // Default: null * uid_key?: scalar|Param|null, // Default: "sAMAccountName" * filter?: scalar|Param|null, // Default: "({uid_key}={user_identifier})" @@ -1005,7 +1005,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * firewalls?: array, + * methods?: string|list, * security?: bool|Param, // Default: true * user_checker?: scalar|Param|null, // The UserChecker to use when authenticating users in this firewall. // Default: "security.user_checker" * request_matcher?: scalar|Param|null, @@ -1024,8 +1024,8 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * path?: scalar|Param|null, // Default: "/logout" * target?: scalar|Param|null, // Default: "/" * invalidate_session?: bool|Param, // Default: true - * clear_site_data?: list<"*"|"cache"|"cookies"|"storage"|"executionContexts"|Param>, - * delete_cookies?: array, + * delete_cookies?: string|array, + * token_extractors?: string|list, * token_handler?: string|array{ * id?: scalar|Param|null, * oidc_user_info?: string|array{ @@ -1254,7 +1255,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * }, * oidc?: array{ * discovery?: array{ // Enable the OIDC discovery. - * base_uri?: list, + * base_uri?: string|list, * cache?: array{ * id?: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration. * }, @@ -1297,7 +1298,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * remember_me?: array{ * secret?: scalar|Param|null, // Default: "%kernel.secret%" * service?: scalar|Param|null, - * user_providers?: list, + * user_providers?: string|list, * catch_exceptions?: bool|Param, // Default: true * signature_properties?: list, * token_provider?: string|array{ @@ -1325,12 +1326,12 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * path?: scalar|Param|null, // Use the urldecoded format. // Default: null * host?: scalar|Param|null, // Default: null * port?: int|Param, // Default: null - * ips?: list, + * ips?: string|list, * attributes?: array, * route?: scalar|Param|null, // Default: null - * methods?: list, + * methods?: string|list, * allow_if?: scalar|Param|null, // Default: null - * roles?: list, + * roles?: string|list, * }>, * role_hierarchy?: array>, * } @@ -1351,7 +1352,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * auto_reload?: scalar|Param|null, * optimizations?: int|Param, * default_path?: scalar|Param|null, // The default path used to load templates. // Default: "%kernel.project_dir%/templates" - * file_name_pattern?: list, + * file_name_pattern?: string|list, * paths?: array, * date?: array{ // The default format options used by the date filter. * format?: scalar|Param|null, // Default: "F j, Y H:i" @@ -1452,7 +1453,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * delay_between_messages?: bool|Param, // Default: false * topic?: int|Param, // Default: null * factor?: int|Param, // Default: 1 - * tags?: list, + * tags?: string|list, * console_formatter_options?: mixed, // Default: [] * formatter?: scalar|Param|null, * nested?: bool|Param, // Default: false @@ -1496,7 +1497,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * host?: scalar|Param|null, * }, * from_email?: scalar|Param|null, - * to_email?: list, + * to_email?: string|list, * subject?: scalar|Param|null, * content_type?: scalar|Param|null, // Default: null * headers?: list, @@ -1550,7 +1551,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * template_parameters?: array{ // Default parameters to be passed to the template * className?: scalar|Param|null, // Default class attribute to apply to the root table elements // Default: "table table-bordered" * columnFilter?: "thead"|"tfoot"|"both"|Param|null, // If and where to enable the DataTables Filter module // Default: null - * ... + * ... * }, * translation_domain?: scalar|Param|null, // Default translation domain to be used // Default: "messages" * } @@ -1586,7 +1587,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * }, * filesystem?: array{ * locator?: "filesystem"|"filesystem_insecure"|Param, // Using the "filesystem_insecure" locator is not recommended due to a less secure resolver mechanism, but is provided for those using heavily symlinked projects. // Default: "filesystem" - * data_root?: list, + * data_root?: string|list, * allow_unresolvable_data_roots?: bool|Param, // Default: false * bundle_resources?: array{ * enabled?: bool|Param, // Default: false @@ -1705,14 +1706,14 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * use_underscore?: bool|Param, // Default: true * unordered_list_markers?: list, * }, - * ... + * ... * }, * } * @psalm-type GregwarCaptchaConfig = array{ * length?: scalar|Param|null, // Default: 5 * width?: scalar|Param|null, // Default: 130 * height?: scalar|Param|null, // Default: 50 - * font?: scalar|Param|null, // Default: "C:\\Users\\mail\\Documents\\PHP\\Part-DB-server\\vendor\\gregwar\\captcha-bundle\\DependencyInjection/../Generator/Font/captcha.ttf" + * font?: scalar|Param|null, // Default: "/home/jan/php/Part-DB-server/vendor/gregwar/captcha-bundle/DependencyInjection/../Generator/Font/captcha.ttf" * keep_value?: scalar|Param|null, // Default: false * charset?: scalar|Param|null, // Default: "abcdefhjkmnprstuvwxyz23456789" * as_file?: scalar|Param|null, // Default: false @@ -1930,7 +1931,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * script-src?: list, * style-src?: list, * upgrade-insecure-requests?: bool|Param, // Default: false - * report-uri?: list, + * report-uri?: string|list, * worker-src?: list, * prefetch-src?: list, * report-to?: scalar|Param|null, @@ -1958,7 +1959,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * script-src?: list, * style-src?: list, * upgrade-insecure-requests?: bool|Param, // Default: false - * report-uri?: list, + * report-uri?: string|list, * worker-src?: list, * prefetch-src?: list, * report-to?: scalar|Param|null, @@ -1966,7 +1967,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * }, * referrer_policy?: bool|array{ * enabled?: bool|Param, // Default: false - * policies?: list, + * policies?: string|list, * }, * permissions_policy?: bool|array{ * enabled?: bool|Param, // Default: false @@ -2097,10 +2098,10 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * secured_rp_ids?: array, * counter_checker?: scalar|Param|null, // This service will check if the counter is valid. By default it throws an exception (recommended). // Default: "Webauthn\\Counter\\ThrowExceptionIfInvalid" * top_origin_validator?: scalar|Param|null, // For cross origin (e.g. iframe), this service will be in charge of verifying the top origin. // Default: null - * creation_profiles?: array, * public_key_credential_parameters?: list, * attestation_conveyance?: scalar|Param|null, // Default: "none" + * conditional_create?: bool|Param, // Enable Conditional Create (auto-register) for this profile. When true, user presence can be false after password authentication. See https://github.com/w3c/webauthn/wiki/Explainer:-Conditional-Create // Default: false * }>, - * request_profiles?: array, * }>, + * client_override_policy?: array{ // Configuration for allowing client request values to override profile configuration + * user_verification?: array{ + * enabled?: bool|Param, // Whether to allow client requests to override the user verification requirement // Default: false + * allowed_values?: list, + * }, + * authenticator_attachment?: array{ + * enabled?: bool|Param, // Whether to allow client requests to override the authenticator attachment // Default: true + * allowed_values?: list, + * }, + * resident_key?: array{ + * enabled?: bool|Param, // Whether to allow client requests to override the resident key requirement // Default: true + * allowed_values?: list, + * }, + * attestation_conveyance?: array{ + * enabled?: bool|Param, // Whether to allow client requests to override the attestation conveyance preference // Default: true + * allowed_values?: list, + * }, + * extensions?: array{ + * enabled?: bool|Param, // Whether to allow client requests to override extensions // Default: true + * }, + * mediation?: array{ + * enabled?: bool|Param, // Whether to allow client requests to request the conditional mediation flow (auto-register). // Default: false + * allowed_values?: list, + * }, + * }, * metadata?: bool|array{ // Enable the support of the Metadata Statements. Please read the documentation for this feature. * enabled?: bool|Param, // Default: false * mds_repository?: scalar|Param|null, // The Metadata Statement repository. @@ -2165,6 +2192,21 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * secured_rp_ids?: array, * }>, * }, + * passkey_endpoints?: bool|array{ // Enable the .well-known/passkey-endpoints discovery endpoint as defined in the W3C Passkey Endpoints specification. + * enabled?: bool|Param, // Default: false + * enroll?: string|array{ // URL to the passkey enrollment/creation interface. + * path?: scalar|Param|null, // The absolute HTTPS URL or Symfony route name. + * params?: list, + * }, + * manage?: string|array{ // URL to the passkey management interface. + * path?: scalar|Param|null, // The absolute HTTPS URL or Symfony route name. + * params?: list, + * }, + * prf_usage_details?: string|array{ // URL to informational page about PRF (Pseudo-Random Function) extension usage. + * path?: scalar|Param|null, // The absolute HTTPS URL or Symfony route name. + * params?: list, + * }, + * }, * } * @psalm-type NbgrpOneloginSamlConfig = array{ // nb:group OneLogin PHP Symfony Bundle configuration * onelogin_settings?: array, * }, - * keys_patterns?: list, + * keys_patterns?: string|list, * } * @psalm-type DompdfFontLoaderConfig = array{ * autodiscovery?: bool|array{ @@ -2353,6 +2395,8 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * proxy_namespace?: scalar|Param|null, // Default: "Jbtronics\\SettingsBundle\\Proxies" * default_storage_adapter?: scalar|Param|null, // Default: null * save_after_migration?: bool|Param, // Default: true + * yaml_mapping_paths?: list, + * metadata_compiler_providers?: list, * file_storage?: array{ * storage_directory?: scalar|Param|null, // Default: "%kernel.project_dir%/var/jbtronics_settings/" * default_filename?: scalar|Param|null, // Default: "settings" @@ -2390,6 +2434,9 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * serialize_payload_fields?: mixed, // Set to null to serialize all payload fields when a validation error is thrown, or set the fields you want to include explicitly. // Default: [] * query_parameter_validation?: bool|Param, // Deprecated: Will be removed in API Platform 5.0. // Default: true * }, + * jsonapi?: array{ + * use_iri_as_id?: bool|Param, // Set to false to use entity identifiers instead of IRIs as the "id" field in JSON:API responses. // Default: true + * }, * eager_loading?: bool|array{ * enabled?: bool|Param, // Default: true * fetch_partial?: bool|Param, // Fetch only partial data according to serialization groups. If enabled, Doctrine ORM entities will not work as expected if any of the other fields are used. // Default: false @@ -2401,11 +2448,12 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * enable_json_streamer?: bool|Param, // Enable json streamer. // Default: false * enable_swagger_ui?: bool|Param, // Enable Swagger UI // Default: true * enable_re_doc?: bool|Param, // Enable ReDoc // Default: true + * enable_scalar?: bool|Param, // Enable Scalar API Reference // Default: true * enable_entrypoint?: bool|Param, // Enable the entrypoint // Default: true * enable_docs?: bool|Param, // Enable the docs // Default: true * enable_profiler?: bool|Param, // Enable the data collector and the WebProfilerBundle integration. // Default: true * enable_phpdoc_parser?: bool|Param, // Enable resource metadata collector using PHPStan PhpDocParser. // Default: true - * enable_link_security?: bool|Param, // Enable security for Links (sub resources) // Default: false + * enable_link_security?: bool|Param, // Deprecated: This option is always enabled and will be removed in API Platform 5.0. // Enable security for Links (sub resources). // Default: true * collection?: array{ * exists_parameter_name?: scalar|Param|null, // The name of the query parameter to filter on nullable field values. // Default: "exists" * order?: scalar|Param|null, // The default order of results. // Default: "ASC" @@ -2489,7 +2537,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * max_header_length?: int|Param, // Max header length supported by the cache server. // Default: 7500 * request_options?: mixed, // To pass options to the client charged with the request. // Default: [] * purger?: scalar|Param|null, // Specify a purger to use (available values: "api_platform.http_cache.purger.varnish.ban", "api_platform.http_cache.purger.varnish.xkey", "api_platform.http_cache.purger.souin"). // Default: "api_platform.http_cache.purger.varnish" - * xkey?: array{ // Deprecated: The "xkey" configuration is deprecated, use your own purger to customize surrogate keys or the appropriate paramters. + * xkey?: array{ // Deprecated: The "xkey" configuration is deprecated, use your own purger to customize surrogate keys or the appropriate parameters. * glue?: scalar|Param|null, // xkey glue between keys // Default: " " * }, * }, @@ -2505,6 +2553,9 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * elasticsearch?: bool|array{ * enabled?: bool|Param, // Default: false * hosts?: list, + * ssl_ca_bundle?: scalar|Param|null, // Path to the SSL CA bundle file for Elasticsearch SSL verification. // Default: null + * ssl_verification?: bool|Param, // Enable or disable SSL verification for Elasticsearch connections. // Default: true + * client?: "elasticsearch"|"opensearch"|Param, // The search engine client to use: "elasticsearch" or "opensearch". // Default: "elasticsearch" * }, * openapi?: array{ * contact?: array{ @@ -2523,12 +2574,18 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * identifier?: scalar|Param|null, // An SPDX license expression for the API. The identifier field is mutually exclusive of the url field. // Default: null * }, * swagger_ui_extra_configuration?: mixed, // To pass extra configuration to Swagger UI, like docExpansion or filter. // Default: [] + * scalar_extra_configuration?: mixed, // To pass extra configuration to Scalar API Reference, like theme or darkMode. // Default: [] * overrideResponses?: bool|Param, // Whether API Platform adds automatic responses to the OpenAPI documentation. // Default: true * error_resource_class?: scalar|Param|null, // The class used to represent errors in the OpenAPI documentation. // Default: null * validation_error_resource_class?: scalar|Param|null, // The class used to represent validation errors in the OpenAPI documentation. // Default: null * }, * maker?: bool|array{ * enabled?: bool|Param, // Default: true + * namespace_prefix?: scalar|Param|null, // Add a prefix to all maker generated classes. e.g set it to "Api" to set the maker namespace to "App\Api\" (if the maker.root_namespace config is App). e.g. App\Api\State\MyStateProcessor // Default: "" + * }, + * mcp?: bool|array{ + * enabled?: bool|Param, // Default: true + * format?: scalar|Param|null, // The serialization format used for MCP tool input/output. Must be a format registered in api_platform.formats (e.g. "jsonld", "json", "jsonapi"). // Default: "jsonld" * }, * exception_to_status?: array, * formats?: array + * }>, * strict_query_parameter_validation?: mixed, * hide_hydra_operation?: mixed, * json_stream?: mixed, * extra_properties?: mixed, * map?: mixed, + * mcp?: mixed, * route_name?: mixed, * errors?: mixed, * read?: mixed, @@ -2626,13 +2708,473 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * validate?: mixed, * write?: mixed, * serialize?: mixed, + * content_negotiation?: mixed, * priority?: mixed, * name?: mixed, * allow_create?: mixed, * item_uri_template?: mixed, - * ... + * ... * }, * } + * @psalm-type AiConfig = array{ + * platform?: array{ + * albert?: array{ + * api_key?: string|Param, + * base_url?: string|Param, + * http_client?: string|Param, // Service ID of the HTTP client to use // Default: "http_client" + * }, + * amazeeai?: array{ + * base_url?: string|Param, + * api_key?: string|Param, + * http_client?: string|Param, // Service ID of the HTTP client to use // Default: "http_client" + * }, + * anthropic?: array{ + * api_key?: string|Param, + * version?: string|Param, // Default: null + * http_client?: string|Param, // Service ID of the HTTP client to use // Default: "http_client" + * cache_retention?: "none"|"short"|"long"|Param, // Prompt cache retention policy for Anthropic models // Default: "short" + * }, + * azure?: array, + * bedrock?: array, + * cache?: array, + * cartesia?: array{ + * api_key?: string|Param, + * version?: string|Param, + * http_client?: string|Param, // Service ID of the HTTP client to use // Default: "http_client" + * }, + * cerebras?: array{ + * api_key?: string|Param, + * http_client?: string|Param, // Service ID of the HTTP client to use // Default: "http_client" + * }, + * cohere?: array{ + * api_key?: string|Param, + * http_client?: string|Param, // Service ID of the HTTP client to use // Default: "http_client" + * }, + * decart?: array{ + * api_key?: string|Param, + * host?: string|Param, // Default: "https://api.decart.ai/v1" + * http_client?: string|Param, // Service ID of the HTTP client to use // Default: "http_client" + * }, + * deepseek?: array{ + * api_key?: string|Param, + * http_client?: string|Param, // Service ID of the HTTP client to use // Default: "http_client" + * }, + * dockermodelrunner?: array{ + * host_url?: string|Param, // Default: "http://127.0.0.1:12434" + * http_client?: string|Param, // Service ID of the HTTP client to use // Default: "http_client" + * }, + * elevenlabs?: array{ + * api_key?: string|Param, + * endpoint?: string|Param, // Default: "https://api.elevenlabs.io/v1/" + * http_client?: string|Param, // Service ID of the HTTP client to use // Default: "http_client" + * }, + * failover?: array, + * rate_limiter?: string|Param, + * }>, + * gemini?: array{ + * api_key?: string|Param, + * http_client?: string|Param, // Service ID of the HTTP client to use // Default: "http_client" + * }, + * generic?: array, + * huggingface?: array{ + * api_key?: string|Param, + * provider?: string|Param, // Default: "hf-inference" + * http_client?: string|Param, // Service ID of the HTTP client to use // Default: "http_client" + * }, + * lmstudio?: array{ + * host_url?: string|Param, // Default: "http://127.0.0.1:1234" + * http_client?: string|Param, // Service ID of the HTTP client to use // Default: "http_client" + * }, + * mistral?: array{ + * api_key?: string|Param, + * http_client?: string|Param, // Service ID of the HTTP client to use // Default: "http_client" + * }, + * ollama?: array{ + * endpoint?: string|Param, // Endpoint for Ollama (e.g. "http://127.0.0.1:11434" for local, or a cloud endpoint). If null, the http_client is used as-is and must already be configured with a base URI. + * api_key?: string|Param, // API key for Ollama Cloud authentication (optional for local usage) + * http_client?: string|Param, // Service ID of the HTTP client to use. When "endpoint" is null, this client must be pre-configured (e.g. with a base_uri). // Default: "http_client" + * }, + * openai?: array{ + * api_key?: string|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" + * }, + * openrouter?: array{ + * api_key?: string|Param, + * http_client?: string|Param, // Service ID of the HTTP client to use // Default: "http_client" + * }, + * ovh?: array{ + * api_key?: scalar|Param|null, + * http_client?: string|Param, // Service ID of the HTTP client to use // Default: "http_client" + * }, + * perplexity?: array{ + * api_key?: string|Param, + * http_client?: string|Param, // Service ID of the HTTP client to use // Default: "http_client" + * }, + * scaleway?: array{ + * api_key?: scalar|Param|null, + * http_client?: string|Param, // Service ID of the HTTP client to use // Default: "http_client" + * }, + * transformersphp?: array, + * vertexai?: array{ + * location?: string|Param, // Required for the project-scoped endpoint. Must be set together with "project_id". // Default: null + * project_id?: string|Param, // Required for the project-scoped endpoint. Must be set together with "location". // Default: null + * api_key?: string|Param, // When set without "location" and "project_id", uses the global endpoint. Note: API keys only identify the project for billing and do not provide identity-based access control. For production use with IAM, audit logging, or data residency, prefer the project-scoped endpoint with service account authentication. // Default: null + * http_client?: string|Param, // Service ID of the HTTP client to use // Default: "http_client" + * }, + * voyage?: array{ + * api_key?: string|Param, + * http_client?: string|Param, // Service ID of the HTTP client to use // Default: "http_client" + * }, + * }, + * model?: array|\Symfony\AI\Platform\Capability|Param>, + * }>>, + * agent?: array, + * }, + * keep_tool_messages?: bool|Param, // Keep tool messages in the conversation history // Default: false + * include_sources?: bool|Param, // Include sources exposed by tools as part of the tool result metadata // Default: false + * fault_tolerant_toolbox?: bool|Param, // Continue the agent run even if a tool call fails // Default: true + * speech?: bool|array{ // Speech (TTS/STT) decorator configuration + * enabled?: bool|Param, // Default: true + * text_to_speech_platform?: string|Param, // Service name of the TTS platform (e.g. ai.platform.elevenlabs). // Default: null + * speech_to_text_platform?: string|Param, // Service name of the STT platform (e.g. ai.platform.openai). // Default: null + * tts_model?: string|Param, // Text-to-speech model name // Default: null + * tts_options?: mixed, // Provider-specific TTS options // Default: [] + * stt_model?: string|Param, // Speech-to-text model name // Default: null + * stt_options?: mixed, // Provider-specific STT options // Default: [] + * }, + * }>, + * multi_agent?: array>, + * fallback?: string|Param, // Service ID of the fallback agent for unmatched requests + * }>, + * store?: array{ + * azuresearch?: array, + * cache?: array, + * chromadb?: array, + * clickhouse?: array, + * cloudflare?: array, + * elasticsearch?: array, + * manticoresearch?: array, + * mariadb?: array, + * meilisearch?: array, + * memory?: array, + * milvus?: array, + * mongodb?: array, + * neo4j?: array, + * opensearch?: array, + * pinecone?: array, + * top_k?: int|Param, + * }>, + * postgres?: array, + * qdrant?: array, + * redis?: array, + * s3vectors?: array, + * vector_bucket_name?: string|Param, + * index_name?: string|Param, + * filter?: array, + * top_k?: int|Param, // Default number of results to return // Default: 3 + * }>, + * sqlite?: array, + * supabase?: array, + * surrealdb?: array, + * typesense?: array, + * weaviate?: array, + * vektor?: array, + * }, + * message_store?: array{ + * cache?: array, + * cloudflare?: array, + * doctrine?: array{ + * dbal?: array, + * }, + * meilisearch?: array, + * memory?: array, + * mongodb?: array, + * pogocache?: array, + * redis?: array, + * session?: array, + * surrealdb?: array, + * }, + * chat?: array, + * vectorizer?: array, + * indexer?: array, + * filters?: list, + * vectorizer?: scalar|Param|null, // Service name of vectorizer // Default: "Symfony\\AI\\Store\\Document\\VectorizerInterface" + * store?: string|Param, // Service name of store // Default: "Symfony\\AI\\Store\\StoreInterface" + * }>, + * retriever?: array, + * } * @psalm-type ConfigType = array{ * imports?: ImportsConfig, * parameters?: ParametersConfig, @@ -2662,6 +3204,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * nelmio_cors?: NelmioCorsConfig, * jbtronics_settings?: JbtronicsSettingsConfig, * api_platform?: ApiPlatformConfig, + * ai?: AiConfig, * "when@dev"?: array{ * imports?: ImportsConfig, * parameters?: ParametersConfig, @@ -2695,6 +3238,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * jbtronics_settings?: JbtronicsSettingsConfig, * jbtronics_translation_editor?: JbtronicsTranslationEditorConfig, * api_platform?: ApiPlatformConfig, + * ai?: AiConfig, * }, * "when@docker"?: array{ * imports?: ImportsConfig, @@ -2725,6 +3269,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * nelmio_cors?: NelmioCorsConfig, * jbtronics_settings?: JbtronicsSettingsConfig, * api_platform?: ApiPlatformConfig, + * ai?: AiConfig, * }, * "when@prod"?: array{ * imports?: ImportsConfig, @@ -2755,6 +3300,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * nelmio_cors?: NelmioCorsConfig, * jbtronics_settings?: JbtronicsSettingsConfig, * api_platform?: ApiPlatformConfig, + * ai?: AiConfig, * }, * "when@test"?: array{ * imports?: ImportsConfig, @@ -2788,6 +3334,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * nelmio_cors?: NelmioCorsConfig, * jbtronics_settings?: JbtronicsSettingsConfig, * api_platform?: ApiPlatformConfig, + * ai?: AiConfig, * }, * ... If you run a root console inside the docker container, and wanna execute commands on the webserver behalf, be sure to use `sudo -E` command (with the `-E` flag) to preserve env variables from the current shell. +> Otherwise Part-DB console might use the wrong configuration to execute commands. + + 6. Create the initial database with ```bash @@ -219,6 +224,52 @@ docker-compose up -d docker exec --user=www-data partdb php bin/console doctrine:migrations:migrate ``` +### Automatic updates via Watchtower (Web UI) + +Part-DB supports triggering Docker container updates directly from the web interface using [Watchtower](https://github.com/nicholas-fedor/watchtower). +When configured, administrators can check for and apply updates from the **System > Update Manager** page. + +{: .info } +> The original `containrrr/watchtower` project is no longer maintained (last release November 2023). These docs use the actively maintained community fork at [`nicholas-fedor/watchtower`](https://github.com/nicholas-fedor/watchtower), which is drop-in compatible with the original HTTP API. + +To enable this feature, add a Watchtower service to your `docker-compose.yaml` and configure the connection: + +```yaml +services: + partdb: + container_name: partdb + image: jbtronics/part-db1:latest + labels: + - com.centurylinklabs.watchtower.enable=true + environment: + # ... your existing environment variables ... + + # Watchtower integration for web-based updates + - WATCHTOWER_API_URL=http://watchtower:8080 + - WATCHTOWER_API_TOKEN=your-secret-token + # ... your existing ports/volumes ... + + watchtower: + image: ghcr.io/nicholas-fedor/watchtower:latest + container_name: watchtower + restart: unless-stopped + volumes: + - /var/run/docker.sock:/var/run/docker.sock + environment: + - WATCHTOWER_HTTP_API_UPDATE=true + - WATCHTOWER_HTTP_API_TOKEN=your-secret-token + - WATCHTOWER_LABEL_ENABLE=true + - WATCHTOWER_CLEANUP=true +``` + +{: .important } +> Replace `your-secret-token` with a strong, unique token. The same token must be set in both the Part-DB (`WATCHTOWER_API_TOKEN`) and Watchtower (`WATCHTOWER_HTTP_API_TOKEN`) environment variables. + +{: .info } +> `WATCHTOWER_LABEL_ENABLE=true` ensures Watchtower only manages containers with the `com.centurylinklabs.watchtower.enable=true` label, preventing it from updating other containers on the same host. + +Once configured, the Update Manager page will show the Watchtower connection status and provide an **Update via Watchtower** button when a new version is available. Clicking it triggers Watchtower to pull the latest image and recreate the Part-DB container automatically. + ## Direct use of docker image You can use the `jbtronics/part-db1:master` image directly. You have to expose port 80 to a host port and configure diff --git a/docs/upgrade/1_to_2.md b/docs/upgrade/1_to_2.md index ef0f4575..af5f1aa6 100644 --- a/docs/upgrade/1_to_2.md +++ b/docs/upgrade/1_to_2.md @@ -18,7 +18,7 @@ fulfilled by the official Part-DB docker image.* Part-DB 2.0 requires at least PHP 8.2 (newer versions are recommended). So if your existing Part-DB installation is still running PHP 8.1, you will have to upgrade your PHP version first. -The minimum required version of node.js is now 20.0 or newer, so if you are using 18.0, you will have to upgrade it too. +The minimum required version of node.js is now 22.0 or newer, so if you are using 18.0, you will have to upgrade it too. Most distributions should have the possibility to get backports for PHP 8.4 and modern nodejs, so you should be able to easily upgrade your system to the new requirements. Otherwise, you can use the official Part-DB docker image, which @@ -60,6 +60,8 @@ The `php bin/console partdb:backup` command can help you with this. If you want to change them, you must migrate them to the settings interface as described below. ### Docker installation +**When running the console commands from inside a docker container's shell as root, be sure to use `sudo -E` to preserve the environment variables, so that they are correctly passed to the command.** + 1. Make a backup of your existing Part-DB installation, including the database, data directories and the configuration files and the file where you configure the docker environment variables. 2. Stop the existing Part-DB container with `docker compose down` 3. Ensure that your docker compose file uses the new latest images (either `latest` or `2` tag). diff --git a/docs/usage/ai.md b/docs/usage/ai.md new file mode 100644 index 00000000..3a1fb419 --- /dev/null +++ b/docs/usage/ai.md @@ -0,0 +1,27 @@ +--- +layout: default +title: AI features +nav_order: 6 +parent: Usage +--- + +# AI features + +Part-DB can utilize large language Models (LLMs) to provide AI-powered features that can assist you in managing your parts and projects. +For now this is mostly the ability to extract part information from websites without any structured data. + +## AI platforms + +Part-DB is platform agnostic and can work with different AI platforms, both locally and in the cloud. They can be configured in the "AI" tab in the system settings. +Currently, the following platforms are supported: + +### OpenRouter + +[OpenRouter](https://openrouter.ai/) is a platform that provides access to various LLMs, including models from OpenAI, Anthropic, and more. +You can use OpenRouter to connect to different LLMs and use them for Part-DB's AI features. +You need to supply an API key for OpenRouter to use it as an AI platform in Part-DB. + +### LMStudio + +[LMStudio](https://lmstudio.ai/) is a local LLM hosting solution that allows you to run LLMs on your own hardware. You can use LMStudio to host your own LLM and connect it to Part-DB for AI features. +Currently only LMStudio without any authentication is supported. Supply your LMStudio instance URL (including the port) to use it as an AI platform in Part-DB. diff --git a/docs/usage/eda_integration.md b/docs/usage/eda_integration.md index b99ed4dd..92b1244d 100644 --- a/docs/usage/eda_integration.md +++ b/docs/usage/eda_integration.md @@ -67,6 +67,7 @@ You can define this on a per-part basis using the KiCad symbol and KiCad footpri For example, to configure the values for a BC547 transistor you would put `Transistor_BJT:BC547` in the part's KiCad symbol field to give it the right schematic symbol in Eeschema and `Package_TO_SOT_THT:TO-92` to give it the right footprint in Pcbnew. If you type in a character, you will get an autocomplete list of all symbols and footprints available in the KiCad standard library. You can also input your own value. +If you want to keep custom suggestions across updates, open the server settings page and use the "Autocomplete settings" page. There you can edit `public/kicad/footprints_custom.txt` and `public/kicad/symbols_custom.txt` and enable the "Use custom autocomplete lists" option to use those files instead of the autogenerated defaults. ### Parts and category visibility diff --git a/docs/usage/information_provider_system.md b/docs/usage/information_provider_system.md index 1600d76f..223771c0 100644 --- a/docs/usage/information_provider_system.md +++ b/docs/usage/information_provider_system.md @@ -111,6 +111,19 @@ may have privacy and security implications. Following env configuration options are available: * `PROVIDER_GENERIC_WEB_ENABLED`: Set this to `1` to enable the Generic Web URL Provider (optional, default: `0`) +### AI Web Extractor +The AI web extractor provider can extract part information from any webpage using AI-based techniques. It is designed to handle unstructured data and can extract relevant information even from websites that do not use structured data formats like Schema.org. +This provider can be particularly useful for extracting information from websites that have complex layouts or do not follow standard e-commerce practices. +It also potentially extracts more detailed information than the Generic Web URL Provider, as it is not limited to the fields defined in the Schema.org format. + +To use the AI Web Extractor, you need to setup an AI platform, in the AI settings tab, and chose a model, which support structured output. +For many use cases a small and cheap model like `google/gemini-2.5-flash-lite` will be sufficient, coming down to costs like 0.001$ per request. +For more complex websites, or if you wanna use the LLM for translation purposes too, you should consider a more powerful model. + +You can add some additional instructions for the model, which gets added to the system prompt, to tweak the output of the model. + +The provider will download the HTML of the given URL, convert it to markdown and send it to the LLM toghether with structured data extracted from the webpage via conventional methods. + ### Octopart The Octopart provider uses the [Octopart / Nexar API](https://nexar.com/api) to search for parts and get information. diff --git a/migrations/Version20260307204859.php b/migrations/Version20260307204859.php new file mode 100644 index 00000000..325f41ab --- /dev/null +++ b/migrations/Version20260307204859.php @@ -0,0 +1,73 @@ +addSql('ALTER TABLE part_lots DROP INDEX part_lots_idx_barcode, ADD INDEX part_lots_idx_barcode (vendor_barcode(100))'); + $this->addSql('ALTER TABLE part_lots CHANGE vendor_barcode vendor_barcode LONGTEXT DEFAULT NULL'); + } + + public function mySQLDown(Schema $schema): void + { + $this->addSql('ALTER TABLE part_lots DROP INDEX part_lots_idx_barcode, ADD INDEX part_lots_idx_barcode (vendor_barcode)'); + $this->addSql('ALTER TABLE part_lots CHANGE vendor_barcode vendor_barcode VARCHAR(255) DEFAULT NULL'); + } + + public function sqLiteUp(Schema $schema): void + { + $this->addSql('CREATE TEMPORARY TABLE __temp__part_lots AS SELECT id, id_store_location, id_part, id_owner, description, comment, expiration_date, instock_unknown, amount, needs_refill, last_modified, datetime_added, vendor_barcode, last_stocktake_at FROM part_lots'); + $this->addSql('DROP TABLE part_lots'); + $this->addSql('CREATE TABLE part_lots (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, id_store_location INTEGER DEFAULT NULL, id_part INTEGER NOT NULL, id_owner INTEGER DEFAULT NULL, description CLOB NOT NULL, comment CLOB NOT NULL, expiration_date DATETIME DEFAULT NULL, instock_unknown BOOLEAN NOT NULL, amount DOUBLE PRECISION NOT NULL, needs_refill BOOLEAN NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, vendor_barcode CLOB DEFAULT NULL, last_stocktake_at DATETIME DEFAULT NULL, CONSTRAINT FK_EBC8F9435D8F4B37 FOREIGN KEY (id_store_location) REFERENCES storelocations (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_EBC8F943C22F6CC4 FOREIGN KEY (id_part) REFERENCES parts (id) ON UPDATE NO ACTION ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_EBC8F94321E5A74C FOREIGN KEY (id_owner) REFERENCES users (id) ON UPDATE NO ACTION ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO part_lots (id, id_store_location, id_part, id_owner, description, comment, expiration_date, instock_unknown, amount, needs_refill, last_modified, datetime_added, vendor_barcode, last_stocktake_at) SELECT id, id_store_location, id_part, id_owner, description, comment, expiration_date, instock_unknown, amount, needs_refill, last_modified, datetime_added, vendor_barcode, last_stocktake_at FROM __temp__part_lots'); + $this->addSql('DROP TABLE __temp__part_lots'); + $this->addSql('CREATE INDEX part_lots_idx_needs_refill ON part_lots (needs_refill)'); + $this->addSql('CREATE INDEX part_lots_idx_instock_un_expiration_id_part ON part_lots (instock_unknown, expiration_date, id_part)'); + $this->addSql('CREATE INDEX IDX_EBC8F9435D8F4B37 ON part_lots (id_store_location)'); + $this->addSql('CREATE INDEX IDX_EBC8F943C22F6CC4 ON part_lots (id_part)'); + $this->addSql('CREATE INDEX IDX_EBC8F94321E5A74C ON part_lots (id_owner)'); + $this->addSql('CREATE INDEX part_lots_idx_barcode ON part_lots (vendor_barcode)'); + } + + public function sqLiteDown(Schema $schema): void + { + $this->addSql('CREATE TEMPORARY TABLE __temp__part_lots AS SELECT id, description, comment, expiration_date, instock_unknown, amount, needs_refill, vendor_barcode, last_stocktake_at, last_modified, datetime_added, id_store_location, id_part, id_owner FROM part_lots'); + $this->addSql('DROP TABLE part_lots'); + $this->addSql('CREATE TABLE part_lots (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, description CLOB NOT NULL, comment CLOB NOT NULL, expiration_date DATETIME DEFAULT NULL, instock_unknown BOOLEAN NOT NULL, amount DOUBLE PRECISION NOT NULL, needs_refill BOOLEAN NOT NULL, vendor_barcode VARCHAR(255) DEFAULT NULL, last_stocktake_at DATETIME DEFAULT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, id_store_location INTEGER DEFAULT NULL, id_part INTEGER NOT NULL, id_owner INTEGER DEFAULT NULL, CONSTRAINT FK_EBC8F9435D8F4B37 FOREIGN KEY (id_store_location) REFERENCES "storelocations" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_EBC8F943C22F6CC4 FOREIGN KEY (id_part) REFERENCES "parts" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_EBC8F94321E5A74C FOREIGN KEY (id_owner) REFERENCES "users" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO part_lots (id, description, comment, expiration_date, instock_unknown, amount, needs_refill, vendor_barcode, last_stocktake_at, last_modified, datetime_added, id_store_location, id_part, id_owner) SELECT id, description, comment, expiration_date, instock_unknown, amount, needs_refill, vendor_barcode, last_stocktake_at, last_modified, datetime_added, id_store_location, id_part, id_owner FROM __temp__part_lots'); + $this->addSql('DROP TABLE __temp__part_lots'); + $this->addSql('CREATE INDEX IDX_EBC8F9435D8F4B37 ON part_lots (id_store_location)'); + $this->addSql('CREATE INDEX IDX_EBC8F943C22F6CC4 ON part_lots (id_part)'); + $this->addSql('CREATE INDEX IDX_EBC8F94321E5A74C ON part_lots (id_owner)'); + $this->addSql('CREATE INDEX part_lots_idx_instock_un_expiration_id_part ON part_lots (instock_unknown, expiration_date, id_part)'); + $this->addSql('CREATE INDEX part_lots_idx_needs_refill ON part_lots (needs_refill)'); + $this->addSql('CREATE INDEX part_lots_idx_barcode ON part_lots (vendor_barcode)'); + } + + public function postgreSQLUp(Schema $schema): void + { + $this->addSql('DROP INDEX part_lots_idx_barcode'); + $this->addSql('ALTER TABLE part_lots ALTER vendor_barcode TYPE TEXT'); + $this->addSql('CREATE INDEX part_lots_idx_barcode ON part_lots (vendor_barcode)'); + } + + public function postgreSQLDown(Schema $schema): void + { + $this->addSql('DROP INDEX part_lots_idx_barcode'); + $this->addSql('ALTER TABLE part_lots ALTER vendor_barcode TYPE VARCHAR(255)'); + $this->addSql('CREATE INDEX part_lots_idx_barcode ON part_lots (vendor_barcode)'); + } +} diff --git a/package.json b/package.json index 583d0b42..99636d37 100644 --- a/package.json +++ b/package.json @@ -9,16 +9,16 @@ "@symfony/stimulus-bridge": "^4.0.0", "@symfony/ux-translator": "file:vendor/symfony/ux-translator/assets", "@symfony/ux-turbo": "file:vendor/symfony/ux-turbo/assets", - "@symfony/webpack-encore": "^5.1.0", + "@symfony/webpack-encore": "^6.0.0", "bootstrap": "^5.1.3", "core-js": "^3.38.0", - "intl-messageformat": "^10.2.5", + "intl-messageformat": "^10.5.11", "jquery": "^3.5.1", "popper.js": "^1.14.7", - "regenerator-runtime": "^0.13.9", + "regenerator-runtime": "^0.14.1", "webpack": "^5.74.0", "webpack-bundle-analyzer": "^5.1.1", - "webpack-cli": "^5.1.0", + "webpack-cli": "^6.0.0", "webpack-notifier": "^1.15.0" }, "license": "AGPL-3.0-or-later", @@ -30,14 +30,12 @@ "build": "encore production --progress" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" }, "dependencies": { "@algolia/autocomplete-js": "^1.17.0", "@algolia/autocomplete-plugin-recent-searches": "^1.17.0", "@algolia/autocomplete-theme-classic": "^1.17.0", - "@ckeditor/ckeditor5-dev-translations": "^43.0.1", - "@ckeditor/ckeditor5-dev-utils": "^43.0.1", "@jbtronics/bs-treeview": "^1.0.1", "@part-db/html5-qrcode": "^4.0.0", "@zxcvbn-ts/core": "^3.0.2", @@ -51,7 +49,7 @@ "bootbox": "^6.0.0", "bootswatch": "^5.1.3", "bs-custom-file-input": "^1.3.4", - "ckeditor5": "^47.0.0", + "ckeditor5": "^48.0.0", "clipboard": "^2.0.4", "compression-webpack-plugin": "^11.1.0", "datatables.net": "^2.0.0", @@ -69,11 +67,11 @@ "marked": "^17.0.1", "marked-gfm-heading-id": "^4.1.1", "marked-mangle": "^1.0.1", - "pdfmake": "^0.2.2", + "pdfmake": "^0.3.7", "stimulus-use": "^0.52.0", "tom-select": "^2.1.0", "ts-loader": "^9.2.6", - "typescript": "^5.7.2" + "typescript": "^6.0.2" }, "resolutions": { "jquery": "^3.5.1" diff --git a/phpstan.banned_code.neon b/phpstan.banned_code.neon new file mode 100644 index 00000000..3099c384 --- /dev/null +++ b/phpstan.banned_code.neon @@ -0,0 +1,86 @@ +# Manually configure ekino/phpstan-banned-code to detect usage of echo, eval, die/exit, print, shell execution and a set of functions that should not be used in production code. + +parametersSchema: + banned_code: structure([ + nodes: listOf(structure([ + type: string() + functions: schema(listOf(string()), nullable()) + ])) + use_from_tests: bool() + non_ignorable: bool() + ]) + +parameters: + banned_code: + nodes: + # enable detection of echo + - + type: Stmt_Echo + functions: null + + # enable detection of eval + - + type: Expr_Eval + functions: null + + # enable detection of die/exit + - + type: Expr_Exit + functions: null + + # enable detection of a set of functions + - + type: Expr_FuncCall + functions: + - dd + - debug_backtrace + - dump + - exec + - passthru + - phpinfo + - print_r + - proc_open + - shell_exec + - system + - var_dump + + # enable detection of print statements + - + type: Expr_Print + functions: null + + # enable detection of shell execution by backticks + - + type: Expr_ShellExec + functions: null + + # enable detection of empty() + #- + # type: Expr_Empty + # functions: null + + # enable detection of `use Tests\Foo\Bar` in a non-test file + use_from_tests: true + + # when true, errors cannot be excluded + non_ignorable: false + +services: + - + class: Ekino\PHPStanBannedCode\Rules\BannedNodesRule + tags: + - phpstan.rules.rule + arguments: + - '%banned_code.nodes%' + + - + class: Ekino\PHPStanBannedCode\Rules\BannedUseTestRule + tags: + - phpstan.rules.rule + arguments: + - '%banned_code.use_from_tests%' + + - + class: Ekino\PHPStanBannedCode\Rules\BannedNodesErrorBuilder + arguments: + - '%banned_code.non_ignorable%' diff --git a/phpstan.dist.neon b/phpstan.dist.neon index b03c20c2..c7da636f 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -1,3 +1,6 @@ +includes: + - phpstan.banned_code.neon + parameters: level: 5 @@ -6,9 +9,6 @@ parameters: - src # - tests - banned_code: - non_ignorable: false # Allow to ignore some banned code - excludePaths: - src/DataTables/Adapter/* - src/Configuration/* @@ -59,6 +59,9 @@ parameters: - '#expects .*PartParameter, .*AbstractParameter given.#' - '#Part::getParameters\(\) should return .*AbstractParameter#' + # Fix some weird issue with how covariance with collections is solved + - '#Method App\\Entity\\Base\\AbstractStructuralDBElement::getParameters\(\) should return Doctrine\\Common\\Collections\\Collection but returns#' + # Ignore doctrine type mapping mismatch - '#Property .* type mapping mismatch: property can contain .* but database expects .*#' @@ -70,3 +73,6 @@ parameters: - message: '#Access to an undefined property Brick\\Schema\\Interfaces\\#' path: src/Services/InfoProviderSystem/Providers/GenericWebProvider.php + + - + identifier: nullCoalesce.property diff --git a/public/kicad/.gitignore b/public/kicad/.gitignore new file mode 100644 index 00000000..1f2ab53d --- /dev/null +++ b/public/kicad/.gitignore @@ -0,0 +1,3 @@ +# They are user generated and should not be tracked by git +footprints_custom.txt +symbols_custom.txt diff --git a/public/kicad/footprints.txt b/public/kicad/footprints.txt index be6020cb..c893ad4b 100644 --- a/public/kicad/footprints.txt +++ b/public/kicad/footprints.txt @@ -1,4 +1,4 @@ -# Generated on Sun Mar 1 11:46:09 UTC 2026 +# Generated on Mon May 4 05:40:05 UTC 2026 # This file contains all footprints available in the offical KiCAD library Audio_Module:Reverb_BTDR-1H Audio_Module:Reverb_BTDR-1V @@ -8366,6 +8366,7 @@ Converter_DCDC:Converter_DCDC_TRACO_TMR-1SM_SMD Converter_DCDC:Converter_DCDC_TRACO_TMR10-24xxWIR_48xxWIR_72xxWIR_THT Converter_DCDC:Converter_DCDC_TRACO_TMR2-xxxxWI_THT Converter_DCDC:Converter_DCDC_TRACO_TMR4-xxxxWI_THT +Converter_DCDC:Converter_DCDC_TRACO_TMR8-xxxxWI_THT Converter_DCDC:Converter_DCDC_TRACO_TMU3-05xx_12xx_THT Converter_DCDC:Converter_DCDC_TRACO_TMU3-24xx_THT Converter_DCDC:Converter_DCDC_TRACO_TMV-051xD_121xD_Dual_THT @@ -11978,6 +11979,8 @@ Package_DFN_QFN:VQFN-48-1EP_7x7mm_P0.5mm_EP4.2x4.2mm Package_DFN_QFN:VQFN-48-1EP_7x7mm_P0.5mm_EP4.2x4.2mm_ThermalVias Package_DFN_QFN:VQFN-48-1EP_7x7mm_P0.5mm_EP5.15x5.15mm Package_DFN_QFN:VQFN-48-1EP_7x7mm_P0.5mm_EP5.15x5.15mm_ThermalVias +Package_DFN_QFN:VQFN-52-1EP_6x6mm_P0.4mm_EP4.7x4.7mm +Package_DFN_QFN:VQFN-52-1EP_6x6mm_P0.4mm_EP4.7x4.7mm_ThermalVias Package_DFN_QFN:VQFN-56-1EP_8x8mm_P0.5mm_EP5.1x4.96mm Package_DFN_QFN:VQFN-56-1EP_8x8mm_P0.5mm_EP5.1x4.96mm_ThermalVias Package_DFN_QFN:VQFN-56-1EP_8x8mm_P0.5mm_EP5.5x5.06mm @@ -12028,6 +12031,8 @@ Package_DFN_QFN:WQFN-24-1EP_4x4mm_P0.5mm_EP2.45x2.45mm Package_DFN_QFN:WQFN-24-1EP_4x4mm_P0.5mm_EP2.45x2.45mm_ThermalVias Package_DFN_QFN:WQFN-24-1EP_4x4mm_P0.5mm_EP2.6x2.6mm Package_DFN_QFN:WQFN-24-1EP_4x4mm_P0.5mm_EP2.6x2.6mm_ThermalVias +Package_DFN_QFN:WQFN-28-1EP_3.5x5.5mm_P0.5mm_EP2.05x4.05mm +Package_DFN_QFN:WQFN-28-1EP_3.5x5.5mm_P0.5mm_EP2.05x4.05mm_ThermalVias Package_DFN_QFN:WQFN-28-1EP_4x4mm_P0.4mm_EP2.7x2.7mm Package_DFN_QFN:WQFN-28-1EP_4x4mm_P0.4mm_EP2.7x2.7mm_ThermalVias Package_DFN_QFN:WQFN-32-1EP_5x5mm_P0.5mm_EP3.1x3.1mm diff --git a/public/kicad/symbols.txt b/public/kicad/symbols.txt index 9941ad2c..f41aa152 100644 --- a/public/kicad/symbols.txt +++ b/public/kicad/symbols.txt @@ -1,4 +1,4 @@ -# Generated on Sun Mar 1 11:46:51 UTC 2026 +# Generated on Mon May 4 05:40:43 UTC 2026 # This file contains all symbols available in the offical KiCAD library 4xxx:14528 4xxx:14529 @@ -899,6 +899,7 @@ Amplifier_Buffer:BUF634AxD Amplifier_Buffer:BUF634AxDDA Amplifier_Buffer:BUF634AxDRB Amplifier_Buffer:BUF634U +Amplifier_Buffer:BUF802 Amplifier_Buffer:EL2001CN Amplifier_Buffer:LH0002H Amplifier_Buffer:LM6321H @@ -1667,7 +1668,6 @@ Analog_ADC:CA3300 Analog_ADC:HX711 Analog_ADC:ICL7106CPL Analog_ADC:ICL7107CPL -Analog_ADC:INA234AxYBJ Analog_ADC:LTC1406CGN Analog_ADC:LTC1406IGN Analog_ADC:LTC1594CS @@ -2198,6 +2198,7 @@ Audio:WM8731SEDS Audio:YM2149 Audio:YM2612 Audio:YM3438 +Auxiliary_Items:Generic_Outline Auxiliary_Items:Jumper_Shunt Auxiliary_Items:MountingScrew Battery_Management:ADP5063 @@ -2254,6 +2255,11 @@ Battery_Management:BQ76200PW Battery_Management:BQ76920PW Battery_Management:BQ76930DBT Battery_Management:BQ76940DBT +Battery_Management:BQ7695201PFBR +Battery_Management:BQ7695202PFBR +Battery_Management:BQ7695203PFBR +Battery_Management:BQ7695204PFBR +Battery_Management:BQ76952PFBR Battery_Management:BQ78350DBT Battery_Management:BQ78350DBT-R1 Battery_Management:CN3063 @@ -2763,6 +2769,8 @@ Connector:DIN41612_02x32_AC Connector:DIN41612_02x32_AE Connector:DIN41612_02x32_ZB Connector:DIN41612_03x32_C_Split +Connector:DP_Sink +Connector:DP_Source Connector:DVI-D_Dual_Link Connector:DVI-I_Dual_Link Connector:ExpressCard @@ -2901,6 +2909,7 @@ Connector:TestPoint_Alt Connector:TestPoint_Flag Connector:TestPoint_Probe Connector:TestPoint_Small +Connector:TestPoint_Square Connector:UEXT_Host Connector:UEXT_Slave Connector:USB3_A @@ -7772,6 +7781,7 @@ FPGA_Lattice:ICE40HX1K-TQ144 FPGA_Lattice:ICE40HX4K-BG121 FPGA_Lattice:ICE40HX4K-TQ144 FPGA_Lattice:ICE40HX8K-BG121 +FPGA_Lattice:ICE40LP384-SG32 FPGA_Lattice:ICE40UL1K-SWG16 FPGA_Lattice:ICE40UP5K-SG48ITR FPGA_Lattice:ICE5LP1K-SG48 @@ -8835,6 +8845,7 @@ Interface_USB:CH343G Interface_USB:CH343P Interface_USB:CH344Q Interface_USB:CH9102F +Interface_USB:CP2102C-Axx-xQFN24 Interface_USB:CP2102N-Axx-xQFN20 Interface_USB:CP2102N-Axx-xQFN24 Interface_USB:CP2102N-Axx-xQFN28 @@ -15731,6 +15742,7 @@ Power_Management:RT9742AGJ5F Power_Management:RT9742ANGJ5F Power_Management:RT9742BGJ5F Power_Management:RT9742BNGJ5F +Power_Management:RT9742SNGV Power_Management:SN6505ADBV Power_Management:SN6505BDBV Power_Management:SN6507DGQ @@ -18692,6 +18704,7 @@ Regulator_Linear:TPS7A0530PDBZ Regulator_Linear:TPS7A0531PDBV Regulator_Linear:TPS7A0533PDBV Regulator_Linear:TPS7A0533PDBZ +Regulator_Linear:TPS7A20xxxDBV Regulator_Linear:TPS7A20xxxDQN Regulator_Linear:TPS7A3301RGW Regulator_Linear:TPS7A39 @@ -20301,7 +20314,6 @@ Sensor:BME280 Sensor:BME680 Sensor:CHT11 Sensor:DHT11 -Sensor:INA260 Sensor:LTC2990 Sensor:MAX30102 Sensor:Nuclear-Radiation_Detector @@ -20588,9 +20600,12 @@ Sensor_Energy:INA219BxD Sensor_Energy:INA219BxDCN Sensor_Energy:INA226 Sensor_Energy:INA228 +Sensor_Energy:INA229 Sensor_Energy:INA233 +Sensor_Energy:INA234AxYBJ Sensor_Energy:INA237 Sensor_Energy:INA238 +Sensor_Energy:INA260 Sensor_Energy:LTC4151xMS Sensor_Energy:MCP39F521 Sensor_Energy:PAC1931x-xJ6CX @@ -20842,6 +20857,9 @@ Sensor_Pressure:40PC015G Sensor_Pressure:40PC100G Sensor_Pressure:40PC150G Sensor_Pressure:40PC250G +Sensor_Pressure:ABPxxxxxxxxx0 +Sensor_Pressure:ABPxxxxxxxxxA +Sensor_Pressure:ABPxxxxxxxxxS Sensor_Pressure:BMP280 Sensor_Pressure:ILPS28QSW Sensor_Pressure:LPS22DF @@ -20869,6 +20887,7 @@ Sensor_Proximity:BPR-105 Sensor_Proximity:BPR-105F Sensor_Proximity:BPR-205 Sensor_Proximity:CNY70 +Sensor_Proximity:FDC1004DGS Sensor_Proximity:GP2S700HCP Sensor_Proximity:ITR1201SR10AR Sensor_Proximity:ITR8307 @@ -21788,6 +21807,7 @@ Transistor_BJT:Q_NPN_Darlington_ECBC Transistor_BJT:Q_NPN_EBC Transistor_BJT:Q_NPN_ECB Transistor_BJT:Q_NPN_ECBC +Transistor_BJT:Q_PNP_ACAB Transistor_BJT:Q_PNP_BCE Transistor_BJT:Q_PNP_BCEC Transistor_BJT:Q_PNP_BEC @@ -22321,6 +22341,7 @@ Transistor_FET:PSMN5R2-60YL Transistor_FET:QM6006D Transistor_FET:QM6015D Transistor_FET:Q_Dual_NMOS_G1S2G2D2S1D1 +Transistor_FET:Q_Dual_NMOS_PMOS_G1S2G2D2S1D1 Transistor_FET:Q_Dual_NMOS_S1G1D2S2G2D1 Transistor_FET:Q_Dual_NMOS_S1G1S2G2D2D1 Transistor_FET:Q_Dual_NMOS_S1G1S2G2D2D2D1D1 diff --git a/src/Command/BackupCommand.php b/src/Command/BackupCommand.php index 085c552a..c4fb3777 100644 --- a/src/Command/BackupCommand.php +++ b/src/Command/BackupCommand.php @@ -201,6 +201,10 @@ class BackupCommand extends Command $config_dir = $this->project_dir.'/config'; $zip->addFile($config_dir.'/parameters.yaml', 'config/parameters.yaml'); $zip->addFile($config_dir.'/banner.md', 'config/banner.md'); + + //Add kicad custom footprints and symbols files + $zip->addFile($this->project_dir . '/public/kicad/footprints_custom.txt', 'public/kicad/footprints_custom.txt'); + $zip->addFile($this->project_dir . '/public/kicad/symbols_custom.txt', 'public/kicad/symbols_custom.txt'); } protected function backupAttachments(ZipFile $zip, SymfonyStyle $io): void diff --git a/src/Command/LoadFixturesCommand.php b/src/Command/LoadFixturesCommand.php index d01d19c3..98052ba6 100644 --- a/src/Command/LoadFixturesCommand.php +++ b/src/Command/LoadFixturesCommand.php @@ -56,13 +56,16 @@ class LoadFixturesCommand extends Command } $factory = new ResetAutoIncrementPurgerFactory(); - $purger = $factory->createForEntityManager(null, $this->entityManager); + + //Use truncate purging to fix compatibility with postgresql + $purger = $factory->createForEntityManager(null, $this->entityManager, purgeWithTruncate: true); $purger->purge(); //Afterwards run the load fixtures command as normal, but with the --append option $new_input = new ArrayInput([ 'command' => 'doctrine:fixtures:load', + '--purge-with-truncate' => true, '--append' => true, ]); @@ -70,4 +73,4 @@ class LoadFixturesCommand extends Command return $returnCode ?? Command::FAILURE; } -} \ No newline at end of file +} diff --git a/src/Command/Migrations/DBPlatformConvertCommand.php b/src/Command/Migrations/DBPlatformConvertCommand.php index 86052bf7..d1215da4 100644 --- a/src/Command/Migrations/DBPlatformConvertCommand.php +++ b/src/Command/Migrations/DBPlatformConvertCommand.php @@ -229,24 +229,37 @@ class DBPlatformConvertCommand extends Command if ($platform instanceof PostgreSQLPlatform) { $connection->executeStatement( - //From: https://wiki.postgresql.org/wiki/Fixing_Sequences + //See https://github.com/Part-DB/Part-DB-server/issues/1362 <<commentHelper->setMessage($form['log_comment']->getData()); + //In principle, the form should be disabled, if the edit permission is not granted, but for good measure, we also check it here, before saving changes. + if (!$entity instanceof User) { //Users entities does not have a simple edit permission, so we skip the check for them + $this->denyAccessUnlessGranted('edit', $entity); + } $em->persist($entity); $em->flush(); $this->addFlash('success', 'entity.edit_flash'); diff --git a/src/Controller/BulkInfoProviderImportController.php b/src/Controller/BulkInfoProviderImportController.php index 2d3dd7f6..a8622a28 100644 --- a/src/Controller/BulkInfoProviderImportController.php +++ b/src/Controller/BulkInfoProviderImportController.php @@ -29,11 +29,14 @@ use App\Entity\Parts\Part; use App\Entity\Parts\Supplier; use App\Entity\UserSystem\User; use App\Form\InfoProviderSystem\GlobalFieldMappingType; +use App\Services\EntityMergers\Mergers\PartMerger; use App\Services\InfoProviderSystem\BulkInfoProviderService; use App\Services\InfoProviderSystem\DTOs\BulkSearchFieldMappingDTO; use App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultsDTO; use App\Services\InfoProviderSystem\DTOs\BulkSearchResponseDTO; +use App\Services\InfoProviderSystem\PartInfoRetriever; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\ORMInvalidArgumentException; use Psr\Log\LoggerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\DependencyInjection\Attribute\Autowire; @@ -66,6 +69,10 @@ class BulkInfoProviderImportController extends AbstractController { $dtos = []; foreach ($fieldMappings as $mapping) { + // Skip entries where field is null/empty (e.g. user added a row but didn't select a field) + if (empty($mapping['field'])) { + continue; + } $dtos[] = new BulkSearchFieldMappingDTO(field: $mapping['field'], providers: $mapping['providers'], priority: $mapping['priority'] ?? 1); } return $dtos; @@ -276,8 +283,8 @@ class BulkInfoProviderImportController extends AbstractController $updatedJobs = true; } - // Mark jobs with no results for deletion (failed searches) - if ($job->getResultCount() === 0 && $job->isInProgress()) { + // Mark jobs with no results for deletion (failed searches or stale pending) + if ($job->getResultCount() === 0 && ($job->isInProgress() || $job->isPending())) { $jobsToDelete[] = $job; } } @@ -297,9 +304,23 @@ class BulkInfoProviderImportController extends AbstractController } } + // Refetch after cleanup and split into active vs finished + $allJobs = $this->entityManager->getRepository(BulkInfoProviderImportJob::class) + ->findBy([], ['createdAt' => 'DESC']); + + $activeJobs = []; + $finishedJobs = []; + foreach ($allJobs as $job) { + if ($job->isCompleted() || $job->isFailed() || $job->isStopped()) { + $finishedJobs[] = $job; + } else { + $activeJobs[] = $job; + } + } + return $this->render('info_providers/bulk_import/manage.html.twig', [ - 'jobs' => $this->entityManager->getRepository(BulkInfoProviderImportJob::class) - ->findBy([], ['createdAt' => 'DESC']) // Refetch after cleanup + 'active_jobs' => $activeJobs, + 'finished_jobs' => $finishedJobs, ]); } @@ -470,22 +491,13 @@ class BulkInfoProviderImportController extends AbstractController $fieldMappingDtos = $job->getFieldMappings(); $prefetchDetails = $job->isPrefetchDetails(); - try { - $searchResultsDto = $this->bulkService->performBulkSearch([$part], $fieldMappingDtos, $prefetchDetails); - } catch (\Exception $searchException) { - // Handle "no search results found" as a normal case, not an error - if (str_contains($searchException->getMessage(), 'No search results found')) { - $searchResultsDto = null; - } else { - throw $searchException; - } - } + $searchResultsDto = $this->bulkService->performBulkSearch([$part], $fieldMappingDtos, $prefetchDetails); // Update the job's search results for this specific part efficiently $this->updatePartSearchResults($job, $searchResultsDto[0] ?? null); // Prefetch details if requested - if ($prefetchDetails && $searchResultsDto !== null) { + if ($prefetchDetails) { $this->bulkService->prefetchDetailsForResults($searchResultsDto); } @@ -515,6 +527,191 @@ class BulkInfoProviderImportController extends AbstractController } } + #[Route('/job/{jobId}/part/{partId}/quick-apply', name: 'bulk_info_provider_quick_apply', methods: ['POST'])] + public function quickApply( + int $jobId, + int $partId, + Request $request, + PartInfoRetriever $infoRetriever, + PartMerger $partMerger + ): JsonResponse { + $job = $this->validateJobAccess($jobId); + if (!$job) { + return $this->createErrorResponse('Job not found or access denied', 404, ['job_id' => $jobId]); + } + + /** @var Part $part */ + $part = $this->entityManager->getRepository(Part::class)->find($partId); + if (!$part) { + return $this->createErrorResponse('Part not found', 404, ['part_id' => $partId]); + } + + $this->denyAccessUnlessGranted('edit', $part); + + // Get provider key/id from request body, or fall back to top search result + $body = $request->toArray(); + $providerKey = $body['providerKey'] ?? null; + $providerId = $body['providerId'] ?? null; + + if (!$providerKey || !$providerId) { + $searchResults = $job->getSearchResults($this->entityManager); + foreach ($searchResults->partResults as $partResult) { + if ($partResult->part->getId() === $partId) { + $sorted = $partResult->getResultsSortedByPriority(); + if (!empty($sorted)) { + $providerKey = $sorted[0]->searchResult->provider_key; + $providerId = $sorted[0]->searchResult->provider_id; + } + break; + } + } + } + + if (!$providerKey || !$providerId) { + return $this->createErrorResponse('No search result available for this part', 400, ['part_id' => $partId]); + } + + try { + $dto = $infoRetriever->getDetails($providerKey, $providerId); + $providerPart = $infoRetriever->dtoToPart($dto); + $partMerger->merge($part, $providerPart); + + //Persist part manufacturer and supplier if they are new, to avoid issues with detached entities during merge + //Do not footprints here, as it might pollute the database with unwanted formatting footprints from the provider, + $this->entityManager->persist($part->getManufacturer()); + foreach ($part->getOrderdetails() as $orderdetail) { + $this->entityManager->persist($orderdetail->getSupplier()); + } + + try { + $this->entityManager->flush(); + } catch (ORMInvalidArgumentException $exception) { + if (str_contains($exception->getMessage(), 'not configured to cascade persist operations')) { + throw new \RuntimeException('Failed to persist merged part, as it would create new datastructures! Review the provider data by yourself.'); + } + + throw $exception; // Re-throw if it's a different ORM error + } + + $job->markPartAsCompleted($partId); + if ($job->isAllPartsCompleted() && !$job->isCompleted()) { + $job->markAsCompleted(); + } + + $this->entityManager->flush(); + + + return $this->json([ + 'success' => true, + 'message' => sprintf('Applied provider data to "%s"', $part->getName()), + 'part_id' => $partId, + 'provider_key' => $providerKey, + 'provider_id' => $providerId, + 'progress' => $job->getProgressPercentage(), + 'completed_count' => $job->getCompletedPartsCount(), + 'total_count' => $job->getPartCount(), + 'job_completed' => $job->isCompleted(), + ]); + } catch (\Exception $e) { + $this->logger->error($e); + + return $this->createErrorResponse( + 'Quick apply failed: ' . $e->getMessage(), + 500, + ['job_id' => $jobId, 'part_id' => $partId, 'exception' => $e->getMessage()] + ); + } + } + + #[Route('/job/{jobId}/quick-apply-all', name: 'bulk_info_provider_quick_apply_all', methods: ['POST'])] + public function quickApplyAll( + int $jobId, + PartInfoRetriever $infoRetriever, + PartMerger $partMerger + ): JsonResponse { + set_time_limit(600); + + $job = $this->validateJobAccess($jobId); + if (!$job) { + return $this->createErrorResponse('Job not found or access denied', 404, ['job_id' => $jobId]); + } + + $searchResults = $job->getSearchResults($this->entityManager); + $applied = 0; + $failed = 0; + $noResults = 0; + $errors = []; + + foreach ($job->getJobParts() as $jobPart) { + if ($jobPart->isCompleted() || $jobPart->isSkipped()) { + continue; + } + + $part = $jobPart->getPart(); + + if (!$this->isGranted('edit', $part)) { + $errors[] = sprintf('No edit permission for "%s"', $part->getName()); + $failed++; + continue; + } + + // Find top search result for this part + $providerKey = null; + $providerId = null; + foreach ($searchResults->partResults as $partResult) { + if ($partResult->part->getId() === $part->getId()) { + $sorted = $partResult->getResultsSortedByPriority(); + if (!empty($sorted)) { + $providerKey = $sorted[0]->searchResult->provider_key; + $providerId = $sorted[0]->searchResult->provider_id; + } + break; + } + } + + if (!$providerKey || !$providerId) { + $noResults++; + continue; + } + + try { + $dto = $infoRetriever->getDetails($providerKey, $providerId); + $providerPart = $infoRetriever->dtoToPart($dto); + $partMerger->merge($part, $providerPart); + $this->entityManager->flush(); + + $job->markPartAsCompleted($part->getId()); + $applied++; + } catch (\Exception $e) { + $this->logger->error('Quick apply failed for part', [ + 'part_id' => $part->getId(), + 'part_name' => $part->getName(), + 'error' => $e->getMessage(), + ]); + $errors[] = sprintf('Failed for "%s": %s', $part->getName(), $e->getMessage()); + $failed++; + } + } + + if ($job->isAllPartsCompleted() && !$job->isCompleted()) { + $job->markAsCompleted(); + } + $this->entityManager->flush(); + + return $this->json([ + 'success' => true, + 'applied' => $applied, + 'failed' => $failed, + 'no_results' => $noResults, + 'errors' => $errors, + 'message' => sprintf('Applied to %d parts, %d failed, %d had no results', $applied, $failed, $noResults), + 'progress' => $job->getProgressPercentage(), + 'completed_count' => $job->getCompletedPartsCount(), + 'total_count' => $job->getPartCount(), + 'job_completed' => $job->isCompleted(), + ]); + } + #[Route('/job/{jobId}/research-all', name: 'bulk_info_provider_research_all', methods: ['POST'])] public function researchAllParts(int $jobId): JsonResponse { diff --git a/src/Controller/InfoProviderController.php b/src/Controller/InfoProviderController.php index deec8a57..817a6651 100644 --- a/src/Controller/InfoProviderController.php +++ b/src/Controller/InfoProviderController.php @@ -26,11 +26,14 @@ namespace App\Controller; use App\Entity\Parts\Manufacturer; use App\Entity\Parts\Part; use App\Exceptions\OAuthReconnectRequiredException; +use App\Form\InfoProviderSystem\FromURLFormType; use App\Form\InfoProviderSystem\PartSearchType; use App\Services\InfoProviderSystem\ExistingPartFinder; +use App\Services\InfoProviderSystem\CreateFromUrlHelper; use App\Services\InfoProviderSystem\PartInfoRetriever; use App\Services\InfoProviderSystem\ProviderRegistry; use App\Services\InfoProviderSystem\Providers\GenericWebProvider; +use App\Services\InfoProviderSystem\Providers\InfoProviderInterface; use App\Settings\AppSettings; use App\Settings\InfoProviderSystem\InfoProviderGeneralSettings; use Doctrine\ORM\EntityManagerInterface; @@ -172,10 +175,15 @@ class InfoProviderController extends AbstractController $keyword = $form->get('keyword')->getData(); $providers = $form->get('providers')->getData(); + $no_cache_search = $form->get('no_cache_search')->getData(); + $no_cache_details = $form->get('no_cache_details')->getData(); + $dtos = []; try { - $dtos = $this->infoRetriever->searchByKeyword(keyword: $keyword, providers: $providers); + $dtos = $this->infoRetriever->searchByKeyword(keyword: $keyword, providers: $providers, options: [ + InfoProviderInterface::OPTION_NO_CACHE => $no_cache_search + ]); } catch (ClientException $e) { $this->addFlash('error', t('info_providers.search.error.client_exception')); $this->addFlash('error',$e->getMessage()); @@ -207,40 +215,41 @@ class InfoProviderController extends AbstractController return $this->render('info_providers/search/part_search.html.twig', [ 'form' => $form, 'results' => $results, - 'update_target' => $update_target + 'update_target' => $update_target, + 'no_cache_details' => $no_cache_details ?? false, ]); } #[Route('/from_url', name: 'info_providers_from_url')] - public function fromURL(Request $request, GenericWebProvider $provider): Response + public function fromURL(Request $request, GenericWebProvider $provider, CreateFromUrlHelper $fromUrlHelper): Response { $this->denyAccessUnlessGranted('@info_providers.create_parts'); - if (!$provider->isActive()) { + if (!$fromUrlHelper->canCreateFromUrl()) { $this->addFlash('error', "Generic Web Provider is not active. Please enable it in the provider settings."); return $this->redirectToRoute('info_providers_list'); } - $formBuilder = $this->createFormBuilder(); - $formBuilder->add('url', UrlType::class, [ - 'label' => 'info_providers.from_url.url.label', - 'required' => true, - ]); - $formBuilder->add('submit', SubmitType::class, [ - 'label' => 'info_providers.search.submit', - ]); - - $form = $formBuilder->getForm(); + $form = $this->createForm(FromURLFormType::class); $form->handleRequest($request); $partDetail = null; if ($form->isSubmitted() && $form->isValid()) { //Try to retrieve the part detail from the given URL $url = $form->get('url')->getData(); + + $method = $form->get('method')->getData(); + $no_cache = $form->get('no_cache')->getData(); + $skip_delegation = $form->get('skip_delegation')->getData(); + try { + //It's okay if we use the cached results here, as its just for convenience $searchResult = $this->infoRetriever->searchByKeyword( keyword: $url, - providers: [$provider] + providers: [$method], + options: [ + InfoProviderInterface::OPTION_SKIP_DELEGATION => $skip_delegation, + ] ); if (count($searchResult) === 0) { @@ -251,6 +260,8 @@ class InfoProviderController extends AbstractController return $this->redirectToRoute('info_providers_create_part', [ 'providerKey' => $searchResult->provider_key, 'providerId' => $searchResult->provider_id, + 'no_cache' => $no_cache ? 1 : null, + 'skip_delegation' => $skip_delegation ? 1 : null, ]); } } catch (ExceptionInterface $e) { diff --git a/src/Controller/KicadListEditorController.php b/src/Controller/KicadListEditorController.php new file mode 100644 index 00000000..85ca0a28 --- /dev/null +++ b/src/Controller/KicadListEditorController.php @@ -0,0 +1,88 @@ +. + */ + +declare(strict_types=1); + +namespace App\Controller; + +use App\Form\Settings\KicadListEditorType; +use App\Settings\MiscSettings\KiCadEDASettings; +use App\Services\EDA\KicadListFileManager; +use Jbtronics\SettingsBundle\Exception\SettingsNotValidException; +use Jbtronics\SettingsBundle\Manager\SettingsManagerInterface; +use RuntimeException; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Attribute\Route; + +use function Symfony\Component\Translation\t; + +final class KicadListEditorController extends AbstractController +{ + public function __construct( + private readonly SettingsManagerInterface $settingsManager, + ) { + } + + #[Route('/settings/misc/kicad-lists', name: 'settings_kicad_lists')] + public function __invoke(Request $request, KicadListFileManager $fileManager): Response + { + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); + $this->denyAccessUnlessGranted('@config.change_system_settings'); + + /** @var KiCadEDASettings $settings */ + $settings = $this->settingsManager->createTemporaryCopy(KiCadEDASettings::class); + $form = $this->createForm(KicadListEditorType::class, [ + 'useCustomList' => $settings->useCustomList, + 'customFootprints' => $fileManager->getCustomFootprintsContent(), + 'customSymbols' => $fileManager->getCustomSymbolsContent(), + ], [ + 'default_footprints' => $fileManager->getFootprintsContent(), + 'default_symbols' => $fileManager->getSymbolsContent(), + ]); + + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $data = $form->getData(); + + try { + $fileManager->saveCustom($data['customFootprints'], $data['customSymbols']); + $settings->useCustomList = (bool) $data['useCustomList']; + $this->settingsManager->mergeTemporaryCopy($settings); + $this->settingsManager->save($settings); + $this->addFlash('success', t('settings.flash.saved')); + + return $this->redirectToRoute('settings_kicad_lists'); + } catch (RuntimeException|SettingsNotValidException $exception) { + $this->addFlash('error', $exception->getMessage()); + } + } + + if ($form->isSubmitted() && !$form->isValid()) { + $this->addFlash('error', t('settings.flash.invalid')); + } + + return $this->render('settings/kicad_list_editor.html.twig', [ + 'form' => $form, + ]); + } +} diff --git a/src/Controller/PartController.php b/src/Controller/PartController.php index b4f46a27..735a48f8 100644 --- a/src/Controller/PartController.php +++ b/src/Controller/PartController.php @@ -36,10 +36,12 @@ use App\Entity\PriceInformations\Orderdetail; use App\Entity\ProjectSystem\Project; use App\Exceptions\AttachmentDownloadException; use App\Form\Part\PartBaseType; +use App\Form\Part\PartLotType; use App\Services\Attachments\AttachmentSubmitHandler; use App\Services\Attachments\PartPreviewGenerator; use App\Services\EntityMergers\Mergers\PartMerger; use App\Services\InfoProviderSystem\PartInfoRetriever; +use App\Services\InfoProviderSystem\Providers\InfoProviderInterface; use App\Services\LogSystem\EventCommentHelper; use App\Services\LogSystem\HistoryHelper; use App\Services\LogSystem\TimeTravel; @@ -127,6 +129,17 @@ final class PartController extends AbstractController $table = null; } + // Build the add-lot form for the INFO page modal (only when not in time-travel mode) + $addLotForm = null; + if ($timeTravel_timestamp === null && $this->isGranted('edit', $part)) { + $newLot = new PartLot(); + $newLot->setPart($part); + $addLotForm = $this->createForm(PartLotType::class, $newLot, [ + 'measurement_unit' => $part->getPartUnit(), + 'action' => $this->generateUrl('part_lot_add', ['id' => $part->getID()]), + ]); + } + return $this->render( 'parts/info/show_part_info.html.twig', [ @@ -139,10 +152,39 @@ final class PartController extends AbstractController 'comment_params' => $this->partInfoSettings->extractParamsFromNotes ? $parameterExtractor->extractParameters($part->getComment()) : [], 'withdraw_add_helper' => $withdrawAddHelper, 'highlightLotId' => $request->query->getInt('highlightLot', 0), + 'add_lot_form' => $addLotForm, ] ); } + #[Route(path: '/{id}/add_lot', name: 'part_lot_add', methods: ['POST'])] + public function addLot(Part $part, Request $request, EntityManagerInterface $em): Response + { + $this->denyAccessUnlessGranted('edit', $part); + + $newLot = new PartLot(); + $newLot->setPart($part); + + $form = $this->createForm(PartLotType::class, $newLot, [ + 'measurement_unit' => $part->getPartUnit(), + ]); + + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $em->persist($newLot); + $em->flush(); + $this->addFlash('success', 'part.edited_flash'); + return $this->redirectToRoute('part_info', [ + 'id' => $part->getID(), + 'highlightLot' => $newLot->getID(), + ]); + } + + $this->addFlash('error', 'part.created_flash.invalid'); + return $this->redirectToRoute('part_info', ['id' => $part->getID()]); + } + #[Route(path: '/{id}/edit', name: 'part_edit')] public function edit(Part $part, Request $request): Response { @@ -283,13 +325,37 @@ final class PartController extends AbstractController { $this->denyAccessUnlessGranted('@info_providers.create_parts'); - $dto = $infoRetriever->getDetails($providerKey, $providerId); + //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); + + $dto = $infoRetriever->getDetails($providerKey, $providerId, [ + InfoProviderInterface::OPTION_NO_CACHE => $no_cache, + InfoProviderInterface::OPTION_SKIP_DELEGATION => $skip_delegation, + ]); $new_part = $infoRetriever->dtoToPart($dto); if ($new_part->getCategory() === null || $new_part->getCategory()->getID() === null) { $this->addFlash('warning', t("part.create_from_info_provider.no_category_yet")); } + $lotAmount = $request->query->get('lotAmount'); + $lotName = $request->query->get('lotName'); + $lotUserBarcode = $request->query->get('lotUserBarcode'); + + if ($lotAmount !== null || $lotName !== null || $lotUserBarcode !== null) { + $partLot = new PartLot(); + $partLot->setAmount($lotAmount !== null ? (float)$lotAmount : 0); + $partLot->setDescription($lotName !== null ? (string)$lotName : ''); + $partLot->setUserBarcode($lotUserBarcode !== null ? (string)$lotUserBarcode : ''); + + $new_part->addPartLot($partLot); + + $this->addFlash('notice', t('part.create_from_info_provider.lot_filled_from_barcode')); + + } + + return $this->renderPartForm('new', $request, $new_part, [ 'info_provider_dto' => $dto, ]); @@ -325,10 +391,13 @@ final class PartController extends AbstractController $this->denyAccessUnlessGranted('edit', $part); $this->denyAccessUnlessGranted('@info_providers.create_parts'); + //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); + //Save the old name of the target part for the template $old_name = $part->getName(); - $dto = $infoRetriever->getDetails($providerKey, $providerId); + $dto = $infoRetriever->getDetails($providerKey, $providerId, [InfoProviderInterface::OPTION_NO_CACHE => $no_cache]); $provider_part = $infoRetriever->dtoToPart($dto); $part = $partMerger->merge($part, $provider_part); diff --git a/src/Controller/ProjectController.php b/src/Controller/ProjectController.php index 2a6d19ee..531deb3f 100644 --- a/src/Controller/ProjectController.php +++ b/src/Controller/ProjectController.php @@ -69,10 +69,13 @@ class ProjectController extends AbstractController return $table->getResponse(); } + $number_of_builds = max(1, $request->query->getInt('n', 1)); + return $this->render('projects/info/info.html.twig', [ 'buildHelper' => $buildHelper, 'datatable' => $table, 'project' => $project, + 'number_of_builds' => $number_of_builds, ]); } @@ -240,7 +243,8 @@ class ProjectController extends AbstractController } // Detect fields and get suggestions - $detected_fields = $BOMImporter->detectFields($file_content); + $detected_delimiter = $BOMImporter->detectDelimiter($file_content); + $detected_fields = $BOMImporter->detectFields($file_content, $detected_delimiter); $suggested_mapping = $BOMImporter->getSuggestedFieldMapping($detected_fields); // Create mapping of original field names to sanitized field names for template @@ -257,7 +261,7 @@ class ProjectController extends AbstractController $builder->add('delimiter', ChoiceType::class, [ 'label' => 'project.bom_import.delimiter', 'required' => true, - 'data' => ',', + 'data' => $detected_delimiter, 'choices' => [ 'project.bom_import.delimiter.comma' => ',', 'project.bom_import.delimiter.semicolon' => ';', diff --git a/src/Controller/SecurityController.php b/src/Controller/SecurityController.php index ad4d272f..93336bf9 100644 --- a/src/Controller/SecurityController.php +++ b/src/Controller/SecurityController.php @@ -25,6 +25,7 @@ namespace App\Controller; use App\Entity\UserSystem\User; use App\Events\SecurityEvent; use App\Events\SecurityEvents; +use App\Form\Security\LoginFormType; use App\Services\UserSystem\PasswordResetManager; use Doctrine\ORM\EntityManagerInterface; use Gregwar\CaptchaBundle\Type\CaptchaType; @@ -61,7 +62,12 @@ class SecurityController extends AbstractController // last username entered by the user $lastUsername = $authenticationUtils->getLastUsername(); + $form = $this->createForm(LoginFormType::class, [ + '_username' => $lastUsername, + ]); + return $this->render('security/login.html.twig', [ + 'form' => $form, 'last_username' => $lastUsername, 'error' => $error, ]); diff --git a/src/Controller/SettingsController.php b/src/Controller/SettingsController.php index 15c945f6..5fed1571 100644 --- a/src/Controller/SettingsController.php +++ b/src/Controller/SettingsController.php @@ -44,6 +44,7 @@ class SettingsController extends AbstractController public function systemSettings(Request $request, TagAwareCacheInterface $cache): Response { $this->denyAccessUnlessGranted('@config.change_system_settings'); + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); //Create a clone of the settings object $settings = $this->settingsManager->createTemporaryCopy(AppSettings::class); diff --git a/src/Controller/TypeaheadController.php b/src/Controller/TypeaheadController.php index 39821f59..f7e15b6d 100644 --- a/src/Controller/TypeaheadController.php +++ b/src/Controller/TypeaheadController.php @@ -22,38 +22,43 @@ declare(strict_types=1); namespace App\Controller; -use App\Entity\Parameters\AbstractParameter; -use App\Settings\MiscSettings\IpnSuggestSettings; -use Symfony\Component\HttpFoundation\Response; use App\Entity\Attachments\Attachment; -use App\Entity\Parts\Category; -use App\Entity\Parts\Footprint; +use App\Entity\Parameters\AbstractParameter; use App\Entity\Parameters\AttachmentTypeParameter; use App\Entity\Parameters\CategoryParameter; -use App\Entity\Parameters\ProjectParameter; use App\Entity\Parameters\FootprintParameter; use App\Entity\Parameters\GroupParameter; use App\Entity\Parameters\ManufacturerParameter; use App\Entity\Parameters\MeasurementUnitParameter; use App\Entity\Parameters\PartParameter; +use App\Entity\Parameters\ProjectParameter; use App\Entity\Parameters\StorageLocationParameter; use App\Entity\Parameters\SupplierParameter; +use App\Entity\Parts\Category; +use App\Entity\Parts\Footprint; use App\Entity\Parts\Part; use App\Entity\PriceInformations\Currency; use App\Repository\ParameterRepository; +use App\Services\AI\AIPlatformRegistry; +use App\Services\AI\AIPlatforms; use App\Services\Attachments\AttachmentURLGenerator; use App\Services\Attachments\BuiltinAttachmentsFinder; use App\Services\Attachments\PartPreviewGenerator; use App\Services\Tools\TagFinder; +use App\Settings\MiscSettings\IpnSuggestSettings; use Doctrine\ORM\EntityManagerInterface; +use Symfony\AI\Platform\Capability; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\Asset\Packages; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Serializer\Encoder\JsonEncoder; use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; use Symfony\Component\Serializer\Serializer; +use Symfony\Contracts\Cache\CacheInterface; +use Symfony\Contracts\Cache\ItemInterface; /** * In this controller the endpoints for the typeaheads are collected. @@ -71,7 +76,10 @@ class TypeaheadController extends AbstractController #[Route(path: '/builtInResources/search', name: 'typeahead_builtInRessources')] public function builtInResources(Request $request, BuiltinAttachmentsFinder $finder): JsonResponse { - $query = $request->get('query'); + //Ensure that the user can access Part-DB at all + $this->denyAccessUnlessGranted('HAS_ACCESS_PERMISSIONS'); + + $query = $request->query->getString('query'); $array = $finder->find($query); $result = []; @@ -118,9 +126,12 @@ class TypeaheadController extends AbstractController } #[Route(path: '/parts/search/{query}', name: 'typeahead_parts')] - public function parts(EntityManagerInterface $entityManager, PartPreviewGenerator $previewGenerator, - AttachmentURLGenerator $attachmentURLGenerator, string $query = ""): JsonResponse - { + public function parts( + EntityManagerInterface $entityManager, + PartPreviewGenerator $previewGenerator, + AttachmentURLGenerator $attachmentURLGenerator, + string $query = "" + ): JsonResponse { $this->denyAccessUnlessGranted('@parts.read'); $repo = $entityManager->getRepository(Part::class); @@ -131,7 +142,7 @@ class TypeaheadController extends AbstractController foreach ($parts as $part) { //Determine the picture to show: $preview_attachment = $previewGenerator->getTablePreviewAttachment($part); - if($preview_attachment instanceof Attachment) { + if ($preview_attachment instanceof Attachment) { $preview_url = $attachmentURLGenerator->getThumbnailURL($preview_attachment, 'thumbnail_sm'); } else { $preview_url = ''; @@ -145,7 +156,7 @@ class TypeaheadController extends AbstractController 'footprint' => $part->getFootprint() instanceof Footprint ? $part->getFootprint()->getName() : '', 'description' => mb_strimwidth($part->getDescription(), 0, 127, '...'), 'image' => $preview_url, - ]; + ]; } return new JsonResponse($data); @@ -205,12 +216,47 @@ class TypeaheadController extends AbstractController /** @var Category|null $category */ $category = $entityManager->getRepository(Category::class)->find($categoryId); + //Ensure the user has access to both the part and the category + $this->denyAccessUnlessGranted('read', $part); + if ($category !== null) { + $this->denyAccessUnlessGranted('read', $category); + } + $clonedPart = clone $part; $clonedPart->setCategory($category); + $partRepository = $entityManager->getRepository(Part::class); - $ipnSuggestions = $partRepository->autoCompleteIpn($clonedPart, $description, $this->ipnSuggestSettings->suggestPartDigits); + $ipnSuggestions = $partRepository->autoCompleteIpn($clonedPart, $description, + $this->ipnSuggestSettings->suggestPartDigits); return new JsonResponse($ipnSuggestions); } + + #[Route(path: '/ai/{platform}/models', name: 'typeahead_ai_models', requirements: ['platform' => '.+'])] + public function aiModels( + AIPlatforms $platform, + Request $request, + AIPlatformRegistry $platformRegistry, + CacheInterface $cache, + ): JsonResponse { + $this->denyAccessUnlessGranted('@config.change_system_settings'); + + $capability_filter = $request->query->getEnum('capability', Capability::class); + + $models = $cache->get('ai_models_'.$platform->value.'_'.($capability_filter->value ?? 'all'), + function (ItemInterface $item) use ($platformRegistry, $platform, $capability_filter) { + $item->expiresAfter(3600); //Cache for 1 hour + if ($capability_filter === null) { + return $platformRegistry->getPlatform($platform)->getModelCatalog()->getModels(); + } + + //Otherwise filter the models by the capability + return array_filter($platformRegistry->getPlatform($platform)->getModelCatalog()->getModels(), + static fn(array $model) => in_array($capability_filter, $model['capabilities'], true) + ); + }); + + return new JsonResponse($models); + } } diff --git a/src/Controller/UpdateManagerController.php b/src/Controller/UpdateManagerController.php index 474c86fc..4901da48 100644 --- a/src/Controller/UpdateManagerController.php +++ b/src/Controller/UpdateManagerController.php @@ -23,16 +23,22 @@ declare(strict_types=1); namespace App\Controller; +use App\Entity\UserSystem\User; use App\Services\System\BackupManager; +use App\Services\System\InstallationTypeDetector; use App\Services\System\UpdateChecker; use App\Services\System\UpdateExecutor; +use App\Services\System\WatchtowerClient; use Shivas\VersioningBundle\Service\VersionManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\ResponseHeaderBag; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; +use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\Routing\Attribute\Route; /** @@ -49,10 +55,15 @@ class UpdateManagerController extends AbstractController private readonly UpdateExecutor $updateExecutor, private readonly VersionManagerInterface $versionManager, private readonly BackupManager $backupManager, + private readonly InstallationTypeDetector $installationTypeDetector, + private readonly UserPasswordHasherInterface $passwordHasher, + private readonly WatchtowerClient $watchtowerClient, #[Autowire(env: 'bool:DISABLE_WEB_UPDATES')] private readonly bool $webUpdatesDisabled = false, #[Autowire(env: 'bool:DISABLE_BACKUP_RESTORE')] private readonly bool $backupRestoreDisabled = false, + #[Autowire(env: 'bool:DISABLE_BACKUP_DOWNLOAD')] + private readonly bool $backupDownloadDisabled = false, ) { } @@ -76,6 +87,16 @@ class UpdateManagerController extends AbstractController } } + /** + * Check if backup download is disabled and throw exception if so. + */ + private function denyIfBackupDownloadDisabled(): void + { + if ($this->backupDownloadDisabled) { + throw new AccessDeniedHttpException('Backup download is disabled by server configuration.'); + } + } + /** * Main update manager page. */ @@ -101,6 +122,8 @@ class UpdateManagerController extends AbstractController 'backups' => $this->backupManager->getBackups(), 'web_updates_disabled' => $this->webUpdatesDisabled, 'backup_restore_disabled' => $this->backupRestoreDisabled, + 'backup_download_disabled' => $this->backupDownloadDisabled, + 'is_docker' => $this->installationTypeDetector->isDocker(), ]); } @@ -206,6 +229,7 @@ class UpdateManagerController extends AbstractController #[Route('/start', name: 'admin_update_manager_start', methods: ['POST'])] public function startUpdate(Request $request): Response { + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); $this->denyAccessUnlessGranted('@system.manage_updates'); $this->denyIfWebUpdatesDisabled(); @@ -314,12 +338,126 @@ class UpdateManagerController extends AbstractController return $this->json($details); } + /** + * Create a manual backup. + */ + #[Route('/backup', name: 'admin_update_manager_backup', methods: ['POST'])] + public function createBackup(Request $request): Response + { + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); + $this->denyAccessUnlessGranted('@system.manage_updates'); + + if (!$this->isCsrfTokenValid('update_manager_backup', $request->request->get('_token'))) { + $this->addFlash('error', 'Invalid CSRF token.'); + return $this->redirectToRoute('admin_update_manager'); + } + + if ($this->updateExecutor->isLocked()) { + $this->addFlash('error', 'Cannot create backup while an update is in progress.'); + return $this->redirectToRoute('admin_update_manager'); + } + + try { + $this->backupManager->createBackup(null, 'manual'); + $this->addFlash('success', 'update_manager.backup.created'); + } catch (\Exception $e) { + $this->addFlash('error', 'Backup failed: ' . $e->getMessage()); + } + + return $this->redirectToRoute('admin_update_manager'); + } + + /** + * Delete a backup file. + */ + #[Route('/backup/delete', name: 'admin_update_manager_backup_delete', methods: ['POST'])] + public function deleteBackup(Request $request): Response + { + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); + $this->denyAccessUnlessGranted('@system.manage_updates'); + + if (!$this->isCsrfTokenValid('update_manager_delete', $request->request->get('_token'))) { + $this->addFlash('error', 'Invalid CSRF token.'); + return $this->redirectToRoute('admin_update_manager'); + } + + $filename = $request->request->get('filename'); + if ($filename && $this->backupManager->deleteBackup($filename)) { + $this->addFlash('success', 'update_manager.backup.deleted'); + } else { + $this->addFlash('error', 'update_manager.backup.delete_error'); + } + + return $this->redirectToRoute('admin_update_manager'); + } + + /** + * Delete an update log file. + */ + #[Route('/log/delete', name: 'admin_update_manager_log_delete', methods: ['POST'])] + public function deleteLog(Request $request): Response + { + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); + $this->denyAccessUnlessGranted('@system.manage_updates'); + + if (!$this->isCsrfTokenValid('update_manager_delete', $request->request->get('_token'))) { + $this->addFlash('error', 'Invalid CSRF token.'); + return $this->redirectToRoute('admin_update_manager'); + } + + $filename = $request->request->get('filename'); + if ($filename && $this->updateExecutor->deleteLog($filename)) { + $this->addFlash('success', 'update_manager.log.deleted'); + } else { + $this->addFlash('error', 'update_manager.log.delete_error'); + } + + return $this->redirectToRoute('admin_update_manager'); + } + + /** + * Download a backup file. + * Requires password confirmation as backups contain sensitive data (password hashes, secrets, etc.). + */ + #[Route('/backup/download', name: 'admin_update_manager_backup_download', methods: ['POST'])] + public function downloadBackup(Request $request): Response + { + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); + $this->denyAccessUnlessGranted('@system.manage_updates'); + $this->denyIfBackupDownloadDisabled(); + + if (!$this->isCsrfTokenValid('update_manager_download', $request->request->get('_token'))) { + $this->addFlash('error', 'Invalid CSRF token.'); + return $this->redirectToRoute('admin_update_manager'); + } + + // Verify password + $password = $request->request->get('password', ''); + $user = $this->getUser(); + if (!$user instanceof User || !$this->passwordHasher->isPasswordValid($user, $password)) { + $this->addFlash('error', 'update_manager.backup.download.invalid_password'); + return $this->redirectToRoute('admin_update_manager'); + } + + $filename = $request->request->get('filename', ''); + $details = $this->backupManager->getBackupDetails($filename); + if (!$details) { + throw $this->createNotFoundException('Backup not found'); + } + + $response = new BinaryFileResponse($details['path']); + $response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $details['file']); + + return $response; + } + /** * Restore from a backup. */ #[Route('/restore', name: 'admin_update_manager_restore', methods: ['POST'])] public function restore(Request $request): Response { + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); $this->denyAccessUnlessGranted('@system.manage_updates'); $this->denyIfBackupRestoreDisabled(); @@ -368,4 +506,100 @@ class UpdateManagerController extends AbstractController return $this->redirectToRoute('admin_update_manager'); } + + /** + * Start a Docker update via Watchtower. + */ + #[Route('/start-docker', name: 'admin_update_manager_start_docker', methods: ['POST'])] + public function startDockerUpdate(Request $request): Response + { + $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY'); + $this->denyAccessUnlessGranted('@system.manage_updates'); + $this->denyIfWebUpdatesDisabled(); + + // Validate CSRF token + if (!$this->isCsrfTokenValid('update_manager_start_docker', $request->request->get('_token'))) { + $this->addFlash('error', 'Invalid CSRF token'); + return $this->redirectToRoute('admin_update_manager'); + } + + // Check if Watchtower is configured and available + if (!$this->watchtowerClient->isConfigured()) { + $this->addFlash('error', 'Watchtower is not configured. Please set WATCHTOWER_API_URL and WATCHTOWER_API_TOKEN.'); + return $this->redirectToRoute('admin_update_manager'); + } + + if (!$this->watchtowerClient->isAvailable()) { + $this->addFlash('error', 'Watchtower is not reachable. Please check that the Watchtower container is running and accessible.'); + return $this->redirectToRoute('admin_update_manager'); + } + + // Create backup if requested + $createBackup = $request->request->getBoolean('backup', true); + if ($createBackup) { + try { + $this->backupManager->createBackup(); + } catch (\Throwable $e) { + $this->addFlash('error', 'Failed to create backup before update: ' . $e->getMessage()); + return $this->redirectToRoute('admin_update_manager'); + } + } + + // Trigger Watchtower update + $success = $this->watchtowerClient->triggerUpdate(); + + if (!$success) { + $this->addFlash('error', 'Failed to trigger Watchtower update. Check the logs for details.'); + return $this->redirectToRoute('admin_update_manager'); + } + + $currentVersion = $this->versionManager->getVersion()->toString(); + + // Redirect to Docker progress page + return $this->redirectToRoute('admin_update_manager_docker_progress', [ + 'previous_version' => $currentVersion, + ]); + } + + /** + * Docker update progress page. + * This page contains client-side JavaScript that polls until the container restarts. + */ + #[Route('/progress/docker', name: 'admin_update_manager_docker_progress', methods: ['GET'])] + public function dockerProgress(Request $request): Response + { + $this->denyAccessUnlessGranted('@system.manage_updates'); + + $previousVersion = $request->query->get('previous_version', 'unknown'); + + return $this->render('admin/update_manager/docker_progress.html.twig', [ + 'previous_version' => $previousVersion, + ]); + } + + /** + * Lightweight health check endpoint used by Docker update progress page. + * Returns current version so the client-side JS can detect when the container restarts with a new version. + * + * Intentionally unauthenticated: after a Docker container restart, the user's session may not survive + * (depends on session storage backend). The version string is non-sensitive public information. + * This endpoint is also whitelisted in MaintenanceModeSubscriber. + */ + #[Route('/health', name: 'admin_update_manager_health', methods: ['GET'])] + public function healthCheck(): JsonResponse + { + //Only show version if user is logged in and has permission + + $response = [ + 'status' => 'ok', + ]; + + if ($this->isGranted('@system.show_updates')) { + $response['version'] = $this->versionManager->getVersion()->toString(); + } else { + $response['version'] = "not authorized"; + } + + return $this->json($response); + } } diff --git a/src/DataTables/PartsDataTable.php b/src/DataTables/PartsDataTable.php index 8bb5f6aa..bcf64056 100644 --- a/src/DataTables/PartsDataTable.php +++ b/src/DataTables/PartsDataTable.php @@ -38,6 +38,7 @@ use App\DataTables\Filters\PartFilter; use App\DataTables\Filters\PartSearchFilter; use App\DataTables\Helpers\ColumnSortHelper; use App\DataTables\Helpers\PartDataTableHelper; +use App\Doctrine\Functions\SiValueSort; use App\Doctrine\Helpers\FieldHelper; use App\Entity\Parts\ManufacturingStatus; use App\Entity\Parts\Part; @@ -59,7 +60,7 @@ use Symfony\Contracts\Translation\TranslatorInterface; final class PartsDataTable implements DataTableTypeInterface { - const LENGTH_MENU = [[10, 25, 50, 100, -1], [10, 25, 50, 100, "All"]]; + public const LENGTH_MENU = [[10, 25, 50, 100, 250, 500, -1], [10, 25, 50, 100, 250, 500, "All"]]; public function __construct( private readonly EntityURLGenerator $urlGenerator, @@ -118,6 +119,18 @@ final class PartsDataTable implements DataTableTypeInterface 'render' => fn($value, Part $context) => $this->partDataTableHelper->renderName($context), 'orderField' => 'NATSORT(part.name)' ]) + ->add('si_value', TextColumn::class, [ + 'label' => $this->translator->trans('part.table.si_value'), + 'render' => function ($value, Part $context): string { + $siValue = SiValueSort::sqliteSiValue($context->getName()); + if ($siValue !== null) { + //Output it as scientific number with a big E + return htmlspecialchars(sprintf('%G', $siValue)); + } + return ''; + }, + 'orderField' => 'SI_VALUE_SORT(part.name)', + ]) ->add('id', TextColumn::class, [ 'label' => $this->translator->trans('part.table.id'), ]) @@ -484,6 +497,19 @@ final class PartsDataTable implements DataTableTypeInterface //$builder->addGroupBy('_bulkImportJob'); } + //When sorting by SI value, add NATSORT as a secondary sort so that parts without + //an SI-prefixed value fall back to natural string ordering seamlessly. + $orderByParts = $builder->getDQLPart('orderBy'); + foreach ($orderByParts as $orderBy) { + foreach ($orderBy->getParts() as $part) { + if (str_contains($part, 'SI_VALUE_SORT')) { + $direction = str_contains($part, 'DESC') ? 'DESC' : 'ASC'; + $builder->addOrderBy('NATSORT(part.name)', $direction); + break 2; + } + } + } + return $builder; } diff --git a/src/DataTables/ProjectBomEntriesDataTable.php b/src/DataTables/ProjectBomEntriesDataTable.php index 433f6f78..2d5c4ebc 100644 --- a/src/DataTables/ProjectBomEntriesDataTable.php +++ b/src/DataTables/ProjectBomEntriesDataTable.php @@ -1,8 +1,5 @@ . */ + +declare(strict_types=1); + namespace App\DataTables; +use App\DataTables\Adapters\TwoStepORMAdapter; use App\DataTables\Column\EntityColumn; +use App\DataTables\Column\EnumColumn; use App\DataTables\Column\LocaleDateTimeColumn; use App\DataTables\Column\MarkdownColumn; use App\DataTables\Helpers\PartDataTableHelper; -use App\Entity\Attachments\Attachment; +use App\Doctrine\Helpers\FieldHelper; +use App\Entity\Parts\ManufacturingStatus; use App\Entity\Parts\Part; use App\Entity\ProjectSystem\ProjectBOMEntry; use App\Services\ElementTypeNameGenerator; use App\Services\EntityURLGenerator; use App\Services\Formatters\AmountFormatter; +use App\Services\Formatters\MoneyFormatter; +use App\Services\ProjectSystem\ProjectBuildHelper; +use Brick\Math\RoundingMode; +use Doctrine\ORM\AbstractQuery; +use Doctrine\ORM\Query; use Doctrine\ORM\QueryBuilder; use Omines\DataTablesBundle\Adapter\Doctrine\ORM\SearchCriteriaProvider; -use Omines\DataTablesBundle\Adapter\Doctrine\ORMAdapter; use Omines\DataTablesBundle\Column\TextColumn; use Omines\DataTablesBundle\DataTable; use Omines\DataTablesBundle\DataTableTypeInterface; @@ -42,9 +49,14 @@ use Symfony\Contracts\Translation\TranslatorInterface; class ProjectBomEntriesDataTable implements DataTableTypeInterface { - public function __construct(protected TranslatorInterface $translator, protected PartDataTableHelper $partDataTableHelper, - protected EntityURLGenerator $entityURLGenerator, protected AmountFormatter $amountFormatter) - { + public function __construct( + protected EntityURLGenerator $entityURLGenerator, + protected TranslatorInterface $translator, + protected AmountFormatter $amountFormatter, + protected PartDataTableHelper $partDataTableHelper, + protected ProjectBuildHelper $projectBuildHelper, + protected MoneyFormatter $moneyFormatter, + ) { } @@ -60,7 +72,7 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface return ''; } return $this->partDataTableHelper->renderPicture($context->getPart()); - }, + } ]) ->add('id', TextColumn::class, [ @@ -131,18 +143,32 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface ->add('category', EntityColumn::class, [ 'label' => $this->translator->trans('part.table.category'), 'property' => 'part.category', - 'orderField' => 'NATSORT(category.name)', + 'orderField' => 'NATSORT(category.name)' ]) ->add('footprint', EntityColumn::class, [ 'property' => 'part.footprint', 'label' => $this->translator->trans('part.table.footprint'), - 'orderField' => 'NATSORT(footprint.name)', + 'orderField' => 'NATSORT(footprint.name)' ]) ->add('manufacturer', EntityColumn::class, [ 'property' => 'part.manufacturer', 'label' => $this->translator->trans('part.table.manufacturer'), - 'orderField' => 'NATSORT(manufacturer.name)', + 'orderField' => 'NATSORT(manufacturer.name)' + ]) + + ->add('manufacturing_status', EnumColumn::class, [ + 'label' => $this->translator->trans('part.table.manufacturingStatus'), + 'data' => static fn(ProjectBOMEntry $context): ?ManufacturingStatus => $context->getPart()?->getManufacturingStatus(), + 'orderField' => 'part.manufacturing_status', + 'class' => ManufacturingStatus::class, + 'render' => function (?ManufacturingStatus $status, ProjectBOMEntry $context): string { + if ($status === null) { + return ''; + } + + return $this->translator->trans($status->toTranslationKey()); + }, ]) ->add('mountnames', TextColumn::class, [ @@ -168,8 +194,10 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface return ''; } ]) - ->add('storageLocations', TextColumn::class, [ - 'label' => 'part.table.storeLocations', + ->add('storelocation', TextColumn::class, [ + 'label' => $this->translator->trans('part.table.storeLocations'), + //We need to use a aggregate function to get the first store location, as we have a one-to-many relation + 'orderField' => 'NATSORT(MIN(_storelocations.name))', 'visible' => false, 'render' => function ($value, ProjectBOMEntry $context) { if ($context->getPart() !== null) { @@ -179,6 +207,27 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface return ''; } ]) + ->add('price', TextColumn::class, [ + 'label' => 'project.bom.price', + '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); + }, + ]) + ->add('ext_price', TextColumn::class, [ + 'label' => 'project.bom.ext_price', + 'visible' => false, + 'render' => function ($value, ProjectBOMEntry $context) { + $price = $this->projectBuildHelper->getEntryUnitPrice($context); + return $this->moneyFormatter->format( + $price->multipliedBy($context->getQuantity())->toScale(2, RoundingMode::UP)->toFloat(), + null, + 2, + true + ); + }, + ]) ->add('addedDate', LocaleDateTimeColumn::class, [ 'label' => $this->translator->trans('part.table.addedDate'), @@ -192,11 +241,13 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface $dataTable->addOrderBy('name', DataTable::SORT_ASCENDING); - $dataTable->createAdapter(ORMAdapter::class, [ - 'entity' => Attachment::class, - 'query' => function (QueryBuilder $builder) use ($options): void { - $this->getQuery($builder, $options); + $dataTable->createAdapter(TwoStepORMAdapter::class, [ + 'entity' => ProjectBOMEntry::class, + 'hydrate' => AbstractQuery::HYDRATE_OBJECT, + 'filter_query' => function (QueryBuilder $builder) use ($options): void { + $this->getFilterQuery($builder, $options); }, + 'detail_query' => $this->getDetailQuery(...), 'criteria' => [ function (QueryBuilder $builder) use ($options): void { $this->buildCriteria($builder, $options); @@ -206,20 +257,71 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface ]); } - private function getQuery(QueryBuilder $builder, array $options): void + private function getFilterQuery(QueryBuilder $builder, array $options): void { - $builder->select('bom_entry') - ->addSelect('part') + $builder + ->select('bom_entry.id') ->from(ProjectBOMEntry::class, 'bom_entry') ->leftJoin('bom_entry.part', 'part') ->leftJoin('part.category', 'category') + ->leftJoin('part.partLots', '_partLots') + ->leftJoin('_partLots.storage_location', '_storelocations') ->leftJoin('part.footprint', 'footprint') ->leftJoin('part.manufacturer', 'manufacturer') + ->leftJoin('part.partCustomState', 'partCustomState') ->where('bom_entry.project = :project') ->setParameter('project', $options['project']) + ->addGroupBy('bom_entry') + ->addGroupBy('part') + ->addGroupBy('category') + ->addGroupBy('footprint') + ->addGroupBy('manufacturer') + ->addGroupBy('partCustomState') ; } + private function getDetailQuery(QueryBuilder $builder, array $filter_results): void + { + $ids = array_map(static fn (array $row) => $row['id'], $filter_results); + if ($ids === []) { + $ids = [-1]; + } + + $builder + ->select('bom_entry') + ->addSelect('part') + ->addSelect('category') + ->addSelect('partLots') + ->addSelect('storelocations') + ->addSelect('footprint') + ->addSelect('manufacturer') + ->addSelect('partCustomState') + ->from(ProjectBOMEntry::class, 'bom_entry') + ->leftJoin('bom_entry.part', 'part') + ->leftJoin('part.category', 'category') + ->leftJoin('part.partLots', 'partLots') + ->leftJoin('partLots.storage_location', 'storelocations') + ->leftJoin('part.footprint', 'footprint') + ->leftJoin('part.manufacturer', 'manufacturer') + ->leftJoin('part.partCustomState', 'partCustomState') + ->where('bom_entry.id IN (:ids)') + ->setParameter('ids', $ids) + ->addGroupBy('bom_entry') + ->addGroupBy('part') + ->addGroupBy('partLots') + ->addGroupBy('category') + ->addGroupBy('storelocations') + ->addGroupBy('footprint') + ->addGroupBy('manufacturer') + ->addGroupBy('partCustomState') + + ->setHint(Query::HINT_READ_ONLY, true) + ->setHint(Query::HINT_FORCE_PARTIAL_LOAD, false) + ; + + FieldHelper::addOrderByFieldParam($builder, 'bom_entry.id', 'ids'); + } + private function buildCriteria(QueryBuilder $builder, array $options): void { diff --git a/src/Doctrine/Functions/SiValueSort.php b/src/Doctrine/Functions/SiValueSort.php new file mode 100644 index 00000000..c4d16444 --- /dev/null +++ b/src/Doctrine/Functions/SiValueSort.php @@ -0,0 +1,196 @@ +. + */ + +declare(strict_types=1); + +namespace App\Doctrine\Functions; + +use Doctrine\DBAL\Platforms\AbstractMySQLPlatform; +use Doctrine\DBAL\Platforms\PostgreSQLPlatform; +use Doctrine\DBAL\Platforms\SQLitePlatform; +use Doctrine\ORM\Query\AST\Functions\FunctionNode; +use Doctrine\ORM\Query\AST\Node; +use Doctrine\ORM\Query\Parser; +use Doctrine\ORM\Query\SqlWalker; +use Doctrine\ORM\Query\TokenType; + +/** + * Custom DQL function that extracts the first numeric value with an optional SI prefix + * from a string and returns the scaled numeric value for sorting. + * + * Usage: SI_VALUE_SORT(part.name) + * + * This enables sorting parts by their physical value. For example, capacitors + * named "100nF", "1uF", "10pF" will be sorted by actual value: 10pF < 100nF < 1uF. + * + * Supported SI prefixes: p (pico, 1e-12), n (nano, 1e-9), u/µ (micro, 1e-6), + * m (milli, 1e-3), k/K (kilo, 1e3), M (mega, 1e6), G (giga, 1e9), T (tera, 1e12). + * + * Only matches numbers at the very beginning of the string (ignoring leading whitespace). + * Names like "Crystal 20MHz" will NOT match since the number is not at the start. + * Names without a recognizable numeric+prefix pattern return NULL and sort last. + */ +class SiValueSort extends FunctionNode +{ + private ?Node $field = null; + + /** + * SI prefix multipliers. Used by the SQLite PHP callback. + */ + private const SI_MULTIPLIERS = [ + 'p' => 1e-12, + 'n' => 1e-9, + 'u' => 1e-6, + 'µ' => 1e-6, + 'm' => 1e-3, + 'k' => 1e3, + 'K' => 1e3, + 'M' => 1e6, + 'G' => 1e9, + 'T' => 1e12, + ]; + + public function parse(Parser $parser): void + { + $parser->match(TokenType::T_IDENTIFIER); + $parser->match(TokenType::T_OPEN_PARENTHESIS); + + $this->field = $parser->ArithmeticExpression(); + + $parser->match(TokenType::T_CLOSE_PARENTHESIS); + } + + public function getSql(SqlWalker $sqlWalker): string + { + assert($this->field !== null, 'Field is not set'); + + $platform = $sqlWalker->getConnection()->getDatabasePlatform(); + $rawField = $this->field->dispatch($sqlWalker); + + // Normalize comma decimal separator to dot for SQL platforms (European locale support) + $fieldSql = "REPLACE({$rawField}, ',', '.')"; + + if ($platform instanceof PostgreSQLPlatform) { + return $this->getPostgreSQLSql($fieldSql); + } + + if ($platform instanceof AbstractMySQLPlatform) { + return $this->getMySQLSql($fieldSql); + } + + // SQLite: comma normalization is handled in the PHP callback + $fieldSql = $rawField; + + if ($platform instanceof SQLitePlatform) { + return "SI_VALUE({$fieldSql})"; + } + + // Fallback: return NULL (no SI sorting available) + return 'NULL'; + } + + /** + * PostgreSQL implementation using substring() with POSIX regex. + */ + private function getPostgreSQLSql(string $field): string + { + // Extract the numeric part using POSIX regex, anchored at start (with optional leading whitespace) + $numericPart = "CAST(substring({$field} FROM '^\\s*(\\d+\\.?\\d*)\\s*[pnuµmkKMGT]?') AS DOUBLE PRECISION)"; + + // Extract the SI prefix character + $prefixPart = "substring({$field} FROM '^\\s*\\d+\\.?\\d*\\s*([pnuµmkKMGT])')"; + + return $this->buildCaseExpression($numericPart, $prefixPart); + } + + /** + * MySQL/MariaDB implementation using REGEXP_SUBSTR. + */ + private function getMySQLSql(string $field): string + { + // Extract the numeric part, anchored at start (with optional leading whitespace) + $numericPart = "CAST(REGEXP_SUBSTR({$field}, '^[[:space:]]*[0-9]+\\.?[0-9]*') AS DECIMAL(30,15))"; + + // Extract the prefix: get the full number+prefix match anchored at start, then take the last char + $fullMatch = "REGEXP_SUBSTR({$field}, '^[[:space:]]*[0-9]+\\.?[0-9]*[[:space:]]*[pnuµmkKMGT]')"; + $prefixPart = "RIGHT({$fullMatch}, 1)"; + + return $this->buildCaseExpression($numericPart, $prefixPart); + } + + /** + * Build a CASE expression that maps an SI prefix character to a multiplier + * and multiplies it with the numeric value. + * + * @param string $numericExpr SQL expression that evaluates to the numeric part + * @param string $prefixExpr SQL expression that evaluates to the SI prefix character + * @return string SQL CASE expression + */ + private function buildCaseExpression(string $numericExpr, string $prefixExpr): string + { + return "(CASE" . + " WHEN {$numericExpr} IS NULL THEN NULL" . + " WHEN {$prefixExpr} = 'p' THEN {$numericExpr} * 1e-12" . + " WHEN {$prefixExpr} = 'n' THEN {$numericExpr} * 1e-9" . + " WHEN {$prefixExpr} = 'u' THEN {$numericExpr} * 1e-6" . + " WHEN {$prefixExpr} = 'µ' THEN {$numericExpr} * 1e-6" . + " WHEN {$prefixExpr} = 'm' THEN {$numericExpr} * 1e-3" . + " WHEN {$prefixExpr} = 'k' THEN {$numericExpr} * 1e3" . + " WHEN {$prefixExpr} = 'K' THEN {$numericExpr} * 1e3" . + " WHEN {$prefixExpr} = 'M' THEN {$numericExpr} * 1e6" . + " WHEN {$prefixExpr} = 'G' THEN {$numericExpr} * 1e9" . + " WHEN {$prefixExpr} = 'T' THEN {$numericExpr} * 1e12" . + " ELSE {$numericExpr} * 1" . + " END)"; + } + + /** + * PHP callback for SQLite's SI_VALUE function. + * Extracts the first numeric value with an optional SI prefix and returns the scaled value. + * + * @param string|null $value The input string + * @return float|null The scaled numeric value, or null if no number found + */ + public static function sqliteSiValue(?string $value): ?float + { + if ($value === null) { + return null; + } + + // Normalize comma decimal separator to dot (European locale support) + $value = str_replace(',', '.', $value); + + // Match a number at the very start (allowing leading whitespace), optionally followed by an SI prefix + if (!preg_match('/^\s*(\d+\.?\d*)\s*([pnuµmkKMGT])?/u', $value, $matches)) { + return null; + } + + $number = (float) $matches[1]; + $prefix = $matches[2] ?? ''; + + if ($prefix === '') { + return $number; + } + + $multiplier = self::SI_MULTIPLIERS[$prefix] ?? 1.0; //@phpstan-ignore-line - fallback to 1.0 if prefix is not recognized (should not happen due to regex) + + return $number * $multiplier; + } +} diff --git a/src/Doctrine/Middleware/SQLiteRegexExtensionMiddlewareDriver.php b/src/Doctrine/Middleware/SQLiteRegexExtensionMiddlewareDriver.php index ad572d4c..aa6108c9 100644 --- a/src/Doctrine/Middleware/SQLiteRegexExtensionMiddlewareDriver.php +++ b/src/Doctrine/Middleware/SQLiteRegexExtensionMiddlewareDriver.php @@ -23,6 +23,7 @@ declare(strict_types=1); namespace App\Doctrine\Middleware; +use App\Doctrine\Functions\SiValueSort; use App\Exceptions\InvalidRegexException; use Doctrine\DBAL\Driver\Connection; use Doctrine\DBAL\Driver\Middleware\AbstractDriverMiddleware; @@ -51,6 +52,9 @@ class SQLiteRegexExtensionMiddlewareDriver extends AbstractDriverMiddleware //Create a new collation for natural sorting $native_connection->sqliteCreateCollation('NATURAL_CMP', strnatcmp(...)); + + //Create a function for SI prefix value sorting + $native_connection->sqliteCreateFunction('SI_VALUE', SiValueSort::sqliteSiValue(...), 1, \PDO::SQLITE_DETERMINISTIC); } } diff --git a/src/Entity/Parts/Category.php b/src/Entity/Parts/Category.php index 7fca81bc..22f8a3e4 100644 --- a/src/Entity/Parts/Category.php +++ b/src/Entity/Parts/Category.php @@ -208,6 +208,15 @@ class Category extends AbstractPartsContainingDBElement $this->eda_info = new EDACategoryInfo(); } + public function __clone() + { + if ($this->id) { + //Clone EDA info to prevent changes to the original EDA info when changing the cloned category + $this->eda_info = clone $this->eda_info; + } + parent::__clone(); + } + public function getPartnameHint(): string { return $this->partname_hint; diff --git a/src/Entity/Parts/Footprint.php b/src/Entity/Parts/Footprint.php index 6b043562..3d8be686 100644 --- a/src/Entity/Parts/Footprint.php +++ b/src/Entity/Parts/Footprint.php @@ -152,6 +152,15 @@ class Footprint extends AbstractPartsContainingDBElement $this->eda_info = new EDAFootprintInfo(); } + public function __clone() + { + if ($this->id) { + //Clone EDA info to prevent changes to the original EDA info when changing the cloned category + $this->eda_info = clone $this->eda_info; + } + parent::__clone(); + } + /**************************************** * Getters ****************************************/ diff --git a/src/Entity/Parts/PartLot.php b/src/Entity/Parts/PartLot.php index 53ecd3d5..a15eeb4f 100644 --- a/src/Entity/Parts/PartLot.php +++ b/src/Entity/Parts/PartLot.php @@ -66,7 +66,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; #[ORM\Table(name: 'part_lots')] #[ORM\Index(columns: ['instock_unknown', 'expiration_date', 'id_part'], name: 'part_lots_idx_instock_un_expiration_id_part')] #[ORM\Index(columns: ['needs_refill'], name: 'part_lots_idx_needs_refill')] -#[ORM\Index(columns: ['vendor_barcode'], name: 'part_lots_idx_barcode')] +#[ORM\Index(name: 'part_lots_idx_barcode', columns: ['vendor_barcode'], options: ['lengths' => [100]])] #[ValidPartLot] #[UniqueEntity(['user_barcode'], message: 'validator.part_lot.vendor_barcode_must_be_unique')] #[ApiResource( @@ -81,7 +81,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; denormalizationContext: ['groups' => ['part_lot:write', 'api:basic:write'], 'openapi_definition_name' => 'Write'], )] #[ApiFilter(PropertyFilter::class)] -#[ApiFilter(LikeFilter::class, properties: ["description", "comment"])] +#[ApiFilter(LikeFilter::class, properties: ["description", "comment", "user_barcode"])] #[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)] #[ApiFilter(BooleanFilter::class, properties: ['instock_unknown', 'needs_refill'])] #[ApiFilter(RangeFilter::class, properties: ['amount'])] @@ -166,9 +166,8 @@ class PartLot extends AbstractDBElement implements TimeStampableInterface, Named /** * @var string|null The content of the barcode of this part lot (e.g. a barcode on the package put by the vendor) */ - #[ORM\Column(name: "vendor_barcode", type: Types::STRING, nullable: true)] + #[ORM\Column(name: "vendor_barcode", type: Types::TEXT, nullable: true)] #[Groups(['part_lot:read', 'part_lot:write'])] - #[Length(max: 255)] protected ?string $user_barcode = null; /** diff --git a/src/EventSubscriber/MaintenanceModeSubscriber.php b/src/EventSubscriber/MaintenanceModeSubscriber.php index 654ba9f2..0ba5aa99 100644 --- a/src/EventSubscriber/MaintenanceModeSubscriber.php +++ b/src/EventSubscriber/MaintenanceModeSubscriber.php @@ -62,8 +62,8 @@ readonly class MaintenanceModeSubscriber implements EventSubscriberInterface return; } - //Allow to view the progress page - if (preg_match('#^/\w{2}/system/update-manager/progress#', $event->getRequest()->getPathInfo())) { + //Allow to view the progress page and health check endpoint + if (preg_match('#^/[a-z]{2}(?:_[A-Z]{2})?/system/update-manager/(progress|health)#', $event->getRequest()->getPathInfo())) { return; } diff --git a/src/Form/AdminPages/BaseEntityAdminForm.php b/src/Form/AdminPages/BaseEntityAdminForm.php index f4bf37f8..bf005882 100644 --- a/src/Form/AdminPages/BaseEntityAdminForm.php +++ b/src/Form/AdminPages/BaseEntityAdminForm.php @@ -121,6 +121,7 @@ class BaseEntityAdminForm extends AbstractType 'label' => 'entity.edit.alternative_names.label', 'help' => 'entity.edit.alternative_names.help', 'empty_data' => null, + 'disabled' => !$this->security->isGranted($is_new ? 'create' : 'edit', $entity), 'attr' => [ 'class' => 'tagsinput', 'data-controller' => 'elements--tagsinput', diff --git a/src/Form/Extension/TogglePasswordTypeExtension.php b/src/Form/Extension/TogglePasswordTypeExtension.php index fec4c0b3..7d49eeda 100644 --- a/src/Form/Extension/TogglePasswordTypeExtension.php +++ b/src/Form/Extension/TogglePasswordTypeExtension.php @@ -45,7 +45,7 @@ final class TogglePasswordTypeExtension extends AbstractTypeExtension public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ - 'toggle' => false, + 'toggle' => true, 'hidden_label' => new TranslatableMessage('password_toggle.hide'), 'visible_label' => new TranslatableMessage('password_toggle.show'), 'hidden_icon' => 'Default', diff --git a/src/Form/InfoProviderSystem/FromURLFormType.php b/src/Form/InfoProviderSystem/FromURLFormType.php new file mode 100644 index 00000000..39ef50f4 --- /dev/null +++ b/src/Form/InfoProviderSystem/FromURLFormType.php @@ -0,0 +1,87 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Form\InfoProviderSystem; + +use App\Services\InfoProviderSystem\ProviderRegistry; +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\CheckboxType; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; +use Symfony\Component\Form\Extension\Core\Type\SubmitType; +use Symfony\Component\Form\Extension\Core\Type\UrlType; +use Symfony\Component\Form\FormBuilderInterface; + +class FromURLFormType extends AbstractType +{ + public function __construct(private readonly ProviderRegistry $providerRegistry) + { + + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->add('url', UrlType::class, [ + 'label' => 'info_providers.from_url.url.label', + 'required' => true, + ]); + + + $builder->add('method', ChoiceType::class, [ + 'expanded' => true, + 'data' => 'generic_web', //Default value + 'label' => 'info_providers.from_url.method', + 'choices' => [ + 'info_providers.from_url.method.generic_web' => 'generic_web', + 'info_providers.from_url.method.ai_web' => 'ai_web', + ], + 'choice_attr' => function ($choice, $key, $value) { + //Disable all providers that are not active + $provider = $this->providerRegistry->getProviderByKey($value); + if (!$provider->isActive()) { + return ['disabled' => 'disabled']; + } + + return []; + }, + + //Render the choices as inline radio buttons + 'label_attr' => [ + 'class' => 'radio-inline', + ], + ]); + + $builder->add('no_cache', CheckboxType::class, [ + 'label' => 'info_providers.from_url.no_cache', + 'required' => false, + ]); + + $builder->add('skip_delegation', CheckboxType::class, [ + 'label' => 'info_providers.from_url.skip_delegation', + 'required' => false, + ]); + + $builder->add('submit', SubmitType::class, [ + 'label' => 'info_providers.search.submit', + ]); + } +} diff --git a/src/Form/InfoProviderSystem/PartSearchType.php b/src/Form/InfoProviderSystem/PartSearchType.php index 9d582ca4..154c1aa3 100644 --- a/src/Form/InfoProviderSystem/PartSearchType.php +++ b/src/Form/InfoProviderSystem/PartSearchType.php @@ -24,6 +24,7 @@ declare(strict_types=1); namespace App\Form\InfoProviderSystem; use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\SearchType; use Symfony\Component\Form\Extension\Core\Type\SubmitType; use Symfony\Component\Form\FormBuilderInterface; @@ -40,8 +41,17 @@ class PartSearchType extends AbstractType 'help' => 'info_providers.search.providers.help', ]); + $builder->add('no_cache_search', CheckboxType::class, [ + 'label' => 'info_providers.no_cache_search', + 'required' => false, + ]); + $builder->add('no_cache_details', CheckboxType::class, [ + 'label' => 'info_providers.no_cache_details', + 'required' => false, + ]); + $builder->add('submit', SubmitType::class, [ 'label' => 'info_providers.search.submit' ]); } -} \ No newline at end of file +} diff --git a/src/Form/Part/EDA/KicadFieldAutocompleteType.php b/src/Form/Part/EDA/KicadFieldAutocompleteType.php index 50de81d0..8a7b0313 100644 --- a/src/Form/Part/EDA/KicadFieldAutocompleteType.php +++ b/src/Form/Part/EDA/KicadFieldAutocompleteType.php @@ -24,6 +24,7 @@ declare(strict_types=1); namespace App\Form\Part\EDA; use App\Form\Type\StaticFileAutocompleteType; +use App\Settings\MiscSettings\KiCadEDASettings; use Symfony\Component\Form\AbstractType; use Symfony\Component\OptionsResolver\Options; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -39,6 +40,13 @@ class KicadFieldAutocompleteType extends AbstractType //Do not use a leading slash here! otherwise it will not work under prefixed reverse proxies public const FOOTPRINT_PATH = 'kicad/footprints.txt'; public const SYMBOL_PATH = 'kicad/symbols.txt'; + public const CUSTOM_FOOTPRINT_PATH = 'kicad/footprints_custom.txt'; + public const CUSTOM_SYMBOL_PATH = 'kicad/symbols_custom.txt'; + + public function __construct( + private readonly KiCadEDASettings $kiCadEDASettings, + ) { + } public function configureOptions(OptionsResolver $resolver): void { @@ -47,8 +55,8 @@ class KicadFieldAutocompleteType extends AbstractType $resolver->setDefaults([ 'file' => fn(Options $options) => match ($options['type']) { - self::TYPE_FOOTPRINT => self::FOOTPRINT_PATH, - self::TYPE_SYMBOL => self::SYMBOL_PATH, + self::TYPE_FOOTPRINT => $this->kiCadEDASettings->useCustomList ? self::CUSTOM_FOOTPRINT_PATH : self::FOOTPRINT_PATH, + self::TYPE_SYMBOL => $this->kiCadEDASettings->useCustomList ? self::CUSTOM_SYMBOL_PATH : self::SYMBOL_PATH, default => throw new \InvalidArgumentException('Invalid type'), } ]); @@ -58,4 +66,4 @@ class KicadFieldAutocompleteType extends AbstractType { return StaticFileAutocompleteType::class; } -} \ No newline at end of file +} diff --git a/src/Form/Security/LoginFormType.php b/src/Form/Security/LoginFormType.php new file mode 100644 index 00000000..184f68ac --- /dev/null +++ b/src/Form/Security/LoginFormType.php @@ -0,0 +1,83 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Form\Security; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\CheckboxType; +use Symfony\Component\Form\Extension\Core\Type\PasswordType; +use Symfony\Component\Form\Extension\Core\Type\TextType; +use Symfony\Component\OptionsResolver\OptionsResolver; + +use function Symfony\Component\Translation\t; + +class LoginFormType extends AbstractType +{ + public function buildForm(\Symfony\Component\Form\FormBuilderInterface $builder, array $options): void + { + $builder + ->add('_username', TextType::class, [ + 'label' => t('login.username.label'), + 'attr' => [ + 'autofocus' => 'autofocus', + 'autocomplete' => 'username', + 'placeholder' => t('login.username.placeholder'), + ] + ]) + ->add('_password', PasswordType::class, [ + 'label' => t('login.password.label'), + 'attr' => [ + 'autocomplete' => 'current-password', + 'placeholder' => t('login.password.placeholder'), + ] + ]) + ->add('_remember_me', CheckboxType::class, [ + 'label' => t('login.rememberme'), + 'required' => false, + ]) + ->add('submit', \Symfony\Component\Form\Extension\Core\Type\SubmitType::class, [ + 'label' => t('login.btn'), + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + // This ensures CSRF protection is active for the login + 'csrf_protection' => true, + 'csrf_field_name' => '_csrf_token', + 'csrf_token_id' => 'authenticate', + 'attr' => [ + 'data-turbo' => 'false', // Disable Turbo for the login form to ensure proper redirection after login + ] + ]); + } + + public function getBlockPrefix(): string + { + // This removes the "login_form_" prefix from field names + // so that Security can find "_username" directly. + return ''; + } +} diff --git a/src/Form/Settings/AiModelsType.php b/src/Form/Settings/AiModelsType.php new file mode 100644 index 00000000..5228bb47 --- /dev/null +++ b/src/Form/Settings/AiModelsType.php @@ -0,0 +1,72 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Form\Settings; + +use Symfony\AI\Platform\Capability; +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\TextType; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormView; +use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; + +/** + * An text input with autocomplete for AI models from the given platform. + * The platform is determined by the value of another form field, which is specified by the "platform_selector" option. This allows to filter the available models based on the selected platform. + */ +final class AiModelsType extends AbstractType +{ + public function __construct(private readonly UrlGeneratorInterface $urlGenerator) + { + } + + public function getParent(): string + { + return TextType::class; + } + + public function configureOptions(OptionsResolver $resolver): void + { + //The target label of the platform select, which is used to filter the models for the selected platform. + $resolver->setRequired('platform_selector'); + $resolver->setAllowedTypes('platform_selector', 'string'); + + //Only show models, that have the given capability. This is used to only show models that support structured output for the AI extractor settings. + $resolver->setDefault('filter_capability', null); + $resolver->setAllowedTypes('filter_capability', ['null', Capability::class]); + } + + public function finishView(FormView $view, FormInterface $form, array $options): void + { + $urlOptions = ['platform' => '__PLATFORM__']; + if ($options['filter_capability'] !== null) { + $urlOptions['capability'] = $options['filter_capability']->value; + } + + $view->vars['attr']['data-url-template'] = $this->urlGenerator->generate('typeahead_ai_models', $urlOptions); + $view->vars['attr']['data-controller'] = 'elements--ai-model-autocomplete'; + + $view->vars['attr']['data-platform-selector'] = $options['platform_selector']; + } +} diff --git a/src/Form/Settings/AiPlatformChoiceType.php b/src/Form/Settings/AiPlatformChoiceType.php new file mode 100644 index 00000000..82ea66b2 --- /dev/null +++ b/src/Form/Settings/AiPlatformChoiceType.php @@ -0,0 +1,65 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Form\Settings; + +use App\Services\AI\AIPlatformRegistry; +use App\Services\AI\AIPlatforms; +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; +use Symfony\Component\Form\Extension\Core\Type\EnumType; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormView; +use Symfony\Component\OptionsResolver\OptionsResolver; + +/** + * Allow to choose an AI platform from the enabled platforms in the system. This is used in the settings to choose the default platform for AI features. + */ +final class AiPlatformChoiceType extends AbstractType +{ + public function __construct(private readonly AIPlatformRegistry $platformRegistry) + { + } + + public function getParent(): string + { + return EnumType::class; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $choices = array_map(static fn(string $val) => AIPlatforms::from($val), array_keys($this->platformRegistry->getEnabledPlatforms())); + + $resolver->setDefaults([ + 'class' => AIPlatforms::class, + 'choices' => $choices, + 'required' => false, + 'platform_selector_label' => null + ]); + } + + public function finishView(FormView $view, FormInterface $form, array $options): void + { + $view->vars['attr']['data-platform-selector-label'] = $options['platform_selector_label'] ?? $view->vars['id'].'_label'; + } +} diff --git a/src/Form/Settings/KicadListEditorType.php b/src/Form/Settings/KicadListEditorType.php new file mode 100644 index 00000000..cefdbdbc --- /dev/null +++ b/src/Form/Settings/KicadListEditorType.php @@ -0,0 +1,103 @@ +. + */ + +declare(strict_types=1); + +namespace App\Form\Settings; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\CheckboxType; +use Symfony\Component\Form\Extension\Core\Type\SubmitType; +use Symfony\Component\Form\Extension\Core\Type\TextareaType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; + +/** + * Form type for editing the custom KiCad footprints and symbols lists. + */ +final class KicadListEditorType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('useCustomList', CheckboxType::class, [ + 'label' => 'settings.misc.kicad_eda.use_custom_list', + 'help' => 'settings.misc.kicad_eda.use_custom_list.help', + 'required' => false, + ]) + ->add('customFootprints', TextareaType::class, [ + 'label' => 'settings.misc.kicad_eda.editor.custom_footprints', + 'help' => 'settings.misc.kicad_eda.editor.footprints.help', + 'attr' => [ + 'rows' => 16, + 'spellcheck' => 'false', + 'class' => 'font-monospace', + ], + ]) + ->add('defaultFootprints', TextareaType::class, [ + 'label' => 'settings.misc.kicad_eda.editor.default_footprints', + 'help' => 'settings.misc.kicad_eda.editor.default_files_help', + 'disabled' => true, + 'mapped' => false, + 'data' => $options['default_footprints'], + 'attr' => [ + 'rows' => 16, + 'spellcheck' => 'false', + 'class' => 'font-monospace', + 'readonly' => 'readonly', + ], + ]) + ->add('customSymbols', TextareaType::class, [ + 'label' => 'settings.misc.kicad_eda.editor.custom_symbols', + 'help' => 'settings.misc.kicad_eda.editor.symbols.help', + 'attr' => [ + 'rows' => 16, + 'spellcheck' => 'false', + 'class' => 'font-monospace', + ], + ]) + ->add('defaultSymbols', TextareaType::class, [ + 'label' => 'settings.misc.kicad_eda.editor.default_symbols', + 'help' => 'settings.misc.kicad_eda.editor.default_files_help', + 'disabled' => true, + 'mapped' => false, + 'data' => $options['default_symbols'], + 'attr' => [ + 'rows' => 16, + 'spellcheck' => 'false', + 'class' => 'font-monospace', + 'readonly' => 'readonly', + ], + ]) + ->add('save', SubmitType::class, [ + 'label' => 'save', + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'default_footprints' => '', + 'default_symbols' => '', + ]); + $resolver->setAllowedTypes('default_footprints', 'string'); + $resolver->setAllowedTypes('default_symbols', 'string'); + } +} diff --git a/src/Form/Settings/TypeSynonymRowType.php b/src/Form/Settings/TypeSynonymRowType.php index f3b8f0b6..ffff14fa 100644 --- a/src/Form/Settings/TypeSynonymRowType.php +++ b/src/Form/Settings/TypeSynonymRowType.php @@ -139,7 +139,7 @@ class TypeSynonymRowType extends AbstractType */ private function getPreferredLocales(): array { - $fromSettings = $this->localizationSettings->languageMenuEntries ?? []; + $fromSettings = $this->localizationSettings->languageMenuEntries; return !empty($fromSettings) ? array_values($fromSettings) : array_values($this->preferredLanguagesParam); } diff --git a/src/Helpers/RandomizeUseragentHttpClient.php b/src/Helpers/RandomizeUseragentHttpClient.php index bca91c79..42e62d11 100644 --- a/src/Helpers/RandomizeUseragentHttpClient.php +++ b/src/Helpers/RandomizeUseragentHttpClient.php @@ -29,60 +29,137 @@ use Symfony\Contracts\HttpClient\ResponseStreamInterface; /** * HttpClient wrapper that randomizes the user agent for each request, to make it harder for servers to detect and block us. + * It also sets some other headers to make the requests look more like real browser requests. * When we get a 503, 403 or 429, we assume that the server is blocking us and try again with a different user agent, until we run out of retries. */ final class RandomizeUseragentHttpClient implements HttpClientInterface { - public const USER_AGENTS = [ - "Mozilla/5.0 (Windows; U; Windows NT 10.0; Win64; x64) AppleWebKit/534.16 (KHTML, like Gecko) Chrome/52.0.1359.302 Safari/600.6 Edge/15.25690", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36 Edge/16.16299", - "Mozilla/5.0 (Macintosh; U; Intel Mac OS X 8_8_3) Gecko/20100101 Firefox/51.6", - "Mozilla/5.0 (Android; Android 4.4.4; E:number:20-23:00 Build/24.0.B.1.34) AppleWebKit/603.18 (KHTML, like Gecko) Chrome/47.0.1559.384 Mobile Safari/600.5", - "Mozilla/5.0 (compatible; MSIE 9.0; Windows; Windows NT 6.3; WOW64 Trident/5.0)", - "Mozilla/5.0 (Windows; Windows NT 6.0; Win64; x64) AppleWebKit/602.21 (KHTML, like Gecko) Chrome/51.0.3187.154 Safari/536", - "Mozilla/5.0 (iPhone; CPU iPhone OS 9_4_2; like Mac OS X) AppleWebKit/537.24 (KHTML, like Gecko) Chrome/51.0.2432.275 Mobile Safari/535.6", - "Mozilla/5.0 (U; Linux i680 ) Gecko/20100101 Firefox/57.5", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 8_8_6; en-US) Gecko/20100101 Firefox/53.9", - "Mozilla/5.0 (Macintosh; U; Intel Mac OS X 8_6_7) AppleWebKit/534.46 (KHTML, like Gecko) Chrome/55.0.3276.345 Safari/535", - "Mozilla/5.0 (Windows; Windows NT 10.5;) AppleWebKit/535.42 (KHTML, like Gecko) Chrome/53.0.1176.353 Safari/534.0 Edge/11.95743", - "Mozilla/5.0 (Linux; Android 5.1.1; MOTO G Build/LPH223) AppleWebKit/600.27 (KHTML, like Gecko) Chrome/47.0.1604.204 Mobile Safari/535.1", - "Mozilla/5.0 (iPod; CPU iPod OS 7_4_8; like Mac OS X) AppleWebKit/534.17 (KHTML, like Gecko) Chrome/50.0.1632.146 Mobile Safari/600.4", - "Mozilla/5.0 (Linux; U; Linux i570 ; en-US) Gecko/20100101 Firefox/49.9", - "Mozilla/5.0 (Windows NT 10.2; WOW64; en-US) AppleWebKit/603.2 (KHTML, like Gecko) Chrome/55.0.1299.311 Safari/535", - "Mozilla/5.0 (Windows; Windows NT 10.5; x64; en-US) AppleWebKit/603.39 (KHTML, like Gecko) Chrome/52.0.1443.139 Safari/536.6 Edge/13.79436", - "Mozilla/5.0 (Linux; U; Android 5.1; SM-G9350T Build/MMB29M) AppleWebKit/537.15 (KHTML, like Gecko) Chrome/55.0.2552.307 Mobile Safari/600.8", - "Mozilla/5.0 (Android; Android 6.0; SAMSUNG SM-D9350V Build/MDB08L) AppleWebKit/535.30 (KHTML, like Gecko) Chrome/53.0.1345.278 Mobile Safari/537.4", - "Mozilla/5.0 (Windows; Windows NT 10.0;) AppleWebKit/534.44 (KHTML, like Gecko) Chrome/47.0.3503.387 Safari/601", + private const PROFILES = [ + // --- CHROME ON WINDOWS --- + 'chrome_windows' => [ + 'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36', + 'Sec-Ch-Ua' => '"Google Chrome";v="142", "Chromium";v="142", "Not=A?Brand";v="99"', + 'Sec-Ch-Ua-Mobile' => '?0', + 'Sec-Ch-Ua-Platform' => '"Windows"', + 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7', + ], + + // --- CHROME ON MACOS --- + 'chrome_mac' => [ + 'User-Agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36', + 'Sec-Ch-Ua' => '"Google Chrome";v="141", "Chromium";v="141", "Not=A?Brand";v="99"', + 'Sec-Ch-Ua-Mobile' => '?0', + 'Sec-Ch-Ua-Platform' => '"macOS"', + 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7', + ], + + // --- EDGE ON WINDOWS --- + 'edge_windows' => [ + 'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36 Edg/142.0.0.0', + 'Sec-Ch-Ua' => '"Microsoft Edge";v="142", "Chromium";v="142", "Not=A?Brand";v="99"', + 'Sec-Ch-Ua-Mobile' => '?0', + 'Sec-Ch-Ua-Platform' => '"Windows"', + 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7', + ], + + // --- FIREFOX ON WINDOWS --- + 'firefox_windows' => [ + 'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:138.0) Gecko/20100101 Firefox/138.0', + 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8', + 'Accept-Language' => 'en-US,en;q=0.5', + // Firefox does not send Sec-Ch-Ua headers by default + ], + + // --- FIREFOX ON LINUX --- + 'firefox_linux' => [ + 'User-Agent' => 'Mozilla/5.0 (X11; Linux x86_64; rv:137.0) Gecko/20100101 Firefox/137.0', + 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8', + 'Accept-Language' => 'en-US,en;q=0.5', + ], + + // --- SAFARI ON MACOS --- + 'safari_mac' => [ + 'User-Agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15', + 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language' => 'en-US,en;q=0.9', + ], + + // --- CHROME ON ANDROID (Mobile) --- + 'chrome_android' => [ + 'User-Agent' => 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Mobile Safari/537.36', + 'Sec-Ch-Ua' => '"Google Chrome";v="142", "Chromium";v="142", "Not=A?Brand";v="99"', + 'Sec-Ch-Ua-Mobile' => '?1', + 'Sec-Ch-Ua-Platform' => '"Android"', + 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7', + ], + + // --- SAFARI ON IPHONE (Mobile) --- + 'safari_iphone' => [ + 'User-Agent' => 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1', + 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language' => 'en-US,en;q=0.9', + ], ]; + private const COMMON_HEADERS = [ + 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7', + 'Accept-Language' => 'en-US,en;q=0.9', + 'Sec-Fetch-Dest' => 'document', + 'Sec-Fetch-Mode' => 'navigate', + 'Sec-Fetch-Site' => 'none', + 'Sec-Fetch-User' => '?1', + 'Upgrade-Insecure-Requests' => '1', + ]; + + private const ENTRY_REFERERS = [ + 'https://www.google.com/', + 'https://www.bing.com/', + 'https://duckduckgo.com/', + 'https://t.co/', // Twitter/X shortener + 'https://www.reddit.com/', + ]; + + private ?string $lastUrl = null; + public function __construct( private readonly HttpClientInterface $client, - private readonly array $userAgents = self::USER_AGENTS, private readonly int $repeatOnFailure = 1, ) { } - public function getRandomUserAgent(): string - { - return $this->userAgents[array_rand($this->userAgents)]; - } - public function request(string $method, string $url, array $options = []): ResponseInterface { $repeatsLeft = $this->repeatOnFailure; do { - $modifiedOptions = $options; - if (!isset($modifiedOptions['headers']['User-Agent'])) { - $modifiedOptions['headers']['User-Agent'] = $this->getRandomUserAgent(); + $profile = self::PROFILES[array_rand(self::PROFILES)]; + + // Merge common headers with the specific browser profile + $headers = array_merge(self::COMMON_HEADERS, $profile); + + //Add a Referer header if not already set, to make it look more like a real browser request. We use the last URL we visited as the referer, to simulate internal navigation. If we don't have a last URL (first request), we pick a random entry point from common referers. + if (!isset($options['headers']['Referer'])) { + if ($this->lastUrl !== null) { + // If we have a previous URL, use it (Internal Navigation) + $headers['Referer'] = $this->lastUrl; + } else { + // First request? Pick an entry point (External Entry) + $headers['Referer'] = self::ENTRY_REFERERS[array_rand(self::ENTRY_REFERERS)]; + } } - $response = $this->client->request($method, $url, $modifiedOptions); + + // Allow manual overrides from $options + $options['headers'] = array_merge($headers, $options['headers'] ?? []); + + $response = $this->client->request($method, $url, $options); //When we get a 503, 403 or 429, we assume that the server is blocking us and try again with a different user agent if (!in_array($response->getStatusCode(), [403, 429, 503], true)) { + $this->lastUrl = $url; // Update last visited URL for referer in the next request return $response; } //Otherwise we try again with a different user agent, until we run out of retries + usleep(5000); // Sleep for 5ms to avoid hammering the server too hard in case of multiple retries } while ($repeatsLeft-- > 0); return $response; @@ -95,6 +172,6 @@ final class RandomizeUseragentHttpClient implements HttpClientInterface public function withOptions(array $options): static { - return new self($this->client->withOptions($options), $this->userAgents, $this->repeatOnFailure); + return new self($this->client->withOptions($options), $this->repeatOnFailure); } } diff --git a/src/Serializer/StructuralElementDenormalizer.php b/src/Serializer/StructuralElementDenormalizer.php index 9f4256f9..3da5f796 100644 --- a/src/Serializer/StructuralElementDenormalizer.php +++ b/src/Serializer/StructuralElementDenormalizer.php @@ -42,6 +42,8 @@ class StructuralElementDenormalizer implements DenormalizerInterface, Denormaliz private const ALREADY_CALLED = 'STRUCTURAL_DENORMALIZER_ALREADY_CALLED'; + private const PARENT_ELEMENT = 'STRUCTURAL_DENORMALIZER_PARENT_ELEMENT'; + private array $object_cache = []; public function __construct( @@ -89,37 +91,59 @@ class StructuralElementDenormalizer implements DenormalizerInterface, Denormaliz $context[self::ALREADY_CALLED][] = $data; + //In the first step, denormalize without children + $context_without_children = $context; + $context_without_children['groups'] = array_filter( + $context_without_children['groups'] ?? [], + static fn($group) => $group !== 'include_children', + ); + //Also unset any parent element, to avoid infinite loops. We will set the parent element in the next step, when we denormalize the children + unset($context_without_children[self::PARENT_ELEMENT]); + /** @var AbstractStructuralDBElement $entity */ + $entity = $this->denormalizer->denormalize($data, $type, $format, $context_without_children); - /** @var AbstractStructuralDBElement $deserialized_entity */ - $deserialized_entity = $this->denormalizer->denormalize($data, $type, $format, $context); + //Assign the parent element to the denormalized entity, so it can be used in the denormalization of the children (e.g. for path generation) + if (isset($context[self::PARENT_ELEMENT]) && $context[self::PARENT_ELEMENT] instanceof $entity && $entity->getID() === null) { + $entity->setParent($context[self::PARENT_ELEMENT]); + } //Check if we already have the entity in the database (via path) /** @var StructuralDBElementRepository $repo */ $repo = $this->entityManager->getRepository($type); - $path = $deserialized_entity->getFullPath(AbstractStructuralDBElement::PATH_DELIMITER_ARROW); + $path = $entity->getFullPath(AbstractStructuralDBElement::PATH_DELIMITER_ARROW); $db_elements = $repo->getEntityByPath($path, AbstractStructuralDBElement::PATH_DELIMITER_ARROW); if ($db_elements !== []) { //We already have the entity in the database, so we can return it - return end($db_elements); + $entity = end($db_elements); } //Check if we have created the entity in this request before (so we don't create multiple entities for the same path) //Entities get saved in the cache by type and path //We use a different cache for this then the objects created by a string value (saved in repo). However, that should not be a problem - //unless the user data has mixed structure between json data and a string path + //unless the user data has mixed structure between JSON data and a string path if (isset($this->object_cache[$type][$path])) { - return $this->object_cache[$type][$path]; + $entity = $this->object_cache[$type][$path]; + } else { + //Save the entity in the cache + $this->object_cache[$type][$path] = $entity; } - //Save the entity in the cache - $this->object_cache[$type][$path] = $deserialized_entity; + //In the next step we can denormalize the children, and add our children to the entity. + if (in_array('include_children', $context['groups'], true) && isset($data['children']) && is_array($data['children'])) { + foreach ($data['children'] as $child_data) { + $child_entity = $this->denormalize($child_data, $type, $format, array_merge($context, [self::PARENT_ELEMENT => $entity])); + if ($child_entity !== null && !$entity->getChildren()->contains($child_entity)) { + $entity->addChild($child_entity); + } + } + } //We don't have the entity in the database, so we have to persist it - $this->entityManager->persist($deserialized_entity); + $this->entityManager->persist($entity); - return $deserialized_entity; + return $entity; } public function getSupportedTypes(?string $format): array diff --git a/src/Services/AI/AIPlatformRegistry.php b/src/Services/AI/AIPlatformRegistry.php new file mode 100644 index 00000000..408bb181 --- /dev/null +++ b/src/Services/AI/AIPlatformRegistry.php @@ -0,0 +1,94 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\AI; + +use Jbtronics\SettingsBundle\Manager\SettingsManagerInterface; +use Symfony\AI\Platform\PlatformInterface; +use Symfony\Component\DependencyInjection\Attribute\AutowireIterator; + +final readonly class AIPlatformRegistry +{ + /** + * All registered platforms, indexed by their service tag name (e.g. "openrouter", "lmstudio") + * @var array $allPlatforms + */ + private array $allPlatforms; + + /** + * All registered platforms, indexed by their AIPlatforms enum value (e.g. AIPlatforms::OPENROUTER->value) + * @var array $enabledPlatforms + */ + private array $enabledPlatforms; + + public function __construct( + SettingsManagerInterface $settingsManager, + #[AutowireIterator(tag: 'ai.platform', indexAttribute: 'name')] + iterable $platforms, + ) { + $this->allPlatforms = iterator_to_array($platforms); + + //Check which platforms are active based on the settings and store them in $activePlatforms + $tmp = []; + foreach (AIPlatforms::cases() as $platform) { + if (isset($this->allPlatforms[$platform->toServiceTagName()])) { + //Check if the platform is active by calling its isActive() on the settings class + $settings = $settingsManager->get($platform->toSettingsClass()); + if (!$settings->isAIPlatformEnabled()) { + continue; + } + + $tmp[$platform->value] = $this->allPlatforms[$platform->toServiceTagName()]; + } + } + $this->enabledPlatforms = $tmp; + } + + public function getPlatform(AIPlatforms $platform): PlatformInterface + { + if (!isset($this->enabledPlatforms[$platform->value])) { + throw new \InvalidArgumentException(sprintf('AI platform "%s" is not active or does not exist.', $platform->name)); + } + + return $this->enabledPlatforms[$platform->value]; + } + + /** + * Check if the given platform is active (i.e. it is registered and its settings are properly configured) + * @param AIPlatforms $platform + * @return bool + */ + public function isEnabled(AIPlatforms $platform): bool + { + return isset($this->enabledPlatforms[$platform->value]); + } + + /** + * Returns an array of all active platforms, indexed by their AIPlatforms enum value (e.g. AIPlatforms::OPENROUTER->value) + * @return PlatformInterface[] + */ + public function getEnabledPlatforms(): array + { + return $this->enabledPlatforms; + } +} diff --git a/src/Services/AI/AIPlatformSettingsInterface.php b/src/Services/AI/AIPlatformSettingsInterface.php new file mode 100644 index 00000000..c400db46 --- /dev/null +++ b/src/Services/AI/AIPlatformSettingsInterface.php @@ -0,0 +1,33 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\AI; + +interface AIPlatformSettingsInterface +{ + /** + * Returns true, if the AI platform is enabled in the settings and can be used, false otherwise. + * @return bool + */ + public function isAIPlatformEnabled(): bool; +} diff --git a/src/Services/AI/AIPlatforms.php b/src/Services/AI/AIPlatforms.php new file mode 100644 index 00000000..2f4d6317 --- /dev/null +++ b/src/Services/AI/AIPlatforms.php @@ -0,0 +1,64 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\AI; + +use App\Settings\AISettings\LMStudioSettings; +use App\Settings\AISettings\OpenRouterSettings; +use Symfony\Contracts\Translation\TranslatableInterface; +use Symfony\Contracts\Translation\TranslatorInterface; + +enum AIPlatforms: string implements TranslatableInterface +{ + case OPENROUTER = 'openrouter'; + case LMSTUDIO = 'lmstudio'; + + /** + * Returns the name attribute of the service tag for this platform, which is used to register the platform in the AIPlatformRegistry + * @return string + */ + public function toServiceTagName(): string + { + return $this->value; + } + + /** + * Returns the class name of the settings class for this platform, which implements AIPlatformSettingsInterface + * @return string + * @phpstan-return class-string + */ + public function toSettingsClass(): string + { + return match ($this) { + self::LMSTUDIO => LMStudioSettings::class, + self::OPENROUTER => OpenRouterSettings::class, + }; + } + + public function trans(TranslatorInterface $translator, ?string $locale = null): string + { + $key = 'settings.ai.' . $this->value; + + return $translator->trans($key, locale: $locale); + } +} diff --git a/src/Services/AI/AcceptAllModelsCatalog.php b/src/Services/AI/AcceptAllModelsCatalog.php new file mode 100644 index 00000000..a2f5c33a --- /dev/null +++ b/src/Services/AI/AcceptAllModelsCatalog.php @@ -0,0 +1,61 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\AI; + +use Symfony\AI\Platform\Bridge\Generic\CompletionsModel; +use Symfony\AI\Platform\Exception\ModelNotFoundException; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\ModelCatalog\ModelCatalogInterface; +use Symfony\Component\DependencyInjection\Attribute\AsDecorator; + +/** + * This is a wrapper, to allow accepting all models, even if they are not contained in the decorated ModelCatalogInterface. + * This is a workaround for outdated/incomplete model catalogs provided by AI platforms, which do not contain all available models, or do not update their catalogs frequently enough. + */ +#[AsDecorator('ai.platform.model_catalog.lmstudio')] +#[AsDecorator('ai.platform.model_catalog.openrouter')] +final readonly class AcceptAllModelsCatalog implements ModelCatalogInterface +{ + + public function __construct(private ModelCatalogInterface $decorated) + { + } + + public function getModel(string $modelName): Model + { + //Use the actual values when its available. + try { + return $this->decorated->getModel($modelName); + } catch (ModelNotFoundException $e) { + //If the model is not found, return a generic model with the given name and no capabilities. + return new CompletionsModel($modelName, []); + } + } + + public function getModels(): array + { + //Return the actual models catalog here for correct autocompletition + return $this->decorated->getModels(); + } +} 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/Attachments/AttachmentSubmitHandler.php b/src/Services/Attachments/AttachmentSubmitHandler.php index 81a83f0c..25f6142f 100644 --- a/src/Services/Attachments/AttachmentSubmitHandler.php +++ b/src/Services/Attachments/AttachmentSubmitHandler.php @@ -44,6 +44,8 @@ use App\Exceptions\AttachmentDownloadException; use App\Settings\SystemSettings\AttachmentsSettings; use Hshn\Base64EncodedFile\HttpFoundation\File\Base64EncodedFile; use Hshn\Base64EncodedFile\HttpFoundation\File\UploadedBase64EncodedFile; +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\HttpClient\NoPrivateNetworkHttpClient; use const DIRECTORY_SEPARATOR; use InvalidArgumentException; use RuntimeException; @@ -76,6 +78,8 @@ class AttachmentSubmitHandler protected FileTypeFilterTools $filterTools, protected AttachmentsSettings $settings, protected readonly SVGSanitizer $SVGSanitizer, + #[Autowire(env: "bool:ALLOW_ATTACHMENT_DOWNLOADS_FROM_LOCALNETWORK")] + private readonly bool $allow_local_network_downloads = false, ) { //The mapping used to determine which folder will be used for an attachment type @@ -95,6 +99,10 @@ class AttachmentSubmitHandler UserAttachment::class => 'user', LabelAttachment::class => 'label_profile', ]; + + if (!$this->allow_local_network_downloads) { + $this->httpClient = new NoPrivateNetworkHttpClient($this->httpClient); + } } /** @@ -373,6 +381,7 @@ class AttachmentSubmitHandler ], ]; + $response = $this->httpClient->request('GET', $url, $opts); //Digikey wants TLSv1.3, so try again with that if we get a 403 if ($response->getStatusCode() === 403) { @@ -434,8 +443,8 @@ class AttachmentSubmitHandler $new_path = $this->pathResolver->realPathToPlaceholder($new_path); //Save the path to the attachment $attachment->setInternalPath($new_path); - } catch (TransportExceptionInterface) { - throw new AttachmentDownloadException('Transport error!'); + } catch (TransportExceptionInterface $exception) { + throw new AttachmentDownloadException('Transport error: '.$exception->getMessage()); } return $attachment; diff --git a/src/Services/EDA/KiCadHelper.php b/src/Services/EDA/KiCadHelper.php index be4532ce..42cc2518 100644 --- a/src/Services/EDA/KiCadHelper.php +++ b/src/Services/EDA/KiCadHelper.php @@ -202,6 +202,7 @@ class KiCadHelper "exclude_from_bom" => $this->boolToKicadBool($part->getEdaInfo()->getExcludeFromBom() ?? $part->getCategory()?->getEdaInfo()->getExcludeFromBom() ?? false), "exclude_from_board" => $this->boolToKicadBool($part->getEdaInfo()->getExcludeFromBoard() ?? $part->getCategory()?->getEdaInfo()->getExcludeFromBoard() ?? false), "exclude_from_sim" => $this->boolToKicadBool($part->getEdaInfo()->getExcludeFromSim() ?? $part->getCategory()?->getEdaInfo()->getExcludeFromSim() ?? false), + "description" => $part->getDescription(), "fields" => [] ]; diff --git a/src/Services/EDA/KicadListFileManager.php b/src/Services/EDA/KicadListFileManager.php new file mode 100644 index 00000000..3d405026 --- /dev/null +++ b/src/Services/EDA/KicadListFileManager.php @@ -0,0 +1,158 @@ +. + */ + +declare(strict_types=1); + +namespace App\Services\EDA; + +use RuntimeException; +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerInterface; + +/** + * Manages the KiCad footprints and symbols list files, including reading, writing and ensuring their existence. + */ +final class KicadListFileManager implements CacheWarmerInterface +{ + private const FOOTPRINTS_PATH = '/public/kicad/footprints.txt'; + private const SYMBOLS_PATH = '/public/kicad/symbols.txt'; + private const CUSTOM_FOOTPRINTS_PATH = '/public/kicad/footprints_custom.txt'; + private const CUSTOM_SYMBOLS_PATH = '/public/kicad/symbols_custom.txt'; + + private const CUSTOM_TEMPLATE = <<<'EOT' + # Custom KiCad autocomplete entries. One entry per line. + + EOT; + + public function __construct( + #[Autowire('%kernel.project_dir%')] + private readonly string $projectDir, + ) { + } + + public function getFootprintsContent(): string + { + return $this->readFile(self::FOOTPRINTS_PATH); + } + + public function getCustomFootprintsContent(): string + { + //Ensure that the custom file exists, so that the UI can always display it without error. + $this->createCustomFileIfNotExists(self::CUSTOM_FOOTPRINTS_PATH); + return $this->readFile(self::CUSTOM_FOOTPRINTS_PATH); + } + + public function getSymbolsContent(): string + { + return $this->readFile(self::SYMBOLS_PATH); + } + + public function getCustomSymbolsContent(): string + { + //Ensure that the custom file exists, so that the UI can always display it without error. + $this->createCustomFileIfNotExists(self::CUSTOM_SYMBOLS_PATH); + return $this->readFile(self::CUSTOM_SYMBOLS_PATH); + } + + public function saveCustom(string $footprints, string $symbols): void + { + $this->writeFile(self::CUSTOM_FOOTPRINTS_PATH, $this->normalizeContent($footprints)); + $this->writeFile(self::CUSTOM_SYMBOLS_PATH, $this->normalizeContent($symbols)); + } + + private function readFile(string $path): string + { + $fullPath = $this->projectDir . $path; + + if (!is_file($fullPath)) { + return ''; + } + + $content = file_get_contents($fullPath); + if ($content === false) { + throw new RuntimeException(sprintf('Failed to read KiCad list file "%s".', $fullPath)); + } + + return $content; + } + + private function writeFile(string $path, string $content): void + { + $fullPath = $this->projectDir . $path; + $tmpPath = $fullPath . '.tmp'; + + if (file_put_contents($tmpPath, $content, LOCK_EX) === false) { + throw new RuntimeException(sprintf('Failed to write KiCad list file "%s".', $fullPath)); + } + + if (!rename($tmpPath, $fullPath)) { + @unlink($tmpPath); + throw new RuntimeException(sprintf('Failed to replace KiCad list file "%s".', $fullPath)); + } + } + + private function normalizeContent(string $content): string + { + $normalized = str_replace(["\r\n", "\r"], "\n", $content); + + if ($normalized !== '' && !str_ends_with($normalized, "\n")) { + $normalized .= "\n"; + } + + return $normalized; + } + + private function createCustomFileIfNotExists(string $path): void + { + $fullPath = $this->projectDir . $path; + + if (!is_file($fullPath)) { + if (file_put_contents($fullPath, self::CUSTOM_TEMPLATE, LOCK_EX) === false) { + throw new RuntimeException(sprintf('Failed to create custom footprints file "%s".', $fullPath)); + } + } + } + + /** + * Ensures that the custom footprints and symbols files exist, so that the UI can always display them without error. + * @return void + */ + public function createCustomFilesIfNotExist(): void + { + $this->createCustomFileIfNotExists(self::CUSTOM_FOOTPRINTS_PATH); + $this->createCustomFileIfNotExists(self::CUSTOM_SYMBOLS_PATH); + } + + + public function isOptional(): bool + { + return false; + } + + /** + * Ensure that the custom footprints and symbols files exist and generate them on cache warmup, so that the frontend + * can always display them without error, even if the user has not yet visited the settings page. + */ + public function warmUp(string $cacheDir, ?string $buildDir = null): array + { + $this->createCustomFilesIfNotExist(); + return []; + } +} 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/BOMImporter.php b/src/Services/ImportExportSystem/BOMImporter.php index e6518687..7cef3f81 100644 --- a/src/Services/ImportExportSystem/BOMImporter.php +++ b/src/Services/ImportExportSystem/BOMImporter.php @@ -721,26 +721,36 @@ class BOMImporter return $mapped; } + /** + * Try to detect the separator used in the CSV data by analyzing the first line and counting occurrences of common delimiters. + * @param string $data + * @return string + */ + public function detectDelimiter(string $data): string + { + $delimiters = [',', ';', "\t"]; + $lines = explode("\n", $data, 2); + $header_line = $lines[0] ?? ''; + $delimiter_counts = []; + foreach ($delimiters as $delim) { + $delimiter_counts[$delim] = substr_count($header_line, $delim); + } + // Choose the delimiter with the highest count, default to comma if all are zero + $max_count = max($delimiter_counts); + $delimiter = array_search($max_count, $delimiter_counts, true); + if ($max_count === 0 || $delimiter === false) { + $delimiter = ','; + } + return $delimiter; + } + /** * Detect available fields in CSV data for field mapping UI */ public function detectFields(string $data, ?string $delimiter = null): array { if ($delimiter === null) { - // Detect delimiter by counting occurrences in the first row (header) - $delimiters = [',', ';', "\t"]; - $lines = explode("\n", $data, 2); - $header_line = $lines[0] ?? ''; - $delimiter_counts = []; - foreach ($delimiters as $delim) { - $delimiter_counts[$delim] = substr_count($header_line, $delim); - } - // Choose the delimiter with the highest count, default to comma if all are zero - $max_count = max($delimiter_counts); - $delimiter = array_search($max_count, $delimiter_counts, true); - if ($max_count === 0 || $delimiter === false) { - $delimiter = ','; - } + $delimiter = $this->detectDelimiter($data); } // Handle potential BOM (Byte Order Mark) at the beginning $data = preg_replace('/^\xEF\xBB\xBF/', '', $data); diff --git a/src/Services/ImportExportSystem/EntityImporter.php b/src/Services/ImportExportSystem/EntityImporter.php index 7b928d6c..c33d6e6a 100644 --- a/src/Services/ImportExportSystem/EntityImporter.php +++ b/src/Services/ImportExportSystem/EntityImporter.php @@ -219,11 +219,6 @@ class EntityImporter $entities = [$entities]; } - //The serializer has only set the children attributes. We also have to change the parent value (the real value in DB) - if ($entities[0] instanceof AbstractStructuralDBElement) { - $this->correctParentEntites($entities, null); - } - //Set the parent of the imported elements to the given options foreach ($entities as $entity) { if ($entity instanceof AbstractStructuralDBElement) { @@ -297,6 +292,14 @@ class EntityImporter return $resolver; } + private function persistRecursively(AbstractStructuralDBElement $entity): void + { + $this->em->persist($entity); + foreach ($entity->getChildren() as $child) { + $this->persistRecursively($child); + } + } + /** * This method deserializes the given file and writes the entities to the database (and flush the db). * The imported elements will be checked (validated) before written to database. @@ -322,7 +325,11 @@ class EntityImporter //Iterate over each $entity write it to DB (the invalid entities were already filtered out). foreach ($entities as $entity) { - $this->em->persist($entity); + if ($entity instanceof AbstractStructuralDBElement) { + $this->persistRecursively($entity); + } else { + $this->em->persist($entity); + } } //Save changes to database, when no error happened, or we should continue on error. @@ -400,7 +407,7 @@ class EntityImporter * * @param File $file The Excel file to convert * @param string $delimiter The CSV delimiter to use - * + * * @return string The CSV data as string */ protected function convertExcelToCsv(File $file, string $delimiter = ';'): string @@ -421,7 +428,7 @@ class EntityImporter ]); $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn); - + for ($row = 1; $row <= $highestRow; $row++) { $rowData = []; @@ -431,7 +438,7 @@ class EntityImporter try { $cellValue = $worksheet->getCell("{$col}{$row}")->getCalculatedValue(); $rowData[] = $cellValue ?? ''; - + } catch (\Exception $e) { $this->logger->warning('Error reading cell value', [ 'cell' => "{$col}{$row}", @@ -484,21 +491,4 @@ class EntityImporter throw $e; } } - - - /** - * This functions corrects the parent setting based on the children value of the parent. - * - * @param iterable $entities the list of entities that should be fixed - * @param AbstractStructuralDBElement|null $parent the parent, to which the entity should be set - */ - protected function correctParentEntites(iterable $entities, ?AbstractStructuralDBElement $parent = null): void - { - foreach ($entities as $entity) { - /** @var AbstractStructuralDBElement $entity */ - $entity->setParent($parent); - //Do the same for the children of entity - $this->correctParentEntites($entity->getChildren(), $entity); - } - } } 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/InfoProviderSystem/BulkInfoProviderService.php b/src/Services/InfoProviderSystem/BulkInfoProviderService.php index 586fb873..79420134 100644 --- a/src/Services/InfoProviderSystem/BulkInfoProviderService.php +++ b/src/Services/InfoProviderSystem/BulkInfoProviderService.php @@ -46,7 +46,6 @@ final class BulkInfoProviderService } $partResults = []; - $hasAnyResults = false; // Group providers by batch capability $batchProviders = []; @@ -88,7 +87,6 @@ final class BulkInfoProviderService ); if (!empty($allResults)) { - $hasAnyResults = true; $searchResults = $this->formatSearchResults($allResults); } @@ -99,10 +97,6 @@ final class BulkInfoProviderService ); } - if (!$hasAnyResults) { - throw new \RuntimeException('No search results found for any of the selected parts'); - } - $response = new BulkSearchResponseDTO($partResults); // Prefetch details if requested diff --git a/src/Services/InfoProviderSystem/CreateFromUrlHelper.php b/src/Services/InfoProviderSystem/CreateFromUrlHelper.php new file mode 100644 index 00000000..0291142f --- /dev/null +++ b/src/Services/InfoProviderSystem/CreateFromUrlHelper.php @@ -0,0 +1,109 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\InfoProviderSystem; + +use App\Entity\UserSystem\User; +use App\Exceptions\ProviderIDNotSupportedException; +use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; +use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; +use App\Services\InfoProviderSystem\Providers\InfoProviderInterface; +use Symfony\Bundle\SecurityBundle\Security; + +final readonly class CreateFromUrlHelper +{ + public function __construct(private Security $security, + private ProviderRegistry $providerRegistry, + private PartInfoRetriever $infoRetriever, + ) + { + } + + /** + * Checks if at least one provider can create parts from an URL and the current user is allowed to use it. + * This is used to determine if the "From URL" feature should be shown to the user. + * @return bool + */ + public function canCreateFromUrl(): bool + { + if (!$this->security->isGranted('@info_providers.create_parts')) { + return false; + } + + //Check if either the generic web provider or the ai web provider is active + $genericWebProvider = $this->providerRegistry->getProviderByKey('generic_web'); + $aiWebProvider = $this->providerRegistry->getProviderByKey('ai_web'); + + return $genericWebProvider->isActive() || $aiWebProvider->isActive(); + } + + /** + * Delegates the URL to another provider if possible, otherwise return null + * @param string $url + * @return SearchResultDTO|null + */ + public function delegateToOtherProvider(string $url, InfoProviderInterface $callingInfoProvider): ?SearchResultDTO + { + //Extract domain from url: + $host = parse_url($url, PHP_URL_HOST); + if ($host === false || $host === null) { + return null; + } + + $provider = $this->providerRegistry->getProviderHandlingDomain($host); + + if ($provider !== null && $provider->isActive() && $provider->getProviderKey() !== $callingInfoProvider->getProviderKey()) { + try { + $id = $provider->getIDFromURL($url); + if ($id !== null) { + $results = $this->infoRetriever->searchByKeyword($id, [$provider]); + if (count($results) > 0) { + return $results[0]; + } + } + return null; + } catch (ProviderIDNotSupportedException $e) { + //Ignore and continue + return null; + } + } + + return null; + } + + /** + * Delegates the URL to another provider if possible and returns the details, otherwise return null + * @param string $url + * @param InfoProviderInterface $callingInfoProvider + * @return PartDetailDTO|null + */ + public function delegateToOtherProviderDetails(string $url, InfoProviderInterface $callingInfoProvider): ?PartDetailDTO + { + $delegatedResult = $this->delegateToOtherProvider($url, $callingInfoProvider); + if ($delegatedResult !== null) { + return $this->infoRetriever->getDetailsForSearchResult($delegatedResult); + } + + return null; + } +} diff --git a/src/Services/InfoProviderSystem/DTOJsonSchemaConverter.php b/src/Services/InfoProviderSystem/DTOJsonSchemaConverter.php new file mode 100644 index 00000000..a61e7465 --- /dev/null +++ b/src/Services/InfoProviderSystem/DTOJsonSchemaConverter.php @@ -0,0 +1,252 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\InfoProviderSystem; + +use App\Entity\Parts\ManufacturingStatus; +use App\Services\InfoProviderSystem\DTOs\FileDTO; +use App\Services\InfoProviderSystem\DTOs\ParameterDTO; +use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; +use App\Services\InfoProviderSystem\DTOs\PriceDTO; +use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO; + +/** + * This class allows to convert the JSON data returned by an LLM into the DTOs used by the info provider system later. + */ +final class DTOJsonSchemaConverter +{ + /** + * Returns the JSON schema, that defines the expected structure of the JSON data returned by the LLM. + * @return array + */ + public function getJSONSchema(): array + { + return [ + 'name' => 'clock', + 'strict' => true, + 'schema' => [ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string', 'description' => 'Product name'], + 'description' => ['type' => 'string', 'description' => 'A short description of the product, maybe containing the most important things. Onnly One line.'], + 'manufacturer' => ['type' => ['string', 'null'], 'description' => 'Manufacturer name'], + 'mpn' => ['type' => ['string', 'null'], 'description' => 'Manufacturer Part Number'], + 'category' => ['type' => ['string', 'null'], 'description' => 'Product category, e.g. "Passive components -> Resistors"'], + 'manufacturing_status' => ['type' => ['string', 'null'], 'enum' => ['active', 'obsolete', 'nrfnd', 'discontinued', null], 'description' => 'Manufacturing status'], + 'footprint' => ['type' => ['string', 'null'], 'description' => 'Package/footprint type, like "SOT-23", "DIP-8", "QFN-32" etc.'], + 'mass' => ['type' => ['number', 'null'], 'description' => 'Mass of the product in grams'], + 'gtin' => ['type' => ['string', 'null'], 'description' => 'Global Trade Item Number (GTIN) / EAN / UPC code for barcodes'], + 'notes' => ['type' => ['string', 'null'], 'description' => 'Optional long description of the part with more details than description. Can be markdown formatted.'], + 'parameters' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string'], + 'symbol' => ['type' => ['string', 'null'], 'description' => 'An optional quantity symbol for the parameter in latex code, like R_1'], + 'value_typical' => ['type' => ['number', 'null'], 'description' => 'The typical value of the parameter. For example, for a resistor this could be 100 for a 100 Ohm resistor. Also used if only one numeric value is given. If used an unit should be given'], + 'value_min' => ['type' => ['number', 'null'], 'description' => 'If a range is given for the parameter, this is the minimum value. Null if no range is given.'], + 'value_max' => ['type' => ['number', 'null'], 'description' => 'If a range is given for the parameter, this is the maximum value. Null if not a range.'], + 'value_text' => ['type' => ['string', 'null'], 'description' => 'When a value is not numeric it can be put here as text. Only use if it does not fit in value_min, value_typical or value_max. E.g. "Yes", "Red", etc.'], + 'group' => ['type' => ['string', 'null'], 'description' => 'An optional group name for the parameter, e.g. "Electrical parameters", "Mechanical parameters" etc.'], + 'unit' => ['type' => ['string', 'null'], 'description' => 'The unit of the parameter values, e.g. kg, Ohm, V, etc.'], + ], + 'required' => ['name', 'value_typical', 'value_min', 'value_max', 'value_text'] + ], + ], + 'datasheets' => [ + 'description' => 'A list of datasheets, manuals, or other technical documents related to the product. Not images, but actual documents, preferably PDFs.', + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'url' => ['type' => 'string'], + 'description' => ['type' => 'string'], + ], + 'required' => ['url'], + ], + ], + 'images' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'url' => ['type' => 'string'], + 'description' => ['type' => 'string'], + ], + 'required' => ['url'], + ], + ], + 'vendor_infos' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'distributor_name' => ['type' => 'string', 'description' => 'Name of the distributor or vendor. Typically the shop name'], + 'order_number' => ['type' => ['string', 'null'], 'description' => 'The order number or SKU used by the distributor. Optional, but can help to find the product on the distributor website.'], + 'product_url' => ['type' => 'string'], + 'prices_include_vat' => ['type' => ['boolean', 'null'], 'description' => 'Whether the prices include VAT or not. Null if unknown.'], + 'prices' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'minimum_quantity' => ['type' => 'integer', 'description' => 'Minimum quantity for this price tier. 1 when no tiered pricing is available.'], + 'price' => ['type' => 'number', 'description' => 'Price for the given minimum quantity.'], + 'currency' => ['type' => 'string', 'description' => 'Currency ISO code, e.g. USD'], + ], + 'required' => ['minimum_quantity', 'price', 'currency'], + ], + ], + ], + 'required' => ['distributor_name', 'product_url'], + ], + ], + 'manufacturer_product_url' => ['type' => ['string', 'null'], 'description' => 'Manufacturer product page URL'], + ], + 'required' => ['name', 'description'], + ] + ]; + } + + public function jsonToDTO(array $data, string $providerKey, string $providerId, ?string $productUrl = null, string $distributorNameFallback = '???'): PartDetailDTO + { + // Map manufacturing status + $manufacturingStatus = null; + if (!empty($data['manufacturing_status'])) { + $status = strtolower((string) $data['manufacturing_status']); + $manufacturingStatus = match ($status) { + 'active' => ManufacturingStatus::ACTIVE, + 'obsolete', 'discontinued' => ManufacturingStatus::DISCONTINUED, + 'nrfnd', 'not recommended for new designs' => ManufacturingStatus::NRFND, + 'eol' => ManufacturingStatus::EOL, + 'announced' => ManufacturingStatus::ANNOUNCED, + default => null, + }; + } + + // Build parameters + $parameters = null; + if (!empty($data['parameters']) && is_array($data['parameters'])) { + $parameters = []; + foreach ($data['parameters'] as $p) { + if (!empty($p['name'])) { + $parameters[] = new ParameterDTO( + name: $p['name'], + value_text: $p['value_text'] ?? null, + value_typ: isset($p['value_typical']) && is_numeric($p['value_typical']) ? (float) $p['value_typical'] : null, + value_min: isset($p['value_min']) && is_numeric($p['value_min']) ? (float) $p['value_min'] : null, + value_max: isset($p['value_max']) && is_numeric($p['value_max']) ? (float) $p['value_max'] : null, + unit: $p['unit'] ?? null, + symbol: $p['symbol'] ?? null, + group: $p['group'] ?? null, + ); + } + } + } + + // Build datasheets + $datasheets = null; + if (!empty($data['datasheets']) && is_array($data['datasheets'])) { + $datasheets = []; + foreach ($data['datasheets'] as $d) { + if (!empty($d['url'])) { + $datasheets[] = new FileDTO( + url: $d['url'], + name: $d['description'] ?? 'Datasheet' + ); + } + } + } + + // Build images + $images = null; + if (!empty($data['images']) && is_array($data['images'])) { + $images = []; + foreach ($data['images'] as $i) { + if (!empty($i['url'])) { + $images[] = new FileDTO( + url: $i['url'], + name: $i['description'] ?? 'Image' + ); + } + } + } + + // Build vendor infos + $vendorInfos = null; + if (!empty($data['vendor_infos']) && is_array($data['vendor_infos'])) { + $vendorInfos = []; + foreach ($data['vendor_infos'] as $v) { + $prices = []; + if (!empty($v['prices']) && is_array($v['prices'])) { + foreach ($v['prices'] as $p) { + $prices[] = new PriceDTO( + minimum_discount_amount: (int) ($p['minimum_quantity'] ?? 1), + price: (string) ($p['price'] ?? 0), + currency_iso_code: $p['currency'] ?? null, + price_related_quantity: 1, + ); + } + } + + $vendorInfos[] = new PurchaseInfoDTO( + distributor_name: $v['distributor_name'] ?? $distributorNameFallback, + order_number: $v['order_number'] ?? 'Unknown', + prices: $prices, + product_url: $v['product_url'] ?? $productUrl, + prices_include_vat: $v['prices_include_vat'] ?? null, + ); + } + } + + // Get preview image URL + $previewImageUrl = null; + if (!empty($data['images']) && is_array($data['images']) && !empty($data['images'][0]['url'])) { + $previewImageUrl = $data['images'][0]['url']; + } + + return new PartDetailDTO( + provider_key: $providerKey, + provider_id: $providerId, + name: $data['name'] ?? 'Unknown', + description: $data['description'] ?? '', + category: $data['category'] ?? null, + manufacturer: $data['manufacturer'] ?? null, + mpn: $data['mpn'] ?? null, + preview_image_url: $previewImageUrl, + manufacturing_status: $manufacturingStatus, + provider_url: $productUrl, + footprint: $data['footprint'] ?? null, + gtin: $data['gtin'] ?? null, + notes: $data['notes'] ?? null, + datasheets: $datasheets, + images: $images, + parameters: $parameters, + vendor_infos: $vendorInfos, + mass: isset($data['mass']) && is_numeric($data['mass']) ? (float) $data['mass'] : null, + manufacturer_product_url: $data['manufacturer_product_url'] ?? null, + ); + } + +} diff --git a/src/Services/InfoProviderSystem/PartInfoRetriever.php b/src/Services/InfoProviderSystem/PartInfoRetriever.php index db1895e7..6c10f10e 100644 --- a/src/Services/InfoProviderSystem/PartInfoRetriever.php +++ b/src/Services/InfoProviderSystem/PartInfoRetriever.php @@ -53,6 +53,7 @@ final class PartInfoRetriever * Search for a keyword in the given providers. The results can be cached * @param string[]|InfoProviderInterface[] $providers A list of providers to search in, either as provider keys or as provider instances * @param string $keyword The keyword to search for + * @param array $options An associative array of options which can be used to modify the search behavior. The supported options depend on the provider and should be documented in the provider's documentation. * @return SearchResultDTO[] The search results * @throws InfoProviderNotActiveException if any of the given providers is not active * @throws ClientException if any of the providers throws an exception during the search @@ -60,7 +61,7 @@ final class PartInfoRetriever * @throws TransportException if any of the providers throws an exception during the search * @throws OAuthReconnectRequiredException if any of the providers throws an exception during the search that indicates that the OAuth token needs to be refreshed */ - public function searchByKeyword(string $keyword, array $providers): array + public function searchByKeyword(string $keyword, array $providers, array $options = []): array { $results = []; @@ -79,7 +80,7 @@ final class PartInfoRetriever } /** @noinspection SlowArrayOperationsInLoopInspection */ - $results = array_merge($results, $this->searchInProvider($provider, $keyword)); + $results = array_merge($results, $this->searchInProvider($provider, $keyword, $options)); } return $results; @@ -89,15 +90,31 @@ final class PartInfoRetriever * Search for a keyword in the given provider. The result is cached for 7 days. * @return SearchResultDTO[] */ - protected function searchInProvider(InfoProviderInterface $provider, string $keyword): array + protected function searchInProvider(InfoProviderInterface $provider, string $keyword, array $options = []): array { //Generate key and escape reserved characters from the provider id $escaped_keyword = hash('xxh3', $keyword); - return $this->partInfoCache->get("search_{$provider->getProviderKey()}_{$escaped_keyword}", function (ItemInterface $item) use ($provider, $keyword) { + + $no_cache = $options[InfoProviderInterface::OPTION_NO_CACHE] ?? false; + + //Exclude the no_cache option from the options hash, since it should not affect the cache key, as it only determines whether to bypass the cache or not, but does not change the actual search results + $options_without_cache = $options; + unset($options_without_cache[InfoProviderInterface::OPTION_NO_CACHE]); + //Generate a hash for the options, to ensure that different options result in different cache entries + $options_hash = hash('xxh3', json_encode($options_without_cache, JSON_THROW_ON_ERROR)); + + $cache_key = "search_{$provider->getProviderKey()}_{$escaped_keyword}_{$options_hash}"; + + //If no_cache is set, bypass the cache and get fresh results from the provider + if ($no_cache) { + $this->partInfoCache->delete($cache_key); + } + + return $this->partInfoCache->get($cache_key, function (ItemInterface $item) use ($provider, $keyword, $options) { //Set the expiration time $item->expiresAfter(!$this->debugMode ? self::CACHE_RESULT_EXPIRATION : 10); - return $provider->searchByKeyword($keyword); + return $provider->searchByKeyword($keyword, $options); }); } @@ -106,10 +123,11 @@ final class PartInfoRetriever * The result is cached for 4 days. * @param string $provider_key * @param string $part_id + * @param array $options An associative array of options which can be used to modify the search behavior. The supported options depend on the provider and should be documented in the provider's documentation. * @return PartDetailDTO * @throws InfoProviderNotActiveException if the the given providers is not active */ - public function getDetails(string $provider_key, string $part_id): PartDetailDTO + public function getDetails(string $provider_key, string $part_id, array $options = []): PartDetailDTO { $provider = $this->provider_registry->getProviderByKey($provider_key); @@ -118,13 +136,26 @@ final class PartInfoRetriever throw InfoProviderNotActiveException::fromProvider($provider); } + //Exclude the no_cache option from the options hash, since it should not affect the cache key, as it only determines whether to bypass the cache or not, but does not change the actual search results + $options_without_cache = $options; + unset($options_without_cache[InfoProviderInterface::OPTION_NO_CACHE]); + //Generate a hash for the options, to ensure that different options result in different cache entries + $options_hash = hash('xxh3', json_encode($options_without_cache, JSON_THROW_ON_ERROR)); + //Generate key and escape reserved characters from the provider id $escaped_part_id = hash('xxh3', $part_id); - return $this->partInfoCache->get("details_{$provider_key}_{$escaped_part_id}", function (ItemInterface $item) use ($provider, $part_id) { + $cache_key = "details_{$provider_key}_{$escaped_part_id}_{$options_hash}"; + + //Delete the cache entry if no_cache is set, to ensure that the next get call will fetch fresh data from the provider, instead of returning stale data from the cache. + if ($options[InfoProviderInterface::OPTION_NO_CACHE] ?? false) { + $this->partInfoCache->delete($cache_key); + } + + return $this->partInfoCache->get($cache_key, function (ItemInterface $item) use ($provider, $part_id, $options) { //Set the expiration time $item->expiresAfter(!$this->debugMode ? self::CACHE_DETAIL_EXPIRATION : 10); - return $provider->getDetails($part_id); + return $provider->getDetails($part_id, $options); }); } diff --git a/src/Services/InfoProviderSystem/Providers/AIWebProvider.php b/src/Services/InfoProviderSystem/Providers/AIWebProvider.php new file mode 100644 index 00000000..79f07be8 --- /dev/null +++ b/src/Services/InfoProviderSystem/Providers/AIWebProvider.php @@ -0,0 +1,312 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\InfoProviderSystem\Providers; + +use App\Exceptions\ProviderIDNotSupportedException; +use App\Helpers\RandomizeUseragentHttpClient; +use App\Services\AI\AIPlatformRegistry; +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; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\Component\DomCrawler\Crawler; +use Symfony\Component\DomCrawler\UriResolver; +use Symfony\Component\HttpClient\NoPrivateNetworkHttpClient; +use Symfony\Component\Intl\Languages; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +use function Symfony\Component\String\u; + + +final class AIWebProvider implements InfoProviderInterface +{ + use FixAndValidateUrlTrait; + + private const DISTRIBUTOR_NAME = 'Website'; + + private readonly HttpClientInterface $httpClient; + + public function __construct( + HttpClientInterface $httpClient, + private readonly AIExtractorSettings $settings, + private readonly AIPlatformRegistry $AIPlatformRegistry, + private readonly DTOJsonSchemaConverter $jsonSchemaConverter, + private readonly CacheItemPoolInterface $partInfoCache, + private readonly CreateFromUrlHelper $createFromUrlHelper, + ) { + //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( + [ + 'timeout' => 15, + ] + ); + } + + public function getProviderInfo(): array + { + return [ + 'name' => 'AI Web Extractor', + 'description' => 'Extract part info from any URL using LLM', + //'url' => 'https://openrouter.ai', + 'disabled_help' => 'Configure AI settings', + 'settings_class' => AIExtractorSettings::class, + ]; + } + + public function getProviderKey(): string + { + return 'ai_web'; + } + + public function isActive(): bool + { + return $this->settings->platform !== null && $this->settings->model !== null && $this->settings->model !== ''; + } + + public function searchByKeyword(string $keyword, array $options = []): array + { + $url = $this->fixAndValidateURL($keyword); + + if (!($options[self::OPTION_SKIP_DELEGATION] ?? false)) { + //Before loading the page, try to delegate to another provider + $delegatedPart = $this->createFromUrlHelper->delegateToOtherProvider($url, $this); + if ($delegatedPart !== null) { + return [$delegatedPart]; + } + } + + try { + + $new_options = $options; + $new_options[self::OPTION_SKIP_DELEGATION] = true; //Skip delegation for the getDetails call to prevent infinite loops + + return [ + $this->getDetails($keyword, $new_options) + ]; } catch (ProviderIDNotSupportedException $e) { + return []; + } + } + + public function getDetails(string $id, array $options = []): PartDetailDTO + { + $url = $this->fixAndValidateURL($id); + + if (!($options[self::OPTION_SKIP_DELEGATION] ?? false)) { + //Before loading the page, try to delegate to another provider + $delegatedPart = $this->createFromUrlHelper->delegateToOtherProviderDetails($url, $this); + if ($delegatedPart !== null) { + return $delegatedPart; + } + } + + //Check if we have a cached result for this URL, to avoid unnecessary LLM calls, which can be slow and costly. + $cacheKey = 'ai_web_'.hash('xxh3', $url); + + //If ignore cache option is set, skip cache and fetch fresh data + if ($options[self::OPTION_NO_CACHE] ?? false) { + $this->partInfoCache->deleteItem($cacheKey); + } + + //Return cached result if available + $cacheItem = $this->partInfoCache->getItem($cacheKey); + if ($cacheItem->isHit()) { + return $cacheItem->get(); + } + + // Fetch HTML content + $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); + //Truncate markdown to max content length, if needed + $markdown = u($markdown)->truncate($this->settings->maxContentLength, '... [truncated]')->toString(); + + //Extract structured data using traditional methods, to provide additional context to the LLM. This can help improve accuracy, especially for technical specifications that might be in tables or specific formats. + $structuredData = $this->extractStructuredData($html, $url); + + // Call LLM + $llmResponse = $this->callLLM($markdown, $url, $structuredData); + + // Build and return PartDetailDTO + $result = $this->jsonSchemaConverter->jsonToDTO($llmResponse, $this->getProviderKey(), $url, $url, self::DISTRIBUTOR_NAME); + + // Cache the result for future use, to improve performance and reduce costs. + $cacheItem->set($result); + $cacheItem->expiresAfter(3600 * 2); //Cache for 2 hours, as web content can change frequently, but we still want to benefit from caching for repeated accesses. + $this->partInfoCache->save($cacheItem); + + return $result; + } + + /** + * Extracts structured data from the HTML using microformats. + * @param string $html + * @param string $url + * @return string JSON encoded structured data + */ + 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); + + return json_encode($items->toObject(), JSON_THROW_ON_ERROR); + } + + private function htmlToMarkdown(string $html, string $url): string + { + + $crawler = new Crawler($html); + + //Replace relative URLs with absolute URLs, to ensure that the LLM has full context and can access the links if needed. + $baseUrl = $crawler->getBaseHref() ?? $url; + + //Replace all relative links with their absolute counnterparts, to provide more context to the LLM and to ensure that any links included in the markdown are valid and can be accessed if needed. + $crawler->filter('a')->each(function (Crawler $node) use ($baseUrl) { + $href = $node->attr('href'); + if ($href) { + $absoluteUrl = UriResolver::resolve($href, $baseUrl); + //@phpstan-ignore-next-line we know that getNode(0) will always return a DOMElement, because the crawler is initialized with valid HTML and we are filtering for 'a' tags, which are always DOMElements. + $node->getNode(0)->setAttribute('href', $absoluteUrl); + } + }); + + $crawler->filter('img')->each(function (Crawler $node) use ($baseUrl) { + $src = $node->attr('src'); + if ($src) { + $absoluteUrl = UriResolver::resolve($src, $baseUrl); + //@phpstan-ignore-next-line we know that getNode(0) will always return a DOMElement, because the crawler is initialized with valid HTML and we are filtering for 'a' tags, which are always DOMElements. + $node->getNode(0)->setAttribute('src', $absoluteUrl); + } + }); + + //Extract only the main content of the page to avoid overwhelming the LLM with irrelevant information. + $mainContent = $crawler->filter('main, article, #content'); + + // If we found a specific content area, get its HTML; otherwise, use the whole body. + //Concat the html of all matched nodes, to provide more context to the LLM, especially for pages that use multiple sections for product info. + if ($mainContent->count() > 0) { + $htmlToConvert = ''; + foreach ($mainContent as $node) { + $htmlToConvert .= $node->ownerDocument->saveHTML($node); + $htmlToConvert .= "\n\n"; // Add some spacing between sections + } + } else { + //Use the whole body content, as it might contain relevant information, especially for simpler pages that don't have a clear main/content section. + $htmlToConvert = $crawler->outerHtml(); + } + + + //Concert to markdown + $converter = new HtmlConverter([ + 'strip_tags' => true, // Removes tags that aren't Markdown-compatible (like
) + 'hard_break' => true, // Preserves line breaks + 'remove_nodes' => 'nav footer script style' // Extra safety layer + ]); + + return $converter->convert($htmlToConvert); + } + + public function getCapabilities(): array + { + return [ + ProviderCapabilities::BASIC, + ProviderCapabilities::PICTURE, + ProviderCapabilities::DATASHEET, + ProviderCapabilities::PRICE, + ProviderCapabilities::PARAMETERS, + ]; + } + + private function callLLM(string $htmlContent, string $url, ?string $structuredData = null): array + { + $input = new MessageBag( + Message::forSystem($this->buildSystemPrompt()), + Message::ofUser("Extract part information from this webpage content:\n\nURL: $url\n\n$htmlContent") + ); + + if ($structuredData) { + $input->add(Message::ofUser("Following data was extracted using traditional methods, but might be incomplete or inaccurate. + Enrich it with the actual website data:\n\n".$structuredData)); + } + + try { + $aiPlatform = $this->AIPlatformRegistry->getPlatform($this->settings->platform ?? throw new \RuntimeException('No AI platform selected') ); + + //'openai/gpt-5-mini' + $result = $aiPlatform->invoke($this->settings->model ?? throw new \RuntimeException('No model selected'), $input, [ + 'response_format' => [ + 'type' => 'json_schema', + 'json_schema' => $this->jsonSchemaConverter->getJSONSchema(), + ] + ]); + } catch (\Throwable $e) { + throw new \RuntimeException('LLM invocation failed: '.$e->getMessage(), previous: $e); + } + + return $result->getResult()->getContent(); + } + + private function buildSystemPrompt(): string + { + $tmp = <<<'PROMPT' +You are an expert at extracting electronic component information from web pages. Extract structured data in JSON format, from markdown extracted from a product page. +Focus on the main content of the page, such as product descriptions, specifications, and tables. Ignore navigation menus, footers, and sidebars. + +Rules: +- manufacturing_status: Use "active", "obsolete", "nrfnd" (not recommended for new designs), "discontinued", or null +- parameters: Extract technical specs like voltage, current, temperature, etc. and put them into the fields according to the JSON schema. Include units if available. +- prices: Extract pricing tiers with minimum_quantity, price, and currency code +- URLs must be absolute (include https://...) +- If information is not found, use null +- Try to avoid duplicating parameters, if the same parameter is mentioned multiple times, or if it is already used in another field. +- Include only the 1 to 3 most relevant images, such as the main product image or important diagrams. Ignore decorative images, logos, or icons. +- Extract GTIN / EAN if available, as it can be useful for matching parts across different sources, even if the part number is different. +- Include detailed product description into notes field, as it can contain important information that doesn't fit into other fields, such as features, applications, or unique selling points. + +PROMPT; + + if ($this->settings->outputLanguage === null) { + $tmp .= "\n\nProvide the response in the same language of the webpage."; + } else { + $tmp .= "\n\nThe response must be in ". Languages::getName($this->settings->outputLanguage, 'en') ." language. Translate texts if needed."; + } + + if ($this->settings->additionalInstructions) { + $tmp .= "\n\nAdditional instructions:\n" . $this->settings->additionalInstructions; + } + + return $tmp; + } + +} diff --git a/src/Services/InfoProviderSystem/Providers/BatchInfoProviderInterface.php b/src/Services/InfoProviderSystem/Providers/BatchInfoProviderInterface.php index 549f117a..cd918439 100644 --- a/src/Services/InfoProviderSystem/Providers/BatchInfoProviderInterface.php +++ b/src/Services/InfoProviderSystem/Providers/BatchInfoProviderInterface.php @@ -34,7 +34,8 @@ interface BatchInfoProviderInterface extends InfoProviderInterface * Search for multiple keywords in a single batch operation and return the results, ordered by the keywords. * This allows for a more efficient search compared to running multiple single searches. * @param string[] $keywords + * @param array $options An associative array of options which can be used to modify the search behavior. The supported options depend on the provider and should be documented in the provider's documentation. * @return array An associative array where the key is the keyword and the value is the search results for that keyword */ - public function searchByKeywordsBatch(array $keywords): array; + public function searchByKeywordsBatch(array $keywords, array $options = []): array; } diff --git a/src/Services/InfoProviderSystem/Providers/BuerklinProvider.php b/src/Services/InfoProviderSystem/Providers/BuerklinProvider.php index c2291107..ca6e26e1 100644 --- a/src/Services/InfoProviderSystem/Providers/BuerklinProvider.php +++ b/src/Services/InfoProviderSystem/Providers/BuerklinProvider.php @@ -120,7 +120,7 @@ class BuerklinProvider implements BatchInfoProviderInterface, URLHandlerInfoProv ]; } - private function getProduct(string $code): array + private function getProduct(string $code, bool $use_cache = true): array { $code = strtoupper(trim($code)); if ($code === '') { @@ -132,6 +132,11 @@ class BuerklinProvider implements BatchInfoProviderInterface, URLHandlerInfoProv md5($code . '|' . $this->settings->language . '|' . $this->settings->currency) ); + if (!$use_cache) { + $this->partInfoCache->deleteItem($cacheKey); + unset($this->productCache[$cacheKey]); + } + if (isset($this->productCache[$cacheKey])) { return $this->productCache[$cacheKey]; } @@ -461,9 +466,11 @@ class BuerklinProvider implements BatchInfoProviderInterface, URLHandlerInfoProv } /** + * @param string $keyword + * @param array $options * @return PartDetailDTO[] */ - public function searchByKeyword(string $keyword): array + public function searchByKeyword(string $keyword, array $options = []): array { $keyword = strtoupper(trim($keyword)); if ($keyword === '') { @@ -486,17 +493,18 @@ class BuerklinProvider implements BatchInfoProviderInterface, URLHandlerInfoProv // Fallback: try direct lookup by code try { - $product = $this->getProduct($keyword); + $product = $this->getProduct($keyword, use_cache: !($options[self::OPTION_NO_CACHE] ?? false)); return [$this->getPartDetail($product)]; } catch (\Throwable $e) { return []; } } - public function getDetails(string $id): PartDetailDTO + public function getDetails(string $id, array $options = []): PartDetailDTO { // Detail endpoint is /products/{code}/ - $response = $this->getProduct($id); + //By default use cache for details, but allow bypassing cache with option (e.g. for refresh) + $response = $this->getProduct($id, use_cache: !($options[self::OPTION_NO_CACHE] ?? false)); return $this->getPartDetail($response); } @@ -588,10 +596,11 @@ class BuerklinProvider implements BatchInfoProviderInterface, URLHandlerInfoProv } /** - * @param string[] $keywords + * @param array $keywords + * @param array $options * @return array */ - public function searchByKeywordsBatch(array $keywords): array + public function searchByKeywordsBatch(array $keywords, array $options = []): array { /** @var array $results */ $results = []; @@ -643,27 +652,27 @@ class BuerklinProvider implements BatchInfoProviderInterface, URLHandlerInfoProv public function getIDFromURL(string $url): ?string { - //Inputs: - //https://www.buerklin.com/de/p/bkl-electronic/niedervoltsteckverbinder/072341-l/40F1332/ + //Inputs: + //https://www.buerklin.com/de/p/bkl-electronic/niedervoltsteckverbinder/072341-l/40F1332/ //https://www.buerklin.com/de/p/40F1332/ //https://www.buerklin.com/en/p/bkl-electronic/dc-connectors/072341-l/40F1332/ //https://www.buerklin.com/en/p/40F1332/ //The ID is the last part after the manufacturer/category/mpn segment and before the final slash //https://www.buerklin.com/de/p/bkl-electronic/niedervoltsteckverbinder/072341-l/40F1332/#download should also work - + $path = parse_url($url, PHP_URL_PATH); - + if (!$path) { return null; } - + // Ensure it's actually a product URL if (strpos($path, '/p/') === false) { return null; } - + $id = basename(rtrim($path, '/')); - + return $id !== '' && $id !== 'p' ? $id : null; } diff --git a/src/Services/InfoProviderSystem/Providers/CanopyProvider.php b/src/Services/InfoProviderSystem/Providers/CanopyProvider.php index 18864a49..aee30d6b 100644 --- a/src/Services/InfoProviderSystem/Providers/CanopyProvider.php +++ b/src/Services/InfoProviderSystem/Providers/CanopyProvider.php @@ -111,7 +111,7 @@ class CanopyProvider implements InfoProviderInterface return null; } - public function searchByKeyword(string $keyword): array + public function searchByKeyword(string $keyword, array $options = []): array { $response = $this->httpClient->request('GET', self::SEARCH_API_URL, [ 'query' => [ @@ -177,15 +177,17 @@ class CanopyProvider implements InfoProviderInterface return new PurchaseInfoDTO(self::DISTRIBUTOR_NAME, order_number: $asin, prices: $priceDtos, product_url: $this->productPageFromASIN($asin)); } - public function getDetails(string $id): PartDetailDTO + public function getDetails(string $id, array $options = []): PartDetailDTO { //Check that the id is a valid ASIN (10 characters, letters and numbers) if (!preg_match('/^[A-Z0-9]{10}$/', $id)) { throw new \InvalidArgumentException("The id must be a valid ASIN (10 characters, letters and numbers)"); } + $do_not_cache = ($options[self::OPTION_NO_CACHE] ?? false) || $this->settings->alwaysGetDetails; + //Use cached details if available and the settings allow it, to avoid unnecessary API requests, since the search results already contain most of the details - if(!$this->settings->alwaysGetDetails && ($cached = $this->getFromCache($id)) !== null) { + if(!$do_not_cache && ($cached = $this->getFromCache($id)) !== null) { return $cached; } diff --git a/src/Services/InfoProviderSystem/Providers/ConradProvider.php b/src/Services/InfoProviderSystem/Providers/ConradProvider.php index 39de1e23..2e6708be 100644 --- a/src/Services/InfoProviderSystem/Providers/ConradProvider.php +++ b/src/Services/InfoProviderSystem/Providers/ConradProvider.php @@ -88,7 +88,7 @@ readonly class ConradProvider implements InfoProviderInterface, URLHandlerInfoPr return null; } - public function searchByKeyword(string $keyword): array + public function searchByKeyword(string $keyword, array $options = []): array { $url = $this->settings->shopID->getAPIRoot() . self::SEARCH_ENDPOINT . '/' . $this->settings->shopID->getDomainEnd() . '/' . $this->settings->shopID->getLanguage() @@ -279,7 +279,7 @@ readonly class ConradProvider implements InfoProviderInterface, URLHandlerInfoPr ); } - public function getDetails(string $id): PartDetailDTO + public function getDetails(string $id, array $options = []): PartDetailDTO { $productInfoURL = $this->settings->shopID->getAPIRoot() . '/product/1/service/' . $this->settings->shopID->getShopID() . '/product/' . $id; diff --git a/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php b/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php index d7eb6e4f..e7a62aa4 100644 --- a/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php +++ b/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php @@ -106,7 +106,7 @@ class DigikeyProvider implements InfoProviderInterface return $this->settings->clientId !== null && $this->settings->clientId !== '' && $this->authTokenManager->hasToken(self::OAUTH_APP_NAME); } - public function searchByKeyword(string $keyword): array + public function searchByKeyword(string $keyword, array $options = []): array { $request = [ 'Keywords' => $keyword, @@ -159,7 +159,7 @@ class DigikeyProvider implements InfoProviderInterface return $result; } - public function getDetails(string $id): PartDetailDTO + public function getDetails(string $id, array $options = []): PartDetailDTO { try { $response = $this->digikeyClient->request('GET', '/products/v4/search/' . urlencode($id) . '/productdetails', [ diff --git a/src/Services/InfoProviderSystem/Providers/Element14Provider.php b/src/Services/InfoProviderSystem/Providers/Element14Provider.php index 9ae45728..1d9e092c 100644 --- a/src/Services/InfoProviderSystem/Providers/Element14Provider.php +++ b/src/Services/InfoProviderSystem/Providers/Element14Provider.php @@ -282,12 +282,12 @@ class Element14Provider implements InfoProviderInterface, URLHandlerInfoProvider }; } - public function searchByKeyword(string $keyword): array + public function searchByKeyword(string $keyword, array $options = []): array { return $this->queryByTerm('any:' . $keyword); } - public function getDetails(string $id): PartDetailDTO + public function getDetails(string $id, array $options = []): PartDetailDTO { $tmp = $this->queryByTerm('id:' . $id); if (count($tmp) === 0) { diff --git a/src/Services/InfoProviderSystem/Providers/EmptyProvider.php b/src/Services/InfoProviderSystem/Providers/EmptyProvider.php index e0de9772..915a118c 100644 --- a/src/Services/InfoProviderSystem/Providers/EmptyProvider.php +++ b/src/Services/InfoProviderSystem/Providers/EmptyProvider.php @@ -54,7 +54,7 @@ class EmptyProvider implements InfoProviderInterface return true; } - public function searchByKeyword(string $keyword): array + public function searchByKeyword(string $keyword, array $options = []): array { return [ @@ -69,7 +69,7 @@ class EmptyProvider implements InfoProviderInterface ]; } - public function getDetails(string $id): PartDetailDTO + public function getDetails(string $id, array $options = []): PartDetailDTO { throw new \RuntimeException('No part details available'); } diff --git a/src/Services/InfoProviderSystem/Providers/FixAndValidateUrlTrait.php b/src/Services/InfoProviderSystem/Providers/FixAndValidateUrlTrait.php new file mode 100644 index 00000000..c9395a46 --- /dev/null +++ b/src/Services/InfoProviderSystem/Providers/FixAndValidateUrlTrait.php @@ -0,0 +1,58 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\InfoProviderSystem\Providers; + +use App\Exceptions\ProviderIDNotSupportedException; + +trait FixAndValidateUrlTrait +{ + private function fixAndValidateURL(string $url): string + { + $originalUrl = $url; + + //Add scheme if missing + if (!preg_match('/^https?:\/\//', $url)) { + //Remove any leading slashes + $url = ltrim($url, '/'); + + //If the URL starts with https:/ or http:/, add the missing slash + //Traefik removes the double slash as secruity measure, so we want to be forgiving and add it back if needed + //See https://github.com/Part-DB/Part-DB-server/issues/1296 + if (preg_match('/^https?:\/[^\/]/', $url)) { + $url = preg_replace('/^(https?:)\/([^\/])/', '$1//$2', $url); + } else { + $url = 'https://'.$url; + } + } + + //If this is not a valid URL with host, domain and path, throw an exception + if (filter_var($url, FILTER_VALIDATE_URL) === false || + parse_url($url, PHP_URL_HOST) === null || + parse_url($url, PHP_URL_PATH) === null) { + throw new ProviderIDNotSupportedException("The given ID is not a valid URL: ".$originalUrl); + } + + return $url; + } +} diff --git a/src/Services/InfoProviderSystem/Providers/GenericWebProvider.php b/src/Services/InfoProviderSystem/Providers/GenericWebProvider.php index bd6d30e6..06a9d4c1 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\CreateFromUrlHelper; use App\Services\InfoProviderSystem\DTOs\ParameterDTO; use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; use App\Services\InfoProviderSystem\DTOs\PriceDTO; @@ -42,20 +43,24 @@ use Brick\Schema\Interfaces\Thing; use Brick\Schema\SchemaReader; use Brick\Schema\SchemaTypeList; use Symfony\Component\DomCrawler\Crawler; +use Symfony\Component\HttpClient\NoPrivateNetworkHttpClient; use Symfony\Contracts\HttpClient\HttpClientInterface; class GenericWebProvider implements InfoProviderInterface { + use FixAndValidateUrlTrait; + public const DISTRIBUTOR_NAME = 'Website'; private readonly HttpClientInterface $httpClient; public function __construct(HttpClientInterface $httpClient, private readonly GenericWebProviderSettings $settings, - private readonly ProviderRegistry $providerRegistry, private readonly PartInfoRetriever $infoRetriever, + private readonly CreateFromUrlHelper $createFromUrlHelper, ) { - $this->httpClient = (new RandomizeUseragentHttpClient($httpClient))->withOptions( + //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( [ 'timeout' => 15, ] @@ -83,19 +88,23 @@ class GenericWebProvider implements InfoProviderInterface return $this->settings->enabled; } - public function searchByKeyword(string $keyword): array + public function searchByKeyword(string $keyword, array $options = []): array { $url = $this->fixAndValidateURL($keyword); - //Before loading the page, try to delegate to another provider - $delegatedPart = $this->delegateToOtherProvider($url); - if ($delegatedPart !== null) { - return [$delegatedPart]; + if (!($options[self::OPTION_SKIP_DELEGATION] ?? false)) { + //Before loading the page, try to delegate to another provider + $delegatedPart = $this->createFromUrlHelper->delegateToOtherProvider($url, $this); + if ($delegatedPart !== null) { + return [$delegatedPart]; + } } try { + $new_options = $options; + $new_options[self::OPTION_SKIP_DELEGATION] = true; //Skip delegation for the getDetails call to prevent infinite loops return [ - $this->getDetails($keyword, false) //We already tried delegation + $this->getDetails($keyword, $new_options) ]; } catch (ProviderIDNotSupportedException $e) { return []; } @@ -272,71 +281,16 @@ class GenericWebProvider implements InfoProviderInterface return null; } - /** - * Delegates the URL to another provider if possible, otherwise return null - * @param string $url - * @return SearchResultDTO|null - */ - private function delegateToOtherProvider(string $url): ?SearchResultDTO - { - //Extract domain from url: - $host = parse_url($url, PHP_URL_HOST); - if ($host === false || $host === null) { - return null; - } - $provider = $this->providerRegistry->getProviderHandlingDomain($host); - - if ($provider !== null && $provider->isActive() && $provider->getProviderKey() !== $this->getProviderKey()) { - try { - $id = $provider->getIDFromURL($url); - if ($id !== null) { - $results = $this->infoRetriever->searchByKeyword($id, [$provider]); - if (count($results) > 0) { - return $results[0]; - } - } - return null; - } catch (ProviderIDNotSupportedException $e) { - //Ignore and continue - return null; - } - } - - return null; - } - - private function fixAndValidateURL(string $url): string - { - $originalUrl = $url; - - //Add scheme if missing - if (!preg_match('/^https?:\/\//', $url)) { - //Remove any leading slashes - $url = ltrim($url, '/'); - - $url = 'https://'.$url; - } - - //If this is not a valid URL with host, domain and path, throw an exception - if (filter_var($url, FILTER_VALIDATE_URL) === false || - parse_url($url, PHP_URL_HOST) === null || - parse_url($url, PHP_URL_PATH) === null) { - throw new ProviderIDNotSupportedException("The given ID is not a valid URL: ".$originalUrl); - } - - return $url; - } - - public function getDetails(string $id, bool $check_for_delegation = true): PartDetailDTO + public function getDetails(string $id, array $options = []): PartDetailDTO { $url = $this->fixAndValidateURL($id); - if ($check_for_delegation) { + if (!($options[self::OPTION_SKIP_DELEGATION] ?? false)) { //Before loading the page, try to delegate to another provider - $delegatedPart = $this->delegateToOtherProvider($url); + $delegatedPart = $this->createFromUrlHelper->delegateToOtherProviderDetails($url, $this); if ($delegatedPart !== null) { - return $this->infoRetriever->getDetailsForSearchResult($delegatedPart); + return $delegatedPart; } } diff --git a/src/Services/InfoProviderSystem/Providers/InfoProviderInterface.php b/src/Services/InfoProviderSystem/Providers/InfoProviderInterface.php index 1f787559..a6e073a5 100644 --- a/src/Services/InfoProviderSystem/Providers/InfoProviderInterface.php +++ b/src/Services/InfoProviderSystem/Providers/InfoProviderInterface.php @@ -28,6 +28,8 @@ use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; 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. /** * Get information about this provider @@ -61,16 +63,18 @@ interface InfoProviderInterface /** * Searches for a keyword and returns a list of search results * @param string $keyword The keyword to search for + * @param array $options An associative array of options for the search, which can be used to pass additional parameters to the provider (e.g. filters, pagination, etc.). The content of this array is provider specific and not defined by the interface * @return SearchResultDTO[] A list of search results */ - public function searchByKeyword(string $keyword): array; + public function searchByKeyword(string $keyword, array $options = []): array; /** * Returns detailed information about the part with the given id * @param string $id + * @param array $options An associative array of options for the search, which can be used to pass additional parameters to the provider (e.g. filters, pagination, etc.). The content of this array is provider specific and not defined by the interface * @return PartDetailDTO */ - public function getDetails(string $id): PartDetailDTO; + public function getDetails(string $id, array $options = []): PartDetailDTO; /** * A list of capabilities this provider supports (which kind of data it can provide). diff --git a/src/Services/InfoProviderSystem/Providers/LCSCProvider.php b/src/Services/InfoProviderSystem/Providers/LCSCProvider.php index 1b807eff..5f251b43 100755 --- a/src/Services/InfoProviderSystem/Providers/LCSCProvider.php +++ b/src/Services/InfoProviderSystem/Providers/LCSCProvider.php @@ -349,17 +349,18 @@ class LCSCProvider implements BatchInfoProviderInterface, URLHandlerInfoProvider return $result; } - public function searchByKeyword(string $keyword): array + public function searchByKeyword(string $keyword, array $options = []): array { return $this->queryByTerm($keyword, true); // Use lightweight mode for search } /** * Batch search multiple keywords asynchronously (like JavaScript Promise.all) - * @param array $keywords Array of keywords to search + * @param array $keywords + * @param array $options * @return array Results indexed by keyword */ - public function searchByKeywordsBatch(array $keywords): array + public function searchByKeywordsBatch(array $keywords, array $options = []): array { if (empty($keywords)) { return []; @@ -396,6 +397,7 @@ class LCSCProvider implements BatchInfoProviderInterface, URLHandlerInfoProvider // Now collect all results (like .then() in JavaScript) foreach ($responses as $keyword => $response) { try { + $keyword = (string) $keyword; $arr = $response->toArray(); // This waits for the response $results[$keyword] = $this->processSearchResponse($arr, $keyword); } catch (\Exception $e) { @@ -428,7 +430,7 @@ class LCSCProvider implements BatchInfoProviderInterface, URLHandlerInfoProvider return $result; } - public function getDetails(string $id): PartDetailDTO + public function getDetails(string $id, array $options = []): PartDetailDTO { $tmp = $this->queryByTerm($id, false); if (count($tmp) === 0) { diff --git a/src/Services/InfoProviderSystem/Providers/MouserProvider.php b/src/Services/InfoProviderSystem/Providers/MouserProvider.php index 3171c994..49ca2d50 100644 --- a/src/Services/InfoProviderSystem/Providers/MouserProvider.php +++ b/src/Services/InfoProviderSystem/Providers/MouserProvider.php @@ -76,7 +76,7 @@ class MouserProvider implements InfoProviderInterface return $this->settings->apiKey !== '' && $this->settings->apiKey !== null; } - public function searchByKeyword(string $keyword): array + public function searchByKeyword(string $keyword, array $options = []): array { /* SearchByKeywordRequest description: @@ -144,7 +144,7 @@ class MouserProvider implements InfoProviderInterface return $this->responseToDTOArray($response); } - public function getDetails(string $id): PartDetailDTO + public function getDetails(string $id, array $options = []): PartDetailDTO { /* SearchByPartRequest description: diff --git a/src/Services/InfoProviderSystem/Providers/OEMSecretsProvider.php b/src/Services/InfoProviderSystem/Providers/OEMSecretsProvider.php index f7048a87..9764517b 100644 --- a/src/Services/InfoProviderSystem/Providers/OEMSecretsProvider.php +++ b/src/Services/InfoProviderSystem/Providers/OEMSecretsProvider.php @@ -278,12 +278,13 @@ class OEMSecretsProvider implements InfoProviderInterface * and debugging with local JSON files. The results are processed, cached, and then sorted based * on the keyword and specified criteria. * - * @param string $keyword The part number to search for + * @param string $keyword + * @param array $options * @return array An array of processed product details, sorted by relevance and additional criteria. * * @throws \Exception If the JSON file used for debugging is not found or contains errors. */ - public function searchByKeyword(string $keyword): array + public function searchByKeyword(string $keyword, array $options = []): array { /* oemsecrets Part Search API 3.0.1 @@ -414,14 +415,20 @@ class OEMSecretsProvider implements InfoProviderInterface * found in the cache, they are returned. If not, an exception is thrown indicating that * the details could not be found. * - * @param string $id The unique identifier of the provider or part. + * @param string $id + * @param array $options * @return PartDetailDTO The detailed information about the part. * * @throws \Exception If no details are found for the given provider ID. */ - public function getDetails(string $id): PartDetailDTO + public function getDetails(string $id, array $options = []): PartDetailDTO { $cacheKey = $this->getCacheKey($id); + + if ($options[self::OPTION_NO_CACHE] ?? false) { + $this->partInfoCache->deleteItem($cacheKey); + } + $cacheItem = $this->partInfoCache->getItem($cacheKey); if ($cacheItem->isHit()) { diff --git a/src/Services/InfoProviderSystem/Providers/OctopartProvider.php b/src/Services/InfoProviderSystem/Providers/OctopartProvider.php index 1142f4ef..de404e18 100644 --- a/src/Services/InfoProviderSystem/Providers/OctopartProvider.php +++ b/src/Services/InfoProviderSystem/Providers/OctopartProvider.php @@ -326,7 +326,7 @@ class OctopartProvider implements InfoProviderInterface ); } - public function searchByKeyword(string $keyword): array + public function searchByKeyword(string $keyword, array $options = []): array { $graphQL = sprintf(<<<'GRAPHQL' query partSearch($keyword: String, $limit: Int, $currency: String!, $country: String!, $authorizedOnly: Boolean!) { @@ -367,11 +367,13 @@ class OctopartProvider implements InfoProviderInterface return $tmp; } - public function getDetails(string $id): PartDetailDTO + public function getDetails(string $id, array $options = []): PartDetailDTO { + $no_cache = $options[self::OPTION_NO_CACHE] ?? false; + //Check if we have the part cached $cached = $this->getFromCache($id); - if ($cached !== null) { + if (!$no_cache && $cached !== null) { return $cached; } diff --git a/src/Services/InfoProviderSystem/Providers/PollinProvider.php b/src/Services/InfoProviderSystem/Providers/PollinProvider.php index 6ac969d3..7acecc3a 100644 --- a/src/Services/InfoProviderSystem/Providers/PollinProvider.php +++ b/src/Services/InfoProviderSystem/Providers/PollinProvider.php @@ -66,7 +66,7 @@ class PollinProvider implements InfoProviderInterface, URLHandlerInfoProviderInt return $this->settings->enabled; } - public function searchByKeyword(string $keyword): array + public function searchByKeyword(string $keyword, array $options = []): array { $response = $this->client->request('GET', 'https://www.pollin.de/search', [ 'query' => [ @@ -110,7 +110,7 @@ class PollinProvider implements InfoProviderInterface, URLHandlerInfoProviderInt }; } - public function getDetails(string $id): PartDetailDTO + public function getDetails(string $id, array $options = []): PartDetailDTO { //Ensure that $id is numeric if (!is_numeric($id)) { diff --git a/src/Services/InfoProviderSystem/Providers/ProviderCapabilities.php b/src/Services/InfoProviderSystem/Providers/ProviderCapabilities.php index 21fba53b..3a7d03e9 100644 --- a/src/Services/InfoProviderSystem/Providers/ProviderCapabilities.php +++ b/src/Services/InfoProviderSystem/Providers/ProviderCapabilities.php @@ -46,6 +46,9 @@ enum ProviderCapabilities /** Provider can provide GTIN for a part */ case GTIN; + /** Provider can provide parameters/specifications for a part */ + case PARAMETERS; + /** * Get the order index for displaying capabilities in a stable order. * @return int @@ -59,6 +62,7 @@ enum ProviderCapabilities self::PRICE => 4, self::FOOTPRINT => 5, self::GTIN => 6, + self::PARAMETERS => 7, }; } @@ -71,6 +75,7 @@ enum ProviderCapabilities self::DATASHEET => 'datasheet', self::PRICE => 'price', self::GTIN => 'gtin', + self::PARAMETERS => 'parameters', }; } @@ -83,6 +88,7 @@ enum ProviderCapabilities self::DATASHEET => 'fa-file-alt', self::PRICE => 'fa-money-bill-wave', self::GTIN => 'fa-barcode', + self::PARAMETERS => 'fa-list-ul', }; } } diff --git a/src/Services/InfoProviderSystem/Providers/ReicheltProvider.php b/src/Services/InfoProviderSystem/Providers/ReicheltProvider.php index 81f0a449..9dfb099f 100644 --- a/src/Services/InfoProviderSystem/Providers/ReicheltProvider.php +++ b/src/Services/InfoProviderSystem/Providers/ReicheltProvider.php @@ -69,7 +69,7 @@ class ReicheltProvider implements InfoProviderInterface return $this->settings->enabled; } - public function searchByKeyword(string $keyword): array + public function searchByKeyword(string $keyword, array $options = []): array { $response = $this->client->request('GET', sprintf($this->getBaseURL() . '/shop/search/%s', $keyword)); $html = $response->getContent(); @@ -108,7 +108,7 @@ class ReicheltProvider implements InfoProviderInterface return $results; } - public function getDetails(string $id): PartDetailDTO + public function getDetails(string $id, array $options = []): PartDetailDTO { //Check that the ID is a number if (!is_numeric($id)) { diff --git a/src/Services/InfoProviderSystem/Providers/TMEProvider.php b/src/Services/InfoProviderSystem/Providers/TMEProvider.php index 938bc7b3..24ba0ea7 100644 --- a/src/Services/InfoProviderSystem/Providers/TMEProvider.php +++ b/src/Services/InfoProviderSystem/Providers/TMEProvider.php @@ -69,7 +69,7 @@ class TMEProvider implements InfoProviderInterface, URLHandlerInfoProviderInterf return $this->tmeClient->isUsable(); } - public function searchByKeyword(string $keyword): array + public function searchByKeyword(string $keyword, array $options = []): array { $response = $this->tmeClient->makeRequest('Products/Search', [ 'Country' => $this->settings->country, @@ -99,7 +99,7 @@ class TMEProvider implements InfoProviderInterface, URLHandlerInfoProviderInterf return $result; } - public function getDetails(string $id): PartDetailDTO + public function getDetails(string $id, array $options = []): PartDetailDTO { $response = $this->tmeClient->makeRequest('Products/GetProducts', [ 'Country' => $this->settings->country, @@ -280,9 +280,13 @@ class TMEProvider implements InfoProviderInterface, URLHandlerInfoProviderInterf { //If a URL starts with // we assume that it is a relative URL and we add the protocol if (str_starts_with($url, '//')) { - return 'https:' . $url; + $url = 'https:' . $url; } + //Encode bare % signs that are not already part of a valid percent-encoded sequence + //Fixes part numbers with % in them e.g. SMD0603-5K1-1% + $url = preg_replace('/%(?![0-9A-Fa-f]{2})/', '%25', $url); + return $url; } diff --git a/src/Services/InfoProviderSystem/Providers/TestProvider.php b/src/Services/InfoProviderSystem/Providers/TestProvider.php index 8b78c95a..42927abd 100644 --- a/src/Services/InfoProviderSystem/Providers/TestProvider.php +++ b/src/Services/InfoProviderSystem/Providers/TestProvider.php @@ -55,7 +55,7 @@ class TestProvider implements InfoProviderInterface return true; } - public function searchByKeyword(string $keyword): array + public function searchByKeyword(string $keyword, array $options = []): array { return [ new SearchResultDTO(provider_key: $this->getProviderKey(), provider_id: 'element1', name: 'Element 1', description: 'fd'), @@ -72,7 +72,7 @@ class TestProvider implements InfoProviderInterface ]; } - public function getDetails(string $id): PartDetailDTO + public function getDetails(string $id, array $options = []): PartDetailDTO { return new PartDetailDTO( provider_key: $this->getProviderKey(), @@ -92,4 +92,4 @@ class TestProvider implements InfoProviderInterface ] ); } -} \ No newline at end of file +} diff --git a/src/Services/LabelSystem/BarcodeScanner/BarcodeScanHelper.php b/src/Services/LabelSystem/BarcodeScanner/BarcodeScanHelper.php index 0bee33a1..7f65262a 100644 --- a/src/Services/LabelSystem/BarcodeScanner/BarcodeScanHelper.php +++ b/src/Services/LabelSystem/BarcodeScanner/BarcodeScanHelper.php @@ -105,6 +105,10 @@ final class BarcodeScanHelper return new AmazonBarcodeScanResult($input); } + if ($type === BarcodeSourceType::TME) { + return TMEBarcodeScanResult::parse($input); + } + //Null means auto and we try the different formats $result = $this->parseInternalBarcode($input); @@ -144,6 +148,11 @@ final class BarcodeScanHelper return new AmazonBarcodeScanResult($input); } + // Try TME barcode + if (TMEBarcodeScanResult::isTMEBarcode($input)) { + return TMEBarcodeScanResult::parse($input); + } + throw new InvalidArgumentException('Unknown barcode'); } @@ -162,6 +171,7 @@ final class BarcodeScanHelper return LCSCBarcodeScanResult::parse($input); } + private function parseUserDefinedBarcode(string $input): ?LocalBarcodeScanResult { $lot_repo = $this->entityManager->getRepository(PartLot::class); diff --git a/src/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandler.php b/src/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandler.php index e24c7077..60a1136f 100644 --- a/src/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandler.php +++ b/src/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandler.php @@ -117,7 +117,8 @@ final readonly class BarcodeScanResultHandler throw InfoProviderNotActiveException::fromProvider($provider); } - return $this->urlGenerator->generate('info_providers_create_part', ['providerKey' => $infos['providerKey'], 'providerId' => $infos['providerId']]); + //So far we can just copy over our provider info array to the URL parameters: + return $this->urlGenerator->generate('info_providers_create_part', $infos); } /** @@ -146,7 +147,11 @@ final readonly class BarcodeScanResultHandler if ($barcodeScan instanceof AmazonBarcodeScanResult) { return $this->em->getRepository(Part::class)->getPartByProviderInfo($barcodeScan->asin) - ?? $this->em->getRepository(Part::class)->getPartBySPN($barcodeScan->asin); + ?? $this->em->getRepository(Part::class)->getPartBySPN($barcodeScan->asin); + } + + if ($barcodeScan instanceof TMEBarcodeScanResult) { + return $this->resolvePartFromTME($barcodeScan); } return null; @@ -216,8 +221,8 @@ final readonly class BarcodeScanResultHandler * Resolve LCSC barcode -> Part. * Strategy: * 1) Try providerReference.provider_id == pc (LCSC "Cxxxxxx") if you store it there - * 2) Fallback to manufacturer_product_number == pm (MPN) * Returns first match (consistent with EIGP114 logic) + * 2) Fallback to search across supplier part number (SPN) */ private function resolvePartFromLCSC(LCSCBarcodeScanResult $barcodeScan): ?Part { @@ -230,32 +235,64 @@ final readonly class BarcodeScanResultHandler } } - // Fallback to MPN (pm) - $pm = $barcodeScan->mpn; // e.g. RC0402FR-071ML - if (!$pm) { - return null; - } - - return $this->em->getRepository(Part::class)->getPartByMPN($pm); + // fallback to search by SPN + return $this->em->getRepository(Part::class)->getPartBySPN($pc); } + private function resolvePartFromTME(TMEBarcodeScanResult $barcodeScan): ?Part + { + $pn = $barcodeScan->tmePartNumber; + if ($pn) { + $part = $this->em->getRepository(Part::class)->getPartByProviderInfo($pn); + if ($part !== null) { + return $part; + } + + //Try to find the part by SPN/SKU + $part = $this->em->getRepository(Part::class)->getPartBySPN($pn); + if ($part !== null) { + return $part; + } + } + + // Fallback: search by MPN + return $this->em->getRepository(Part::class)->getPartByMPN($barcodeScan->mpn, $barcodeScan->manufacturer); + } + /** * Tries to extract creation information for a part from the given barcode scan result. This can be used to * automatically fill in the info provider reference of a part, when creating a new part based on the scan result. * Returns null if no provider information could be extracted from the scan result, or if the scan result type is unknown and cannot be handled by this function. * It is not necessarily checked that the provider is active, or that the result actually exists on the provider side. * @param BarcodeScanResultInterface $scanResult - * @return array{providerKey: string, providerId: string}|null + * @return array{providerKey: string, providerId: string, lotAmount?: float|int, lotName?: string, lotUserBarcode?: string}|null * @throws InfoProviderNotActiveException If the scan result contains information for a provider which is currently not active in the system */ public function getCreateInfos(BarcodeScanResultInterface $scanResult): ?array { + // TME + if ($scanResult instanceof TMEBarcodeScanResult) { + if ($scanResult->tmePartNumber === null) { + return null; + } + return [ + 'providerKey' => 'tme', + 'providerId' => $scanResult->tmePartNumber, + 'lotAmount' => $scanResult->quantity, + 'lotName' => $scanResult->purchaseOrder, + 'lotUserBarcode' => $scanResult->rawInput, + ]; + } + // LCSC if ($scanResult instanceof LCSCBarcodeScanResult) { return [ 'providerKey' => 'lcsc', 'providerId' => $scanResult->lcscCode, + 'lotAmount' => $scanResult->quantity, + 'lotName' => $scanResult->orderNumber ?? $scanResult->pickBatchNumber, + 'lotUserBarcode' => $scanResult->rawInput, ]; } @@ -276,7 +313,7 @@ final readonly class BarcodeScanResultHandler /** * @param EIGP114BarcodeScanResult $scanResult - * @return array{providerKey: string, providerId: string}|null + * * @return array{providerKey: string, providerId: string, lotAmount?: float|int, lotName?: string, lotUserBarcode?: string}|null */ private function getCreationInfoForEIGP114(EIGP114BarcodeScanResult $scanResult): ?array { @@ -285,30 +322,36 @@ final readonly class BarcodeScanResultHandler // Mouser: use supplierPartNumber -> search provider -> provider_id if ($vendor === 'mouser' && $scanResult->supplierPartNumber !== null ) { - // Search Mouser using the MPN - $dtos = $this->infoRetriever->searchByKeyword( - keyword: $scanResult->supplierPartNumber, - providers: ["mouser"] - ); + // Search Mouser using the MPN + $dtos = $this->infoRetriever->searchByKeyword( + keyword: $scanResult->supplierPartNumber, + providers: ["mouser"] + ); - // If there are results, provider_id is MouserPartNumber (per MouserProvider.php) - $best = $dtos[0] ?? null; + // If there are results, provider_id is MouserPartNumber (per MouserProvider.php) + $best = $dtos[0] ?? null; - if ($best !== null) { - return [ - 'providerKey' => 'mouser', - 'providerId' => $best->provider_id, - ]; - } + if ($best !== null) { + return [ + 'providerKey' => 'mouser', + 'providerId' => $best->provider_id, + 'lotAmount' => $scanResult->quantity, + 'lotName' => $scanResult->customerPO, + 'lotUserBarcode' => $scanResult->rawInput, + ]; + } - return null; + return null; } - // Digi-Key: can use customerPartNumber or supplierPartNumber directly + // Digi-Key: supplierPartNumber directly if ($vendor === 'digikey') { return [ 'providerKey' => 'digikey', - 'providerId' => $scanResult->customerPartNumber ?? $scanResult->supplierPartNumber, + 'providerId' => $scanResult->supplierPartNumber ?? throw new \RuntimeException('Digikey barcode does not contain required supplier part number'), + 'lotAmount' => $scanResult->quantity, + 'lotName' => $scanResult->digikeyInvoiceNumber ?? $scanResult->digikeySalesOrderNumber ?? $scanResult->customerPO, + 'lotUserBarcode' => $scanResult->rawInput, ]; } @@ -316,7 +359,10 @@ final readonly class BarcodeScanResultHandler if ($vendor === 'element14') { return [ 'providerKey' => 'element14', - 'providerId' => $scanResult->supplierPartNumber, + 'providerId' => $scanResult->supplierPartNumber ?? throw new \RuntimeException('Element14 barcode does not contain required supplier part number'), + 'lotAmount' => $scanResult->quantity, + 'lotName' => $scanResult->customerPO, + 'lotUserBarcode' => $scanResult->rawInput, ]; } diff --git a/src/Services/LabelSystem/BarcodeScanner/BarcodeSourceType.php b/src/Services/LabelSystem/BarcodeScanner/BarcodeSourceType.php index fb6eaa77..df991a8c 100644 --- a/src/Services/LabelSystem/BarcodeScanner/BarcodeSourceType.php +++ b/src/Services/LabelSystem/BarcodeScanner/BarcodeSourceType.php @@ -52,4 +52,7 @@ enum BarcodeSourceType: string case LCSC = 'lcsc'; case AMAZON = 'amazon'; + + /** For TME (tme.eu) formatted QR codes */ + case TME = 'tme'; } diff --git a/src/Services/LabelSystem/BarcodeScanner/EIGP114BarcodeScanResult.php b/src/Services/LabelSystem/BarcodeScanner/EIGP114BarcodeScanResult.php index 37c03f55..0ff74fd4 100644 --- a/src/Services/LabelSystem/BarcodeScanner/EIGP114BarcodeScanResult.php +++ b/src/Services/LabelSystem/BarcodeScanner/EIGP114BarcodeScanResult.php @@ -187,7 +187,7 @@ readonly class EIGP114BarcodeScanResult implements BarcodeScanResultInterface * * @param array $data The fields of the EIGP114 barcode, where the key is the field name and the value is the field content */ - public function __construct(public array $data) + public function __construct(public array $data, public readonly ?string $rawInput = null) { //IDs per EIGP 114.2018 $this->shipDate = $data['6D'] ?? null; @@ -254,12 +254,16 @@ readonly class EIGP114BarcodeScanResult implements BarcodeScanResultInterface */ public static function isFormat06Code(string $input): bool { - //Code must begin with [)>06 - if(!str_starts_with($input, "[)>\u{1E}06\u{1D}")){ - return false; + //Code should begin with [)>06 as per the standard + if(!str_starts_with($input, "[)>\u{1E}06\u{1D}") + // some codes don't contain record separators + && !str_starts_with($input, "[)>06\u{1D}") + // This is found on old Mouser parts + && !str_starts_with($input, ">[)>06\u{1D}")) + { + return false; } - - //Digikey does not put a trailer onto the barcode, so we just check for the header + //Digikey and Mouser don't put a trailer onto the barcode, so we just check for the header return true; } @@ -271,6 +275,8 @@ readonly class EIGP114BarcodeScanResult implements BarcodeScanResultInterface */ public static function parseFormat06Code(string $input): self { + $rawInput = $input; + //Ensure that the input is a valid format06 code if (!self::isFormat06Code($input)) { throw new \InvalidArgumentException("The given input is not a valid format06 code"); @@ -306,7 +312,7 @@ readonly class EIGP114BarcodeScanResult implements BarcodeScanResultInterface $results[$key] = $fieldValue; } - return new self($results); + return new self($results, $rawInput); } public function getDecodedForInfoMode(): array diff --git a/src/Services/LabelSystem/BarcodeScanner/TMEBarcodeScanResult.php b/src/Services/LabelSystem/BarcodeScanner/TMEBarcodeScanResult.php new file mode 100644 index 00000000..5feb67c1 --- /dev/null +++ b/src/Services/LabelSystem/BarcodeScanner/TMEBarcodeScanResult.php @@ -0,0 +1,143 @@ +. + */ + +declare(strict_types=1); + +namespace App\Services\LabelSystem\BarcodeScanner; + +use InvalidArgumentException; + +/** + * This class represents the content of a tme.eu barcode label. + * The format is space-separated KEY:VALUE tokens, e.g.: + * QTY:1000 PN:SMD0603-5K1-1% PO:32723349/7 MFR:ROYALOHM MPN:0603SAF5101T5E CoO:TH RoHS https://www.tme.eu/details/... + */ +readonly class TMEBarcodeScanResult implements BarcodeScanResultInterface +{ + /** @var int|null Quantity (QTY) */ + public ?int $quantity; + + /** @var string|null TME part number (PN) */ + public ?string $tmePartNumber; + + /** @var string|null Purchase order number (PO) */ + public ?string $purchaseOrder; + + /** @var string|null Manufacturer name (MFR) */ + public ?string $manufacturer; + + /** @var string|null Manufacturer part number (MPN) */ + public ?string $mpn; + + /** @var string|null Country of origin (CoO) */ + public ?string $countryOfOrigin; + + /** @var bool Whether the part is RoHS compliant */ + public bool $rohs; + + /** @var string|null The product URL */ + public ?string $productUrl; + + /** + * @param array $fields Parsed key-value fields (keys uppercased) + * @param string $rawInput Original barcode string + */ + public function __construct( + public array $fields, + public string $rawInput, + ) { + $this->quantity = isset($this->fields['QTY']) ? (int) $this->fields['QTY'] : null; + $this->tmePartNumber = $this->fields['PN'] ?? null; + $this->purchaseOrder = $this->fields['PO'] ?? null; + $this->manufacturer = $this->fields['MFR'] ?? null; + $this->mpn = $this->fields['MPN'] ?? null; + $this->countryOfOrigin = $this->fields['COO'] ?? null; + $this->rohs = isset($this->fields['ROHS']); + $this->productUrl = $this->fields['URL'] ?? null; + } + + public function getSourceType(): BarcodeSourceType + { + return BarcodeSourceType::TME; + } + + public function getDecodedForInfoMode(): array + { + return [ + 'Barcode type' => 'TME', + 'TME Part No. (PN)' => $this->tmePartNumber ?? '', + 'MPN' => $this->mpn ?? '', + 'Manufacturer (MFR)' => $this->manufacturer ?? '', + 'Qty' => $this->quantity !== null ? (string) $this->quantity : '', + 'Purchase Order (PO)' => $this->purchaseOrder ?? '', + 'Country of Origin (CoO)' => $this->countryOfOrigin ?? '', + 'RoHS' => $this->rohs ? 'Yes' : 'No', + 'URL' => $this->productUrl ?? '', + ]; + } + + /** + * Returns true if the input looks like a TME barcode label (contains tme.eu URL). + */ + public static function isTMEBarcode(string $input): bool + { + return str_contains(strtolower($input), 'tme.eu'); + } + + /** + * Parse the TME barcode string into a TMEBarcodeScanResult. + */ + public static function parse(string $input): self + { + $raw = trim($input); + + if (!self::isTMEBarcode($raw)) { + throw new InvalidArgumentException('Not a TME barcode'); + } + + $fields = []; + + // Split on whitespace; each token is either KEY:VALUE, a bare keyword, or the URL + $tokens = preg_split('/\s+/', $raw); + foreach ($tokens as $token) { + if ($token === '') { + continue; + } + + // The TME URL + if (str_starts_with(strtolower($token), 'http')) { + $fields['URL'] = $token; + continue; + } + + $colonPos = strpos($token, ':'); + if ($colonPos !== false) { + $key = strtoupper(substr($token, 0, $colonPos)); + $value = substr($token, $colonPos + 1); + $fields[$key] = $value; + } else { + // Bare keyword like "RoHS" + $fields[strtoupper($token)] = ''; + } + } + + return new self($fields, $raw); + } +} diff --git a/src/Services/LabelSystem/PlaceholderProviders/BarcodeProvider.php b/src/Services/LabelSystem/PlaceholderProviders/BarcodeProvider.php index 400fef35..9719917a 100644 --- a/src/Services/LabelSystem/PlaceholderProviders/BarcodeProvider.php +++ b/src/Services/LabelSystem/PlaceholderProviders/BarcodeProvider.php @@ -114,10 +114,12 @@ final class BarcodeProvider implements PlaceholderProviderInterface return 'IPN Barcode ERROR!: '.$e->getMessage(); } } - - - - return null; } + + public static function getDefaultPriority(): int + { + //This provider should be checked before all others, so that nothing is delegated for part lots + return 1000; + } } diff --git a/src/Services/ProjectSystem/ProjectBuildHelper.php b/src/Services/ProjectSystem/ProjectBuildHelper.php index a541c29d..ee5b8c68 100644 --- a/src/Services/ProjectSystem/ProjectBuildHelper.php +++ b/src/Services/ProjectSystem/ProjectBuildHelper.php @@ -25,16 +25,22 @@ namespace App\Services\ProjectSystem; use App\Entity\Parts\Part; use App\Entity\ProjectSystem\Project; use App\Entity\ProjectSystem\ProjectBOMEntry; +use App\Entity\PriceInformations\Currency; use App\Helpers\Projects\ProjectBuildRequest; use App\Services\Parts\PartLotWithdrawAddHelper; +use App\Services\Parts\PricedetailHelper; +use Brick\Math\BigDecimal; +use Brick\Math\RoundingMode; /** * @see \App\Tests\Services\ProjectSystem\ProjectBuildHelperTest */ final readonly class ProjectBuildHelper { - public function __construct(private PartLotWithdrawAddHelper $withdraw_add_helper) - { + public function __construct( + private PartLotWithdrawAddHelper $withdraw_add_helper, + private PricedetailHelper $pricedetailHelper, + ) { } /** @@ -168,4 +174,81 @@ final readonly class ProjectBuildHelper $this->withdraw_add_helper->add($buildRequest->getBuildsPartLot(), $buildRequest->getNumberOfBuilds(), $message); } } + + /** + * Calculates the total price to build the given project N times, taking bulk pricing into account. + * Returns null if no BOM entry has any pricing information. + */ + public function calculateTotalBuildPrice(Project $project, int $number_of_builds = 1, ?Currency $currency = null): ?BigDecimal + { + $total = BigDecimal::zero(); + $has_price = false; + + foreach ($project->getBomEntries() as $entry) { + $unit_price = $this->getBomEntryUnitPrice($entry, $number_of_builds, $currency); + if ($unit_price === null) { + continue; + } + $has_price = true; + $total = $total->plus($unit_price->multipliedBy($entry->getQuantity())->multipliedBy($number_of_builds)); + } + + return $has_price ? $total : null; + } + + /** + * Calculates the price to build one unit of the given project when ordering for N builds in total. + * Returns null if no BOM entry has any pricing information. + */ + public function calculateUnitBuildPrice(Project $project, int $number_of_builds = 1, ?Currency $currency = null): ?BigDecimal + { + $total = $this->calculateTotalBuildPrice($project, $number_of_builds, $currency); + if ($total === null) { + return null; + } + return $total->dividedBy($number_of_builds, 10, RoundingMode::HALF_UP); + } + + /** + * Returns the total build price rounded up to 2 decimal places, ready for display. + */ + 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); + } + + /** + * Returns the unit build price rounded up to 2 decimal places, ready for display. + */ + 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); + } + + /** + * Returns the effective unit price for a single piece of the given BOM entry, + * taking bulk pricing and minimum order amounts into account for N builds. + * Returns BigDecimal::zero() when no pricing data is available. + */ + public function getEntryUnitPrice(ProjectBOMEntry $entry, int $number_of_builds = 1, ?Currency $currency = null): BigDecimal + { + return $this->getBomEntryUnitPrice($entry, $number_of_builds, $currency) ?? BigDecimal::zero(); + } + + /** + * Returns the effective unit price for a single piece of the given BOM entry, + * taking bulk pricing into account for N builds. + */ + private function getBomEntryUnitPrice(ProjectBOMEntry $entry, int $number_of_builds, ?Currency $currency): ?BigDecimal + { + if ($entry->getPart() instanceof Part) { + $total_qty = $entry->getQuantity() * $number_of_builds; + $min_order = $this->pricedetailHelper->getMinOrderAmount($entry->getPart()); + $effective_qty = ($min_order !== null) ? max($total_qty, $min_order) : $total_qty; + return $this->pricedetailHelper->calculateAvgPrice($entry->getPart(), $effective_qty, $currency); + } + return $entry->getPrice(); + } } diff --git a/src/Services/System/BackupManager.php b/src/Services/System/BackupManager.php index 4946bc24..621b58d7 100644 --- a/src/Services/System/BackupManager.php +++ b/src/Services/System/BackupManager.php @@ -327,14 +327,14 @@ readonly class BackupManager */ private function restoreDatabaseFromBackup(string $tempDir): void { + // Get database connection params from Doctrine + $connection = $this->entityManager->getConnection(); + $params = $connection->getParams(); + $platform = $connection->getDatabasePlatform(); + // Check for SQL dump (MySQL/PostgreSQL) $sqlFile = $tempDir . '/database.sql'; if (file_exists($sqlFile)) { - // Import SQL using mysql/psql command directly - // First, get database connection params from Doctrine - $connection = $this->entityManager->getConnection(); - $params = $connection->getParams(); - $platform = $connection->getDatabasePlatform(); if ($platform instanceof AbstractMySQLPlatform) { // Use mysql command to import - need to use shell to handle input redirection @@ -403,7 +403,8 @@ readonly class BackupManager // Check for SQLite database file $sqliteFile = $tempDir . '/var/app.db'; if (file_exists($sqliteFile)) { - $targetDb = $this->projectDir . '/var/app.db'; + // Use the actual configured SQLite path from Doctrine, not a hardcoded path + $targetDb = $params['path'] ?? $this->projectDir . '/var/app.db'; $this->filesystem->copy($sqliteFile, $targetDb, true); return; } 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/System/InstallationType.php b/src/Services/System/InstallationType.php index 74479bb9..2631e644 100644 --- a/src/Services/System/InstallationType.php +++ b/src/Services/System/InstallationType.php @@ -46,7 +46,7 @@ enum InstallationType: string { return match ($this) { self::GIT => true, - self::DOCKER => false, + self::DOCKER => true, // ZIP_RELEASE auto-update not yet implemented self::ZIP_RELEASE => false, self::UNKNOWN => false, @@ -57,7 +57,7 @@ enum InstallationType: string { return match ($this) { self::GIT => 'Run: php bin/console partdb:update', - self::DOCKER => 'Pull the new Docker image and recreate the container: docker-compose pull && docker-compose up -d', + self::DOCKER => 'Configure Watchtower for one-click updates, or manually: docker-compose pull && docker-compose up -d', self::ZIP_RELEASE => 'Download the new release ZIP from GitHub, extract it over your installation, and run: php bin/console doctrine:migrations:migrate && php bin/console cache:clear', self::UNKNOWN => 'Unable to determine installation type. Please update manually.', }; diff --git a/src/Services/System/UpdateAvailableFacade.php b/src/Services/System/UpdateAvailableFacade.php index ac3a46c0..60f66036 100644 --- a/src/Services/System/UpdateAvailableFacade.php +++ b/src/Services/System/UpdateAvailableFacade.php @@ -105,6 +105,6 @@ class UpdateAvailableFacade return $this->updateCache->get(self::CACHE_KEY, function (ItemInterface $item) { $item->expiresAfter(self::CACHE_TTL); return $this->updateChecker->getLatestVersion(); - }); + }) ?? ['version' => '0.0.1', 'url' => 'update-checking-failed']; } } diff --git a/src/Services/System/UpdateChecker.php b/src/Services/System/UpdateChecker.php index fdb8d9dd..366e8d67 100644 --- a/src/Services/System/UpdateChecker.php +++ b/src/Services/System/UpdateChecker.php @@ -50,7 +50,8 @@ class UpdateChecker private readonly InstallationTypeDetector $installationTypeDetector, private readonly GitVersionInfoProvider $gitVersionInfoProvider, #[Autowire(param: 'kernel.debug')] private readonly bool $is_dev_mode, - #[Autowire(param: 'kernel.project_dir')] private readonly string $project_dir) + #[Autowire(param: 'kernel.project_dir')] private readonly string $project_dir, + private readonly ?WatchtowerClient $watchtowerClient = null) { } @@ -284,8 +285,16 @@ class UpdateChecker $updateBlockers[] = 'local_changes'; } - if ($installInfo['type'] === InstallationType::DOCKER) { - $updateBlockers[] = 'docker_installation'; + // Docker installations require Watchtower for auto-update + $watchtowerConfigured = $this->watchtowerClient !== null && $this->watchtowerClient->isConfigured(); + $watchtowerAvailable = $watchtowerConfigured && $this->watchtowerClient->isAvailable(); + + if ($installInfo['type'] === InstallationType::DOCKER && !$watchtowerConfigured) { + $canAutoUpdate = false; + $updateBlockers[] = 'docker_no_watchtower'; + } elseif ($installInfo['type'] === InstallationType::DOCKER && !$watchtowerAvailable) { + $canAutoUpdate = false; + $updateBlockers[] = 'docker_watchtower_unreachable'; } return [ @@ -301,6 +310,8 @@ class UpdateChecker 'can_auto_update' => $canAutoUpdate, 'update_blockers' => $updateBlockers, 'check_enabled' => $this->privacySettings->checkForUpdates, + 'watchtower_configured' => $watchtowerConfigured, + 'watchtower_available' => $watchtowerAvailable, ]; } diff --git a/src/Services/System/UpdateExecutor.php b/src/Services/System/UpdateExecutor.php index 2fe54173..ccc346d5 100644 --- a/src/Services/System/UpdateExecutor.php +++ b/src/Services/System/UpdateExecutor.php @@ -207,6 +207,79 @@ class UpdateExecutor } } + /** + * Reset PHP OPcache for the web server process. + * + * OPcache in PHP-FPM is separate from CLI. After updating code files, + * PHP-FPM may still serve stale cached bytecode, causing constructor + * mismatches and 500 errors. This method creates a temporary PHP script + * in the public directory, invokes it via HTTP to reset OPcache in the + * web server context, then removes the script. + * + * @return bool Whether OPcache was successfully reset + */ + private function resetOpcache(): bool + { + $token = bin2hex(random_bytes(16)); + $resetScript = $this->project_dir . '/public/_opcache_reset_' . $token . '.php'; + + try { + // Create a temporary PHP script that resets OPcache + $scriptContent = 'filesystem->dumpFile($resetScript, $scriptContent); + + // Try to invoke it via HTTP on localhost + $urls = [ + 'http://127.0.0.1/_opcache_reset_' . $token . '.php', + 'http://localhost/_opcache_reset_' . $token . '.php', + ]; + + $success = false; + foreach ($urls as $url) { + try { + $context = stream_context_create([ + 'http' => [ + 'timeout' => 5, + 'ignore_errors' => true, + ], + ]); + + $response = @file_get_contents($url, false, $context); + if ($response === 'OK') { + $this->logger->info('OPcache reset via ' . $url); + $success = true; + break; + } + } catch (\Throwable $e) { + // Try next URL + continue; + } + } + + if (!$success) { + $this->logger->info('OPcache reset via HTTP not available, trying CLI fallback'); + // CLI opcache_reset() only affects CLI, but try anyway + if (function_exists('opcache_reset')) { + opcache_reset(); + } + } + + return $success; + } catch (\Throwable $e) { + $this->logger->warning('OPcache reset failed: ' . $e->getMessage()); + return false; + } finally { + // Ensure the temp script is removed + if (file_exists($resetScript)) { + @unlink($resetScript); + } + } + } + /** * Validate that we can perform an update. * @@ -226,6 +299,23 @@ class UpdateExecutor ); } + // Docker installations are updated via Watchtower - skip Git/Composer/Yarn checks + if ($installType === InstallationType::DOCKER) { + // Only check if already locked + if ($this->isLocked()) { + $lockInfo = $this->getLockInfo(); + $errors[] = sprintf( + 'An update is already in progress (started at %s).', + $lockInfo['started_at'] ?? 'unknown time' + ); + } + + return [ + 'valid' => empty($errors), + 'errors' => $errors, + ]; + } + // Check for Git installation if ($installType === InstallationType::GIT) { // Check if git is available @@ -420,7 +510,7 @@ class UpdateExecutor // Step 11: Clear cache $stepStart = microtime(true); $this->runCommand([ - 'php', 'bin/console', 'cache:clear', + 'php', 'bin/console', 'cache:pool:clear', '--all', '--env=prod', '--no-interaction', ], 'Clear cache', 120); @@ -434,12 +524,20 @@ class UpdateExecutor ], 'Warmup cache', 120); $log('cache_warmup', 'Warmed up application cache', true, microtime(true) - $stepStart); - // Step 13: Disable maintenance mode + // Step 13: Reset OPcache (if available) + $stepStart = microtime(true); + $opcacheResult = $this->resetOpcache(); + $log('opcache_reset', $opcacheResult + ? 'Reset PHP OPcache for web server' + : 'OPcache reset skipped (not available or not needed)', + true, microtime(true) - $stepStart); + + // Step 14: Disable maintenance mode $stepStart = microtime(true); $this->disableMaintenanceMode(); $log('maintenance_off', 'Disabled maintenance mode', true, microtime(true) - $stepStart); - // Step 14: Release lock + // Step 15: Release lock $stepStart = microtime(true); $this->releaseLock(); @@ -489,11 +587,14 @@ class UpdateExecutor // Clear cache after rollback $this->runCommand([ - 'php', 'bin/console', 'cache:clear', + 'php', 'bin/console', 'cache:pool:clear', '--all', '--env=prod', ], 'Clear cache after rollback', 120); $log('rollback_cache', 'Cleared cache after rollback', true); + // Reset OPcache after rollback + $this->resetOpcache(); + } catch (\Exception $rollbackError) { $log('rollback_failed', 'Rollback failed: ' . $rollbackError->getMessage(), false); } @@ -602,6 +703,33 @@ class UpdateExecutor } + /** + * Delete a specific update log file. + */ + public function deleteLog(string $filename): bool + { + // Validate filename pattern for security + if (!preg_match('/^update-[\w.\-]+\.log$/', $filename)) { + $this->logger->warning('Attempted to delete invalid log filename: ' . $filename); + return false; + } + + $logPath = $this->project_dir . '/' . self::UPDATE_LOG_DIR . '/' . basename($filename); + + if (!file_exists($logPath)) { + return false; + } + + try { + $this->filesystem->remove($logPath); + $this->logger->info('Deleted update log: ' . $filename); + return true; + } catch (\Exception $e) { + $this->logger->error('Failed to delete update log: ' . $e->getMessage()); + return false; + } + } + /** * Restore from a backup file with maintenance mode and cache clearing. * @@ -682,12 +810,17 @@ class UpdateExecutor $this->runCommand(['php', 'bin/console', 'cache:warmup'], 'Warm up cache'); $log('cache_warmup', 'Warmed up application cache', true, microtime(true) - $stepStart); - // Step 6: Disable maintenance mode + // Step 6: Reset OPcache + $stepStart = microtime(true); + $this->resetOpcache(); + $log('opcache_reset', 'Reset PHP OPcache', true, microtime(true) - $stepStart); + + // Step 7: Disable maintenance mode $stepStart = microtime(true); $this->disableMaintenanceMode(); $log('maintenance_off', 'Disabled maintenance mode', true, microtime(true) - $stepStart); - // Step 7: Release lock + // Step 8: Release lock $this->releaseLock(); $totalDuration = microtime(true) - $startTime; @@ -817,7 +950,7 @@ class UpdateExecutor 'create_backup' => $createBackup, 'started_at' => (new \DateTime())->format('c'), 'current_step' => 0, - 'total_steps' => 14, + 'total_steps' => 15, 'step_name' => 'initializing', 'step_message' => 'Starting update process...', 'steps' => [], @@ -890,7 +1023,7 @@ class UpdateExecutor bool $createBackup = true, ?callable $onProgress = null ): array { - $totalSteps = 12; + $totalSteps = 13; $currentStep = 0; $updateProgress = function (string $stepName, string $message, bool $success = true) use (&$currentStep, $totalSteps, $targetVersion, $createBackup): void { diff --git a/src/Services/System/WatchtowerClient.php b/src/Services/System/WatchtowerClient.php new file mode 100644 index 00000000..87cc06fd --- /dev/null +++ b/src/Services/System/WatchtowerClient.php @@ -0,0 +1,125 @@ +. + */ + +declare(strict_types=1); + +namespace App\Services\System; + +use Psr\Log\LoggerInterface; +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * HTTP client for communicating with the Watchtower container updater API. + * Used to trigger Docker container updates from the Part-DB UI. + * + * @see https://containrrr.dev/watchtower/ + */ +readonly class WatchtowerClient +{ + public function __construct( + private HttpClientInterface $httpClient, + private LoggerInterface $logger, + #[Autowire(env: 'WATCHTOWER_API_URL')] private string $apiUrl, + #[Autowire(env: 'WATCHTOWER_API_TOKEN')] private string $apiToken, + ) { + } + + /** + * Whether Watchtower integration is configured (URL and token are set). + */ + public function isConfigured(): bool + { + return $this->apiUrl !== '' && $this->apiToken !== ''; + } + + /** + * Check if the Watchtower API is reachable. + * Makes a lightweight HTTP request with a short timeout. + */ + public function isAvailable(): bool + { + if (!$this->isConfigured()) { + return false; + } + + try { + $response = $this->httpClient->request('GET', $this->getUpdateEndpoint(), [ + 'headers' => $this->getAuthHeaders(), + 'timeout' => 3, + ]); + + // Any response means Watchtower is reachable + $statusCode = $response->getStatusCode(); + return $statusCode < 500; + } catch (\Throwable $e) { + $this->logger->debug('Watchtower availability check failed: ' . $e->getMessage()); + return false; + } + } + + /** + * Trigger a container update via the Watchtower HTTP API. + * This is fire-and-forget: Watchtower will pull the new image and restart the container. + * + * @return bool True if Watchtower accepted the update request + */ + public function triggerUpdate(): bool + { + if (!$this->isConfigured()) { + throw new \RuntimeException('Watchtower is not configured. Set WATCHTOWER_API_URL and WATCHTOWER_API_TOKEN.'); + } + + try { + $response = $this->httpClient->request('POST', $this->getUpdateEndpoint(), [ + 'headers' => $this->getAuthHeaders(), + 'timeout' => 10, + ]); + + $statusCode = $response->getStatusCode(); + + if ($statusCode >= 200 && $statusCode < 300) { + $this->logger->info('Watchtower update triggered successfully.'); + return true; + } + + $this->logger->error('Watchtower update request returned HTTP ' . $statusCode); + return false; + } catch (\Throwable $e) { + $this->logger->error('Failed to trigger Watchtower update: ' . $e->getMessage()); + return false; + } + } + + private function getUpdateEndpoint(): string + { + return rtrim($this->apiUrl, '/') . '/v1/update'; + } + + /** + * @return array + */ + private function getAuthHeaders(): array + { + return [ + 'Authorization' => 'Bearer ' . $this->apiToken, + ]; + } +} diff --git a/src/Settings/AISettings/AISettings.php b/src/Settings/AISettings/AISettings.php new file mode 100644 index 00000000..732eb597 --- /dev/null +++ b/src/Settings/AISettings/AISettings.php @@ -0,0 +1,43 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Settings\AISettings; + +use App\Settings\SettingsIcon; +use Jbtronics\SettingsBundle\Settings\EmbeddedSettings; +use Jbtronics\SettingsBundle\Settings\Settings; +use Jbtronics\SettingsBundle\Settings\SettingsTrait; +use Symfony\Component\Translation\TranslatableMessage as TM; + +#[Settings(label: new TM("settings.ai"))] +#[SettingsIcon("fa-brain")] +class AISettings +{ + use SettingsTrait; + + #[EmbeddedSettings] + public ?OpenRouterSettings $openRouter = null; + + #[EmbeddedSettings] + public ?LMStudioSettings $lmstudio = null; +} diff --git a/src/Settings/AISettings/LMStudioSettings.php b/src/Settings/AISettings/LMStudioSettings.php new file mode 100644 index 00000000..2bdad06e --- /dev/null +++ b/src/Settings/AISettings/LMStudioSettings.php @@ -0,0 +1,53 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Settings\AISettings; + +use App\Form\Type\APIKeyType; +use App\Services\AI\AIPlatformSettingsInterface; +use App\Settings\SettingsIcon; +use Jbtronics\SettingsBundle\Metadata\EnvVarMode; +use Jbtronics\SettingsBundle\Settings\Settings; +use Jbtronics\SettingsBundle\Settings\SettingsParameter; +use Jbtronics\SettingsBundle\Settings\SettingsTrait; +use Symfony\Component\Form\Extension\Core\Type\UrlType; +use Symfony\Component\Translation\StaticMessage; +use Symfony\Component\Translation\TranslatableMessage as TM; + +#[Settings(name: 'ai_lmstudio', label: new TM("settings.ai.lmstudio"))] +#[SettingsIcon("fa-robot")] +class LMStudioSettings implements AIPlatformSettingsInterface +{ + use SettingsTrait; + + #[SettingsParameter(label: new TM("settings.ai.lmstudio.hosturl"), + formType: UrlType::class, + formOptions: ["attr" => ["placeholder" => new StaticMessage("http://localhost:1234")]], + envVar: "AI_LMSTUDIO_HOSTURL", envVarMode: EnvVarMode::OVERWRITE)] + public ?string $hostURL = null; + + public function isAIPlatformEnabled(): bool + { + return $this->hostURL !== null && $this->hostURL !== ""; + } +} diff --git a/src/Settings/AISettings/OpenRouterSettings.php b/src/Settings/AISettings/OpenRouterSettings.php new file mode 100644 index 00000000..e083513a --- /dev/null +++ b/src/Settings/AISettings/OpenRouterSettings.php @@ -0,0 +1,50 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Settings\AISettings; + +use App\Form\Type\APIKeyType; +use App\Services\AI\AIPlatformSettingsInterface; +use App\Settings\SettingsIcon; +use Jbtronics\SettingsBundle\Metadata\EnvVarMode; +use Jbtronics\SettingsBundle\Settings\Settings; +use Jbtronics\SettingsBundle\Settings\SettingsParameter; +use Jbtronics\SettingsBundle\Settings\SettingsTrait; +use Symfony\Component\Translation\TranslatableMessage as TM; + +#[Settings(name: 'ai_openrouter', label: new TM("settings.ai.openrouter"), description: "settings.ai.openrouter.help")] +#[SettingsIcon("fa-robot")] +class OpenRouterSettings implements AIPlatformSettingsInterface +{ + use SettingsTrait; + + #[SettingsParameter(label: new TM("settings.ips.element14.apiKey"), + formType: APIKeyType::class, + formOptions: ["help_html" => true], envVar: "AI_OPENROUTER_KEY", envVarMode: EnvVarMode::OVERWRITE)] + public ?string $apiKey = null; + + public function isAIPlatformEnabled(): bool + { + return $this->apiKey !== null && $this->apiKey !== ""; + } +} diff --git a/src/Settings/AppSettings.php b/src/Settings/AppSettings.php index 14d9395e..085f7ffe 100644 --- a/src/Settings/AppSettings.php +++ b/src/Settings/AppSettings.php @@ -23,6 +23,7 @@ declare(strict_types=1); namespace App\Settings; +use App\Settings\AISettings\AISettings; use App\Settings\BehaviorSettings\BehaviorSettings; use App\Settings\InfoProviderSystem\InfoProviderSettings; use App\Settings\MiscSettings\MiscSettings; @@ -50,6 +51,9 @@ class AppSettings #[EmbeddedSettings] public ?SynonymSettings $synonyms = null; + #[EmbeddedSettings] + public ?AISettings $ai = null; + #[EmbeddedSettings()] public ?MiscSettings $miscSettings = null; diff --git a/src/Settings/BehaviorSettings/PartTableColumns.php b/src/Settings/BehaviorSettings/PartTableColumns.php index 3b30e0a4..32f6100b 100644 --- a/src/Settings/BehaviorSettings/PartTableColumns.php +++ b/src/Settings/BehaviorSettings/PartTableColumns.php @@ -52,6 +52,8 @@ enum PartTableColumns : string implements TranslatableInterface case TAGS = "tags"; case ATTACHMENTS = "attachments"; + case SI_VALUE = "si_value"; + case EDA_REFERENCE = "eda_reference"; case EDA_VALUE = "eda_value"; diff --git a/src/Settings/InfoProviderSystem/AIExtractorSettings.php b/src/Settings/InfoProviderSystem/AIExtractorSettings.php new file mode 100644 index 00000000..af9753d9 --- /dev/null +++ b/src/Settings/InfoProviderSystem/AIExtractorSettings.php @@ -0,0 +1,78 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Settings\InfoProviderSystem; + +use App\Form\Settings\AiModelsType; +use App\Form\Settings\AiPlatformChoiceType; +use App\Services\AI\AIPlatforms; +use App\Settings\SettingsIcon; +use Jbtronics\SettingsBundle\Metadata\EnvVarMode; +use Jbtronics\SettingsBundle\Settings\Settings; +use Jbtronics\SettingsBundle\Settings\SettingsParameter; +use Jbtronics\SettingsBundle\Settings\SettingsTrait; +use Symfony\AI\Platform\Capability; +use Symfony\Component\Form\Extension\Core\Type\LanguageType; +use Symfony\Component\Form\Extension\Core\Type\TextareaType; +use Symfony\Component\Translation\StaticMessage; +use Symfony\Component\Translation\TranslatableMessage as TM; +use Symfony\Component\Validator\Constraints\Language; + +#[Settings(name: "ai_extractor", label: new TM("settings.ips.ai_extractor"), description: new TM("settings.ips.ai_extractor.description"))] +#[SettingsIcon("fa-plug")] +class AIExtractorSettings +{ + private const MODEL_SELECTOR_LABEL = 'ai_extractor'; + + use SettingsTrait; + + #[SettingsParameter(label: new TM("settings.ips.ai_extractor.ai_platform"), + formType: AiPlatformChoiceType::class, formOptions: ['platform_selector_label' => self::MODEL_SELECTOR_LABEL], + )] + public ?AIPlatforms $platform = null; + + #[SettingsParameter(label: new TM("settings.ips.ai_extractor.model"), description: new TM("settings.ips.ai_extractor.model.help"), + formType: AiModelsType::class, formOptions: [ + 'platform_selector' => self::MODEL_SELECTOR_LABEL, 'filter_capability' => Capability::OUTPUT_STRUCTURED, + 'attr' => ['placeholder' => new StaticMessage('google/gemini-2.5-flash-lite')] + ], + + )] + public ?string $model = null; + + #[SettingsParameter(label: new TM("settings.ips.ai_extractor.max_content_length"), + description: new TM("settings.ips.ai_extractor.max_content_length.description"), + )] + public int $maxContentLength = 50000; + + #[Language] + #[SettingsParameter(label: new TM("settings.ips.ai_extractor.output_language"), description: new TM("settings.ips.ai_extractor.output_language.description"), + formType: LanguageType::class, + )] + public ?string $outputLanguage = null; + + #[SettingsParameter(label: new TM("settings.ips.ai_extractor.additional_instructions"), description: new TM("settings.ips.ai_extractor.additional_instructions.description"), + formType: TextareaType::class, + )] + public ?string $additionalInstructions = null; +} diff --git a/src/Settings/InfoProviderSystem/CanopySettings.php b/src/Settings/InfoProviderSystem/CanopySettings.php index 0858871b..3c97a80e 100644 --- a/src/Settings/InfoProviderSystem/CanopySettings.php +++ b/src/Settings/InfoProviderSystem/CanopySettings.php @@ -72,7 +72,7 @@ class CanopySettings /** * @var string The domain used internally for the API requests. This is not necessarily the same as the domain shown to the user, which is determined by the keys of the ALLOWED_DOMAINS constant */ - #[SettingsParameter(label: new TM("settings.ips.tme.country"), formType: ChoiceType::class, formOptions: ["choices" => self::ALLOWED_DOMAINS])] + #[SettingsParameter(label: new TM("settings.ips.tme.country"), formType: ChoiceType::class, formOptions: ["choices" => self::ALLOWED_DOMAINS, 'translation_domain' => false])] public string $domain = "DE"; /** diff --git a/src/Settings/InfoProviderSystem/InfoProviderSettings.php b/src/Settings/InfoProviderSystem/InfoProviderSettings.php index 248fcedc..3e2a27ef 100644 --- a/src/Settings/InfoProviderSystem/InfoProviderSettings.php +++ b/src/Settings/InfoProviderSystem/InfoProviderSettings.php @@ -40,6 +40,9 @@ class InfoProviderSettings #[EmbeddedSettings] public ?GenericWebProviderSettings $genericWebProvider = null; + #[EmbeddedSettings] + public ?AIExtractorSettings $aiExtractor = null; + #[EmbeddedSettings] public ?DigikeySettings $digikey = null; @@ -75,4 +78,5 @@ class InfoProviderSettings #[EmbeddedSettings] public ?CanopySettings $canopy = null; + } diff --git a/src/Settings/MiscSettings/KiCadEDASettings.php b/src/Settings/MiscSettings/KiCadEDASettings.php index cf31bd95..dd223007 100644 --- a/src/Settings/MiscSettings/KiCadEDASettings.php +++ b/src/Settings/MiscSettings/KiCadEDASettings.php @@ -62,4 +62,10 @@ class KiCadEDASettings )] public bool $defaultOrderdetailsVisibility = false; + + #[SettingsParameter( + label: new TM("settings.misc.kicad_eda.use_custom_list"), + description: new TM("settings.misc.kicad_eda.use_custom_list.help"), + )] + public bool $useCustomList = false; } diff --git a/src/Twig/MiscExtension.php b/src/Twig/MiscExtension.php index 390ad084..565d56f2 100644 --- a/src/Twig/MiscExtension.php +++ b/src/Twig/MiscExtension.php @@ -22,6 +22,7 @@ declare(strict_types=1); */ namespace App\Twig; +use App\Services\InfoProviderSystem\CreateFromUrlHelper; use Twig\Attribute\AsTwigFunction; use App\Settings\SettingsIcon; use Symfony\Component\HttpFoundation\Request; @@ -34,7 +35,7 @@ use Twig\Extension\AbstractExtension; final readonly class MiscExtension { - public function __construct(private EventCommentNeededHelper $eventCommentNeededHelper) + public function __construct(private EventCommentNeededHelper $eventCommentNeededHelper, private CreateFromUrlHelper $fromUrlHelper) { } @@ -84,4 +85,14 @@ final readonly class MiscExtension return $request->getBaseUrl().$request->getPathInfo().$qs; } + + /** + * Returns true if the from url provider is active, false otherwise. + * @return bool + */ + #[AsTwigFunction(name: 'create_from_url_active')] + public function create_from_url_active(): bool + { + return $this->fromUrlHelper->canCreateFromUrl(); + } } diff --git a/symfony.lock b/symfony.lock index 68de2da7..f8f88675 100644 --- a/symfony.lock +++ b/symfony.lock @@ -375,6 +375,54 @@ "shivas/versioning-bundle": { "version": "4.0.3" }, + "symfony/ai-bundle": { + "version": "0.8", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "0.1", + "ref": "2be6ccd77335c2631fdf12d1680649b072efb8ad" + }, + "files": [ + "config/packages/ai.yaml" + ] + }, + "symfony/ai-generic-platform": { + "version": "0.8", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "0.1", + "ref": "f38913b87380322d4c40c302b41626e811516bc4" + }, + "files": [ + "config/packages/ai_generic_platform.yaml" + ] + }, + "symfony/ai-lm-studio-platform": { + "version": "0.8", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "0.1", + "ref": "e35cced28f6559fc5effccb8f22597f309fedfdf" + }, + "files": [ + "config/packages/ai_lm_studio_platform.yaml" + ] + }, + "symfony/ai-open-router-platform": { + "version": "0.8", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "0.1", + "ref": "c39a146c6ec3df8b874accf6ce1cccbda431a688" + }, + "files": [ + "config/packages/ai_open_router_platform.yaml" + ] + }, "symfony/apache-pack": { "version": "1.0", "recipe": { @@ -393,12 +441,6 @@ "symfony/browser-kit": { "version": "v4.2.3" }, - "symfony/cache": { - "version": "v4.2.3" - }, - "symfony/cache-contracts": { - "version": "v1.1.5" - }, "symfony/config": { "version": "v4.2.3" }, diff --git a/templates/_navbar.html.twig b/templates/_navbar.html.twig index 57331370..7719ab2b 100644 --- a/templates/_navbar.html.twig +++ b/templates/_navbar.html.twig @@ -52,7 +52,7 @@ {% trans %}info_providers.search.title{% endtrans %} - {% if settings_instance('generic_web_provider').enabled %} + {% if create_from_url_active() %}
  • diff --git a/templates/_turbo_control.html.twig b/templates/_turbo_control.html.twig index 281b21f2..a9f78c2e 100644 --- a/templates/_turbo_control.html.twig +++ b/templates/_turbo_control.html.twig @@ -1,23 +1,19 @@ {% block flashes %} {# Insert flashes #} - + {% endblock %} - - {# Insert info about when the sidebar trees were updated last time, so the sidebar_tree_controller can decide if it needs to reload the tree #} diff --git a/templates/admin/_delete_form.html.twig b/templates/admin/_delete_form.html.twig index fd653256..5a1d5a1a 100644 --- a/templates/admin/_delete_form.html.twig +++ b/templates/admin/_delete_form.html.twig @@ -5,7 +5,7 @@
    -
    +
    {% set delete_disabled = (not is_granted("delete", entity)) or (entity.group is defined and entity.id == 1) or entity == app.user %}
    @@ -20,7 +20,7 @@
    {% if entity.parent is defined %} -
    +
    diff --git a/templates/admin/_duplicate.html.twig b/templates/admin/_duplicate.html.twig index 1b18cd71..cf6e8cca 100644 --- a/templates/admin/_duplicate.html.twig +++ b/templates/admin/_duplicate.html.twig @@ -1,5 +1,5 @@
    diff --git a/templates/admin/_export_form.html.twig b/templates/admin/_export_form.html.twig index 07b00d43..7ef2e269 100644 --- a/templates/admin/_export_form.html.twig +++ b/templates/admin/_export_form.html.twig @@ -35,8 +35,8 @@
    -
    +
    - \ No newline at end of file + diff --git a/templates/admin/base_admin.html.twig b/templates/admin/base_admin.html.twig index e9fc0fb9..f19f4c44 100644 --- a/templates/admin/base_admin.html.twig +++ b/templates/admin/base_admin.html.twig @@ -129,7 +129,7 @@
    -
    +
    {{ form_widget(form.save) }}
    - - {# Include turbo control things, so we can still control page title and reloading #} - {% include "_turbo_control.html.twig" %}
    - {% endblock %} \ No newline at end of file + {% endblock %} diff --git a/templates/admin/category_admin.html.twig b/templates/admin/category_admin.html.twig index 3ddc1472..e20349e9 100644 --- a/templates/admin/category_admin.html.twig +++ b/templates/admin/category_admin.html.twig @@ -40,14 +40,12 @@
    {{ form_row(form.eda_info.reference_prefix) }} -
    -
    - {{ form_row(form.eda_info.visibility) }} -
    -
    + + {{ form_row(form.eda_info.visibility) }} +
    -
    +
    {{ form_widget(form.eda_info.exclude_from_bom) }} {{ form_widget(form.eda_info.exclude_from_board) }} {{ form_widget(form.eda_info.exclude_from_sim) }} @@ -55,7 +53,7 @@
    -
    +
    {% trans %}eda_info.kicad_section.title{% endtrans %}:
    diff --git a/templates/admin/currency_admin.html.twig b/templates/admin/currency_admin.html.twig index a5d59970..7e3d3bf8 100644 --- a/templates/admin/currency_admin.html.twig +++ b/templates/admin/currency_admin.html.twig @@ -10,10 +10,10 @@ {{ form_row(form.iso_code) }} {% if entity.isoCode %}
    - + {% trans %}currency.iso_code.caption{% endtrans %}: {{ entity.isoCode }} - + {% trans %}currency.symbol.caption{% endtrans %}: {{ entity.isoCode | currency_symbol }}
    @@ -21,7 +21,7 @@ {{ form_row(form.exchange_rate) }} {% if entity.inverseExchangeRate %} -

    +

    {{ '1'|format_currency(vars.base_currency()) }} = {{ entity.inverseExchangeRate.tofloat | format_currency(entity.isoCode, {fraction_digit: 5}) }}
    {{ '1'|format_currency(entity.isoCode) }} = {{ entity.exchangeRate.tofloat | format_currency(vars.base_currency(), {fraction_digit: 5}) }}

    diff --git a/templates/admin/footprint_admin.html.twig b/templates/admin/footprint_admin.html.twig index 1ed39e9f..305ffc21 100644 --- a/templates/admin/footprint_admin.html.twig +++ b/templates/admin/footprint_admin.html.twig @@ -28,7 +28,7 @@ {% block additional_panes %}
    -
    +
    {% trans %}eda_info.kicad_section.title{% endtrans %}:
    diff --git a/templates/admin/label_profile_admin.html.twig b/templates/admin/label_profile_admin.html.twig index 8702b18a..6b78d0df 100644 --- a/templates/admin/label_profile_admin.html.twig +++ b/templates/admin/label_profile_admin.html.twig @@ -27,7 +27,7 @@ {{ form_row(form.options.supported_element) }}
    {{ form_label(form.options.width) }} -
    +
    {{ form_widget(form.options.width) }} diff --git a/templates/admin/project_admin.html.twig b/templates/admin/project_admin.html.twig index d199b63c..11c41397 100644 --- a/templates/admin/project_admin.html.twig +++ b/templates/admin/project_admin.html.twig @@ -31,8 +31,8 @@ {{ form_row(form.status) }} {% if entity.id %}
    - -
    + +
    {% if entity.buildPart %} {{ entity.buildPart.name }} {% else %} diff --git a/templates/admin/storelocation_admin.html.twig b/templates/admin/storelocation_admin.html.twig index b01ecc73..4bc5d305 100644 --- a/templates/admin/storelocation_admin.html.twig +++ b/templates/admin/storelocation_admin.html.twig @@ -8,7 +8,7 @@ {% block additional_controls %} {% if entity.id %}
    -
    +
    {{ dropdown.profile_dropdown('storelocation', entity.id) }}
    diff --git a/templates/admin/update_manager/docker_progress.html.twig b/templates/admin/update_manager/docker_progress.html.twig new file mode 100644 index 00000000..e43b9afa --- /dev/null +++ b/templates/admin/update_manager/docker_progress.html.twig @@ -0,0 +1,235 @@ +{% extends "main_card.html.twig" %} + +{% block title %}{% trans %}update_manager.docker.progress_title{% endtrans %}{% endblock %} + +{% block card_title %} + + {% trans %}update_manager.docker.progress_title{% endtrans %} +{% endblock %} + +{% block card_content %} + +
    + + {# Progress Header #} +
    +
    +
    +
    + + + +
    +
    ~ ~ ~ ~ ~
    +
    +
    +

    + {% trans %}update_manager.docker.updating{% endtrans %} +

    +

    + {% trans %}update_manager.docker.updating_via_watchtower{% endtrans %} +

    +
    + + {# Progress Bar #} +
    +
    + 15% +
    +
    + + {# Current Step Info #} +
    + {% trans %}update_manager.docker.step_trigger{% endtrans %}: + {% trans %}update_manager.docker.step_trigger_desc{% endtrans %} +
    + + {# Success Message #} +
    + + {% trans %}update_manager.docker.success_message{% endtrans %} +
    + {% trans %}update_manager.docker.previous_version{% endtrans %}: + {{ previous_version }} + → + {% trans %}update_manager.docker.new_version{% endtrans %}: + ... +
    + + {# Timeout Message #} +
    + + {% trans %}update_manager.docker.timeout_message{% endtrans %} +
    + + {# Error Message #} +
    + + {% trans %}update_manager.progress.error{% endtrans %}: + +
    + + {# Steps Timeline - matches git progress style #} +
    +
    + {% trans %}update_manager.docker.steps{% endtrans %} +
    +
    +
      + {# Step 1: Trigger Watchtower #} +
    • + +
      + {% trans %}update_manager.docker.step_trigger{% endtrans %} +
      {% trans %}update_manager.docker.step_trigger_desc{% endtrans %} +
      + +
    • + + {# Step 2: Pull Image #} +
    • + +
      + {% trans %}update_manager.docker.step_pull{% endtrans %} +
      {% trans %}update_manager.docker.step_pull_desc{% endtrans %} +
      + +
    • + + {# Step 3: Stop Container #} +
    • + +
      + {% trans %}update_manager.docker.step_stop{% endtrans %} +
      {% trans %}update_manager.docker.step_stop_desc{% endtrans %} +
      + +
    • + + {# Step 4: Restart Container #} +
    • + +
      + {% trans %}update_manager.docker.step_restart{% endtrans %} +
      {% trans %}update_manager.docker.step_restart_desc{% endtrans %} +
      + +
    • + + {# Step 5: Health Check #} +
    • + +
      + {% trans %}update_manager.docker.step_health{% endtrans %} +
      {% trans %}update_manager.docker.step_health_desc{% endtrans %} +
      + +
    • + + {# Step 6: Verify Version #} +
    • + +
      + {% trans %}update_manager.docker.step_verify{% endtrans %} +
      {% trans %}update_manager.docker.step_verify_desc{% endtrans %} +
      + +
    • +
    +
    +
    + + {# Elapsed Time #} +
    + + {% trans %}update_manager.docker.elapsed{% endtrans %}: + 0s +
    + + {# Actions - shown after completion or timeout #} + + + {# Warning Notice #} +
    + + {% trans %}update_manager.docker.warning{% endtrans %}: + {% trans %}update_manager.docker.do_not_close{% endtrans %} +
    +
    +{% endblock %} diff --git a/templates/admin/update_manager/index.html.twig b/templates/admin/update_manager/index.html.twig index 44b9f8c0..3e4483a6 100644 --- a/templates/admin/update_manager/index.html.twig +++ b/templates/admin/update_manager/index.html.twig @@ -1,5 +1,7 @@ {% extends "main_card.html.twig" %} +{% import "helper.twig" as helper %} + {% block title %}Part-DB {% trans %}update_manager.title{% endtrans %}{% endblock %} {% block card_title %} @@ -7,60 +9,60 @@ {% endblock %} {% block card_content %} -
    +
    - {# Maintenance Mode Warning #} - {% if is_maintenance %} - - {% endif %} + {# Maintenance Mode Warning #} + {% if is_maintenance %} + + {% endif %} - {# Lock Warning #} - {% if is_locked %} - - {% endif %} + {# Lock Warning #} + {% if is_locked %} + + {% endif %} - {# Web Updates Disabled Warning #} - {% if web_updates_disabled %} - - {% endif %} + {# Web Updates Disabled Warning #} + {% if web_updates_disabled %} + + {% endif %} - {# Backup Restore Disabled Warning #} - {% if backup_restore_disabled %} - - {% endif %} + {# Backup Restore Disabled Warning #} + {% if backup_restore_disabled %} + + {% endif %} -
    - {# Current Version Card #} -
    -
    -
    - {% trans %}update_manager.current_installation{% endtrans %} -
    -
    - - +
    + {# Current Version Card #} +
    +
    +
    + {% trans %}update_manager.current_installation{% endtrans %} +
    +
    +
    + + + + + + + + + + + + + + + {% if status.git.is_git_install %} @@ -97,156 +113,252 @@ {% endif %} - - - - - -
    {% trans %}update_manager.version{% endtrans %} @@ -73,6 +75,20 @@ {{ status.installation.type_name }}
    {% trans %}update_manager.web_updates_allowed{% endtrans %}{{ helper.boolean_badge(not web_updates_disabled) }}
    {% trans %}update_manager.backup_restore_allowed{% endtrans %}{{ helper.boolean_badge(not backup_restore_disabled) }}
    {% trans %}update_manager.backup_download_allowed{% endtrans %}{{ helper.boolean_badge(not backup_download_disabled) }}
    {% trans %}update_manager.git_branch{% endtrans %}
    {% trans %}update_manager.auto_update_supported{% endtrans %} - {% if status.can_auto_update %} - - {% trans %}Yes{% endtrans %} - - {% else %} - - {% trans %}No{% endtrans %} - - {% endif %} -
    -
    - +
    -
    - {# Latest Version / Update Card #} -
    -
    -
    - {% if status.update_available %} - {% trans %}update_manager.new_version_available.title{% endtrans %} - {% else %} - {% trans %}update_manager.latest_release{% endtrans %} - {% endif %} -
    -
    - {% if status.latest_version %} -
    + {# Latest Version / Update Card #} +
    +
    +
    + {% if status.update_available %} + {% trans %}update_manager.new_version_available.title{% endtrans %} + {% else %} + {% trans %}update_manager.latest_release{% endtrans %} + {% endif %} +
    +
    + {% if status.latest_version %} +
    {{ status.latest_tag }} - {% if not status.update_available %} -

    - - {% trans %}update_manager.already_up_to_date{% endtrans %} + {% if not status.update_available %} +

    + + {% trans %}update_manager.already_up_to_date{% endtrans %} +

    + {% endif %} +
    + + {% if status.update_available and status.can_auto_update and validation.valid and not web_updates_disabled %} + {% if is_docker %} + {# Docker update via Watchtower #} +
    + + +
    + +
    + +
    + + +
    + +
    + + {% trans %}update_manager.docker.no_rollback_warning{% endtrans %} +
    +
    + {% else %} + {# Git update #} +
    + + + +
    + +
    + +
    + + +
    +
    + {% endif %} + {% endif %} + + {% if status.published_at %} +

    + + {% trans %}update_manager.released{% endtrans %}: {{ status.published_at|date('Y-m-d') }}

    {% endif %} -
    - - {% if status.update_available and status.can_auto_update and validation.valid and not web_updates_disabled %} -
    - - - -
    - -
    - -
    - - -
    -
    + {% else %} +
    + +

    {% trans %}update_manager.could_not_fetch_releases{% endtrans %}

    +
    {% endif %} - - {% if status.published_at %} -

    - - {% trans %}update_manager.released{% endtrans %}: {{ status.published_at|date('Y-m-d') }} -

    - {% endif %} - {% else %} -
    - -

    {% trans %}update_manager.could_not_fetch_releases{% endtrans %}

    +
    + {% if status.latest_tag %} + {% endif %}
    - {% if status.latest_tag %} - - {% endif %}
    -
    - {# Validation Issues #} - {% if not validation.valid %} - - {% endif %} + {# Validation Issues #} + {% if not validation.valid %} + + {% endif %} - {# Non-auto-update installations info #} - {% if not status.can_auto_update %} -
    -
    - {% trans%}update_manager.cant_auto_update{% endtrans%}: {{ status.installation.type_name }} -
    -

    {{ status.installation.update_instructions }}

    -
    - {% endif %} + {# Non-auto-update installations info #} + {% if not status.can_auto_update %} + {% if is_docker and not status.watchtower_configured|default(false) %} + {# Docker without Watchtower - show setup instructions #} +
    +
    + {% trans %}update_manager.docker.setup_title{% endtrans %} +
    +
    +

    {% trans %}update_manager.docker.setup_description{% endtrans %}

    -
    - {# Available Versions #} -
    -
    -
    - {% trans %}update_manager.available_versions{% endtrans %} +
    {% trans %}update_manager.docker.setup_step1{% endtrans %}
    +
    
    +                                # See documentation for full example: https://docs.part-db.de/installation/installation_docker.html
    +                                services:
    +                                  watchtower:
    +                                    image: ghcr.io/nicholas-fedor/watchtower:latest
    +                                    container_name: watchtower
    +                                    restart: unless-stopped
    +                                    volumes:
    +                                      - /var/run/docker.sock:/var/run/docker.sock
    +                                    environment:
    +                                      - WATCHTOWER_HTTP_API_UPDATE=true
    +                                      - WATCHTOWER_HTTP_API_TOKEN=your-secret-token
    +                                      - WATCHTOWER_LABEL_ENABLE=true
    +                                      - WATCHTOWER_CLEANUP=true
    +                            
    + +
    {% trans %}update_manager.docker.setup_step2{% endtrans %}
    +
    WATCHTOWER_API_URL=http://watchtower:8080
    +WATCHTOWER_API_TOKEN=your-secret-token
    + +
    + + {% trans %}update_manager.docker.setup_network_hint{% endtrans %} +
    +
    -
    -
    - - + {% elseif is_docker and status.watchtower_configured|default(false) and not status.watchtower_available|default(false) %} + {# Docker with Watchtower configured but not reachable #} +
    +
    + {% trans %}update_manager.docker.watchtower_unreachable_title{% endtrans %} +
    +

    {% trans %}update_manager.docker.watchtower_unreachable_description{% endtrans %}

    +
    + {% else %} + {# Other non-auto-update installations (ZIP, unknown) #} +
    +
    + {% trans%}update_manager.cant_auto_update{% endtrans%}: {{ status.installation.type_name }} +
    +

    {{ status.installation.update_instructions }}

    +
    + {% endif %} + {% endif %} + +
    + {# Available Versions #} +
    +
    +
    + {% trans %}update_manager.available_versions{% endtrans %} +
    +
    +
    +
    + - - + + {% for release in all_releases %} @@ -300,54 +416,69 @@ {% endfor %} - -
    {% trans %}update_manager.version{% endtrans %} {% trans %}update_manager.released{% endtrans %}
    @@ -269,6 +381,9 @@ {% if release.version != status.current_version and status.can_auto_update and validation.valid and not web_updates_disabled %} + {% if is_docker %} + {# Docker: version switching not supported, only update to latest via Watchtower #} + {% else %}
    + {% endif %} {% endif %}
    + + +
    -
    - {# Update History & Backups #} -
    -
    -
    - -
    -
    -
    -
    -
    - - + {# Update History & Backups #} +
    +
    +
    + +
    +
    +
    +
    +
    +
    + - - + + {% for log in update_logs %} - {% else %} @@ -357,22 +488,39 @@ {% endfor %} - -
    {% trans %}update_manager.date{% endtrans %} {% trans %}update_manager.log_file{% endtrans %}
    {{ log.date|date('Y-m-d H:i') }} {{ log.file }} - - - + +
    + + + + {% if is_granted('@system.manage_updates') %} +
    + + + +
    + {% endif %} +
    + + +
    -
    -
    -
    - - +
    + {% if is_granted('@system.manage_updates') and not is_locked %} +
    +
    + + + +
    + {% endif %} + {% if is_docker %} +
    + + {% trans %}update_manager.backup.docker_warning{% endtrans %} +
    + {% endif %} +
    +
    + - - + + {% for backup in backups %} @@ -410,8 +624,9 @@ {% endfor %} - -
    {% trans %}update_manager.date{% endtrans %} {% trans %}update_manager.file{% endtrans %} {% trans %}update_manager.size{% endtrans %}
    @@ -383,23 +531,89 @@ {{ (backup.size / 1024 / 1024)|number_format(1) }} MB - {% if status.can_auto_update and validation.valid and not backup_restore_disabled %} -
    - - - - -
    + {% endif %} + {% if not backup_restore_disabled and is_granted('@system.manage_updates') %} +
    + + + + +
    + {% endif %} + {% if is_granted('@system.manage_updates') %} +
    + + + +
    + {% endif %} + + + {% if not backup_download_disabled and is_granted('@system.manage_updates') %} + {# Per-backup download modal - no inline JS needed, CSP compatible with Turbo #} + {% endif %}
    + + +
    @@ -419,5 +634,5 @@
    -
    + {% endblock %} diff --git a/templates/admin/user_admin.html.twig b/templates/admin/user_admin.html.twig index 9b241e56..2092042f 100644 --- a/templates/admin/user_admin.html.twig +++ b/templates/admin/user_admin.html.twig @@ -50,7 +50,7 @@
    {% if entity.samlUser %} -
    +
    {% trans %}user.saml_user{% endtrans %}
    {% endif %} @@ -60,7 +60,7 @@ {{ form_row(form.disabled) }} {% if entity.id is not null %} -
    +

    {% trans %}user.edit.tfa.caption{% endtrans %}
    diff --git a/templates/attachment_list.html.twig b/templates/attachment_list.html.twig index 3ff45700..2b2f6c39 100644 --- a/templates/attachment_list.html.twig +++ b/templates/attachment_list.html.twig @@ -44,7 +44,7 @@ {{ form_row(filterForm.discard) }}
    -
    +
    diff --git a/templates/bundles/TwigBundle/Exception/error.html.twig b/templates/bundles/TwigBundle/Exception/error.html.twig index efdba462..936f5ca3 100644 --- a/templates/bundles/TwigBundle/Exception/error.html.twig +++ b/templates/bundles/TwigBundle/Exception/error.html.twig @@ -17,7 +17,7 @@ Consider yourself lucky. You found some rare error code.
    You should maybe inform your administrator about it... {% endblock %} - {% block further_actions %}

    You can try to Go Back or Visit the homepage.

    {% endblock %} + {% block further_actions %}

    You can try to Go Back or Visit the homepage.

    {% endblock %} {% block admin_contact %}

    If this error persists, please contact your {% if error_page_admin_email is not empty %} administrator. diff --git a/templates/bundles/TwigBundle/Exception/error500.html.twig b/templates/bundles/TwigBundle/Exception/error500.html.twig index 40a418c2..a4e5827b 100644 --- a/templates/bundles/TwigBundle/Exception/error500.html.twig +++ b/templates/bundles/TwigBundle/Exception/error500.html.twig @@ -17,7 +17,7 @@ Can not load frontend assets.

    Try following things:

    • Run yarn install and yarn build in Part-DB folder.
    • -
    • Run php bin/console cache:clear
    • +
    • Run php bin/console cache:clear and php bin/console cache:pool:clear --all
    {% elseif exception.class == "Doctrine\\DBAL\\Exception\\InvalidFieldNameException" or exception.class == "Doctrine\\DBAL\\Exception\\TableNotFoundException" @@ -26,21 +26,21 @@
    • Check if the DATABASE_URL in .env.local (or docker configure) is correct
    • Run php bin/console doctrine:migrations:migrate to upgrade database schema
    • -
    • Run php bin/console cache:clear
    • +
    • Run php bin/console cache:clear and php bin/console cache:pool:clear --all
    {% elseif exception.class == "Doctrine\\DBAL\\Exception\\DriverException" %} Error while executing database query.
    This is maybe caused by an old database schema.

    Try following things:

    • Check if the DATABASE_URL in .env.local (or docker configure) is correct
    • Run php bin/console doctrine:migrations:migrate to upgrade database schema (if upgrade is available)
    • -
    • Run php bin/console cache:clear
    • +
    • Run php bin/console cache:clear and php bin/console cache:pool:clear --all
    • If this issue persist, create a ticket at GitHub.
    {% else %} You could try following things, if this error is unexpected:
    • Check var/log/prod.log (or docker logs when Part-DB is running inside a docker container) for additional informations
    • -
    • Run php bin/console cache:clear to clear cache
    • +
    • Run php bin/console cache:clear and php bin/console cache:pool:clear --all to clear caches
    {% endif %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/form/collection_types_layout.html.twig b/templates/form/collection_types_layout.html.twig index 96b71bf0..818f0a0f 100644 --- a/templates/form/collection_types_layout.html.twig +++ b/templates/form/collection_types_layout.html.twig @@ -63,8 +63,8 @@
    {{ form_row(form.mountnames) }}
    - -
    + +
    {{ form_widget(form.price) }} {{ form_widget(form.priceCurrency) }} diff --git a/templates/form/extended_bootstrap_layout.html.twig b/templates/form/extended_bootstrap_layout.html.twig index 1227750c..01d30815 100644 --- a/templates/form/extended_bootstrap_layout.html.twig +++ b/templates/form/extended_bootstrap_layout.html.twig @@ -17,11 +17,11 @@ {% block form_label_class -%} - col-sm-3 + {{ col_label }} {%- endblock form_label_class %} {% block form_group_class -%} - col-sm-9 + {{ col_input }} {%- endblock form_group_class %} {% block si_unit_widget %} diff --git a/templates/helper.twig b/templates/helper.twig index 9e68d56c..e8c926e7 100644 --- a/templates/helper.twig +++ b/templates/helper.twig @@ -1,8 +1,8 @@ {% macro boolean(value) %} {% if value %} - {% trans %}bool.true{% endtrans %} + {% trans %}Yes{% endtrans %} {% else %} - {% trans %}bool.false{% endtrans %} + {% trans %}No{% endtrans %} {% endif %} {% endmacro %} @@ -14,9 +14,9 @@ {% macro bool_icon(bool) %} {% if bool %} - + {% else %} - + {% endif %} {% endmacro %} @@ -24,7 +24,7 @@ {% if value %} {% set class = class ~ ' bg-success' %} {% else %} - {% set class = class ~ ' bg-danger' %} + {% set class = class ~ ' bg-secondary' %} {% endif %} {{ _self.bool_icon(value) }} {{ _self.boolean(value) }} diff --git a/templates/info_providers/bulk_import/manage.html.twig b/templates/info_providers/bulk_import/manage.html.twig index 9bbed906..b31dd650 100644 --- a/templates/info_providers/bulk_import/manage.html.twig +++ b/templates/info_providers/bulk_import/manage.html.twig @@ -22,103 +22,130 @@

    - {% if jobs is not empty %} -
    - - - - - - - - - - - - - - - - {% for job in jobs %} - - - - - - - - - - - - {% endfor %} - -
    {% trans %}info_providers.bulk_import.job_name{% endtrans %}{% trans %}info_providers.bulk_import.parts_count{% endtrans %}{% trans %}info_providers.bulk_import.results_count{% endtrans %}{% trans %}info_providers.bulk_import.progress{% endtrans %}{% trans %}info_providers.bulk_import.status{% endtrans %}{% trans %}info_providers.bulk_import.created_by{% endtrans %}{% trans %}info_providers.bulk_import.created_at{% endtrans %}{% trans %}info_providers.bulk_import.completed_at{% endtrans %}{% trans %}info_providers.bulk_import.action.label{% endtrans %}
    - {{ job.displayNameKey|trans(job.displayNameParams) }} - {{ job.formattedTimestamp }} - {% if job.isInProgress %} - Active - {% endif %} - {{ job.partCount }}{{ job.resultCount }} -
    -
    -
    -
    -
    - {{ job.progressPercentage }}% -
    - - {% trans with {'%current%': job.completedPartsCount + job.skippedPartsCount, '%total%': job.partCount} %}info_providers.bulk_import.progress_label{% endtrans %} - -
    - {% if job.isPending %} - {% trans %}info_providers.bulk_import.status.pending{% endtrans %} - {% elseif job.isInProgress %} - {% trans %}info_providers.bulk_import.status.in_progress{% endtrans %} - {% elseif job.isCompleted %} - {% trans %}info_providers.bulk_import.status.completed{% endtrans %} - {% elseif job.isStopped %} - {% trans %}info_providers.bulk_import.status.stopped{% endtrans %} - {% elseif job.isFailed %} - {% trans %}info_providers.bulk_import.status.failed{% endtrans %} - {% endif %} - {{ job.createdBy.fullName(true) }}{{ job.createdAt|format_datetime('short') }} - {% if job.completedAt %} - {{ job.completedAt|format_datetime('short') }} - {% else %} - - - {% endif %} - -
    - {% if job.isInProgress or job.isCompleted or job.isStopped %} - - {% trans %}info_providers.bulk_import.view_results{% endtrans %} - - {% endif %} - {% if job.canBeStopped %} - - {% endif %} - {% if job.isCompleted or job.isFailed or job.isStopped %} - - {% endif %} -
    -
    -
    - {% else %} + {% if active_jobs is empty and finished_jobs is empty %} + {% else %} + {# Active Jobs #} + {% if active_jobs is not empty %} +
    + {% trans %}info_providers.bulk_import.active_jobs{% endtrans %} + {{ active_jobs|length }} +
    + {{ _self.job_table(active_jobs, false) }} + {% endif %} + + {# Finished Jobs (History) #} + {% if finished_jobs is not empty %} +
    + {% trans %}info_providers.bulk_import.finished_jobs{% endtrans %} + {{ finished_jobs|length }} +
    + {{ _self.job_table(finished_jobs, true) }} + {% endif %} {% endif %}
    {% endblock %} + +{% macro job_table(jobs, showCompletedAt) %} +
    + + + + + + + + + + + {% if showCompletedAt %} + + {% endif %} + + + + + {% for job in jobs %} + {{ _self.job_row(job, showCompletedAt) }} + {% endfor %} + +
    {% trans %}info_providers.bulk_import.job_name{% endtrans %}{% trans %}info_providers.bulk_import.parts_count{% endtrans %}{% trans %}info_providers.bulk_import.results_count{% endtrans %}{% trans %}info_providers.bulk_import.progress{% endtrans %}{% trans %}info_providers.bulk_import.status{% endtrans %}{% trans %}info_providers.bulk_import.created_by{% endtrans %}{% trans %}info_providers.bulk_import.created_at{% endtrans %}{% trans %}info_providers.bulk_import.completed_at{% endtrans %}{% trans %}info_providers.bulk_import.action.label{% endtrans %}
    +
    +{% endmacro %} + +{% macro job_row(job, showCompletedAt) %} + {% set showCompletedAt = showCompletedAt|default(false) %} + + + #{{ job.id }} - {{ job.displayNameKey|trans(job.displayNameParams) }} +
    {{ job.formattedTimestamp }} + + {{ job.partCount }} + {{ job.resultCount }} + +
    +
    +
    +
    +
    + {{ job.progressPercentage }}% +
    + + {% trans with {'%current%': job.completedPartsCount + job.skippedPartsCount, '%total%': job.partCount} %}info_providers.bulk_import.progress_label{% endtrans %} + + + + {% if job.isPending %} + {% trans %}info_providers.bulk_import.status.pending{% endtrans %} + {% elseif job.isInProgress %} + {% trans %}info_providers.bulk_import.status.in_progress{% endtrans %} + {% elseif job.isCompleted %} + {% trans %}info_providers.bulk_import.status.completed{% endtrans %} + {% elseif job.isStopped %} + {% trans %}info_providers.bulk_import.status.stopped{% endtrans %} + {% elseif job.isFailed %} + {% trans %}info_providers.bulk_import.status.failed{% endtrans %} + {% endif %} + + {{ job.createdBy.fullName(true) }} + {{ job.createdAt|format_datetime('short') }} + {% if showCompletedAt %} + + {% if job.completedAt %} + {{ job.completedAt|format_datetime('short') }} + {% else %} + - + {% endif %} + + {% endif %} + +
    + {% if job.isInProgress or job.isCompleted or job.isStopped %} + + {% trans %}info_providers.bulk_import.view_results{% endtrans %} + + {% endif %} + {% if job.canBeStopped %} + + {% endif %} + {% if job.isCompleted or job.isFailed or job.isStopped %} + + {% endif %} +
    + + +{% endmacro %} diff --git a/templates/info_providers/bulk_import/step2.html.twig b/templates/info_providers/bulk_import/step2.html.twig index 559ca20a..e68202e0 100644 --- a/templates/info_providers/bulk_import/step2.html.twig +++ b/templates/info_providers/bulk_import/step2.html.twig @@ -9,22 +9,42 @@ {% block card_title %} {% trans %}info_providers.bulk_import.step2.title{% endtrans %} - {{ job.displayNameKey|trans(job.displayNameParams) }} - {{ job.formattedTimestamp }} + #{{ job.id }} - {{ job.displayNameKey|trans(job.displayNameParams) }} {% endblock %} {% block card_content %} + + + + {% if job.isCompleted %} + + {% endif %} +
    -
    {{ job.displayNameKey|trans(job.displayNameParams) }} - {{ job.formattedTimestamp }}
    +
    #{{ job.id }} - {{ job.displayNameKey|trans(job.displayNameParams) }}
    {{ job.partCount }} {% trans %}info_providers.bulk_import.parts{% endtrans %} • {{ job.resultCount }} {% trans %}info_providers.bulk_import.results{% endtrans %} • @@ -95,6 +115,13 @@ {% trans %}info_providers.bulk_import.research.all_pending{% endtrans %} +
    @@ -181,39 +208,74 @@ - {% for result in part_result.searchResults %} + {% set sortedResults = part_result.resultsSortedByPriority %} + {% for result in sortedResults %} {# @var result \App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultDTO #} {% set dto = result.searchResult %} {% set localPart = result.localPart %} - + {% set isTopResult = loop.first %} + - + {% if dto.preview_image_url %} + + {% endif %} + {# Check for matches against source keyword (what was searched) #} + {% set sourceKw = result.sourceKeyword|default('')|lower %} + {% set nameMatch = sourceKw is not empty and dto.name is not null and dto.name|lower == sourceKw %} + {% set mpnMatch = sourceKw is not empty and dto.mpn is not null and dto.mpn|lower == sourceKw %} + {% set spnMatch = sourceKw is not empty and dto.provider_id is not null and dto.provider_id|lower == sourceKw %} + {% set anyMatch = nameMatch or mpnMatch or spnMatch %} {% if dto.provider_url is not null %} - {{ dto.name }} + {{ dto.name }} {% else %} - {{ dto.name }} + {{ dto.name }} + {% endif %} + {% if nameMatch %} + {% endif %} {% if dto.mpn is not null %} -
    {{ dto.mpn }} +
    {{ dto.mpn }}
    + {% if mpnMatch %} + MPN + {% endif %} {% endif %} {{ dto.description }} {{ dto.manufacturer ?? '' }} {{ info_provider_label(dto.provider_key)|default(dto.provider_key) }} -
    {{ dto.provider_id }} +
    {{ dto.provider_id }} + {% if spnMatch %} + SPN + {% endif %} - {{ result.sourceField ?? 'unknown' }} + {% if anyMatch %} + {% trans %}info_providers.bulk_import.match{% endtrans %} + {% else %} + {{ result.sourceField ?? 'unknown' }} + {% endif %} {% if result.sourceKeyword %} -
    {{ result.sourceKeyword }} - {% endif %} +
    {{ result.sourceKeyword }} + {% endif %}
    + {% if not isCompleted %} + + {% endif %} {% set updateHref = path('info_providers_update_part', {'id': part.id, 'providerKey': dto.provider_key, 'providerId': dto.provider_id}) ~ '?jobId=' ~ job.id %} diff --git a/templates/info_providers/from_url/from_url.html.twig b/templates/info_providers/from_url/from_url.html.twig index 5aad1a03..49d4b116 100644 --- a/templates/info_providers/from_url/from_url.html.twig +++ b/templates/info_providers/from_url/from_url.html.twig @@ -12,10 +12,26 @@ {% endblock %} {% block card_content %} -

    {% trans %}info_providers.from_url.help{% endtrans %}

    +

    {% trans %}info_providers.from_url.help{% endtrans %}

    {{ form_start(form) }} {{ form_row(form.url) }} + + {{ form_row(form.method) }} + +
    + +
    +
    + {{ form_row(form.no_cache) }} + {{ form_row(form.skip_delegation) }} +
    +
    + {{ form_row(form.submit) }} {{ form_end(form) }} {% endblock %} diff --git a/templates/info_providers/search/part_search.html.twig b/templates/info_providers/search/part_search.html.twig index a5602618..9e1f0834 100644 --- a/templates/info_providers/search/part_search.html.twig +++ b/templates/info_providers/search/part_search.html.twig @@ -28,11 +28,24 @@ {{ form_row(form.providers) }}
    - + + +
    +
    + {{ form_row(form.no_cache_search) }} + {{ form_row(form.no_cache_details) }} +
    +
    + {{ form_row(form.submit) }} {{ form_end(form) }} @@ -116,16 +129,16 @@ {% if update_target %} {# We update an existing part #} {% set href = path('info_providers_update_part', - {'providerKey': dto.provider_key, 'providerId': dto.provider_id, 'id': update_target.iD}) %} + {'providerKey': dto.provider_key, 'providerId': dto.provider_id, 'id': update_target.iD, 'no_cache': no_cache_details ? 1 : null}) %} {% else %} {# Create a fresh part #} {% set href = path('info_providers_create_part', - {'providerKey': dto.provider_key, 'providerId': dto.provider_id}) %} + {'providerKey': dto.provider_key, 'providerId': dto.provider_id, 'no_cache': no_cache_details ? 1 : null}) %} {% endif %} {# If we have no local part, then we can just show the create button #} {% if localPart is null %} + target="_blank" title="{% trans %}part.create.btn{% endtrans %}"> {% else %} {# Otherwise add a button group with all three buttons #} @@ -139,7 +152,7 @@ target="_blank" title="{% trans %}info_providers.search.show_existing_part{% endtrans %}"> - diff --git a/templates/info_providers/settings/provider_settings.html.twig b/templates/info_providers/settings/provider_settings.html.twig index 86e5bc9b..db942f8a 100644 --- a/templates/info_providers/settings/provider_settings.html.twig +++ b/templates/info_providers/settings/provider_settings.html.twig @@ -8,7 +8,7 @@ {% block card_title %} {% trans %}info_providers.settings.title{% endtrans %}: {{ info_provider_info.name }}{% endblock %} {% block card_content %} -
    +

    {% if info_provider_info.url is defined %} {{ info_provider_info.name }} @@ -23,7 +23,7 @@ {{ form_start(form) }}
    -
    +
    {{ form_help(form) }}
    diff --git a/templates/label_system/dialog.html.twig b/templates/label_system/dialog.html.twig index 11877a4c..532a4b63 100644 --- a/templates/label_system/dialog.html.twig +++ b/templates/label_system/dialog.html.twig @@ -36,7 +36,7 @@ {{ form_row(form.options.supported_element) }}
    {{ form_label(form.options.width) }} -
    +
    {{ form_widget(form.options.width) }} @@ -59,8 +59,8 @@
    - -
    + +
    {{ profile.name ?? '-' }} {% if profile and is_granted("edit", profile) %}
    -
    +
    diff --git a/templates/parts/edit/_eda.html.twig b/templates/parts/edit/_eda.html.twig index 1383871e..12299add 100644 --- a/templates/parts/edit/_eda.html.twig +++ b/templates/parts/edit/_eda.html.twig @@ -4,7 +4,7 @@ {{ form_row(form.eda_info.visibility) }}
    -
    +
    {{ form_widget(form.eda_info.exclude_from_bom) }} {{ form_widget(form.eda_info.exclude_from_board) }} {{ form_widget(form.eda_info.exclude_from_sim) }} @@ -12,7 +12,7 @@
    -
    +
    {% trans %}eda_info.kicad_section.title{% endtrans %}:
    diff --git a/templates/parts/edit/_main.html.twig b/templates/parts/edit/_main.html.twig index f153d878..06c71106 100644 --- a/templates/parts/edit/_main.html.twig +++ b/templates/parts/edit/_main.html.twig @@ -1,7 +1,7 @@ {{ form_row(form.name) }} {% if part.category is not null and part.category.partnameHint is not empty %}
    -
    +

    {% trans %}part.edit.name.category_hint{% endtrans %}: {{ part.category.partnameHint }}

    diff --git a/templates/parts/edit/edit_form_styles.html.twig b/templates/parts/edit/edit_form_styles.html.twig index 9e989c92..44c8d89c 100644 --- a/templates/parts/edit/edit_form_styles.html.twig +++ b/templates/parts/edit/edit_form_styles.html.twig @@ -107,7 +107,7 @@ {% set id = 'collapse_' ~ random() %} -
    @@ -142,7 +142,7 @@
    {{ form_label(form.file) }} -
    +
    {{ form_widget(form.file) }} {{ form_errors(form.file) }} {% trans %}attachment.max_file_size{% endtrans %}: {{ max_upload_size | format_bytes }} diff --git a/templates/parts/edit/edit_part_info.html.twig b/templates/parts/edit/edit_part_info.html.twig index 28a88132..5b6d288a 100644 --- a/templates/parts/edit/edit_part_info.html.twig +++ b/templates/parts/edit/edit_part_info.html.twig @@ -140,7 +140,7 @@
    -
    +
    {{ form_widget(form.save) }} +
    + + + {{ form_end(add_lot_form) }} +
    +
    +
    +{% endif %} diff --git a/templates/parts/info/_order_infos.html.twig b/templates/parts/info/_order_infos.html.twig index 59b904df..9aa9d888 100644 --- a/templates/parts/info/_order_infos.html.twig +++ b/templates/parts/info/_order_infos.html.twig @@ -47,17 +47,17 @@ {{ detail.price | format_money(detail.currency) }} / {{ detail.PriceRelatedQuantity | format_amount(part.partUnit) }} - {% set tmp = pricedetail_helper.convertMoneyToCurrency(detail.price, detail.currency) %} + {% set tmp = pricedetail_helper.convertMoneyToCurrency(detail.price, detail.currency, app.user.currency ?? null) %} {% if detail.currency != (app.user.currency ?? null) and tmp is not null and tmp.GreaterThan(0) %} - ({{ pricedetail_helper.convertMoneyToCurrency(detail.price, detail.currency, app.user.currency ?? null) | format_money(app.user.currency ?? null) }}) + ({{ tmp | format_money(app.user.currency ?? null) }}) {% endif %} {{- helper.vat_text(detail.includesVAT) -}} {{ detail.PricePerUnit | format_money(detail.currency) }} - {% set tmp = pricedetail_helper.convertMoneyToCurrency(detail.PricePerUnit, detail.currency) %} + {% set tmp = pricedetail_helper.convertMoneyToCurrency(detail.PricePerUnit, detail.currency, app.user.currency ?? null) %} {% if detail.currency != (app.user.currency ?? null) and tmp is not null and tmp.GreaterThan(0) %} - ({{ pricedetail_helper.convertMoneyToCurrency(detail.PricePerUnit, detail.currency, app.user.currency ?? null) | format_money(app.user.currency ?? null) }}) + ({{ tmp | format_money(app.user.currency ?? null) }}) {% endif %} {{- helper.vat_text(detail.includesVAT) -}} diff --git a/templates/parts/info/_part_lots.html.twig b/templates/parts/info/_part_lots.html.twig index 70e5dc4e..7e53aec1 100644 --- a/templates/parts/info/_part_lots.html.twig +++ b/templates/parts/info/_part_lots.html.twig @@ -3,6 +3,7 @@ {% include "parts/info/_withdraw_modal.html.twig" %} {% include "parts/info/_stocktake_modal.html.twig" %} +{% include "parts/info/_add_lot_modal.html.twig" %}
    @@ -126,3 +127,10 @@
    + +{% if add_lot_form is not null %} + +{% endif %} diff --git a/templates/parts/info/_stocktake_modal.html.twig b/templates/parts/info/_stocktake_modal.html.twig index 5e8c1ae5..5cde8a08 100644 --- a/templates/parts/info/_stocktake_modal.html.twig +++ b/templates/parts/info/_stocktake_modal.html.twig @@ -13,39 +13,39 @@
    -

    + {% set n = number_of_builds ?? 1 %} + {% set total_build_price = buildHelper.roundedTotalBuildPrice(project, n, app.user.currency ?? null) %} + {% set unit_build_price = buildHelper.roundedUnitBuildPrice(project, n, app.user.currency ?? null) %} + {% if total_build_price is not null %} +
    +
    + + + {% trans %}project.info.total_build_price{% endtrans %}: + {{ total_build_price | format_money(app.user.currency ?? null, 2) }} + {% if n > 1 and unit_build_price is not null %} + + ({% trans %}project.info.per_unit_price{% endtrans %}: {{ unit_build_price | format_money(app.user.currency ?? null, 2) }}) + + {% endif %} + +
    +
    + {% endif %} +
    +
    + {% trans %}project.builds.number_of_builds{% endtrans %} + + +
    +
    {% if project.children is not empty %}
    @@ -69,9 +95,9 @@
    {% if project.comment is not empty %} -

    -

    {% trans %}comment.label{% endtrans %}:
    - {{ project.comment|format_markdown }} -

    +
    +
    {% trans %}comment.label{% endtrans %}:
    + {{ project.comment|format_markdown }} +
    {% endif %} -
    \ No newline at end of file +
    diff --git a/templates/security/2fa_base_form.html.twig b/templates/security/2fa_base_form.html.twig index 847048e4..e6ec99bb 100644 --- a/templates/security/2fa_base_form.html.twig +++ b/templates/security/2fa_base_form.html.twig @@ -33,7 +33,7 @@ {% if displayTrustedOption %}
    -
    +
    @@ -48,11 +48,11 @@ {% block submit_btn %}
    -
    +
    {% trans %}user.logout{% endtrans %}
    {% endblock %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/security/login.html.twig b/templates/security/login.html.twig index b8a0df1d..0489f568 100644 --- a/templates/security/login.html.twig +++ b/templates/security/login.html.twig @@ -20,55 +20,25 @@ {% endblock %} {% block card_content %} -
    - + {% if saml_enabled %} +
    + {% trans %}login.sso_saml_login{% endtrans %} - - - {% if saml_enabled %} -
    - {% trans %}login.sso_saml_login{% endtrans %} - -

    {% trans %}login.local_login_hint{% endtrans %}

    -
    - - {% endif %} - -
    - -
    - -
    -
    -
    - -
    - -
    +

    {% trans %}login.local_login_hint{% endtrans %}

    + {% endif %} -
    -
    -
    - - -
    -
    -
    + {{ form_start(form) }} -
    -
    - -
    -
    - + {{ form_row(form._username) }} + {{ form_row(form._password) }} + {{ form_row(form._remember_me) }} + {{ form_row(form.submit) }} + + {{ form_end(form) }} {% if allow_email_pw_reset %} - {% trans %}pw_reset.password_forget{% endtrans %} + {% trans %}pw_reset.password_forget{% endtrans %} {% endif %} {% endblock %} diff --git a/templates/settings/kicad_list_editor.html.twig b/templates/settings/kicad_list_editor.html.twig new file mode 100644 index 00000000..33ff00ec --- /dev/null +++ b/templates/settings/kicad_list_editor.html.twig @@ -0,0 +1,28 @@ +{% extends "main_card.html.twig" %} + +{% block title %}{% trans %}settings.misc.kicad_eda.editor.title{% endtrans %}{% endblock %} + +{% block card_title %} {% trans %}settings.misc.kicad_eda.editor.title{% endtrans %}{% endblock %} + +{% block card_content %} +

    + {% trans %}settings.misc.kicad_eda.editor.description{% endtrans %} +

    + + {{ form_start(form) }} + {{ form_row(form.useCustomList) }} + +
    +
    + {{ form_row(form.customFootprints) }} + {{ form_row(form.customSymbols) }} +
    +
    + {{ form_row(form.defaultFootprints) }} + {{ form_row(form.defaultSymbols) }} +
    +
    + + {{ form_row(form.save) }} + {{ form_end(form) }} +{% endblock %} diff --git a/templates/settings/settings.html.twig b/templates/settings/settings.html.twig index 96e0f209..325118d6 100644 --- a/templates/settings/settings.html.twig +++ b/templates/settings/settings.html.twig @@ -38,17 +38,26 @@ {% if section_widget.vars.embedded_settings_metadata is defined %} {# Check if we have nested embedded settings or not #}
    - + {{ (section_widget.vars.label ?? section_widget.vars.name|humanize)|trans }}
    -
    +
    {{ form_help(section_widget) }} {{ form_errors(section_widget) }}
    {{ form_widget(section_widget) }} + {% if section_widget.vars.name == 'kicadEDA' %} + + {% endif %}
    {% if not loop.last %}
    diff --git a/templates/users/_2fa_settings.html.twig b/templates/users/_2fa_settings.html.twig index 80392c17..da0e61ff 100644 --- a/templates/users/_2fa_settings.html.twig +++ b/templates/users/_2fa_settings.html.twig @@ -37,11 +37,11 @@ {{ form_start(google_form, { 'attr': google_form_attr}) }} {% if not tfa_google.enabled %} -
    +
    {% trans %}tfa_google.disabled_message{% endtrans %}
    -
    +
    {{ tfa_google.qrContent }}
    @@ -55,7 +55,7 @@
    -
    +
    @@ -72,7 +72,7 @@ {{ form_row(google_form.google_confirmation) }} {% else %} -
    +
    {% trans %}tfa_google.enabled_message{% endtrans %}
    {% endif %} @@ -81,7 +81,7 @@
    {% if user.backupCodes is empty %} -
    +
    {% trans %}tfa_backup.disabled{% endtrans %}
    {% trans %}tfa_backup.explanation{% endtrans %}
    @@ -89,19 +89,19 @@ {% set backup_form_attr = { 'data-delete-form': true, 'data-controller': 'elements--delete-btn', 'data-action': 'submit->elements--delete-btn#submit', 'data-delete-title': 'tfa_backup.reset_codes.confirm_title' | trans, 'data-delete-message': 'tfa_backup.reset_codes.confirm_message' | trans} %} {{ form_start(backup_form, { 'attr': backup_form_attr}) }} -
    +
    {% trans %}tfa_backup.enabled{% endtrans %}
    {% trans %}tfa_backup.explanation{% endtrans %}
    -
    +

    {% trans %}tfa_backup.remaining_tokens{% endtrans %}: {{ user.backupCodes | length }}

    {% trans %}tfa_backup.generation_date{% endtrans %}: {{ user.backupCodesGenerationDate | format_datetime }}

    -
    + -
    +
    {{ form_widget(backup_form.reset_codes) }}
    {{ form_end(backup_form) }} diff --git a/templates/users/user_settings.html.twig b/templates/users/user_settings.html.twig index 0f218a1b..36cde643 100644 --- a/templates/users/user_settings.html.twig +++ b/templates/users/user_settings.html.twig @@ -28,7 +28,7 @@ {{ form_row(settings_form.showEmailOnProfile) }} {{ form_row(settings_form.avatar_file) }}
    -
    +
    {% if user.masterPictureAttachment %} avatar {% endif %} diff --git a/tests/API/Endpoints/PartLotsEndpointTest.php b/tests/API/Endpoints/PartLotsEndpointTest.php index 70f1f9ab..0d48d1e7 100644 --- a/tests/API/Endpoints/PartLotsEndpointTest.php +++ b/tests/API/Endpoints/PartLotsEndpointTest.php @@ -47,6 +47,32 @@ final class PartLotsEndpointTest extends CrudEndpointTestCase $this->_testGetItem(2); } + public function testFilterByUserBarcode(): void + { + $response = self::createAuthenticatedClient()->request('GET', '/api/part_lots?user_barcode=lot2_vendor_barcode'); + + self::assertResponseIsSuccessful(); + self::assertJsonContains([ + 'hydra:totalItems' => 1, + ]); + + $json = $response->toArray(); + self::assertSame('/api/part_lots/2', $json['hydra:member'][0]['@id']); + } + + public function testFilterByUserBarcodeUsingWildcard(): void + { + $response = self::createAuthenticatedClient()->request('GET', '/api/part_lots?user_barcode=lot2_%'); + + self::assertResponseIsSuccessful(); + self::assertJsonContains([ + 'hydra:totalItems' => 1, + ]); + + $json = $response->toArray(); + self::assertSame('/api/part_lots/2', $json['hydra:member'][0]['@id']); + } + public function testCreateItem(): void { $this->_testPostItem([ diff --git a/tests/ApplicationAvailabilityFunctionalTest.php b/tests/ApplicationAvailabilityFunctionalTest.php index c7449411..3bb222d0 100644 --- a/tests/ApplicationAvailabilityFunctionalTest.php +++ b/tests/ApplicationAvailabilityFunctionalTest.php @@ -60,6 +60,7 @@ final class ApplicationAvailabilityFunctionalTest extends WebTestCase //User related things yield ['/user/settings']; yield ['/user/info']; + yield ['/settings/misc/kicad-lists']; //Login/logout yield ['/login']; 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/BulkInfoProviderImportControllerTest.php b/tests/Controller/BulkInfoProviderImportControllerTest.php index ec3629fe..d768f55c 100644 --- a/tests/Controller/BulkInfoProviderImportControllerTest.php +++ b/tests/Controller/BulkInfoProviderImportControllerTest.php @@ -589,6 +589,296 @@ final class BulkInfoProviderImportControllerTest extends WebTestCase return $parts; } + public function testQuickApplyWithNonExistentJob(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $client->request('POST', '/en/tools/bulk_info_provider_import/job/999999/part/1/quick-apply'); + + $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + $response = json_decode($client->getResponse()->getContent(), true); + $this->assertArrayHasKey('error', $response); + } + + public function testQuickApplyWithNonExistentPart(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $entityManager = $client->getContainer()->get('doctrine')->getManager(); + $userRepository = $entityManager->getRepository(User::class); + $user = $userRepository->findOneBy(['name' => 'admin']); + + if (!$user) { + $this->markTestSkipped('Admin user not found in fixtures'); + } + + $parts = $this->getTestParts($entityManager, [1]); + + $job = new BulkInfoProviderImportJob(); + $job->setCreatedBy($user); + foreach ($parts as $part) { + $job->addPart($part); + } + $job->setStatus(BulkImportJobStatus::IN_PROGRESS); + $job->setSearchResults(new BulkSearchResponseDTO([])); + + $entityManager->persist($job); + $entityManager->flush(); + + $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $job->getId() . '/part/999999/quick-apply'); + + $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + + // Clean up + $entityManager->remove($job); + $entityManager->flush(); + } + + public function testQuickApplyWithNoSearchResults(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $entityManager = $client->getContainer()->get('doctrine')->getManager(); + $userRepository = $entityManager->getRepository(User::class); + $user = $userRepository->findOneBy(['name' => 'admin']); + + if (!$user) { + $this->markTestSkipped('Admin user not found in fixtures'); + } + + $parts = $this->getTestParts($entityManager, [1]); + + $job = new BulkInfoProviderImportJob(); + $job->setCreatedBy($user); + foreach ($parts as $part) { + $job->addPart($part); + } + $job->setStatus(BulkImportJobStatus::IN_PROGRESS); + // Empty search results - no provider results for any parts + $job->setSearchResults(new BulkSearchResponseDTO([ + new BulkSearchPartResultsDTO(part: $parts[0], searchResults: [], errors: []) + ])); + + $entityManager->persist($job); + $entityManager->flush(); + + // Quick apply without providing providerKey/providerId and no search results available + $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $job->getId() . '/part/1/quick-apply', [], [], [ + 'CONTENT_TYPE' => 'application/json', + ], json_encode([])); + + $this->assertResponseStatusCodeSame(Response::HTTP_BAD_REQUEST); + $response = json_decode($client->getResponse()->getContent(), true); + $this->assertFalse($response['success']); + + // Clean up + $entityManager->remove($job); + $entityManager->flush(); + } + + public function testQuickApplyAccessControl(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $entityManager = $client->getContainer()->get('doctrine')->getManager(); + $userRepository = $entityManager->getRepository(User::class); + $admin = $userRepository->findOneBy(['name' => 'admin']); + $readonly = $userRepository->findOneBy(['name' => 'noread']); + + if (!$admin || !$readonly) { + $this->markTestSkipped('Required test users not found in fixtures'); + } + + $parts = $this->getTestParts($entityManager, [1]); + + // Create job owned by readonly user + $job = new BulkInfoProviderImportJob(); + $job->setCreatedBy($readonly); + foreach ($parts as $part) { + $job->addPart($part); + } + $job->setStatus(BulkImportJobStatus::IN_PROGRESS); + $job->setSearchResults(new BulkSearchResponseDTO([])); + + $entityManager->persist($job); + $entityManager->flush(); + + // Admin tries to quick apply on readonly user's job - should fail + $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $job->getId() . '/part/1/quick-apply'); + $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + + // Clean up + $jobId = $job->getId(); + $entityManager->clear(); + $persistedJob = $entityManager->find(BulkInfoProviderImportJob::class, $jobId); + if ($persistedJob) { + $entityManager->remove($persistedJob); + $entityManager->flush(); + } + } + + public function testQuickApplyAllWithNonExistentJob(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $client->request('POST', '/en/tools/bulk_info_provider_import/job/999999/quick-apply-all'); + + $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + $response = json_decode($client->getResponse()->getContent(), true); + $this->assertArrayHasKey('error', $response); + } + + public function testQuickApplyAllWithNoResults(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $entityManager = $client->getContainer()->get('doctrine')->getManager(); + $userRepository = $entityManager->getRepository(User::class); + $user = $userRepository->findOneBy(['name' => 'admin']); + + if (!$user) { + $this->markTestSkipped('Admin user not found in fixtures'); + } + + $parts = $this->getTestParts($entityManager, [1, 2]); + + $job = new BulkInfoProviderImportJob(); + $job->setCreatedBy($user); + foreach ($parts as $part) { + $job->addPart($part); + } + $job->setStatus(BulkImportJobStatus::IN_PROGRESS); + // Empty search results for all parts + $job->setSearchResults(new BulkSearchResponseDTO([ + new BulkSearchPartResultsDTO(part: $parts[0], searchResults: [], errors: []), + new BulkSearchPartResultsDTO(part: $parts[1], searchResults: [], errors: []), + ])); + + $entityManager->persist($job); + $entityManager->flush(); + + $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $job->getId() . '/quick-apply-all'); + + $this->assertResponseStatusCodeSame(Response::HTTP_OK); + $response = json_decode($client->getResponse()->getContent(), true); + $this->assertTrue($response['success']); + $this->assertEquals(0, $response['applied']); + $this->assertEquals(2, $response['no_results']); + + // Clean up + $entityManager->remove($job); + $entityManager->flush(); + } + + public function testQuickApplyAllAccessControl(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $entityManager = $client->getContainer()->get('doctrine')->getManager(); + $userRepository = $entityManager->getRepository(User::class); + $readonly = $userRepository->findOneBy(['name' => 'noread']); + + if (!$readonly) { + $this->markTestSkipped('Required test users not found in fixtures'); + } + + $parts = $this->getTestParts($entityManager, [1]); + + $job = new BulkInfoProviderImportJob(); + $job->setCreatedBy($readonly); + foreach ($parts as $part) { + $job->addPart($part); + } + $job->setStatus(BulkImportJobStatus::IN_PROGRESS); + $job->setSearchResults(new BulkSearchResponseDTO([])); + + $entityManager->persist($job); + $entityManager->flush(); + + // Admin tries quick apply all on readonly user's job + $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $job->getId() . '/quick-apply-all'); + $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + + // Clean up + $jobId = $job->getId(); + $entityManager->clear(); + $persistedJob = $entityManager->find(BulkInfoProviderImportJob::class, $jobId); + if ($persistedJob) { + $entityManager->remove($persistedJob); + $entityManager->flush(); + } + } + + public function testStep2TemplateRenderingWithQuickApplyButtons(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $entityManager = static::getContainer()->get('doctrine')->getManager(); + $partRepository = $entityManager->getRepository(Part::class); + $part = $partRepository->find(1); + + if (!$part) { + $this->markTestSkipped('Test part with ID 1 not found in fixtures'); + } + + $userRepository = $entityManager->getRepository(User::class); + $user = $userRepository->findOneBy(['name' => 'admin']); + + if (!$user) { + $this->markTestSkipped('Admin user not found in fixtures'); + } + + $job = new BulkInfoProviderImportJob(); + $job->setCreatedBy($user); + $job->addPart($part); + $job->setStatus(BulkImportJobStatus::IN_PROGRESS); + + $searchResults = new BulkSearchResponseDTO(partResults: [ + new BulkSearchPartResultsDTO(part: $part, + searchResults: [new BulkSearchPartResultDTO( + searchResult: new SearchResultDTO(provider_key: 'test_provider', provider_id: 'TEST123', name: 'Test Component', description: 'Test description', manufacturer: 'Test Mfg', mpn: 'TEST-MPN', provider_url: 'https://example.com/test', preview_image_url: null), + sourceField: 'mpn', + sourceKeyword: 'TEST-MPN', + )] + ) + ]); + + $job->setSearchResults($searchResults); + + $entityManager->persist($job); + $entityManager->flush(); + + $client->request('GET', '/tools/bulk_info_provider_import/step2/' . $job->getId()); + + if ($client->getResponse()->isRedirect()) { + $client->followRedirect(); + } + + self::assertResponseStatusCodeSame(Response::HTTP_OK); + + $content = (string) $client->getResponse()->getContent(); + // Verify quick apply buttons are rendered (Stimulus renders camelCase as kebab-case data attributes) + $this->assertStringContainsString('quick-apply-url-value', $content); + $this->assertStringContainsString('quick-apply-all-url-value', $content); + + // Clean up + $jobId = $job->getId(); + $entityManager->clear(); + $jobToRemove = $entityManager->find(BulkInfoProviderImportJob::class, $jobId); + if ($jobToRemove) { + $entityManager->remove($jobToRemove); + $entityManager->flush(); + } + } + public function testStep1Form(): void { $client = static::createClient(); @@ -735,13 +1025,9 @@ final class BulkInfoProviderImportControllerTest extends WebTestCase new BulkSearchFieldMappingDTO('test_supplier_spn', ['test'], 2) ]; - // The service should be able to process the request and throw an exception when no results are found - try { - $bulkService->performBulkSearch([$part], $fieldMappings, false); - $this->fail('Expected RuntimeException to be thrown when no search results are found'); - } catch (\RuntimeException $e) { - $this->assertStringContainsString('No search results found', $e->getMessage()); - } + // The service should return an empty response DTO when no results are found + $response = $bulkService->performBulkSearch([$part], $fieldMappings, false); + $this->assertFalse($response->hasAnyResults()); } public function testBulkInfoProviderServiceBatchProcessing(): void @@ -765,13 +1051,9 @@ final class BulkInfoProviderImportControllerTest extends WebTestCase new BulkSearchFieldMappingDTO('empty', ['test'], 1) ]; - // The service should be able to process the request and throw an exception when no results are found - try { - $response = $bulkService->performBulkSearch([$part], $fieldMappings, false); - $this->fail('Expected RuntimeException to be thrown when no search results are found'); - } catch (\RuntimeException $e) { - $this->assertStringContainsString('No search results found', $e->getMessage()); - } + // The service should return an empty response DTO when no results are found + $response = $bulkService->performBulkSearch([$part], $fieldMappings, false); + $this->assertFalse($response->hasAnyResults()); } public function testBulkInfoProviderServicePrefetchDetails(): void @@ -887,4 +1169,684 @@ final class BulkInfoProviderImportControllerTest extends WebTestCase $entityManager->remove($job); $entityManager->flush(); } + + /** + * Helper to create a job with search results for testing. + */ + private function createJobWithSearchResults(object $entityManager, object $user, array $parts, string $status = 'in_progress'): BulkInfoProviderImportJob + { + $job = new BulkInfoProviderImportJob(); + $job->setCreatedBy($user); + foreach ($parts as $part) { + $job->addPart($part); + } + + $statusEnum = match ($status) { + 'pending' => BulkImportJobStatus::PENDING, + 'completed' => BulkImportJobStatus::COMPLETED, + 'stopped' => BulkImportJobStatus::STOPPED, + default => BulkImportJobStatus::IN_PROGRESS, + }; + $job->setStatus($statusEnum); + + // Create search results with a result per part + $partResults = []; + foreach ($parts as $part) { + $partResults[] = new BulkSearchPartResultsDTO( + part: $part, + searchResults: [ + new BulkSearchPartResultDTO( + searchResult: new SearchResultDTO( + provider_key: 'test_provider', + provider_id: 'TEST_' . $part->getId(), + name: $part->getName() ?? 'Test Part', + description: 'Test description', + manufacturer: 'Test Mfg', + mpn: 'MPN-' . $part->getId(), + provider_url: 'https://example.com/' . $part->getId(), + preview_image_url: null, + ), + sourceField: 'mpn', + sourceKeyword: $part->getName() ?? 'test', + localPart: null, + ), + ] + ); + } + + $job->setSearchResults(new BulkSearchResponseDTO($partResults)); + $entityManager->persist($job); + $entityManager->flush(); + + return $job; + } + + private function cleanupJob(object $entityManager, int $jobId): void + { + $entityManager->clear(); + $persistedJob = $entityManager->find(BulkInfoProviderImportJob::class, $jobId); + if ($persistedJob) { + $entityManager->remove($persistedJob); + $entityManager->flush(); + } + } + + public function testDeleteCompletedJob(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $entityManager = $client->getContainer()->get('doctrine')->getManager(); + $user = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']); + $parts = $this->getTestParts($entityManager, [1]); + + if (!$user || empty($parts)) { + $this->markTestSkipped('Required fixtures not found'); + } + + $job = $this->createJobWithSearchResults($entityManager, $user, $parts, 'completed'); + $jobId = $job->getId(); + + $client->request('DELETE', '/en/tools/bulk_info_provider_import/job/' . $jobId . '/delete'); + $this->assertResponseStatusCodeSame(Response::HTTP_OK); + $response = json_decode($client->getResponse()->getContent(), true); + $this->assertTrue($response['success']); + + // Verify job was deleted + $entityManager->clear(); + $this->assertNull($entityManager->find(BulkInfoProviderImportJob::class, $jobId)); + } + + public function testDeleteActiveJobFails(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $entityManager = $client->getContainer()->get('doctrine')->getManager(); + $user = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']); + $parts = $this->getTestParts($entityManager, [1]); + + if (!$user || empty($parts)) { + $this->markTestSkipped('Required fixtures not found'); + } + + $job = $this->createJobWithSearchResults($entityManager, $user, $parts, 'in_progress'); + $jobId = $job->getId(); + + $client->request('DELETE', '/en/tools/bulk_info_provider_import/job/' . $jobId . '/delete'); + $this->assertResponseStatusCodeSame(Response::HTTP_BAD_REQUEST); + + $this->cleanupJob($entityManager, $jobId); + } + + public function testDeleteNonExistentJob(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $client->request('DELETE', '/en/tools/bulk_info_provider_import/job/999999/delete'); + $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + } + + public function testStopInProgressJob(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $entityManager = $client->getContainer()->get('doctrine')->getManager(); + $user = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']); + $parts = $this->getTestParts($entityManager, [1]); + + if (!$user || empty($parts)) { + $this->markTestSkipped('Required fixtures not found'); + } + + $job = $this->createJobWithSearchResults($entityManager, $user, $parts, 'in_progress'); + $jobId = $job->getId(); + + $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $jobId . '/stop'); + $this->assertResponseStatusCodeSame(Response::HTTP_OK); + $response = json_decode($client->getResponse()->getContent(), true); + $this->assertTrue($response['success']); + + // Verify job is stopped + $entityManager->clear(); + $stoppedJob = $entityManager->find(BulkInfoProviderImportJob::class, $jobId); + $this->assertTrue($stoppedJob->isStopped()); + + $entityManager->remove($stoppedJob); + $entityManager->flush(); + } + + public function testStopNonExistentJob(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $client->request('POST', '/en/tools/bulk_info_provider_import/job/999999/stop'); + $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + } + + public function testMarkPartCompletedAutoCompletesJob(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $entityManager = $client->getContainer()->get('doctrine')->getManager(); + $user = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']); + $parts = $this->getTestParts($entityManager, [1]); + + if (!$user || empty($parts)) { + $this->markTestSkipped('Required fixtures not found'); + } + + $job = $this->createJobWithSearchResults($entityManager, $user, $parts); + $jobId = $job->getId(); + $partId = $parts[0]->getId(); + + $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $jobId . '/part/' . $partId . '/mark-completed'); + $this->assertResponseStatusCodeSame(Response::HTTP_OK); + $response = json_decode($client->getResponse()->getContent(), true); + $this->assertTrue($response['success']); + $this->assertEquals(1, $response['completed_count']); + $this->assertTrue($response['job_completed']); + + $this->cleanupJob($entityManager, $jobId); + } + + public function testMarkPartSkippedWithReason(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $entityManager = $client->getContainer()->get('doctrine')->getManager(); + $user = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']); + $parts = $this->getTestParts($entityManager, [1]); + + if (!$user || empty($parts)) { + $this->markTestSkipped('Required fixtures not found'); + } + + $job = $this->createJobWithSearchResults($entityManager, $user, $parts); + $jobId = $job->getId(); + $partId = $parts[0]->getId(); + + $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $jobId . '/part/' . $partId . '/mark-skipped', [ + 'reason' => 'Not needed' + ]); + $this->assertResponseStatusCodeSame(Response::HTTP_OK); + $response = json_decode($client->getResponse()->getContent(), true); + $this->assertTrue($response['success']); + $this->assertEquals(1, $response['skipped_count']); + + $this->cleanupJob($entityManager, $jobId); + } + + public function testMarkPartPendingAfterCompleted(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $entityManager = $client->getContainer()->get('doctrine')->getManager(); + $user = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']); + $parts = $this->getTestParts($entityManager, [1]); + + if (!$user || empty($parts)) { + $this->markTestSkipped('Required fixtures not found'); + } + + $job = $this->createJobWithSearchResults($entityManager, $user, $parts); + $jobId = $job->getId(); + $partId = $parts[0]->getId(); + + // First mark as completed + $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $jobId . '/part/' . $partId . '/mark-completed'); + $this->assertResponseStatusCodeSame(Response::HTTP_OK); + + // Then mark as pending again + $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $jobId . '/part/' . $partId . '/mark-pending'); + $this->assertResponseStatusCodeSame(Response::HTTP_OK); + $response = json_decode($client->getResponse()->getContent(), true); + $this->assertTrue($response['success']); + $this->assertEquals(0, $response['completed_count']); + + $this->cleanupJob($entityManager, $jobId); + } + + public function testMarkPartCompletedNonExistentJob(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $client->request('POST', '/en/tools/bulk_info_provider_import/job/999999/part/1/mark-completed'); + $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + } + + public function testQuickApplyWithValidJob(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $entityManager = $client->getContainer()->get('doctrine')->getManager(); + $user = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']); + $parts = $this->getTestParts($entityManager, [1]); + + if (!$user || empty($parts)) { + $this->markTestSkipped('Required fixtures not found'); + } + + $job = $this->createJobWithSearchResults($entityManager, $user, $parts); + $jobId = $job->getId(); + $partId = $parts[0]->getId(); + + // Quick apply will fail because test_provider doesn't exist, but it exercises the code path + $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $jobId . '/part/' . $partId . '/quick-apply', [], [], [ + 'CONTENT_TYPE' => 'application/json', + ], json_encode(['providerKey' => 'test_provider', 'providerId' => 'TEST_1'])); + + // Will get 500 because test_provider doesn't exist, which exercises the catch block + $this->assertResponseStatusCodeSame(Response::HTTP_INTERNAL_SERVER_ERROR); + $response = json_decode($client->getResponse()->getContent(), true); + $this->assertFalse($response['success']); + $this->assertStringContainsString('Quick apply failed', $response['error']); + + $this->cleanupJob($entityManager, $jobId); + } + + public function testQuickApplyFallsBackToTopResult(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $entityManager = $client->getContainer()->get('doctrine')->getManager(); + $user = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']); + $parts = $this->getTestParts($entityManager, [1]); + + if (!$user || empty($parts)) { + $this->markTestSkipped('Required fixtures not found'); + } + + $job = $this->createJobWithSearchResults($entityManager, $user, $parts); + $jobId = $job->getId(); + $partId = $parts[0]->getId(); + + // No providerKey/providerId in body - should fall back to top search result + $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $jobId . '/part/' . $partId . '/quick-apply', [], [], [ + 'CONTENT_TYPE' => 'application/json', + ], '{}'); + + // Will get 500 because test_provider doesn't exist, but exercises the fallback code path + $this->assertResponseStatusCodeSame(Response::HTTP_INTERNAL_SERVER_ERROR); + $response = json_decode($client->getResponse()->getContent(), true); + $this->assertStringContainsString('Quick apply failed', $response['error']); + + $this->cleanupJob($entityManager, $jobId); + } + + public function testQuickApplyEmptyResultsReturns400(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $entityManager = $client->getContainer()->get('doctrine')->getManager(); + $user = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']); + $parts = $this->getTestParts($entityManager, [1]); + + if (!$user || empty($parts)) { + $this->markTestSkipped('Required fixtures not found'); + } + + // Create job with empty search results + $job = new BulkInfoProviderImportJob(); + $job->setCreatedBy($user); + foreach ($parts as $part) { + $job->addPart($part); + } + $job->setStatus(BulkImportJobStatus::IN_PROGRESS); + $job->setSearchResults(new BulkSearchResponseDTO([ + new BulkSearchPartResultsDTO(part: $parts[0], searchResults: []) + ])); + $entityManager->persist($job); + $entityManager->flush(); + + $jobId = $job->getId(); + $partId = $parts[0]->getId(); + + // No provider specified and no search results - should return 400 + $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $jobId . '/part/' . $partId . '/quick-apply', [], [], [ + 'CONTENT_TYPE' => 'application/json', + ], '{}'); + $this->assertResponseStatusCodeSame(Response::HTTP_BAD_REQUEST); + $response = json_decode($client->getResponse()->getContent(), true); + $this->assertStringContainsString('No search result available', $response['error']); + + $this->cleanupJob($entityManager, $jobId); + } + + public function testQuickApplyNonExistentPart(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $entityManager = $client->getContainer()->get('doctrine')->getManager(); + $user = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']); + $parts = $this->getTestParts($entityManager, [1]); + + if (!$user || empty($parts)) { + $this->markTestSkipped('Required fixtures not found'); + } + + $job = $this->createJobWithSearchResults($entityManager, $user, $parts); + $jobId = $job->getId(); + + $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $jobId . '/part/999999/quick-apply'); + $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + + $this->cleanupJob($entityManager, $jobId); + } + + public function testQuickApplyAllWithValidJob(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $entityManager = $client->getContainer()->get('doctrine')->getManager(); + $user = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']); + $parts = $this->getTestParts($entityManager, [1]); + + if (!$user || empty($parts)) { + $this->markTestSkipped('Required fixtures not found'); + } + + $job = $this->createJobWithSearchResults($entityManager, $user, $parts); + $jobId = $job->getId(); + + // Quick apply all - will fail for test_provider but exercises the code path + $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $jobId . '/quick-apply-all'); + $this->assertResponseStatusCodeSame(Response::HTTP_OK); + $response = json_decode($client->getResponse()->getContent(), true); + $this->assertTrue($response['success']); + // Should have 1 failed (because test_provider doesn't exist) + $this->assertEquals(1, $response['failed']); + $this->assertNotEmpty($response['errors']); + + $this->cleanupJob($entityManager, $jobId); + } + + public function testQuickApplyAllWithNoSearchResults(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $entityManager = $client->getContainer()->get('doctrine')->getManager(); + $user = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']); + $parts = $this->getTestParts($entityManager, [1]); + + if (!$user || empty($parts)) { + $this->markTestSkipped('Required fixtures not found'); + } + + // Create job with empty results + $job = new BulkInfoProviderImportJob(); + $job->setCreatedBy($user); + foreach ($parts as $part) { + $job->addPart($part); + } + $job->setStatus(BulkImportJobStatus::IN_PROGRESS); + $job->setSearchResults(new BulkSearchResponseDTO([ + new BulkSearchPartResultsDTO(part: $parts[0], searchResults: []) + ])); + $entityManager->persist($job); + $entityManager->flush(); + + $jobId = $job->getId(); + + $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $jobId . '/quick-apply-all'); + $this->assertResponseStatusCodeSame(Response::HTTP_OK); + $response = json_decode($client->getResponse()->getContent(), true); + $this->assertTrue($response['success']); + $this->assertEquals(0, $response['applied']); + $this->assertEquals(1, $response['no_results']); + + $this->cleanupJob($entityManager, $jobId); + } + + public function testQuickApplyAllNonExistentJob(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $client->request('POST', '/en/tools/bulk_info_provider_import/job/999999/quick-apply-all'); + $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + } + + public function testQuickApplyAllSkipsCompletedParts(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $entityManager = $client->getContainer()->get('doctrine')->getManager(); + $user = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']); + $parts = $this->getTestParts($entityManager, [1]); + + if (!$user || empty($parts)) { + $this->markTestSkipped('Required fixtures not found'); + } + + $job = $this->createJobWithSearchResults($entityManager, $user, $parts); + $jobId = $job->getId(); + + // Mark the part as completed first + $job->markPartAsCompleted($parts[0]->getId()); + $entityManager->flush(); + + // Quick apply all should skip already-completed parts + $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $jobId . '/quick-apply-all'); + $this->assertResponseStatusCodeSame(Response::HTTP_OK); + $response = json_decode($client->getResponse()->getContent(), true); + $this->assertEquals(0, $response['applied']); + $this->assertEquals(0, $response['failed']); + $this->assertEquals(0, $response['no_results']); + + $this->cleanupJob($entityManager, $jobId); + } + + public function testDeleteStoppedJob(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $entityManager = $client->getContainer()->get('doctrine')->getManager(); + $user = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']); + $parts = $this->getTestParts($entityManager, [1]); + + if (!$user || empty($parts)) { + $this->markTestSkipped('Required fixtures not found'); + } + + $job = $this->createJobWithSearchResults($entityManager, $user, $parts, 'stopped'); + $jobId = $job->getId(); + + $client->request('DELETE', '/en/tools/bulk_info_provider_import/job/' . $jobId . '/delete'); + $this->assertResponseStatusCodeSame(Response::HTTP_OK); + $response = json_decode($client->getResponse()->getContent(), true); + $this->assertTrue($response['success']); + + $entityManager->clear(); + $this->assertNull($entityManager->find(BulkInfoProviderImportJob::class, $jobId)); + } + + public function testManagePageSplitsActiveAndHistory(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $entityManager = $client->getContainer()->get('doctrine')->getManager(); + $user = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']); + $parts = $this->getTestParts($entityManager, [1]); + + if (!$user || empty($parts)) { + $this->markTestSkipped('Required fixtures not found'); + } + + // Create one active and one completed job + $activeJob = $this->createJobWithSearchResults($entityManager, $user, $parts, 'in_progress'); + $completedJob = $this->createJobWithSearchResults($entityManager, $user, $parts, 'completed'); + + $client->request('GET', '/en/tools/bulk_info_provider_import/manage'); + if ($client->getResponse()->isRedirect()) { + $client->followRedirect(); + } + $this->assertResponseStatusCodeSame(Response::HTTP_OK); + + $content = (string) $client->getResponse()->getContent(); + $this->assertStringContainsString('Active Jobs', $content); + $this->assertStringContainsString('History', $content); + + $this->cleanupJob($entityManager, $activeJob->getId()); + $this->cleanupJob($entityManager, $completedJob->getId()); + } + + public function testManagePageCleansUpPendingJobsWithNoResults(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $entityManager = $client->getContainer()->get('doctrine')->getManager(); + $user = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']); + $parts = $this->getTestParts($entityManager, [1]); + + if (!$user || empty($parts)) { + $this->markTestSkipped('Required fixtures not found'); + } + + // Create a pending job with no results (should be cleaned up) + $job = new BulkInfoProviderImportJob(); + $job->setCreatedBy($user); + foreach ($parts as $part) { + $job->addPart($part); + } + $job->setStatus(BulkImportJobStatus::PENDING); + $job->setSearchResults(new BulkSearchResponseDTO([])); + $entityManager->persist($job); + $entityManager->flush(); + $jobId = $job->getId(); + + // Visit manage page - should trigger cleanup + $client->request('GET', '/en/tools/bulk_info_provider_import/manage'); + if ($client->getResponse()->isRedirect()) { + $client->followRedirect(); + } + $this->assertResponseStatusCodeSame(Response::HTTP_OK); + + // Verify the stale job was cleaned up + $entityManager->clear(); + $this->assertNull($entityManager->find(BulkInfoProviderImportJob::class, $jobId)); + } + + public function testStep2RedirectsForNonExistentJob(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $client->request('GET', '/en/tools/bulk_info_provider_import/step2/999999'); + + // Should redirect with error flash + $this->assertResponseRedirects(); + } + + public function testStep2WithOtherUsersJob(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $entityManager = $client->getContainer()->get('doctrine')->getManager(); + $otherUser = $entityManager->getRepository(User::class)->findOneBy(['name' => 'noread']); + $parts = $this->getTestParts($entityManager, [1]); + + if (!$otherUser || empty($parts)) { + $this->markTestSkipped('Required fixtures not found'); + } + + $job = $this->createJobWithSearchResults($entityManager, $otherUser, $parts); + $jobId = $job->getId(); + + $client->request('GET', '/en/tools/bulk_info_provider_import/step2/' . $jobId); + + // Should redirect with access denied + $this->assertResponseRedirects(); + + $this->cleanupJob($entityManager, $jobId); + } + + public function testResearchPartNonExistentJob(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $client->request('POST', '/en/tools/bulk_info_provider_import/job/999999/part/1/research'); + $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + } + + public function testResearchPartNonExistentPart(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $entityManager = $client->getContainer()->get('doctrine')->getManager(); + $user = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']); + $parts = $this->getTestParts($entityManager, [1]); + + if (!$user || empty($parts)) { + $this->markTestSkipped('Required fixtures not found'); + } + + $job = $this->createJobWithSearchResults($entityManager, $user, $parts); + $jobId = $job->getId(); + + $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $jobId . '/part/999999/research'); + $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + + $this->cleanupJob($entityManager, $jobId); + } + + public function testResearchAllNonExistentJob(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $client->request('POST', '/en/tools/bulk_info_provider_import/job/999999/research-all'); + $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + } + + public function testResearchAllWithAllPartsCompleted(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $entityManager = $client->getContainer()->get('doctrine')->getManager(); + $user = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']); + $parts = $this->getTestParts($entityManager, [1]); + + if (!$user || empty($parts)) { + $this->markTestSkipped('Required fixtures not found'); + } + + $job = $this->createJobWithSearchResults($entityManager, $user, $parts); + $jobId = $job->getId(); + + // Mark all parts as completed + foreach ($parts as $part) { + $job->markPartAsCompleted($part->getId()); + } + $entityManager->flush(); + + $client->request('POST', '/en/tools/bulk_info_provider_import/job/' . $jobId . '/research-all'); + $this->assertResponseStatusCodeSame(Response::HTTP_OK); + $response = json_decode($client->getResponse()->getContent(), true); + $this->assertTrue($response['success']); + $this->assertEquals(0, $response['researched_count']); + + $this->cleanupJob($entityManager, $jobId); + } } diff --git a/tests/Controller/KiCadApiControllerTest.php b/tests/Controller/KiCadApiControllerTest.php index 26a47032..8e55de85 100644 --- a/tests/Controller/KiCadApiControllerTest.php +++ b/tests/Controller/KiCadApiControllerTest.php @@ -121,6 +121,7 @@ final class KiCadApiControllerTest extends WebTestCase 'exclude_from_bom' => 'False', 'exclude_from_board' => 'True', 'exclude_from_sim' => 'False', + 'description' => '', 'fields' => array( 'footprint' => @@ -203,6 +204,7 @@ final class KiCadApiControllerTest extends WebTestCase 'exclude_from_bom' => 'False', 'exclude_from_board' => 'True', 'exclude_from_sim' => 'False', + 'description' => '', 'fields' => array ( 'footprint' => @@ -318,4 +320,4 @@ final class KiCadApiControllerTest extends WebTestCase self::assertResponseStatusCodeSame(304); } -} \ No newline at end of file +} diff --git a/tests/Controller/KicadListEditorControllerTest.php b/tests/Controller/KicadListEditorControllerTest.php new file mode 100644 index 00000000..0aa05aa1 --- /dev/null +++ b/tests/Controller/KicadListEditorControllerTest.php @@ -0,0 +1,162 @@ +. + */ + +declare(strict_types=1); + +namespace App\Tests\Controller; + +use App\Entity\UserSystem\User; +use App\Settings\MiscSettings\KiCadEDASettings; +use Jbtronics\SettingsBundle\Manager\SettingsManagerInterface; +use PHPUnit\Framework\Attributes\Group; +use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; + +#[Group('slow')] +#[Group('DB')] +final class KicadListEditorControllerTest extends WebTestCase +{ + private string $footprintsPath; + private string $symbolsPath; + private string $customFootprintsPath; + private string $customSymbolsPath; + private string $originalFootprints; + private string $originalSymbols; + private string $originalCustomFootprints; + private string $originalCustomSymbols; + private bool $originalUseCustomList; + + protected function setUp(): void + { + parent::setUp(); + + $projectDir = dirname(__DIR__, 2); + $this->footprintsPath = $projectDir . '/public/kicad/footprints.txt'; + $this->symbolsPath = $projectDir . '/public/kicad/symbols.txt'; + $this->customFootprintsPath = $projectDir . '/public/kicad/footprints_custom.txt'; + $this->customSymbolsPath = $projectDir . '/public/kicad/symbols_custom.txt'; + $this->originalFootprints = (string) file_get_contents($this->footprintsPath); + $this->originalSymbols = (string) file_get_contents($this->symbolsPath); + $this->originalCustomFootprints = is_file($this->customFootprintsPath) ? (string) file_get_contents($this->customFootprintsPath) : ''; + $this->originalCustomSymbols = is_file($this->customSymbolsPath) ? (string) file_get_contents($this->customSymbolsPath) : ''; + + static::bootKernel(); + /** @var SettingsManagerInterface $settingsManager */ + $settingsManager = static::getContainer()->get(SettingsManagerInterface::class); + /** @var KiCadEDASettings $settings */ + $settings = $settingsManager->get(KiCadEDASettings::class); + $this->originalUseCustomList = $settings->useCustomList; + static::ensureKernelShutdown(); + } + + protected function tearDown(): void + { + file_put_contents($this->footprintsPath, $this->originalFootprints); + file_put_contents($this->symbolsPath, $this->originalSymbols); + file_put_contents($this->customFootprintsPath, $this->originalCustomFootprints); + file_put_contents($this->customSymbolsPath, $this->originalCustomSymbols); + + static::bootKernel(); + /** @var SettingsManagerInterface $settingsManager */ + $settingsManager = static::getContainer()->get(SettingsManagerInterface::class); + /** @var KiCadEDASettings $settings */ + $settings = $settingsManager->get(KiCadEDASettings::class); + $settings->useCustomList = $this->originalUseCustomList; + $settingsManager->save($settings); + static::ensureKernelShutdown(); + + parent::tearDown(); + } + + public function testEditorRequiresAuthentication(): void + { + $client = static::createClient(); + $client->request('GET', '/en/settings/misc/kicad-lists'); + + $this->assertResponseStatusCodeSame(401); + } + + public function testEditorAccessibleByAdmin(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $client->request('GET', '/en/settings/misc/kicad-lists'); + + $this->assertResponseIsSuccessful(); + $this->assertSelectorExists('form[name="kicad_list_editor"]'); + } + + public function testEditorShowsDefaultAndCustomFiles(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + file_put_contents($this->footprintsPath, "DefaultFootprint\n"); + file_put_contents($this->symbolsPath, "DefaultSymbol\n"); + file_put_contents($this->customFootprintsPath, "CustomFootprint\n"); + file_put_contents($this->customSymbolsPath, "CustomSymbol\n"); + + $crawler = $client->request('GET', '/en/settings/misc/kicad-lists'); + + $this->assertSame("CustomFootprint\n", $crawler->filter('#kicad_list_editor_customFootprints')->getNode(0)->nodeValue); + $this->assertSame("CustomSymbol\n", $crawler->filter('#kicad_list_editor_customSymbols')->getNode(0)->nodeValue); + $this->assertSame("DefaultFootprint\n", $crawler->filter('#kicad_list_editor_defaultFootprints')->getNode(0)->nodeValue); + $this->assertSame("DefaultSymbol\n", $crawler->filter('#kicad_list_editor_defaultSymbols')->getNode(0)->nodeValue); + } + + public function testEditorSavesCustomFilesAndSetting(): void + { + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $crawler = $client->request('GET', '/en/settings/misc/kicad-lists'); + $form = $crawler->filter('form[name="kicad_list_editor"]')->form(); + $form['kicad_list_editor[customFootprints]'] = "Package_DIP:DIP-8_W7.62mm\n"; + $form['kicad_list_editor[customSymbols]'] = "Device:R\n"; + $form['kicad_list_editor[useCustomList]']->tick(); + + $client->submit($form); + + $this->assertResponseRedirects('/en/settings/misc/kicad-lists'); + $this->assertSame("Package_DIP:DIP-8_W7.62mm\n", (string) file_get_contents($this->customFootprintsPath)); + $this->assertSame("Device:R\n", (string) file_get_contents($this->customSymbolsPath)); + $this->assertSame($this->originalFootprints, (string) file_get_contents($this->footprintsPath)); + $this->assertSame($this->originalSymbols, (string) file_get_contents($this->symbolsPath)); + + /** @var SettingsManagerInterface $settingsManager */ + $settingsManager = $client->getContainer()->get(SettingsManagerInterface::class); + /** @var KiCadEDASettings $settings */ + $settings = $settingsManager->reload(KiCadEDASettings::class); + $this->assertTrue($settings->useCustomList); + } + + private function loginAsUser($client, string $username): void + { + $entityManager = $client->getContainer()->get('doctrine')->getManager(); + $userRepository = $entityManager->getRepository(User::class); + $user = $userRepository->findOneBy(['name' => $username]); + + if (!$user) { + $this->markTestSkipped(sprintf('User "%s" not found in fixtures', $username)); + } + + $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/Controller/UpdateManagerControllerTest.php b/tests/Controller/UpdateManagerControllerTest.php new file mode 100644 index 00000000..0c2b2224 --- /dev/null +++ b/tests/Controller/UpdateManagerControllerTest.php @@ -0,0 +1,381 @@ +. + */ + +declare(strict_types=1); + +namespace App\Tests\Controller; + +use App\Entity\UserSystem\User; +use App\Services\System\BackupManager; +use App\Services\System\UpdateExecutor; +use PHPUnit\Framework\Attributes\Group; +use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; + +#[Group("slow")] +#[Group("DB")] +final class UpdateManagerControllerTest extends WebTestCase +{ + private function loginAsAdmin($client): void + { + $entityManager = $client->getContainer()->get('doctrine')->getManager(); + $userRepository = $entityManager->getRepository(User::class); + $user = $userRepository->findOneBy(['name' => 'admin']); + + if (!$user) { + $this->markTestSkipped('Admin user not found'); + } + + $client->loginUser($user); + } + + /** + * Extract a CSRF token from the rendered update manager page. + */ + private function getCsrfTokenFromPage($crawler, string $formAction): string + { + $form = $crawler->filter('form[action*="' . $formAction . '"]'); + if ($form->count() === 0) { + $this->fail('Form with action containing "' . $formAction . '" not found on page'); + } + + return $form->filter('input[name="_token"]')->attr('value'); + } + + // ---- Authentication tests ---- + + public function testIndexPageRequiresAuth(): void + { + $client = static::createClient(); + + $client->request('GET', '/en/system/update-manager'); + + // Should deny access (401 with HTTP Basic auth in test env) + $this->assertResponseStatusCodeSame(401); + } + + public function testIndexPageAccessibleByAdmin(): void + { + $client = static::createClient(); + $this->loginAsAdmin($client); + + $client->request('GET', '/en/system/update-manager'); + + $this->assertResponseIsSuccessful(); + } + + // ---- Backup creation tests ---- + + public function testCreateBackupRequiresCsrf(): void + { + $client = static::createClient(); + $this->loginAsAdmin($client); + + $client->request('POST', '/en/system/update-manager/backup', [ + '_token' => 'invalid', + ]); + + // Should redirect with error flash + $this->assertResponseRedirects(); + } + + public function testCreateBackupWithValidCsrf(): void + { + $client = static::createClient(); + $this->loginAsAdmin($client); + + // Load the page and extract CSRF token from the backup form + $crawler = $client->request('GET', '/en/system/update-manager'); + $csrfToken = $this->getCsrfTokenFromPage($crawler, 'backup'); + + $client->request('POST', '/en/system/update-manager/backup', [ + '_token' => $csrfToken, + ]); + + $this->assertResponseRedirects(); + + // Clean up: delete the backup that was just created + $backupManager = $client->getContainer()->get(BackupManager::class); + $backups = $backupManager->getBackups(); + foreach ($backups as $backup) { + if (str_contains($backup['file'], 'manual')) { + $backupManager->deleteBackup($backup['file']); + } + } + } + + public function testCreateBackupBlockedWhenLocked(): void + { + $client = static::createClient(); + $this->loginAsAdmin($client); + + // Load the page first to get CSRF token before locking + $crawler = $client->request('GET', '/en/system/update-manager'); + $csrfToken = $this->getCsrfTokenFromPage($crawler, 'backup'); + + // Acquire lock to simulate update in progress + $updateExecutor = $client->getContainer()->get(UpdateExecutor::class); + $updateExecutor->acquireLock(); + + try { + $client->request('POST', '/en/system/update-manager/backup', [ + '_token' => $csrfToken, + ]); + + $this->assertResponseRedirects(); + } finally { + // Always release lock + $updateExecutor->releaseLock(); + } + } + + // ---- Backup deletion tests ---- + + public function testDeleteBackupRequiresCsrf(): void + { + $client = static::createClient(); + $this->loginAsAdmin($client); + + $client->request('POST', '/en/system/update-manager/backup/delete', [ + '_token' => 'invalid', + 'filename' => 'test.zip', + ]); + + $this->assertResponseRedirects(); + } + + public function testDeleteBackupWithValidCsrf(): void + { + $client = static::createClient(); + $this->loginAsAdmin($client); + + // Create a temporary backup file so the page shows the delete form + $backupManager = $client->getContainer()->get(BackupManager::class); + $backupDir = $backupManager->getBackupDir(); + if (!is_dir($backupDir)) { + mkdir($backupDir, 0755, true); + } + $testFile = 'test-delete-' . uniqid() . '.zip'; + file_put_contents($backupDir . '/' . $testFile, 'test'); + + // Load the page and extract CSRF token from the delete form + $crawler = $client->request('GET', '/en/system/update-manager'); + $csrfToken = $this->getCsrfTokenFromPage($crawler, 'backup/delete'); + + $client->request('POST', '/en/system/update-manager/backup/delete', [ + '_token' => $csrfToken, + 'filename' => $testFile, + ]); + + $this->assertResponseRedirects(); + $this->assertFileDoesNotExist($backupDir . '/' . $testFile); + } + + // ---- Log deletion tests ---- + + public function testDeleteLogRequiresCsrf(): void + { + $client = static::createClient(); + $this->loginAsAdmin($client); + + $client->request('POST', '/en/system/update-manager/log/delete', [ + '_token' => 'invalid', + 'filename' => 'test.log', + ]); + + $this->assertResponseRedirects(); + } + + public function testDeleteLogWithValidCsrf(): void + { + $client = static::createClient(); + $this->loginAsAdmin($client); + + // Create a temporary log file so the page shows the delete form + $projectDir = $client->getContainer()->getParameter('kernel.project_dir'); + $logDir = $projectDir . '/var/log/updates'; + if (!is_dir($logDir)) { + mkdir($logDir, 0755, true); + } + $testFile = 'update-test-delete-' . uniqid() . '.log'; + file_put_contents($logDir . '/' . $testFile, 'test log content'); + + // Load the page and extract CSRF token from the log delete form + $crawler = $client->request('GET', '/en/system/update-manager'); + $csrfToken = $this->getCsrfTokenFromPage($crawler, 'log/delete'); + + $client->request('POST', '/en/system/update-manager/log/delete', [ + '_token' => $csrfToken, + 'filename' => $testFile, + ]); + + $this->assertResponseRedirects(); + $this->assertFileDoesNotExist($logDir . '/' . $testFile); + } + + // ---- Backup download tests ---- + + public function testDownloadBackupBlockedByDefault(): void + { + $client = static::createClient(); + $this->loginAsAdmin($client); + + // DISABLE_BACKUP_DOWNLOAD=1 is the default in .env, so this should return 403 + $client->request('POST', '/en/system/update-manager/backup/download', [ + '_token' => 'any', + 'filename' => 'test.zip', + 'password' => 'test', + ]); + + $this->assertResponseStatusCodeSame(403); + } + + public function testDownloadBackupRequiresPost(): void + { + $client = static::createClient(); + $this->loginAsAdmin($client); + + // GET returns 404 since no GET route exists for this path + $client->request('GET', '/en/system/update-manager/backup/download'); + + $this->assertResponseStatusCodeSame(404); + } + + public function testDownloadBackupRequiresAuth(): void + { + $client = static::createClient(); + + $client->request('POST', '/en/system/update-manager/backup/download', [ + '_token' => 'any', + 'filename' => 'test.zip', + 'password' => 'test', + ]); + + // Should deny access (401 with HTTP Basic auth in test env) + $this->assertResponseStatusCodeSame(401); + } + + // ---- Backup details tests ---- + + public function testBackupDetailsReturns404ForNonExistent(): void + { + $client = static::createClient(); + $this->loginAsAdmin($client); + + $client->request('GET', '/en/system/update-manager/backup/nonexistent.zip'); + + $this->assertResponseStatusCodeSame(404); + } + + // ---- Restore tests ---- + + public function testRestoreBlockedWhenDisabled(): void + { + $client = static::createClient(); + $this->loginAsAdmin($client); + + // DISABLE_BACKUP_RESTORE=1 is the default in .env, so this should return 403 + $client->request('POST', '/en/system/update-manager/restore', [ + '_token' => 'invalid', + 'filename' => 'test.zip', + ]); + + $this->assertResponseStatusCodeSame(403); + } + + public function testRestoreRequiresAuth(): void + { + $client = static::createClient(); + + $client->request('POST', '/en/system/update-manager/restore', [ + '_token' => 'invalid', + 'filename' => 'test.zip', + ]); + + $this->assertResponseStatusCodeSame(401); + } + + // ---- Start update tests ---- + + public function testStartUpdateRequiresAuth(): void + { + $client = static::createClient(); + + $client->request('POST', '/en/system/update-manager/start', [ + '_token' => 'invalid', + 'version' => 'v1.0.0', + ]); + + $this->assertResponseStatusCodeSame(401); + } + + public function testStartUpdateBlockedWhenWebUpdatesDisabled(): void + { + $client = static::createClient(); + $this->loginAsAdmin($client); + + // DISABLE_WEB_UPDATES=1 is the default in .env + $client->request('POST', '/en/system/update-manager/start', [ + '_token' => 'invalid', + 'version' => 'v1.0.0', + ]); + + $this->assertResponseStatusCodeSame(403); + } + + // ---- Status and progress tests ---- + + public function testStatusEndpointRequiresAuth(): void + { + $client = static::createClient(); + + $client->request('GET', '/en/system/update-manager/status'); + + $this->assertResponseStatusCodeSame(401); + } + + public function testStatusEndpointAccessibleByAdmin(): void + { + $client = static::createClient(); + $this->loginAsAdmin($client); + + $client->request('GET', '/en/system/update-manager/status'); + + $this->assertResponseIsSuccessful(); + } + + public function testProgressStatusEndpointRequiresAuth(): void + { + $client = static::createClient(); + + $client->request('GET', '/en/system/update-manager/progress/status'); + + $this->assertResponseStatusCodeSame(401); + } + + public function testProgressStatusEndpointAccessibleByAdmin(): void + { + $client = static::createClient(); + $this->loginAsAdmin($client); + + $client->request('GET', '/en/system/update-manager/progress/status'); + + $this->assertResponseIsSuccessful(); + } +} diff --git a/tests/Doctrine/Functions/AbstractDoctrineFunctionTestCase.php b/tests/Doctrine/Functions/AbstractDoctrineFunctionTestCase.php new file mode 100644 index 00000000..7bc3d628 --- /dev/null +++ b/tests/Doctrine/Functions/AbstractDoctrineFunctionTestCase.php @@ -0,0 +1,68 @@ +. + */ + +declare(strict_types=1); + +namespace App\Tests\Doctrine\Functions; + +use Doctrine\DBAL\Connection; +use Doctrine\DBAL\Platforms\AbstractPlatform; +use Doctrine\ORM\Query\AST\Node; +use Doctrine\ORM\Query\SqlWalker; +use PHPUnit\Framework\TestCase; + +abstract class AbstractDoctrineFunctionTestCase extends TestCase +{ + protected function createSqlWalker(AbstractPlatform $platform, string $serverVersion = '11.0.0-MariaDB'): SqlWalker + { + $connection = $this->createMock(Connection::class); + $connection->method('getDatabasePlatform')->willReturn($platform); + $connection->method('getServerVersion')->willReturn($serverVersion); + + $sqlWalker = $this->getMockBuilder(SqlWalker::class) + ->disableOriginalConstructor() + ->onlyMethods(['getConnection']) + ->getMock(); + + $sqlWalker->method('getConnection')->willReturn($connection); + + return $sqlWalker; + } + + protected function createNode(string $sql): Node + { + $node = $this->createMock(Node::class); + $node->method('dispatch')->willReturn($sql); + + return $node; + } + + protected function setObjectProperty(object $object, string $property, mixed $value): void + { + $reflection = new \ReflectionProperty($object, $property); + $reflection->setValue($object, $value); + } + + protected function setStaticProperty(string $class, string $property, mixed $value): void + { + $reflection = new \ReflectionProperty($class, $property); + $reflection->setValue(null, $value); + } +} diff --git a/tests/Doctrine/Functions/ArrayPositionTest.php b/tests/Doctrine/Functions/ArrayPositionTest.php new file mode 100644 index 00000000..7fdff42d --- /dev/null +++ b/tests/Doctrine/Functions/ArrayPositionTest.php @@ -0,0 +1,42 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Tests\Doctrine\Functions; + +use App\Doctrine\Functions\ArrayPosition; +use Doctrine\DBAL\Platforms\PostgreSQLPlatform; + +final class ArrayPositionTest extends AbstractDoctrineFunctionTestCase +{ + public function testArrayPositionBuildsSql(): void + { + $function = new ArrayPosition('ARRAY_POSITION'); + $this->setObjectProperty($function, 'array', $this->createNode(':ids')); + $this->setObjectProperty($function, 'field', $this->createNode('p.id')); + + $sql = $function->getSql($this->createSqlWalker(new PostgreSQLPlatform())); + + $this->assertSame('ARRAY_POSITION(:ids, p.id)', $sql); + } +} + diff --git a/tests/Doctrine/Functions/Field2Test.php b/tests/Doctrine/Functions/Field2Test.php new file mode 100644 index 00000000..d25e511f --- /dev/null +++ b/tests/Doctrine/Functions/Field2Test.php @@ -0,0 +1,45 @@ +. + */ + +declare(strict_types=1); + +namespace App\Tests\Doctrine\Functions; + +use App\Doctrine\Functions\Field2; +use Doctrine\DBAL\Platforms\MySQLPlatform; + +final class Field2Test extends AbstractDoctrineFunctionTestCase +{ + public function testField2BuildsSql(): void + { + $function = new Field2('FIELD2'); + $this->setObjectProperty($function, 'field', $this->createNode('p.id')); + $this->setObjectProperty($function, 'values', [ + $this->createNode('1'), + $this->createNode('2'), + $this->createNode('3'), + ]); + + $sql = $function->getSql($this->createSqlWalker(new MySQLPlatform())); + + $this->assertSame('FIELD2(p.id, 1, 2, 3)', $sql); + } +} + diff --git a/tests/Doctrine/Functions/ILikeTest.php b/tests/Doctrine/Functions/ILikeTest.php new file mode 100644 index 00000000..4541e9c9 --- /dev/null +++ b/tests/Doctrine/Functions/ILikeTest.php @@ -0,0 +1,66 @@ +. + */ + +declare(strict_types=1); + +namespace App\Tests\Doctrine\Functions; + +use App\Doctrine\Functions\ILike; +use Doctrine\DBAL\Platforms\AbstractPlatform; +use Doctrine\DBAL\Platforms\MySQLPlatform; +use Doctrine\DBAL\Platforms\PostgreSQLPlatform; +use Doctrine\DBAL\Platforms\SQLitePlatform; +use Doctrine\DBAL\Platforms\SQLServerPlatform; +use PHPUnit\Framework\Attributes\DataProvider; + +final class ILikeTest extends AbstractDoctrineFunctionTestCase +{ + public static function iLikePlatformProvider(): \Generator + { + yield 'mysql' => [new MySQLPlatform(), '(part_name LIKE :pattern)']; + yield 'postgres' => [new PostgreSQLPlatform(), '(part_name ILIKE :pattern)']; + yield 'sqlite' => [new SQLitePlatform(), "(part_name LIKE :pattern ESCAPE '\\')"]; + } + + #[DataProvider('iLikePlatformProvider')] + public function testILikeUsesExpectedOperator(AbstractPlatform $platform, string $expectedSql): void + { + $function = new ILike('ILIKE'); + $function->value = $this->createNode('part_name'); + $function->expr = $this->createNode(':pattern'); + + $sql = $function->getSql($this->createSqlWalker($platform)); + + $this->assertSame($expectedSql, $sql); + } + + public function testILikeThrowsOnUnsupportedPlatform(): void + { + $function = new ILike('ILIKE'); + $function->value = $this->createNode('part_name'); + $function->expr = $this->createNode(':pattern'); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('does not support case insensitive like expressions'); + + $function->getSql($this->createSqlWalker(new SQLServerPlatform())); + } +} + diff --git a/tests/Doctrine/Functions/NatsortTest.php b/tests/Doctrine/Functions/NatsortTest.php new file mode 100644 index 00000000..fd10199f --- /dev/null +++ b/tests/Doctrine/Functions/NatsortTest.php @@ -0,0 +1,95 @@ +. + */ + +declare(strict_types=1); + +namespace App\Tests\Doctrine\Functions; + +use App\Doctrine\Functions\Natsort; +use Doctrine\DBAL\Platforms\MariaDBPlatform; +use Doctrine\DBAL\Platforms\MySQLPlatform; +use Doctrine\DBAL\Platforms\PostgreSQLPlatform; +use Doctrine\DBAL\Platforms\SQLitePlatform; + +final class NatsortTest extends AbstractDoctrineFunctionTestCase +{ + protected function setUp(): void + { + parent::setUp(); + + Natsort::allowSlowNaturalSort(false); + $this->setStaticProperty(Natsort::class, 'supportsNaturalSort', null); + } + + public function testNatsortUsesPostgresCollation(): void + { + $function = new Natsort('NATSORT'); + $this->setObjectProperty($function, 'field', $this->createNode('part_name')); + + $sql = $function->getSql($this->createSqlWalker(new PostgreSQLPlatform())); + + $this->assertSame('part_name COLLATE numeric', $sql); + } + + public function testNatsortUsesMariaDbNativeFunctionOnSupportedVersion(): void + { + $function = new Natsort('NATSORT'); + $this->setObjectProperty($function, 'field', $this->createNode('part_name')); + + $sql = $function->getSql($this->createSqlWalker(new MariaDBPlatform(), '10.11.2-MariaDB')); + + $this->assertSame('NATURAL_SORT_KEY(part_name)', $sql); + } + + public function testNatsortFallsBackWithoutSlowSort(): void + { + $function = new Natsort('NATSORT'); + $this->setObjectProperty($function, 'field', $this->createNode('part_name')); + + $sql = $function->getSql($this->createSqlWalker(new MariaDBPlatform(), '10.6.10-MariaDB')); + + $this->assertSame('part_name', $sql); + } + + public function testNatsortUsesSlowSortFunctionOnMySqlWhenEnabled(): void + { + Natsort::allowSlowNaturalSort(); + + $function = new Natsort('NATSORT'); + $this->setObjectProperty($function, 'field', $this->createNode('part_name')); + + $sql = $function->getSql($this->createSqlWalker(new MySQLPlatform())); + + $this->assertSame('NatSortKey(part_name, 0)', $sql); + } + + public function testNatsortUsesSlowSortCollationOnSqliteWhenEnabled(): void + { + Natsort::allowSlowNaturalSort(); + + $function = new Natsort('NATSORT'); + $this->setObjectProperty($function, 'field', $this->createNode('part_name')); + + $sql = $function->getSql($this->createSqlWalker(new SQLitePlatform())); + + $this->assertSame('part_name COLLATE NATURAL_CMP', $sql); + } +} + diff --git a/tests/Doctrine/Functions/RegexpTest.php b/tests/Doctrine/Functions/RegexpTest.php new file mode 100644 index 00000000..d1866210 --- /dev/null +++ b/tests/Doctrine/Functions/RegexpTest.php @@ -0,0 +1,66 @@ +. + */ + +declare(strict_types=1); + +namespace App\Tests\Doctrine\Functions; + +use App\Doctrine\Functions\Regexp; +use Doctrine\DBAL\Platforms\AbstractPlatform; +use Doctrine\DBAL\Platforms\MySQLPlatform; +use Doctrine\DBAL\Platforms\PostgreSQLPlatform; +use Doctrine\DBAL\Platforms\SQLitePlatform; +use Doctrine\DBAL\Platforms\SQLServerPlatform; +use PHPUnit\Framework\Attributes\DataProvider; + +final class RegexpTest extends AbstractDoctrineFunctionTestCase +{ + public static function regexpPlatformProvider(): \Generator + { + yield 'mysql' => [new MySQLPlatform(), '(part_name REGEXP :regex)']; + yield 'sqlite' => [new SQLitePlatform(), '(part_name REGEXP :regex)']; + yield 'postgres' => [new PostgreSQLPlatform(), '(part_name ~* :regex)']; + } + + #[DataProvider('regexpPlatformProvider')] + public function testRegexpUsesExpectedOperator(AbstractPlatform $platform, string $expectedSql): void + { + $function = new Regexp('REGEXP'); + $this->setObjectProperty($function, 'value', $this->createNode('part_name')); + $this->setObjectProperty($function, 'regexp', $this->createNode(':regex')); + + $sql = $function->getSql($this->createSqlWalker($platform)); + + $this->assertSame($expectedSql, $sql); + } + + public function testRegexpThrowsOnUnsupportedPlatform(): void + { + $function = new Regexp('REGEXP'); + $this->setObjectProperty($function, 'value', $this->createNode('part_name')); + $this->setObjectProperty($function, 'regexp', $this->createNode(':regex')); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('does not support regular expressions'); + + $function->getSql($this->createSqlWalker(new SQLServerPlatform())); + } +} + diff --git a/tests/Doctrine/Functions/SiValueSortTest.php b/tests/Doctrine/Functions/SiValueSortTest.php new file mode 100644 index 00000000..dbdd9d28 --- /dev/null +++ b/tests/Doctrine/Functions/SiValueSortTest.php @@ -0,0 +1,193 @@ +. + */ + +declare(strict_types=1); + +namespace App\Tests\Doctrine\Functions; + +use App\Doctrine\Functions\SiValueSort; +use Doctrine\DBAL\Platforms\MySQLPlatform; +use Doctrine\DBAL\Platforms\PostgreSQLPlatform; +use Doctrine\DBAL\Platforms\SQLitePlatform; + +final class SiValueSortTest extends AbstractDoctrineFunctionTestCase +{ + public function testPostgreSQLGeneratesCaseExpression(): void + { + $function = new SiValueSort('SI_VALUE_SORT'); + $this->setObjectProperty($function, 'field', $this->createNode('part_name')); + + $sql = $function->getSql($this->createSqlWalker(new PostgreSQLPlatform())); + + $this->assertStringContainsString('CASE', $sql); + $this->assertStringContainsString("REPLACE(part_name, ',', '.')", $sql); + $this->assertStringContainsString('1e-12', $sql); + $this->assertStringContainsString('1e-9', $sql); + $this->assertStringContainsString('1e-6', $sql); + $this->assertStringContainsString('1e-3', $sql); + $this->assertStringContainsString('1e3', $sql); + $this->assertStringContainsString('1e6', $sql); + $this->assertStringContainsString('1e9', $sql); + $this->assertStringContainsString('1e12', $sql); + } + + public function testMySQLGeneratesCaseExpression(): void + { + $function = new SiValueSort('SI_VALUE_SORT'); + $this->setObjectProperty($function, 'field', $this->createNode('part_name')); + + $sql = $function->getSql($this->createSqlWalker(new MySQLPlatform())); + + $this->assertStringContainsString('CASE', $sql); + $this->assertStringContainsString("REPLACE(part_name, ',', '.')", $sql); + $this->assertStringContainsString('1e-12', $sql); + $this->assertStringContainsString('1e6', $sql); + } + + public function testSQLiteUsesSiValueFunction(): void + { + $function = new SiValueSort('SI_VALUE_SORT'); + $this->setObjectProperty($function, 'field', $this->createNode('part_name')); + + $sql = $function->getSql($this->createSqlWalker(new SQLitePlatform())); + + $this->assertSame('SI_VALUE(part_name)', $sql); + } + + /** + * @dataProvider sqliteSiValueProvider + */ + public function testSqliteSiValue(?string $input, ?float $expected): void + { + $result = SiValueSort::sqliteSiValue($input); + + if ($expected === null) { + $this->assertNull($result); + } else { + $this->assertEqualsWithDelta($expected, $result, $expected * 1e-9); + } + } + + /** + * @return iterable + */ + public static function sqliteSiValueProvider(): iterable + { + // Basic SI prefix values + yield 'pico' => ['10pF', 10e-12]; + yield 'nano' => ['100nF', 100e-9]; + yield 'micro_u' => ['1uF', 1e-6]; + yield 'micro_µ' => ['1µF', 1e-6]; + yield 'milli' => ['4.7mH', 4.7e-3]; + yield 'kilo_lower' => ['4.7k', 4.7e3]; + yield 'kilo_upper' => ['4.7K', 4.7e3]; + yield 'mega' => ['1M', 1e6]; + yield 'giga' => ['2.2G', 2.2e9]; + yield 'tera' => ['1T', 1e12]; + + // No prefix (plain number) + yield 'plain_integer' => ['100', 100.0]; + yield 'plain_decimal' => ['4.7', 4.7]; + + // Decimal values with prefix (dot separator) + yield 'decimal_nano' => ['4.7nF', 4.7e-9]; + yield 'decimal_micro' => ['0.1uF', 0.1e-6]; + yield 'decimal_kilo' => ['2.2k', 2.2e3]; + + // Comma decimal separator (European locale) + yield 'comma_kilo' => ['4,7k', 4.7e3]; + yield 'comma_micro' => ['2,2uF', 2.2e-6]; + yield 'comma_kilo_space' => ['1,2 kΩ', 1.2e3]; + + // Number NOT at the start — should return NULL + yield 'prefixed_name' => ['CAP-100nF', null]; + yield 'name_with_number' => ['R 4.7k 1%', null]; + yield 'crystal' => ['Crystal 20MHz', null]; + + // Number at start with trailing text + yield 'number_with_suffix' => ['10nF 25V', 10e-9]; + + // Space between number and prefix + yield 'space_before_prefix' => ['100 nF', 100e-9]; + + // Leading whitespace before number + yield 'leading_whitespace' => [' 10uF', 10e-6]; + + // No number at all + yield 'no_number' => ['Connector', null]; + yield 'text_only' => ['LED red', null]; + + // Null input + yield 'null' => [null, null]; + + // Empty string + yield 'empty' => ['', null]; + } + + /** + * Test that the sort order is correct by comparing sqliteSiValue results. + */ + public function testSortOrder(): void + { + $parts = ['1uF', '100nF', '10pF', '10uF', '0.1mF', '1F', '10kF', '1MF']; + $expected = ['10pF', '100nF', '1uF', '10uF', '0.1mF', '1F', '10kF', '1MF']; + + // Sort using sqliteSiValue + usort($parts, static function (string $a, string $b): int { + $va = SiValueSort::sqliteSiValue($a); + $vb = SiValueSort::sqliteSiValue($b); + return $va <=> $vb; + }); + + $this->assertSame($expected, $parts); + } + + /** + * Test that NULL values sort last (after all numeric values). + */ + public function testNullSortsLast(): void + { + $parts = ['Connector', '100nF', 'LED red', '10pF']; + + usort($parts, static function (string $a, string $b): int { + $va = SiValueSort::sqliteSiValue($a); + $vb = SiValueSort::sqliteSiValue($b); + + // NULL sorts last + if ($va === null && $vb === null) { + return 0; + } + if ($va === null) { + return 1; + } + if ($vb === null) { + return -1; + } + + return $va <=> $vb; + }); + + $this->assertSame('10pF', $parts[0]); + $this->assertSame('100nF', $parts[1]); + // Last two should be the non-numeric names + $this->assertContains('Connector', array_slice($parts, 2)); + $this->assertContains('LED red', array_slice($parts, 2)); + } +} 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/Serializer/StructuralElementDenormalizerTest.php b/tests/Serializer/StructuralElementDenormalizerTest.php index e8e46611..c81f02e3 100644 --- a/tests/Serializer/StructuralElementDenormalizerTest.php +++ b/tests/Serializer/StructuralElementDenormalizerTest.php @@ -85,4 +85,41 @@ final class StructuralElementDenormalizerTest extends WebTestCase $result2 = $this->service->denormalize($data, Category::class, 'json', ['groups' => ['import']]); $this->assertSame($result, $result2); } + + public function testDenormalizeViaChildren(): void + { + $data = ['name' => 'Node', + 'children' => [ + ['name' => 'A', 'children' => [['name' => '1'], ['name' => '2']]], + ['name' => 'B', 'children' => [['name' => '1'], ['name' => '2']]], + ['name' => 'C', 'children' => [['name' => '1'], ['name' => '2'], ['name' => '3']]], + ] + ]; + + $result = $this->service->denormalize($data, Category::class, 'json', ['groups' => ['import', 'include_children']]); + $this->assertInstanceOf(Category::class, $result); + + $this->assertCount(3, $result->getChildren()); + $this->assertSame('A', $result->getChildren()[0]->getName()); + $this->assertSame('B', $result->getChildren()[1]->getName()); + $this->assertSame('C', $result->getChildren()[2]->getName()); + //Parents should be set correctly + $this->assertSame($result, $result->getChildren()[0]->getParent()); + $this->assertSame($result, $result->getChildren()[1]->getParent()); + $this->assertSame($result, $result->getChildren()[2]->getParent()); + + $this->assertCount(2, $result->getChildren()[0]->getChildren()); + $this->assertSame('1', $result->getChildren()[0]->getChildren()[0]->getName()); + $this->assertSame('2', $result->getChildren()[0]->getChildren()[1]->getName()); + //Parents should be set correctly + $this->assertSame($result->getChildren()[0], $result->getChildren()[0]->getChildren()[0]->getParent()); + $this->assertSame($result->getChildren()[0], $result->getChildren()[0]->getChildren()[1]->getParent()); + + $this->assertCount(2, $result->getChildren()[1]->getChildren()); + $this->assertSame('1', $result->getChildren()[1]->getChildren()[0]->getName()); + $this->assertSame('2', $result->getChildren()[1]->getChildren()[1]->getName()); + //Must be different instances than the children of A, because we create new elements for the same path, if we don't have them in the DB + $this->assertNotSame($result->getChildren()[0]->getChildren()[0], $result->getChildren()[1]->getChildren()[0]); + + } } diff --git a/tests/Services/AI/AIPlatformRegistryTest.php b/tests/Services/AI/AIPlatformRegistryTest.php new file mode 100644 index 00000000..1577f9b5 --- /dev/null +++ b/tests/Services/AI/AIPlatformRegistryTest.php @@ -0,0 +1,99 @@ +. + */ + +/** + * Tests for App\Services\AI\AIPlatformRegistry + */ +declare(strict_types=1); + +namespace App\Tests\Services\AI; + +use App\Services\AI\AIPlatformRegistry; +use App\Services\AI\AIPlatforms; +use App\Services\AI\AIPlatformSettingsInterface; +use Jbtronics\SettingsBundle\Manager\SettingsManagerInterface; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\PlatformInterface; + +class AIPlatformRegistryTest extends TestCase +{ + public function testRegistersEnabledPlatformsAndReturnsPlatform(): void + { + // Create a platform mock and expose it under the service tag name (openrouter) + $platformMock = $this->createMock(PlatformInterface::class); + + // Settings for OpenRouter -> enabled + $openRouterSettings = $this->createMock(AIPlatformSettingsInterface::class); + $openRouterSettings->method('isAIPlatformEnabled')->willReturn(true); + + // Settings for LMStudio -> disabled + $lmSettings = $this->createMock(AIPlatformSettingsInterface::class); + $lmSettings->method('isAIPlatformEnabled')->willReturn(false); + + // Settings manager should return the corresponding settings object depending on the requested class name + $settingsManager = $this->createMock(SettingsManagerInterface::class); + $settingsManager->method('get')->willReturnMap([ + [AIPlatforms::OPENROUTER->toSettingsClass(), $openRouterSettings], + [AIPlatforms::LMSTUDIO->toSettingsClass(), $lmSettings], + ]); + + $platforms = new \ArrayIterator([ + AIPlatforms::OPENROUTER->toServiceTagName() => $platformMock, + ]); + + $registry = new AIPlatformRegistry($settingsManager, $platforms); + + // OPENROUTER should be enabled and retrievable + $this->assertTrue($registry->isEnabled(AIPlatforms::OPENROUTER)); + $this->assertSame($platformMock, $registry->getPlatform(AIPlatforms::OPENROUTER)); + + // LMSTUDIO is either not registered or disabled -> should not be enabled + $this->assertFalse($registry->isEnabled(AIPlatforms::LMSTUDIO)); + $this->expectException(\InvalidArgumentException::class); + $registry->getPlatform(AIPlatforms::LMSTUDIO); + } + + public function testGetEnabledPlatformsReturnsIndexedArray(): void + { + $platformMock = $this->createMock(PlatformInterface::class); + + $openRouterSettings = $this->createMock(AIPlatformSettingsInterface::class); + $openRouterSettings->method('isAIPlatformEnabled')->willReturn(true); + + $settingsManager = $this->createMock(SettingsManagerInterface::class); + $settingsManager->method('get')->willReturnMap([ + [AIPlatforms::OPENROUTER->toSettingsClass(), $openRouterSettings], + [AIPlatforms::LMSTUDIO->toSettingsClass(), $this->createMock(AIPlatformSettingsInterface::class)], + ]); + + $platforms = new \ArrayIterator([ + AIPlatforms::OPENROUTER->toServiceTagName() => $platformMock, + // lmstudio not registered + ]); + + $registry = new AIPlatformRegistry($settingsManager, $platforms); + + $enabled = $registry->getEnabledPlatforms(); + + $this->assertArrayHasKey(AIPlatforms::OPENROUTER->value, $enabled); + $this->assertSame($platformMock, $enabled[AIPlatforms::OPENROUTER->value]); + } +} + 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('