From 5499ca54afd3d484d9e3afae5b1feff80c4a1856 Mon Sep 17 00:00:00 2001 From: Oliver Rasche Date: Fri, 4 Sep 2026 12:59:17 +0200 Subject: [PATCH] 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 --- composer.lock | 194 +++++---- migrations/check_product_structure.php | 110 +++++ migrations/check_productlist_ces.php | 69 ++++ migrations/reset_subproduct_flags.php | 49 +++ .../Command/ImportProductWorkbookCommand.php | 172 ++++++++ .../MigrateProductStructureCommand.php | 388 ++++++++++++++++++ .../Command/RemoveFamilyRecordsCommand.php | 153 +++++++ .../Backend/ProductTextImportController.php | 6 +- .../Classes/Import/ProductXlsxReader.php | 28 +- .../UserFunc/ProductListJsonRenderer.php | 80 ++++ public/_frontend/.htaccess | 54 +-- public/_frontend/index.html | 4 +- 12 files changed, 1186 insertions(+), 121 deletions(-) create mode 100644 migrations/check_product_structure.php create mode 100644 migrations/check_productlist_ces.php create mode 100644 migrations/reset_subproduct_flags.php create mode 100644 packages/vitec/Classes/Command/ImportProductWorkbookCommand.php create mode 100644 packages/vitec/Classes/Command/MigrateProductStructureCommand.php create mode 100644 packages/vitec/Classes/Command/RemoveFamilyRecordsCommand.php diff --git a/composer.lock b/composer.lock index 6f59f97..b7c907d 100644 --- a/composer.lock +++ b/composer.lock @@ -8,16 +8,16 @@ "packages": [ { "name": "apache-solr-for-typo3/solr", - "version": "14.0.0-RC1", + "version": "14.0.1", "source": { "type": "git", "url": "https://github.com/TYPO3-Solr/ext-solr.git", - "reference": "96834e83592d59c350005216821ee502cfee8ece" + "reference": "25fad97ffe192b1c4afc9400b98f4b4c4d3a6ea3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/TYPO3-Solr/ext-solr/zipball/96834e83592d59c350005216821ee502cfee8ece", - "reference": "96834e83592d59c350005216821ee502cfee8ece", + "url": "https://api.github.com/repos/TYPO3-Solr/ext-solr/zipball/25fad97ffe192b1c4afc9400b98f4b4c4d3a6ea3", + "reference": "25fad97ffe192b1c4afc9400b98f4b4c4d3a6ea3", "shasum": "" }, "require": { @@ -33,7 +33,6 @@ "typo3/cms-extbase": "*", "typo3/cms-fluid": "*", "typo3/cms-frontend": "*", - "typo3/cms-install": "*", "typo3/cms-reports": "*", "typo3/cms-scheduler": "*", "typo3/cms-tstemplate": "*" @@ -52,12 +51,23 @@ "phpstan/phpstan-phpunit": "^2.0.18", "phpunit/phpunit": "^11.5.55", "typo3/cms-fluid-styled-content": "*", + "typo3/cms-install": "*", "typo3/coding-standards": "v0.8.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", "extra": { "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", "extension-key": "solr" }, @@ -119,7 +129,7 @@ "type": "custom" } ], - "time": "2026-07-17T15:16:50+00:00" + "time": "2026-09-03T14:19:19+00:00" }, { "name": "b13/container", @@ -1610,16 +1620,16 @@ }, { "name": "friendsoftypo3/headless", - "version": "v5.0.0-rc1", + "version": "v5.0.0-rc2", "source": { "type": "git", "url": "https://github.com/TYPO3-Headless/headless.git", - "reference": "53eb4233a2b94fe47b88fa00a3f9859ba4eb1419" + "reference": "1eae1026caa455d05dea5e89c69b7c8474295b83" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/TYPO3-Headless/headless/zipball/53eb4233a2b94fe47b88fa00a3f9859ba4eb1419", - "reference": "53eb4233a2b94fe47b88fa00a3f9859ba4eb1419", + "url": "https://api.github.com/repos/TYPO3-Headless/headless/zipball/1eae1026caa455d05dea5e89c69b7c8474295b83", + "reference": "1eae1026caa455d05dea5e89c69b7c8474295b83", "shasum": "" }, "require": { @@ -1637,18 +1647,26 @@ "helmich/typo3-typoscript-lint": "^v3", "justinrainbow/json-schema": "^5", "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "^1.12", + "phpstan/phpstan": "^2", "phpunit/phpcov": "^8 || ^9 || ^10", "seld/jsonlint": "^1.11", "symfony/yaml": "^6.1 || ^7.1", + "typo3/cms-felogin": "^14.3", "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/testing-framework": "^9.0" }, "type": "typo3-cms-extension", "extra": { "typo3/cms": { + "Package": { + "providesPackages": [] + }, "app-dir": ".Build", + "version": "5.0.0-rc2", "web-dir": ".Build/public", "extension-key": "headless" } @@ -1686,7 +1704,7 @@ ], "support": { "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": [ { @@ -1694,7 +1712,7 @@ "type": "custom" } ], - "time": "2026-05-08T15:09:37+00:00" + "time": "2026-09-02T12:07:34+00:00" }, { "name": "georgringer/news", @@ -3252,16 +3270,16 @@ }, { "name": "phpstan/phpdoc-parser", - "version": "2.3.3", + "version": "2.3.5", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3" + "reference": "148cefffaf0233e4c08cc13db8a195a56dd6dfe9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", - "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/148cefffaf0233e4c08cc13db8a195a56dd6dfe9", + "reference": "148cefffaf0233e4c08cc13db8a195a56dd6dfe9", "shasum": "" }, "require": { @@ -3293,9 +3311,9 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/2.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", @@ -4634,16 +4652,16 @@ }, { "name": "symfony/console", - "version": "v7.4.17", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "962e18f09ebe68a49039b4c82fc0ea4871824fca" + "reference": "23d6f88a29f6d0eac45bd77d70307adf83ba7ab0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/962e18f09ebe68a49039b4c82fc0ea4871824fca", - "reference": "962e18f09ebe68a49039b4c82fc0ea4871824fca", + "url": "https://api.github.com/repos/symfony/console/zipball/23d6f88a29f6d0eac45bd77d70307adf83ba7ab0", + "reference": "23d6f88a29f6d0eac45bd77d70307adf83ba7ab0", "shasum": "" }, "require": { @@ -4708,7 +4726,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.17" + "source": "https://github.com/symfony/console/tree/v7.4.18" }, "funding": [ { @@ -4728,7 +4746,7 @@ "type": "tidelift" } ], - "time": "2026-08-21T12:09:28+00:00" + "time": "2026-08-25T14:18:37+00:00" }, { "name": "symfony/dependency-injection", @@ -4887,7 +4905,7 @@ }, { "name": "symfony/doctrine-messenger", - "version": "v7.4.15", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/doctrine-messenger.git", @@ -4939,7 +4957,7 @@ "description": "Symfony Doctrine Messenger Bridge", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/doctrine-messenger/tree/v7.4.15" + "source": "https://github.com/symfony/doctrine-messenger/tree/v7.4.18" }, "funding": [ { @@ -5128,16 +5146,16 @@ }, { "name": "symfony/expression-language", - "version": "v7.4.14", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/expression-language.git", - "reference": "bd5763f92959201816ecc31defdf352d2ea473be" + "reference": "b73866506ff02800836a40fa1f95614a71064f35" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/expression-language/zipball/bd5763f92959201816ecc31defdf352d2ea473be", - "reference": "bd5763f92959201816ecc31defdf352d2ea473be", + "url": "https://api.github.com/repos/symfony/expression-language/zipball/b73866506ff02800836a40fa1f95614a71064f35", + "reference": "b73866506ff02800836a40fa1f95614a71064f35", "shasum": "" }, "require": { @@ -5172,7 +5190,7 @@ "description": "Provides an engine that can compile and evaluate expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/expression-language/tree/v7.4.14" + "source": "https://github.com/symfony/expression-language/tree/v7.4.18" }, "funding": [ { @@ -5192,20 +5210,20 @@ "type": "tidelift" } ], - "time": "2026-06-08T20:24:16+00:00" + "time": "2026-08-30T00:47:26+00:00" }, { "name": "symfony/filesystem", - "version": "v7.4.17", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "ee7bc7bca4c7079b88e57d5000aeeb20df570c8d" + "reference": "90d412aa5277c6819db39e7605aa46b1019e3232" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/ee7bc7bca4c7079b88e57d5000aeeb20df570c8d", - "reference": "ee7bc7bca4c7079b88e57d5000aeeb20df570c8d", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/90d412aa5277c6819db39e7605aa46b1019e3232", + "reference": "90d412aa5277c6819db39e7605aa46b1019e3232", "shasum": "" }, "require": { @@ -5242,7 +5260,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v7.4.17" + "source": "https://github.com/symfony/filesystem/tree/v7.4.18" }, "funding": [ { @@ -5262,7 +5280,7 @@ "type": "tidelift" } ], - "time": "2026-08-21T12:09:28+00:00" + "time": "2026-08-23T10:03:40+00:00" }, { "name": "symfony/finder", @@ -5334,16 +5352,16 @@ }, { "name": "symfony/http-foundation", - "version": "v7.4.17", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "2ebe78c083501dfb9509b31a7aedcae4d60a391f" + "reference": "d070b716a32fbe3bf04204db0f58ace73b86d133" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/2ebe78c083501dfb9509b31a7aedcae4d60a391f", - "reference": "2ebe78c083501dfb9509b31a7aedcae4d60a391f", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/d070b716a32fbe3bf04204db0f58ace73b86d133", + "reference": "d070b716a32fbe3bf04204db0f58ace73b86d133", "shasum": "" }, "require": { @@ -5392,7 +5410,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.4.17" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.18" }, "funding": [ { @@ -5412,7 +5430,7 @@ "type": "tidelift" } ], - "time": "2026-08-20T09:55:18+00:00" + "time": "2026-08-30T20:10:52+00:00" }, { "name": "symfony/mailer", @@ -5500,16 +5518,16 @@ }, { "name": "symfony/messenger", - "version": "v7.4.17", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/messenger.git", - "reference": "3976018381c403e369d3027592e911b979eaeed9" + "reference": "b109f9f4e54470ee6835751bc39318fe3d8275e6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/messenger/zipball/3976018381c403e369d3027592e911b979eaeed9", - "reference": "3976018381c403e369d3027592e911b979eaeed9", + "url": "https://api.github.com/repos/symfony/messenger/zipball/b109f9f4e54470ee6835751bc39318fe3d8275e6", + "reference": "b109f9f4e54470ee6835751bc39318fe3d8275e6", "shasum": "" }, "require": { @@ -5570,7 +5588,7 @@ "description": "Helps applications send and receive messages to/from other applications or via message queues", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/messenger/tree/v7.4.17" + "source": "https://github.com/symfony/messenger/tree/v7.4.18" }, "funding": [ { @@ -5590,11 +5608,11 @@ "type": "tidelift" } ], - "time": "2026-08-21T17:40:08+00:00" + "time": "2026-08-30T00:47:26+00:00" }, { "name": "symfony/mime", - "version": "v7.4.17", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", @@ -5659,7 +5677,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.4.17" + "source": "https://github.com/symfony/mime/tree/v7.4.18" }, "funding": [ { @@ -6743,7 +6761,7 @@ }, { "name": "symfony/process", - "version": "v7.4.17", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/process.git", @@ -6784,7 +6802,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.4.17" + "source": "https://github.com/symfony/process/tree/v7.4.18" }, "funding": [ { @@ -6889,16 +6907,16 @@ }, { "name": "symfony/property-info", - "version": "v7.4.17", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/property-info.git", - "reference": "a37837b52ed5dd7474c251d15694007344c6462d" + "reference": "52420d499b03ec20c0d38f0dc2f940bc9c43dc8e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/property-info/zipball/a37837b52ed5dd7474c251d15694007344c6462d", - "reference": "a37837b52ed5dd7474c251d15694007344c6462d", + "url": "https://api.github.com/repos/symfony/property-info/zipball/52420d499b03ec20c0d38f0dc2f940bc9c43dc8e", + "reference": "52420d499b03ec20c0d38f0dc2f940bc9c43dc8e", "shasum": "" }, "require": { @@ -6955,7 +6973,7 @@ "validator" ], "support": { - "source": "https://github.com/symfony/property-info/tree/v7.4.17" + "source": "https://github.com/symfony/property-info/tree/v7.4.18" }, "funding": [ { @@ -6975,11 +6993,11 @@ "type": "tidelift" } ], - "time": "2026-08-21T17:40:08+00:00" + "time": "2026-08-25T14:18:37+00:00" }, { "name": "symfony/rate-limiter", - "version": "v7.4.16", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/rate-limiter.git", @@ -7029,7 +7047,7 @@ "rate-limiter" ], "support": { - "source": "https://github.com/symfony/rate-limiter/tree/v7.4.16" + "source": "https://github.com/symfony/rate-limiter/tree/v7.4.18" }, "funding": [ { @@ -7053,7 +7071,7 @@ }, { "name": "symfony/routing", - "version": "v7.4.17", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", @@ -7114,7 +7132,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.4.17" + "source": "https://github.com/symfony/routing/tree/v7.4.18" }, "funding": [ { @@ -7138,16 +7156,16 @@ }, { "name": "symfony/service-contracts", - "version": "v3.7.1", + "version": "v3.7.3", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", - "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", "shasum": "" }, "require": { @@ -7201,7 +7219,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.3" }, "funding": [ { @@ -7221,7 +7239,7 @@ "type": "tidelift" } ], - "time": "2026-06-16T09:55:08+00:00" + "time": "2026-07-27T15:39:01+00:00" }, { "name": "symfony/string", @@ -7657,16 +7675,16 @@ }, { "name": "symfony/validator", - "version": "v7.4.17", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/validator.git", - "reference": "b1cbb758c005fbe0d7b2b8d1561869d318628a10" + "reference": "976bd879fda78de30ee4688fd38ae87bc4d2dc76" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/validator/zipball/b1cbb758c005fbe0d7b2b8d1561869d318628a10", - "reference": "b1cbb758c005fbe0d7b2b8d1561869d318628a10", + "url": "https://api.github.com/repos/symfony/validator/zipball/976bd879fda78de30ee4688fd38ae87bc4d2dc76", + "reference": "976bd879fda78de30ee4688fd38ae87bc4d2dc76", "shasum": "" }, "require": { @@ -7737,7 +7755,7 @@ "description": "Provides tools to validate values", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/validator/tree/v7.4.17" + "source": "https://github.com/symfony/validator/tree/v7.4.18" }, "funding": [ { @@ -7757,7 +7775,7 @@ "type": "tidelift" } ], - "time": "2026-08-22T09:04:42+00:00" + "time": "2026-08-30T00:47:26+00:00" }, { "name": "symfony/var-exporter", @@ -7841,16 +7859,16 @@ }, { "name": "symfony/yaml", - "version": "v7.4.17", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "0b040d7b66ceb10b7bb24c8e6656257932693a12" + "reference": "4cef939e55b8a21c5780418de0d98ab00d0736e1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/0b040d7b66ceb10b7bb24c8e6656257932693a12", - "reference": "0b040d7b66ceb10b7bb24c8e6656257932693a12", + "url": "https://api.github.com/repos/symfony/yaml/zipball/4cef939e55b8a21c5780418de0d98ab00d0736e1", + "reference": "4cef939e55b8a21c5780418de0d98ab00d0736e1", "shasum": "" }, "require": { @@ -7893,7 +7911,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.4.17" + "source": "https://github.com/symfony/yaml/tree/v7.4.18" }, "funding": [ { @@ -7913,7 +7931,7 @@ "type": "tidelift" } ], - "time": "2026-08-21T12:09:28+00:00" + "time": "2026-08-30T00:47:26+00:00" }, { "name": "typo3/class-alias-loader", @@ -10081,16 +10099,16 @@ }, { "name": "typo3fluid/fluid", - "version": "5.3.1", + "version": "5.3.2", "source": { "type": "git", "url": "https://github.com/TYPO3/Fluid.git", - "reference": "28c42c75b171aef196ddbe151b0d1146c290249d" + "reference": "2f16b9e8d2509f7abb6a087ad781e361351f465d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/TYPO3/Fluid/zipball/28c42c75b171aef196ddbe151b0d1146c290249d", - "reference": "28c42c75b171aef196ddbe151b0d1146c290249d", + "url": "https://api.github.com/repos/TYPO3/Fluid/zipball/2f16b9e8d2509f7abb6a087ad781e361351f465d", + "reference": "2f16b9e8d2509f7abb6a087ad781e361351f465d", "shasum": "" }, "require": { @@ -10137,7 +10155,7 @@ "issues": "https://github.com/TYPO3/Fluid/issues", "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", diff --git a/migrations/check_product_structure.php b/migrations/check_product_structure.php new file mode 100644 index 0000000..7e2abc4 --- /dev/null +++ b/migrations/check_product_structure.php @@ -0,0 +1,110 @@ + 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)); diff --git a/migrations/check_productlist_ces.php b/migrations/check_productlist_ces.php new file mode 100644 index 0000000..e217462 --- /dev/null +++ b/migrations/check_productlist_ces.php @@ -0,0 +1,69 @@ + 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('/.*?(.*?)<\/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"; diff --git a/migrations/reset_subproduct_flags.php b/migrations/reset_subproduct_flags.php new file mode 100644 index 0000000..571a651 --- /dev/null +++ b/migrations/reset_subproduct_flags.php @@ -0,0 +1,49 @@ + 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"; diff --git a/packages/vitec/Classes/Command/ImportProductWorkbookCommand.php b/packages/vitec/Classes/Command/ImportProductWorkbookCommand.php new file mode 100644 index 0000000..32d2e39 --- /dev/null +++ b/packages/vitec/Classes/Command/ImportProductWorkbookCommand.php @@ -0,0 +1,172 @@ +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('File not found: ' . $file . ''); + return Command::FAILURE; + } + + $product = $this->loadProduct((string)$input->getArgument('product')); + if ($product === null) { + $output->writeln('No product record for "' . $input->getArgument('product') . '".'); + 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('Not a product workbook (pageType "' . $pageType . '") - nothing imported.'); + 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('URL mismatch: /%s (workbook) vs /%s (record) - check the target!', $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 . ''); + } + 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|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 $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]); + } + } +} diff --git a/packages/vitec/Classes/Command/MigrateProductStructureCommand.php b/packages/vitec/Classes/Command/MigrateProductStructureCommand.php new file mode 100644 index 0000000..3ca5e36 --- /dev/null +++ b/packages/vitec/Classes/Command/MigrateProductStructureCommand.php @@ -0,0 +1,388 @@ + 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('category "%s" not found - skipped', $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('slug "%s" already taken - "%s" skipped, resolve manually', $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 . ''); + } + 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 . ''); + } + return Command::FAILURE; + } + } + + $output->writeln('Done. Flush the frontend cache to publish: vendor/bin/typo3 cache:flush'); + return Command::SUCCESS; + } + + /** @return array 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> */ + 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 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); + } +} diff --git a/packages/vitec/Classes/Command/RemoveFamilyRecordsCommand.php b/packages/vitec/Classes/Command/RemoveFamilyRecordsCommand.php new file mode 100644 index 0000000..c7391da --- /dev/null +++ b/packages/vitec/Classes/Command/RemoveFamilyRecordsCommand.php @@ -0,0 +1,153 @@ +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('No level-1 product categories found.'); + 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('skip %d %s - has content, review manually', $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 . ''); + } + return Command::FAILURE; + } + + $output->writeln(sprintf('%d record(s) deleted. Flush the cache: vendor/bin/typo3 cache:flush', count($delete))); + return Command::SUCCESS; + } +} diff --git a/packages/vitec/Classes/Controller/Backend/ProductTextImportController.php b/packages/vitec/Classes/Controller/Backend/ProductTextImportController.php index 4292fed..294685d 100644 --- a/packages/vitec/Classes/Controller/Backend/ProductTextImportController.php +++ b/packages/vitec/Classes/Controller/Backend/ProductTextImportController.php @@ -87,9 +87,11 @@ final class ProductTextImportController * `subtitle` and onto columns that exist only in the DB but not in the * TCA (`cta`, `key1-3`, `apptext1-3`) - both corrected. */ - private const DEFAULT_MAPPING = [ + public const DEFAULT_MAPPING = [ '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', 'description2' => 'c:Product Overview — H2#1:body', 'capabilities' => 'g:Key Capability Group', diff --git a/packages/vitec/Classes/Import/ProductXlsxReader.php b/packages/vitec/Classes/Import/ProductXlsxReader.php index fe9c58f..de6c86a 100644 --- a/packages/vitec/Classes/Import/ProductXlsxReader.php +++ b/packages/vitec/Classes/Import/ProductXlsxReader.php @@ -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 // value: heading plus paragraph in the exact markup the finished // MGW-Diamond product stores in `textrelatedproducts` - imported @@ -250,10 +266,18 @@ final class ProductXlsxReader $value = $parsed['seo'][substr($selector, 2)] ?? 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) { 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; } } diff --git a/packages/vitec/Classes/UserFunc/ProductListJsonRenderer.php b/packages/vitec/Classes/UserFunc/ProductListJsonRenderer.php index 8e92d2c..1671ae0 100755 --- a/packages/vitec/Classes/UserFunc/ProductListJsonRenderer.php +++ b/packages/vitec/Classes/UserFunc/ProductListJsonRenderer.php @@ -111,6 +111,12 @@ class ProductListJsonRenderer $categoryUids = array_filter( 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); $allProducts = (bool)($settings['allproducts'] ?? false); @@ -147,6 +153,41 @@ class ProductListJsonRenderer $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 = []; foreach ($products as $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. * diff --git a/public/_frontend/.htaccess b/public/_frontend/.htaccess index 52d7f05..15135c6 100644 --- a/public/_frontend/.htaccess +++ b/public/_frontend/.htaccess @@ -1,27 +1,27 @@ -RewriteEngine On -RewriteBase /_frontend/ - -RewriteRule ^index\.html$ - [L] - -RewriteCond %{REQUEST_FILENAME} -f [OR] -RewriteCond %{REQUEST_FILENAME} -d -RewriteRule ^ - [L] - -RewriteRule ^ index.html [L] - -# ── Cache headers ──────────────────────────────────────────────────────────── - -# index.html: never cache — always fetch fresh so the browser picks up -# the latest hashed JS/CSS filenames after a deploy. - - Header set Cache-Control "no-store, no-cache, must-revalidate" - Header set Pragma "no-cache" - Header set Expires "0" - - -# Hashed assets (JS, CSS, fonts, images inside /assets/): -# Vite appends a content hash to every filename, so these are immutable. -# Cache them for 1 year — a new deploy produces new filenames automatically. - - Header set Cache-Control "public, max-age=31536000, immutable" - +RewriteEngine On +RewriteBase /_frontend/ + +RewriteRule ^index\.html$ - [L] + +RewriteCond %{REQUEST_FILENAME} -f [OR] +RewriteCond %{REQUEST_FILENAME} -d +RewriteRule ^ - [L] + +RewriteRule ^ index.html [L] + +# ── Cache headers ──────────────────────────────────────────────────────────── + +# index.html: never cache — always fetch fresh so the browser picks up +# the latest hashed JS/CSS filenames after a deploy. + + Header set Cache-Control "no-store, no-cache, must-revalidate" + Header set Pragma "no-cache" + Header set Expires "0" + + +# Hashed assets (JS, CSS, fonts, images inside /assets/): +# Vite appends a content hash to every filename, so these are immutable. +# Cache them for 1 year — a new deploy produces new filenames automatically. + + Header set Cache-Control "public, max-age=31536000, immutable" + diff --git a/public/_frontend/index.html b/public/_frontend/index.html index a2867c2..7625da5 100644 --- a/public/_frontend/index.html +++ b/public/_frontend/index.html @@ -25,8 +25,8 @@ VITEC - - + +