diff --git a/CHANGELOG.md b/CHANGELOG.md index 337284d18..bc4968012 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ All notable changes to this project will be documented in this file. - Aligned with v. 2.5.2. - Removed themes. - Added command to migrate config.json files. -- Fix data fetching bug + tests +- Fix data fetching bug and tests - Refactored screen layout commands. - Moved list components (search and checkboxes) around. - Aligned environment variable names. diff --git a/assets/admin/components/slide/slide-manager.jsx b/assets/admin/components/slide/slide-manager.jsx index 4ff804386..eeee9320b 100644 --- a/assets/admin/components/slide/slide-manager.jsx +++ b/assets/admin/components/slide/slide-manager.jsx @@ -4,6 +4,7 @@ import { ulid } from "ulid"; import { useDispatch } from "react-redux"; import dayjs from "dayjs"; import { useNavigate } from "react-router-dom"; +import rebuildMediaFromContent from "./slide-media-utils"; import UserContext from "../../context/user-context"; import { enhancedApi, @@ -337,13 +338,11 @@ function SlideManager({ const localFormStateObject = { ...formStateObject }; const localMediaData = { ...mediaData }; // Set field as a field to look into for new references. - setMediaFields([...new Set([...mediaFields, fieldId])]); + const updatedMediaFields = [...new Set([...mediaFields, fieldId])]; + setMediaFields(updatedMediaFields); const newField = []; - if (Array.isArray(fieldValue) && fieldValue.length === 0) { - localFormStateObject.media = []; - } // Handle each entry in field. if (Array.isArray(fieldValue)) { fieldValue.forEach((entry) => { @@ -383,17 +382,19 @@ function SlideManager({ !Object.prototype.hasOwnProperty.call(localMediaData, entry["@id"]) ) { set(localMediaData, entry["@id"], entry); - - localFormStateObject.media.push(entry["@id"]); } } }); } set(localFormStateObject.content, fieldId, newField); - set(localFormStateObject, "media", [ - ...new Set([...localFormStateObject.media]), - ]); + + // Rebuild the media array from all content fields to keep it in sync. + set( + localFormStateObject, + "media", + rebuildMediaFromContent(localFormStateObject.content), + ); setFormStateObject(localFormStateObject); setMediaData(localMediaData); diff --git a/assets/admin/components/slide/slide-media-utils.js b/assets/admin/components/slide/slide-media-utils.js new file mode 100644 index 000000000..8c78bfb41 --- /dev/null +++ b/assets/admin/components/slide/slide-media-utils.js @@ -0,0 +1,61 @@ +/** + * Rebuild the media array from all content fields that reference media. + * + * This ensures that the top-level `media` array (sent to the API as slide_media + * associations) always matches the media actually referenced in the slide's + * `content` object. + * + * @param {object} content - The slide content object. + * @returns {string[]} Deduplicated array of media IRIs. + */ +export default function rebuildMediaFromContent(content) { + const media = []; + + const mediaIriRegex = /\/v2\/media\/.+/; + + const isMediaIri = (value) => + typeof value === "string" && + !value.startsWith("TEMP--") && + mediaIriRegex.test(value); + + const collectMediaFromValue = (value, seen = new Set()) => { + // 1) Ignore empty values early (nothing to scan) + if (value === null || value === undefined) return; + + // 2) If it's a string, it might be a media IRI; validate and collect it + if (typeof value === "string") { + if (isMediaIri(value)) media.push(value); + return; + } + + // 3) If it's not an object (e.g. number/boolean/function), it cannot contain nested media + if (typeof value !== "object") return; + + // 4) Defensive guard against circular references: + // - JSON content won't have cycles, but runtime objects might. + // - If we've seen this object/array already, stop to avoid infinite recursion. + if (seen.has(value)) return; + seen.add(value); + + // 5) If it's an array, scan each element (elements can be strings, objects, or more arrays) + if (Array.isArray(value)) { + value.forEach((item) => collectMediaFromValue(item, seen)); + return; + } + + // 6) Otherwise it's a plain object: scan its property values recursively + Object.values(value).forEach((item) => collectMediaFromValue(item, seen)); + }; + + const fieldsToScan = new Set([]); + + // Scan content for media references. + if (content && typeof content === "object") { + Object.keys(content).forEach((key) => fieldsToScan.add(key)); + } + + // Scan the entire content object (one traversal) + collectMediaFromValue(content); + + return [...new Set(media)]; +} diff --git a/assets/shared/templates/image-text.jsx b/assets/shared/templates/image-text.jsx index de32bd6d6..6f6b33baa 100644 --- a/assets/shared/templates/image-text.jsx +++ b/assets/shared/templates/image-text.jsx @@ -42,6 +42,7 @@ function renderSlide(slide, run, slideDone) { */ function ImageText({ slide, content, run, slideDone, executionId }) { const imageTimeoutRef = useRef(); + const imagesRef = useRef([]); const [images, setImages] = useState([]); const [currentImage, setCurrentImage] = useState(); const logo = slide?.theme?.logo; @@ -132,13 +133,15 @@ function ImageText({ slide, content, run, slideDone, executionId }) { } const changeImage = (newIndex) => { - if (newIndex < images.length) { - setCurrentImage(images[newIndex]); + const currentImages = imagesRef.current; - if (newIndex < images.length - 1) { + if (newIndex < currentImages.length) { + setCurrentImage(currentImages[newIndex]); + + if (newIndex < currentImages.length - 1) { imageTimeoutRef.current = setTimeout( () => changeImage(newIndex + 1), - duration / images.length, + duration / currentImages.length, ); } } @@ -152,33 +155,41 @@ function ImageText({ slide, content, run, slideDone, executionId }) { ); if (imageUrls?.length > 0) { - const newImages = imageUrls.map((url) => { - return { - url, - nodeRef: createRef(), - }; - }); + const newImages = imageUrls.map((url) => ({ + url, + nodeRef: createRef(), + })); + + imagesRef.current = newImages; setImages(newImages); + } else { + imagesRef.current = []; + setImages([]); } } }, [slide]); const startTheShow = () => { - if (images?.length > 0 && !currentImage) { - setCurrentImage(images[0]); + if (imageTimeoutRef.current) { + clearTimeout(imageTimeoutRef.current); } - // If there are multiple images, we are going to loop through these WITHIN the set duration. - if (images?.length > 1) { + const currentImages = imagesRef.current; + + if (currentImages.length > 1) { // Kickoff the display of multiple images with the zero indexed changeImage(0); + } else if (currentImages.length === 1) { + setCurrentImage(currentImages[0]); } }; useEffect(() => { - if (!currentImage) { + if (images.length > 0) { startTheShow(); + } else { + setCurrentImage(undefined); } }, [images]); diff --git a/assets/tests/admin/admin-slide-media-sync.spec.js b/assets/tests/admin/admin-slide-media-sync.spec.js new file mode 100644 index 000000000..6f8164888 --- /dev/null +++ b/assets/tests/admin/admin-slide-media-sync.spec.js @@ -0,0 +1,116 @@ +import { test, expect } from "@playwright/test"; +import rebuildMediaFromContent from "../../admin/components/slide/slide-media-utils"; + +test.describe("Slide media sync", () => { + test("It returns media IRIs referenced in content fields", () => { + const content = { + mainImage: ["/v2/media/1", "/v2/media/2"], + backgroundVideo: ["/v2/media/3"], + }; + + const result = rebuildMediaFromContent(content); + + expect(result).toEqual(["/v2/media/1", "/v2/media/2", "/v2/media/3"]); + }); + + test("It excludes TEMP-- IDs that have not been uploaded yet", () => { + const content = { + mainImage: ["TEMP--abc123", "/v2/media/1"], + }; + + const result = rebuildMediaFromContent(content); + + expect(result).toEqual(["/v2/media/1"]); + }); + + test("It removes media no longer referenced in any content field", () => { + const content = { + mainImage: ["/v2/media/2"], + }; + + const result = rebuildMediaFromContent(content); + + expect(result).toEqual(["/v2/media/2"]); + expect(result).not.toContain("/v2/media/1"); + }); + + test("It returns empty array when all media is removed from content", () => { + const content = { + mainImage: [], + backgroundVideo: ["/v2/media/3"], + }; + + const result = rebuildMediaFromContent(content); + + expect(result).toEqual(["/v2/media/3"]); + }); + + test("It deduplicates media used across multiple content fields", () => { + const content = { + mainImage: ["/v2/media/1"], + thumbnail: ["/v2/media/1"], + }; + + const result = rebuildMediaFromContent(content); + + expect(result).toEqual(["/v2/media/1"]); + }); + + test("It handles non-existent content fields gracefully", () => { + const content = {}; + + const result = rebuildMediaFromContent(content); + + expect(result).toEqual([]); + }); + + test("It handles nested content field paths", () => { + const content = { + sections: { + hero: ["/v2/media/1"], + }, + }; + + const result = rebuildMediaFromContent(content); + + expect(result).toEqual(["/v2/media/1"]); + }); + + test("It ignores non-media content values when scanning top-level keys", () => { + const content = { + images: ["/v2/media/1"], + title: "Some text", + separator: true, + contacts: [{ name: "John", image: ["/v2/media/2"], tags: ["news"] }], + }; + + const result = rebuildMediaFromContent(content); + + expect(result).toContain("/v2/media/1"); + expect(result).toContain("/v2/media/2"); + expect(result).not.toContain("news"); + }); + + test("It does not include non-media string arrays from content", () => { + const content = { + images: ["/v2/media/1"], + tags: ["news", "sports"], + }; + + const result = rebuildMediaFromContent(content); + + expect(result).toEqual(["/v2/media/1"]); + expect(result).not.toContain("news"); + expect(result).not.toContain("sports"); + }); + + test("It avoids infinite recursion when content contains circular references", () => { + const circular = { images: ["/v2/media/1"] }; + circular.self = circular; // create an explicit cycle + + expect(() => rebuildMediaFromContent(circular)).not.toThrow(); + + const result = rebuildMediaFromContent(circular); + expect(result).toContain("/v2/media/1"); + }); +}); diff --git a/composer.lock b/composer.lock index d6fd19db3..846da321c 100644 --- a/composer.lock +++ b/composer.lock @@ -755,16 +755,16 @@ }, { "name": "doctrine/collections", - "version": "2.4.0", + "version": "2.6.0", "source": { "type": "git", "url": "https://github.com/doctrine/collections.git", - "reference": "9acfeea2e8666536edff3d77c531261c63680160" + "reference": "7713da39d8e237f28411d6a616a3dce5e20d5de2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/collections/zipball/9acfeea2e8666536edff3d77c531261c63680160", - "reference": "9acfeea2e8666536edff3d77c531261c63680160", + "url": "https://api.github.com/repos/doctrine/collections/zipball/7713da39d8e237f28411d6a616a3dce5e20d5de2", + "reference": "7713da39d8e237f28411d6a616a3dce5e20d5de2", "shasum": "" }, "require": { @@ -821,7 +821,7 @@ ], "support": { "issues": "https://github.com/doctrine/collections/issues", - "source": "https://github.com/doctrine/collections/tree/2.4.0" + "source": "https://github.com/doctrine/collections/tree/2.6.0" }, "funding": [ { @@ -837,7 +837,7 @@ "type": "tidelift" } ], - "time": "2025-10-25T09:18:13+00:00" + "time": "2026-01-15T10:01:58+00:00" }, { "name": "doctrine/common", @@ -932,16 +932,16 @@ }, { "name": "doctrine/dbal", - "version": "3.10.4", + "version": "3.10.5", "source": { "type": "git", "url": "https://github.com/doctrine/dbal.git", - "reference": "63a46cb5aa6f60991186cc98c1d1b50c09311868" + "reference": "95d84866bf3c04b2ddca1df7c049714660959aef" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/dbal/zipball/63a46cb5aa6f60991186cc98c1d1b50c09311868", - "reference": "63a46cb5aa6f60991186cc98c1d1b50c09311868", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/95d84866bf3c04b2ddca1df7c049714660959aef", + "reference": "95d84866bf3c04b2ddca1df7c049714660959aef", "shasum": "" }, "require": { @@ -962,9 +962,9 @@ "jetbrains/phpstorm-stubs": "2023.1", "phpstan/phpstan": "2.1.30", "phpstan/phpstan-strict-rules": "^2", - "phpunit/phpunit": "9.6.29", - "slevomat/coding-standard": "8.24.0", - "squizlabs/php_codesniffer": "4.0.0", + "phpunit/phpunit": "9.6.34", + "slevomat/coding-standard": "8.27.1", + "squizlabs/php_codesniffer": "4.0.1", "symfony/cache": "^5.4|^6.0|^7.0|^8.0", "symfony/console": "^4.4|^5.4|^6.0|^7.0|^8.0" }, @@ -1026,7 +1026,7 @@ ], "support": { "issues": "https://github.com/doctrine/dbal/issues", - "source": "https://github.com/doctrine/dbal/tree/3.10.4" + "source": "https://github.com/doctrine/dbal/tree/3.10.5" }, "funding": [ { @@ -1042,33 +1042,33 @@ "type": "tidelift" } ], - "time": "2025-11-29T10:46:08+00:00" + "time": "2026-02-24T08:03:57+00:00" }, { "name": "doctrine/deprecations", - "version": "1.1.5", + "version": "1.1.6", "source": { "type": "git", "url": "https://github.com/doctrine/deprecations.git", - "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38" + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", - "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", "shasum": "" }, "require": { "php": "^7.1 || ^8.0" }, "conflict": { - "phpunit/phpunit": "<=7.5 || >=13" + "phpunit/phpunit": "<=7.5 || >=14" }, "require-dev": { - "doctrine/coding-standard": "^9 || ^12 || ^13", - "phpstan/phpstan": "1.4.10 || 2.1.11", + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", "phpstan/phpstan-phpunit": "^1.0 || ^2", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", "psr/log": "^1 || ^2 || ^3" }, "suggest": { @@ -1088,9 +1088,9 @@ "homepage": "https://www.doctrine-project.org/", "support": { "issues": "https://github.com/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/1.1.5" + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" }, - "time": "2025-04-07T20:06:18+00:00" + "time": "2026-02-07T07:09:04+00:00" }, { "name": "doctrine/doctrine-bundle", @@ -1300,16 +1300,16 @@ }, { "name": "doctrine/event-manager", - "version": "2.0.1", + "version": "2.1.1", "source": { "type": "git", "url": "https://github.com/doctrine/event-manager.git", - "reference": "b680156fa328f1dfd874fd48c7026c41570b9c6e" + "reference": "dda33921b198841ca8dbad2eaa5d4d34769d18cf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/event-manager/zipball/b680156fa328f1dfd874fd48c7026c41570b9c6e", - "reference": "b680156fa328f1dfd874fd48c7026c41570b9c6e", + "url": "https://api.github.com/repos/doctrine/event-manager/zipball/dda33921b198841ca8dbad2eaa5d4d34769d18cf", + "reference": "dda33921b198841ca8dbad2eaa5d4d34769d18cf", "shasum": "" }, "require": { @@ -1319,10 +1319,10 @@ "doctrine/common": "<2.9" }, "require-dev": { - "doctrine/coding-standard": "^12", - "phpstan/phpstan": "^1.8.8", - "phpunit/phpunit": "^10.5", - "vimeo/psalm": "^5.24" + "doctrine/coding-standard": "^14", + "phpdocumentor/guides-cli": "^1.4", + "phpstan/phpstan": "^2.1.32", + "phpunit/phpunit": "^10.5.58" }, "type": "library", "autoload": { @@ -1371,7 +1371,7 @@ ], "support": { "issues": "https://github.com/doctrine/event-manager/issues", - "source": "https://github.com/doctrine/event-manager/tree/2.0.1" + "source": "https://github.com/doctrine/event-manager/tree/2.1.1" }, "funding": [ { @@ -1387,7 +1387,7 @@ "type": "tidelift" } ], - "time": "2024-05-22T20:47:39+00:00" + "time": "2026-01-29T07:11:08+00:00" }, { "name": "doctrine/inflector", @@ -1628,16 +1628,16 @@ }, { "name": "doctrine/migrations", - "version": "3.9.5", + "version": "3.9.6", "source": { "type": "git", "url": "https://github.com/doctrine/migrations.git", - "reference": "1b823afbc40f932dae8272574faee53f2755eac5" + "reference": "ffd8355cdd8505fc650d9604f058bf62aedd80a1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/migrations/zipball/1b823afbc40f932dae8272574faee53f2755eac5", - "reference": "1b823afbc40f932dae8272574faee53f2755eac5", + "url": "https://api.github.com/repos/doctrine/migrations/zipball/ffd8355cdd8505fc650d9604f058bf62aedd80a1", + "reference": "ffd8355cdd8505fc650d9604f058bf62aedd80a1", "shasum": "" }, "require": { @@ -1711,7 +1711,7 @@ ], "support": { "issues": "https://github.com/doctrine/migrations/issues", - "source": "https://github.com/doctrine/migrations/tree/3.9.5" + "source": "https://github.com/doctrine/migrations/tree/3.9.6" }, "funding": [ { @@ -1727,7 +1727,7 @@ "type": "tidelift" } ], - "time": "2025-11-20T11:15:36+00:00" + "time": "2026-02-11T06:46:11+00:00" }, { "name": "doctrine/orm", @@ -1928,16 +1928,16 @@ }, { "name": "doctrine/sql-formatter", - "version": "1.5.3", + "version": "1.5.4", "source": { "type": "git", "url": "https://github.com/doctrine/sql-formatter.git", - "reference": "a8af23a8e9d622505baa2997465782cbe8bb7fc7" + "reference": "9563949f5cd3bd12a17d12fb980528bc141c5806" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/sql-formatter/zipball/a8af23a8e9d622505baa2997465782cbe8bb7fc7", - "reference": "a8af23a8e9d622505baa2997465782cbe8bb7fc7", + "url": "https://api.github.com/repos/doctrine/sql-formatter/zipball/9563949f5cd3bd12a17d12fb980528bc141c5806", + "reference": "9563949f5cd3bd12a17d12fb980528bc141c5806", "shasum": "" }, "require": { @@ -1977,9 +1977,9 @@ ], "support": { "issues": "https://github.com/doctrine/sql-formatter/issues", - "source": "https://github.com/doctrine/sql-formatter/tree/1.5.3" + "source": "https://github.com/doctrine/sql-formatter/tree/1.5.4" }, - "time": "2025-10-26T09:35:14+00:00" + "time": "2026-02-08T16:21:46+00:00" }, { "name": "evenement/evenement", @@ -2154,16 +2154,16 @@ }, { "name": "friendsofphp/php-cs-fixer", - "version": "v3.92.4", + "version": "v3.94.2", "source": { "type": "git", "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", - "reference": "9e7488b19403423e02e8403cc1eb596baf4673b0" + "reference": "7787ceff91365ba7d623ec410b8f429cdebb4f63" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/9e7488b19403423e02e8403cc1eb596baf4673b0", - "reference": "9e7488b19403423e02e8403cc1eb596baf4673b0", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/7787ceff91365ba7d623ec410b8f429cdebb4f63", + "reference": "7787ceff91365ba7d623ec410b8f429cdebb4f63", "shasum": "" }, "require": { @@ -2180,7 +2180,7 @@ "react/event-loop": "^1.5", "react/socket": "^1.16", "react/stream": "^1.4", - "sebastian/diff": "^4.0.6 || ^5.1.1 || ^6.0.2 || ^7.0", + "sebastian/diff": "^4.0.6 || ^5.1.1 || ^6.0.2 || ^7.0 || ^8.0", "symfony/console": "^5.4.47 || ^6.4.24 || ^7.0 || ^8.0", "symfony/event-dispatcher": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", "symfony/filesystem": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", @@ -2194,18 +2194,18 @@ "symfony/stopwatch": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0" }, "require-dev": { - "facile-it/paraunit": "^1.3.1 || ^2.7", - "infection/infection": "^0.31", - "justinrainbow/json-schema": "^6.6", + "facile-it/paraunit": "^1.3.1 || ^2.7.1", + "infection/infection": "^0.32.3", + "justinrainbow/json-schema": "^6.6.4", "keradus/cli-executor": "^2.3", "mikey179/vfsstream": "^1.6.12", - "php-coveralls/php-coveralls": "^2.9", - "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.6", - "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.6", - "phpunit/phpunit": "^9.6.31 || ^10.5.60 || ^11.5.46", + "php-coveralls/php-coveralls": "^2.9.1", + "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.7", + "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.7", + "phpunit/phpunit": "^9.6.34 || ^10.5.63 || ^11.5.51", "symfony/polyfill-php85": "^1.33", - "symfony/var-dumper": "^5.4.48 || ^6.4.26 || ^7.4.0 || ^8.0", - "symfony/yaml": "^5.4.45 || ^6.4.30 || ^7.4.1 || ^8.0" + "symfony/var-dumper": "^5.4.48 || ^6.4.32 || ^7.4.4 || ^8.0.4", + "symfony/yaml": "^5.4.45 || ^6.4.30 || ^7.4.1 || ^8.0.1" }, "suggest": { "ext-dom": "For handling output formats in XML", @@ -2246,7 +2246,7 @@ ], "support": { "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", - "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.92.4" + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.94.2" }, "funding": [ { @@ -2254,7 +2254,7 @@ "type": "github" } ], - "time": "2026-01-04T00:38:52+00:00" + "time": "2026-02-20T16:13:53+00:00" }, { "name": "friendsofphp/proxy-manager-lts", @@ -2745,16 +2745,16 @@ }, { "name": "imagine/imagine", - "version": "1.5.1", + "version": "1.5.2", "source": { "type": "git", "url": "https://github.com/php-imagine/Imagine.git", - "reference": "8b130cd281efdea67e52d5f0f998572eb62d2f04" + "reference": "f9ed796eefb77c2f0f2167e1d4e36bc2b5ed6b0c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-imagine/Imagine/zipball/8b130cd281efdea67e52d5f0f998572eb62d2f04", - "reference": "8b130cd281efdea67e52d5f0f998572eb62d2f04", + "url": "https://api.github.com/repos/php-imagine/Imagine/zipball/f9ed796eefb77c2f0f2167e1d4e36bc2b5ed6b0c", + "reference": "f9ed796eefb77c2f0f2167e1d4e36bc2b5ed6b0c", "shasum": "" }, "require": { @@ -2801,9 +2801,9 @@ ], "support": { "issues": "https://github.com/php-imagine/Imagine/issues", - "source": "https://github.com/php-imagine/Imagine/tree/1.5.1" + "source": "https://github.com/php-imagine/Imagine/tree/1.5.2" }, - "time": "2025-12-09T15:27:47+00:00" + "time": "2026-01-09T10:45:12+00:00" }, { "name": "itk-dev/openid-connect", @@ -2997,16 +2997,16 @@ }, { "name": "justinrainbow/json-schema", - "version": "5.3.1", + "version": "5.3.2", "source": { "type": "git", "url": "https://github.com/jsonrainbow/json-schema.git", - "reference": "b5a44b6391a3bbb75c9f2b73e1ef03d6045e1e20" + "reference": "2f7abf648939847a789c55c206d4cb9dd0d53e2c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/b5a44b6391a3bbb75c9f2b73e1ef03d6045e1e20", - "reference": "b5a44b6391a3bbb75c9f2b73e1ef03d6045e1e20", + "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/2f7abf648939847a789c55c206d4cb9dd0d53e2c", + "reference": "2f7abf648939847a789c55c206d4cb9dd0d53e2c", "shasum": "" }, "require": { @@ -3056,22 +3056,22 @@ ], "support": { "issues": "https://github.com/jsonrainbow/json-schema/issues", - "source": "https://github.com/jsonrainbow/json-schema/tree/5.3.1" + "source": "https://github.com/jsonrainbow/json-schema/tree/5.3.2" }, - "time": "2025-12-12T08:56:22+00:00" + "time": "2026-02-27T12:33:19+00:00" }, { "name": "kubawerlos/php-cs-fixer-custom-fixers", - "version": "v3.35.1", + "version": "v3.36.0", "source": { "type": "git", "url": "https://github.com/kubawerlos/php-cs-fixer-custom-fixers.git", - "reference": "2a35f80ae24ca77443a7af1599c3a3db1b6bd395" + "reference": "e1f97f6463f0b2a22e0dd320948a04132ff9c501" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/kubawerlos/php-cs-fixer-custom-fixers/zipball/2a35f80ae24ca77443a7af1599c3a3db1b6bd395", - "reference": "2a35f80ae24ca77443a7af1599c3a3db1b6bd395", + "url": "https://api.github.com/repos/kubawerlos/php-cs-fixer-custom-fixers/zipball/e1f97f6463f0b2a22e0dd320948a04132ff9c501", + "reference": "e1f97f6463f0b2a22e0dd320948a04132ff9c501", "shasum": "" }, "require": { @@ -3081,7 +3081,7 @@ "php": "^7.4 || ^8.0" }, "require-dev": { - "phpunit/phpunit": "^9.6.24 || ^10.5.51 || ^11.5.32" + "phpunit/phpunit": "^9.6.24 || ^10.5.51 || ^11.5.44" }, "type": "library", "autoload": { @@ -3102,7 +3102,7 @@ "description": "A set of custom fixers for PHP CS Fixer", "support": { "issues": "https://github.com/kubawerlos/php-cs-fixer-custom-fixers/issues", - "source": "https://github.com/kubawerlos/php-cs-fixer-custom-fixers/tree/v3.35.1" + "source": "https://github.com/kubawerlos/php-cs-fixer-custom-fixers/tree/v3.36.0" }, "funding": [ { @@ -3110,7 +3110,7 @@ "type": "github" } ], - "time": "2025-09-28T18:43:35+00:00" + "time": "2026-01-31T07:02:11+00:00" }, { "name": "laminas/laminas-code", @@ -3498,22 +3498,22 @@ }, { "name": "liip/imagine-bundle", - "version": "2.16.0", + "version": "2.17.1", "source": { "type": "git", "url": "https://github.com/liip/LiipImagineBundle.git", - "reference": "335121ef65d9841af9b40a850aa143cd6b61f847" + "reference": "69d2df3c6606495d1878fa190d6c3dc4bc5623b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/liip/LiipImagineBundle/zipball/335121ef65d9841af9b40a850aa143cd6b61f847", - "reference": "335121ef65d9841af9b40a850aa143cd6b61f847", + "url": "https://api.github.com/repos/liip/LiipImagineBundle/zipball/69d2df3c6606495d1878fa190d6c3dc4bc5623b6", + "reference": "69d2df3c6606495d1878fa190d6c3dc4bc5623b6", "shasum": "" }, "require": { "ext-mbstring": "*", "imagine/imagine": "^1.3.2", - "php": "^7.2|^8.0", + "php": "^8.0", "symfony/dependency-injection": "^5.4|^6.4|^7.4|^8.0", "symfony/deprecation-contracts": "^2.5 || ^3", "symfony/filesystem": "^5.4|^6.4|^7.3|^8.0", @@ -3542,6 +3542,7 @@ "symfony/form": "^5.4|^6.4|^7.3|^8.0", "symfony/messenger": "^5.4|^6.4|^7.3|^8.0", "symfony/phpunit-bridge": "^7.3", + "symfony/runtime": "^5.4|^6.4|^7.3|^8.0", "symfony/templating": "^5.4|^6.4|^7.3|^8.0", "symfony/validator": "^5.4|^6.4|^7.3|^8.0", "symfony/yaml": "^5.4|^6.4|^7.3|^8.0" @@ -3599,9 +3600,9 @@ ], "support": { "issues": "https://github.com/liip/LiipImagineBundle/issues", - "source": "https://github.com/liip/LiipImagineBundle/tree/2.16.0" + "source": "https://github.com/liip/LiipImagineBundle/tree/2.17.1" }, - "time": "2025-12-01T10:49:05+00:00" + "time": "2026-01-06T09:34:48+00:00" }, { "name": "masterminds/html5", @@ -3842,16 +3843,16 @@ }, { "name": "nelmio/cors-bundle", - "version": "2.6.0", + "version": "2.6.1", "source": { "type": "git", "url": "https://github.com/nelmio/NelmioCorsBundle.git", - "reference": "530217472204881cacd3671909f634b960c7b948" + "reference": "3d80dbcd5d1eb5f8b20ed5199e1778d44c2e4d1c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nelmio/NelmioCorsBundle/zipball/530217472204881cacd3671909f634b960c7b948", - "reference": "530217472204881cacd3671909f634b960c7b948", + "url": "https://api.github.com/repos/nelmio/NelmioCorsBundle/zipball/3d80dbcd5d1eb5f8b20ed5199e1778d44c2e4d1c", + "reference": "3d80dbcd5d1eb5f8b20ed5199e1778d44c2e4d1c", "shasum": "" }, "require": { @@ -3901,9 +3902,9 @@ ], "support": { "issues": "https://github.com/nelmio/NelmioCorsBundle/issues", - "source": "https://github.com/nelmio/NelmioCorsBundle/tree/2.6.0" + "source": "https://github.com/nelmio/NelmioCorsBundle/tree/2.6.1" }, - "time": "2025-10-23T06:57:22+00:00" + "time": "2026-01-12T15:59:08+00:00" }, { "name": "nyholm/psr7", @@ -5649,16 +5650,16 @@ }, { "name": "symfony/asset", - "version": "v6.4.24", + "version": "v6.4.34", "source": { "type": "git", "url": "https://github.com/symfony/asset.git", - "reference": "cfee7c0d64be113383db74a2fdd65d426b7f3aab" + "reference": "1bd59aa278691b6310ca56b996cf6e2619a6a347" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/asset/zipball/cfee7c0d64be113383db74a2fdd65d426b7f3aab", - "reference": "cfee7c0d64be113383db74a2fdd65d426b7f3aab", + "url": "https://api.github.com/repos/symfony/asset/zipball/1bd59aa278691b6310ca56b996cf6e2619a6a347", + "reference": "1bd59aa278691b6310ca56b996cf6e2619a6a347", "shasum": "" }, "require": { @@ -5698,7 +5699,7 @@ "description": "Manages URL generation and versioning of web assets such as CSS stylesheets, JavaScript files and image files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/asset/tree/v6.4.24" + "source": "https://github.com/symfony/asset/tree/v6.4.34" }, "funding": [ { @@ -5718,20 +5719,20 @@ "type": "tidelift" } ], - "time": "2025-07-10T08:14:14+00:00" + "time": "2026-02-07T09:15:39+00:00" }, { "name": "symfony/cache", - "version": "v6.4.31", + "version": "v6.4.34", "source": { "type": "git", "url": "https://github.com/symfony/cache.git", - "reference": "a1b306757c34b96fe97c0c586f50dceed05c7adb" + "reference": "a0a1690543329685c044362c873b78c6de9d4faa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache/zipball/a1b306757c34b96fe97c0c586f50dceed05c7adb", - "reference": "a1b306757c34b96fe97c0c586f50dceed05c7adb", + "url": "https://api.github.com/repos/symfony/cache/zipball/a0a1690543329685c044362c873b78c6de9d4faa", + "reference": "a0a1690543329685c044362c873b78c6de9d4faa", "shasum": "" }, "require": { @@ -5798,7 +5799,7 @@ "psr6" ], "support": { - "source": "https://github.com/symfony/cache/tree/v6.4.31" + "source": "https://github.com/symfony/cache/tree/v6.4.34" }, "funding": [ { @@ -5818,7 +5819,7 @@ "type": "tidelift" } ], - "time": "2025-12-27T18:26:25+00:00" + "time": "2026-02-20T15:06:30+00:00" }, { "name": "symfony/cache-contracts", @@ -5976,16 +5977,16 @@ }, { "name": "symfony/config", - "version": "v6.4.28", + "version": "v6.4.34", "source": { "type": "git", "url": "https://github.com/symfony/config.git", - "reference": "15947c18ef3ddb0b2f4ec936b9e90e2520979f62" + "reference": "ce9cb0c0d281aaf188b802d4968e42bfb60701e9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/config/zipball/15947c18ef3ddb0b2f4ec936b9e90e2520979f62", - "reference": "15947c18ef3ddb0b2f4ec936b9e90e2520979f62", + "url": "https://api.github.com/repos/symfony/config/zipball/ce9cb0c0d281aaf188b802d4968e42bfb60701e9", + "reference": "ce9cb0c0d281aaf188b802d4968e42bfb60701e9", "shasum": "" }, "require": { @@ -6031,7 +6032,7 @@ "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/config/tree/v6.4.28" + "source": "https://github.com/symfony/config/tree/v6.4.34" }, "funding": [ { @@ -6051,20 +6052,20 @@ "type": "tidelift" } ], - "time": "2025-11-01T19:52:02+00:00" + "time": "2026-02-24T17:34:50+00:00" }, { "name": "symfony/console", - "version": "v6.4.31", + "version": "v6.4.34", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "f9f8a889f54c264f9abac3fc0f7a371ffca51997" + "reference": "7b1f1c37eff5910ddda2831345467e593a5120ad" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/f9f8a889f54c264f9abac3fc0f7a371ffca51997", - "reference": "f9f8a889f54c264f9abac3fc0f7a371ffca51997", + "url": "https://api.github.com/repos/symfony/console/zipball/7b1f1c37eff5910ddda2831345467e593a5120ad", + "reference": "7b1f1c37eff5910ddda2831345467e593a5120ad", "shasum": "" }, "require": { @@ -6129,7 +6130,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v6.4.31" + "source": "https://github.com/symfony/console/tree/v6.4.34" }, "funding": [ { @@ -6149,20 +6150,20 @@ "type": "tidelift" } ], - "time": "2025-12-22T08:30:34+00:00" + "time": "2026-02-23T15:42:15+00:00" }, { "name": "symfony/dependency-injection", - "version": "v6.4.31", + "version": "v6.4.34", "source": { "type": "git", "url": "https://github.com/symfony/dependency-injection.git", - "reference": "10058832a74a33648870aa2057e3fdc8796a6566" + "reference": "91e49958b8a6092e48e4711894a1aeb1b151c62a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/10058832a74a33648870aa2057e3fdc8796a6566", - "reference": "10058832a74a33648870aa2057e3fdc8796a6566", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/91e49958b8a6092e48e4711894a1aeb1b151c62a", + "reference": "91e49958b8a6092e48e4711894a1aeb1b151c62a", "shasum": "" }, "require": { @@ -6214,7 +6215,7 @@ "description": "Allows you to standardize and centralize the way objects are constructed in your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dependency-injection/tree/v6.4.31" + "source": "https://github.com/symfony/dependency-injection/tree/v6.4.34" }, "funding": [ { @@ -6234,7 +6235,7 @@ "type": "tidelift" } ], - "time": "2025-12-23T13:34:50+00:00" + "time": "2026-02-24T15:33:38+00:00" }, { "name": "symfony/deprecation-contracts", @@ -6305,16 +6306,16 @@ }, { "name": "symfony/doctrine-bridge", - "version": "v6.4.26", + "version": "v6.4.34", "source": { "type": "git", "url": "https://github.com/symfony/doctrine-bridge.git", - "reference": "c14bb5a9125c411e73354954940e06b6e7fcc344" + "reference": "9e82991eda36e85b640644e1d8d34d89eff498a6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/c14bb5a9125c411e73354954940e06b6e7fcc344", - "reference": "c14bb5a9125c411e73354954940e06b6e7fcc344", + "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/9e82991eda36e85b640644e1d8d34d89eff498a6", + "reference": "9e82991eda36e85b640644e1d8d34d89eff498a6", "shasum": "" }, "require": { @@ -6393,7 +6394,7 @@ "description": "Provides integration for Doctrine with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/doctrine-bridge/tree/v6.4.26" + "source": "https://github.com/symfony/doctrine-bridge/tree/v6.4.34" }, "funding": [ { @@ -6413,20 +6414,20 @@ "type": "tidelift" } ], - "time": "2025-09-26T15:07:38+00:00" + "time": "2026-02-06T08:53:22+00:00" }, { "name": "symfony/dom-crawler", - "version": "v6.4.25", + "version": "v6.4.34", "source": { "type": "git", "url": "https://github.com/symfony/dom-crawler.git", - "reference": "976302990f9f2a6d4c07206836dd4ca77cae9524" + "reference": "ec0d22e1b89d5767a44f7abb63a1f1439bd9c735" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/976302990f9f2a6d4c07206836dd4ca77cae9524", - "reference": "976302990f9f2a6d4c07206836dd4ca77cae9524", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/ec0d22e1b89d5767a44f7abb63a1f1439bd9c735", + "reference": "ec0d22e1b89d5767a44f7abb63a1f1439bd9c735", "shasum": "" }, "require": { @@ -6464,7 +6465,7 @@ "description": "Eases DOM navigation for HTML and XML documents", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dom-crawler/tree/v6.4.25" + "source": "https://github.com/symfony/dom-crawler/tree/v6.4.34" }, "funding": [ { @@ -6484,7 +6485,7 @@ "type": "tidelift" } ], - "time": "2025-08-05T18:56:08+00:00" + "time": "2026-02-16T20:44:03+00:00" }, { "name": "symfony/dotenv", @@ -6566,16 +6567,16 @@ }, { "name": "symfony/error-handler", - "version": "v6.4.26", + "version": "v6.4.32", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "41bedcaec5b72640b0ec2096547b75fda72ead6c" + "reference": "8c18400784fcb014dc73c8d5601a9576af7f8ad4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/41bedcaec5b72640b0ec2096547b75fda72ead6c", - "reference": "41bedcaec5b72640b0ec2096547b75fda72ead6c", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/8c18400784fcb014dc73c8d5601a9576af7f8ad4", + "reference": "8c18400784fcb014dc73c8d5601a9576af7f8ad4", "shasum": "" }, "require": { @@ -6621,7 +6622,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v6.4.26" + "source": "https://github.com/symfony/error-handler/tree/v6.4.32" }, "funding": [ { @@ -6641,20 +6642,20 @@ "type": "tidelift" } ], - "time": "2025-09-11T09:57:09+00:00" + "time": "2026-01-19T19:28:19+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v6.4.25", + "version": "v6.4.32", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "b0cf3162020603587363f0551cd3be43958611ff" + "reference": "99d7e101826e6610606b9433248f80c1997cd20b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/b0cf3162020603587363f0551cd3be43958611ff", - "reference": "b0cf3162020603587363f0551cd3be43958611ff", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/99d7e101826e6610606b9433248f80c1997cd20b", + "reference": "99d7e101826e6610606b9433248f80c1997cd20b", "shasum": "" }, "require": { @@ -6705,7 +6706,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v6.4.25" + "source": "https://github.com/symfony/event-dispatcher/tree/v6.4.32" }, "funding": [ { @@ -6725,7 +6726,7 @@ "type": "tidelift" } ], - "time": "2025-08-13T09:41:44+00:00" + "time": "2026-01-05T11:13:48+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -6805,16 +6806,16 @@ }, { "name": "symfony/expression-language", - "version": "v6.4.30", + "version": "v6.4.32", "source": { "type": "git", "url": "https://github.com/symfony/expression-language.git", - "reference": "01906f3b379833b347de9abc8ddc326593e9122b" + "reference": "89c10ef5ca65968ec7ce7ce033c7f36eeb1b0312" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/expression-language/zipball/01906f3b379833b347de9abc8ddc326593e9122b", - "reference": "01906f3b379833b347de9abc8ddc326593e9122b", + "url": "https://api.github.com/repos/symfony/expression-language/zipball/89c10ef5ca65968ec7ce7ce033c7f36eeb1b0312", + "reference": "89c10ef5ca65968ec7ce7ce033c7f36eeb1b0312", "shasum": "" }, "require": { @@ -6849,7 +6850,7 @@ "description": "Provides an engine that can compile and evaluate expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/expression-language/tree/v6.4.30" + "source": "https://github.com/symfony/expression-language/tree/v6.4.32" }, "funding": [ { @@ -6869,20 +6870,20 @@ "type": "tidelift" } ], - "time": "2025-11-09T10:02:06+00:00" + "time": "2026-01-04T11:52:13+00:00" }, { "name": "symfony/filesystem", - "version": "v6.4.30", + "version": "v6.4.34", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "441c6b69f7222aadae7cbf5df588496d5ee37789" + "reference": "01ffe0411b842f93c571e5c391f289c3fdd498c3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/441c6b69f7222aadae7cbf5df588496d5ee37789", - "reference": "441c6b69f7222aadae7cbf5df588496d5ee37789", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/01ffe0411b842f93c571e5c391f289c3fdd498c3", + "reference": "01ffe0411b842f93c571e5c391f289c3fdd498c3", "shasum": "" }, "require": { @@ -6919,7 +6920,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v6.4.30" + "source": "https://github.com/symfony/filesystem/tree/v6.4.34" }, "funding": [ { @@ -6939,20 +6940,20 @@ "type": "tidelift" } ], - "time": "2025-11-26T14:43:45+00:00" + "time": "2026-02-24T17:51:06+00:00" }, { "name": "symfony/finder", - "version": "v6.4.31", + "version": "v6.4.34", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "5547f2e1f0ca8e2e7abe490156b62da778cfbe2b" + "reference": "9590e86be1d1c57bfbb16d0dd040345378c20896" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/5547f2e1f0ca8e2e7abe490156b62da778cfbe2b", - "reference": "5547f2e1f0ca8e2e7abe490156b62da778cfbe2b", + "url": "https://api.github.com/repos/symfony/finder/zipball/9590e86be1d1c57bfbb16d0dd040345378c20896", + "reference": "9590e86be1d1c57bfbb16d0dd040345378c20896", "shasum": "" }, "require": { @@ -6987,7 +6988,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v6.4.31" + "source": "https://github.com/symfony/finder/tree/v6.4.34" }, "funding": [ { @@ -7007,7 +7008,7 @@ "type": "tidelift" } ], - "time": "2025-12-11T14:52:17+00:00" + "time": "2026-01-28T15:16:37+00:00" }, { "name": "symfony/flex", @@ -7084,16 +7085,16 @@ }, { "name": "symfony/framework-bundle", - "version": "v6.4.31", + "version": "v6.4.34", "source": { "type": "git", "url": "https://github.com/symfony/framework-bundle.git", - "reference": "0ab60c05570b9e2bfab92b9944b938b8ffb5ba96" + "reference": "5b5d19473f22d699811a41b01cef2462bc42b238" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/0ab60c05570b9e2bfab92b9944b938b8ffb5ba96", - "reference": "0ab60c05570b9e2bfab92b9944b938b8ffb5ba96", + "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/5b5d19473f22d699811a41b01cef2462bc42b238", + "reference": "5b5d19473f22d699811a41b01cef2462bc42b238", "shasum": "" }, "require": { @@ -7213,7 +7214,7 @@ "description": "Provides a tight integration between Symfony components and the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/framework-bundle/tree/v6.4.31" + "source": "https://github.com/symfony/framework-bundle/tree/v6.4.34" }, "funding": [ { @@ -7233,20 +7234,20 @@ "type": "tidelift" } ], - "time": "2025-12-23T14:16:13+00:00" + "time": "2026-02-24T16:00:52+00:00" }, { "name": "symfony/http-client", - "version": "v6.4.31", + "version": "v6.4.34", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "f166fe476c996237666bcf7ec2cf827cd82ad573" + "reference": "0dc71f52e5d35bb045fd0f82b1a80c027971d551" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/f166fe476c996237666bcf7ec2cf827cd82ad573", - "reference": "f166fe476c996237666bcf7ec2cf827cd82ad573", + "url": "https://api.github.com/repos/symfony/http-client/zipball/0dc71f52e5d35bb045fd0f82b1a80c027971d551", + "reference": "0dc71f52e5d35bb045fd0f82b1a80c027971d551", "shasum": "" }, "require": { @@ -7311,7 +7312,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v6.4.31" + "source": "https://github.com/symfony/http-client/tree/v6.4.34" }, "funding": [ { @@ -7331,7 +7332,7 @@ "type": "tidelift" } ], - "time": "2025-12-23T14:19:38+00:00" + "time": "2026-02-18T07:27:25+00:00" }, { "name": "symfony/http-client-contracts", @@ -7413,16 +7414,16 @@ }, { "name": "symfony/http-foundation", - "version": "v6.4.31", + "version": "v6.4.34", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "a35ee6f47e4775179704d7877a8b0da3cb09241a" + "reference": "5bb346d1b4b2a616e5c3d99b3ee4d5810735c535" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/a35ee6f47e4775179704d7877a8b0da3cb09241a", - "reference": "a35ee6f47e4775179704d7877a8b0da3cb09241a", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/5bb346d1b4b2a616e5c3d99b3ee4d5810735c535", + "reference": "5bb346d1b4b2a616e5c3d99b3ee4d5810735c535", "shasum": "" }, "require": { @@ -7470,7 +7471,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v6.4.31" + "source": "https://github.com/symfony/http-foundation/tree/v6.4.34" }, "funding": [ { @@ -7490,20 +7491,20 @@ "type": "tidelift" } ], - "time": "2025-12-17T10:10:57+00:00" + "time": "2026-02-21T15:48:41+00:00" }, { "name": "symfony/http-kernel", - "version": "v6.4.31", + "version": "v6.4.34", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "16b0d46d8e11f480345c15b229cfc827a8a0f731" + "reference": "006a49fc4f41ee21a6ca61e69caed1c30b29f07c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/16b0d46d8e11f480345c15b229cfc827a8a0f731", - "reference": "16b0d46d8e11f480345c15b229cfc827a8a0f731", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/006a49fc4f41ee21a6ca61e69caed1c30b29f07c", + "reference": "006a49fc4f41ee21a6ca61e69caed1c30b29f07c", "shasum": "" }, "require": { @@ -7544,7 +7545,7 @@ "symfony/config": "^6.1|^7.0", "symfony/console": "^5.4|^6.0|^7.0", "symfony/css-selector": "^5.4|^6.0|^7.0", - "symfony/dependency-injection": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4.1|^7.0.1", "symfony/dom-crawler": "^5.4|^6.0|^7.0", "symfony/expression-language": "^5.4|^6.0|^7.0", "symfony/finder": "^5.4|^6.0|^7.0", @@ -7588,7 +7589,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v6.4.31" + "source": "https://github.com/symfony/http-kernel/tree/v6.4.34" }, "funding": [ { @@ -7608,20 +7609,20 @@ "type": "tidelift" } ], - "time": "2025-12-31T08:27:27+00:00" + "time": "2026-02-26T08:27:11+00:00" }, { "name": "symfony/mime", - "version": "v6.4.30", + "version": "v6.4.34", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "69aeef5d2692bb7c18ce133b09f67b27260b7acf" + "reference": "2b32fbbe10b36a8379efab6e702ad8b917151839" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/69aeef5d2692bb7c18ce133b09f67b27260b7acf", - "reference": "69aeef5d2692bb7c18ce133b09f67b27260b7acf", + "url": "https://api.github.com/repos/symfony/mime/zipball/2b32fbbe10b36a8379efab6e702ad8b917151839", + "reference": "2b32fbbe10b36a8379efab6e702ad8b917151839", "shasum": "" }, "require": { @@ -7677,7 +7678,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v6.4.30" + "source": "https://github.com/symfony/mime/tree/v6.4.34" }, "funding": [ { @@ -7697,20 +7698,20 @@ "type": "tidelift" } ], - "time": "2025-11-16T09:57:53+00:00" + "time": "2026-02-02T17:01:23+00:00" }, { "name": "symfony/monolog-bridge", - "version": "v6.4.28", + "version": "v6.4.34", "source": { "type": "git", "url": "https://github.com/symfony/monolog-bridge.git", - "reference": "d2f4b68e3247cf44d93f48545c8c072a75c17e5b" + "reference": "ee2d0150031b7c6ee2a1149fddddef3e7cdec117" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/d2f4b68e3247cf44d93f48545c8c072a75c17e5b", - "reference": "d2f4b68e3247cf44d93f48545c8c072a75c17e5b", + "url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/ee2d0150031b7c6ee2a1149fddddef3e7cdec117", + "reference": "ee2d0150031b7c6ee2a1149fddddef3e7cdec117", "shasum": "" }, "require": { @@ -7760,7 +7761,7 @@ "description": "Provides integration for Monolog with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/monolog-bridge/tree/v6.4.28" + "source": "https://github.com/symfony/monolog-bridge/tree/v6.4.34" }, "funding": [ { @@ -7780,7 +7781,7 @@ "type": "tidelift" } ], - "time": "2025-10-30T19:57:08+00:00" + "time": "2026-02-16T20:44:03+00:00" }, { "name": "symfony/monolog-bundle", @@ -7935,16 +7936,16 @@ }, { "name": "symfony/password-hasher", - "version": "v6.4.24", + "version": "v6.4.32", "source": { "type": "git", "url": "https://github.com/symfony/password-hasher.git", - "reference": "dcab5ac87450aaed26483ba49c2ce86808da7557" + "reference": "fbdfa5a2ca218ec8bb9029517426df2d780bdba9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/password-hasher/zipball/dcab5ac87450aaed26483ba49c2ce86808da7557", - "reference": "dcab5ac87450aaed26483ba49c2ce86808da7557", + "url": "https://api.github.com/repos/symfony/password-hasher/zipball/fbdfa5a2ca218ec8bb9029517426df2d780bdba9", + "reference": "fbdfa5a2ca218ec8bb9029517426df2d780bdba9", "shasum": "" }, "require": { @@ -7987,7 +7988,7 @@ "password" ], "support": { - "source": "https://github.com/symfony/password-hasher/tree/v6.4.24" + "source": "https://github.com/symfony/password-hasher/tree/v6.4.32" }, "funding": [ { @@ -8007,7 +8008,7 @@ "type": "tidelift" } ], - "time": "2025-07-10T08:14:14+00:00" + "time": "2026-01-01T21:24:53+00:00" }, { "name": "symfony/polyfill-intl-grapheme", @@ -8825,16 +8826,16 @@ }, { "name": "symfony/process", - "version": "v6.4.31", + "version": "v6.4.33", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "8541b7308fca001320e90bca8a73a28aa5604a6e" + "reference": "c46e854e79b52d07666e43924a20cb6dc546644e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/8541b7308fca001320e90bca8a73a28aa5604a6e", - "reference": "8541b7308fca001320e90bca8a73a28aa5604a6e", + "url": "https://api.github.com/repos/symfony/process/zipball/c46e854e79b52d07666e43924a20cb6dc546644e", + "reference": "c46e854e79b52d07666e43924a20cb6dc546644e", "shasum": "" }, "require": { @@ -8866,7 +8867,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v6.4.31" + "source": "https://github.com/symfony/process/tree/v6.4.33" }, "funding": [ { @@ -8886,26 +8887,26 @@ "type": "tidelift" } ], - "time": "2025-12-15T19:26:35+00:00" + "time": "2026-01-23T16:02:12+00:00" }, { "name": "symfony/property-access", - "version": "v6.4.31", + "version": "v6.4.32", "source": { "type": "git", "url": "https://github.com/symfony/property-access.git", - "reference": "1b1044599d7fb93cdb82f5a1291ba66f1caf6119" + "reference": "6dfa655ac9e9860c05cabb287f34da86b18c237e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/property-access/zipball/1b1044599d7fb93cdb82f5a1291ba66f1caf6119", - "reference": "1b1044599d7fb93cdb82f5a1291ba66f1caf6119", + "url": "https://api.github.com/repos/symfony/property-access/zipball/6dfa655ac9e9860c05cabb287f34da86b18c237e", + "reference": "6dfa655ac9e9860c05cabb287f34da86b18c237e", "shasum": "" }, "require": { "php": ">=8.1", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/property-info": "^6.4.31|~7.3.9|^7.4.2" + "symfony/property-info": "^6.4.32|~7.3.10|^7.4.4" }, "require-dev": { "symfony/cache": "^5.4|^6.0|^7.0" @@ -8947,7 +8948,7 @@ "reflection" ], "support": { - "source": "https://github.com/symfony/property-access/tree/v6.4.31" + "source": "https://github.com/symfony/property-access/tree/v6.4.32" }, "funding": [ { @@ -8967,20 +8968,20 @@ "type": "tidelift" } ], - "time": "2025-12-18T08:11:26+00:00" + "time": "2026-01-05T08:25:17+00:00" }, { "name": "symfony/property-info", - "version": "v6.4.31", + "version": "v6.4.34", "source": { "type": "git", "url": "https://github.com/symfony/property-info.git", - "reference": "f155cef234af1c16ed47791d182e146df237b35f" + "reference": "916455e4c9dcddbebfd101f29d7983841c3564e0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/property-info/zipball/f155cef234af1c16ed47791d182e146df237b35f", - "reference": "f155cef234af1c16ed47791d182e146df237b35f", + "url": "https://api.github.com/repos/symfony/property-info/zipball/916455e4c9dcddbebfd101f29d7983841c3564e0", + "reference": "916455e4c9dcddbebfd101f29d7983841c3564e0", "shasum": "" }, "require": { @@ -8989,7 +8990,7 @@ }, "conflict": { "doctrine/annotations": "<1.12", - "phpdocumentor/reflection-docblock": "<5.2", + "phpdocumentor/reflection-docblock": "<5.2|>=6", "phpdocumentor/type-resolver": "<1.5.1", "symfony/cache": "<5.4", "symfony/dependency-injection": "<5.4|>=6.0,<6.4", @@ -9037,7 +9038,7 @@ "validator" ], "support": { - "source": "https://github.com/symfony/property-info/tree/v6.4.31" + "source": "https://github.com/symfony/property-info/tree/v6.4.34" }, "funding": [ { @@ -9057,7 +9058,7 @@ "type": "tidelift" } ], - "time": "2025-12-16T19:55:30+00:00" + "time": "2026-02-13T09:42:46+00:00" }, { "name": "symfony/proxy-manager-bridge", @@ -9132,16 +9133,16 @@ }, { "name": "symfony/routing", - "version": "v6.4.30", + "version": "v6.4.34", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "ea50a13c2711eebcbb66b38ef6382e62e3262859" + "reference": "5ab3a3e1a03535ec5ca6ce2d39e4369a1096ae47" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/ea50a13c2711eebcbb66b38ef6382e62e3262859", - "reference": "ea50a13c2711eebcbb66b38ef6382e62e3262859", + "url": "https://api.github.com/repos/symfony/routing/zipball/5ab3a3e1a03535ec5ca6ce2d39e4369a1096ae47", + "reference": "5ab3a3e1a03535ec5ca6ce2d39e4369a1096ae47", "shasum": "" }, "require": { @@ -9195,7 +9196,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v6.4.30" + "source": "https://github.com/symfony/routing/tree/v6.4.34" }, "funding": [ { @@ -9215,7 +9216,7 @@ "type": "tidelift" } ], - "time": "2025-11-22T09:51:35+00:00" + "time": "2026-02-24T17:34:50+00:00" }, { "name": "symfony/runtime", @@ -9302,16 +9303,16 @@ }, { "name": "symfony/security-bundle", - "version": "v6.4.30", + "version": "v6.4.34", "source": { "type": "git", "url": "https://github.com/symfony/security-bundle.git", - "reference": "508e67e68156cf3cb2b982504191b2ce34daa921" + "reference": "f67bd24782a80095e9b8953e18d01983b9fe8e34" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-bundle/zipball/508e67e68156cf3cb2b982504191b2ce34daa921", - "reference": "508e67e68156cf3cb2b982504191b2ce34daa921", + "url": "https://api.github.com/repos/symfony/security-bundle/zipball/f67bd24782a80095e9b8953e18d01983b9fe8e34", + "reference": "f67bd24782a80095e9b8953e18d01983b9fe8e34", "shasum": "" }, "require": { @@ -9394,7 +9395,7 @@ "description": "Provides a tight integration of the Security component into the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-bundle/tree/v6.4.30" + "source": "https://github.com/symfony/security-bundle/tree/v6.4.34" }, "funding": [ { @@ -9414,7 +9415,7 @@ "type": "tidelift" } ], - "time": "2025-12-04T18:05:02+00:00" + "time": "2026-02-22T21:48:58+00:00" }, { "name": "symfony/security-core", @@ -9580,16 +9581,16 @@ }, { "name": "symfony/security-http", - "version": "v6.4.31", + "version": "v6.4.34", "source": { "type": "git", "url": "https://github.com/symfony/security-http.git", - "reference": "2f4ddc4a79b0e6f7dcc72e5ebd7109b3436b967a" + "reference": "894bb42b39bfb240a1f82c9a46a1ce9402a31a6f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-http/zipball/2f4ddc4a79b0e6f7dcc72e5ebd7109b3436b967a", - "reference": "2f4ddc4a79b0e6f7dcc72e5ebd7109b3436b967a", + "url": "https://api.github.com/repos/symfony/security-http/zipball/894bb42b39bfb240a1f82c9a46a1ce9402a31a6f", + "reference": "894bb42b39bfb240a1f82c9a46a1ce9402a31a6f", "shasum": "" }, "require": { @@ -9648,7 +9649,7 @@ "description": "Symfony Security Component - HTTP Integration", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-http/tree/v6.4.31" + "source": "https://github.com/symfony/security-http/tree/v6.4.34" }, "funding": [ { @@ -9668,20 +9669,20 @@ "type": "tidelift" } ], - "time": "2025-12-17T22:32:13+00:00" + "time": "2026-02-16T20:44:03+00:00" }, { "name": "symfony/serializer", - "version": "v6.4.31", + "version": "v6.4.34", "source": { "type": "git", "url": "https://github.com/symfony/serializer.git", - "reference": "abf80f880943224afca831d7da6eff584c3af751" + "reference": "26446be5ec3d84c2aa16a08e195c783e3d4c2af7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/serializer/zipball/abf80f880943224afca831d7da6eff584c3af751", - "reference": "abf80f880943224afca831d7da6eff584c3af751", + "url": "https://api.github.com/repos/symfony/serializer/zipball/26446be5ec3d84c2aa16a08e195c783e3d4c2af7", + "reference": "26446be5ec3d84c2aa16a08e195c783e3d4c2af7", "shasum": "" }, "require": { @@ -9750,7 +9751,7 @@ "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/serializer/tree/v6.4.31" + "source": "https://github.com/symfony/serializer/tree/v6.4.34" }, "funding": [ { @@ -9770,7 +9771,7 @@ "type": "tidelift" } ], - "time": "2025-12-19T17:17:42+00:00" + "time": "2026-02-24T16:55:33+00:00" }, { "name": "symfony/service-contracts", @@ -9927,16 +9928,16 @@ }, { "name": "symfony/string", - "version": "v6.4.30", + "version": "v6.4.34", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "50590a057841fa6bf69d12eceffce3465b9e32cb" + "reference": "2adaf4106f2ef4c67271971bde6d3fe0a6936432" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/50590a057841fa6bf69d12eceffce3465b9e32cb", - "reference": "50590a057841fa6bf69d12eceffce3465b9e32cb", + "url": "https://api.github.com/repos/symfony/string/zipball/2adaf4106f2ef4c67271971bde6d3fe0a6936432", + "reference": "2adaf4106f2ef4c67271971bde6d3fe0a6936432", "shasum": "" }, "require": { @@ -9992,7 +9993,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v6.4.30" + "source": "https://github.com/symfony/string/tree/v6.4.34" }, "funding": [ { @@ -10012,7 +10013,7 @@ "type": "tidelift" } ], - "time": "2025-11-21T18:03:05+00:00" + "time": "2026-02-08T20:44:54+00:00" }, { "name": "symfony/translation-contracts", @@ -10098,16 +10099,16 @@ }, { "name": "symfony/twig-bridge", - "version": "v6.4.31", + "version": "v6.4.34", "source": { "type": "git", "url": "https://github.com/symfony/twig-bridge.git", - "reference": "24a498d80fd2a28087fbac0a96e0721ce2756b65" + "reference": "5169074f4a88dfb02eeccddaba78edfdf212a9b2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/24a498d80fd2a28087fbac0a96e0721ce2756b65", - "reference": "24a498d80fd2a28087fbac0a96e0721ce2756b65", + "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/5169074f4a88dfb02eeccddaba78edfdf212a9b2", + "reference": "5169074f4a88dfb02eeccddaba78edfdf212a9b2", "shasum": "" }, "require": { @@ -10120,7 +10121,7 @@ "phpdocumentor/reflection-docblock": "<3.2.2", "phpdocumentor/type-resolver": "<1.4.0", "symfony/console": "<5.4", - "symfony/form": "<6.4", + "symfony/form": "<6.4.32|>7,<7.3.10|>7.4,<7.4.4", "symfony/http-foundation": "<5.4", "symfony/http-kernel": "<6.4", "symfony/mime": "<6.2", @@ -10138,7 +10139,7 @@ "symfony/dependency-injection": "^5.4|^6.0|^7.0", "symfony/expression-language": "^5.4|^6.0|^7.0", "symfony/finder": "^5.4|^6.0|^7.0", - "symfony/form": "^6.4.30|~7.3.8|^7.4.1", + "symfony/form": "^6.4.32|~7.3.10|^7.4.4", "symfony/html-sanitizer": "^6.1|^7.0", "symfony/http-foundation": "^5.4|^6.0|^7.0", "symfony/http-kernel": "^6.4|^7.0", @@ -10187,7 +10188,7 @@ "description": "Provides integration for Twig with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/twig-bridge/tree/v6.4.31" + "source": "https://github.com/symfony/twig-bridge/tree/v6.4.34" }, "funding": [ { @@ -10207,20 +10208,20 @@ "type": "tidelift" } ], - "time": "2025-12-11T18:16:47+00:00" + "time": "2026-02-23T18:17:33+00:00" }, { "name": "symfony/twig-bundle", - "version": "v6.4.31", + "version": "v6.4.32", "source": { "type": "git", "url": "https://github.com/symfony/twig-bundle.git", - "reference": "df14e1b81483b04534317e8f61617560ab60777d" + "reference": "a5c8dcc11a5bf9c96320da20070d2e158a4e0b30" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/twig-bundle/zipball/df14e1b81483b04534317e8f61617560ab60777d", - "reference": "df14e1b81483b04534317e8f61617560ab60777d", + "url": "https://api.github.com/repos/symfony/twig-bundle/zipball/a5c8dcc11a5bf9c96320da20070d2e158a4e0b30", + "reference": "a5c8dcc11a5bf9c96320da20070d2e158a4e0b30", "shasum": "" }, "require": { @@ -10275,7 +10276,7 @@ "description": "Provides a tight integration of Twig into the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/twig-bundle/tree/v6.4.31" + "source": "https://github.com/symfony/twig-bundle/tree/v6.4.32" }, "funding": [ { @@ -10295,20 +10296,20 @@ "type": "tidelift" } ], - "time": "2025-12-18T03:19:37+00:00" + "time": "2026-01-05T12:44:39+00:00" }, { "name": "symfony/uid", - "version": "v6.4.24", + "version": "v6.4.32", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "17da16a750541a42cf2183935e0f6008316c23f7" + "reference": "6b973c385f00341b246f697d82dc01a09107acdd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/17da16a750541a42cf2183935e0f6008316c23f7", - "reference": "17da16a750541a42cf2183935e0f6008316c23f7", + "url": "https://api.github.com/repos/symfony/uid/zipball/6b973c385f00341b246f697d82dc01a09107acdd", + "reference": "6b973c385f00341b246f697d82dc01a09107acdd", "shasum": "" }, "require": { @@ -10353,7 +10354,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v6.4.24" + "source": "https://github.com/symfony/uid/tree/v6.4.32" }, "funding": [ { @@ -10373,20 +10374,20 @@ "type": "tidelift" } ], - "time": "2025-07-10T08:14:14+00:00" + "time": "2025-12-23T15:07:59+00:00" }, { "name": "symfony/validator", - "version": "v6.4.31", + "version": "v6.4.34", "source": { "type": "git", "url": "https://github.com/symfony/validator.git", - "reference": "0c3f60adce4e6fc86583b0c7e363ce90fe3ca3e7" + "reference": "7c3897b7f739d4ab913481e680405ca82d08084d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/validator/zipball/0c3f60adce4e6fc86583b0c7e363ce90fe3ca3e7", - "reference": "0c3f60adce4e6fc86583b0c7e363ce90fe3ca3e7", + "url": "https://api.github.com/repos/symfony/validator/zipball/7c3897b7f739d4ab913481e680405ca82d08084d", + "reference": "7c3897b7f739d4ab913481e680405ca82d08084d", "shasum": "" }, "require": { @@ -10454,7 +10455,7 @@ "description": "Provides tools to validate values", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/validator/tree/v6.4.31" + "source": "https://github.com/symfony/validator/tree/v6.4.34" }, "funding": [ { @@ -10474,20 +10475,20 @@ "type": "tidelift" } ], - "time": "2025-12-24T09:35:58+00:00" + "time": "2026-02-23T17:49:24+00:00" }, { "name": "symfony/var-dumper", - "version": "v6.4.26", + "version": "v6.4.32", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "cfae1497a2f1eaad78dbc0590311c599c7178d4a" + "reference": "131fc9915e0343052af5ed5040401b481ca192aa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/cfae1497a2f1eaad78dbc0590311c599c7178d4a", - "reference": "cfae1497a2f1eaad78dbc0590311c599c7178d4a", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/131fc9915e0343052af5ed5040401b481ca192aa", + "reference": "131fc9915e0343052af5ed5040401b481ca192aa", "shasum": "" }, "require": { @@ -10542,7 +10543,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v6.4.26" + "source": "https://github.com/symfony/var-dumper/tree/v6.4.32" }, "funding": [ { @@ -10562,7 +10563,7 @@ "type": "tidelift" } ], - "time": "2025-09-25T15:37:27+00:00" + "time": "2026-01-01T13:34:06+00:00" }, { "name": "symfony/var-exporter", @@ -10647,16 +10648,16 @@ }, { "name": "symfony/web-link", - "version": "v6.4.24", + "version": "v6.4.32", "source": { "type": "git", "url": "https://github.com/symfony/web-link.git", - "reference": "75ffbb304f26a716969863328c8c6a11eadcfa5a" + "reference": "636d5e34cd5c4a2538b02ba48a3c02989bfdf06b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/web-link/zipball/75ffbb304f26a716969863328c8c6a11eadcfa5a", - "reference": "75ffbb304f26a716969863328c8c6a11eadcfa5a", + "url": "https://api.github.com/repos/symfony/web-link/zipball/636d5e34cd5c4a2538b02ba48a3c02989bfdf06b", + "reference": "636d5e34cd5c4a2538b02ba48a3c02989bfdf06b", "shasum": "" }, "require": { @@ -10710,7 +10711,7 @@ "push" ], "support": { - "source": "https://github.com/symfony/web-link/tree/v6.4.24" + "source": "https://github.com/symfony/web-link/tree/v6.4.32" }, "funding": [ { @@ -10730,20 +10731,20 @@ "type": "tidelift" } ], - "time": "2025-07-10T08:14:14+00:00" + "time": "2026-01-01T13:45:34+00:00" }, { "name": "symfony/yaml", - "version": "v6.4.30", + "version": "v6.4.34", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "8207ae83da19ee3748d6d4f567b4d9a7c656e331" + "reference": "7bca30dabed7900a08c5ad4f1d6483f881a64d0f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/8207ae83da19ee3748d6d4f567b4d9a7c656e331", - "reference": "8207ae83da19ee3748d6d4f567b4d9a7c656e331", + "url": "https://api.github.com/repos/symfony/yaml/zipball/7bca30dabed7900a08c5ad4f1d6483f881a64d0f", + "reference": "7bca30dabed7900a08c5ad4f1d6483f881a64d0f", "shasum": "" }, "require": { @@ -10786,7 +10787,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v6.4.30" + "source": "https://github.com/symfony/yaml/tree/v6.4.34" }, "funding": [ { @@ -10806,20 +10807,20 @@ "type": "tidelift" } ], - "time": "2025-12-02T11:50:18+00:00" + "time": "2026-02-06T18:32:11+00:00" }, { "name": "twig/twig", - "version": "v3.22.2", + "version": "v3.23.0", "source": { "type": "git", "url": "https://github.com/twigphp/Twig.git", - "reference": "946ddeafa3c9f4ce279d1f34051af041db0e16f2" + "reference": "a64dc5d2cc7d6cafb9347f6cd802d0d06d0351c9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/Twig/zipball/946ddeafa3c9f4ce279d1f34051af041db0e16f2", - "reference": "946ddeafa3c9f4ce279d1f34051af041db0e16f2", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/a64dc5d2cc7d6cafb9347f6cd802d0d06d0351c9", + "reference": "a64dc5d2cc7d6cafb9347f6cd802d0d06d0351c9", "shasum": "" }, "require": { @@ -10873,7 +10874,7 @@ ], "support": { "issues": "https://github.com/twigphp/Twig/issues", - "source": "https://github.com/twigphp/Twig/tree/v3.22.2" + "source": "https://github.com/twigphp/Twig/tree/v3.23.0" }, "funding": [ { @@ -10885,7 +10886,7 @@ "type": "tidelift" } ], - "time": "2025-12-14T11:28:47+00:00" + "time": "2026-01-23T21:00:41+00:00" }, { "name": "vich/uploader-bundle", @@ -11391,16 +11392,16 @@ }, { "name": "ergebnis/composer-normalize", - "version": "2.48.2", + "version": "2.50.0", "source": { "type": "git", "url": "https://github.com/ergebnis/composer-normalize.git", - "reference": "86dc9731b8320f49e9be9ad6d8e4de9b8b0e9b8b" + "reference": "80971fe24ff10709789942bcbe9368b2c704097c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ergebnis/composer-normalize/zipball/86dc9731b8320f49e9be9ad6d8e4de9b8b0e9b8b", - "reference": "86dc9731b8320f49e9be9ad6d8e4de9b8b0e9b8b", + "url": "https://api.github.com/repos/ergebnis/composer-normalize/zipball/80971fe24ff10709789942bcbe9368b2c704097c", + "reference": "80971fe24ff10709789942bcbe9368b2c704097c", "shasum": "" }, "require": { @@ -11414,20 +11415,20 @@ "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" }, "require-dev": { - "composer/composer": "^2.8.3", + "composer/composer": "^2.9.4", "ergebnis/license": "^2.7.0", - "ergebnis/php-cs-fixer-config": "^6.53.0", - "ergebnis/phpstan-rules": "^2.11.0", + "ergebnis/php-cs-fixer-config": "^6.59.0", + "ergebnis/phpstan-rules": "^2.13.1", "ergebnis/phpunit-slow-test-detector": "^2.20.0", + "ergebnis/rector-rules": "^1.9.0", "fakerphp/faker": "^1.24.1", - "infection/infection": "~0.26.6", "phpstan/extension-installer": "^1.4.3", - "phpstan/phpstan": "^2.1.17", + "phpstan/phpstan": "^2.1.38", "phpstan/phpstan-deprecation-rules": "^2.0.3", - "phpstan/phpstan-phpunit": "^2.0.7", - "phpstan/phpstan-strict-rules": "^2.0.6", - "phpunit/phpunit": "^9.6.20", - "rector/rector": "^2.1.4", + "phpstan/phpstan-phpunit": "^2.0.12", + "phpstan/phpstan-strict-rules": "^2.0.8", + "phpunit/phpunit": "^9.6.33", + "rector/rector": "^2.3.5", "symfony/filesystem": "^5.4.41" }, "type": "composer-plugin", @@ -11471,7 +11472,7 @@ "security": "https://github.com/ergebnis/composer-normalize/blob/main/.github/SECURITY.md", "source": "https://github.com/ergebnis/composer-normalize" }, - "time": "2025-09-06T11:42:34+00:00" + "time": "2026-02-09T20:57:47+00:00" }, { "name": "ergebnis/json", @@ -12523,11 +12524,11 @@ }, { "name": "phpstan/phpstan", - "version": "1.12.32", + "version": "1.12.33", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/2770dcdf5078d0b0d53f94317e06affe88419aa8", - "reference": "2770dcdf5078d0b0d53f94317e06affe88419aa8", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/37982d6fc7cbb746dda7773530cda557cdf119e1", + "reference": "37982d6fc7cbb746dda7773530cda557cdf119e1", "shasum": "" }, "require": { @@ -12572,7 +12573,7 @@ "type": "github" } ], - "time": "2025-09-30T10:16:31+00:00" + "time": "2026-02-28T20:30:03+00:00" }, { "name": "phpunit/php-code-coverage", @@ -12895,16 +12896,16 @@ }, { "name": "phpunit/phpunit", - "version": "9.6.31", + "version": "9.6.34", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "945d0b7f346a084ce5549e95289962972c4272e5" + "reference": "b36f02317466907a230d3aa1d34467041271ef4a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/945d0b7f346a084ce5549e95289962972c4272e5", - "reference": "945d0b7f346a084ce5549e95289962972c4272e5", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/b36f02317466907a230d3aa1d34467041271ef4a", + "reference": "b36f02317466907a230d3aa1d34467041271ef4a", "shasum": "" }, "require": { @@ -12926,7 +12927,7 @@ "phpunit/php-timer": "^5.0.3", "sebastian/cli-parser": "^1.0.2", "sebastian/code-unit": "^1.0.8", - "sebastian/comparator": "^4.0.9", + "sebastian/comparator": "^4.0.10", "sebastian/diff": "^4.0.6", "sebastian/environment": "^5.1.5", "sebastian/exporter": "^4.0.8", @@ -12978,7 +12979,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.31" + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.34" }, "funding": [ { @@ -13002,7 +13003,7 @@ "type": "tidelift" } ], - "time": "2025-12-06T07:45:52+00:00" + "time": "2026-01-27T05:45:00+00:00" }, { "name": "psalm/plugin-symfony", @@ -13297,16 +13298,16 @@ }, { "name": "sebastian/comparator", - "version": "4.0.9", + "version": "4.0.10", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "67a2df3a62639eab2cc5906065e9805d4fd5dfc5" + "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/67a2df3a62639eab2cc5906065e9805d4fd5dfc5", - "reference": "67a2df3a62639eab2cc5906065e9805d4fd5dfc5", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/e4df00b9b3571187db2831ae9aada2c6efbd715d", + "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d", "shasum": "" }, "require": { @@ -13359,7 +13360,7 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", - "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.9" + "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.10" }, "funding": [ { @@ -13379,7 +13380,7 @@ "type": "tidelift" } ], - "time": "2025-08-10T06:51:50+00:00" + "time": "2026-01-24T09:22:56+00:00" }, { "name": "sebastian/complexity", @@ -14143,16 +14144,16 @@ }, { "name": "symfony/browser-kit", - "version": "v6.4.31", + "version": "v6.4.32", "source": { "type": "git", "url": "https://github.com/symfony/browser-kit.git", - "reference": "5b8564c882ca8eb9a06ed2840abc9b2a40f1e12a" + "reference": "f49947cf0cbd7d685281ef74e05b98f5e75b181f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/browser-kit/zipball/5b8564c882ca8eb9a06ed2840abc9b2a40f1e12a", - "reference": "5b8564c882ca8eb9a06ed2840abc9b2a40f1e12a", + "url": "https://api.github.com/repos/symfony/browser-kit/zipball/f49947cf0cbd7d685281ef74e05b98f5e75b181f", + "reference": "f49947cf0cbd7d685281ef74e05b98f5e75b181f", "shasum": "" }, "require": { @@ -14191,7 +14192,7 @@ "description": "Simulates the behavior of a web browser, allowing you to make requests, click on links and submit forms programmatically", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/browser-kit/tree/v6.4.31" + "source": "https://github.com/symfony/browser-kit/tree/v6.4.32" }, "funding": [ { @@ -14211,20 +14212,20 @@ "type": "tidelift" } ], - "time": "2025-12-12T07:51:57+00:00" + "time": "2026-01-13T10:09:10+00:00" }, { "name": "symfony/css-selector", - "version": "v6.4.24", + "version": "v6.4.34", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "9b784413143701aa3c94ac1869a159a9e53e8761" + "reference": "b0314c186f1464de048cce58979ff1625ca88bbb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/9b784413143701aa3c94ac1869a159a9e53e8761", - "reference": "9b784413143701aa3c94ac1869a159a9e53e8761", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/b0314c186f1464de048cce58979ff1625ca88bbb", + "reference": "b0314c186f1464de048cce58979ff1625ca88bbb", "shasum": "" }, "require": { @@ -14260,7 +14261,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v6.4.24" + "source": "https://github.com/symfony/css-selector/tree/v6.4.34" }, "funding": [ { @@ -14280,7 +14281,7 @@ "type": "tidelift" } ], - "time": "2025-07-10T08:14:14+00:00" + "time": "2026-02-16T08:37:21+00:00" }, { "name": "symfony/debug-bundle", @@ -14543,16 +14544,16 @@ }, { "name": "symfony/web-profiler-bundle", - "version": "v6.4.27", + "version": "v6.4.34", "source": { "type": "git", "url": "https://github.com/symfony/web-profiler-bundle.git", - "reference": "4c2ab411372e8bd854678cd7c81f1a9bfd6914aa" + "reference": "848bc5d5745500f855bb201d57ae066fd7e67448" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/4c2ab411372e8bd854678cd7c81f1a9bfd6914aa", - "reference": "4c2ab411372e8bd854678cd7c81f1a9bfd6914aa", + "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/848bc5d5745500f855bb201d57ae066fd7e67448", + "reference": "848bc5d5745500f855bb201d57ae066fd7e67448", "shasum": "" }, "require": { @@ -14605,7 +14606,7 @@ "dev" ], "support": { - "source": "https://github.com/symfony/web-profiler-bundle/tree/v6.4.27" + "source": "https://github.com/symfony/web-profiler-bundle/tree/v6.4.34" }, "funding": [ { @@ -14625,7 +14626,7 @@ "type": "tidelift" } ], - "time": "2025-10-05T13:55:43+00:00" + "time": "2026-02-05T15:19:06+00:00" }, { "name": "theofidry/alice-data-fixtures", @@ -14890,37 +14891,37 @@ }, { "name": "vincentlanglet/twig-cs-fixer", - "version": "3.11.0", + "version": "3.14.0", "source": { "type": "git", "url": "https://github.com/VincentLanglet/Twig-CS-Fixer.git", - "reference": "866af065fd09980b6390ee5c69e45b08053101e8" + "reference": "599f110f192c31af5deb5736d6c1a970afdf51f3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/VincentLanglet/Twig-CS-Fixer/zipball/866af065fd09980b6390ee5c69e45b08053101e8", - "reference": "866af065fd09980b6390ee5c69e45b08053101e8", + "url": "https://api.github.com/repos/VincentLanglet/Twig-CS-Fixer/zipball/599f110f192c31af5deb5736d6c1a970afdf51f3", + "reference": "599f110f192c31af5deb5736d6c1a970afdf51f3", "shasum": "" }, "require": { "composer-runtime-api": "^2.0.0", "ext-ctype": "*", - "ext-json": "*", - "php": ">=8.0", + "php": ">=8.1", "symfony/console": "^5.4.9 || ^6.4 || ^7.0 || ^8.0", "symfony/filesystem": "^5.4 || ^6.4 || ^7.0 || ^8.0", "symfony/finder": "^5.4 || ^6.4 || ^7.0 || ^8.0", "symfony/string": "^5.4.42 || ^6.4.10 || ~7.0.10 || ^7.1.3 || ^8.0", "twig/twig": "^3.4", - "webmozart/assert": "^1.10" + "webmozart/assert": "^1.10 || ^2.0" }, "require-dev": { "composer/semver": "^3.2.0", "dereuromark/composer-prefer-lowest": "^0.1.10", "ergebnis/composer-normalize": "^2.29", "friendsofphp/php-cs-fixer": "^3.13.0", - "infection/infection": "^0.26.16 || ^0.29.14", + "infection/infection": "^0.26.16 || ^0.32.0", "phpstan/phpstan": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0", "phpstan/phpstan-phpunit": "^2.0", "phpstan/phpstan-strict-rules": "^2.0", "phpstan/phpstan-symfony": "^2.0", @@ -14955,7 +14956,7 @@ "homepage": "https://github.com/VincentLanglet/Twig-CS-Fixer", "support": { "issues": "https://github.com/VincentLanglet/Twig-CS-Fixer/issues", - "source": "https://github.com/VincentLanglet/Twig-CS-Fixer/tree/3.11.0" + "source": "https://github.com/VincentLanglet/Twig-CS-Fixer/tree/3.14.0" }, "funding": [ { @@ -14963,7 +14964,7 @@ "type": "github" } ], - "time": "2025-11-24T18:13:18+00:00" + "time": "2026-02-23T13:21:35+00:00" }, { "name": "weirdan/doctrine-psalm-plugin", diff --git a/docs/v2-changelogs/admin.md b/docs/v2-changelogs/admin.md index fdb6189a9..861678f6c 100644 --- a/docs/v2-changelogs/admin.md +++ b/docs/v2-changelogs/admin.md @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [2.6.1] - 2026-03-06 + +- [#295](https://github.com/os2display/display-admin-client/pull/295) + - Fixed slide media array not syncing with content on media removal. + ## [2.6.0] - 2025-12-05 - [#293](https://github.com/os2display/display-admin-client/pull/293) diff --git a/docs/v2-changelogs/api.md b/docs/v2-changelogs/api.md index e50d76405..d4e91037a 100644 --- a/docs/v2-changelogs/api.md +++ b/docs/v2-changelogs/api.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [2.6.1] - 2026-03-10 + +- [#347](https://github.com/os2display/display-api-service/pull/347) + - Added onFlush listener to handle ManyToMany collection changes for relations checksum propagation. + - Added command to refresh relation checksums. + ## [2.6.0] - 2025-12-05 - [#330](https://github.com/os2display/display-api-service/pull/330) diff --git a/docs/v2-changelogs/template.md b/docs/v2-changelogs/template.md index 43ab7f98a..793c82992 100644 --- a/docs/v2-changelogs/template.md +++ b/docs/v2-changelogs/template.md @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file. ## Unreleased +## [2.6.1] - 2026-03-06 + +- [#196](https://github.com/os2display/display-templates/pull/196) + - Fixed image-text carousel not updating when images array changes. + ## [2.6.0] - 2025-12-05 - [#194](https://github.com/os2display/display-templates/pull/194) diff --git a/src/Command/Checksum/RecalculateChecksumCommand.php b/src/Command/Checksum/RecalculateChecksumCommand.php new file mode 100644 index 000000000..43d5b53a9 --- /dev/null +++ b/src/Command/Checksum/RecalculateChecksumCommand.php @@ -0,0 +1,158 @@ +addOption(self::OPTION_TENANT, null, InputOption::VALUE_REQUIRED, 'Filter by tenant key') + ->addOption(self::OPTION_MODIFIED_AFTER, null, InputOption::VALUE_REQUIRED, 'Filter by modified_at >= date (e.g. "2024-01-01" or "2024-01-01 12:00:00")') + ->setHelp(<<<'HELP' +The %command.name% command recalculates relation checksums for slides and media, +then propagates the changes up the entity tree. + + php %command.full_name% + +You can filter by tenant key and/or modification date: + + php %command.full_name% --tenant=ABC + php %command.full_name% --modified-after="2024-01-01" + php %command.full_name% --tenant=ABC --modified-after="2024-01-01 12:00:00" + +Without any filters, all slides and media will be recalculated. +HELP) + ; + } + + #[\Override] + public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void + { + if ($input->mustSuggestOptionValuesFor(self::OPTION_TENANT)) { + $tenants = $this->tenantRepository->findAll(); + foreach ($tenants as $tenant) { + $suggestions->suggestValue($tenant->getTenantKey()); + } + } + } + + #[\Override] + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $stopwatch = new Stopwatch(); + $stopwatch->start('checksum-recalculate'); + + $tenantKey = $input->getOption(self::OPTION_TENANT); + $modifiedAfterStr = $input->getOption(self::OPTION_MODIFIED_AFTER); + + // Resolve tenant + $tenant = null; + if (null !== $tenantKey) { + $tenant = $this->tenantRepository->findOneBy(['tenantKey' => $tenantKey]); + if (null === $tenant) { + $io->error(sprintf('Tenant with key "%s" not found.', $tenantKey)); + + return Command::FAILURE; + } + $io->info(sprintf('Filtering by tenant: %s', $tenantKey)); + } + + // Parse date + $modifiedAfter = null; + if (null !== $modifiedAfterStr) { + try { + $modifiedAfter = new \DateTimeImmutable($modifiedAfterStr); + } catch (\Exception) { + $io->error(sprintf('Invalid date format: "%s". Use formats like "Y-m-d" or "Y-m-d H:i:s".', $modifiedAfterStr)); + + return Command::FAILURE; + } + $io->info(sprintf('Filtering by modified after: %s', $modifiedAfter->format('Y-m-d H:i:s'))); + } + + // Mark matching slides and media as changed using DQL UPDATE + $targetEntities = [ + 'slide' => Slide::class, + 'media' => Media::class, + ]; + $totalAffected = 0; + + foreach ($targetEntities as $label => $entityClass) { + $qb = $this->entityManager->createQueryBuilder() + ->update($entityClass, 'e') + ->set('e.changed', ':changed') + ->setParameter('changed', true); + + if (null !== $tenant) { + $qb->andWhere('e.tenant = :tenant') + ->setParameter('tenant', $tenant, Tenant::class); + } + + if (null !== $modifiedAfter) { + $qb->andWhere('e.modifiedAt >= :modifiedAfter') + ->setParameter('modifiedAfter', $modifiedAfter, 'datetime_immutable'); + } + + $affected = $qb->getQuery()->execute(); + $totalAffected += $affected; + $io->info(sprintf('Marked %d rows in "%s" as changed.', $affected, $label)); + } + + if (0 === $totalAffected) { + $io->warning('No rows matched the given filters. Nothing to recalculate.'); + + return Command::SUCCESS; + } + + // Propagate checksums through entity tree + $io->info('Propagating checksums through entity tree...'); + $this->calculator->execute(withWhereClause: true); + + $event = $stopwatch->stop('checksum-recalculate'); + + $io->success(sprintf( + 'Checksums recalculated. %d rows marked. Elapsed: %.2f ms, Memory: %.2f MB', + $totalAffected, + $event->getDuration(), + $event->getMemory() / (1024 ** 2) + )); + + return Command::SUCCESS; + } +} diff --git a/src/Controller/Api/InteractiveController.php b/src/Controller/Api/InteractiveController.php index edd3ee460..6cdde602a 100644 --- a/src/Controller/Api/InteractiveController.php +++ b/src/Controller/Api/InteractiveController.php @@ -35,7 +35,7 @@ public function __invoke(Request $request, Slide $slide): JsonResponse { $user = $this->security->getUser(); - if (!($user instanceof ScreenUser)) { + if (!$user instanceof ScreenUser) { throw new AccessDeniedHttpException('Only screen user can perform action.'); } diff --git a/src/DataFixtures/Loader/DoctrineOrmLoaderDecorator.php b/src/DataFixtures/Loader/DoctrineOrmLoaderDecorator.php index f6c21aa4b..85ef46bfc 100644 --- a/src/DataFixtures/Loader/DoctrineOrmLoaderDecorator.php +++ b/src/DataFixtures/Loader/DoctrineOrmLoaderDecorator.php @@ -6,6 +6,7 @@ use App\EventListener\RelationsChecksumListener; use App\EventListener\TimestampableListener; +use App\Service\RelationsChecksumCalculator; use Doctrine\ORM\EntityManagerInterface; use Hautelook\AliceBundle\Loader\DoctrineOrmLoader; use Hautelook\AliceBundle\LoaderInterface as AliceBundleLoaderInterface; @@ -25,7 +26,8 @@ readonly class DoctrineOrmLoaderDecorator implements AliceBundleLoaderInterface, LoggerAwareInterface { public function __construct( - private DoctrineOrmLoader $decorated, + private readonly DoctrineOrmLoader $decorated, + private readonly RelationsChecksumCalculator $calculator, ) {} public function load(Application $application, EntityManagerInterface $manager, array $bundles, string $environment, bool $append, bool $purgeWithTruncate, bool $noBundles = false): array @@ -57,7 +59,7 @@ public function load(Application $application, EntityManagerInterface $manager, $result = $this->decorated->load($application, $manager, $bundles, $environment, $append, $purgeWithTruncate, $noBundles); // Apply the SQL statements from the disabled "postFlush" listener - $this->applyRelationsModified($manager); + $this->applyRelationsModified(); // Re-enable listeners $eventManager->addEventListener('postFlush', $relationsModifiedAtListener); @@ -75,16 +77,8 @@ public function withLogger(LoggerInterface $logger): static $this->decorated->withLogger($logger); } - private function applyRelationsModified(EntityManagerInterface $manager): void + private function applyRelationsModified(): void { - $connection = $manager->getConnection(); - - $sqlQueries = RelationsChecksumListener::getUpdateRelationsAtQueries(withWhereClause: false); - - $rows = 0; - foreach ($sqlQueries as $sqlQuery) { - $stm = $connection->prepare($sqlQuery); - $rows += $stm->executeStatement(); - } + $this->calculator->execute(withWhereClause: false); } } diff --git a/src/EventListener/RelationsChecksumListener.php b/src/EventListener/RelationsChecksumListener.php index ce78efb3d..17b7d98b4 100644 --- a/src/EventListener/RelationsChecksumListener.php +++ b/src/EventListener/RelationsChecksumListener.php @@ -18,7 +18,9 @@ use App\Entity\Tenant\ScreenGroup; use App\Entity\Tenant\ScreenGroupCampaign; use App\Entity\Tenant\Slide; +use App\Service\RelationsChecksumCalculator; use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener; +use Doctrine\ORM\Event\OnFlushEventArgs; use Doctrine\ORM\Event\PostFlushEventArgs; use Doctrine\ORM\Event\PrePersistEventArgs; use Doctrine\ORM\Event\PreRemoveEventArgs; @@ -53,14 +55,12 @@ #[AsDoctrineListener(event: Events::prePersist, priority: 100)] #[AsDoctrineListener(event: Events::preUpdate)] #[AsDoctrineListener(event: Events::preRemove)] +#[AsDoctrineListener(event: Events::onFlush)] #[AsDoctrineListener(event: Events::postFlush)] class RelationsChecksumListener { - private const array CHECKSUM_TABLES = ['feed_source', 'feed', 'slide', 'media', 'theme', 'template', 'playlist_slide', - 'playlist', 'screen_campaign', 'screen', 'screen_group_campaign', 'screen_group', - 'playlist_screen_region', 'screen_layout_regions', 'screen_layout']; - public function __construct( + private readonly RelationsChecksumCalculator $calculator, private readonly bool $enabled = false, ) {} @@ -73,6 +73,8 @@ public function __construct( * function to work. If the field is left as null it will be serialized to the * database as `[]` preventing JSON_SET from updating the field. * + * @param PrePersistEventArgs $args + * * @return void */ final public function prePersist(PrePersistEventArgs $args): void @@ -166,6 +168,8 @@ final public function prePersist(PrePersistEventArgs $args): void * * On update set "changed" to "true" to ensure checksum changes propagate up the tree. * + * @param PreUpdateEventArgs $args + * * @return void */ final public function preUpdate(PreUpdateEventArgs $args): void @@ -184,10 +188,12 @@ final public function preUpdate(PreUpdateEventArgs $args): void /** * PreRemove listener. * - * For "toMany" relations the "preUpdate" listener will not be called for the parent if a child relations - * is deleted by calling remove() on the entity manager. We need to manually set "changed" on the parent + * For "toMany" relations the "preUpdate" listener will not be called for the parent if child relations + * are deleted by calling remove() on the entity manager. We need to manually set "changed" on the parent * to "true" to ensure checksum changes propagate up the tree. * + * @param PreRemoveEventArgs $args + * * @return void */ final public function preRemove(PreRemoveEventArgs $args): void @@ -231,317 +237,66 @@ final public function preRemove(PreRemoveEventArgs $args): void } /** - * PostFlush listener. + * OnFlush listener. * - * Executes update SQL queries to set changed and relations_checksum fields in the database. + * This listener is used to set the "changed" flag on entities that have been modified since the last flush. + * This is required because Doctrine does not call the "preUpdate" listener for entities that only have + * collection changes. * - * @param PostFlushEventArgs $args the PostFlushEventArgs object containing information about the event + * @param OnFlushEventArgs $args * * @return void - * - * @throws \Doctrine\DBAL\Exception */ - final public function postFlush(PostFlushEventArgs $args): void + final public function onFlush(OnFlushEventArgs $args): void { if (!$this->enabled) { return; } - $connection = $args->getObjectManager()->getConnection(); - - $sqlQueries = self::getUpdateRelationsAtQueries(withWhereClause: true); - - $connection->beginTransaction(); - - try { - foreach ($sqlQueries as $sqlQuery) { - $stm = $connection->prepare($sqlQuery); - $stm->executeStatement(); + $em = $args->getObjectManager(); + $uow = $em->getUnitOfWork(); + + // Catch ManyToMany collection adds/removes + foreach ($uow->getScheduledCollectionUpdates() as $collection) { + $owner = $collection->getOwner(); + if ($owner instanceof RelationsChecksumInterface) { + $owner->setChanged(true); + $uow->recomputeSingleEntityChangeSet( + $em->getClassMetadata($owner::class), + $owner + ); } - - $connection->commit(); - } catch (\Exception $e) { - $connection->rollBack(); - throw $e; - } - } - - /** - * Get an array of SQL update statements to update the changed and relationsModified fields. - * - * @param bool $withWhereClause - * Should the statements include a where clause to limit the statement - * - * @return string[] - * Array of SQL statements - */ - public static function getUpdateRelationsAtQueries(bool $withWhereClause = true): array - { - // Set SQL update queries for the "relations checksum" fields on the parent (p), child (c) relationships up through the entity tree - $sqlQueries = []; - - // Feed - $sqlQueries[] = self::getToOneQuery(jsonKey: 'feedSource', parentTable: 'feed', childTable: 'feed_source', withWhereClause: $withWhereClause); - $sqlQueries[] = self::getToOneQuery(jsonKey: 'slide', parentTable: 'feed', childTable: 'slide', parentTableId: 'id', childTableId: 'feed_id', withWhereClause: $withWhereClause); - - // Slide - $sqlQueries[] = self::getManyToManyQuery(jsonKey: 'media', parentTable: 'slide', pivotTable: 'slide_media', childTable: 'media', withWhereClause: $withWhereClause); - $sqlQueries[] = self::getToOneQuery(jsonKey: 'theme', parentTable: 'slide', childTable: 'theme', withWhereClause: $withWhereClause); - $sqlQueries[] = self::getToOneQuery(jsonKey: 'templateInfo', parentTable: 'slide', childTable: 'template', withWhereClause: $withWhereClause); - $sqlQueries[] = self::getToOneQuery(jsonKey: 'feed', parentTable: 'slide', childTable: 'feed', withWhereClause: $withWhereClause); - - // PlaylistSlide - $sqlQueries[] = self::getToOneQuery(jsonKey: 'slide', parentTable: 'playlist_slide', childTable: 'slide', withWhereClause: $withWhereClause); - - // Playlist - $sqlQueries[] = self::getOneToManyQuery(jsonKey: 'slides', parentTable: 'playlist', childTable: 'playlist_slide', withWhereClause: $withWhereClause); - - // ScreenCampaign - $sqlQueries[] = self::getToOneQuery(jsonKey: 'campaign', parentTable: 'screen_campaign', childTable: 'playlist', parentTableId: 'campaign_id', withWhereClause: $withWhereClause); - $sqlQueries[] = self::getToOneQuery(jsonKey: 'screen', parentTable: 'screen_campaign', childTable: 'screen', withWhereClause: $withWhereClause); - - // ScreenGroupCampaign - campaign - $sqlQueries[] = self::getToOneQuery(jsonKey: 'campaign', parentTable: 'screen_group_campaign', childTable: 'playlist', parentTableId: 'campaign_id', withWhereClause: $withWhereClause); - - // ScreenGroup - $sqlQueries[] = self::getManyToManyQuery(jsonKey: 'screens', parentTable: 'screen_group', pivotTable: 'screen_group_screen', childTable: 'screen', withWhereClause: $withWhereClause); - $sqlQueries[] = self::getOneToManyQuery(jsonKey: 'screenGroupCampaigns', parentTable: 'screen_group', childTable: 'screen_group_campaign', withWhereClause: $withWhereClause); - - // ScreenGroupCampaign - screenGroup - $sqlQueries[] = self::getToOneQuery(jsonKey: 'screenGroup', parentTable: 'screen_group_campaign', childTable: 'screen_group', withWhereClause: $withWhereClause); - - // PlaylistScreenRegion - $sqlQueries[] = self::getToOneQuery(jsonKey: 'playlist', parentTable: 'playlist_screen_region', childTable: 'playlist', withWhereClause: $withWhereClause); - - // ScreenLayoutRegions - $sqlQueries[] = self::getToOneQuery(jsonKey: 'regions', parentTable: 'screen_layout_regions', childTable: 'playlist_screen_region', parentTableId: 'id', childTableId: 'region_id', withWhereClause: $withWhereClause); - - // ScreenLayout - $sqlQueries[] = self::getOneToManyQuery(jsonKey: 'regions', parentTable: 'screen_layout', childTable: 'screen_layout_regions', withWhereClause: $withWhereClause); - - // Screen - $sqlQueries[] = self::getOneToManyQuery(jsonKey: 'campaigns', parentTable: 'screen', childTable: 'screen_campaign', withWhereClause: $withWhereClause); - $sqlQueries[] = self::getToOneQuery(jsonKey: 'layout', parentTable: 'screen', childTable: 'screen_layout', withWhereClause: $withWhereClause); - $sqlQueries[] = self::getOneToManyQuery(jsonKey: 'regions', parentTable: 'screen', childTable: 'playlist_screen_region', withWhereClause: $withWhereClause); - $sqlQueries[] = self::getManyToManyQuery(jsonKey: 'inScreenGroups', parentTable: 'screen', pivotTable: 'screen_group_screen', childTable: 'screen_group', withWhereClause: $withWhereClause); - - // Add reset 'changed' fields queries - $sqlQueries = array_merge($sqlQueries, self::getResetChangedQueries()); - - return $sqlQueries; - } - - /** - * Get "One/ManyToOne" query. - * - * For a table (parent) that has a relation to another table (child) where we need to update the "relations_checksum" - * field on the parent with a checksum of values from the child we need to join the tables and set the values. - * - * Basically we do: "Update parent, join child, set parent value = SHA(child values)" - * - * Example: - * UPDATE slide p - * INNER JOIN theme c ON p.theme_id = c.id - * SET p.changed = 1, - * p.relations_checksum = JSON_SET(p.relations_checksum, "$.theme", SHA1(CONCAT(c.id, c.version, c.relations_checksum))) - * WHERE - * p.changed = 1 - * OR c.changed = 1 - * - * Explanation: - * UPDATE parent table p, INNER JOIN child table c - * - use INNER JOIN because the query only makes sense for result where both parent and child tables have rows - * SET changed to 1 (true) to enable propagation up the tree. - * SET the value for the relevant json key on the json object in p.relations_checksum to the checksum of the child id, version and relations checksum - * WHERE either p.changed or c.changed is true - * - Because we can't easily get a list of ID's of affected rows as we work up the tree we use the bool "changed" as clause in WHERE to limit to only update the rows just modified. - * - * @param string|null $parentTableId - * - * @return string - */ - private static function getToOneQuery(string $jsonKey, string $parentTable, string $childTable, ?string $parentTableId = null, string $childTableId = 'id', bool $withWhereClause = true): string - { - // Set the column name to use for "ON" in the Join clause. By default, the child table name with "_id" appended. - // E.g. "UPDATE feed p INNER JOIN feed_source c ON p.feed_source_id = c.id" - $parentTableId ??= $childTable.'_id'; - - // The base UPDATE query. - // - Use INNER JON to only select rows that have a match in both parent and child tables - // - Use JSON_SET to only INSERT/UPDATE the relevant key in the json object, not the whole field. - $queryFormat = ' - UPDATE %s p - INNER JOIN %s c ON p.%s = c.%s - SET p.changed = 1, - p.relations_checksum = JSON_SET(p.relations_checksum, "$.%s", SHA1(CONCAT(c.id, c.version, c.relations_checksum))) - '; - - $query = sprintf($queryFormat, $parentTable, $childTable, $parentTableId, $childTableId, $jsonKey); - - // Add WHERE clause to only update rows that have been modified since ":modified_at" - if ($withWhereClause) { - $query .= ' WHERE p.changed = 1 OR c.changed = 1'; } - return $query; - } - - /** - * Get "OnetoMany" query. - * - * For a table (parent) that has a toMany relationship to another table (child) where we need to update the "relations_checksum" - * field on the parent with a checksum of values from the child we need to join the tables and set the values. - * - * Example: - * UPDATE - * playlist p - * INNER JOIN ( - * SELECT - * c.playlist_id, - * CAST(GROUP_CONCAT(DISTINCT c.changed SEPARATOR "") > 0 AS UNSIGNED) changed, - * SHA1(GROUP_CONCAT(c.id, c.version, c.relations_checksum)) checksum - * FROM - * playlist_slide c - * GROUP BY - * c.playlist_id - * ) temp ON p.id = temp.playlist_id - * SET p.changed = 1, - * p.relations_checksum = JSON_SET(p.relations_checksum, "$.slides", temp.checksum) - * WHERE p.changed = 1 OR temp.changed = 1 - * - * Explanation: - * Because this is a "to many" relation we need to GROUP_CONCAT values from the child relations. This is done in a temporary table - * with GROUP BY parent id in the child table. This gives us just one child row for each parent row with a checksum from the relevant - * fields across all child rows. We use a DISTINCT clause in GROUP_CONCAT to limit the length of the resulting value and avoid - * illegal integer values. - * - * This temp table is then joined to the parent table to allow us to SET the p.changed and p.relations_checksum values on the parent. - * - Because GROUP_CONCAT will give us all child rows "changed" as one, e.g. "00010001" we need "> 0" to evaluate to true/false - * and then CAST that to "unsigned" to get a TINYINT (bool) - * WHERE either p.changed or c.changed is true - * - Because we can't easily get a list of ID's of affected rows as we work up the tree we use the bool "changed" as clause in - * WHERE to limit to only update the rows just modified. - * - * @return string - */ - private static function getOneToManyQuery(string $jsonKey, string $parentTable, string $childTable, bool $withWhereClause = true): string - { - $parentTableId = $parentTable.'_id'; - - $queryFormat = ' - UPDATE - %s p - INNER JOIN ( - SELECT - c.%s, - CAST(GROUP_CONCAT(DISTINCT c.changed SEPARATOR "") > 0 AS UNSIGNED) changed, - SHA1(GROUP_CONCAT(c.id, c.version, c.relations_checksum)) checksum - FROM - %s c - GROUP BY - c.%s - ) temp ON p.id = temp.%s - SET p.changed = 1, - p.relations_checksum = JSON_SET(p.relations_checksum, "$.%s", temp.checksum) - '; - - $query = sprintf($queryFormat, $parentTable, $parentTableId, $childTable, $parentTableId, $parentTableId, $jsonKey); - - if ($withWhereClause) { - $query .= ' WHERE p.changed = 1 OR temp.changed = 1'; + foreach ($uow->getScheduledCollectionDeletions() as $collection) { + $owner = $collection->getOwner(); + if ($owner instanceof RelationsChecksumInterface) { + $owner->setChanged(true); + $uow->recomputeSingleEntityChangeSet( + $em->getClassMetadata($owner::class), + $owner + ); + } } - - return $query; } /** - * Get "many to many" query. - * - * For a table (parent) that has a relation to another table (child) through a pivot table where we need to update the "changed" - * and "relations_checksum" fields on the parent with values from the child we need to join the tables and set the values. - * - * Basically we do: - * "Update parent, join temp (SELECT checksum of c.id, c.version, c.relations_checksum from the child rows with GROUP_CONCAT), set parent values = child values" - * - * Example: - * UPDATE - * slide p - * INNER JOIN ( - * SELECT - * pivot.slide_id, - * CAST(GROUP_CONCAT(DISTINCT c.changed SEPARATOR "") > 0 AS UNSIGNED) changed, - * SHA1(GROUP_CONCAT(c.id, c.version, c.relations_checksum)) checksum - * FROM - * slide_media pivot - * INNER JOIN media c ON pivot.media_id = c.id - * GROUP BY - * pivot.slide_id - * ) temp ON p.id = temp.slide_id - * SET p.changed = 1, - * p.relations_checksum = JSON_SET(p.relations_checksum, "$.media", temp.checksum) - * WHERE p.changed = 1 OR temp.changed = 1 - * - * Explanation: - * Because this is a "to many" relation we need to GROUP_CONCAT values from the child relations. This is done in a temporary table - * with GROUP BY parent id in the child table. This gives us just one child row for each parent row with a checksum from the relevant - * fields across all child rows. We use a DISTINCT clause in GROUP_CONCAT to limit the length of the resulting value and avoid - * illegal integer values. + * PostFlush listener. * - * This temp table is then joined to the parent table to allow us to SET the p.changed and p.relations_checksum values on the parent. - * - Because GROUP_CONCAT will give us all child rows "changed" as one, e.g. "00010001" we need "> 0" to evaluate to true/false - * and then CAST that to "unsigned" to get a TINYINT (bool) - * WHERE either p.changed or c.changed is true - * - Because we can't easily get a list of ID's of affected rows as we work up the tree we use the bool "changed" as clause in - * WHERE to limit to only update the rows just modified. + * Executes update SQL queries to set changed and relations_checksum fields in the database. * - * @return string - */ - private static function getManyToManyQuery(string $jsonKey, string $parentTable, string $pivotTable, string $childTable, bool $withWhereClause = true): string - { - $parentTableId = $parentTable.'_id'; - $childTableId = $childTable.'_id'; - - $queryFormat = ' - UPDATE - %s p - INNER JOIN ( - SELECT - pivot.%s, - CAST(GROUP_CONCAT(DISTINCT c.changed SEPARATOR "") > 0 AS UNSIGNED) changed, - SHA1(GROUP_CONCAT(c.id, c.version, c.relations_checksum)) checksum - FROM - %s pivot - INNER JOIN %s c ON pivot.%s = c.id - GROUP BY - pivot.%s - ) temp ON p.id = temp.%s - SET p.changed = 1, - p.relations_checksum = JSON_SET(p.relations_checksum, "$.%s", temp.checksum) - '; - - $query = sprintf($queryFormat, $parentTable, $parentTableId, $pivotTable, $childTable, $childTableId, $parentTableId, $parentTableId, $jsonKey); - if ($withWhereClause) { - $query .= ' WHERE p.changed = 1 OR temp.changed = 1'; - } - - return $query; - } - - /** - * Get an array of queries to reset all "changed" fields to 0. + * @param PostFlushEventArgs $args the PostFlushEventArgs object containing information about the event * - * Example: - * UPDATE screen SET screen.changed = 0 WHERE screen.changed = 1; + * @return void * - * @return array + * @throws \Doctrine\DBAL\Exception */ - private static function getResetChangedQueries(): array + final public function postFlush(PostFlushEventArgs $args): void { - $queries = []; - foreach (self::CHECKSUM_TABLES as $table) { - $queries[] = sprintf('UPDATE %s SET changed = 0 WHERE changed = 1', $table); + if (!$this->enabled) { + return; } - return $queries; + $this->calculator->execute(withWhereClause: true); } } diff --git a/src/Feed/CalendarApiFeedType.php b/src/Feed/CalendarApiFeedType.php index 3b0a7ed0b..8f5393ffe 100644 --- a/src/Feed/CalendarApiFeedType.php +++ b/src/Feed/CalendarApiFeedType.php @@ -461,9 +461,9 @@ private function parseBool(string|bool $value): bool { if (is_bool($value)) { return $value; - } else { - return 'true' == strtolower($value); } + + return 'true' == strtolower($value); } private function getMapping(string $key): string diff --git a/src/Feed/EventDatabaseApiV2FeedType.php b/src/Feed/EventDatabaseApiV2FeedType.php index e24e002f7..3f552f571 100644 --- a/src/Feed/EventDatabaseApiV2FeedType.php +++ b/src/Feed/EventDatabaseApiV2FeedType.php @@ -165,9 +165,9 @@ public function getData(Feed $feed): array // Fallback option is to return the cached data. if ($cacheItem->isHit()) { return $cacheItem->get(); - } else { - return []; } + + return []; } /** @@ -281,9 +281,9 @@ public function getConfigOptions(Request $request, FeedSource $feedSource, strin } catch (\Exception) { if ($cacheItem->isHit()) { return $cacheItem->get(); - } else { - return []; } + + return []; } } elseif ('subscription' === $name) { $query = $request->query->all(); diff --git a/src/Feed/KobaFeedType.php b/src/Feed/KobaFeedType.php index aab326081..db4bd10ec 100644 --- a/src/Feed/KobaFeedType.php +++ b/src/Feed/KobaFeedType.php @@ -88,9 +88,8 @@ public function getData(Feed $feed): array if (true === $filterList) { if (!str_contains($title, '(liste)')) { continue; - } else { - $title = str_replace('(liste)', '', $title); } + $title = str_replace('(liste)', '', $title); } // Apply booked title override. If enabled it changes the title to Optaget if it contains (optaget). diff --git a/src/Repository/PlaylistSlideRepository.php b/src/Repository/PlaylistSlideRepository.php index 8987f6797..a084f6a50 100644 --- a/src/Repository/PlaylistSlideRepository.php +++ b/src/Repository/PlaylistSlideRepository.php @@ -216,8 +216,8 @@ private function getWeight(Ulid $ulid): int $playlistSlide = $queryBuilder->getQuery()->setMaxResults(1)->execute(); if (0 === count($playlistSlide)) { return 0; - } else { - return $playlistSlide[0]->getWeight() + 1; } + + return $playlistSlide[0]->getWeight() + 1; } } diff --git a/src/Service/RelationsChecksumCalculator.php b/src/Service/RelationsChecksumCalculator.php new file mode 100644 index 000000000..2e76cf999 --- /dev/null +++ b/src/Service/RelationsChecksumCalculator.php @@ -0,0 +1,287 @@ + slide -> + * playlist_slide -> playlist -> screen). + * + * Extracted from RelationsChecksumListener to allow reuse in console commands + * and other contexts outside Doctrine lifecycle events. + */ +class RelationsChecksumCalculator +{ + public const array CHECKSUM_TABLES = ['feed_source', 'feed', 'slide', 'media', 'theme', 'template', 'playlist_slide', + 'playlist', 'screen_campaign', 'screen', 'screen_group_campaign', 'screen_group', + 'playlist_screen_region', 'screen_layout_regions', 'screen_layout']; + + public function __construct( + private readonly Connection $connection, + ) {} + + /** + * Execute all checksum propagation queries in a transaction. + * + * @param bool $withWhereClause limit updates to rows where changed = 1 + * + * @throws \Doctrine\DBAL\Exception + */ + public function execute(bool $withWhereClause = true): void + { + $sqlQueries = $this->getUpdateRelationsAtQueries(withWhereClause: $withWhereClause); + + $this->connection->beginTransaction(); + + try { + foreach ($sqlQueries as $sqlQuery) { + $stm = $this->connection->prepare($sqlQuery); + $stm->executeStatement(); + } + + $this->connection->commit(); + } catch (\Exception $e) { + $this->connection->rollBack(); + throw $e; + } + } + + /** + * Get an array of SQL update statements to update the changed and relationsModified fields. + * + * @param bool $withWhereClause + * Should the statements include a where clause to limit the statement + * + * @return string[] + * Array of SQL statements + */ + public function getUpdateRelationsAtQueries(bool $withWhereClause = true): array + { + // Set SQL update queries for the "relations checksum" fields on the parent (p), child (c) relationships up through the entity tree + $sqlQueries = []; + + // Feed + $sqlQueries[] = $this->getToOneQuery(jsonKey: 'feedSource', parentTable: 'feed', childTable: 'feed_source', withWhereClause: $withWhereClause); + $sqlQueries[] = $this->getToOneQuery(jsonKey: 'slide', parentTable: 'feed', childTable: 'slide', parentTableId: 'id', childTableId: 'feed_id', withWhereClause: $withWhereClause); + + // Slide + $sqlQueries[] = $this->getManyToManyQuery(jsonKey: 'media', parentTable: 'slide', pivotTable: 'slide_media', childTable: 'media', withWhereClause: $withWhereClause); + $sqlQueries[] = $this->getToOneQuery(jsonKey: 'theme', parentTable: 'slide', childTable: 'theme', withWhereClause: $withWhereClause); + $sqlQueries[] = $this->getToOneQuery(jsonKey: 'templateInfo', parentTable: 'slide', childTable: 'template', withWhereClause: $withWhereClause); + $sqlQueries[] = $this->getToOneQuery(jsonKey: 'feed', parentTable: 'slide', childTable: 'feed', withWhereClause: $withWhereClause); + + // PlaylistSlide + $sqlQueries[] = $this->getToOneQuery(jsonKey: 'slide', parentTable: 'playlist_slide', childTable: 'slide', withWhereClause: $withWhereClause); + + // Playlist + $sqlQueries[] = $this->getOneToManyQuery(jsonKey: 'slides', parentTable: 'playlist', childTable: 'playlist_slide', withWhereClause: $withWhereClause); + + // ScreenCampaign + $sqlQueries[] = $this->getToOneQuery(jsonKey: 'campaign', parentTable: 'screen_campaign', childTable: 'playlist', parentTableId: 'campaign_id', withWhereClause: $withWhereClause); + $sqlQueries[] = $this->getToOneQuery(jsonKey: 'screen', parentTable: 'screen_campaign', childTable: 'screen', withWhereClause: $withWhereClause); + + // ScreenGroupCampaign - campaign + $sqlQueries[] = $this->getToOneQuery(jsonKey: 'campaign', parentTable: 'screen_group_campaign', childTable: 'playlist', parentTableId: 'campaign_id', withWhereClause: $withWhereClause); + + // ScreenGroup + $sqlQueries[] = $this->getManyToManyQuery(jsonKey: 'screens', parentTable: 'screen_group', pivotTable: 'screen_group_screen', childTable: 'screen', withWhereClause: $withWhereClause); + $sqlQueries[] = $this->getOneToManyQuery(jsonKey: 'screenGroupCampaigns', parentTable: 'screen_group', childTable: 'screen_group_campaign', withWhereClause: $withWhereClause); + + // ScreenGroupCampaign - screenGroup + $sqlQueries[] = $this->getToOneQuery(jsonKey: 'screenGroup', parentTable: 'screen_group_campaign', childTable: 'screen_group', withWhereClause: $withWhereClause); + + // PlaylistScreenRegion + $sqlQueries[] = $this->getToOneQuery(jsonKey: 'playlist', parentTable: 'playlist_screen_region', childTable: 'playlist', withWhereClause: $withWhereClause); + + // ScreenLayoutRegions + $sqlQueries[] = $this->getToOneQuery(jsonKey: 'regions', parentTable: 'screen_layout_regions', childTable: 'playlist_screen_region', parentTableId: 'id', childTableId: 'region_id', withWhereClause: $withWhereClause); + + // ScreenLayout + $sqlQueries[] = $this->getOneToManyQuery(jsonKey: 'regions', parentTable: 'screen_layout', childTable: 'screen_layout_regions', withWhereClause: $withWhereClause); + + // Screen + $sqlQueries[] = $this->getOneToManyQuery(jsonKey: 'campaigns', parentTable: 'screen', childTable: 'screen_campaign', withWhereClause: $withWhereClause); + $sqlQueries[] = $this->getToOneQuery(jsonKey: 'layout', parentTable: 'screen', childTable: 'screen_layout', withWhereClause: $withWhereClause); + $sqlQueries[] = $this->getOneToManyQuery(jsonKey: 'regions', parentTable: 'screen', childTable: 'playlist_screen_region', withWhereClause: $withWhereClause); + $sqlQueries[] = $this->getManyToManyQuery(jsonKey: 'inScreenGroups', parentTable: 'screen', pivotTable: 'screen_group_screen', childTable: 'screen_group', withWhereClause: $withWhereClause); + + // Add reset 'changed' fields queries + $sqlQueries = array_merge($sqlQueries, $this->getResetChangedQueries()); + + return $sqlQueries; + } + + /** + * Get "One/ManyToOne" query. + * + * For a table (parent) that has a relation to another table (child) where we need to update the "relations_checksum" + * field on the parent with a checksum of values from the child we need to join the tables and set the values. + * + * Basically we do: "Update parent, join child, set parent value = SHA(child values)" + * + * Example: + * UPDATE slide p + * INNER JOIN theme c ON p.theme_id = c.id + * SET p.changed = 1, + * p.relations_checksum = JSON_SET(p.relations_checksum, "$.theme", SHA1(CONCAT(c.id, c.version, c.relations_checksum))) + * WHERE + * p.changed = 1 + * OR c.changed = 1 + * + * @param string|null $parentTableId + */ + private function getToOneQuery(string $jsonKey, string $parentTable, string $childTable, ?string $parentTableId = null, string $childTableId = 'id', bool $withWhereClause = true): string + { + // Set the column name to use for "ON" in the Join clause. By default, the child table name with "_id" appended. + // E.g. "UPDATE feed p INNER JOIN feed_source c ON p.feed_source_id = c.id" + $parentTableId ??= $childTable.'_id'; + + // The base UPDATE query. + // - Use INNER JON to only select rows that have a match in both parent and child tables + // - Use JSON_SET to only INSERT/UPDATE the relevant key in the json object, not the whole field. + $queryFormat = ' + UPDATE %s p + INNER JOIN %s c ON p.%s = c.%s + SET p.changed = 1, + p.relations_checksum = JSON_SET(p.relations_checksum, "$.%s", SHA1(CONCAT(c.id, c.version, c.relations_checksum))) + '; + + $query = sprintf($queryFormat, $parentTable, $childTable, $parentTableId, $childTableId, $jsonKey); + + // Add WHERE clause to only update rows that have been modified since ":modified_at" + if ($withWhereClause) { + $query .= ' WHERE p.changed = 1 OR c.changed = 1'; + } + + return $query; + } + + /** + * Get "OnetoMany" query. + * + * For a table (parent) that has a toMany relationship to another table (child) where we need to update the "relations_checksum" + * field on the parent with a checksum of values from the child we need to join the tables and set the values. + * + * Example: + * UPDATE + * playlist p + * INNER JOIN ( + * SELECT + * c.playlist_id, + * CAST(GROUP_CONCAT(DISTINCT c.changed SEPARATOR "") > 0 AS UNSIGNED) changed, + * SHA1(GROUP_CONCAT(c.id, c.version, c.relations_checksum)) checksum + * FROM + * playlist_slide c + * GROUP BY + * c.playlist_id + * ) temp ON p.id = temp.playlist_id + * SET p.changed = 1, + * p.relations_checksum = JSON_SET(p.relations_checksum, "$.slides", temp.checksum) + * WHERE p.changed = 1 OR temp.changed = 1 + */ + private function getOneToManyQuery(string $jsonKey, string $parentTable, string $childTable, bool $withWhereClause = true): string + { + $parentTableId = $parentTable.'_id'; + + $queryFormat = ' + UPDATE + %s p + INNER JOIN ( + SELECT + c.%s, + CAST(GROUP_CONCAT(DISTINCT c.changed SEPARATOR "") > 0 AS UNSIGNED) changed, + SHA1(GROUP_CONCAT(c.id, c.version, c.relations_checksum)) checksum + FROM + %s c + GROUP BY + c.%s + ) temp ON p.id = temp.%s + SET p.changed = 1, + p.relations_checksum = JSON_SET(p.relations_checksum, "$.%s", temp.checksum) + '; + + $query = sprintf($queryFormat, $parentTable, $parentTableId, $childTable, $parentTableId, $parentTableId, $jsonKey); + + if ($withWhereClause) { + $query .= ' WHERE p.changed = 1 OR temp.changed = 1'; + } + + return $query; + } + + /** + * Get "many to many" query. + * + * For a table (parent) that has a relation to another table (child) through a pivot table where we need to update the "changed" + * and "relations_checksum" fields on the parent with values from the child we need to join the tables and set the values. + * + * Example: + * UPDATE + * slide p + * INNER JOIN ( + * SELECT + * pivot.slide_id, + * CAST(GROUP_CONCAT(DISTINCT c.changed SEPARATOR "") > 0 AS UNSIGNED) changed, + * SHA1(GROUP_CONCAT(c.id, c.version, c.relations_checksum)) checksum + * FROM + * slide_media pivot + * INNER JOIN media c ON pivot.media_id = c.id + * GROUP BY + * pivot.slide_id + * ) temp ON p.id = temp.slide_id + * SET p.changed = 1, + * p.relations_checksum = JSON_SET(p.relations_checksum, "$.media", temp.checksum) + * WHERE p.changed = 1 OR temp.changed = 1 + */ + private function getManyToManyQuery(string $jsonKey, string $parentTable, string $pivotTable, string $childTable, bool $withWhereClause = true): string + { + $parentTableId = $parentTable.'_id'; + $childTableId = $childTable.'_id'; + + $queryFormat = ' + UPDATE + %s p + INNER JOIN ( + SELECT + pivot.%s, + CAST(GROUP_CONCAT(DISTINCT c.changed SEPARATOR "") > 0 AS UNSIGNED) changed, + SHA1(GROUP_CONCAT(c.id, c.version, c.relations_checksum)) checksum + FROM + %s pivot + INNER JOIN %s c ON pivot.%s = c.id + GROUP BY + pivot.%s + ) temp ON p.id = temp.%s + SET p.changed = 1, + p.relations_checksum = JSON_SET(p.relations_checksum, "$.%s", temp.checksum) + '; + + $query = sprintf($queryFormat, $parentTable, $parentTableId, $pivotTable, $childTable, $childTableId, $parentTableId, $parentTableId, $jsonKey); + if ($withWhereClause) { + $query .= ' WHERE p.changed = 1 OR temp.changed = 1'; + } + + return $query; + } + + /** + * Get an array of queries to reset all "changed" fields to 0. + * + * @return string[] + */ + private function getResetChangedQueries(): array + { + $queries = []; + foreach (self::CHECKSUM_TABLES as $table) { + $queries[] = sprintf('UPDATE %s SET changed = 0 WHERE changed = 1', $table); + } + + return $queries; + } +} diff --git a/tests/EventListener/RelationsChecksumListenerTest.php b/tests/EventListener/RelationsChecksumListenerTest.php index 56da30fe7..ab294d678 100644 --- a/tests/EventListener/RelationsChecksumListenerTest.php +++ b/tests/EventListener/RelationsChecksumListenerTest.php @@ -478,6 +478,137 @@ public function testPersistScreen(): void $this->assertFalse($playlistScreenRegion->isChanged()); } + public function testAddScreenToScreenGroupUpdatesChecksum(): void + { + $tenant = $this->em->getRepository(Tenant::class)->findOneBy(['tenantKey' => 'ABC']); + /** @var Tenant\ScreenGroup $screenGroup */ + $screenGroup = $this->em->getRepository(Tenant\ScreenGroup::class)->findOneBy(['tenant' => $tenant]); + $beforeChecksum = $screenGroup->getRelationsChecksum()['screens']; + + // Create a new screen to add + $screenLayout = $this->em->getRepository(ScreenLayout::class)->findOneBy(['title' => 'Full screen']); + $screen = new Tenant\Screen(); + $screen->setTenant($tenant); + $screen->setScreenLayout($screenLayout); + $screen->setCreatedBy(self::class.'::testAddScreenToScreenGroupUpdatesChecksum()'); + + $this->em->persist($screen); + $this->em->flush(); + + // Collection-only change on screen group — no scalar property change + $screenGroup->addScreen($screen); + $this->em->flush(); + + $this->em->refresh($screenGroup); + $this->assertNotEquals($beforeChecksum, $screenGroup->getRelationsChecksum()['screens']); + $this->assertFalse($screenGroup->isChanged()); + } + + public function testRemoveScreenFromScreenGroupUpdatesChecksum(): void + { + $tenant = $this->em->getRepository(Tenant::class)->findOneBy(['tenantKey' => 'ABC']); + /** @var Tenant\ScreenGroup $screenGroup */ + $screenGroup = $this->em->getRepository(Tenant\ScreenGroup::class)->findOneBy(['tenant' => $tenant]); + $this->assertGreaterThan(0, $screenGroup->getScreens()->count()); + + $beforeChecksum = $screenGroup->getRelationsChecksum()['screens']; + + // Collection-only change — remove a screen without changing scalar properties + $screen = $screenGroup->getScreens()->first(); + $screenGroup->removeScreen($screen); + $this->em->flush(); + + $this->em->refresh($screenGroup); + $this->assertNotEquals($beforeChecksum, $screenGroup->getRelationsChecksum()['screens']); + $this->assertFalse($screenGroup->isChanged()); + } + + public function testAddMediaToSlideUpdatesChecksum(): void + { + $tenant = $this->em->getRepository(Tenant::class)->findOneBy(['tenantKey' => 'ABC']); + /** @var Tenant\Slide $slide */ + $slide = $this->em->getRepository(Tenant\Slide::class)->findOneBy(['tenant' => $tenant]); + $beforeChecksum = $slide->getRelationsChecksum()['media']; + + // Find a media not already on this slide + $allMedia = $this->em->getRepository(Tenant\Media::class)->findBy(['tenant' => $tenant]); + $existingMediaIds = $slide->getMedia()->map(fn ($m) => $m->getId())->toArray(); + $newMedia = null; + foreach ($allMedia as $candidate) { + if (!in_array($candidate->getId(), $existingMediaIds, true)) { + $newMedia = $candidate; + break; + } + } + $this->assertNotNull($newMedia, 'No available media found for test'); + + // Collection-only change on slide — no scalar property change + $slide->addMedium($newMedia); + $this->em->flush(); + + $this->em->refresh($slide); + $this->assertNotEquals($beforeChecksum, $slide->getRelationsChecksum()['media']); + $this->assertFalse($slide->isChanged()); + } + + public function testRemoveMediaFromSlideUpdatesChecksum(): void + { + $tenant = $this->em->getRepository(Tenant::class)->findOneBy(['tenantKey' => 'ABC']); + /** @var Tenant\Slide $slide */ + $slide = $this->em->getRepository(Tenant\Slide::class)->findOneBy(['tenant' => $tenant]); + $this->assertGreaterThan(0, $slide->getMedia()->count()); + + $beforeChecksum = $slide->getRelationsChecksum()['media']; + + // Collection-only change — remove media without changing scalar properties + $media = $slide->getMedia()->first(); + $slide->removeMedium($media); + $this->em->flush(); + + $this->em->refresh($slide); + $this->assertNotEquals($beforeChecksum, $slide->getRelationsChecksum()['media']); + $this->assertFalse($slide->isChanged()); + } + + public function testManyToManyChangePropagatesUpTree(): void + { + $tenant = $this->em->getRepository(Tenant::class)->findOneBy(['tenantKey' => 'ABC']); + + /** @var Tenant\ScreenGroup $screenGroup */ + $screenGroup = $this->em->getRepository(Tenant\ScreenGroup::class)->findOneBy(['tenant' => $tenant]); + $screenGroupBefore = $screenGroup->getRelationsChecksum()['screens']; + + // Get a screen that belongs to this group — its inScreenGroups checksum should also change + /** @var Tenant\Screen $existingScreen */ + $existingScreen = $screenGroup->getScreens()->first(); + $this->assertNotNull($existingScreen); + $screenBefore = $existingScreen->getRelationsChecksum()['inScreenGroups']; + + // Create a new screen to add to the group + $screenLayout = $this->em->getRepository(ScreenLayout::class)->findOneBy(['title' => 'Full screen']); + $newScreen = new Tenant\Screen(); + $newScreen->setTenant($tenant); + $newScreen->setScreenLayout($screenLayout); + $newScreen->setCreatedBy(self::class.'::testManyToManyChangePropagatesUpTree()'); + + $this->em->persist($newScreen); + $this->em->flush(); + + // Collection-only change on screen group — triggers onFlush + $screenGroup->addScreen($newScreen); + $this->em->flush(); + + // Screen group checksum should update + $this->em->refresh($screenGroup); + $this->assertNotEquals($screenGroupBefore, $screenGroup->getRelationsChecksum()['screens']); + $this->assertFalse($screenGroup->isChanged()); + + // Existing screen's inScreenGroups checksum should propagate + $this->em->refresh($existingScreen); + $this->assertNotEquals($screenBefore, $existingScreen->getRelationsChecksum()['inScreenGroups']); + $this->assertFalse($existingScreen->isChanged()); + } + public function testPlaylistSlideRelation(): void { $tenant = $this->em->getRepository(Tenant::class)->findOneBy(['tenantKey' => 'ABC']);