Align product records with sitemap V2; product family model; CLI workbook import
- vitec:migrate-product-structure: exact V2 titles, Milestone typo fix (incl. slug), QTX100 stray VSN category removed, Aligo record moved to its own category with slug /product/aligo, new Aligo Workstation record takes over /product/aligo-workstation - product families (sitemap column K) are ordinary content pages now: vitec:remove-family-records deletes the interim landing records again, reset_subproduct_flags.php clears the misused subproduct flag (the list renderer has always excluded subproduct=1) - vitec:import-product-workbook: non-interactive counterpart of the module import (same reader, mapping and DataHandler path); Arqa imported, Aligo re-applied with the corrected capabilities markup - ProductXlsxReader: new :copy cell selector (Body Copy with CTA fallback) absorbs the agency's column drift; default teaser mapping uses it - ProductListJsonRenderer: selected categories now match their whole subtree, results ordered by category tree position, sheet order within a category - read-only diagnostics: check_product_structure.php, check_productlist_ces.php
This commit is contained in:
194
composer.lock
generated
194
composer.lock
generated
@@ -8,16 +8,16 @@
|
|||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "apache-solr-for-typo3/solr",
|
"name": "apache-solr-for-typo3/solr",
|
||||||
"version": "14.0.0-RC1",
|
"version": "14.0.1",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/TYPO3-Solr/ext-solr.git",
|
"url": "https://github.com/TYPO3-Solr/ext-solr.git",
|
||||||
"reference": "96834e83592d59c350005216821ee502cfee8ece"
|
"reference": "25fad97ffe192b1c4afc9400b98f4b4c4d3a6ea3"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/TYPO3-Solr/ext-solr/zipball/96834e83592d59c350005216821ee502cfee8ece",
|
"url": "https://api.github.com/repos/TYPO3-Solr/ext-solr/zipball/25fad97ffe192b1c4afc9400b98f4b4c4d3a6ea3",
|
||||||
"reference": "96834e83592d59c350005216821ee502cfee8ece",
|
"reference": "25fad97ffe192b1c4afc9400b98f4b4c4d3a6ea3",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -33,7 +33,6 @@
|
|||||||
"typo3/cms-extbase": "*",
|
"typo3/cms-extbase": "*",
|
||||||
"typo3/cms-fluid": "*",
|
"typo3/cms-fluid": "*",
|
||||||
"typo3/cms-frontend": "*",
|
"typo3/cms-frontend": "*",
|
||||||
"typo3/cms-install": "*",
|
|
||||||
"typo3/cms-reports": "*",
|
"typo3/cms-reports": "*",
|
||||||
"typo3/cms-scheduler": "*",
|
"typo3/cms-scheduler": "*",
|
||||||
"typo3/cms-tstemplate": "*"
|
"typo3/cms-tstemplate": "*"
|
||||||
@@ -52,12 +51,23 @@
|
|||||||
"phpstan/phpstan-phpunit": "^2.0.18",
|
"phpstan/phpstan-phpunit": "^2.0.18",
|
||||||
"phpunit/phpunit": "^11.5.55",
|
"phpunit/phpunit": "^11.5.55",
|
||||||
"typo3/cms-fluid-styled-content": "*",
|
"typo3/cms-fluid-styled-content": "*",
|
||||||
|
"typo3/cms-install": "*",
|
||||||
"typo3/coding-standards": "v0.8.0",
|
"typo3/coding-standards": "v0.8.0",
|
||||||
"typo3/testing-framework": "^9.5.0"
|
"typo3/testing-framework": "^9.5.0"
|
||||||
},
|
},
|
||||||
|
"suggest": {
|
||||||
|
"typo3/cms-install": "Required only to run the \"Apache Solr Search\" plugin to content element upgrade wizard."
|
||||||
|
},
|
||||||
"type": "typo3-cms-extension",
|
"type": "typo3-cms-extension",
|
||||||
"extra": {
|
"extra": {
|
||||||
"typo3/cms": {
|
"typo3/cms": {
|
||||||
|
"Package": {
|
||||||
|
"providesPackages": {
|
||||||
|
"solarium/solarium": "Resources/Private/Php/ComposerLibraries/vendor",
|
||||||
|
"halaxa/json-machine": "Resources/Private/Php/ComposerLibraries/vendor"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"version": "14.0.x-dev",
|
||||||
"web-dir": ".Build/Web",
|
"web-dir": ".Build/Web",
|
||||||
"extension-key": "solr"
|
"extension-key": "solr"
|
||||||
},
|
},
|
||||||
@@ -119,7 +129,7 @@
|
|||||||
"type": "custom"
|
"type": "custom"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-07-17T15:16:50+00:00"
|
"time": "2026-09-03T14:19:19+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "b13/container",
|
"name": "b13/container",
|
||||||
@@ -1610,16 +1620,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "friendsoftypo3/headless",
|
"name": "friendsoftypo3/headless",
|
||||||
"version": "v5.0.0-rc1",
|
"version": "v5.0.0-rc2",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/TYPO3-Headless/headless.git",
|
"url": "https://github.com/TYPO3-Headless/headless.git",
|
||||||
"reference": "53eb4233a2b94fe47b88fa00a3f9859ba4eb1419"
|
"reference": "1eae1026caa455d05dea5e89c69b7c8474295b83"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/TYPO3-Headless/headless/zipball/53eb4233a2b94fe47b88fa00a3f9859ba4eb1419",
|
"url": "https://api.github.com/repos/TYPO3-Headless/headless/zipball/1eae1026caa455d05dea5e89c69b7c8474295b83",
|
||||||
"reference": "53eb4233a2b94fe47b88fa00a3f9859ba4eb1419",
|
"reference": "1eae1026caa455d05dea5e89c69b7c8474295b83",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -1637,18 +1647,26 @@
|
|||||||
"helmich/typo3-typoscript-lint": "^v3",
|
"helmich/typo3-typoscript-lint": "^v3",
|
||||||
"justinrainbow/json-schema": "^5",
|
"justinrainbow/json-schema": "^5",
|
||||||
"phpstan/extension-installer": "^1.1",
|
"phpstan/extension-installer": "^1.1",
|
||||||
"phpstan/phpstan": "^1.12",
|
"phpstan/phpstan": "^2",
|
||||||
"phpunit/phpcov": "^8 || ^9 || ^10",
|
"phpunit/phpcov": "^8 || ^9 || ^10",
|
||||||
"seld/jsonlint": "^1.11",
|
"seld/jsonlint": "^1.11",
|
||||||
"symfony/yaml": "^6.1 || ^7.1",
|
"symfony/yaml": "^6.1 || ^7.1",
|
||||||
|
"typo3/cms-felogin": "^14.3",
|
||||||
"typo3/cms-form": "^14.0",
|
"typo3/cms-form": "^14.0",
|
||||||
|
"typo3/cms-redirects": "^14.3",
|
||||||
|
"typo3/cms-seo": "^14.3",
|
||||||
|
"typo3/cms-workspaces": "^14.3",
|
||||||
"typo3/coding-standards": "^0.8",
|
"typo3/coding-standards": "^0.8",
|
||||||
"typo3/testing-framework": "^9.0"
|
"typo3/testing-framework": "^9.0"
|
||||||
},
|
},
|
||||||
"type": "typo3-cms-extension",
|
"type": "typo3-cms-extension",
|
||||||
"extra": {
|
"extra": {
|
||||||
"typo3/cms": {
|
"typo3/cms": {
|
||||||
|
"Package": {
|
||||||
|
"providesPackages": []
|
||||||
|
},
|
||||||
"app-dir": ".Build",
|
"app-dir": ".Build",
|
||||||
|
"version": "5.0.0-rc2",
|
||||||
"web-dir": ".Build/public",
|
"web-dir": ".Build/public",
|
||||||
"extension-key": "headless"
|
"extension-key": "headless"
|
||||||
}
|
}
|
||||||
@@ -1686,7 +1704,7 @@
|
|||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/TYPO3-Headless/headless/issues",
|
"issues": "https://github.com/TYPO3-Headless/headless/issues",
|
||||||
"source": "https://github.com/TYPO3-Headless/headless/tree/v5.0.0-rc1"
|
"source": "https://github.com/TYPO3-Headless/headless/tree/v5.0.0-rc2"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -1694,7 +1712,7 @@
|
|||||||
"type": "custom"
|
"type": "custom"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-05-08T15:09:37+00:00"
|
"time": "2026-09-02T12:07:34+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "georgringer/news",
|
"name": "georgringer/news",
|
||||||
@@ -3252,16 +3270,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "phpstan/phpdoc-parser",
|
"name": "phpstan/phpdoc-parser",
|
||||||
"version": "2.3.3",
|
"version": "2.3.5",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/phpstan/phpdoc-parser.git",
|
"url": "https://github.com/phpstan/phpdoc-parser.git",
|
||||||
"reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3"
|
"reference": "148cefffaf0233e4c08cc13db8a195a56dd6dfe9"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/fb19eedd2bb67ff8cf7a5502ad329e701d6398a3",
|
"url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/148cefffaf0233e4c08cc13db8a195a56dd6dfe9",
|
||||||
"reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3",
|
"reference": "148cefffaf0233e4c08cc13db8a195a56dd6dfe9",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -3293,9 +3311,9 @@
|
|||||||
"description": "PHPDoc parser with support for nullable, intersection and generic types",
|
"description": "PHPDoc parser with support for nullable, intersection and generic types",
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/phpstan/phpdoc-parser/issues",
|
"issues": "https://github.com/phpstan/phpdoc-parser/issues",
|
||||||
"source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.3"
|
"source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.5"
|
||||||
},
|
},
|
||||||
"time": "2026-07-08T07:01:06+00:00"
|
"time": "2026-08-31T16:05:28+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "plan2net/backend-category-hierarchy",
|
"name": "plan2net/backend-category-hierarchy",
|
||||||
@@ -4634,16 +4652,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/console",
|
"name": "symfony/console",
|
||||||
"version": "v7.4.17",
|
"version": "v7.4.18",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/console.git",
|
"url": "https://github.com/symfony/console.git",
|
||||||
"reference": "962e18f09ebe68a49039b4c82fc0ea4871824fca"
|
"reference": "23d6f88a29f6d0eac45bd77d70307adf83ba7ab0"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/symfony/console/zipball/962e18f09ebe68a49039b4c82fc0ea4871824fca",
|
"url": "https://api.github.com/repos/symfony/console/zipball/23d6f88a29f6d0eac45bd77d70307adf83ba7ab0",
|
||||||
"reference": "962e18f09ebe68a49039b4c82fc0ea4871824fca",
|
"reference": "23d6f88a29f6d0eac45bd77d70307adf83ba7ab0",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -4708,7 +4726,7 @@
|
|||||||
"terminal"
|
"terminal"
|
||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/symfony/console/tree/v7.4.17"
|
"source": "https://github.com/symfony/console/tree/v7.4.18"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -4728,7 +4746,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-08-21T12:09:28+00:00"
|
"time": "2026-08-25T14:18:37+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/dependency-injection",
|
"name": "symfony/dependency-injection",
|
||||||
@@ -4887,7 +4905,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/doctrine-messenger",
|
"name": "symfony/doctrine-messenger",
|
||||||
"version": "v7.4.15",
|
"version": "v7.4.18",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/doctrine-messenger.git",
|
"url": "https://github.com/symfony/doctrine-messenger.git",
|
||||||
@@ -4939,7 +4957,7 @@
|
|||||||
"description": "Symfony Doctrine Messenger Bridge",
|
"description": "Symfony Doctrine Messenger Bridge",
|
||||||
"homepage": "https://symfony.com",
|
"homepage": "https://symfony.com",
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/symfony/doctrine-messenger/tree/v7.4.15"
|
"source": "https://github.com/symfony/doctrine-messenger/tree/v7.4.18"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -5128,16 +5146,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/expression-language",
|
"name": "symfony/expression-language",
|
||||||
"version": "v7.4.14",
|
"version": "v7.4.18",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/expression-language.git",
|
"url": "https://github.com/symfony/expression-language.git",
|
||||||
"reference": "bd5763f92959201816ecc31defdf352d2ea473be"
|
"reference": "b73866506ff02800836a40fa1f95614a71064f35"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/symfony/expression-language/zipball/bd5763f92959201816ecc31defdf352d2ea473be",
|
"url": "https://api.github.com/repos/symfony/expression-language/zipball/b73866506ff02800836a40fa1f95614a71064f35",
|
||||||
"reference": "bd5763f92959201816ecc31defdf352d2ea473be",
|
"reference": "b73866506ff02800836a40fa1f95614a71064f35",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -5172,7 +5190,7 @@
|
|||||||
"description": "Provides an engine that can compile and evaluate expressions",
|
"description": "Provides an engine that can compile and evaluate expressions",
|
||||||
"homepage": "https://symfony.com",
|
"homepage": "https://symfony.com",
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/symfony/expression-language/tree/v7.4.14"
|
"source": "https://github.com/symfony/expression-language/tree/v7.4.18"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -5192,20 +5210,20 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-06-08T20:24:16+00:00"
|
"time": "2026-08-30T00:47:26+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/filesystem",
|
"name": "symfony/filesystem",
|
||||||
"version": "v7.4.17",
|
"version": "v7.4.18",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/filesystem.git",
|
"url": "https://github.com/symfony/filesystem.git",
|
||||||
"reference": "ee7bc7bca4c7079b88e57d5000aeeb20df570c8d"
|
"reference": "90d412aa5277c6819db39e7605aa46b1019e3232"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/symfony/filesystem/zipball/ee7bc7bca4c7079b88e57d5000aeeb20df570c8d",
|
"url": "https://api.github.com/repos/symfony/filesystem/zipball/90d412aa5277c6819db39e7605aa46b1019e3232",
|
||||||
"reference": "ee7bc7bca4c7079b88e57d5000aeeb20df570c8d",
|
"reference": "90d412aa5277c6819db39e7605aa46b1019e3232",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -5242,7 +5260,7 @@
|
|||||||
"description": "Provides basic utilities for the filesystem",
|
"description": "Provides basic utilities for the filesystem",
|
||||||
"homepage": "https://symfony.com",
|
"homepage": "https://symfony.com",
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/symfony/filesystem/tree/v7.4.17"
|
"source": "https://github.com/symfony/filesystem/tree/v7.4.18"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -5262,7 +5280,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-08-21T12:09:28+00:00"
|
"time": "2026-08-23T10:03:40+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/finder",
|
"name": "symfony/finder",
|
||||||
@@ -5334,16 +5352,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/http-foundation",
|
"name": "symfony/http-foundation",
|
||||||
"version": "v7.4.17",
|
"version": "v7.4.18",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/http-foundation.git",
|
"url": "https://github.com/symfony/http-foundation.git",
|
||||||
"reference": "2ebe78c083501dfb9509b31a7aedcae4d60a391f"
|
"reference": "d070b716a32fbe3bf04204db0f58ace73b86d133"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/symfony/http-foundation/zipball/2ebe78c083501dfb9509b31a7aedcae4d60a391f",
|
"url": "https://api.github.com/repos/symfony/http-foundation/zipball/d070b716a32fbe3bf04204db0f58ace73b86d133",
|
||||||
"reference": "2ebe78c083501dfb9509b31a7aedcae4d60a391f",
|
"reference": "d070b716a32fbe3bf04204db0f58ace73b86d133",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -5392,7 +5410,7 @@
|
|||||||
"description": "Defines an object-oriented layer for the HTTP specification",
|
"description": "Defines an object-oriented layer for the HTTP specification",
|
||||||
"homepage": "https://symfony.com",
|
"homepage": "https://symfony.com",
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/symfony/http-foundation/tree/v7.4.17"
|
"source": "https://github.com/symfony/http-foundation/tree/v7.4.18"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -5412,7 +5430,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-08-20T09:55:18+00:00"
|
"time": "2026-08-30T20:10:52+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/mailer",
|
"name": "symfony/mailer",
|
||||||
@@ -5500,16 +5518,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/messenger",
|
"name": "symfony/messenger",
|
||||||
"version": "v7.4.17",
|
"version": "v7.4.18",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/messenger.git",
|
"url": "https://github.com/symfony/messenger.git",
|
||||||
"reference": "3976018381c403e369d3027592e911b979eaeed9"
|
"reference": "b109f9f4e54470ee6835751bc39318fe3d8275e6"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/symfony/messenger/zipball/3976018381c403e369d3027592e911b979eaeed9",
|
"url": "https://api.github.com/repos/symfony/messenger/zipball/b109f9f4e54470ee6835751bc39318fe3d8275e6",
|
||||||
"reference": "3976018381c403e369d3027592e911b979eaeed9",
|
"reference": "b109f9f4e54470ee6835751bc39318fe3d8275e6",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -5570,7 +5588,7 @@
|
|||||||
"description": "Helps applications send and receive messages to/from other applications or via message queues",
|
"description": "Helps applications send and receive messages to/from other applications or via message queues",
|
||||||
"homepage": "https://symfony.com",
|
"homepage": "https://symfony.com",
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/symfony/messenger/tree/v7.4.17"
|
"source": "https://github.com/symfony/messenger/tree/v7.4.18"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -5590,11 +5608,11 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-08-21T17:40:08+00:00"
|
"time": "2026-08-30T00:47:26+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/mime",
|
"name": "symfony/mime",
|
||||||
"version": "v7.4.17",
|
"version": "v7.4.18",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/mime.git",
|
"url": "https://github.com/symfony/mime.git",
|
||||||
@@ -5659,7 +5677,7 @@
|
|||||||
"mime-type"
|
"mime-type"
|
||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/symfony/mime/tree/v7.4.17"
|
"source": "https://github.com/symfony/mime/tree/v7.4.18"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -6743,7 +6761,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/process",
|
"name": "symfony/process",
|
||||||
"version": "v7.4.17",
|
"version": "v7.4.18",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/process.git",
|
"url": "https://github.com/symfony/process.git",
|
||||||
@@ -6784,7 +6802,7 @@
|
|||||||
"description": "Executes commands in sub-processes",
|
"description": "Executes commands in sub-processes",
|
||||||
"homepage": "https://symfony.com",
|
"homepage": "https://symfony.com",
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/symfony/process/tree/v7.4.17"
|
"source": "https://github.com/symfony/process/tree/v7.4.18"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -6889,16 +6907,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/property-info",
|
"name": "symfony/property-info",
|
||||||
"version": "v7.4.17",
|
"version": "v7.4.18",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/property-info.git",
|
"url": "https://github.com/symfony/property-info.git",
|
||||||
"reference": "a37837b52ed5dd7474c251d15694007344c6462d"
|
"reference": "52420d499b03ec20c0d38f0dc2f940bc9c43dc8e"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/symfony/property-info/zipball/a37837b52ed5dd7474c251d15694007344c6462d",
|
"url": "https://api.github.com/repos/symfony/property-info/zipball/52420d499b03ec20c0d38f0dc2f940bc9c43dc8e",
|
||||||
"reference": "a37837b52ed5dd7474c251d15694007344c6462d",
|
"reference": "52420d499b03ec20c0d38f0dc2f940bc9c43dc8e",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -6955,7 +6973,7 @@
|
|||||||
"validator"
|
"validator"
|
||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/symfony/property-info/tree/v7.4.17"
|
"source": "https://github.com/symfony/property-info/tree/v7.4.18"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -6975,11 +6993,11 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-08-21T17:40:08+00:00"
|
"time": "2026-08-25T14:18:37+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/rate-limiter",
|
"name": "symfony/rate-limiter",
|
||||||
"version": "v7.4.16",
|
"version": "v7.4.18",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/rate-limiter.git",
|
"url": "https://github.com/symfony/rate-limiter.git",
|
||||||
@@ -7029,7 +7047,7 @@
|
|||||||
"rate-limiter"
|
"rate-limiter"
|
||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/symfony/rate-limiter/tree/v7.4.16"
|
"source": "https://github.com/symfony/rate-limiter/tree/v7.4.18"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -7053,7 +7071,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/routing",
|
"name": "symfony/routing",
|
||||||
"version": "v7.4.17",
|
"version": "v7.4.18",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/routing.git",
|
"url": "https://github.com/symfony/routing.git",
|
||||||
@@ -7114,7 +7132,7 @@
|
|||||||
"url"
|
"url"
|
||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/symfony/routing/tree/v7.4.17"
|
"source": "https://github.com/symfony/routing/tree/v7.4.18"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -7138,16 +7156,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/service-contracts",
|
"name": "symfony/service-contracts",
|
||||||
"version": "v3.7.1",
|
"version": "v3.7.3",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/service-contracts.git",
|
"url": "https://github.com/symfony/service-contracts.git",
|
||||||
"reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0"
|
"reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0",
|
"url": "https://api.github.com/repos/symfony/service-contracts/zipball/15e6a07ec2a2c75ceb1b21dd98105ee8456d2257",
|
||||||
"reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0",
|
"reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -7201,7 +7219,7 @@
|
|||||||
"standards"
|
"standards"
|
||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/symfony/service-contracts/tree/v3.7.1"
|
"source": "https://github.com/symfony/service-contracts/tree/v3.7.3"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -7221,7 +7239,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-06-16T09:55:08+00:00"
|
"time": "2026-07-27T15:39:01+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/string",
|
"name": "symfony/string",
|
||||||
@@ -7657,16 +7675,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/validator",
|
"name": "symfony/validator",
|
||||||
"version": "v7.4.17",
|
"version": "v7.4.18",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/validator.git",
|
"url": "https://github.com/symfony/validator.git",
|
||||||
"reference": "b1cbb758c005fbe0d7b2b8d1561869d318628a10"
|
"reference": "976bd879fda78de30ee4688fd38ae87bc4d2dc76"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/symfony/validator/zipball/b1cbb758c005fbe0d7b2b8d1561869d318628a10",
|
"url": "https://api.github.com/repos/symfony/validator/zipball/976bd879fda78de30ee4688fd38ae87bc4d2dc76",
|
||||||
"reference": "b1cbb758c005fbe0d7b2b8d1561869d318628a10",
|
"reference": "976bd879fda78de30ee4688fd38ae87bc4d2dc76",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -7737,7 +7755,7 @@
|
|||||||
"description": "Provides tools to validate values",
|
"description": "Provides tools to validate values",
|
||||||
"homepage": "https://symfony.com",
|
"homepage": "https://symfony.com",
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/symfony/validator/tree/v7.4.17"
|
"source": "https://github.com/symfony/validator/tree/v7.4.18"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -7757,7 +7775,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-08-22T09:04:42+00:00"
|
"time": "2026-08-30T00:47:26+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/var-exporter",
|
"name": "symfony/var-exporter",
|
||||||
@@ -7841,16 +7859,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/yaml",
|
"name": "symfony/yaml",
|
||||||
"version": "v7.4.17",
|
"version": "v7.4.18",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/yaml.git",
|
"url": "https://github.com/symfony/yaml.git",
|
||||||
"reference": "0b040d7b66ceb10b7bb24c8e6656257932693a12"
|
"reference": "4cef939e55b8a21c5780418de0d98ab00d0736e1"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/symfony/yaml/zipball/0b040d7b66ceb10b7bb24c8e6656257932693a12",
|
"url": "https://api.github.com/repos/symfony/yaml/zipball/4cef939e55b8a21c5780418de0d98ab00d0736e1",
|
||||||
"reference": "0b040d7b66ceb10b7bb24c8e6656257932693a12",
|
"reference": "4cef939e55b8a21c5780418de0d98ab00d0736e1",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -7893,7 +7911,7 @@
|
|||||||
"description": "Loads and dumps YAML files",
|
"description": "Loads and dumps YAML files",
|
||||||
"homepage": "https://symfony.com",
|
"homepage": "https://symfony.com",
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/symfony/yaml/tree/v7.4.17"
|
"source": "https://github.com/symfony/yaml/tree/v7.4.18"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -7913,7 +7931,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-08-21T12:09:28+00:00"
|
"time": "2026-08-30T00:47:26+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "typo3/class-alias-loader",
|
"name": "typo3/class-alias-loader",
|
||||||
@@ -10081,16 +10099,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "typo3fluid/fluid",
|
"name": "typo3fluid/fluid",
|
||||||
"version": "5.3.1",
|
"version": "5.3.2",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/TYPO3/Fluid.git",
|
"url": "https://github.com/TYPO3/Fluid.git",
|
||||||
"reference": "28c42c75b171aef196ddbe151b0d1146c290249d"
|
"reference": "2f16b9e8d2509f7abb6a087ad781e361351f465d"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/TYPO3/Fluid/zipball/28c42c75b171aef196ddbe151b0d1146c290249d",
|
"url": "https://api.github.com/repos/TYPO3/Fluid/zipball/2f16b9e8d2509f7abb6a087ad781e361351f465d",
|
||||||
"reference": "28c42c75b171aef196ddbe151b0d1146c290249d",
|
"reference": "2f16b9e8d2509f7abb6a087ad781e361351f465d",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -10137,7 +10155,7 @@
|
|||||||
"issues": "https://github.com/TYPO3/Fluid/issues",
|
"issues": "https://github.com/TYPO3/Fluid/issues",
|
||||||
"source": "https://github.com/TYPO3/Fluid"
|
"source": "https://github.com/TYPO3/Fluid"
|
||||||
},
|
},
|
||||||
"time": "2026-04-16T17:09:54+00:00"
|
"time": "2026-08-12T19:45:55+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "webmozart/assert",
|
"name": "webmozart/assert",
|
||||||
|
|||||||
110
migrations/check_product_structure.php
Normal file
110
migrations/check_product_structure.php
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only: dump the current product structure - the sys_category tree and
|
||||||
|
* every product record with its category relations - to compare against the
|
||||||
|
* agency's "Merged site map" V2 (Products tab, columns J/K/L).
|
||||||
|
*
|
||||||
|
* Usage: php migrations/check_product_structure.php
|
||||||
|
*/
|
||||||
|
|
||||||
|
$root = null;
|
||||||
|
$dir = __DIR__;
|
||||||
|
for ($i = 0; $i < 5; $i++) {
|
||||||
|
if (is_file($dir . '/config/system/settings.php')) {
|
||||||
|
$root = $dir;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$dir = dirname($dir);
|
||||||
|
}
|
||||||
|
if ($root === null) {
|
||||||
|
exit("Could not locate project root above " . __DIR__ . "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
$settings = require $root . '/config/system/settings.php';
|
||||||
|
$db = $settings['DB']['Connections']['Default'];
|
||||||
|
$pdo = new PDO(
|
||||||
|
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''),
|
||||||
|
$db['user'] ?? '',
|
||||||
|
$db['password'] ?? '',
|
||||||
|
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- sys_category tree ------------------------------------------------------
|
||||||
|
$cats = $pdo->query(
|
||||||
|
"SELECT uid, parent, title, hidden, sorting FROM sys_category WHERE deleted = 0 ORDER BY parent, sorting"
|
||||||
|
)->fetchAll();
|
||||||
|
$byParent = [];
|
||||||
|
foreach ($cats as $cat) {
|
||||||
|
$byParent[(int)$cat['parent']][] = $cat;
|
||||||
|
}
|
||||||
|
|
||||||
|
// product count per category (MM to our product table)
|
||||||
|
$counts = [];
|
||||||
|
foreach ($pdo->query(
|
||||||
|
"SELECT mm.uid_local AS cat, COUNT(*) AS n
|
||||||
|
FROM sys_category_record_mm mm
|
||||||
|
JOIN tx_vitec_domain_model_product p ON p.uid = mm.uid_foreign AND p.deleted = 0
|
||||||
|
WHERE mm.tablenames = 'tx_vitec_domain_model_product' AND mm.fieldname = 'categories'
|
||||||
|
GROUP BY mm.uid_local"
|
||||||
|
) as $row) {
|
||||||
|
$counts[(int)$row['cat']] = (int)$row['n'];
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "=== sys_category tree (uid | title | hidden? | #products) ===\n";
|
||||||
|
$printTree = function (int $parent, int $depth) use (&$printTree, $byParent, $counts): void {
|
||||||
|
foreach ($byParent[$parent] ?? [] as $cat) {
|
||||||
|
$uid = (int)$cat['uid'];
|
||||||
|
printf(
|
||||||
|
"%s%d | %s%s%s\n",
|
||||||
|
str_repeat(' ', $depth),
|
||||||
|
$uid,
|
||||||
|
$cat['title'],
|
||||||
|
$cat['hidden'] ? ' | HIDDEN' : '',
|
||||||
|
isset($counts[$uid]) ? ' | ' . $counts[$uid] . ' products' : ''
|
||||||
|
);
|
||||||
|
$printTree($uid, $depth + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$printTree(0, 0);
|
||||||
|
|
||||||
|
// --- products with their categories ----------------------------------------
|
||||||
|
$products = $pdo->query(
|
||||||
|
"SELECT uid, title, slug, hidden, legacy, subproduct, supportproduct
|
||||||
|
FROM tx_vitec_domain_model_product WHERE deleted = 0 ORDER BY title"
|
||||||
|
)->fetchAll();
|
||||||
|
|
||||||
|
$catTitles = [];
|
||||||
|
foreach ($cats as $cat) {
|
||||||
|
$catTitles[(int)$cat['uid']] = $cat['title'];
|
||||||
|
}
|
||||||
|
$prodCats = [];
|
||||||
|
foreach ($pdo->query(
|
||||||
|
"SELECT uid_foreign AS product, uid_local AS cat
|
||||||
|
FROM sys_category_record_mm
|
||||||
|
WHERE tablenames = 'tx_vitec_domain_model_product' AND fieldname = 'categories'"
|
||||||
|
) as $row) {
|
||||||
|
$prodCats[(int)$row['product']][] = $catTitles[(int)$row['cat']] ?? ('?' . $row['cat']);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "\n=== products (uid | title | slug | flags | categories) ===\n";
|
||||||
|
foreach ($products as $p) {
|
||||||
|
$uid = (int)$p['uid'];
|
||||||
|
$flags = [];
|
||||||
|
foreach (['hidden', 'legacy', 'subproduct', 'supportproduct'] as $flag) {
|
||||||
|
if ($p[$flag]) {
|
||||||
|
$flags[] = $flag;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
printf(
|
||||||
|
"%d | %s | %s | %s | %s\n",
|
||||||
|
$uid,
|
||||||
|
$p['title'],
|
||||||
|
$p['slug'],
|
||||||
|
$flags === [] ? '-' : implode(',', $flags),
|
||||||
|
isset($prodCats[$uid]) ? implode(' + ', $prodCats[$uid]) : 'KEINE KATEGORIE'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
printf("\n%d categories, %d products total\n", count($cats), count($products));
|
||||||
69
migrations/check_productlist_ces.php
Normal file
69
migrations/check_productlist_ces.php
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only: print the FlexForm settings (categories, allproducts, layout)
|
||||||
|
* of every vitec_productlist content element, plus which of the configured
|
||||||
|
* category uids still exist and how many products they carry.
|
||||||
|
*
|
||||||
|
* Usage: php migrations/check_productlist_ces.php
|
||||||
|
*/
|
||||||
|
|
||||||
|
$root = null;
|
||||||
|
$dir = __DIR__;
|
||||||
|
for ($i = 0; $i < 5; $i++) {
|
||||||
|
if (is_file($dir . '/config/system/settings.php')) {
|
||||||
|
$root = $dir;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$dir = dirname($dir);
|
||||||
|
}
|
||||||
|
if ($root === null) {
|
||||||
|
exit("Could not locate project root above " . __DIR__ . "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
$settings = require $root . '/config/system/settings.php';
|
||||||
|
$db = $settings['DB']['Connections']['Default'];
|
||||||
|
$pdo = new PDO(
|
||||||
|
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''),
|
||||||
|
$db['user'] ?? '',
|
||||||
|
$db['password'] ?? '',
|
||||||
|
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
|
||||||
|
);
|
||||||
|
|
||||||
|
$ces = $pdo->query(
|
||||||
|
"SELECT uid, pid, header, hidden, pi_flexform FROM tt_content
|
||||||
|
WHERE CType = 'vitec_productlist' AND deleted = 0 ORDER BY pid, sorting"
|
||||||
|
)->fetchAll();
|
||||||
|
|
||||||
|
$flexValue = function (string $xml, string $field): string {
|
||||||
|
if (preg_match('/<field index="settings\.' . preg_quote($field, '/') . '">.*?<value index="vDEF">(.*?)<\/value>/s', $xml, $m)) {
|
||||||
|
return trim($m[1]);
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach ($ces as $ce) {
|
||||||
|
printf("CE %d (pid %d)%s | header: %s\n", $ce['uid'], $ce['pid'], $ce['hidden'] ? ' HIDDEN' : '', $ce['header'] ?: '-');
|
||||||
|
$cats = $flexValue((string)$ce['pi_flexform'], 'categories');
|
||||||
|
printf(" categories: %s | allproducts: %s | layout: %s\n",
|
||||||
|
$cats === '' ? '(leer)' : $cats,
|
||||||
|
$flexValue((string)$ce['pi_flexform'], 'allproducts') ?: '0',
|
||||||
|
$flexValue((string)$ce['pi_flexform'], 'layout') ?: '-'
|
||||||
|
);
|
||||||
|
foreach (array_filter(array_map('intval', explode(',', $cats))) as $catUid) {
|
||||||
|
$cat = $pdo->query("SELECT title, deleted, hidden FROM sys_category WHERE uid = " . $catUid)->fetch();
|
||||||
|
$count = (int)$pdo->query(
|
||||||
|
"SELECT COUNT(DISTINCT p.uid) FROM sys_category_record_mm mm
|
||||||
|
JOIN tx_vitec_domain_model_product p ON p.uid = mm.uid_foreign AND p.deleted = 0
|
||||||
|
WHERE mm.uid_local = $catUid AND mm.tablenames = 'tx_vitec_domain_model_product' AND mm.fieldname = 'categories'"
|
||||||
|
)->fetchColumn();
|
||||||
|
printf(" cat %d: %s | %d products\n",
|
||||||
|
$catUid,
|
||||||
|
$cat ? ($cat['title'] . ($cat['deleted'] ? ' [DELETED]' : '') . ($cat['hidden'] ? ' [HIDDEN]' : '')) : 'EXISTIERT NICHT',
|
||||||
|
$count
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
echo count($ces) . " productlist elements total\n";
|
||||||
49
migrations/reset_subproduct_flags.php
Normal file
49
migrations/reset_subproduct_flags.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-shot: clear the subproduct flag on ALL product records.
|
||||||
|
*
|
||||||
|
* The flag was set on every classic product on 2026-09-04 while K-level
|
||||||
|
* entries still existed as records. Same-day model change: families are
|
||||||
|
* content pages, the record table holds only classic products - and the
|
||||||
|
* product list renderer has always EXCLUDED subproduct=1 (its original,
|
||||||
|
* editorial meaning), which emptied every product list on /products.
|
||||||
|
* Before 2026-09-04 no record carried the flag, so 0 across the board
|
||||||
|
* restores the original state.
|
||||||
|
*
|
||||||
|
* Usage: php migrations/reset_subproduct_flags.php
|
||||||
|
*/
|
||||||
|
|
||||||
|
$root = null;
|
||||||
|
$dir = __DIR__;
|
||||||
|
for ($i = 0; $i < 5; $i++) {
|
||||||
|
if (is_file($dir . '/config/system/settings.php')) {
|
||||||
|
$root = $dir;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$dir = dirname($dir);
|
||||||
|
}
|
||||||
|
if ($root === null) {
|
||||||
|
exit("Could not locate project root above " . __DIR__ . "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
$settings = require $root . '/config/system/settings.php';
|
||||||
|
$db = $settings['DB']['Connections']['Default'];
|
||||||
|
$pdo = new PDO(
|
||||||
|
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''),
|
||||||
|
$db['user'] ?? '',
|
||||||
|
$db['password'] ?? '',
|
||||||
|
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
|
||||||
|
);
|
||||||
|
|
||||||
|
$count = (int)$pdo->query(
|
||||||
|
"SELECT COUNT(*) FROM tx_vitec_domain_model_product WHERE deleted = 0 AND subproduct = 1"
|
||||||
|
)->fetchColumn();
|
||||||
|
echo "Records with subproduct=1: " . $count . "\n";
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("UPDATE tx_vitec_domain_model_product SET subproduct = 0, tstamp = ? WHERE deleted = 0 AND subproduct = 1");
|
||||||
|
$stmt->execute([time()]);
|
||||||
|
|
||||||
|
echo "Cleared " . $stmt->rowCount() . " flag(s). Flush the cache: vendor/bin/typo3 cache:flush\n";
|
||||||
172
packages/vitec/Classes/Command/ImportProductWorkbookCommand.php
Normal file
172
packages/vitec/Classes/Command/ImportProductWorkbookCommand.php
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Evomedien\Vitec\Command;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\ParameterType;
|
||||||
|
use Evomedien\Vitec\Controller\Backend\ProductTextImportController;
|
||||||
|
use Evomedien\Vitec\Import\MappingRepository;
|
||||||
|
use Evomedien\Vitec\Import\ProductXlsxReader;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use TYPO3\CMS\Core\Core\Bootstrap;
|
||||||
|
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||||
|
use TYPO3\CMS\Core\DataHandling\DataHandler;
|
||||||
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Non-interactive counterpart of the backend module's product-text import:
|
||||||
|
* apply one agency "Product Page Content" workbook to one product record
|
||||||
|
* from the CLI. Same reader, same mapping (the saved one from
|
||||||
|
* tx_vitec_import_mapping, falling back to the module's DEFAULT_MAPPING),
|
||||||
|
* same DataHandler write path.
|
||||||
|
*
|
||||||
|
* Writes only mapped fields whose composed value differs from the record;
|
||||||
|
* unmapped fields and the related-product cards are untouched (review those
|
||||||
|
* in the module, which also shows this run's workbook - the parse is stored
|
||||||
|
* in tx_vitec_product_workbook like an upload).
|
||||||
|
*
|
||||||
|
* vendor/bin/typo3 vitec:import-product-workbook \
|
||||||
|
* "migrations/products_xlsx/Arqa_Product Page Content.xlsx" arqa --dry-run
|
||||||
|
*/
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'vitec:import-product-workbook',
|
||||||
|
description: 'Apply a product-content workbook (XLSX) to a product record, using the module mapping'
|
||||||
|
)]
|
||||||
|
final class ImportProductWorkbookCommand extends Command
|
||||||
|
{
|
||||||
|
private const TABLE = 'tx_vitec_domain_model_product';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly ProductXlsxReader $reader,
|
||||||
|
private readonly MappingRepository $mappingRepository,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this->addArgument('file', InputArgument::REQUIRED, 'Path to the workbook (XLSX)');
|
||||||
|
$this->addArgument('product', InputArgument::REQUIRED, 'Slug or uid of the target product');
|
||||||
|
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report only - nothing written');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
Bootstrap::initializeBackendAuthentication();
|
||||||
|
$dryRun = (bool)$input->getOption('dry-run');
|
||||||
|
|
||||||
|
$file = (string)$input->getArgument('file');
|
||||||
|
if (!is_file($file)) {
|
||||||
|
$output->writeln('<error>File not found: ' . $file . '</error>');
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$product = $this->loadProduct((string)$input->getArgument('product'));
|
||||||
|
if ($product === null) {
|
||||||
|
$output->writeln('<error>No product record for "' . $input->getArgument('product') . '".</error>');
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
$uid = (int)$product['uid'];
|
||||||
|
$output->writeln(sprintf('Target: uid %d "%s" (/%s)%s', $uid, $product['title'], $product['slug'], $dryRun ? ' - DRY RUN' : ''));
|
||||||
|
|
||||||
|
$parsed = $this->reader->parse($file);
|
||||||
|
$pageType = (string)($parsed['pageType'] ?? 'unknown');
|
||||||
|
if ($pageType !== 'product') {
|
||||||
|
$output->writeln('<error>Not a product workbook (pageType "' . $pageType . '") - nothing imported.</error>');
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Workbook URL vs record slug - warn only, same as the module.
|
||||||
|
$url = trim((string)($parsed['meta']['url'] ?? ''), '/');
|
||||||
|
$slug = trim((string)$product['slug'], '/');
|
||||||
|
if ($url !== '' && $slug !== '' && !str_ends_with($url, $slug)) {
|
||||||
|
$output->writeln(sprintf('<comment>URL mismatch: /%s (workbook) vs /%s (record) - check the target!</comment>', $url, $slug));
|
||||||
|
}
|
||||||
|
|
||||||
|
$mapping = array_filter($this->mappingRepository->load('product_xlsx')['mapping'] ?? []);
|
||||||
|
if ($mapping === []) {
|
||||||
|
$mapping = ProductTextImportController::DEFAULT_MAPPING;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = [];
|
||||||
|
foreach ($mapping as $field => $selector) {
|
||||||
|
$value = $this->reader->valueFor($parsed, $selector);
|
||||||
|
if ($value === null) {
|
||||||
|
$output->writeln(sprintf('%-20s %-34s -> not in this file', $field, $selector));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$old = (string)($product[$field] ?? '');
|
||||||
|
if (trim($old) === trim($value)) {
|
||||||
|
$output->writeln(sprintf('%-20s %-34s -> unchanged', $field, $selector));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$data[$field] = $value;
|
||||||
|
$output->writeln(sprintf('%-20s %-34s -> WRITE (%d chars%s)', $field, $selector, mb_strlen($value), $old === '' ? ', was empty' : ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($data === []) {
|
||||||
|
$output->writeln('All mapped fields are up to date - nothing to write.');
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
if ($dryRun) {
|
||||||
|
$output->writeln(sprintf('DRY RUN - %d field(s) would be written.', count($data)));
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||||
|
$dataHandler->start([self::TABLE => [(string)$uid => $data]], []);
|
||||||
|
$dataHandler->process_datamap();
|
||||||
|
if ($dataHandler->errorLog !== []) {
|
||||||
|
foreach ($dataHandler->errorLog as $error) {
|
||||||
|
$output->writeln('<error>' . $error . '</error>');
|
||||||
|
}
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->storeWorkbook($uid, $parsed, basename($file));
|
||||||
|
$output->writeln(sprintf('%d field(s) written (%s). Workbook stored for the module. Flush the cache: vendor/bin/typo3 cache:flush', count($data), implode(', ', array_keys($data))));
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string,mixed>|null */
|
||||||
|
private function loadProduct(string $slugOrUid): ?array
|
||||||
|
{
|
||||||
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::TABLE);
|
||||||
|
$qb->select('*')->from(self::TABLE)->where($qb->expr()->eq('deleted', 0));
|
||||||
|
if (ctype_digit($slugOrUid)) {
|
||||||
|
$qb->andWhere($qb->expr()->eq('uid', $qb->createNamedParameter((int)$slugOrUid, ParameterType::INTEGER)));
|
||||||
|
} else {
|
||||||
|
$qb->andWhere($qb->expr()->eq('slug', $qb->createNamedParameter($slugOrUid, ParameterType::STRING)));
|
||||||
|
}
|
||||||
|
$row = $qb->executeQuery()->fetchAssociative();
|
||||||
|
return $row ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same storage the module upload uses, so "Edit Product" reopens on this
|
||||||
|
* workbook (related-card review without re-upload).
|
||||||
|
*
|
||||||
|
* @param array<string,mixed> $parsed
|
||||||
|
*/
|
||||||
|
private function storeWorkbook(int $productUid, array $parsed, string $filename): void
|
||||||
|
{
|
||||||
|
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tx_vitec_product_workbook');
|
||||||
|
$values = [
|
||||||
|
'filename' => mb_substr($filename, 0, 255),
|
||||||
|
'payload' => (string)json_encode($parsed, JSON_UNESCAPED_UNICODE),
|
||||||
|
'be_user' => 0,
|
||||||
|
'tstamp' => time(),
|
||||||
|
];
|
||||||
|
if ($connection->count('product_uid', 'tx_vitec_product_workbook', ['product_uid' => $productUid]) > 0) {
|
||||||
|
$connection->update('tx_vitec_product_workbook', $values, ['product_uid' => $productUid]);
|
||||||
|
} else {
|
||||||
|
$connection->insert('tx_vitec_product_workbook', $values + ['product_uid' => $productUid]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Evomedien\Vitec\Command;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\ParameterType;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use TYPO3\CMS\Core\Core\Bootstrap;
|
||||||
|
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||||
|
use TYPO3\CMS\Core\DataHandling\DataHandler;
|
||||||
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aligns the product records with the agency's "Merged site map" V2
|
||||||
|
* (Products tab, columns J/K/L - the binding structure per decision
|
||||||
|
* 2026-09-04). The sys_category tree already matches V2; this command fixes
|
||||||
|
* the records:
|
||||||
|
*
|
||||||
|
* Model: K-level entries ("Products") are landing records - one product
|
||||||
|
* record per level-2 category, same title as the category, subproduct=0,
|
||||||
|
* its sub-products teasered via `relatedprodukt`. L-level entries
|
||||||
|
* ("Sub-Products") are classic detail-page products with subproduct=1.
|
||||||
|
* A record is recognized as K-record by title == title of its category.
|
||||||
|
*
|
||||||
|
* Steps (in order, each idempotent):
|
||||||
|
* 1. uid 36 "Aligo": slug aligo-workstation -> aligo (frees the slug for
|
||||||
|
* the new Aligo Workstation record), category Operator Workstations ->
|
||||||
|
* Aligo (decision 3a: uid 36 stays the platform landing, the imported
|
||||||
|
* workbook content stays put)
|
||||||
|
* 2. renames to the exact V2 strings (4 records) + Mileston -> Milestone
|
||||||
|
* (typo, also in V2 itself; incl. slug)
|
||||||
|
* 3. QTX100: drop the stray VSN category (V2: Aligo only)
|
||||||
|
* 4. create the L-record "Aligo Workstation" (Operator Workstations)
|
||||||
|
* 5. create the missing K-records (27 categories without a landing record)
|
||||||
|
* 6. subproduct flag: 1 for every L-record, 0 for K-records
|
||||||
|
* 7. relatedprodukt of K-records: when EMPTY, fill with the products of
|
||||||
|
* the K category (curated lists - e.g. Aligo's imported aliases - are
|
||||||
|
* never touched)
|
||||||
|
*
|
||||||
|
* Status cases (APEX not launched, 39-Series coming soon, MGES EOL) stay
|
||||||
|
* visible per decision 2026-09-04.
|
||||||
|
*
|
||||||
|
* vendor/bin/typo3 vitec:migrate-product-structure --dry-run
|
||||||
|
* vendor/bin/typo3 vitec:migrate-product-structure
|
||||||
|
*/
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'vitec:migrate-product-structure',
|
||||||
|
description: 'Align product records with the Merged-site-map V2 structure (K landing records, L subproduct flags)'
|
||||||
|
)]
|
||||||
|
final class MigrateProductStructureCommand extends Command
|
||||||
|
{
|
||||||
|
private const TABLE = 'tx_vitec_domain_model_product';
|
||||||
|
|
||||||
|
/** Exact V2 titles for records whose current title deviates. */
|
||||||
|
private const RENAMES = [
|
||||||
|
6 => 'Avedia End-Points (EP6, 95-Series)',
|
||||||
|
11 => 'MGW Diamond-H (HDMI encoder)',
|
||||||
|
14 => 'MGW Ace decoder (ultra-low latency decoder)',
|
||||||
|
21 => 'MGW Diamond-Hx OG (HDMI/DVI blade encoder)',
|
||||||
|
55 => 'Milestone',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* K-level entries: category title => slug for the landing record.
|
||||||
|
* Slugs are explicit because several natural ones are taken by L-records
|
||||||
|
* (aetria, channellink, activesqx, visionav ...).
|
||||||
|
*/
|
||||||
|
private const K_RECORDS = [
|
||||||
|
'Avedia Platform' => 'avedia-platform',
|
||||||
|
'EZ TV Platform' => 'ez-tv-platform',
|
||||||
|
'APEX Platform' => 'apex-platform',
|
||||||
|
'Appliances' => 'appliances',
|
||||||
|
'Avedia Modular System' => 'avedia-modular-system',
|
||||||
|
'VITEC OG Modular System' => 'vitec-og-modular-system',
|
||||||
|
'MGW Blade System' => 'mgw-blade-system',
|
||||||
|
'Avedia Modular System (RF gateways)' => 'avedia-modular-system-rf-gateways',
|
||||||
|
'ChannelLink (IP to IP gateways)' => 'channellink-ip-to-ip-gateways',
|
||||||
|
'PRISM Transcoder' => 'prism-transcoder',
|
||||||
|
'Aetria' => 'aetria-platform',
|
||||||
|
'Operator Workstations' => 'operator-workstations',
|
||||||
|
'Aligo' => 'aligo',
|
||||||
|
'Arqa' => 'arqa',
|
||||||
|
'VSN' => 'vsn',
|
||||||
|
'Video Wall Management Software' => 'video-wall-management-software',
|
||||||
|
'X-Series (Multi-display Processors)' => 'x-series-multi-display-processors',
|
||||||
|
'VMS Plugins & Integrations' => 'vms-plugins-integrations',
|
||||||
|
'Image Graphics Cards (multi-output GPU cards)' => 'image-graphics-cards',
|
||||||
|
'IQS4 (4K splitter)' => 'iqs4-4k-splitter',
|
||||||
|
'VisionSC (scalable capture & processing)' => 'visionsc-scalable-capture-processing',
|
||||||
|
'VisionIO (real-time capture + overlay)' => 'visionio-real-time-capture-overlay',
|
||||||
|
'VisionAV (video + audio capture)' => 'visionav-video-audio-capture',
|
||||||
|
'Vision (DVI & SDI capture cards)' => 'vision-dvi-sdi-capture-cards',
|
||||||
|
'VisionLC (low-profile capture cards)' => 'visionlc-low-profile-capture-cards',
|
||||||
|
'ActiveSQX (IP encode/decode)' => 'activesqx-ip-encode-decode',
|
||||||
|
'Express Backplanes' => 'express-backplanes',
|
||||||
|
'Accessories & Cables' => 'accessories-cables',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report the plan - write nothing');
|
||||||
|
$this->addOption('pid', null, InputOption::VALUE_REQUIRED, 'Storage pid for new records (default: pid of the existing products)');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
Bootstrap::initializeBackendAuthentication();
|
||||||
|
$dryRun = (bool)$input->getOption('dry-run');
|
||||||
|
|
||||||
|
$categories = $this->productCategories(); // title => uid (level-2 under "Product")
|
||||||
|
$products = $this->products(); // uid => row
|
||||||
|
$productCats = $this->productCategoryMap(); // product uid => [category uids]
|
||||||
|
|
||||||
|
$pid = (int)($input->getOption('pid') ?? 0);
|
||||||
|
if ($pid <= 0) {
|
||||||
|
$pid = $this->detectPid();
|
||||||
|
}
|
||||||
|
$output->writeln(sprintf('%d products, %d level-2 categories, storage pid %d%s',
|
||||||
|
count($products), count($categories), $pid, $dryRun ? ' - DRY RUN' : ''));
|
||||||
|
|
||||||
|
$datamap = [];
|
||||||
|
$newIndex = 0;
|
||||||
|
|
||||||
|
// ---- 1. Aligo (36): slug + category move ---------------------------
|
||||||
|
$aligo = $products[36] ?? null;
|
||||||
|
if ($aligo !== null) {
|
||||||
|
if ($aligo['slug'] === 'aligo-workstation') {
|
||||||
|
$datamap[36]['slug'] = 'aligo';
|
||||||
|
$output->writeln('uid 36 Aligo: slug aligo-workstation -> aligo');
|
||||||
|
}
|
||||||
|
$cats = $productCats[36] ?? [];
|
||||||
|
if (in_array((int)($categories['Operator Workstations'] ?? -1), $cats, true)) {
|
||||||
|
$newCats = array_diff($cats, [(int)$categories['Operator Workstations']]);
|
||||||
|
$newCats[] = (int)$categories['Aligo'];
|
||||||
|
$datamap[36]['categories'] = implode(',', array_unique($newCats));
|
||||||
|
$output->writeln('uid 36 Aligo: category Operator Workstations -> Aligo');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 2. renames ----------------------------------------------------
|
||||||
|
foreach (self::RENAMES as $uid => $title) {
|
||||||
|
if (isset($products[$uid]) && $products[$uid]['title'] !== $title) {
|
||||||
|
$datamap[$uid]['title'] = $title;
|
||||||
|
$output->writeln(sprintf('uid %d: "%s" -> "%s"', $uid, $products[$uid]['title'], $title));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isset($products[55]) && $products[55]['slug'] === 'mileston') {
|
||||||
|
$datamap[55]['slug'] = 'milestone';
|
||||||
|
$output->writeln('uid 55: slug mileston -> milestone');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 3. QTX100: drop VSN -------------------------------------------
|
||||||
|
$vsnCat = (int)($categories['VSN'] ?? -1);
|
||||||
|
if (isset($productCats[37]) && in_array($vsnCat, $productCats[37], true)) {
|
||||||
|
$datamap[37]['categories'] = implode(',', array_diff($productCats[37], [$vsnCat]));
|
||||||
|
$output->writeln('uid 37 QTX100: category VSN removed (V2: Aligo only)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 4. + 5. creates ------------------------------------------------
|
||||||
|
// Existing K-record = a product whose title equals the title of one of
|
||||||
|
// its categories.
|
||||||
|
$existsAsK = function (string $catTitle) use ($products, $productCats, $categories): bool {
|
||||||
|
$catUid = (int)($categories[$catTitle] ?? -1);
|
||||||
|
foreach ($products as $uid => $p) {
|
||||||
|
if ($p['title'] === $catTitle && in_array($catUid, $productCats[$uid] ?? [], true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
$slugs = array_column($products, 'slug');
|
||||||
|
|
||||||
|
$hasAligoWorkstation = false;
|
||||||
|
foreach ($products as $p) {
|
||||||
|
if ($p['title'] === 'Aligo Workstation') {
|
||||||
|
$hasAligoWorkstation = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$hasAligoWorkstation) {
|
||||||
|
$newId = 'NEW' . ++$newIndex;
|
||||||
|
$datamap[$newId] = [
|
||||||
|
'pid' => $pid,
|
||||||
|
'title' => 'Aligo Workstation',
|
||||||
|
'slug' => 'aligo-workstation',
|
||||||
|
'categories' => (string)($categories['Operator Workstations'] ?? ''),
|
||||||
|
'subproduct' => 1,
|
||||||
|
];
|
||||||
|
$output->writeln('create L-record: Aligo Workstation (Operator Workstations)');
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (self::K_RECORDS as $catTitle => $slug) {
|
||||||
|
if (!isset($categories[$catTitle])) {
|
||||||
|
$output->writeln(sprintf('<error>category "%s" not found - skipped</error>', $catTitle));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// uid 36 IS the Aligo K-record; its move into the Aligo category
|
||||||
|
// happens in this very datamap, so $existsAsK cannot see it yet.
|
||||||
|
if ($catTitle === 'Aligo' && isset($products[36])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($existsAsK($catTitle)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (in_array($slug, $slugs, true) && !($slug === 'aligo' && isset($datamap[36]['slug']))) {
|
||||||
|
$output->writeln(sprintf('<error>slug "%s" already taken - "%s" skipped, resolve manually</error>', $slug, $catTitle));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$newId = 'NEW' . ++$newIndex;
|
||||||
|
$datamap[$newId] = [
|
||||||
|
'pid' => $pid,
|
||||||
|
'title' => $catTitle,
|
||||||
|
'slug' => $slug,
|
||||||
|
'categories' => (string)$categories[$catTitle],
|
||||||
|
'subproduct' => 0,
|
||||||
|
];
|
||||||
|
$output->writeln(sprintf('create K-record: %s (slug %s)', $catTitle, $slug));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 6. subproduct flags on existing records -----------------------
|
||||||
|
$catTitleByUid = array_flip($categories);
|
||||||
|
$flagged = 0;
|
||||||
|
foreach ($products as $uid => $p) {
|
||||||
|
$isK = false;
|
||||||
|
foreach ($productCats[$uid] ?? [] as $catUid) {
|
||||||
|
if (($catTitleByUid[$catUid] ?? null) === $p['title']) {
|
||||||
|
$isK = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// uid 36 becomes K through this run's category move
|
||||||
|
if ($uid === 36 && isset($datamap[36]['categories'])) {
|
||||||
|
$isK = true;
|
||||||
|
}
|
||||||
|
$target = $isK ? 0 : 1;
|
||||||
|
if ((int)$p['subproduct'] !== $target) {
|
||||||
|
$datamap[$uid]['subproduct'] = $target;
|
||||||
|
$flagged++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$output->writeln(sprintf('subproduct flags to update: %d records', $flagged));
|
||||||
|
|
||||||
|
if ($datamap === []) {
|
||||||
|
$output->writeln('Nothing to do - structure already matches V2.');
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
if ($dryRun) {
|
||||||
|
$output->writeln(sprintf('DRY RUN - %d datamap entries, nothing written.', count($datamap)));
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||||
|
$dataHandler->start([self::TABLE => $datamap], []);
|
||||||
|
$dataHandler->process_datamap();
|
||||||
|
if ($dataHandler->errorLog !== []) {
|
||||||
|
foreach ($dataHandler->errorLog as $error) {
|
||||||
|
$output->writeln('<error>' . $error . '</error>');
|
||||||
|
}
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
$output->writeln(sprintf('%d records written/created.', count($datamap)));
|
||||||
|
|
||||||
|
// ---- 7. relatedprodukt on K-records (only when empty) --------------
|
||||||
|
// Re-read: creates need their real uids, categories their fresh state.
|
||||||
|
$products = $this->products();
|
||||||
|
$productCats = $this->productCategoryMap();
|
||||||
|
$related = [];
|
||||||
|
foreach ($products as $uid => $p) {
|
||||||
|
$catUid = (int)($categories[$p['title']] ?? -1);
|
||||||
|
if ($catUid < 0 || !in_array($catUid, $productCats[$uid] ?? [], true)) {
|
||||||
|
continue; // not a K-record
|
||||||
|
}
|
||||||
|
if ($this->relatedCount($uid) > 0) {
|
||||||
|
continue; // curated - never touch
|
||||||
|
}
|
||||||
|
$subs = [];
|
||||||
|
foreach ($productCats as $otherUid => $cats) {
|
||||||
|
if ($otherUid !== $uid && in_array($catUid, $cats, true)) {
|
||||||
|
$subs[] = $otherUid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort($subs);
|
||||||
|
if ($subs !== []) {
|
||||||
|
$related[$uid] = ['relatedprodukt' => implode(',', $subs)];
|
||||||
|
$output->writeln(sprintf('K-record %d %s: relatedprodukt = %s', $uid, $p['title'], implode(',', $subs)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($related !== []) {
|
||||||
|
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||||
|
$dataHandler->start([self::TABLE => $related], []);
|
||||||
|
$dataHandler->process_datamap();
|
||||||
|
if ($dataHandler->errorLog !== []) {
|
||||||
|
foreach ($dataHandler->errorLog as $error) {
|
||||||
|
$output->writeln('<error>' . $error . '</error>');
|
||||||
|
}
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$output->writeln('Done. Flush the frontend cache to publish: vendor/bin/typo3 cache:flush');
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string,int> level-2 category title => uid (parents under root "Product") */
|
||||||
|
private function productCategories(): array
|
||||||
|
{
|
||||||
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_category');
|
||||||
|
$rootUid = (int)$qb->select('uid')->from('sys_category')
|
||||||
|
->where(
|
||||||
|
$qb->expr()->eq('deleted', 0),
|
||||||
|
$qb->expr()->eq('parent', 0),
|
||||||
|
$qb->expr()->eq('title', $qb->createNamedParameter('Product', ParameterType::STRING))
|
||||||
|
)->executeQuery()->fetchOne();
|
||||||
|
|
||||||
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_category');
|
||||||
|
$level1 = $qb->select('uid')->from('sys_category')
|
||||||
|
->where($qb->expr()->eq('deleted', 0), $qb->expr()->eq('parent', $rootUid))
|
||||||
|
->executeQuery()->fetchFirstColumn();
|
||||||
|
if ($level1 === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_category');
|
||||||
|
$rows = $qb->select('uid', 'title')->from('sys_category')
|
||||||
|
->where($qb->expr()->eq('deleted', 0), $qb->expr()->in('parent', array_map('intval', $level1)))
|
||||||
|
->executeQuery()->fetchAllAssociative();
|
||||||
|
$map = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$map[(string)$row['title']] = (int)$row['uid'];
|
||||||
|
}
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<int,array<string,mixed>> */
|
||||||
|
private function products(): array
|
||||||
|
{
|
||||||
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::TABLE);
|
||||||
|
$rows = $qb->select('uid', 'pid', 'title', 'slug', 'subproduct')->from(self::TABLE)
|
||||||
|
->where($qb->expr()->eq('deleted', 0))
|
||||||
|
->executeQuery()->fetchAllAssociative();
|
||||||
|
$map = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$map[(int)$row['uid']] = $row;
|
||||||
|
}
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<int,int[]> product uid => category uids */
|
||||||
|
private function productCategoryMap(): array
|
||||||
|
{
|
||||||
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_category_record_mm');
|
||||||
|
$rows = $qb->select('uid_local', 'uid_foreign')->from('sys_category_record_mm')
|
||||||
|
->where(
|
||||||
|
$qb->expr()->eq('tablenames', $qb->createNamedParameter(self::TABLE, ParameterType::STRING)),
|
||||||
|
$qb->expr()->eq('fieldname', $qb->createNamedParameter('categories', ParameterType::STRING))
|
||||||
|
)->executeQuery()->fetchAllAssociative();
|
||||||
|
$map = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$map[(int)$row['uid_foreign']][] = (int)$row['uid_local'];
|
||||||
|
}
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function relatedCount(int $productUid): int
|
||||||
|
{
|
||||||
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('tx_vitec_product_related_mm');
|
||||||
|
return (int)$qb->count('*')->from('tx_vitec_product_related_mm')
|
||||||
|
->where($qb->expr()->eq('uid_local', $qb->createNamedParameter($productUid, ParameterType::INTEGER)))
|
||||||
|
->executeQuery()->fetchOne();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function detectPid(): int
|
||||||
|
{
|
||||||
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::TABLE);
|
||||||
|
$pids = $qb->select('pid')->from(self::TABLE)
|
||||||
|
->where($qb->expr()->eq('deleted', 0))
|
||||||
|
->executeQuery()->fetchFirstColumn();
|
||||||
|
if ($pids === []) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
$counts = array_count_values(array_map('intval', $pids));
|
||||||
|
arsort($counts);
|
||||||
|
return (int)array_key_first($counts);
|
||||||
|
}
|
||||||
|
}
|
||||||
153
packages/vitec/Classes/Command/RemoveFamilyRecordsCommand.php
Normal file
153
packages/vitec/Classes/Command/RemoveFamilyRecordsCommand.php
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Evomedien\Vitec\Command;
|
||||||
|
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use TYPO3\CMS\Core\Core\Bootstrap;
|
||||||
|
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||||
|
use TYPO3\CMS\Core\DataHandling\DataHandler;
|
||||||
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes the K-level "family landing" product records again.
|
||||||
|
*
|
||||||
|
* Background (2026-09-04): the V2 structure migration created one landing
|
||||||
|
* record per level-2 product category. The model decision changed the same
|
||||||
|
* day - product families are ordinary, manually maintained CONTENT PAGES
|
||||||
|
* with cards, not records - so those empty landing records would only
|
||||||
|
* pollute lists and the search index.
|
||||||
|
*
|
||||||
|
* A record qualifies for deletion ONLY when ALL of these hold:
|
||||||
|
* - its title equals the title of one of its level-2 categories
|
||||||
|
* (the K-record signature),
|
||||||
|
* - subproduct = 0,
|
||||||
|
* - teaser, description and capabilities are all empty
|
||||||
|
* (a content-bearing record is never deleted - it is reported instead),
|
||||||
|
* - it is not Aligo (36) or Arqa (93): they carry imported workbook
|
||||||
|
* content and stay until their family pages are built.
|
||||||
|
*
|
||||||
|
* Deletion goes through DataHandler (soft delete, history, relation
|
||||||
|
* handling). Idempotent: a second run finds nothing.
|
||||||
|
*
|
||||||
|
* vendor/bin/typo3 vitec:remove-family-records --dry-run
|
||||||
|
* vendor/bin/typo3 vitec:remove-family-records
|
||||||
|
*/
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'vitec:remove-family-records',
|
||||||
|
description: 'Delete the empty K-level family landing records (families are content pages now)'
|
||||||
|
)]
|
||||||
|
final class RemoveFamilyRecordsCommand extends Command
|
||||||
|
{
|
||||||
|
private const TABLE = 'tx_vitec_domain_model_product';
|
||||||
|
|
||||||
|
/** Carry imported workbook content - kept until their family pages exist. */
|
||||||
|
private const KEEP = [36, 93];
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report the candidates - delete nothing');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
Bootstrap::initializeBackendAuthentication();
|
||||||
|
$dryRun = (bool)$input->getOption('dry-run');
|
||||||
|
|
||||||
|
// Level-2 category titles under root "Product" (parent chain root->J->K).
|
||||||
|
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('sys_category');
|
||||||
|
$rootUid = (int)$connection->fetchOne(
|
||||||
|
"SELECT uid FROM sys_category WHERE deleted = 0 AND parent = 0 AND title = 'Product'"
|
||||||
|
);
|
||||||
|
$level1 = $connection->fetchFirstColumn(
|
||||||
|
'SELECT uid FROM sys_category WHERE deleted = 0 AND parent = ?',
|
||||||
|
[$rootUid]
|
||||||
|
);
|
||||||
|
if ($level1 === []) {
|
||||||
|
$output->writeln('<error>No level-1 product categories found.</error>');
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
$placeholders = implode(',', array_fill(0, count($level1), '?'));
|
||||||
|
$catTitleByUid = [];
|
||||||
|
foreach ($connection->fetchAllAssociative(
|
||||||
|
"SELECT uid, title FROM sys_category WHERE deleted = 0 AND parent IN ($placeholders)",
|
||||||
|
array_map('intval', $level1)
|
||||||
|
) as $row) {
|
||||||
|
$catTitleByUid[(int)$row['uid']] = (string)$row['title'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Products with their category assignments and the content probe.
|
||||||
|
$productConnection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable(self::TABLE);
|
||||||
|
$products = $productConnection->fetchAllAssociative(
|
||||||
|
'SELECT uid, title, slug, subproduct, teaser, description, capabilities FROM ' . self::TABLE . ' WHERE deleted = 0'
|
||||||
|
);
|
||||||
|
$mm = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('sys_category_record_mm')
|
||||||
|
->fetchAllAssociative(
|
||||||
|
"SELECT uid_local, uid_foreign FROM sys_category_record_mm
|
||||||
|
WHERE tablenames = ? AND fieldname = ?",
|
||||||
|
[self::TABLE, 'categories']
|
||||||
|
);
|
||||||
|
$catsByProduct = [];
|
||||||
|
foreach ($mm as $row) {
|
||||||
|
$catsByProduct[(int)$row['uid_foreign']][] = (int)$row['uid_local'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$delete = [];
|
||||||
|
foreach ($products as $product) {
|
||||||
|
$uid = (int)$product['uid'];
|
||||||
|
$isK = false;
|
||||||
|
foreach ($catsByProduct[$uid] ?? [] as $catUid) {
|
||||||
|
if (($catTitleByUid[$catUid] ?? null) === (string)$product['title']) {
|
||||||
|
$isK = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$isK || (int)$product['subproduct'] === 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (in_array($uid, self::KEEP, true)) {
|
||||||
|
$output->writeln(sprintf('keep %d %s - carries imported workbook content (delete manually once its family page exists)', $uid, $product['title']));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$hasContent = trim((string)$product['teaser']) !== ''
|
||||||
|
|| trim((string)$product['description']) !== ''
|
||||||
|
|| trim((string)$product['capabilities']) !== '';
|
||||||
|
if ($hasContent) {
|
||||||
|
$output->writeln(sprintf('<comment>skip %d %s - has content, review manually</comment>', $uid, $product['title']));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$delete[] = $uid;
|
||||||
|
$output->writeln(sprintf('delete %d %s (/%s)', $uid, $product['title'], $product['slug']));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($delete === []) {
|
||||||
|
$output->writeln('No empty family landing records found - nothing to delete.');
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
if ($dryRun) {
|
||||||
|
$output->writeln(sprintf('DRY RUN - %d record(s) would be deleted.', count($delete)));
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cmdmap = [];
|
||||||
|
foreach ($delete as $uid) {
|
||||||
|
$cmdmap[self::TABLE][$uid]['delete'] = 1;
|
||||||
|
}
|
||||||
|
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||||
|
$dataHandler->start([], $cmdmap);
|
||||||
|
$dataHandler->process_cmdmap();
|
||||||
|
if ($dataHandler->errorLog !== []) {
|
||||||
|
foreach ($dataHandler->errorLog as $error) {
|
||||||
|
$output->writeln('<error>' . $error . '</error>');
|
||||||
|
}
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$output->writeln(sprintf('%d record(s) deleted. Flush the cache: vendor/bin/typo3 cache:flush', count($delete)));
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -87,9 +87,11 @@ final class ProductTextImportController
|
|||||||
* `subtitle` and onto columns that exist only in the DB but not in the
|
* `subtitle` and onto columns that exist only in the DB but not in the
|
||||||
* TCA (`cta`, `key1-3`, `apptext1-3`) - both corrected.
|
* TCA (`cta`, `key1-3`, `apptext1-3`) - both corrected.
|
||||||
*/
|
*/
|
||||||
private const DEFAULT_MAPPING = [
|
public const DEFAULT_MAPPING = [
|
||||||
'seotitle' => 'x:seotitle',
|
'seotitle' => 'x:seotitle',
|
||||||
'teaser' => 'c:Hero — H1#1:body',
|
// :copy = body with cta fallback - Aligo carries the hero one-liner
|
||||||
|
// in "Body Copy", Arqa in "CTA / Card Copy" (column drift).
|
||||||
|
'teaser' => 'c:Hero — H1#1:copy',
|
||||||
'description' => 'c:Product Introduction#1:body',
|
'description' => 'c:Product Introduction#1:body',
|
||||||
'description2' => 'c:Product Overview — H2#1:body',
|
'description2' => 'c:Product Overview — H2#1:body',
|
||||||
'capabilities' => 'g:Key Capability Group',
|
'capabilities' => 'g:Key Capability Group',
|
||||||
|
|||||||
@@ -170,6 +170,22 @@ final class ProductXlsxReader
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Drift-proof copy selector: the agency fills text sometimes into
|
||||||
|
// "Body Copy" (D), sometimes into "CTA / Card Copy" (E) - Aligo's
|
||||||
|
// hero one-liner sits in D, Arqa's in E. `copy` reads body and
|
||||||
|
// falls back to cta, so one stored mapping fits every delivery.
|
||||||
|
$copy = trim((string)($component['body'] ?? ''));
|
||||||
|
if ($copy === '') {
|
||||||
|
$copy = trim((string)($component['cta'] ?? ''));
|
||||||
|
}
|
||||||
|
if ($copy !== '') {
|
||||||
|
$groups['Components'][] = $this->entry(
|
||||||
|
'c:' . $name . '#' . $component['occurrence'] . ':copy',
|
||||||
|
$name . $suffix . ' · Copy (Body, sonst CTA)',
|
||||||
|
$copy
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Section-intro rows ("... — H2") additionally as ONE ready RTE
|
// Section-intro rows ("... — H2") additionally as ONE ready RTE
|
||||||
// value: heading plus paragraph in the exact markup the finished
|
// value: heading plus paragraph in the exact markup the finished
|
||||||
// MGW-Diamond product stores in `textrelatedproducts` - imported
|
// MGW-Diamond product stores in `textrelatedproducts` - imported
|
||||||
@@ -250,10 +266,18 @@ final class ProductXlsxReader
|
|||||||
$value = $parsed['seo'][substr($selector, 2)] ?? null;
|
$value = $parsed['seo'][substr($selector, 2)] ?? null;
|
||||||
return is_string($value) && $value !== '' ? $value : null;
|
return is_string($value) && $value !== '' ? $value : null;
|
||||||
}
|
}
|
||||||
if (str_starts_with($selector, 'c:') && preg_match('/^c:(.+)#(\d+):(title|body|cta)$/', $selector, $m)) {
|
if (str_starts_with($selector, 'c:') && preg_match('/^c:(.+)#(\d+):(title|body|cta|copy)$/', $selector, $m)) {
|
||||||
foreach ($parsed['components'] ?? [] as $component) {
|
foreach ($parsed['components'] ?? [] as $component) {
|
||||||
if ((string)$component['component'] === $m[1] && (int)$component['occurrence'] === (int)$m[2]) {
|
if ((string)$component['component'] === $m[1] && (int)$component['occurrence'] === (int)$m[2]) {
|
||||||
$value = (string)($component[$m[3]] ?? '');
|
if ($m[3] === 'copy') {
|
||||||
|
$value = trim((string)($component['body'] ?? ''));
|
||||||
|
if ($value === '') {
|
||||||
|
$value = trim((string)($component['cta'] ?? ''));
|
||||||
|
}
|
||||||
|
$value = trim((string)preg_replace('/\|\s*CTA:.*$/su', '', $value));
|
||||||
|
} else {
|
||||||
|
$value = (string)($component[$m[3]] ?? '');
|
||||||
|
}
|
||||||
return $value !== '' ? $value : null;
|
return $value !== '' ? $value : null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,6 +111,12 @@ class ProductListJsonRenderer
|
|||||||
$categoryUids = array_filter(
|
$categoryUids = array_filter(
|
||||||
array_map('intval', explode(',', (string)($settings['categories'] ?? '')))
|
array_map('intval', explode(',', (string)($settings['categories'] ?? '')))
|
||||||
);
|
);
|
||||||
|
// A selected category matches its WHOLE subtree (decision
|
||||||
|
// 2026-09-04): editors pick e.g. the "Platforms and End-Points"
|
||||||
|
// parent, the products hang on its child categories.
|
||||||
|
if ($categoryUids !== []) {
|
||||||
|
$categoryUids = $this->expandWithDescendants($categoryUids);
|
||||||
|
}
|
||||||
$debugMode = (bool)($settings['debug'] ?? false);
|
$debugMode = (bool)($settings['debug'] ?? false);
|
||||||
$allProducts = (bool)($settings['allproducts'] ?? false);
|
$allProducts = (bool)($settings['allproducts'] ?? false);
|
||||||
|
|
||||||
@@ -147,6 +153,41 @@ class ProductListJsonRenderer
|
|||||||
|
|
||||||
$products = $productQuery->executeQuery()->fetchAllAssociative();
|
$products = $productQuery->executeQuery()->fetchAllAssociative();
|
||||||
|
|
||||||
|
// Order by category in TREE order (decision 2026-09-04): products
|
||||||
|
// of the first selected/child category first, then the next, so a
|
||||||
|
// list over a parent category groups its families like the
|
||||||
|
// sitemap. $categoryUids comes from expandWithDescendants in
|
||||||
|
// depth-first tree order; within one category the uid order is
|
||||||
|
// kept - the records were created in sitemap-V2 row order.
|
||||||
|
if (!empty($categoryUids) && !$allProducts && $products !== []) {
|
||||||
|
$rankByCategory = array_flip($categoryUids);
|
||||||
|
$mmQueryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class)
|
||||||
|
->getQueryBuilderForTable('sys_category_record_mm');
|
||||||
|
$assignments = $mmQueryBuilder->select('uid_local', 'uid_foreign')
|
||||||
|
->from('sys_category_record_mm')
|
||||||
|
->where(
|
||||||
|
$mmQueryBuilder->expr()->eq('tablenames', $mmQueryBuilder->createNamedParameter('tx_vitec_domain_model_product', ParameterType::STRING)),
|
||||||
|
$mmQueryBuilder->expr()->eq('fieldname', $mmQueryBuilder->createNamedParameter('categories', ParameterType::STRING)),
|
||||||
|
$mmQueryBuilder->expr()->in('uid_foreign', $mmQueryBuilder->createNamedParameter(
|
||||||
|
array_map(static fn(array $p): int => (int)$p['uid'], $products),
|
||||||
|
Connection::PARAM_INT_ARRAY
|
||||||
|
))
|
||||||
|
)->executeQuery()->fetchAllAssociative();
|
||||||
|
$rankByProduct = [];
|
||||||
|
foreach ($assignments as $assignment) {
|
||||||
|
$productUid = (int)$assignment['uid_foreign'];
|
||||||
|
$rank = $rankByCategory[(int)$assignment['uid_local']] ?? null;
|
||||||
|
if ($rank !== null && $rank < ($rankByProduct[$productUid] ?? PHP_INT_MAX)) {
|
||||||
|
$rankByProduct[$productUid] = $rank;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
usort($products, static function (array $a, array $b) use ($rankByProduct): int {
|
||||||
|
$rankA = $rankByProduct[(int)$a['uid']] ?? PHP_INT_MAX;
|
||||||
|
$rankB = $rankByProduct[(int)$b['uid']] ?? PHP_INT_MAX;
|
||||||
|
return $rankA <=> $rankB ?: (int)$a['uid'] <=> (int)$b['uid'];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
$productsData = [];
|
$productsData = [];
|
||||||
foreach ($products as $product) {
|
foreach ($products as $product) {
|
||||||
$productsData[] = $this->serializeProduct($product);
|
$productsData[] = $this->serializeProduct($product);
|
||||||
@@ -181,6 +222,45 @@ class ProductListJsonRenderer
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The given category uids plus every descendant category uid, DEPTH first
|
||||||
|
* over sys_category.parent with siblings in sys_category.sorting order -
|
||||||
|
* the result is in backend-tree order and doubles as the sort rank for
|
||||||
|
* the list. One query for the whole table - the tree is small (< 100
|
||||||
|
* rows).
|
||||||
|
*
|
||||||
|
* @param int[] $categoryUids
|
||||||
|
* @return int[]
|
||||||
|
*/
|
||||||
|
private function expandWithDescendants(array $categoryUids): array
|
||||||
|
{
|
||||||
|
$queryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class)
|
||||||
|
->getQueryBuilderForTable('sys_category');
|
||||||
|
$rows = $queryBuilder->select('uid', 'parent')->from('sys_category')
|
||||||
|
->where($queryBuilder->expr()->eq('deleted', 0))
|
||||||
|
->orderBy('parent')->addOrderBy('sorting')
|
||||||
|
->executeQuery()->fetchAllAssociative();
|
||||||
|
$childrenByParent = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$childrenByParent[(int)$row['parent']][] = (int)$row['uid'];
|
||||||
|
}
|
||||||
|
$result = [];
|
||||||
|
$visit = function (int $categoryUid) use (&$visit, &$result, $childrenByParent): void {
|
||||||
|
$result[] = $categoryUid;
|
||||||
|
foreach ($childrenByParent[$categoryUid] ?? [] as $childUid) {
|
||||||
|
if (!in_array($childUid, $result, true)) {
|
||||||
|
$visit($childUid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
foreach (array_map('intval', $categoryUids) as $categoryUid) {
|
||||||
|
if (!in_array($categoryUid, $result, true)) {
|
||||||
|
$visit($categoryUid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serialize a single product DB row to the full headless JSON structure.
|
* Serialize a single product DB row to the full headless JSON structure.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
RewriteEngine On
|
RewriteEngine On
|
||||||
RewriteBase /_frontend/
|
RewriteBase /_frontend/
|
||||||
|
|
||||||
RewriteRule ^index\.html$ - [L]
|
RewriteRule ^index\.html$ - [L]
|
||||||
|
|
||||||
RewriteCond %{REQUEST_FILENAME} -f [OR]
|
RewriteCond %{REQUEST_FILENAME} -f [OR]
|
||||||
RewriteCond %{REQUEST_FILENAME} -d
|
RewriteCond %{REQUEST_FILENAME} -d
|
||||||
RewriteRule ^ - [L]
|
RewriteRule ^ - [L]
|
||||||
|
|
||||||
RewriteRule ^ index.html [L]
|
RewriteRule ^ index.html [L]
|
||||||
|
|
||||||
# ── Cache headers ────────────────────────────────────────────────────────────
|
# ── Cache headers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# index.html: never cache — always fetch fresh so the browser picks up
|
# index.html: never cache — always fetch fresh so the browser picks up
|
||||||
# the latest hashed JS/CSS filenames after a deploy.
|
# the latest hashed JS/CSS filenames after a deploy.
|
||||||
<Files "index.html">
|
<Files "index.html">
|
||||||
Header set Cache-Control "no-store, no-cache, must-revalidate"
|
Header set Cache-Control "no-store, no-cache, must-revalidate"
|
||||||
Header set Pragma "no-cache"
|
Header set Pragma "no-cache"
|
||||||
Header set Expires "0"
|
Header set Expires "0"
|
||||||
</Files>
|
</Files>
|
||||||
|
|
||||||
# Hashed assets (JS, CSS, fonts, images inside /assets/):
|
# Hashed assets (JS, CSS, fonts, images inside /assets/):
|
||||||
# Vite appends a content hash to every filename, so these are immutable.
|
# Vite appends a content hash to every filename, so these are immutable.
|
||||||
# Cache them for 1 year — a new deploy produces new filenames automatically.
|
# Cache them for 1 year — a new deploy produces new filenames automatically.
|
||||||
<FilesMatch "\.(js|css|woff2?|ttf|eot|otf)$">
|
<FilesMatch "\.(js|css|woff2?|ttf|eot|otf)$">
|
||||||
Header set Cache-Control "public, max-age=31536000, immutable"
|
Header set Cache-Control "public, max-age=31536000, immutable"
|
||||||
</FilesMatch>
|
</FilesMatch>
|
||||||
|
|||||||
@@ -25,8 +25,8 @@
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<title>VITEC</title>
|
<title>VITEC</title>
|
||||||
<script type="module" crossorigin src="/_frontend/assets/index-BHtumi6P.js"></script>
|
<script type="module" crossorigin src="/_frontend/assets/index-CrOmtYLr.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/_frontend/assets/index-DP4QFhOv.css">
|
<link rel="stylesheet" crossorigin href="/_frontend/assets/index-CrFFiPFX.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="page"></div>
|
<div id="page"></div>
|
||||||
|
|||||||
Reference in New Issue
Block a user