Product text import from agency XLSX workbooks
VITEC Import module: Products tab is now a searchable/sortable product overview. "Edit Product" opens an XLSX upload with per-field source mapping (component + cell), old/new preview and checkbox apply via DataHandler; the mapping and manual matches are persisted and preselect the next workbook. Category workbooks are detected and rejected. - new: ProductXlsxReader (PhpSpreadsheet), ProductTextImportController, Products/ProductTexts templates, 4 module routes - related products: per-pair card text in new side table tx_vitec_product_related_text (survives MM rewrites), emitted as `cardtext` in the product JSON; missing MM relations added add-only - card copy read from Body Copy (D) with fallback to CTA/Card Copy (E) - the workbooks fill either depending on row type - product detail page <title> now uses seotitle with title fallback (provider made singleton and fed from the JSON renderer) - per-user recent-search badges; last loaded workbook stored per product (tx_vitec_product_workbook), Edit Product reopens on it - composer: add phpoffice/phpspreadsheet ^5.9 - diagnostics: migrations/check_workbook.php
This commit is contained in:
@@ -22,6 +22,7 @@
|
|||||||
"friendsoftypo3/headless": "^5.0@rc",
|
"friendsoftypo3/headless": "^5.0@rc",
|
||||||
"georgringer/news": "^14.0",
|
"georgringer/news": "^14.0",
|
||||||
"netzbewegung/nb-headless-content-blocks": "^0.0.23",
|
"netzbewegung/nb-headless-content-blocks": "^0.0.23",
|
||||||
|
"phpoffice/phpspreadsheet": "^5.9",
|
||||||
"plan2net/backend-category-hierarchy": "^14.0",
|
"plan2net/backend-category-hierarchy": "^14.0",
|
||||||
"typo3/cms-backend": "^14.3",
|
"typo3/cms-backend": "^14.3",
|
||||||
"typo3/cms-belog": "^14.3",
|
"typo3/cms-belog": "^14.3",
|
||||||
|
|||||||
353
composer.lock
generated
353
composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "09c5be5fca6a03ab5f0ed81662f20f36",
|
"content-hash": "727d61bcbce8571b4b05c61cf61813aa",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "b13/container",
|
"name": "b13/container",
|
||||||
@@ -2155,6 +2155,84 @@
|
|||||||
},
|
},
|
||||||
"time": "2025-10-31T12:28:27+00:00"
|
"time": "2025-10-31T12:28:27+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "maennchen/zipstream-php",
|
||||||
|
"version": "3.2.2",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/maennchen/ZipStream-PHP.git",
|
||||||
|
"reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e",
|
||||||
|
"reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-mbstring": "*",
|
||||||
|
"ext-zlib": "*",
|
||||||
|
"php-64bit": "^8.3"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"brianium/paratest": "^7.7",
|
||||||
|
"ext-zip": "*",
|
||||||
|
"friendsofphp/php-cs-fixer": "^3.86",
|
||||||
|
"guzzlehttp/guzzle": "^7.5",
|
||||||
|
"mikey179/vfsstream": "^1.6",
|
||||||
|
"php-coveralls/php-coveralls": "^2.5",
|
||||||
|
"phpunit/phpunit": "^12.0",
|
||||||
|
"vimeo/psalm": "^6.0"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"guzzlehttp/psr7": "^2.4",
|
||||||
|
"psr/http-message": "^2.0"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"ZipStream\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Paul Duncan",
|
||||||
|
"email": "pabs@pablotron.org"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Jonatan Männchen",
|
||||||
|
"email": "jonatan@maennchen.ch"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Jesse Donat",
|
||||||
|
"email": "donatj@gmail.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "András Kolesár",
|
||||||
|
"email": "kolesar@kolesar.hu"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.",
|
||||||
|
"keywords": [
|
||||||
|
"stream",
|
||||||
|
"zip"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/maennchen/ZipStream-PHP/issues",
|
||||||
|
"source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.2"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://github.com/maennchen",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-04-11T18:38:28+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "marc-mabe/php-enum",
|
"name": "marc-mabe/php-enum",
|
||||||
"version": "v4.7.2",
|
"version": "v4.7.2",
|
||||||
@@ -2228,6 +2306,113 @@
|
|||||||
},
|
},
|
||||||
"time": "2025-09-14T11:18:39+00:00"
|
"time": "2025-09-14T11:18:39+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "markbaker/complex",
|
||||||
|
"version": "3.0.2",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/MarkBaker/PHPComplex.git",
|
||||||
|
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
|
||||||
|
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^7.2 || ^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
|
||||||
|
"phpcompatibility/php-compatibility": "^9.3",
|
||||||
|
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
|
||||||
|
"squizlabs/php_codesniffer": "^3.7"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Complex\\": "classes/src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Mark Baker",
|
||||||
|
"email": "mark@lange.demon.co.uk"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "PHP Class for working with complex numbers",
|
||||||
|
"homepage": "https://github.com/MarkBaker/PHPComplex",
|
||||||
|
"keywords": [
|
||||||
|
"complex",
|
||||||
|
"mathematics"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/MarkBaker/PHPComplex/issues",
|
||||||
|
"source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2"
|
||||||
|
},
|
||||||
|
"time": "2022-12-06T16:21:08+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "markbaker/matrix",
|
||||||
|
"version": "3.0.1",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/MarkBaker/PHPMatrix.git",
|
||||||
|
"reference": "728434227fe21be27ff6d86621a1b13107a2562c"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c",
|
||||||
|
"reference": "728434227fe21be27ff6d86621a1b13107a2562c",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^7.1 || ^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
|
||||||
|
"phpcompatibility/php-compatibility": "^9.3",
|
||||||
|
"phpdocumentor/phpdocumentor": "2.*",
|
||||||
|
"phploc/phploc": "^4.0",
|
||||||
|
"phpmd/phpmd": "2.*",
|
||||||
|
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
|
||||||
|
"sebastian/phpcpd": "^4.0",
|
||||||
|
"squizlabs/php_codesniffer": "^3.7"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Matrix\\": "classes/src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Mark Baker",
|
||||||
|
"email": "mark@demon-angel.eu"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "PHP Class for working with matrices",
|
||||||
|
"homepage": "https://github.com/MarkBaker/PHPMatrix",
|
||||||
|
"keywords": [
|
||||||
|
"mathematics",
|
||||||
|
"matrix",
|
||||||
|
"vector"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/MarkBaker/PHPMatrix/issues",
|
||||||
|
"source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1"
|
||||||
|
},
|
||||||
|
"time": "2022-12-02T22:17:43+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "masterminds/html5",
|
"name": "masterminds/html5",
|
||||||
"version": "2.10.1",
|
"version": "2.10.1",
|
||||||
@@ -2787,6 +2972,115 @@
|
|||||||
},
|
},
|
||||||
"time": "2026-01-06T21:53:42+00:00"
|
"time": "2026-01-06T21:53:42+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "phpoffice/phpspreadsheet",
|
||||||
|
"version": "5.9.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
|
||||||
|
"reference": "05e99ebf61238a70227b4d9cc02d0030d34f6339"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/05e99ebf61238a70227b4d9cc02d0030d34f6339",
|
||||||
|
"reference": "05e99ebf61238a70227b4d9cc02d0030d34f6339",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"composer/pcre": "^1||^2||^3",
|
||||||
|
"ext-ctype": "*",
|
||||||
|
"ext-dom": "*",
|
||||||
|
"ext-fileinfo": "*",
|
||||||
|
"ext-filter": "*",
|
||||||
|
"ext-gd": "*",
|
||||||
|
"ext-iconv": "*",
|
||||||
|
"ext-libxml": "*",
|
||||||
|
"ext-mbstring": "*",
|
||||||
|
"ext-simplexml": "*",
|
||||||
|
"ext-xml": "*",
|
||||||
|
"ext-xmlreader": "*",
|
||||||
|
"ext-xmlwriter": "*",
|
||||||
|
"ext-zip": "*",
|
||||||
|
"ext-zlib": "*",
|
||||||
|
"maennchen/zipstream-php": "^2.1 || ^3.0",
|
||||||
|
"markbaker/complex": "^3.0",
|
||||||
|
"markbaker/matrix": "^3.0",
|
||||||
|
"php": "^8.2",
|
||||||
|
"psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"dealerdirect/phpcodesniffer-composer-installer": "dev-main",
|
||||||
|
"dompdf/dompdf": "^2.0 || ^3.0",
|
||||||
|
"ext-intl": "*",
|
||||||
|
"friendsofphp/php-cs-fixer": "^3.2",
|
||||||
|
"mitoteam/jpgraph": "^10.5",
|
||||||
|
"mpdf/mpdf": "^8.1.1",
|
||||||
|
"phpcompatibility/php-compatibility": "^9.3",
|
||||||
|
"phpstan/phpstan": "^1.1 || ^2.0",
|
||||||
|
"phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0",
|
||||||
|
"phpstan/phpstan-phpunit": "^1.0 || ^2.0",
|
||||||
|
"phpunit/phpunit": "^10.5 || ^11.0",
|
||||||
|
"squizlabs/php_codesniffer": "^3.7",
|
||||||
|
"tecnickcom/tcpdf": "^6.5"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"dompdf/dompdf": "Option for rendering PDF with PDF Writer",
|
||||||
|
"ext-intl": "PHP Internationalization Functions, required for NumberFormat Wizard and StringHelper::setLocale()",
|
||||||
|
"mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers",
|
||||||
|
"mpdf/mpdf": "Option for rendering PDF with PDF Writer",
|
||||||
|
"tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Maarten Balliauw",
|
||||||
|
"homepage": "https://blog.maartenballiauw.be"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Mark Baker",
|
||||||
|
"homepage": "https://markbakeruk.net"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Franck Lefevre",
|
||||||
|
"homepage": "https://rootslabs.net"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Erik Tilt"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Adrien Crivelli"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Owen Leibman"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine",
|
||||||
|
"homepage": "https://github.com/PHPOffice/PhpSpreadsheet",
|
||||||
|
"keywords": [
|
||||||
|
"OpenXML",
|
||||||
|
"excel",
|
||||||
|
"gnumeric",
|
||||||
|
"ods",
|
||||||
|
"php",
|
||||||
|
"spreadsheet",
|
||||||
|
"xls",
|
||||||
|
"xlsx"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
|
||||||
|
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.9.0"
|
||||||
|
},
|
||||||
|
"time": "2026-07-12T19:17:39+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "phpstan/phpdoc-parser",
|
"name": "phpstan/phpdoc-parser",
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
@@ -3417,6 +3711,57 @@
|
|||||||
},
|
},
|
||||||
"time": "2024-09-11T13:17:53+00:00"
|
"time": "2024-09-11T13:17:53+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "psr/simple-cache",
|
||||||
|
"version": "3.0.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/php-fig/simple-cache.git",
|
||||||
|
"reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865",
|
||||||
|
"reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.0.0"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "3.0.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Psr\\SimpleCache\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "PHP-FIG",
|
||||||
|
"homepage": "https://www.php-fig.org/"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Common interfaces for simple caching",
|
||||||
|
"keywords": [
|
||||||
|
"cache",
|
||||||
|
"caching",
|
||||||
|
"psr",
|
||||||
|
"psr-16",
|
||||||
|
"simple-cache"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/php-fig/simple-cache/tree/3.0.0"
|
||||||
|
},
|
||||||
|
"time": "2021-10-29T13:26:27+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "ralouphie/getallheaders",
|
"name": "ralouphie/getallheaders",
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
@@ -9554,10 +9899,10 @@
|
|||||||
},
|
},
|
||||||
"prefer-stable": false,
|
"prefer-stable": false,
|
||||||
"prefer-lowest": false,
|
"prefer-lowest": false,
|
||||||
"platform": {},
|
"platform": [],
|
||||||
"platform-dev": {},
|
"platform-dev": [],
|
||||||
"platform-overrides": {
|
"platform-overrides": {
|
||||||
"php": "8.4.0"
|
"php": "8.4.0"
|
||||||
},
|
},
|
||||||
"plugin-api-version": "2.9.0"
|
"plugin-api-version": "2.3.0"
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
migrations/aligo-test.xlsx
Normal file
BIN
migrations/aligo-test.xlsx
Normal file
Binary file not shown.
51
migrations/check_workbook.php
Normal file
51
migrations/check_workbook.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diagnose: what does ProductXlsxReader actually extract from a workbook?
|
||||||
|
*
|
||||||
|
* Standalone on purpose - the reader has no TYPO3 dependencies, only
|
||||||
|
* PhpSpreadsheet, so composer's autoloader is all it needs. That makes this the
|
||||||
|
* exact code path the backend module runs, without cache or session in the way.
|
||||||
|
*
|
||||||
|
* Usage: php migrations/check_workbook.php migrations/aligo-test.xlsx
|
||||||
|
*/
|
||||||
|
|
||||||
|
require __DIR__ . '/../vendor/autoload.php';
|
||||||
|
|
||||||
|
$file = $argv[1] ?? '';
|
||||||
|
if ($file === '' || !is_file($file)) {
|
||||||
|
exit("Usage: php migrations/check_workbook.php <file.xlsx>\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
$reader = new \Evomedien\Vitec\Import\ProductXlsxReader();
|
||||||
|
$parsed = $reader->parse($file);
|
||||||
|
|
||||||
|
echo "pageType : " . $parsed['pageType'] . "\n";
|
||||||
|
echo "meta : " . json_encode($parsed['meta'], JSON_UNESCAPED_UNICODE) . "\n";
|
||||||
|
echo "seo keys : " . implode(' | ', array_keys($parsed['seo'])) . "\n";
|
||||||
|
echo "components: " . count($parsed['components']) . "\n\n";
|
||||||
|
|
||||||
|
printf("%-3s %-34s %-3s %-28s %-9s %-12s\n", '#', 'component', 'occ', 'title', 'body-len', 'cta-in-body');
|
||||||
|
foreach ($parsed['components'] as $c) {
|
||||||
|
printf(
|
||||||
|
"%-3d %-34s %-3d %-28s %-9d %s\n",
|
||||||
|
$c['order'],
|
||||||
|
mb_substr($c['component'], 0, 34),
|
||||||
|
$c['occurrence'],
|
||||||
|
mb_substr($c['title'], 0, 28),
|
||||||
|
mb_strlen((string)$c['body']),
|
||||||
|
str_contains((string)$c['body'], '| CTA:') ? 'yes' : '-'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "\n--- Related Product Cards, raw ---\n";
|
||||||
|
foreach ($parsed['components'] as $c) {
|
||||||
|
if ($c['component'] !== 'Related Product Card') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
echo "title: [" . $c['title'] . "]\n";
|
||||||
|
echo "body : [" . $c['body'] . "]\n";
|
||||||
|
echo "cta : [" . $c['cta'] . "]\n\n";
|
||||||
|
}
|
||||||
@@ -31,6 +31,9 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
|
|||||||
* used for matching) is persisted per model via MappingRepository.
|
* used for matching) is persisted per model via MappingRepository.
|
||||||
* 3. A unified list shows CSV rows matched against the DB records:
|
* 3. A unified list shows CSV rows matched against the DB records:
|
||||||
* new / update (with the differing fields) / unchanged / db-only.
|
* new / update (with the differing fields) / unchanged / db-only.
|
||||||
|
* Rows the identity match misses can be linked by hand to an existing
|
||||||
|
* record (alias select on every "new" row); the links persist per
|
||||||
|
* model next to the column mapping and are re-applied on upload.
|
||||||
* Each importable row carries an editable JSON payload textarea -
|
* Each importable row carries an editable JSON payload textarea -
|
||||||
* what is applied is the textarea, not the raw CSV.
|
* what is applied is the textarea, not the raw CSV.
|
||||||
* 4. Checked rows are written through DataHandler (BE user permissions
|
* 4. Checked rows are written through DataHandler (BE user permissions
|
||||||
@@ -113,6 +116,16 @@ final class ImportController
|
|||||||
$this->flash('Mapping stored for this model - it will be preselected on the next upload.', 'Mapping saved', true);
|
$this->flash('Mapping stored for this model - it will be preselected on the next upload.', 'Mapping saved', true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($op === 'saveAliases' && $csv !== null && $mapping !== null && ($identity ?? '') !== '') {
|
||||||
|
$this->saveAliases(
|
||||||
|
$modelKey,
|
||||||
|
$csv,
|
||||||
|
$mapping,
|
||||||
|
(string)$identity,
|
||||||
|
is_array($body['alias'] ?? null) ? $body['alias'] : []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if ($op === 'apply') {
|
if ($op === 'apply') {
|
||||||
$this->apply($modelKey, $body);
|
$this->apply($modelKey, $body);
|
||||||
}
|
}
|
||||||
@@ -187,7 +200,8 @@ final class ImportController
|
|||||||
$identity = $saved['identity'] ?? (isset($fields['slug']) ? 'slug' : 'title');
|
$identity = $saved['identity'] ?? (isset($fields['slug']) ? 'slug' : 'title');
|
||||||
}
|
}
|
||||||
|
|
||||||
$union = $this->buildUnion($model['table'], $fields, $csv, $mapping, $identity);
|
['rows' => $union, 'candidates' => $aliasCandidates] =
|
||||||
|
$this->buildUnion($model['table'], $fields, $csv, $mapping, $identity, $saved['aliases'] ?? []);
|
||||||
|
|
||||||
$counts = ['new' => 0, 'update' => 0, 'unchanged' => 0, 'dbonly' => 0];
|
$counts = ['new' => 0, 'update' => 0, 'unchanged' => 0, 'dbonly' => 0];
|
||||||
foreach ($union as $row) {
|
foreach ($union as $row) {
|
||||||
@@ -215,7 +229,8 @@ final class ImportController
|
|||||||
'csvRowCount' => count($csv['rows'] ?? []),
|
'csvRowCount' => count($csv['rows'] ?? []),
|
||||||
'csvstate' => $csv !== null ? (string)json_encode($csv, JSON_UNESCAPED_UNICODE) : '',
|
'csvstate' => $csv !== null ? (string)json_encode($csv, JSON_UNESCAPED_UNICODE) : '',
|
||||||
'identity' => $identity,
|
'identity' => $identity,
|
||||||
'mappingSaved' => $saved !== null,
|
'mappingSaved' => ($saved['mapping'] ?? []) !== [],
|
||||||
|
'aliasCandidates' => $aliasCandidates,
|
||||||
'union' => $union,
|
'union' => $union,
|
||||||
'counts' => $counts,
|
'counts' => $counts,
|
||||||
'pid' => $this->registry->detectPid($model['table']),
|
'pid' => $this->registry->detectPid($model['table']),
|
||||||
@@ -246,12 +261,17 @@ final class ImportController
|
|||||||
/**
|
/**
|
||||||
* Merge CSV rows and DB records into one status-annotated list.
|
* Merge CSV rows and DB records into one status-annotated list.
|
||||||
*
|
*
|
||||||
|
* Matching order per CSV row: identity field first, then the stored
|
||||||
|
* aliases. Alias candidates are all records the identity match did not
|
||||||
|
* claim - those are what the per-row select in the template offers.
|
||||||
|
*
|
||||||
* @param array<string,string> $fields
|
* @param array<string,string> $fields
|
||||||
* @param array{columns:array<int,string>,rows:array<int,array<string,string>>}|null $csv
|
* @param array{columns:array<int,string>,rows:array<int,array<string,string>>}|null $csv
|
||||||
* @param array<string,string> $mapping
|
* @param array<string,string> $mapping
|
||||||
* @return array<int,array<string,mixed>>
|
* @param array<string,int> $aliases
|
||||||
|
* @return array{rows: array<int,array<string,mixed>>, candidates: array<int,array{uid:int,title:string}>}
|
||||||
*/
|
*/
|
||||||
private function buildUnion(string $table, array $fields, ?array $csv, array $mapping, string $identity): array
|
private function buildUnion(string $table, array $fields, ?array $csv, array $mapping, string $identity, array $aliases): array
|
||||||
{
|
{
|
||||||
$mappedFields = array_values(array_unique(array_values($mapping)));
|
$mappedFields = array_values(array_unique(array_values($mapping)));
|
||||||
$records = $this->registry->loadRecords($table, array_keys($fields));
|
$records = $this->registry->loadRecords($table, array_keys($fields));
|
||||||
@@ -266,6 +286,7 @@ final class ImportController
|
|||||||
|
|
||||||
$union = [];
|
$union = [];
|
||||||
$matchedUids = [];
|
$matchedUids = [];
|
||||||
|
$directUids = [];
|
||||||
$index = 0;
|
$index = 0;
|
||||||
|
|
||||||
foreach ($csv['rows'] ?? [] as $raw) {
|
foreach ($csv['rows'] ?? [] as $raw) {
|
||||||
@@ -275,9 +296,20 @@ final class ImportController
|
|||||||
}
|
}
|
||||||
$identityValue = mb_strtolower(trim((string)($payload[$identity] ?? '')));
|
$identityValue = mb_strtolower(trim((string)($payload[$identity] ?? '')));
|
||||||
$uid = $identityValue !== '' ? ($byIdentity[$identityValue] ?? 0) : 0;
|
$uid = $identityValue !== '' ? ($byIdentity[$identityValue] ?? 0) : 0;
|
||||||
|
$viaAlias = false;
|
||||||
|
if ($uid === 0 && $identityValue !== '') {
|
||||||
|
$aliasUid = (int)($aliases[$identityValue] ?? 0);
|
||||||
|
if ($aliasUid > 0 && isset($records[$aliasUid])) {
|
||||||
|
$uid = $aliasUid;
|
||||||
|
$viaAlias = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ($uid > 0) {
|
if ($uid > 0) {
|
||||||
$matchedUids[$uid] = true;
|
$matchedUids[$uid] = true;
|
||||||
|
if (!$viaAlias) {
|
||||||
|
$directUids[$uid] = true;
|
||||||
|
}
|
||||||
$record = $records[$uid];
|
$record = $records[$uid];
|
||||||
$diff = [];
|
$diff = [];
|
||||||
foreach ($payload as $field => $value) {
|
foreach ($payload as $field => $value) {
|
||||||
@@ -299,6 +331,9 @@ final class ImportController
|
|||||||
'diff' => implode(', ', $diff),
|
'diff' => implode(', ', $diff),
|
||||||
'payload' => (string)json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
'payload' => (string)json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||||
'checked' => $status === 'new' || $status === 'update',
|
'checked' => $status === 'new' || $status === 'update',
|
||||||
|
'viaAlias' => $viaAlias,
|
||||||
|
'aliasable' => $viaAlias || $status === 'new',
|
||||||
|
'aliasUid' => $viaAlias ? $uid : 0,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,7 +352,61 @@ final class ImportController
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return $union;
|
$candidates = [];
|
||||||
|
foreach ($records as $uid => $record) {
|
||||||
|
if (isset($directUids[$uid])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$candidates[] = ['uid' => $uid, 'title' => (string)($record['title'] ?? $uid)];
|
||||||
|
}
|
||||||
|
usort($candidates, static fn(array $a, array $b): int => strcasecmp($a['title'], $b['title']));
|
||||||
|
|
||||||
|
return ['rows' => $union, 'candidates' => $candidates];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge the alias selects into the stored aliases. Key is the CSV
|
||||||
|
* identity value (lowercased, like the matcher sees it), value the
|
||||||
|
* record uid; uid 0 removes a link. Untouched keys stay - a CSV that
|
||||||
|
* lacks a row must not lose that row's link.
|
||||||
|
*
|
||||||
|
* @param array{columns:array<int,string>,rows:array<int,array<string,string>>} $csv
|
||||||
|
* @param array<string,string> $mapping
|
||||||
|
* @param array<int|string,mixed> $input row index => uid, from the form
|
||||||
|
*/
|
||||||
|
private function saveAliases(string $modelKey, array $csv, array $mapping, string $identity, array $input): void
|
||||||
|
{
|
||||||
|
$aliases = $this->mappingRepository->load($modelKey)['aliases'] ?? [];
|
||||||
|
$changed = 0;
|
||||||
|
foreach ($input as $rowIndex => $selectedUid) {
|
||||||
|
$raw = $csv['rows'][(int)$rowIndex] ?? null;
|
||||||
|
if ($raw === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$payload = [];
|
||||||
|
foreach ($mapping as $column => $field) {
|
||||||
|
$payload[$field] = (string)($raw[$column] ?? '');
|
||||||
|
}
|
||||||
|
$key = mb_strtolower(trim((string)($payload[$identity] ?? '')));
|
||||||
|
if ($key === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$uid = (int)$selectedUid;
|
||||||
|
$current = (int)($aliases[$key] ?? 0);
|
||||||
|
if ($uid > 0 && $uid !== $current) {
|
||||||
|
$aliases[$key] = $uid;
|
||||||
|
$changed++;
|
||||||
|
} elseif ($uid === 0 && $current > 0) {
|
||||||
|
unset($aliases[$key]);
|
||||||
|
$changed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->mappingRepository->saveAliases($modelKey, $aliases);
|
||||||
|
$this->flash(
|
||||||
|
sprintf('%d record link(s) changed, %d stored for this model in total.', $changed, count($aliases)),
|
||||||
|
'Record links saved',
|
||||||
|
true
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- apply
|
// ---------------------------------------------------------------- apply
|
||||||
|
|||||||
@@ -0,0 +1,630 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Evomedien\Vitec\Controller\Backend;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\ParameterType;
|
||||||
|
use Evomedien\Vitec\Import\ImportModelRegistry;
|
||||||
|
use Evomedien\Vitec\Import\MappingRepository;
|
||||||
|
use Evomedien\Vitec\Import\ProductXlsxReader;
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
use TYPO3\CMS\Backend\Attribute\AsController;
|
||||||
|
use TYPO3\CMS\Backend\Routing\UriBuilder;
|
||||||
|
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
|
||||||
|
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||||
|
use TYPO3\CMS\Core\DataHandling\DataHandler;
|
||||||
|
use TYPO3\CMS\Core\Http\RedirectResponse;
|
||||||
|
use TYPO3\CMS\Core\Messaging\FlashMessage;
|
||||||
|
use TYPO3\CMS\Core\Messaging\FlashMessageService;
|
||||||
|
use TYPO3\CMS\Core\Page\PageRenderer;
|
||||||
|
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
|
||||||
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Products flow of the "VITEC Import" module: import agency-delivered
|
||||||
|
* "Product Page Content" workbooks into tx_vitec_domain_model_product.
|
||||||
|
*
|
||||||
|
* Flow (per the 2026-08-21 plan): product overview (searchable, sortable) ->
|
||||||
|
* "Edit Product" -> upload the product's XLSX -> one row per importable product
|
||||||
|
* field with a dropdown choosing the Excel source (component + cell) -> tick ->
|
||||||
|
* apply through DataHandler.
|
||||||
|
*
|
||||||
|
* The chosen mapping is persisted once, globally (MappingRepository, key
|
||||||
|
* `product_xlsx`) - the component vocabulary is stable across deliveries, so
|
||||||
|
* every next product's file arrives pre-mapped.
|
||||||
|
*
|
||||||
|
* Stateless like the CSV flow: the parsed workbook travels through the form as
|
||||||
|
* a hidden JSON field (~10 KB per file), no session, no temp file.
|
||||||
|
*
|
||||||
|
* Lives in its own controller to keep the already-large ImportController from
|
||||||
|
* growing; same module, same look (backend-import.css), same patterns.
|
||||||
|
*/
|
||||||
|
#[AsController]
|
||||||
|
final class ProductTextImportController
|
||||||
|
{
|
||||||
|
private const TABLE = 'tx_vitec_domain_model_product';
|
||||||
|
private const MAPPING_KEY = 'product_xlsx';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Never offered as import targets. `slug` changes URLs - that is redirect
|
||||||
|
* territory, not a text import; the workbook URL is checked against the
|
||||||
|
* record's slug informationally instead.
|
||||||
|
*/
|
||||||
|
private const EXCLUDED_TARGETS = ['slug'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-seed for the very first mapping (no row in tx_vitec_import_mapping
|
||||||
|
* yet). Editors change everything in the dropdowns; this only saves the
|
||||||
|
* first manual pass. Keys are product columns, values source selectors as
|
||||||
|
* ProductXlsxReader defines them.
|
||||||
|
*/
|
||||||
|
private const DEFAULT_MAPPING = [
|
||||||
|
'subtitle' => 'c:Hero — H1#1:body',
|
||||||
|
'teaser' => 'c:Product Introduction#1:body',
|
||||||
|
'description' => 'c:Product Overview — H2#1:body',
|
||||||
|
'cta' => 'c:Pre-footer CTA — H2#1:cta',
|
||||||
|
'keywords' => 'm:primaryKeyword',
|
||||||
|
'key1' => 'c:Why Choose Card#1:title',
|
||||||
|
'apptext1' => 'c:Why Choose Card#1:body',
|
||||||
|
'key2' => 'c:Why Choose Card#2:title',
|
||||||
|
'apptext2' => 'c:Why Choose Card#2:body',
|
||||||
|
'key3' => 'c:Why Choose Card#3:title',
|
||||||
|
'apptext3' => 'c:Why Choose Card#3:body',
|
||||||
|
];
|
||||||
|
|
||||||
|
private const SORTABLE = ['title', 'slug', 'tstamp'];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly ModuleTemplateFactory $moduleTemplateFactory,
|
||||||
|
private readonly ImportModelRegistry $registry,
|
||||||
|
private readonly MappingRepository $mappingRepository,
|
||||||
|
private readonly ProductXlsxReader $reader,
|
||||||
|
private readonly FlashMessageService $flashMessageService,
|
||||||
|
private readonly PageRenderer $pageRenderer,
|
||||||
|
private readonly UriBuilder $uriBuilder,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- overview
|
||||||
|
|
||||||
|
public function productsAction(ServerRequestInterface $request): ResponseInterface
|
||||||
|
{
|
||||||
|
$params = array_merge($request->getQueryParams(), (array)$request->getParsedBody());
|
||||||
|
$q = trim((string)($params['q'] ?? ''));
|
||||||
|
$sort = in_array((string)($params['sort'] ?? ''), self::SORTABLE, true) ? (string)$params['sort'] : 'title';
|
||||||
|
$dir = strtolower((string)($params['dir'] ?? 'asc')) === 'desc' ? 'desc' : 'asc';
|
||||||
|
|
||||||
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::TABLE);
|
||||||
|
$qb->select('uid', 'title', 'slug', 'tstamp', 'hidden', 'teaser', 'description')
|
||||||
|
->from(self::TABLE)
|
||||||
|
->where($qb->expr()->eq('deleted', 0))
|
||||||
|
->orderBy($sort, $dir === 'desc' ? 'DESC' : 'ASC')
|
||||||
|
->addOrderBy('title', 'ASC');
|
||||||
|
if ($q !== '') {
|
||||||
|
$like = '%' . $qb->escapeLikeWildcards($q) . '%';
|
||||||
|
$qb->andWhere($qb->expr()->or(
|
||||||
|
$qb->expr()->like('title', $qb->createNamedParameter($like, ParameterType::STRING)),
|
||||||
|
$qb->expr()->like('slug', $qb->createNamedParameter($like, ParameterType::STRING))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
$rows = $qb->executeQuery()->fetchAllAssociative();
|
||||||
|
|
||||||
|
$products = array_map(static fn(array $r): array => [
|
||||||
|
'uid' => (int)$r['uid'],
|
||||||
|
'title' => (string)$r['title'],
|
||||||
|
'slug' => (string)$r['slug'],
|
||||||
|
'hidden' => (bool)$r['hidden'],
|
||||||
|
'changed' => date('Y-m-d H:i', (int)$r['tstamp']),
|
||||||
|
'hasTeaser' => trim((string)$r['teaser']) !== '',
|
||||||
|
'hasDescription' => trim((string)$r['description']) !== '',
|
||||||
|
], $rows);
|
||||||
|
|
||||||
|
// Last searches as one-click badges, kept per backend user in their uc
|
||||||
|
// (no schema, survives logout). Only searches that actually found
|
||||||
|
// something are remembered - a typo makes a useless badge.
|
||||||
|
$backendUser = $GLOBALS['BE_USER'] ?? null;
|
||||||
|
$searchHistory = $backendUser !== null
|
||||||
|
? array_values(array_filter(array_map('strval', (array)($backendUser->uc['vitec_import_product_searches'] ?? []))))
|
||||||
|
: [];
|
||||||
|
if ($backendUser !== null && $q !== '' && $products !== []) {
|
||||||
|
$searchHistory = array_merge([$q], array_diff($searchHistory, [$q]));
|
||||||
|
$searchHistory = array_slice($searchHistory, 0, 10);
|
||||||
|
$backendUser->uc['vitec_import_product_searches'] = $searchHistory;
|
||||||
|
$backendUser->writeUC();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Precomputed per column: the dir a header link should use next, and
|
||||||
|
// the arrow it shows now. Keeps nested inline conditions out of Fluid.
|
||||||
|
$sortLinks = [];
|
||||||
|
$sortArrows = [];
|
||||||
|
foreach (self::SORTABLE as $column) {
|
||||||
|
$sortLinks[$column] = ($sort === $column && $dir === 'asc') ? 'desc' : 'asc';
|
||||||
|
$sortArrows[$column] = $sort === $column ? ($dir === 'asc' ? '▲' : '▼') : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$view = $this->moduleTemplateFactory->create($request);
|
||||||
|
$this->pageRenderer->addCssFile('EXT:vitec/Resources/Public/Css/backend-import.css');
|
||||||
|
$view->assignMultiple([
|
||||||
|
'models' => $this->registry->all(),
|
||||||
|
'products' => $products,
|
||||||
|
'q' => $q,
|
||||||
|
'searchHistory' => $searchHistory,
|
||||||
|
'sortLinks' => $sortLinks,
|
||||||
|
'sortArrows' => $sortArrows,
|
||||||
|
'total' => count($products),
|
||||||
|
]);
|
||||||
|
return $view->renderResponse('Import/Products');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ edit/upload
|
||||||
|
|
||||||
|
public function productEditAction(ServerRequestInterface $request): ResponseInterface
|
||||||
|
{
|
||||||
|
$uid = (int)($request->getQueryParams()['uid'] ?? 0);
|
||||||
|
$product = $this->loadProduct($uid);
|
||||||
|
if ($product === null) {
|
||||||
|
$this->message('No product with uid ' . $uid . '.', ContextualFeedbackSeverity::ERROR);
|
||||||
|
return new RedirectResponse((string)$this->uriBuilder->buildUriFromRoute('web_vitecimport.products'), 303);
|
||||||
|
}
|
||||||
|
// Reopen on the last successfully loaded workbook (null when there was
|
||||||
|
// none yet) - the editor lands in the mapping view, not an empty form.
|
||||||
|
return $this->renderTexts($request, $product, $this->loadWorkbook($uid), []);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------- preview
|
||||||
|
|
||||||
|
public function productPreviewAction(ServerRequestInterface $request): ResponseInterface
|
||||||
|
{
|
||||||
|
$body = (array)$request->getParsedBody();
|
||||||
|
$uid = (int)($body['uid'] ?? 0);
|
||||||
|
$product = $this->loadProduct($uid);
|
||||||
|
if ($product === null) {
|
||||||
|
return new RedirectResponse((string)$this->uriBuilder->buildUriFromRoute('web_vitecimport.products'), 303);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fresh upload wins; otherwise the hidden state field carries the
|
||||||
|
// already-parsed workbook (mapping refresh without re-upload).
|
||||||
|
$parsed = null;
|
||||||
|
$freshFilename = '';
|
||||||
|
$upload = $request->getUploadedFiles()['xlsxfile'] ?? null;
|
||||||
|
if ($upload !== null && $upload->getError() === UPLOAD_ERR_OK) {
|
||||||
|
$freshFilename = (string)$upload->getClientFilename();
|
||||||
|
$tmp = GeneralUtility::tempnam('vitec_xlsx_');
|
||||||
|
try {
|
||||||
|
$upload->moveTo($tmp);
|
||||||
|
$parsed = $this->reader->parse($tmp);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->message('The file could not be read as a spreadsheet: ' . $e->getMessage(), ContextualFeedbackSeverity::ERROR);
|
||||||
|
return $this->renderTexts($request, $product, null, []);
|
||||||
|
} finally {
|
||||||
|
if (file_exists($tmp)) {
|
||||||
|
@unlink($tmp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} elseif (($body['parsed'] ?? '') !== '') {
|
||||||
|
$decoded = json_decode((string)$body['parsed'], true);
|
||||||
|
$parsed = is_array($decoded) ? $decoded : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($parsed === null) {
|
||||||
|
$parsed = $this->loadWorkbook($uid);
|
||||||
|
}
|
||||||
|
if ($parsed === null) {
|
||||||
|
$this->message('Upload a workbook first.', ContextualFeedbackSeverity::WARNING);
|
||||||
|
return $this->renderTexts($request, $product, null, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category and unknown files are rejected, not guessed at. Category
|
||||||
|
// pages are handled separately (own vocabulary, target is a page).
|
||||||
|
$pageType = (string)($parsed['pageType'] ?? 'unknown');
|
||||||
|
if ($pageType !== 'product') {
|
||||||
|
$this->message(
|
||||||
|
$pageType === 'category'
|
||||||
|
? 'This is a CATEGORY content file - category pages are handled separately. Nothing was imported.'
|
||||||
|
: 'This file does not match the known product content format (no "Product Introduction" component). Nothing was imported.',
|
||||||
|
ContextualFeedbackSeverity::ERROR
|
||||||
|
);
|
||||||
|
return $this->renderTexts($request, $product, null, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fresh, valid product workbook replaces the stored one for this
|
||||||
|
// product - that is what "Edit Product" reopens on next time.
|
||||||
|
if ($freshFilename !== '') {
|
||||||
|
$this->saveWorkbook($uid, $parsed, $freshFilename);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->renderTexts($request, $product, $parsed, (array)($body['map'] ?? []));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- apply
|
||||||
|
|
||||||
|
public function productApplyAction(ServerRequestInterface $request): ResponseInterface
|
||||||
|
{
|
||||||
|
$body = (array)$request->getParsedBody();
|
||||||
|
$uid = (int)($body['uid'] ?? 0);
|
||||||
|
$product = $this->loadProduct($uid);
|
||||||
|
$parsed = json_decode((string)($body['parsed'] ?? ''), true);
|
||||||
|
if ($product === null || !is_array($parsed)) {
|
||||||
|
$this->message('Apply failed: missing product or workbook state.', ContextualFeedbackSeverity::ERROR);
|
||||||
|
return new RedirectResponse((string)$this->uriBuilder->buildUriFromRoute('web_vitecimport.products'), 303);
|
||||||
|
}
|
||||||
|
|
||||||
|
$map = array_map('strval', (array)($body['map'] ?? []));
|
||||||
|
$apply = array_map('strval', (array)($body['apply'] ?? []));
|
||||||
|
$targets = $this->targetFields();
|
||||||
|
|
||||||
|
$data = [];
|
||||||
|
foreach ($apply as $field) {
|
||||||
|
$selector = $map[$field] ?? '';
|
||||||
|
if ($selector === '' || !isset($targets[$field])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$value = $this->reader->valueFor($parsed, $selector);
|
||||||
|
if ($value !== null) {
|
||||||
|
$data[$field] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Related-product cards: one chosen target record per ticked Excel card.
|
||||||
|
// A card mapped onto the edited product itself is ignored - a product
|
||||||
|
// cannot be related to itself.
|
||||||
|
$relatedChoices = array_map('intval', (array)($body['related'] ?? []));
|
||||||
|
$applyRelated = array_map('intval', (array)($body['applyRelated'] ?? []));
|
||||||
|
$relatedCards = $this->relatedCards($parsed);
|
||||||
|
$relatedTodo = [];
|
||||||
|
foreach ($applyRelated as $index) {
|
||||||
|
$chosen = $relatedChoices[$index] ?? 0;
|
||||||
|
if ($chosen > 0 && $chosen !== $uid && isset($relatedCards[$index])) {
|
||||||
|
$relatedTodo[$index] = $chosen;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($data === [] && $relatedTodo === []) {
|
||||||
|
$this->message('Nothing ticked - no fields were written.', ContextualFeedbackSeverity::WARNING);
|
||||||
|
return $this->renderTexts($request, $product, $parsed, $map);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($data !== []) {
|
||||||
|
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||||
|
$dataHandler->start([self::TABLE => [(string)$uid => $data]], []);
|
||||||
|
$dataHandler->process_datamap();
|
||||||
|
if ($dataHandler->errorLog !== []) {
|
||||||
|
$this->message('DataHandler: ' . implode(' | ', $dataHandler->errorLog), ContextualFeedbackSeverity::ERROR);
|
||||||
|
return $this->renderTexts($request, $product, $parsed, $map);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$relationsAdded = 0;
|
||||||
|
if ($relatedTodo !== []) {
|
||||||
|
// Missing MM relations are ADDED, existing ones and their order are
|
||||||
|
// never touched, nothing is removed. Written through DataHandler
|
||||||
|
// rather than into the MM table directly, so the relation counter
|
||||||
|
// in `relatedprodukt` stays correct and FormEngine keeps agreeing
|
||||||
|
// with the database.
|
||||||
|
$current = $this->relatedUidsOf($uid);
|
||||||
|
$merged = $current;
|
||||||
|
foreach ($relatedTodo as $chosen) {
|
||||||
|
if (!in_array($chosen, $merged, true)) {
|
||||||
|
$merged[] = $chosen;
|
||||||
|
$relationsAdded++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($merged !== $current) {
|
||||||
|
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||||
|
$dataHandler->start([self::TABLE => [(string)$uid => ['relatedprodukt' => implode(',', $merged)]]], []);
|
||||||
|
$dataHandler->process_datamap();
|
||||||
|
if ($dataHandler->errorLog !== []) {
|
||||||
|
$this->message('DataHandler (relations): ' . implode(' | ', $dataHandler->errorLog), ContextualFeedbackSeverity::ERROR);
|
||||||
|
return $this->renderTexts($request, $product, $parsed, $map);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Card texts live in the side table (survives MM rewrites), and the
|
||||||
|
// editor's manual title->record choices are remembered as aliases,
|
||||||
|
// so the next workbook arrives preselected.
|
||||||
|
$aliases = $this->mappingRepository->load(self::MAPPING_KEY)['aliases'] ?? [];
|
||||||
|
foreach ($relatedTodo as $index => $chosen) {
|
||||||
|
$this->upsertCardText($uid, $chosen, $relatedCards[$index]['cardtext']);
|
||||||
|
$aliases[$relatedCards[$index]['title']] = $chosen;
|
||||||
|
}
|
||||||
|
$this->mappingRepository->saveAliases(self::MAPPING_KEY, $aliases);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($body['saveMapping'])) {
|
||||||
|
$this->mappingRepository->save(self::MAPPING_KEY, array_filter($map), '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
$summary = [];
|
||||||
|
if ($data !== []) {
|
||||||
|
$summary[] = count($data) . ' field(s) written (' . implode(', ', array_keys($data)) . ')';
|
||||||
|
}
|
||||||
|
if ($relatedTodo !== []) {
|
||||||
|
$summary[] = count($relatedTodo) . ' related-product card(s), ' . $relationsAdded . ' relation(s) added';
|
||||||
|
}
|
||||||
|
$this->message(
|
||||||
|
sprintf('"%s": %s.', $product['title'], implode('; ', $summary)),
|
||||||
|
ContextualFeedbackSeverity::OK
|
||||||
|
);
|
||||||
|
|
||||||
|
// Re-render with fresh DB values: every applied row now shows as
|
||||||
|
// unchanged, which doubles as the visual confirmation.
|
||||||
|
return $this->renderTexts($request, $this->loadProduct($uid), $parsed, $map);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ internals
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed>|null $product
|
||||||
|
* @param array<string,mixed>|null $parsed
|
||||||
|
* @param array<string,string> $submittedMap map from the form; wins over saved/default
|
||||||
|
*/
|
||||||
|
private function renderTexts(ServerRequestInterface $request, ?array $product, ?array $parsed, array $submittedMap): ResponseInterface
|
||||||
|
{
|
||||||
|
$targets = $this->targetFields();
|
||||||
|
$saved = $this->mappingRepository->load(self::MAPPING_KEY)['mapping'] ?? [];
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
$sources = [];
|
||||||
|
$slugMismatch = null;
|
||||||
|
|
||||||
|
if ($parsed !== null) {
|
||||||
|
$sources = $this->reader->sources($parsed);
|
||||||
|
|
||||||
|
// Workbook URL vs record slug - informational only (slug is not a
|
||||||
|
// target), but a mismatch usually means "wrong product picked".
|
||||||
|
$url = trim((string)($parsed['meta']['url'] ?? ''), '/');
|
||||||
|
$slug = trim((string)($product['slug'] ?? ''), '/');
|
||||||
|
if ($url !== '' && $slug !== '' && !str_ends_with($url, $slug)) {
|
||||||
|
$slugMismatch = '/' . $url . ' (workbook) vs /' . $slug . ' (record)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Related-product cards: match preselection order is submitted choice ->
|
||||||
|
// remembered alias -> exact title -> none. Submitted choices are read
|
||||||
|
// from the request here so the dropdowns survive a "Refresh preview".
|
||||||
|
$relatedRows = [];
|
||||||
|
$productOptions = [];
|
||||||
|
if ($parsed !== null && $product !== null) {
|
||||||
|
$productUid = (int)$product['uid'];
|
||||||
|
$productOptions = $this->productOptions($productUid);
|
||||||
|
$submittedRelated = array_map('intval', (array)(((array)$request->getParsedBody())['related'] ?? []));
|
||||||
|
$aliases = $this->mappingRepository->load(self::MAPPING_KEY)['aliases'] ?? [];
|
||||||
|
$byTitle = [];
|
||||||
|
foreach ($productOptions as $option) {
|
||||||
|
$byTitle[mb_strtolower($option['title'])] = $option['uid'];
|
||||||
|
}
|
||||||
|
$existingRelated = array_flip($this->relatedUidsOf($productUid));
|
||||||
|
foreach ($this->relatedCards($parsed) as $index => $card) {
|
||||||
|
$chosen = $submittedRelated[$index] ?? 0;
|
||||||
|
$status = $chosen > 0 ? 'manual' : 'none';
|
||||||
|
if ($chosen === 0 && isset($aliases[$card['title']])) {
|
||||||
|
$chosen = (int)$aliases[$card['title']];
|
||||||
|
$status = 'alias';
|
||||||
|
} elseif ($chosen === 0 && isset($byTitle[mb_strtolower($card['title'])])) {
|
||||||
|
$chosen = $byTitle[mb_strtolower($card['title'])];
|
||||||
|
$status = 'title';
|
||||||
|
}
|
||||||
|
$old = $chosen > 0 ? $this->existingCardText($productUid, $chosen) : '';
|
||||||
|
$relatedRows[] = [
|
||||||
|
'index' => $index,
|
||||||
|
'title' => $card['title'],
|
||||||
|
'cta' => $card['cta'],
|
||||||
|
'preview' => $this->preview($card['cardtext']),
|
||||||
|
'selected' => $chosen,
|
||||||
|
'status' => $status,
|
||||||
|
'isRelated' => $chosen > 0 && isset($existingRelated[$chosen]),
|
||||||
|
'oldPreview' => $this->preview($old),
|
||||||
|
'changed' => $chosen > 0 && trim($old) !== trim($card['cardtext']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($targets as $field => $label) {
|
||||||
|
$selector = $submittedMap[$field] ?? $saved[$field] ?? self::DEFAULT_MAPPING[$field] ?? '';
|
||||||
|
$old = (string)($product[$field] ?? '');
|
||||||
|
$new = ($parsed !== null && $selector !== '') ? $this->reader->valueFor($parsed, $selector) : null;
|
||||||
|
$changed = $new !== null && trim($new) !== trim($old);
|
||||||
|
$rows[] = [
|
||||||
|
'field' => $field,
|
||||||
|
'label' => $label,
|
||||||
|
'selector' => $selector,
|
||||||
|
'old' => $old,
|
||||||
|
'oldPreview' => $this->preview($old),
|
||||||
|
'new' => $new,
|
||||||
|
'newPreview' => $new === null ? '' : $this->preview($new),
|
||||||
|
'changed' => $changed,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$view = $this->moduleTemplateFactory->create($request);
|
||||||
|
$this->pageRenderer->addCssFile('EXT:vitec/Resources/Public/Css/backend-import.css');
|
||||||
|
$view->assignMultiple([
|
||||||
|
'product' => $product,
|
||||||
|
'parsed' => $parsed,
|
||||||
|
'parsedJson' => $parsed !== null ? (string)json_encode($parsed, JSON_UNESCAPED_UNICODE) : '',
|
||||||
|
'sources' => $sources,
|
||||||
|
'rows' => $rows,
|
||||||
|
'slugMismatch' => $slugMismatch,
|
||||||
|
'relatedRows' => $relatedRows,
|
||||||
|
'productOptions' => $productOptions,
|
||||||
|
'pageType' => $parsed['pageType'] ?? null,
|
||||||
|
'metaUrl' => $parsed['meta']['url'] ?? '',
|
||||||
|
'workbook' => $this->workbookInfo((int)($product['uid'] ?? 0)),
|
||||||
|
]);
|
||||||
|
return $view->renderResponse('Import/ProductTexts');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The stored parse of the last loaded workbook, null when there is none. */
|
||||||
|
private function loadWorkbook(int $productUid): ?array
|
||||||
|
{
|
||||||
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('tx_vitec_product_workbook');
|
||||||
|
$payload = $qb->select('payload')->from('tx_vitec_product_workbook')
|
||||||
|
->where($qb->expr()->eq('product_uid', $qb->createNamedParameter($productUid, ParameterType::INTEGER)))
|
||||||
|
->executeQuery()->fetchOne();
|
||||||
|
if (!is_string($payload) || $payload === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$decoded = json_decode($payload, true);
|
||||||
|
return is_array($decoded) ? $decoded : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string,mixed> $parsed */
|
||||||
|
private function saveWorkbook(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' => (int)($GLOBALS['BE_USER']->user['uid'] ?? 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{filename:string,date:string}|null display info for the stored workbook */
|
||||||
|
private function workbookInfo(int $productUid): ?array
|
||||||
|
{
|
||||||
|
if ($productUid <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('tx_vitec_product_workbook');
|
||||||
|
$row = $qb->select('filename', 'tstamp')->from('tx_vitec_product_workbook')
|
||||||
|
->where($qb->expr()->eq('product_uid', $qb->createNamedParameter($productUid, ParameterType::INTEGER)))
|
||||||
|
->executeQuery()->fetchAssociative();
|
||||||
|
if (!$row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'filename' => (string)$row['filename'],
|
||||||
|
'date' => date('Y-m-d H:i', (int)$row['tstamp']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string,string> field => label, without excluded targets */
|
||||||
|
private function targetFields(): array
|
||||||
|
{
|
||||||
|
$fields = $this->registry->importableFields(self::TABLE);
|
||||||
|
foreach (self::EXCLUDED_TARGETS as $excluded) {
|
||||||
|
unset($fields[$excluded]);
|
||||||
|
}
|
||||||
|
return $fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string,mixed>|null */
|
||||||
|
private function loadProduct(int $uid): ?array
|
||||||
|
{
|
||||||
|
if ($uid <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::TABLE);
|
||||||
|
$row = $qb->select('*')->from(self::TABLE)
|
||||||
|
->where(
|
||||||
|
$qb->expr()->eq('uid', $qb->createNamedParameter($uid, ParameterType::INTEGER)),
|
||||||
|
$qb->expr()->eq('deleted', 0)
|
||||||
|
)
|
||||||
|
->executeQuery()->fetchAssociative();
|
||||||
|
return $row ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The "Related Product Card" components of a parsed workbook. The card copy
|
||||||
|
* arrives as "Explore QTX100 ... | CTA: Learn more" - text and CTA label are
|
||||||
|
* split here; the CTA label has no storage yet and is display-only.
|
||||||
|
*
|
||||||
|
* @param array<string,mixed> $parsed
|
||||||
|
* @return array<int,array{title:string,cardtext:string,cta:string}>
|
||||||
|
*/
|
||||||
|
private function relatedCards(array $parsed): array
|
||||||
|
{
|
||||||
|
$cards = [];
|
||||||
|
foreach ($parsed['components'] ?? [] as $component) {
|
||||||
|
if ((string)($component['component'] ?? '') !== 'Related Product Card') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// The agency uses the columns loosely: text components carry their
|
||||||
|
// copy in "Body Copy" (D), card components in "CTA / Card Copy" (E).
|
||||||
|
// Verified in the raw sheet - D of a card row is an empty
|
||||||
|
// self-closing cell. Take D when filled, fall back to E.
|
||||||
|
$raw = trim((string)($component['body'] ?? ''));
|
||||||
|
if ($raw === '') {
|
||||||
|
$raw = trim((string)($component['cta'] ?? ''));
|
||||||
|
}
|
||||||
|
$cta = '';
|
||||||
|
if (preg_match('/^(.*?)\|\s*CTA:\s*(.+)$/su', $raw, $m)) {
|
||||||
|
$raw = $m[1];
|
||||||
|
$cta = trim($m[2]);
|
||||||
|
}
|
||||||
|
$cards[] = [
|
||||||
|
'title' => trim((string)($component['title'] ?? '')),
|
||||||
|
'cardtext' => trim($raw),
|
||||||
|
'cta' => $cta,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $cards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<int,array{uid:int,title:string}> all products except the edited one */
|
||||||
|
private function productOptions(int $excludeUid): array
|
||||||
|
{
|
||||||
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::TABLE);
|
||||||
|
$rows = $qb->select('uid', 'title')->from(self::TABLE)
|
||||||
|
->where(
|
||||||
|
$qb->expr()->eq('deleted', 0),
|
||||||
|
$qb->expr()->neq('uid', $qb->createNamedParameter($excludeUid, ParameterType::INTEGER))
|
||||||
|
)
|
||||||
|
->orderBy('title', 'ASC')
|
||||||
|
->executeQuery()->fetchAllAssociative();
|
||||||
|
return array_map(static fn(array $r): array => ['uid' => (int)$r['uid'], 'title' => (string)$r['title']], $rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return int[] related product uids in MM sorting order */
|
||||||
|
private function relatedUidsOf(int $productUid): array
|
||||||
|
{
|
||||||
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('tx_vitec_product_related_mm');
|
||||||
|
$uids = $qb->select('uid_foreign')->from('tx_vitec_product_related_mm')
|
||||||
|
->where($qb->expr()->eq('uid_local', $qb->createNamedParameter($productUid, ParameterType::INTEGER)))
|
||||||
|
->orderBy('sorting', 'ASC')
|
||||||
|
->executeQuery()->fetchFirstColumn();
|
||||||
|
return array_map('intval', $uids);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function existingCardText(int $productUid, int $relatedUid): string
|
||||||
|
{
|
||||||
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('tx_vitec_product_related_text');
|
||||||
|
$text = $qb->select('cardtext')->from('tx_vitec_product_related_text')
|
||||||
|
->where(
|
||||||
|
$qb->expr()->eq('product_uid', $qb->createNamedParameter($productUid, ParameterType::INTEGER)),
|
||||||
|
$qb->expr()->eq('related_uid', $qb->createNamedParameter($relatedUid, ParameterType::INTEGER))
|
||||||
|
)
|
||||||
|
->executeQuery()->fetchOne();
|
||||||
|
return is_string($text) ? $text : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function upsertCardText(int $productUid, int $relatedUid, string $text): void
|
||||||
|
{
|
||||||
|
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tx_vitec_product_related_text');
|
||||||
|
$keys = ['product_uid' => $productUid, 'related_uid' => $relatedUid];
|
||||||
|
if ($connection->count('product_uid', 'tx_vitec_product_related_text', $keys) > 0) {
|
||||||
|
$connection->update('tx_vitec_product_related_text', ['cardtext' => $text, 'tstamp' => time()], $keys);
|
||||||
|
} else {
|
||||||
|
$connection->insert('tx_vitec_product_related_text', $keys + ['cardtext' => $text, 'tstamp' => time()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function preview(string $value): string
|
||||||
|
{
|
||||||
|
$flat = preg_replace('/\s+/u', ' ', trim($value)) ?? $value;
|
||||||
|
return mb_strlen($flat) > 160 ? mb_substr($flat, 0, 157) . '…' : $flat;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function message(string $text, ContextualFeedbackSeverity $severity): void
|
||||||
|
{
|
||||||
|
$message = GeneralUtility::makeInstance(FlashMessage::class, $text, '', $severity, true);
|
||||||
|
$this->flashMessageService->getMessageQueueByIdentifier()->addMessage($message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -720,6 +720,7 @@ class Product extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity
|
|||||||
{
|
{
|
||||||
$this->categories = new ObjectStorage();
|
$this->categories = new ObjectStorage();
|
||||||
$this->productimage = new ObjectStorage();
|
$this->productimage = new ObjectStorage();
|
||||||
|
$this->heroimage = new ObjectStorage();
|
||||||
$this->downloads = new ObjectStorage();
|
$this->downloads = new ObjectStorage();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -977,6 +978,98 @@ class Product extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity
|
|||||||
{
|
{
|
||||||
$this->relatedprodukt = $relatedprodukt;
|
$this->relatedprodukt = $relatedprodukt;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Hero images
|
||||||
|
*
|
||||||
|
* @var ObjectStorage<FileReference>
|
||||||
|
* @TYPO3\CMS\Extbase\Annotation\ORM\Cascade("remove")
|
||||||
|
*/
|
||||||
|
protected $heroimage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Second description
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description2 = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capabilities
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $capabilities = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Portfolio link (typolink parameter)
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $portfolio = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return ObjectStorage<FileReference>
|
||||||
|
*/
|
||||||
|
public function getHeroimage(): ObjectStorage
|
||||||
|
{
|
||||||
|
return $this->heroimage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param ObjectStorage<FileReference> $heroimage
|
||||||
|
*/
|
||||||
|
public function setHeroimage(ObjectStorage $heroimage): void
|
||||||
|
{
|
||||||
|
$this->heroimage = $heroimage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDescription2(): string
|
||||||
|
{
|
||||||
|
return $this->description2;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDescription2(string $description2): void
|
||||||
|
{
|
||||||
|
$this->description2 = $description2;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCapabilities(): string
|
||||||
|
{
|
||||||
|
return $this->capabilities;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCapabilities(string $capabilities): void
|
||||||
|
{
|
||||||
|
$this->capabilities = $capabilities;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPortfolio(): string
|
||||||
|
{
|
||||||
|
return $this->portfolio;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setPortfolio(string $portfolio): void
|
||||||
|
{
|
||||||
|
$this->portfolio = $portfolio;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intro text above the related products
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $textrelatedproducts = '';
|
||||||
|
|
||||||
|
public function getTextrelatedproducts(): string
|
||||||
|
{
|
||||||
|
return $this->textrelatedproducts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setTextrelatedproducts(string $textrelatedproducts): void
|
||||||
|
{
|
||||||
|
$this->textrelatedproducts = $textrelatedproducts;
|
||||||
|
}
|
||||||
|
|
||||||
/* --------------------------------------------------------------------- */
|
/* --------------------------------------------------------------------- */
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,43 +14,63 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
|
|||||||
*
|
*
|
||||||
* Plain table without TCA (tx_vitec_import_mapping) - the records are pure
|
* Plain table without TCA (tx_vitec_import_mapping) - the records are pure
|
||||||
* tool configuration, never edited through FormEngine.
|
* tool configuration, never edited through FormEngine.
|
||||||
|
*
|
||||||
|
* The same row also stores record aliases (CSV identity value -> record
|
||||||
|
* uid) for rows whose name differs slightly from the TYPO3 record, so a
|
||||||
|
* hand-made link survives every future delivery of the same CSV.
|
||||||
*/
|
*/
|
||||||
final class MappingRepository
|
final class MappingRepository
|
||||||
{
|
{
|
||||||
private const TABLE = 'tx_vitec_import_mapping';
|
private const TABLE = 'tx_vitec_import_mapping';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{mapping: array<string,string>, identity: string}|null
|
* @return array{mapping: array<string,string>, identity: string, aliases: array<string,int>}|null
|
||||||
*/
|
*/
|
||||||
public function load(string $model): ?array
|
public function load(string $model): ?array
|
||||||
{
|
{
|
||||||
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::TABLE);
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::TABLE);
|
||||||
$row = $qb->select('mapping', 'identity_field')->from(self::TABLE)
|
$row = $qb->select('mapping', 'identity_field', 'record_aliases')->from(self::TABLE)
|
||||||
->where($qb->expr()->eq('model', $qb->createNamedParameter($model, ParameterType::STRING)))
|
->where($qb->expr()->eq('model', $qb->createNamedParameter($model, ParameterType::STRING)))
|
||||||
->setMaxResults(1)
|
->setMaxResults(1)
|
||||||
->executeQuery()->fetchAssociative();
|
->executeQuery()->fetchAssociative();
|
||||||
if (!$row) {
|
if (!$row) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
// A row may exist with aliases only (saveAliases before the first
|
||||||
|
// saveMapping) - an unparsable mapping degrades to [], not to null.
|
||||||
$mapping = json_decode((string)$row['mapping'], true);
|
$mapping = json_decode((string)$row['mapping'], true);
|
||||||
if (!is_array($mapping)) {
|
$aliases = json_decode((string)($row['record_aliases'] ?? ''), true);
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return [
|
return [
|
||||||
'mapping' => array_map('strval', $mapping),
|
'mapping' => is_array($mapping) ? array_map('strval', $mapping) : [],
|
||||||
'identity' => (string)$row['identity_field'],
|
'identity' => (string)$row['identity_field'],
|
||||||
|
'aliases' => is_array($aliases) ? array_map('intval', $aliases) : [],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param array<string,string> $mapping */
|
/** @param array<string,string> $mapping */
|
||||||
public function save(string $model, array $mapping, string $identity): void
|
public function save(string $model, array $mapping, string $identity): void
|
||||||
{
|
{
|
||||||
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable(self::TABLE);
|
|
||||||
$values = [
|
$values = [
|
||||||
'mapping' => (string)json_encode($mapping, JSON_UNESCAPED_UNICODE),
|
'mapping' => (string)json_encode($mapping, JSON_UNESCAPED_UNICODE),
|
||||||
'identity_field' => $identity,
|
'identity_field' => $identity,
|
||||||
'tstamp' => time(),
|
'tstamp' => time(),
|
||||||
];
|
];
|
||||||
|
$this->upsert($model, $values);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string,int> $aliases */
|
||||||
|
public function saveAliases(string $model, array $aliases): void
|
||||||
|
{
|
||||||
|
$this->upsert($model, [
|
||||||
|
'record_aliases' => (string)json_encode($aliases, JSON_UNESCAPED_UNICODE),
|
||||||
|
'tstamp' => time(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string,mixed> $values */
|
||||||
|
private function upsert(string $model, array $values): void
|
||||||
|
{
|
||||||
|
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable(self::TABLE);
|
||||||
$exists = $connection->count('uid', self::TABLE, ['model' => $model]) > 0;
|
$exists = $connection->count('uid', self::TABLE, ['model' => $model]) > 0;
|
||||||
if ($exists) {
|
if ($exists) {
|
||||||
$connection->update(self::TABLE, $values, ['model' => $model]);
|
$connection->update(self::TABLE, $values, ['model' => $model]);
|
||||||
|
|||||||
245
packages/vitec/Classes/Import/ProductXlsxReader.php
Normal file
245
packages/vitec/Classes/Import/ProductXlsxReader.php
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Evomedien\Vitec\Import;
|
||||||
|
|
||||||
|
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads one agency-delivered "Product Page Content" workbook into a structured
|
||||||
|
* array the import module can work with.
|
||||||
|
*
|
||||||
|
* The workbook format (verified against the Aligo, Arqa and AV-over-IP&KVM
|
||||||
|
* deliveries of 2026-08): a meta block in the top rows (URL, primary keyword,
|
||||||
|
* intent, SEO approach), a component table whose header row carries "Component"
|
||||||
|
* in column B, and a second sheet "Page SEO Check" with one header and one
|
||||||
|
* value row.
|
||||||
|
*
|
||||||
|
* The component column is a controlled vocabulary and doubles as the page-type
|
||||||
|
* detector: product files carry "Product Introduction", category files carry
|
||||||
|
* "Category Introduction". Category files use the same file format but a
|
||||||
|
* different vocabulary and target a page rather than a record - they are
|
||||||
|
* recognised here and rejected by the import flow, never guessed at.
|
||||||
|
*
|
||||||
|
* Source selectors (what the persisted mapping stores) are stable strings:
|
||||||
|
*
|
||||||
|
* c:<Component>#<n>:<cell> one cell of the n-th occurrence of a component
|
||||||
|
* (cell = title | body | cta)
|
||||||
|
* m:<key> meta block (url, primaryKeyword, intent, seoApproach)
|
||||||
|
* s:<Header> one column of the "Page SEO Check" value row
|
||||||
|
*
|
||||||
|
* They survive across deliveries because the vocabulary is stable - that is
|
||||||
|
* what makes the saved mapping reusable for every next product.
|
||||||
|
*/
|
||||||
|
final class ProductXlsxReader
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Explicitly marked "Internal note - not website copy" in the deliveries;
|
||||||
|
* never offered as an import source.
|
||||||
|
*/
|
||||||
|
private const HIDDEN_COMPONENTS = ['Technical Gap / Validation Note'];
|
||||||
|
|
||||||
|
/** Meta-block labels (column A, lowercased) => meta keys. */
|
||||||
|
private const META_LABELS = [
|
||||||
|
'url' => 'url',
|
||||||
|
'primary keyword' => 'primaryKeyword',
|
||||||
|
'intent' => 'intent',
|
||||||
|
'seo approach' => 'seoApproach',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* pageType: string,
|
||||||
|
* meta: array<string,string>,
|
||||||
|
* components: array<int,array<string,mixed>>,
|
||||||
|
* seo: array<string,string>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function parse(string $filePath): array
|
||||||
|
{
|
||||||
|
$reader = IOFactory::createReaderForFile($filePath);
|
||||||
|
$reader->setReadDataOnly(true);
|
||||||
|
$spreadsheet = $reader->load($filePath);
|
||||||
|
|
||||||
|
$sheet = $spreadsheet->getSheet(0);
|
||||||
|
// Formatting flags off: raw values, no calculated formatting - the
|
||||||
|
// deliveries are pure text.
|
||||||
|
$rows = $sheet->toArray(null, false, false, false);
|
||||||
|
|
||||||
|
// The header row is FOUND, not assumed at row 9: the one whose column B
|
||||||
|
// says "Component".
|
||||||
|
$headerIndex = null;
|
||||||
|
foreach ($rows as $i => $row) {
|
||||||
|
if (trim((string)($row[1] ?? '')) === 'Component') {
|
||||||
|
$headerIndex = $i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$meta = [];
|
||||||
|
$components = [];
|
||||||
|
|
||||||
|
if ($headerIndex !== null) {
|
||||||
|
foreach (array_slice($rows, 0, $headerIndex) as $row) {
|
||||||
|
$label = strtolower(trim((string)($row[0] ?? '')));
|
||||||
|
$value = trim((string)($row[1] ?? ''));
|
||||||
|
if ($label !== '' && $value !== '' && isset(self::META_LABELS[$label])) {
|
||||||
|
$meta[self::META_LABELS[$label]] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$occurrences = [];
|
||||||
|
foreach (array_slice($rows, $headerIndex + 1) as $row) {
|
||||||
|
$component = trim((string)($row[1] ?? ''));
|
||||||
|
if ($component === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$occurrences[$component] = ($occurrences[$component] ?? 0) + 1;
|
||||||
|
$components[] = [
|
||||||
|
'order' => (int)($row[0] ?? 0),
|
||||||
|
'component' => $component,
|
||||||
|
'occurrence' => $occurrences[$component],
|
||||||
|
'title' => trim((string)($row[2] ?? '')),
|
||||||
|
'body' => trim((string)($row[3] ?? '')),
|
||||||
|
'cta' => trim((string)($row[4] ?? '')),
|
||||||
|
'lengthNote' => trim((string)($row[5] ?? '')),
|
||||||
|
'seoNote' => trim((string)($row[6] ?? '')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$seo = [];
|
||||||
|
$seoSheet = $spreadsheet->getSheetByName('Page SEO Check')
|
||||||
|
?? ($spreadsheet->getSheetCount() > 1 ? $spreadsheet->getSheet(1) : null);
|
||||||
|
if ($seoSheet !== null) {
|
||||||
|
$seoRows = $seoSheet->toArray(null, false, false, false);
|
||||||
|
$headers = $seoRows[0] ?? [];
|
||||||
|
$values = $seoRows[1] ?? [];
|
||||||
|
foreach ($headers as $i => $header) {
|
||||||
|
$header = trim((string)$header);
|
||||||
|
$value = trim((string)($values[$i] ?? ''));
|
||||||
|
if ($header !== '' && $value !== '') {
|
||||||
|
$seo[$header] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$spreadsheet->disconnectWorksheets();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'pageType' => $this->detectPageType($components),
|
||||||
|
'meta' => $meta,
|
||||||
|
'components' => $components,
|
||||||
|
'seo' => $seo,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The selectable sources of a parsed workbook, grouped for the dropdown.
|
||||||
|
* Every entry: selector, label, value (full) and preview (shortened).
|
||||||
|
*
|
||||||
|
* @param array<string,mixed> $parsed
|
||||||
|
* @return array<string,array<int,array{selector:string,label:string,value:string,preview:string}>>
|
||||||
|
*/
|
||||||
|
public function sources(array $parsed): array
|
||||||
|
{
|
||||||
|
$groups = ['Meta' => [], 'Components' => [], 'Page SEO Check' => []];
|
||||||
|
|
||||||
|
foreach ($parsed['meta'] ?? [] as $key => $value) {
|
||||||
|
$groups['Meta'][] = $this->entry('m:' . $key, 'Meta · ' . $key, (string)$value);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($parsed['components'] ?? [] as $component) {
|
||||||
|
$name = (string)$component['component'];
|
||||||
|
if (in_array($name, self::HIDDEN_COMPONENTS, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$suffix = ((int)$component['occurrence']) > 1 || $this->occursMultipleTimes($parsed, $name)
|
||||||
|
? ' #' . $component['occurrence']
|
||||||
|
: '';
|
||||||
|
foreach (['title' => 'Title', 'body' => 'Body', 'cta' => 'CTA'] as $cell => $cellLabel) {
|
||||||
|
$value = (string)($component[$cell] ?? '');
|
||||||
|
if ($value === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$groups['Components'][] = $this->entry(
|
||||||
|
'c:' . $name . '#' . $component['occurrence'] . ':' . $cell,
|
||||||
|
$name . $suffix . ' · ' . $cellLabel,
|
||||||
|
$value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($parsed['seo'] ?? [] as $header => $value) {
|
||||||
|
$groups['Page SEO Check'][] = $this->entry('s:' . $header, 'SEO · ' . $header, (string)$value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_filter($groups, static fn(array $g): bool => $g !== []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves one stored selector against a parsed workbook. null = the
|
||||||
|
* selector points at nothing in THIS file (component missing or cell
|
||||||
|
* empty) - the caller shows that instead of silently writing ''.
|
||||||
|
*
|
||||||
|
* @param array<string,mixed> $parsed
|
||||||
|
*/
|
||||||
|
public function valueFor(array $parsed, string $selector): ?string
|
||||||
|
{
|
||||||
|
if (str_starts_with($selector, 'm:')) {
|
||||||
|
$value = $parsed['meta'][substr($selector, 2)] ?? null;
|
||||||
|
return is_string($value) && $value !== '' ? $value : null;
|
||||||
|
}
|
||||||
|
if (str_starts_with($selector, 's:')) {
|
||||||
|
$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)) {
|
||||||
|
foreach ($parsed['components'] ?? [] as $component) {
|
||||||
|
if ((string)$component['component'] === $m[1] && (int)$component['occurrence'] === (int)$m[2]) {
|
||||||
|
$value = (string)($component[$m[3]] ?? '');
|
||||||
|
return $value !== '' ? $value : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<int,array<string,mixed>> $components */
|
||||||
|
private function detectPageType(array $components): string
|
||||||
|
{
|
||||||
|
$names = array_column($components, 'component');
|
||||||
|
if (in_array('Product Introduction', $names, true)) {
|
||||||
|
return 'product';
|
||||||
|
}
|
||||||
|
if (in_array('Category Introduction', $names, true)) {
|
||||||
|
return 'category';
|
||||||
|
}
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string,mixed> $parsed */
|
||||||
|
private function occursMultipleTimes(array $parsed, string $name): bool
|
||||||
|
{
|
||||||
|
$count = 0;
|
||||||
|
foreach ($parsed['components'] ?? [] as $component) {
|
||||||
|
if ((string)$component['component'] === $name && ++$count > 1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{selector:string,label:string,value:string,preview:string} */
|
||||||
|
private function entry(string $selector, string $label, string $value): array
|
||||||
|
{
|
||||||
|
$flat = preg_replace('/\s+/u', ' ', $value) ?? $value;
|
||||||
|
return [
|
||||||
|
'selector' => $selector,
|
||||||
|
'label' => $label,
|
||||||
|
'value' => $value,
|
||||||
|
'preview' => mb_strlen($flat) > 60 ? mb_substr($flat, 0, 57) . '…' : $flat,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
56
packages/vitec/Classes/Middleware/DownloadFileMiddleware.php
Normal file
56
packages/vitec/Classes/Middleware/DownloadFileMiddleware.php
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Evomedien\Vitec\Middleware;
|
||||||
|
|
||||||
|
use Evomedien\Vitec\Service\DownloadFileResolver;
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
use Psr\Http\Server\MiddlewareInterface;
|
||||||
|
use Psr\Http\Server\RequestHandlerInterface;
|
||||||
|
use TYPO3\CMS\Core\Http\Response;
|
||||||
|
use TYPO3\CMS\Core\Http\Stream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forced file download for download records: /download/file/<uid>
|
||||||
|
*
|
||||||
|
* This is the frontend target of the "Download" record links from the link
|
||||||
|
* browser (config.recordLinks.download builds exactly this path). A direct
|
||||||
|
* fileadmin URL would open PDFs inline; this endpoint streams the file with
|
||||||
|
* Content-Disposition: attachment, so the browser saves it.
|
||||||
|
*
|
||||||
|
* The file lookup lives in Service\DownloadFileResolver (FAL -> Collateral
|
||||||
|
* naming convention -> filepath column). Unknown uid, hidden record or
|
||||||
|
* missing file fall through to the regular pipeline - the reply is then the
|
||||||
|
* normal 404 page, never a broken download.
|
||||||
|
*/
|
||||||
|
final class DownloadFileMiddleware implements MiddlewareInterface
|
||||||
|
{
|
||||||
|
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$path = $request->getUri()->getPath();
|
||||||
|
if (preg_match('#^/download/file/(\\d+)/?$#', $path, $matches) === 1) {
|
||||||
|
$file = DownloadFileResolver::resolve((int)$matches[1]);
|
||||||
|
if ($file !== null) {
|
||||||
|
$filename = str_replace(['"', "\r", "\n"], '', $file['name']);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
new Stream($file['path'], 'rb'),
|
||||||
|
200,
|
||||||
|
[
|
||||||
|
'Content-Type' => $file['mimeType'] !== '' ? $file['mimeType'] : 'application/octet-stream',
|
||||||
|
'Content-Length' => (string)$file['size'],
|
||||||
|
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// fall through to the regular pipeline
|
||||||
|
}
|
||||||
|
|
||||||
|
return $handler->handle($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,8 +5,16 @@ declare(strict_types=1);
|
|||||||
namespace Evomedien\Vitec\PageTitle;
|
namespace Evomedien\Vitec\PageTitle;
|
||||||
|
|
||||||
use TYPO3\CMS\Core\PageTitle\AbstractPageTitleProvider;
|
use TYPO3\CMS\Core\PageTitle\AbstractPageTitleProvider;
|
||||||
|
use TYPO3\CMS\Core\SingletonInterface;
|
||||||
|
|
||||||
final class ProductPageTitleProvider extends AbstractPageTitleProvider
|
/**
|
||||||
|
* Singleton on purpose: whoever sets the title (ProductShowJsonRenderer in
|
||||||
|
* headless mode, ProductController in classic mode) and the
|
||||||
|
* PageTitleProviderManager that later reads it both obtain the provider via
|
||||||
|
* GeneralUtility::makeInstance(). Without the singleton those are two
|
||||||
|
* different instances and the title set here is never seen by the manager.
|
||||||
|
*/
|
||||||
|
final class ProductPageTitleProvider extends AbstractPageTitleProvider implements SingletonInterface
|
||||||
{
|
{
|
||||||
private string $seotitle = '';
|
private string $seotitle = '';
|
||||||
|
|
||||||
|
|||||||
@@ -22,8 +22,10 @@ use Evomedien\Vitec\UserFunc\CustomerlogosJsonRenderer;
|
|||||||
use Evomedien\Vitec\UserFunc\FormsJsonRenderer;
|
use Evomedien\Vitec\UserFunc\FormsJsonRenderer;
|
||||||
use Evomedien\Vitec\UserFunc\ModelcardJsonRenderer;
|
use Evomedien\Vitec\UserFunc\ModelcardJsonRenderer;
|
||||||
use Evomedien\Vitec\UserFunc\NewsJsonRenderer;
|
use Evomedien\Vitec\UserFunc\NewsJsonRenderer;
|
||||||
|
use Evomedien\Vitec\UserFunc\ContainerBackgroundRenderer;
|
||||||
use TYPO3\CMS\Core\Database\ConnectionPool;
|
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||||
|
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves a TYPO3 typolink string (as stored by `inputLink` fields like
|
* Resolves a TYPO3 typolink string (as stored by `inputLink` fields like
|
||||||
@@ -38,6 +40,12 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
|
|||||||
* just like container children, so a referenced productlist / productshow /
|
* just like container children, so a referenced productlist / productshow /
|
||||||
* usecaselist / usecaseshow appears with its full headless JSON in `data`.
|
* usecaselist / usecaseshow appears with its full headless JSON in `data`.
|
||||||
*
|
*
|
||||||
|
* A linked CONTAINER (vitec_container, vitec_cols_*, vitec_cards_carousel)
|
||||||
|
* additionally carries its resolved `background` and its children under
|
||||||
|
* `items` - the exact shape ContainerChildrenProcessor emits for page-level
|
||||||
|
* containers, so the frontend reuses its container component. Nested
|
||||||
|
* containers recurse, depth-capped and cycle-safe.
|
||||||
|
*
|
||||||
* Exception-safe: every public entry point returns `null` on any failure
|
* Exception-safe: every public entry point returns `null` on any failure
|
||||||
* so the surrounding JSON output stays clean.
|
* so the surrounding JSON output stays clean.
|
||||||
*/
|
*/
|
||||||
@@ -71,6 +79,38 @@ final class ContentElementResolver
|
|||||||
|
|
||||||
private const KEEP_IF_ZERO = ['header_layout'];
|
private const KEEP_IF_ZERO = ['header_layout'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Container-level fields - stripped from CHILD `data` like the page-level
|
||||||
|
* ContainerChildrenProcessor does (they carry non-empty defaults and
|
||||||
|
* belong to the parent). The linked container itself keeps them.
|
||||||
|
*/
|
||||||
|
private const CONTAINER_FIELDS = [
|
||||||
|
'tx_vitec_gap',
|
||||||
|
'tx_vitec_bg_variant', 'tx_vitec_bg_image', 'tx_vitec_bg_size',
|
||||||
|
'tx_vitec_bg_size_percent', 'tx_vitec_bg_position',
|
||||||
|
'tx_vitec_bg_pos_top', 'tx_vitec_bg_pos_bottom',
|
||||||
|
'tx_vitec_bg_pos_left', 'tx_vitec_bg_pos_right',
|
||||||
|
'tx_vitec_col1_align', 'tx_vitec_col1_justify',
|
||||||
|
'tx_vitec_col2_align', 'tx_vitec_col2_justify',
|
||||||
|
'tx_vitec_col3_align', 'tx_vitec_col3_justify',
|
||||||
|
'tx_vitec_col4_align', 'tx_vitec_col4_justify',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per container CType: colPos value => 1-based column number, for the
|
||||||
|
* parent's tx_vitec_col{N}_align/justify - same map as the processor.
|
||||||
|
*/
|
||||||
|
private const COLPOS_TO_COLUMN = [
|
||||||
|
'vitec_cols_50_50' => [211 => 1, 212 => 2],
|
||||||
|
'vitec_cols_33_66' => [251 => 1, 252 => 2],
|
||||||
|
'vitec_cols_66_33' => [241 => 1, 242 => 2],
|
||||||
|
'vitec_cols_33_33_33' => [221 => 1, 222 => 2, 223 => 3],
|
||||||
|
'vitec_cols_25_25_25_25' => [231 => 1, 232 => 2, 233 => 3, 234 => 4],
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Recursion cap for nested containers. */
|
||||||
|
private const MAX_CONTAINER_DEPTH = 5;
|
||||||
|
|
||||||
private const PLUGIN_RENDERERS = [
|
private const PLUGIN_RENDERERS = [
|
||||||
'vitec_productlist' => [ProductListJsonRenderer::class, 'products'],
|
'vitec_productlist' => [ProductListJsonRenderer::class, 'products'],
|
||||||
'vitec_productshow' => [ProductShowJsonRenderer::class, 'product'],
|
'vitec_productshow' => [ProductShowJsonRenderer::class, 'product'],
|
||||||
@@ -143,7 +183,10 @@ final class ContentElementResolver
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return self::normaliseRecord($row);
|
$element = self::normaliseRecord($row);
|
||||||
|
self::attachContainerPayload($row, $element, [$uid => true], 0);
|
||||||
|
|
||||||
|
return $element;
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -156,9 +199,12 @@ final class ContentElementResolver
|
|||||||
* resolved to their headless JSON.
|
* resolved to their headless JSON.
|
||||||
*
|
*
|
||||||
* @param array<string,mixed> $record
|
* @param array<string,mixed> $record
|
||||||
|
* @param bool $isContainerChild strip container-level fields (bg, gap,
|
||||||
|
* col flex) like the page-level processor
|
||||||
|
* does for its children
|
||||||
* @return array<string,mixed>
|
* @return array<string,mixed>
|
||||||
*/
|
*/
|
||||||
public static function normaliseRecord(array $record): array
|
public static function normaliseRecord(array $record, bool $isContainerChild = false): array
|
||||||
{
|
{
|
||||||
$data = [];
|
$data = [];
|
||||||
foreach ($record as $field => $value) {
|
foreach ($record as $field => $value) {
|
||||||
@@ -168,6 +214,9 @@ final class ContentElementResolver
|
|||||||
if (in_array($field, self::SYSTEM_FIELDS, true)) {
|
if (in_array($field, self::SYSTEM_FIELDS, true)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if ($isContainerChild && in_array($field, self::CONTAINER_FIELDS, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (self::isEmpty($value) && !in_array($field, self::KEEP_IF_ZERO, true)) {
|
if (self::isEmpty($value) && !in_array($field, self::KEEP_IF_ZERO, true)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -279,6 +328,107 @@ final class ContentElementResolver
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the element has container children (tx_container_parent), attach the
|
||||||
|
* resolved `background` and the children as `items` in the page-level
|
||||||
|
* shape: [{config: {colPos, align?, justify?}, contentElements: [...]}].
|
||||||
|
* Recurses into nested containers; $visited guards against cycles.
|
||||||
|
*
|
||||||
|
* @param array<string,mixed> $record raw tt_content row
|
||||||
|
* @param array<string,mixed> $element normalised element, modified in place
|
||||||
|
* @param array<int,bool> $visited uids already on this path
|
||||||
|
*/
|
||||||
|
private static function attachContainerPayload(array $record, array &$element, array $visited, int $depth): void
|
||||||
|
{
|
||||||
|
$background = self::resolveContainerBackground($record);
|
||||||
|
if ($background !== null) {
|
||||||
|
$element['background'] = $background;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($depth >= self::MAX_CONTAINER_DEPTH) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
|
||||||
|
->getQueryBuilderForTable('tt_content');
|
||||||
|
$rows = $qb
|
||||||
|
->select('*')
|
||||||
|
->from('tt_content')
|
||||||
|
->where(
|
||||||
|
$qb->expr()->eq('tx_container_parent', $qb->createNamedParameter((int)$record['uid'], ParameterType::INTEGER)),
|
||||||
|
$qb->expr()->eq('pid', $qb->createNamedParameter((int)($record['pid'] ?? 0), ParameterType::INTEGER)),
|
||||||
|
$qb->expr()->eq('sys_language_uid', $qb->createNamedParameter((int)($record['sys_language_uid'] ?? 0), ParameterType::INTEGER))
|
||||||
|
)
|
||||||
|
->orderBy('colPos')
|
||||||
|
->addOrderBy('sorting')
|
||||||
|
->executeQuery()
|
||||||
|
->fetchAllAssociative();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($rows === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$byColPos = [];
|
||||||
|
foreach ($rows as $childRow) {
|
||||||
|
$childUid = (int)$childRow['uid'];
|
||||||
|
$child = self::normaliseRecord($childRow, true);
|
||||||
|
if (!isset($visited[$childUid])) {
|
||||||
|
$childVisited = $visited;
|
||||||
|
$childVisited[$childUid] = true;
|
||||||
|
self::attachContainerPayload($childRow, $child, $childVisited, $depth + 1);
|
||||||
|
}
|
||||||
|
$byColPos[(int)$childRow['colPos']][] = $child;
|
||||||
|
}
|
||||||
|
ksort($byColPos);
|
||||||
|
|
||||||
|
$colMap = self::COLPOS_TO_COLUMN[(string)($record['CType'] ?? '')] ?? [];
|
||||||
|
$items = [];
|
||||||
|
foreach ($byColPos as $colPos => $contentElements) {
|
||||||
|
$config = ['colPos' => $colPos];
|
||||||
|
$columnNo = $colMap[$colPos] ?? null;
|
||||||
|
if ($columnNo !== null) {
|
||||||
|
$config['align'] = (string)($record['tx_vitec_col' . $columnNo . '_align'] ?? 'stretch');
|
||||||
|
$config['justify'] = (string)($record['tx_vitec_col' . $columnNo . '_justify'] ?? 'flex-start');
|
||||||
|
}
|
||||||
|
$items[] = [
|
||||||
|
'config' => $config,
|
||||||
|
'contentElements' => $contentElements,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$element['items'] = $items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolved {image, size, position} of a container row - the same payload
|
||||||
|
* ContainerBackgroundRenderer emits in the page JSON. Null without image.
|
||||||
|
*
|
||||||
|
* @param array<string,mixed> $record
|
||||||
|
* @return array<string,mixed>|null
|
||||||
|
*/
|
||||||
|
private static function resolveContainerBackground(array $record): ?array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$cObj = GeneralUtility::makeInstance(ContentObjectRenderer::class);
|
||||||
|
$cObj->start($record, 'tt_content');
|
||||||
|
$renderer = GeneralUtility::makeInstance(ContainerBackgroundRenderer::class);
|
||||||
|
$renderer->setContentObjectRenderer($cObj);
|
||||||
|
$json = $renderer->render('', []);
|
||||||
|
if ($json === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$decoded = json_decode($json, true);
|
||||||
|
|
||||||
|
return is_array($decoded) ? $decoded : null;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static function isEmpty(mixed $value): bool
|
private static function isEmpty(mixed $value): bool
|
||||||
{
|
{
|
||||||
return $value === null || $value === '' || $value === 0 || $value === '0';
|
return $value === null || $value === '' || $value === 0 || $value === '0';
|
||||||
|
|||||||
226
packages/vitec/Classes/Service/DownloadFileResolver.php
Normal file
226
packages/vitec/Classes/Service/DownloadFileResolver.php
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Evomedien\Vitec\Service;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\ParameterType;
|
||||||
|
use TYPO3\CMS\Core\Core\Environment;
|
||||||
|
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||||
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a download record to its file on disk.
|
||||||
|
*
|
||||||
|
* Resolution order is the one the three download renderers use for their
|
||||||
|
* `file` payload - FAL reference, then the Collateral naming convention,
|
||||||
|
* then the filepath column - so a record link and the JSON always point at
|
||||||
|
* the same file. The renderers still carry private copies of this logic for
|
||||||
|
* their richer payload (thumbnail, title, url); consolidating them onto this
|
||||||
|
* service is the standing 10.2 goal (Annex B-4).
|
||||||
|
*
|
||||||
|
* Contract: null means "no file". Never throws.
|
||||||
|
*/
|
||||||
|
final class DownloadFileResolver
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array{path: string, name: string, size: int, mimeType: string}|null
|
||||||
|
*/
|
||||||
|
public static function resolve(int $downloadUid): ?array
|
||||||
|
{
|
||||||
|
if ($downloadUid <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$download = self::loadDownload($downloadUid);
|
||||||
|
if ($download === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = self::resolveByFal($downloadUid)
|
||||||
|
?? self::resolveByConvention(
|
||||||
|
(string)($download['fileprefix'] ?? ''),
|
||||||
|
self::fileType($downloadUid)
|
||||||
|
)
|
||||||
|
?? self::resolveByFilepath((string)($download['filepath'] ?? ''));
|
||||||
|
|
||||||
|
return $path !== null ? self::describe($path) : null;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string,mixed>|null */
|
||||||
|
private static function loadDownload(int $uid): ?array
|
||||||
|
{
|
||||||
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
|
||||||
|
->getQueryBuilderForTable('tx_vitec_domain_model_download');
|
||||||
|
$row = $qb
|
||||||
|
->select('uid', 'fileprefix', 'filepath')
|
||||||
|
->from('tx_vitec_domain_model_download')
|
||||||
|
->where(
|
||||||
|
$qb->expr()->eq('uid', $qb->createNamedParameter($uid, ParameterType::INTEGER)),
|
||||||
|
$qb->expr()->eq('deleted', 0),
|
||||||
|
$qb->expr()->eq('hidden', 0)
|
||||||
|
)
|
||||||
|
->setMaxResults(1)
|
||||||
|
->executeQuery()
|
||||||
|
->fetchAssociative();
|
||||||
|
|
||||||
|
return $row ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function resolveByFal(int $downloadUid): ?string
|
||||||
|
{
|
||||||
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
|
||||||
|
->getQueryBuilderForTable('sys_file_reference');
|
||||||
|
$row = $qb
|
||||||
|
->select('f.identifier')
|
||||||
|
->from('sys_file_reference', 'fr')
|
||||||
|
->join('fr', 'sys_file', 'f', 'fr.uid_local = f.uid')
|
||||||
|
->where(
|
||||||
|
$qb->expr()->eq('fr.tablenames', $qb->createNamedParameter('tx_vitec_domain_model_download', ParameterType::STRING)),
|
||||||
|
$qb->expr()->eq('fr.fieldname', $qb->createNamedParameter('file', ParameterType::STRING)),
|
||||||
|
$qb->expr()->eq('fr.uid_foreign', $qb->createNamedParameter($downloadUid, ParameterType::INTEGER)),
|
||||||
|
$qb->expr()->eq('fr.deleted', 0),
|
||||||
|
$qb->expr()->eq('f.missing', 0)
|
||||||
|
)
|
||||||
|
->orderBy('fr.sorting_foreign', 'ASC')
|
||||||
|
->setMaxResults(1)
|
||||||
|
->executeQuery()
|
||||||
|
->fetchAssociative();
|
||||||
|
|
||||||
|
$identifier = (string)($row['identifier'] ?? '');
|
||||||
|
if ($identifier === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = Environment::getPublicPath() . '/fileadmin' . $identifier;
|
||||||
|
|
||||||
|
return is_file($path) && is_readable($path) ? $path : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Highest revision of <prefix>__<filetype>__<NN>-<letters>.<ext> in the
|
||||||
|
* Collateral directory - same pattern and ranking as the renderers.
|
||||||
|
*/
|
||||||
|
private static function resolveByConvention(string $fileprefix, string $filetype): ?string
|
||||||
|
{
|
||||||
|
$fileprefix = trim($fileprefix);
|
||||||
|
$filetype = trim($filetype);
|
||||||
|
if ($fileprefix === '' || $filetype === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$baseDir = Environment::getPublicPath() . '/fileadmin/downloads/Collateral';
|
||||||
|
if (!is_dir($baseDir) || !is_readable($baseDir)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pattern = '/^' . preg_quote($fileprefix, '/') . '__' . preg_quote($filetype, '/')
|
||||||
|
. '__(\\d+)-([A-Za-z]+)\\.([A-Za-z0-9]+)$/';
|
||||||
|
|
||||||
|
$entries = scandir($baseDir);
|
||||||
|
if ($entries === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$bestFile = null;
|
||||||
|
$bestNumber = -1;
|
||||||
|
$bestLetterRank = -1;
|
||||||
|
foreach ($entries as $entry) {
|
||||||
|
if (!is_string($entry) || !preg_match($pattern, $entry, $matches)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$number = (int)$matches[1];
|
||||||
|
$letterRank = self::letterSequenceToRank((string)$matches[2]);
|
||||||
|
if ($number > $bestNumber || ($number === $bestNumber && $letterRank > $bestLetterRank)) {
|
||||||
|
$bestNumber = $number;
|
||||||
|
$bestLetterRank = $letterRank;
|
||||||
|
$bestFile = $entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($bestFile === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = $baseDir . '/' . $bestFile;
|
||||||
|
|
||||||
|
return is_file($path) && is_readable($path) ? $path : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function resolveByFilepath(string $filepath): ?string
|
||||||
|
{
|
||||||
|
$filepath = trim($filepath);
|
||||||
|
if ($filepath === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = Environment::getPublicPath() . '/' . ltrim($filepath, '/');
|
||||||
|
|
||||||
|
return is_file($path) && is_readable($path) ? $path : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filetype segment from the assigned filetype category (parent 4). */
|
||||||
|
private static function fileType(int $downloadUid): string
|
||||||
|
{
|
||||||
|
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
|
||||||
|
->getQueryBuilderForTable('sys_category');
|
||||||
|
$categories = $qb
|
||||||
|
->select('c.filetype')
|
||||||
|
->from('sys_category', 'c')
|
||||||
|
->join(
|
||||||
|
'c',
|
||||||
|
'sys_category_record_mm',
|
||||||
|
'mm',
|
||||||
|
'mm.uid_local = c.uid AND mm.tablenames = ' .
|
||||||
|
$qb->createNamedParameter('tx_vitec_domain_model_download', ParameterType::STRING) .
|
||||||
|
' AND mm.fieldname = ' .
|
||||||
|
$qb->createNamedParameter('categories', ParameterType::STRING)
|
||||||
|
)
|
||||||
|
->where(
|
||||||
|
$qb->expr()->eq('mm.uid_foreign', $qb->createNamedParameter($downloadUid, ParameterType::INTEGER)),
|
||||||
|
$qb->expr()->eq('c.deleted', 0),
|
||||||
|
$qb->expr()->eq('c.hidden', 0),
|
||||||
|
$qb->expr()->eq('c.parent', $qb->createNamedParameter(4, ParameterType::INTEGER))
|
||||||
|
)
|
||||||
|
->orderBy('mm.sorting', 'ASC')
|
||||||
|
->executeQuery()
|
||||||
|
->fetchAllAssociative();
|
||||||
|
|
||||||
|
foreach ($categories as $category) {
|
||||||
|
$filetype = trim((string)($category['filetype'] ?? ''));
|
||||||
|
if ($filetype !== '') {
|
||||||
|
return $filetype;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "A" = 1 ... "Z" = 26, "AA" = 27 - revision letters as a rank. */
|
||||||
|
private static function letterSequenceToRank(string $letters): int
|
||||||
|
{
|
||||||
|
$rank = 0;
|
||||||
|
foreach (str_split(strtoupper($letters)) as $char) {
|
||||||
|
$rank = $rank * 26 + (ord($char) - 64);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rank;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{path: string, name: string, size: int, mimeType: string} */
|
||||||
|
private static function describe(string $path): array
|
||||||
|
{
|
||||||
|
$mimeType = function_exists('mime_content_type') ? (string)(mime_content_type($path) ?: '') : '';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'path' => $path,
|
||||||
|
'name' => basename($path),
|
||||||
|
'size' => (int)(filesize($path) ?: 0),
|
||||||
|
'mimeType' => $mimeType,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,4 +45,26 @@ final class LinkResolver
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a full typolink parameter (t3://page..., t3://file..., an
|
||||||
|
* external URL, mailto:...) to a URL. Same contract as pageUrl():
|
||||||
|
* null means "no link", and a failure never throws.
|
||||||
|
*/
|
||||||
|
public static function typolinkUrl(string $parameter): ?string
|
||||||
|
{
|
||||||
|
$parameter = trim($parameter);
|
||||||
|
if ($parameter === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$cObj = GeneralUtility::makeInstance(ContentObjectRenderer::class);
|
||||||
|
$url = $cObj->typoLink_URL(['parameter' => $parameter]);
|
||||||
|
|
||||||
|
return $url !== '' ? $url : null;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,11 +205,15 @@ class ProductListJsonRenderer
|
|||||||
'subtitle' => (string)($product['subtitle'] ?? ''),
|
'subtitle' => (string)($product['subtitle'] ?? ''),
|
||||||
'video' => (string)($product['video'] ?? ''),
|
'video' => (string)($product['video'] ?? ''),
|
||||||
'applications' => RteResolver::html($product['applications'] ?? ''),
|
'applications' => RteResolver::html($product['applications'] ?? ''),
|
||||||
|
'capabilities' => RteResolver::html($product['capabilities'] ?? ''),
|
||||||
'description' => RteResolver::html($product['description'] ?? ''),
|
'description' => RteResolver::html($product['description'] ?? ''),
|
||||||
|
'description2' => RteResolver::html($product['description2'] ?? ''),
|
||||||
'highlights' => RteResolver::html($product['highlights'] ?? ''),
|
'highlights' => RteResolver::html($product['highlights'] ?? ''),
|
||||||
|
'textrelatedproducts' => RteResolver::html($product['textrelatedproducts'] ?? ''),
|
||||||
'shortcutpid' => (string)($product['shortcutpid'] ?? ''),
|
'shortcutpid' => (string)($product['shortcutpid'] ?? ''),
|
||||||
'contentelement' => \Evomedien\Vitec\Service\ContentElementResolver::resolveLink((string)($product['contentelement'] ?? '')),
|
'contentelement' => \Evomedien\Vitec\Service\ContentElementResolver::resolveLink((string)($product['contentelement'] ?? '')),
|
||||||
'contentelementcta' => \Evomedien\Vitec\Service\ContentElementResolver::resolveLink((string)($product['contentelementcta'] ?? '')),
|
'contentelementcta' => \Evomedien\Vitec\Service\ContentElementResolver::resolveLink((string)($product['contentelementcta'] ?? '')),
|
||||||
|
'portfolio' => \Evomedien\Vitec\Service\LinkResolver::typolinkUrl((string)($product['portfolio'] ?? '')),
|
||||||
|
|
||||||
'hideonapp' => (bool)($product['hideonapp'] ?? false),
|
'hideonapp' => (bool)($product['hideonapp'] ?? false),
|
||||||
'hideonwebsite' => (bool)($product['hideonwebsite'] ?? false),
|
'hideonwebsite' => (bool)($product['hideonwebsite'] ?? false),
|
||||||
|
|||||||
@@ -164,6 +164,19 @@ class ProductShowJsonRenderer
|
|||||||
: '';
|
: '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Page <title> for the detail page: the product's seotitle, falling
|
||||||
|
// back to its title. Fed from here because in headless mode the
|
||||||
|
// Extbase showAction (which used to do this) never runs; headless's
|
||||||
|
// MetaHandler reads the provider chain when composing `seo`.
|
||||||
|
$pageTitle = trim((string)($product['seotitle'] ?? ''));
|
||||||
|
if ($pageTitle === '') {
|
||||||
|
$pageTitle = trim((string)($product['title'] ?? ''));
|
||||||
|
}
|
||||||
|
if ($pageTitle !== '') {
|
||||||
|
GeneralUtility::makeInstance(\Evomedien\Vitec\PageTitle\ProductPageTitleProvider::class)
|
||||||
|
->setSeoTitle($pageTitle);
|
||||||
|
}
|
||||||
|
|
||||||
$response = [
|
$response = [
|
||||||
'product' => $this->serializeProduct($product),
|
'product' => $this->serializeProduct($product),
|
||||||
'layout' => $layout,
|
'layout' => $layout,
|
||||||
@@ -210,11 +223,15 @@ class ProductShowJsonRenderer
|
|||||||
'subtitle' => (string)($product['subtitle'] ?? ''),
|
'subtitle' => (string)($product['subtitle'] ?? ''),
|
||||||
'video' => (string)($product['video'] ?? ''),
|
'video' => (string)($product['video'] ?? ''),
|
||||||
'applications' => RteResolver::html($product['applications'] ?? ''),
|
'applications' => RteResolver::html($product['applications'] ?? ''),
|
||||||
|
'capabilities' => RteResolver::html($product['capabilities'] ?? ''),
|
||||||
'description' => RteResolver::html($product['description'] ?? ''),
|
'description' => RteResolver::html($product['description'] ?? ''),
|
||||||
|
'description2' => RteResolver::html($product['description2'] ?? ''),
|
||||||
'highlights' => RteResolver::html($product['highlights'] ?? ''),
|
'highlights' => RteResolver::html($product['highlights'] ?? ''),
|
||||||
|
'textrelatedproducts' => RteResolver::html($product['textrelatedproducts'] ?? ''),
|
||||||
'shortcutpid' => (string)($product['shortcutpid'] ?? ''),
|
'shortcutpid' => (string)($product['shortcutpid'] ?? ''),
|
||||||
'contentelement' => \Evomedien\Vitec\Service\ContentElementResolver::resolveLink((string)($product['contentelement'] ?? '')),
|
'contentelement' => \Evomedien\Vitec\Service\ContentElementResolver::resolveLink((string)($product['contentelement'] ?? '')),
|
||||||
'contentelementcta' => \Evomedien\Vitec\Service\ContentElementResolver::resolveLink((string)($product['contentelementcta'] ?? '')),
|
'contentelementcta' => \Evomedien\Vitec\Service\ContentElementResolver::resolveLink((string)($product['contentelementcta'] ?? '')),
|
||||||
|
'portfolio' => \Evomedien\Vitec\Service\LinkResolver::typolinkUrl((string)($product['portfolio'] ?? '')),
|
||||||
|
|
||||||
'hideonapp' => (bool)($product['hideonapp'] ?? false),
|
'hideonapp' => (bool)($product['hideonapp'] ?? false),
|
||||||
'hideonwebsite' => (bool)($product['hideonwebsite'] ?? false),
|
'hideonwebsite' => (bool)($product['hideonwebsite'] ?? false),
|
||||||
@@ -230,6 +247,7 @@ class ProductShowJsonRenderer
|
|||||||
|
|
||||||
'categories' => $this->getProductCategories($uid),
|
'categories' => $this->getProductCategories($uid),
|
||||||
'images' => $this->getProductImages($uid),
|
'images' => $this->getProductImages($uid),
|
||||||
|
'heroimage' => $this->getProductImages($uid, 'heroimage'),
|
||||||
'downloads' => $this->getProductDownloads($uid),
|
'downloads' => $this->getProductDownloads($uid),
|
||||||
'ogimage' => $this->getProductOgImage($uid),
|
'ogimage' => $this->getProductOgImage($uid),
|
||||||
'videofile' => $this->getProductVideoFile($uid),
|
'videofile' => $this->getProductVideoFile($uid),
|
||||||
@@ -310,7 +328,7 @@ class ProductShowJsonRenderer
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function getProductImages(int $productUid): array
|
protected function getProductImages(int $productUid, string $fieldName = 'productimage'): array
|
||||||
{
|
{
|
||||||
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
|
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
|
||||||
->getQueryBuilderForTable('sys_file_reference');
|
->getQueryBuilderForTable('sys_file_reference');
|
||||||
@@ -321,7 +339,7 @@ class ProductShowJsonRenderer
|
|||||||
->where(
|
->where(
|
||||||
$queryBuilder->expr()->eq('uid_foreign', $queryBuilder->createNamedParameter($productUid, ParameterType::INTEGER)),
|
$queryBuilder->expr()->eq('uid_foreign', $queryBuilder->createNamedParameter($productUid, ParameterType::INTEGER)),
|
||||||
$queryBuilder->expr()->eq('tablenames', $queryBuilder->createNamedParameter('tx_vitec_domain_model_product', ParameterType::STRING)),
|
$queryBuilder->expr()->eq('tablenames', $queryBuilder->createNamedParameter('tx_vitec_domain_model_product', ParameterType::STRING)),
|
||||||
$queryBuilder->expr()->eq('fieldname', $queryBuilder->createNamedParameter('productimage', ParameterType::STRING)),
|
$queryBuilder->expr()->eq('fieldname', $queryBuilder->createNamedParameter($fieldName, ParameterType::STRING)),
|
||||||
$queryBuilder->expr()->eq('deleted', 0),
|
$queryBuilder->expr()->eq('deleted', 0),
|
||||||
$queryBuilder->expr()->eq('hidden', 0)
|
$queryBuilder->expr()->eq('hidden', 0)
|
||||||
)
|
)
|
||||||
@@ -805,6 +823,7 @@ class ProductShowJsonRenderer
|
|||||||
|
|
||||||
$related = $queryBuilder
|
$related = $queryBuilder
|
||||||
->select('p.uid', 'p.title', 'p.slug', 'p.subtitle', 'p.teaser', 'p.description')
|
->select('p.uid', 'p.title', 'p.slug', 'p.subtitle', 'p.teaser', 'p.description')
|
||||||
|
->addSelectLiteral('t.cardtext')
|
||||||
->from('tx_vitec_domain_model_product', 'p')
|
->from('tx_vitec_domain_model_product', 'p')
|
||||||
->join(
|
->join(
|
||||||
'p',
|
'p',
|
||||||
@@ -812,6 +831,16 @@ class ProductShowJsonRenderer
|
|||||||
'mm',
|
'mm',
|
||||||
'mm.uid_foreign = p.uid'
|
'mm.uid_foreign = p.uid'
|
||||||
)
|
)
|
||||||
|
// Per-pair card copy from the workbook import. A separate table
|
||||||
|
// rather than an MM column, because DataHandler rewrites the MM
|
||||||
|
// rows on every product save - see tx_vitec_product_related_text
|
||||||
|
// in ext_tables.sql.
|
||||||
|
->leftJoin(
|
||||||
|
'mm',
|
||||||
|
'tx_vitec_product_related_text',
|
||||||
|
't',
|
||||||
|
't.product_uid = mm.uid_local AND t.related_uid = p.uid'
|
||||||
|
)
|
||||||
->where(
|
->where(
|
||||||
$queryBuilder->expr()->eq('mm.uid_local', $queryBuilder->createNamedParameter($productUid, ParameterType::INTEGER)),
|
$queryBuilder->expr()->eq('mm.uid_local', $queryBuilder->createNamedParameter($productUid, ParameterType::INTEGER)),
|
||||||
$queryBuilder->expr()->eq('p.deleted', 0),
|
$queryBuilder->expr()->eq('p.deleted', 0),
|
||||||
@@ -831,6 +860,7 @@ class ProductShowJsonRenderer
|
|||||||
'subtitle' => (string)($rel['subtitle'] ?? ''),
|
'subtitle' => (string)($rel['subtitle'] ?? ''),
|
||||||
'teaser' => (string)($rel['teaser'] ?? ''),
|
'teaser' => (string)($rel['teaser'] ?? ''),
|
||||||
'description' => RteResolver::html($rel['description'] ?? ''),
|
'description' => RteResolver::html($rel['description'] ?? ''),
|
||||||
|
'cardtext' => (string)($rel['cardtext'] ?? ''),
|
||||||
'link' => '/product/' . (string)($rel['slug'] ?? ''),
|
'link' => '/product/' . (string)($rel['slug'] ?? ''),
|
||||||
'images' => $this->getProductImages($relUid),
|
'images' => $this->getProductImages($relUid),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use Evomedien\Vitec\Controller\Backend\ImportController;
|
use Evomedien\Vitec\Controller\Backend\ImportController;
|
||||||
|
use Evomedien\Vitec\Controller\Backend\ProductTextImportController;
|
||||||
use Evomedien\Vitec\Controller\Backend\WordPressImportController;
|
use Evomedien\Vitec\Controller\Backend\WordPressImportController;
|
||||||
use Evomedien\Vitec\Controller\OgImageController;
|
use Evomedien\Vitec\Controller\OgImageController;
|
||||||
|
|
||||||
@@ -34,6 +35,20 @@ return [
|
|||||||
'target' => ImportController::class . '::seoUploadAction',
|
'target' => ImportController::class . '::seoUploadAction',
|
||||||
'methods' => ['POST'],
|
'methods' => ['POST'],
|
||||||
],
|
],
|
||||||
|
'products' => [
|
||||||
|
'target' => ProductTextImportController::class . '::productsAction',
|
||||||
|
],
|
||||||
|
'product_edit' => [
|
||||||
|
'target' => ProductTextImportController::class . '::productEditAction',
|
||||||
|
],
|
||||||
|
'product_preview' => [
|
||||||
|
'target' => ProductTextImportController::class . '::productPreviewAction',
|
||||||
|
'methods' => ['POST'],
|
||||||
|
],
|
||||||
|
'product_apply' => [
|
||||||
|
'target' => ProductTextImportController::class . '::productApplyAction',
|
||||||
|
'methods' => ['POST'],
|
||||||
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
'web_vitecwpimport' => [
|
'web_vitecwpimport' => [
|
||||||
|
|||||||
@@ -25,5 +25,14 @@ return [
|
|||||||
'typo3/cms-frontend/site',
|
'typo3/cms-frontend/site',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
'vitec/download-file' => [
|
||||||
|
'target' => \Evomedien\Vitec\Middleware\DownloadFileMiddleware::class,
|
||||||
|
'after' => [
|
||||||
|
'typo3/cms-core/normalized-params-attribute',
|
||||||
|
],
|
||||||
|
'before' => [
|
||||||
|
'typo3/cms-frontend/site',
|
||||||
|
],
|
||||||
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -252,3 +252,29 @@ tt_content {
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
page.10.fields.jsonLd = USER
|
page.10.fields.jsonLd = USER
|
||||||
page.10.fields.jsonLd.userFunc = Evomedien\Vitec\UserFunc\PageJsonLdRenderer->render
|
page.10.fields.jsonLd.userFunc = Evomedien\Vitec\UserFunc\PageJsonLdRenderer->render
|
||||||
|
|
||||||
|
############################################################
|
||||||
|
# Frontend resolution for the record links from the link
|
||||||
|
# browser (page.tsconfig TCEMAIN.linkHandler):
|
||||||
|
# download -> forced download endpoint (DownloadFileMiddleware)
|
||||||
|
# product -> detail view, same /product/<slug> convention as
|
||||||
|
# the `link` field in the product JSON (page 10)
|
||||||
|
# A hidden or deleted target makes the typolink fail -> null.
|
||||||
|
############################################################
|
||||||
|
|
||||||
|
config.recordLinks {
|
||||||
|
download {
|
||||||
|
forceLink = 1
|
||||||
|
typolink {
|
||||||
|
parameter.cObject = TEXT
|
||||||
|
parameter.cObject.dataWrap = /download/file/{field:uid}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
product {
|
||||||
|
forceLink = 1
|
||||||
|
typolink {
|
||||||
|
parameter.cObject = TEXT
|
||||||
|
parameter.cObject.dataWrap = /product/{field:slug}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,12 +23,12 @@ return [
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
'types' => [
|
'types' => [
|
||||||
'1' => ['showitem' => 'title, subtitle, slug, teaser, description, applications, highlights, contentelement, contentelementcta, downloads, showdatapath,
|
'1' => ['showitem' => 'title, subtitle, slug, teaser, description, description2, applications, capabilities, highlights, contentelement, contentelementcta, downloads, portfolio, showdatapath, textrelatedproducts, relatedprodukt,
|
||||||
--div--;SEO, seotitle, urltitle, seometa, keywords, structureddata,
|
--div--;SEO, seotitle, urltitle, seometa, keywords, structureddata,
|
||||||
--div--;Images and Videos, productimage, ogimage, video, videofile,
|
--div--;Images and Videos, heroimage, productimage, ogimage, video, videofile,
|
||||||
--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:categories, categories,
|
--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:categories, categories,
|
||||||
--div--;Visibility, hideonapp, hideonwebsite, hideondatasheets, hideonproducts, shortcut, shortcutpid,
|
--div--;Visibility, hideonapp, hideonwebsite, hideondatasheets, hideonproducts, shortcut, shortcutpid,
|
||||||
--div--;Misc, legacy, supportproduct, subproduct, relatedprodukt,
|
--div--;Misc, legacy, supportproduct, subproduct,
|
||||||
--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:access, hidden, starttime, endtime'],
|
--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:access, hidden, starttime, endtime'],
|
||||||
],
|
],
|
||||||
'columns' => [
|
'columns' => [
|
||||||
@@ -556,6 +556,88 @@ return [
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'description2' => [
|
||||||
|
'exclude' => true,
|
||||||
|
'label' => '2nd Description',
|
||||||
|
'config' => [
|
||||||
|
'type' => 'text',
|
||||||
|
'enableRichtext' => true,
|
||||||
|
'eval' => 'trim',
|
||||||
|
'default' => ''
|
||||||
|
],
|
||||||
|
'defaultExtras' => 'richtext:rte_transform[mode=ts_css]'
|
||||||
|
],
|
||||||
|
|
||||||
|
'capabilities' => [
|
||||||
|
'exclude' => true,
|
||||||
|
'label' => 'Capabilities',
|
||||||
|
'config' => [
|
||||||
|
'type' => 'text',
|
||||||
|
'enableRichtext' => true,
|
||||||
|
'eval' => 'trim',
|
||||||
|
'default' => ''
|
||||||
|
],
|
||||||
|
'defaultExtras' => 'richtext:rte_transform[mode=ts_css]'
|
||||||
|
],
|
||||||
|
|
||||||
|
'portfolio' => [
|
||||||
|
'exclude' => true,
|
||||||
|
'label' => 'Portfolio',
|
||||||
|
'description' => 'Link to the product portfolio (page, file or external URL)',
|
||||||
|
'config' => [
|
||||||
|
'type' => 'link',
|
||||||
|
'allowedTypes' => ['page', 'url', 'file', 'record'],
|
||||||
|
'size' => 50,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'textrelatedproducts' => [
|
||||||
|
'exclude' => true,
|
||||||
|
'label' => 'Text Related Products',
|
||||||
|
'config' => [
|
||||||
|
'type' => 'text',
|
||||||
|
'enableRichtext' => true,
|
||||||
|
'eval' => 'trim',
|
||||||
|
'default' => ''
|
||||||
|
],
|
||||||
|
'defaultExtras' => 'richtext:rte_transform[mode=ts_css]'
|
||||||
|
],
|
||||||
|
|
||||||
|
'heroimage' => [
|
||||||
|
'exclude' => true,
|
||||||
|
'label' => 'Hero Image(s)',
|
||||||
|
'config' => [
|
||||||
|
'type' => 'inline',
|
||||||
|
'foreign_table' => 'sys_file_reference',
|
||||||
|
'foreign_field' => 'uid_foreign',
|
||||||
|
'foreign_sortby' => 'sorting_foreign',
|
||||||
|
'foreign_table_field' => 'tablenames',
|
||||||
|
'foreign_match_fields' => [
|
||||||
|
'fieldname' => 'heroimage',
|
||||||
|
],
|
||||||
|
'appearance' => [
|
||||||
|
'collapseAll' => true,
|
||||||
|
'levelLinksPosition' => 'top',
|
||||||
|
'showSynchronizationLink' => true,
|
||||||
|
'showPossibleLocalizationRecords' => true,
|
||||||
|
'showAllLocalizationLink' => true,
|
||||||
|
],
|
||||||
|
'behaviour' => [
|
||||||
|
'allowLanguageSynchronization' => true,
|
||||||
|
],
|
||||||
|
'filter' => [
|
||||||
|
[
|
||||||
|
'userFunc' => \TYPO3\CMS\Core\Resource\Filter\FileExtensionFilter::class . '->filterInlineChildren',
|
||||||
|
'parameters' => [
|
||||||
|
'allowedFileExtensions' => 'jpg,jpeg,png,gif,webp',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'maxitems' => 10,
|
||||||
|
'minitems' => 0,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
'contentelement' => [
|
'contentelement' => [
|
||||||
'exclude' => true,
|
'exclude' => true,
|
||||||
'label' => 'Select Content Element for Key Features Section',
|
'label' => 'Select Content Element for Key Features Section',
|
||||||
|
|||||||
@@ -127,3 +127,28 @@ mod.web_layout.tt_content.preview {
|
|||||||
# Hinweis: Content-Blocks-Previews werden automatisch aus
|
# Hinweis: Content-Blocks-Previews werden automatisch aus
|
||||||
# packages/vitec/ContentBlocks/ContentElements/<n>/templates/backend-preview.html geladen.
|
# packages/vitec/ContentBlocks/ContentElements/<n>/templates/backend-preview.html geladen.
|
||||||
# Keine separate Registrierung nötig.
|
# Keine separate Registrierung nötig.
|
||||||
|
|
||||||
|
############################################################
|
||||||
|
# Link browser: record links to VITEC domain models.
|
||||||
|
# Stored as t3://record?identifier=download|product&uid=N -
|
||||||
|
# frontend resolution lives in setup.typoscript (config.recordLinks).
|
||||||
|
############################################################
|
||||||
|
|
||||||
|
TCEMAIN.linkHandler {
|
||||||
|
download {
|
||||||
|
handler = TYPO3\CMS\Backend\LinkHandler\RecordLinkHandler
|
||||||
|
label = Download
|
||||||
|
configuration {
|
||||||
|
table = tx_vitec_domain_model_download
|
||||||
|
}
|
||||||
|
displayAfter = file
|
||||||
|
}
|
||||||
|
product {
|
||||||
|
handler = TYPO3\CMS\Backend\LinkHandler\RecordLinkHandler
|
||||||
|
label = Product
|
||||||
|
configuration {
|
||||||
|
table = tx_vitec_domain_model_product
|
||||||
|
}
|
||||||
|
displayAfter = download
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
| 1.7 | 2026‑08‑06 | **Robustness:** the import normalizes legacy filenames on fetch so every imported file matches the version convention (`__NN_A` → `__NN-A`, `___NN` → `__NN`, `__NNA` → `__NN-A`, bare `__NN` → `__NN-A` as initial revision), and the three download renderers gained a `filepath` fallback in `getDownloadFile()` (FAL → convention → filepath) as a safety net for anything that still escapes it. Extends the B‑4 duplication (three copies of the fallback) — consolidation target remains a shared file‑resolver service (10.2). |
|
| 1.7 | 2026‑08‑06 | **Robustness:** the import normalizes legacy filenames on fetch so every imported file matches the version convention (`__NN_A` → `__NN-A`, `___NN` → `__NN`, `__NNA` → `__NN-A`, bare `__NN` → `__NN-A` as initial revision), and the three download renderers gained a `filepath` fallback in `getDownloadFile()` (FAL → convention → filepath) as a safety net for anything that still escapes it. Extends the B‑4 duplication (three copies of the fallback) — consolidation target remains a shared file‑resolver service (10.2). |
|
||||||
| 1.8 | 2026‑08‑13 | **Defect fix and interface change (additive).** Content Blocks never carried the Core *Appearance* tab: `layout`, `frame_class` — including the VITEC frame classes — `space_before_class`, `space_after_class`, `sectionIndex` and `linkToTop` were unreachable for editors on all nine blocks. Added centrally for every `vitec_*` type (7.7); the `appearance` envelope is unchanged, its values were merely always default. Side effect: those six columns now also appear raw inside `data` on top‑level blocks (B‑13), and `appearance.layout` is represented differently on the two envelope paths (B‑14). `intro-paragraph` gained `background_color` (7.7). `vitec_eventlist` gained the layout `regions`, emitting a `regions` array built from the region categories below parent 104; the event payload is specified for the first time (7.14). B‑11 and B‑12 recorded as resolved. |
|
| 1.8 | 2026‑08‑13 | **Defect fix and interface change (additive).** Content Blocks never carried the Core *Appearance* tab: `layout`, `frame_class` — including the VITEC frame classes — `space_before_class`, `space_after_class`, `sectionIndex` and `linkToTop` were unreachable for editors on all nine blocks. Added centrally for every `vitec_*` type (7.7); the `appearance` envelope is unchanged, its values were merely always default. Side effect: those six columns now also appear raw inside `data` on top‑level blocks (B‑13), and `appearance.layout` is represented differently on the two envelope paths (B‑14). `intro-paragraph` gained `background_color` (7.7). `vitec_eventlist` gained the layout `regions`, emitting a `regions` array built from the region categories below parent 104; the event payload is specified for the first time (7.14). B‑11 and B‑12 recorded as resolved. |
|
||||||
| 1.9 | 2026‑08‑15 | **Interface change, partly breaking.** The four list plugins were unified: every one of them now emits an object carrying `layout`, the new `showToolbar` flag and its payload array. `vitec_usecaselist` and `vitec_productlist` previously emitted a **bare array** — front ends reading them have to move one level down (7.3, 7.14, 8). New plugin **`vitec_solutionlist`** (`SolutionListJsonRenderer`, key `solutions`), the counterpart 7.13.2 had been asking for since v1.2. `tx_vitec_domain_model_usecase` gained `detail_page`, emitted as the resolved `detailUrl` in the story card shape and therefore also in the `vitec_modelcard` story branch, which had carried no link at all until now. Noted: `vitec_productlist` had a configurable `layout` that was never serialised, and its vocabulary (`0`–`3`) differs from the other lists. |
|
| 1.9 | 2026‑08‑15 | **Interface change, partly breaking.** The four list plugins were unified: every one of them now emits an object carrying `layout`, the new `showToolbar` flag and its payload array. `vitec_usecaselist` and `vitec_productlist` previously emitted a **bare array** — front ends reading them have to move one level down (7.3, 7.14, 8). New plugin **`vitec_solutionlist`** (`SolutionListJsonRenderer`, key `solutions`), the counterpart 7.13.2 had been asking for since v1.2. `tx_vitec_domain_model_usecase` gained `detail_page`, emitted as the resolved `detailUrl` in the story card shape and therefore also in the `vitec_modelcard` story branch, which had carried no link at all until now. Noted: `vitec_productlist` had a configurable `layout` that was never serialised, and its vocabulary (`0`–`3`) differs from the other lists. |
|
||||||
|
| 1.10 | 2026‑08‑18 | **Interface change (additive).** `tx_vitec_domain_model_product` gained five fields: `heroimage` (multiple FAL images, detail payload only), the richtext fields `description2`, `capabilities` and `textrelatedproducts`, and `portfolio` (TCA `link`) — emitted as a **resolved URL** through the new `LinkResolver::typolinkUrl()`. The product payloads are specified for the first time (7.15). Links in `contentelement` / `contentelementcta` that point at a **container** now resolve its children (`items`, page‑level shape) and `background` (7.4). Two record link handlers (`download`, `product`) added to the link browser, resolved server‑side per the new Clause 9.12; new middleware `vitec/download-file` streams `/download/file/<uid>` as a forced download (5.3), file lookup consolidated into `DownloadFileResolver` — first step towards the B‑4 target (10.2). Backend‑only: `relatedprodukt` moved from the Misc tab to General. Editorial: the document footer had been stuck at v1.7 since v1.8. |
|
||||||
|
|
||||||
This document is drafted in the style of, and adopts the terminology conventions of,
|
This document is drafted in the style of, and adopts the terminology conventions of,
|
||||||
ISO/IEC/IEEE 42010 (architecture description), ISO/IEC/IEEE 26514 (information for
|
ISO/IEC/IEEE 42010 (architecture description), ISO/IEC/IEEE 26514 (information for
|
||||||
@@ -180,7 +181,7 @@ enhancers for products (`tx_vitec_domain_model_product.slug`) and news detail
|
|||||||
(`path_segment`).
|
(`path_segment`).
|
||||||
|
|
||||||
### 5.3 Frontend middlewares
|
### 5.3 Frontend middlewares
|
||||||
Two middlewares are registered in `Configuration/RequestMiddlewares.php`, both after
|
Three middlewares are registered in `Configuration/RequestMiddlewares.php`, all after
|
||||||
`typo3/cms-core/normalized-params-attribute` and before `typo3/cms-frontend/site` —
|
`typo3/cms-core/normalized-params-attribute` and before `typo3/cms-frontend/site` —
|
||||||
that is, **before page resolution**:
|
that is, **before page resolution**:
|
||||||
|
|
||||||
@@ -188,6 +189,7 @@ that is, **before page resolution**:
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `vitec/form-submission` | Answers `POST /api/vitec/form/<formKey>` (Clause 7.8.2). Every other request passes through untouched. |
|
| `vitec/form-submission` | Answers `POST /api/vitec/form/<formKey>` (Clause 7.8.2). Every other request passes through untouched. |
|
||||||
| `vitec/success-story-path-rewrite` | Rewrites `/success-stories/<slug>` internally to `/success-stories/story/<slug>` when `<slug>` matches a visible Success Story. The page router would otherwise always resolve the public SEO URL to the list page (longest page‑slug prefix), so the detail subpage could never answer it. The browser URL is unchanged; a non‑matching slug leaves the request untouched; any exception leaves the request untouched. |
|
| `vitec/success-story-path-rewrite` | Rewrites `/success-stories/<slug>` internally to `/success-stories/story/<slug>` when `<slug>` matches a visible Success Story. The page router would otherwise always resolve the public SEO URL to the list page (longest page‑slug prefix), so the detail subpage could never answer it. The browser URL is unchanged; a non‑matching slug leaves the request untouched; any exception leaves the request untouched. |
|
||||||
|
| `vitec/download-file` | Answers `GET /download/file/<uid>`: resolves the download record's file (`DownloadFileResolver`: FAL → Collateral naming convention → `filepath`) and streams it with `Content-Disposition: attachment` (Clause 9.12). Unknown uid, hidden record or missing file fall through to normal page resolution. |
|
||||||
|
|
||||||
### 5.4 Architectural principles (rationale)
|
### 5.4 Architectural principles (rationale)
|
||||||
- **P1 — One envelope.** Every content element, regardless of source, is exposed with
|
- **P1 — One envelope.** Every content element, regardless of source, is exposed with
|
||||||
@@ -444,6 +446,13 @@ The `colPos` value of each column is fixed by `COLPOS_TO_COLUMN` in
|
|||||||
(25/25/25/25), 241/242 (66/33), 251/252 (33/66) — and **shall** be kept in sync with
|
(25/25/25/25), 241/242 (66/33), 251/252 (33/66) — and **shall** be kept in sync with
|
||||||
the container TCA (Annex B‑5).
|
the container TCA (Annex B‑5).
|
||||||
|
|
||||||
|
**Linked containers (since v1.10).** When a link resolved through
|
||||||
|
`ContentElementResolver` (e.g. `contentelement` / `contentelementcta`, 7.15) points at
|
||||||
|
a container, the resolver attaches the container's children under `items` in exactly
|
||||||
|
the column shape above, plus the resolved `background`. Nested containers recurse
|
||||||
|
(depth‑capped at 5, cycle‑safe); children are stripped of container‑level fields as in
|
||||||
|
the page‑level rendering; nested plugins resolve per 9.7.
|
||||||
|
|
||||||
### 7.5 Success Story (use case) detail payload
|
### 7.5 Success Story (use case) detail payload
|
||||||
Produced by `UsecaseSerializer::serializeDetail()` — the reference implementation of
|
Produced by `UsecaseSerializer::serializeDetail()` — the reference implementation of
|
||||||
the centralised pattern (Clause 9.2):
|
the centralised pattern (Clause 9.2):
|
||||||
@@ -856,6 +865,36 @@ Normative behaviour:
|
|||||||
An event carrying no category belongs to no region and is invisible to this layout while
|
An event carrying no category belongs to no region and is invisible to this layout while
|
||||||
remaining present in `events`.
|
remaining present in `events`.
|
||||||
|
|
||||||
|
### 7.15 Product payloads
|
||||||
|
|
||||||
|
#### 7.15.1 Detail payload (`vitec_productshow`, key `product`)
|
||||||
|
|
||||||
|
Produced by `ProductShowJsonRenderer::serializeProduct()`. Members:
|
||||||
|
|
||||||
|
| Member | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `uid` | int | |
|
||||||
|
| `title`, `slug`, `urltitle`, `seotitle`, `seometa`, `keywords`, `structureddata`, `teaser`, `subtitle`, `video`, `shortcutpid` | string | raw column values; `video` is a YouTube clip id |
|
||||||
|
| `applications`, `capabilities`, `description`, `description2`, `highlights`, `textrelatedproducts` | string | richtext, resolved per 9.11 |
|
||||||
|
| `hideonapp`, `hideonwebsite`, `hideondatasheets`, `hideonproducts`, `shortcut`, `legacy`, `supportproduct`, `subproduct`, `showdatapath` | bool | |
|
||||||
|
| `link` | string | `/product/<slug>` |
|
||||||
|
| `portfolio` | string \| null | resolved URL of the portfolio link (9.12); null = no link |
|
||||||
|
| `contentelement`, `contentelementcta` | object \| null | linked tt_content element in the envelope shape of 7.2; a linked container carries `items` and `background` (7.4) |
|
||||||
|
| `categories` | array | `{uid, title, description}` |
|
||||||
|
| `images` | array | product images with `url`, `srcset` and properties (9.9) |
|
||||||
|
| `heroimage` | array | hero images, same shape as `images` — **detail payload only** |
|
||||||
|
| `downloads` | array | download records incl. the resolved `file` |
|
||||||
|
| `ogimage`, `videofile` | object \| null | FAL singletons |
|
||||||
|
| `relatedprodukt` | array | card shape `{uid, title, slug, subtitle, teaser, description, link, images}`, backend order |
|
||||||
|
| `jsonLd` | string | ready‑to‑emit schema.org `@graph` (6.6), detail only |
|
||||||
|
|
||||||
|
#### 7.15.2 List payload (`vitec_productlist`, key `products`)
|
||||||
|
|
||||||
|
List envelope per 7.3 (`layout`, `showToolbar`, `products[]`) — since v1.9. Each item
|
||||||
|
carries the member set of 7.15.1 **except** `heroimage` and `jsonLd`. `heroimage` is
|
||||||
|
deliberately detail‑only: one further FAL query per product would buy data a list
|
||||||
|
rarely shows. The `layout` vocabulary remains `0`–`3` (see the v1.9 note).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8 Content‑type catalogue
|
## 8 Content‑type catalogue
|
||||||
@@ -1016,6 +1055,23 @@ Fields that merely *look* like richtext are out of scope: FAL metadata
|
|||||||
(`sys_file_reference.description`), `sys_category.description` and `sys_file` metadata
|
(`sys_file_reference.description`), `sys_category.description` and `sys_file` metadata
|
||||||
are plain text and **shall not** be passed through the resolver.
|
are plain text and **shall not** be passed through the resolver.
|
||||||
|
|
||||||
|
### 9.12 Link resolution and record links
|
||||||
|
|
||||||
|
JSON output **shall never** carry an unresolved TYPO3 link (`t3://…`). Every link
|
||||||
|
field is resolved server‑side — `LinkResolver::pageUrl()` for page uids,
|
||||||
|
`LinkResolver::typolinkUrl()` for full typolink parameters — under the shared
|
||||||
|
contract **null means "no link"**: a hidden or deleted target resolves to null,
|
||||||
|
never to a broken URL.
|
||||||
|
|
||||||
|
The link browser offers two record link handlers (`TCEMAIN.linkHandler`,
|
||||||
|
`Configuration/page.tsconfig`), stored as `t3://record?identifier=<key>&uid=<n>`
|
||||||
|
and resolved by `config.recordLinks` in `setup.typoscript`:
|
||||||
|
|
||||||
|
| Identifier | Resolves to | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `product` | `/product/<slug>` | same convention as the `link` member (7.15); assumes the product detail page (uid 10) answers `/product` |
|
||||||
|
| `download` | `/download/file/<uid>` | forced‑download endpoint (5.3), file lookup via `DownloadFileResolver` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10 Maintainability and upgrade‑safety (ISO 25010)
|
## 10 Maintainability and upgrade‑safety (ISO 25010)
|
||||||
@@ -1208,4 +1264,4 @@ remediation.
|
|||||||
- Header convention — the uniform header section across CEs, plugins and containers.
|
- Header convention — the uniform header section across CEs, plugins and containers.
|
||||||
- `Configuration/Sets/Vitecset/setup.typoscript` — the single TypoScript entry point.
|
- `Configuration/Sets/Vitecset/setup.typoscript` — the single TypoScript entry point.
|
||||||
|
|
||||||
*End of document EVO‑VITEC‑HL‑001 v1.7.*
|
*End of document EVO‑VITEC‑HL‑001 v1.10.*
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ as the reference).
|
|||||||
| Products | `vitec_productlist` / `vitec_productshow` | `ProductList/ProductShowJsonRenderer` | `products` / `product` |
|
| Products | `vitec_productlist` / `vitec_productshow` | `ProductList/ProductShowJsonRenderer` | `products` / `product` |
|
||||||
| Success Stories | `vitec_usecaselist` / `vitec_usecaseshow` | `UsecaseList/ShowJsonRenderer` → `UsecaseSerializer` | `usecases` / `usecase` |
|
| Success Stories | `vitec_usecaselist` / `vitec_usecaseshow` | `UsecaseList/ShowJsonRenderer` → `UsecaseSerializer` | `usecases` / `usecase` |
|
||||||
| Markets | `vitec_marketlist` / `vitec_marketshow` | `MarketList/MarketShowJsonRenderer` | `markets` / `market` |
|
| Markets | `vitec_marketlist` / `vitec_marketshow` | `MarketList/MarketShowJsonRenderer` | `markets` / `market` |
|
||||||
| Solutions | `vitec_solutionshow` | `SolutionShowJsonRenderer` | `solution` |
|
| Solutions | `vitec_solutionlist` / `vitec_solutionshow` | `SolutionList/SolutionShowJsonRenderer` | `solutions` / `solution` |
|
||||||
| Downloads | `vitec_downloadcard` / `vitec_downloadcardcollection` | `Downloadcard*JsonRenderer` | `downloadcard` / `downloadcardcollection` |
|
| Downloads | `vitec_downloadcard` / `vitec_downloadcardcollection` | `Downloadcard*JsonRenderer` | `downloadcard` / `downloadcardcollection` |
|
||||||
| Datasheets | `vitec_datasheets` | `DatasheetsJsonRenderer` | `datasheets` |
|
| Datasheets | `vitec_datasheets` | `DatasheetsJsonRenderer` | `datasheets` |
|
||||||
| Events | `vitec_eventlist` | `EventlistJsonRenderer` | `eventlist` |
|
| Events | `vitec_eventlist` | `EventlistJsonRenderer` | `eventlist` |
|
||||||
@@ -113,6 +113,10 @@ as the reference).
|
|||||||
| Forms | `vitec_contactform` / `vitec_demoform` / `vitec_helpdeskform` | `FormsJsonRenderer` | `form` |
|
| Forms | `vitec_contactform` / `vitec_demoform` / `vitec_helpdeskform` | `FormsJsonRenderer` | `form` |
|
||||||
| News | `news_pi1` (+ 8 variants) | `NewsJsonRenderer` | `news` |
|
| News | `news_pi1` (+ 8 variants) | `NewsJsonRenderer` | `news` |
|
||||||
|
|
||||||
|
**Events** (`vitec_eventlist`) offer four layouts including `regions`, which groups
|
||||||
|
upcoming events by region category — each region tile carries its next event and
|
||||||
|
that event's image.
|
||||||
|
|
||||||
**Cards** (`vitec_modelcard`) are one plugin for four model types — the FlexForm picks
|
**Cards** (`vitec_modelcard`) are one plugin for four model types — the FlexForm picks
|
||||||
`product`, `story`, `market` or `solution` plus a record, and every card field comes
|
`product`, `story`, `market` or `solution` plus a record, and every card field comes
|
||||||
from that record. Image resolution is delegated to `UsecaseSerializer::image()`.
|
from that record. Image resolution is delegated to `UsecaseSerializer::image()`.
|
||||||
@@ -134,6 +138,8 @@ Declarative content elements (`friendsoftypo3/content-blocks`), serialised to JS
|
|||||||
| `video` | YouTube or uploaded video with poster |
|
| `video` | YouTube or uploaded video with poster |
|
||||||
| `faq` | Accordion; also feeds the `FAQPage` structured data |
|
| `faq` | Accordion; also feeds the `FAQPage` structured data |
|
||||||
| `columns` | Two‑column layout (50/50 · 66/33 · 33/66) with per‑item content |
|
| `columns` | Two‑column layout (50/50 · 66/33 · 33/66) with per‑item content |
|
||||||
|
| `quotation` | Quote / testimonial (shared header section, header optional) |
|
||||||
|
| `featured-content` | Featured teaser with image, colour overlay and CTA |
|
||||||
|
|
||||||
## Layout containers
|
## Layout containers
|
||||||
|
|
||||||
@@ -179,9 +185,11 @@ Beyond `content[]`, every page response carries:
|
|||||||
| `favicons` | `FaviconsJsonRenderer` — ready‑to‑render `<link>` descriptors plus `themeColor` |
|
| `favicons` | `FaviconsJsonRenderer` — ready‑to‑render `<link>` descriptors plus `themeColor` |
|
||||||
| `jsonLd` | `PageJsonLdRenderer` (see below) |
|
| `jsonLd` | `PageJsonLdRenderer` (see below) |
|
||||||
|
|
||||||
Two frontend middlewares run before page resolution: `vitec/form-submission` (the form
|
Three frontend middlewares run before page resolution: `vitec/form-submission` (the form
|
||||||
endpoint) and `vitec/success-story-path-rewrite`, which lets the public SEO URL
|
endpoint); `vitec/success-story-path-rewrite`, which lets the public SEO URL
|
||||||
`/success-stories/<slug>` resolve to the detail subpage without changing the browser URL.
|
`/success-stories/<slug>` resolve to the detail subpage without changing the browser URL;
|
||||||
|
and `vitec/download-file`, which streams `/download/file/<uid>` as a forced download —
|
||||||
|
the target of the link browser's Download record links.
|
||||||
|
|
||||||
## Structured data (JSON‑LD)
|
## Structured data (JSON‑LD)
|
||||||
|
|
||||||
@@ -194,17 +202,31 @@ endpoint) and `vitec/success-story-path-rewrite`, which lets the public SEO URL
|
|||||||
**Backend module "VITEC Import"** (Web menu): CSV import per domain model
|
**Backend module "VITEC Import"** (Web menu): CSV import per domain model
|
||||||
(Market, Solution, Product) with a persistable column mapper and a unified
|
(Market, Solution, Product) with a persistable column mapper and a unified
|
||||||
review list (new / update / unchanged / db-only) — what gets written is the
|
review list (new / update / unchanged / db-only) — what gets written is the
|
||||||
editable per-row payload, applied through DataHandler. A fourth tab
|
editable per-row payload, applied through DataHandler. Rows whose CSV name
|
||||||
|
differs from the TYPO3 record can be linked by hand (persistable record
|
||||||
|
aliases), so they keep matching as updates on every future delivery. A fourth tab
|
||||||
**SEO Research** stores each delivery of the recurring keyword-research CSV,
|
**SEO Research** stores each delivery of the recurring keyword-research CSV,
|
||||||
diffs it against the previous one and checks the CSV structure against the
|
diffs it against the previous one and checks the CSV structure against the
|
||||||
page tree and the domain records.
|
page tree and the domain records.
|
||||||
|
|
||||||
|
**Backend module "WordPress Import"** (Web menu, behind the CSV import): pulls
|
||||||
|
posts from a WordPress REST API (VITEC blog and the Datapath site) into news
|
||||||
|
records — checkbox selection, featured and inline images are fetched and
|
||||||
|
localized so they survive the go-live.
|
||||||
|
|
||||||
|
**Link browser record links**: two extra tabs (Download, Product) let editors link
|
||||||
|
download and product records wherever links are offered. The JSON always carries the
|
||||||
|
resolved URL — products point at the detail view (`/product/<slug>`), downloads at
|
||||||
|
the forced-download endpoint.
|
||||||
|
|
||||||
| CLI command | Purpose |
|
| CLI command | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `vitec:import-success-stories` | One-time migration of the old-site success stories |
|
| `vitec:import-success-stories` | One-time migration of the old-site success stories |
|
||||||
| `vitec:import-downloads` | Import old-site downloads (Collateral only, idempotent, filename normalization) |
|
| `vitec:import-downloads` | Import old-site downloads (Collateral only, idempotent, filename normalization) |
|
||||||
| `vitec:create-markets` | Create market records the SEO structure check reports missing, incl. sys_category assignment |
|
| `vitec:create-markets` | Create market records the SEO structure check reports missing, incl. sys_category assignment |
|
||||||
| `vitec:market-dummy-image` | Assign the shared placeholder image to markets without an image |
|
| `vitec:market-dummy-image` | Assign the shared placeholder image to markets without an image |
|
||||||
|
| `vitec:migrate-newspages` | Rewire impexp-imported old-site news pages (FLUX `colPos` nesting, `internalurl`) via `tx_impexp_origuid` |
|
||||||
|
| `vitec:import-news` | Import old-site news records from `migrations/news_export.json` |
|
||||||
|
|
||||||
All commands support `--dry-run` and are safe to re-run.
|
All commands support `--dry-run` and are safe to re-run.
|
||||||
|
|
||||||
@@ -278,7 +300,7 @@ packages/vitec/
|
|||||||
│ ├── Service/ # Serializers, ContentElementResolver, StructuredDataService
|
│ ├── Service/ # Serializers, ContentElementResolver, StructuredDataService
|
||||||
│ ├── DataProcessing/ # ContainerChildrenProcessor (container → items)
|
│ ├── DataProcessing/ # ContainerChildrenProcessor (container → items)
|
||||||
│ ├── Forms/ # FormDefinitions + Delivery/ (email, salesforce)
|
│ ├── Forms/ # FormDefinitions + Delivery/ (email, salesforce)
|
||||||
│ ├── Middleware/ # form endpoint, success-story path rewrite
|
│ ├── Middleware/ # form endpoint, success-story rewrite, download streaming
|
||||||
│ ├── Domain/Model|Repository/
|
│ ├── Domain/Model|Repository/
|
||||||
│ ├── Controller/ # Extbase controllers (non-headless / backend)
|
│ ├── Controller/ # Extbase controllers (non-headless / backend)
|
||||||
│ └── Backend/ · View/ · Hook/ · EventListener/ · Tca/ · Preview/
|
│ └── Backend/ · View/ · Hook/ · EventListener/ · Tca/ · Preview/
|
||||||
|
|||||||
@@ -11,14 +11,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Model tabs -->
|
<!-- Model tabs. Products left the CSV flow on 2026-08-21: its tab now leads to
|
||||||
|
the XLSX product-text overview (ProductTextImportController); the CSV code
|
||||||
|
path for products stays functional but is no longer linked. -->
|
||||||
<ul class="nav nav-tabs" style="margin-bottom:1rem;">
|
<ul class="nav nav-tabs" style="margin-bottom:1rem;">
|
||||||
<f:for each="{models}" as="m">
|
<f:for each="{models}" as="m">
|
||||||
<li class="nav-item">
|
<f:if condition="{m.key} != 'product'">
|
||||||
<a class="nav-link {f:if(condition: '{m.key} == {model.key}', then: 'active')}"
|
<li class="nav-item">
|
||||||
href="{f:be.uri(route: 'web_vitecimport', parameters: {model: m.key})}">{m.label}</a>
|
<a class="nav-link {f:if(condition: '{m.key} == {model.key}', then: 'active')}"
|
||||||
</li>
|
href="{f:be.uri(route: 'web_vitecimport', parameters: {model: m.key})}">{m.label}</a>
|
||||||
|
</li>
|
||||||
|
</f:if>
|
||||||
</f:for>
|
</f:for>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{f:be.uri(route: 'web_vitecimport.products')}">Products</a>
|
||||||
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="{f:be.uri(route: 'web_vitecimport.seo')}">SEO Research</a>
|
<a class="nav-link" href="{f:be.uri(route: 'web_vitecimport.seo')}">SEO Research</a>
|
||||||
</li>
|
</li>
|
||||||
@@ -134,6 +141,7 @@
|
|||||||
<f:case value="unchanged"><span class="vitec-badge vitec-badge-unchanged">unchanged</span></f:case>
|
<f:case value="unchanged"><span class="vitec-badge vitec-badge-unchanged">unchanged</span></f:case>
|
||||||
<f:defaultCase><span class="vitec-badge vitec-badge-dbonly">only in DB</span></f:defaultCase>
|
<f:defaultCase><span class="vitec-badge vitec-badge-dbonly">only in DB</span></f:defaultCase>
|
||||||
</f:switch>
|
</f:switch>
|
||||||
|
<f:if condition="{row.viaAlias}"><br /><span class="vitec-badge vitec-badge-info">linked</span></f:if>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<strong>{row.label}</strong>
|
<strong>{row.label}</strong>
|
||||||
@@ -141,6 +149,14 @@
|
|||||||
<f:if condition="{row.status} == 'update'">
|
<f:if condition="{row.status} == 'update'">
|
||||||
<br /><small class="text-muted">changes: {row.diff}</small>
|
<br /><small class="text-muted">changes: {row.diff}</small>
|
||||||
</f:if>
|
</f:if>
|
||||||
|
<f:if condition="{row.aliasable}">
|
||||||
|
<select name="alias[{row.index}]" class="form-select form-select-sm" style="margin-top:4px;">
|
||||||
|
<option value="0">— not linked —</option>
|
||||||
|
<f:for each="{aliasCandidates}" as="cand">
|
||||||
|
<option value="{cand.uid}" {f:if(condition: '{cand.uid} == {row.aliasUid}', then: 'selected')}>{cand.title} (uid {cand.uid})</option>
|
||||||
|
</f:for>
|
||||||
|
</select>
|
||||||
|
</f:if>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<f:if condition="{row.status} != 'dbonly'">
|
<f:if condition="{row.status} != 'dbonly'">
|
||||||
@@ -156,10 +172,18 @@
|
|||||||
<div class="row row-cols-auto align-items-center g-2">
|
<div class="row row-cols-auto align-items-center g-2">
|
||||||
<div class="col"><label for="pid"><strong>Storage pid for new records</strong></label></div>
|
<div class="col"><label for="pid"><strong>Storage pid for new records</strong></label></div>
|
||||||
<div class="col"><input type="number" name="pid" id="pid" value="{pid}" class="form-control form-control-sm" style="width:110px;" /></div>
|
<div class="col"><input type="number" name="pid" id="pid" value="{pid}" class="form-control form-control-sm" style="width:110px;" /></div>
|
||||||
|
<div class="col">
|
||||||
|
<button type="submit" name="op" value="saveAliases" class="btn btn-default">Save record links</button>
|
||||||
|
</div>
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<button type="submit" name="op" value="apply" class="btn btn-primary"
|
<button type="submit" name="op" value="apply" class="btn btn-primary"
|
||||||
onclick="return confirm('Apply the checked rows to the database?');">Apply checked rows</button>
|
onclick="return confirm('Apply the checked rows to the database?');">Apply checked rows</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col form-text">
|
||||||
|
A "new" row that exists in TYPO3 under a different name: pick the record in
|
||||||
|
its select, then "Save record links" - the link is stored per model and
|
||||||
|
re-applied on every upload.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
|
||||||
|
data-namespace-typo3-fluid="true">
|
||||||
|
|
||||||
|
<f:layout name="Backend/Default" />
|
||||||
|
|
||||||
|
<f:section name="main">
|
||||||
|
|
||||||
|
<div class="module-docheader-bar module-docheader-bar-navigation">
|
||||||
|
<div class="module-docheader-bar-column-left">
|
||||||
|
<h2 class="t3js-title-inlineedit">Edit Product — {product.title}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<a href="{f:be.uri(route: 'web_vitecimport.products')}">← back to product list</a>
|
||||||
|
<small class="text-muted">uid {product.uid} · {product.slug}</small>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form action="{f:be.uri(route: 'web_vitecimport.product_preview')}" method="post" enctype="multipart/form-data">
|
||||||
|
<input type="hidden" name="uid" value="{product.uid}" />
|
||||||
|
<input type="hidden" name="parsed" value="{parsedJson}" />
|
||||||
|
|
||||||
|
<!-- Upload -->
|
||||||
|
<div class="card" style="margin-bottom:1rem;">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row row-cols-auto align-items-center g-2">
|
||||||
|
<div class="col">
|
||||||
|
<input type="file" class="form-control" name="xlsxfile" accept=".xlsx,.xls" />
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<button type="submit" class="btn btn-default">Load workbook</button>
|
||||||
|
</div>
|
||||||
|
<f:if condition="{parsed}">
|
||||||
|
<div class="col text-muted">
|
||||||
|
<f:if condition="{workbook}">
|
||||||
|
<strong>{workbook.filename}</strong> · loaded {workbook.date} ·
|
||||||
|
</f:if>
|
||||||
|
<code>{metaUrl}</code> · page type <strong>{pageType}</strong>
|
||||||
|
</div>
|
||||||
|
</f:if>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<f:if condition="{parsed}">
|
||||||
|
|
||||||
|
<f:if condition="{slugMismatch}">
|
||||||
|
<div class="callout callout-warning">
|
||||||
|
<div class="callout-body">
|
||||||
|
<strong>URL mismatch:</strong> {slugMismatch} — is this the right product for this workbook?
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</f:if>
|
||||||
|
|
||||||
|
<!-- Mapping + preview -->
|
||||||
|
<table class="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:11rem;">Product field</th>
|
||||||
|
<th style="width:26rem;">Source in workbook</th>
|
||||||
|
<th>Current value</th>
|
||||||
|
<th>New value</th>
|
||||||
|
<th style="width:5rem;">Apply</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<f:for each="{rows}" as="row">
|
||||||
|
<tr>
|
||||||
|
<td><strong>{row.label}</strong><br /><small class="text-muted">{row.field}</small></td>
|
||||||
|
<td>
|
||||||
|
<select class="form-select form-select-sm" name="map[{row.field}]">
|
||||||
|
<option value="">— do not import —</option>
|
||||||
|
<f:for each="{sources}" as="group" key="groupName">
|
||||||
|
<optgroup label="{groupName}">
|
||||||
|
<f:for each="{group}" as="src">
|
||||||
|
<option value="{src.selector}" {f:if(condition: '{src.selector} == {row.selector}', then: 'selected="selected"')}>{src.label} — {src.preview}</option>
|
||||||
|
</f:for>
|
||||||
|
</optgroup>
|
||||||
|
</f:for>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td><small>{row.oldPreview}</small></td>
|
||||||
|
<td>
|
||||||
|
<f:if condition="{row.new}">
|
||||||
|
<f:then>
|
||||||
|
<small>{row.newPreview}</small>
|
||||||
|
<f:if condition="{row.changed}"><br /><span class="badge bg-warning">differs</span></f:if>
|
||||||
|
</f:then>
|
||||||
|
<f:else>
|
||||||
|
<f:if condition="{row.selector}">
|
||||||
|
<small class="text-muted">not in this file</small>
|
||||||
|
</f:if>
|
||||||
|
</f:else>
|
||||||
|
</f:if>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input class="form-check-input" type="checkbox" name="apply[]" value="{row.field}"
|
||||||
|
{f:if(condition: row.changed, then: 'checked="checked"')} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</f:for>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<f:if condition="{relatedRows}">
|
||||||
|
<div class="card" style="margin-bottom:1rem;">
|
||||||
|
<div class="card-header">Related products from the workbook</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted">
|
||||||
|
Each ticked card writes its text and adds the missing relation
|
||||||
|
(existing relations and their order are never touched, nothing is removed).
|
||||||
|
Your manual choices are remembered for the next workbook.
|
||||||
|
</p>
|
||||||
|
<table class="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:11rem;">Card in workbook</th>
|
||||||
|
<th style="width:20rem;">Target product</th>
|
||||||
|
<th>Card text (new)</th>
|
||||||
|
<th>Card text (current)</th>
|
||||||
|
<th style="width:5rem;">Apply</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<f:for each="{relatedRows}" as="rel">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong>{rel.title}</strong>
|
||||||
|
<f:if condition="{rel.cta}"><br /><small class="text-muted">CTA: {rel.cta}</small></f:if>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<select class="form-select form-select-sm" name="related[{rel.index}]">
|
||||||
|
<option value="0">— none —</option>
|
||||||
|
<f:for each="{productOptions}" as="opt">
|
||||||
|
<option value="{opt.uid}" {f:if(condition: '{opt.uid} == {rel.selected}', then: 'selected="selected"')}>{opt.title}</option>
|
||||||
|
</f:for>
|
||||||
|
</select>
|
||||||
|
<f:if condition="{rel.status} == 'alias'"><span class="badge bg-info">remembered</span></f:if>
|
||||||
|
<f:if condition="{rel.status} == 'title'"><span class="badge bg-success">title match</span></f:if>
|
||||||
|
<f:if condition="{rel.status} == 'none'"><span class="badge bg-warning">no match</span></f:if>
|
||||||
|
<f:if condition="{rel.selected}">
|
||||||
|
<f:if condition="{rel.isRelated}">
|
||||||
|
<f:else><span class="badge bg-secondary">relation will be added</span></f:else>
|
||||||
|
</f:if>
|
||||||
|
</f:if>
|
||||||
|
</td>
|
||||||
|
<td><small>{rel.preview}</small></td>
|
||||||
|
<td>
|
||||||
|
<small>{rel.oldPreview}</small>
|
||||||
|
<f:if condition="{rel.changed}"><br /><span class="badge bg-warning">differs</span></f:if>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input class="form-check-input" type="checkbox" name="applyRelated[]" value="{rel.index}"
|
||||||
|
{f:if(condition: rel.changed, then: 'checked="checked"')} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</f:for>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</f:if>
|
||||||
|
|
||||||
|
<div class="row row-cols-auto align-items-center g-2" style="margin-bottom:2rem;">
|
||||||
|
<div class="col">
|
||||||
|
<button type="submit" class="btn btn-default">Refresh preview</button>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<button type="submit" class="btn btn-primary"
|
||||||
|
formaction="{f:be.uri(route: 'web_vitecimport.product_apply')}">Apply selected</button>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" name="saveMapping" id="saveMapping" value="1" checked="checked" />
|
||||||
|
<label class="form-check-label" for="saveMapping">Save mapping for the next product</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-muted">
|
||||||
|
"Refresh preview" re-reads the values after changing a dropdown — nothing is written.
|
||||||
|
Only ticked rows are written on "Apply selected".
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</f:if>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</f:section>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
|
||||||
|
data-namespace-typo3-fluid="true">
|
||||||
|
|
||||||
|
<f:layout name="Backend/Default" />
|
||||||
|
|
||||||
|
<f:section name="main">
|
||||||
|
|
||||||
|
<div class="module-docheader-bar module-docheader-bar-navigation">
|
||||||
|
<div class="module-docheader-bar-column-left">
|
||||||
|
<h2 class="t3js-title-inlineedit">VITEC Import — Products</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs: Markets/Solutions stay on the CSV flow, Products is this overview -->
|
||||||
|
<ul class="nav nav-tabs" style="margin-bottom:1rem;">
|
||||||
|
<f:for each="{models}" as="m">
|
||||||
|
<f:if condition="{m.key} != 'product'">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{f:be.uri(route: 'web_vitecimport', parameters: {model: m.key})}">{m.label}</a>
|
||||||
|
</li>
|
||||||
|
</f:if>
|
||||||
|
</f:for>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link active" href="{f:be.uri(route: 'web_vitecimport.products')}">Products</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{f:be.uri(route: 'web_vitecimport.seo')}">SEO Research</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- Search -->
|
||||||
|
<div class="card" style="margin-bottom:1rem;">
|
||||||
|
<div class="card-body">
|
||||||
|
<form action="{f:be.uri(route: 'web_vitecimport.products')}" method="post" class="row row-cols-auto align-items-center g-2">
|
||||||
|
<div class="col">
|
||||||
|
<input type="text" class="form-control" name="q" value="{q}" placeholder="Search title or slug …" />
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<button type="submit" class="btn btn-default">Search</button>
|
||||||
|
</div>
|
||||||
|
<f:if condition="{q}">
|
||||||
|
<div class="col">
|
||||||
|
<a class="btn btn-link" href="{f:be.uri(route: 'web_vitecimport.products')}">Reset</a>
|
||||||
|
</div>
|
||||||
|
</f:if>
|
||||||
|
<div class="col text-muted">{total} products</div>
|
||||||
|
</form>
|
||||||
|
<f:if condition="{searchHistory}">
|
||||||
|
<div style="margin-top:0.6rem;">
|
||||||
|
<small class="text-muted" style="margin-right:0.4rem;">Recent:</small>
|
||||||
|
<f:for each="{searchHistory}" as="term">
|
||||||
|
<a class="badge {f:if(condition: '{term} == {q}', then: 'bg-primary', else: 'bg-secondary')}"
|
||||||
|
style="text-decoration:none;margin-right:0.3rem;"
|
||||||
|
href="{f:be.uri(route: 'web_vitecimport.products', parameters: {q: term})}">{term}</a>
|
||||||
|
</f:for>
|
||||||
|
</div>
|
||||||
|
</f:if>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="table table-striped table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
<a href="{f:be.uri(route: 'web_vitecimport.products', parameters: {q: q, sort: 'title', dir: sortLinks.title})}">Title {sortArrows.title}</a>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
<a href="{f:be.uri(route: 'web_vitecimport.products', parameters: {q: q, sort: 'slug', dir: sortLinks.slug})}">Slug {sortArrows.slug}</a>
|
||||||
|
</th>
|
||||||
|
<th style="width:10rem;">
|
||||||
|
<a href="{f:be.uri(route: 'web_vitecimport.products', parameters: {q: q, sort: 'tstamp', dir: sortLinks.tstamp})}">Changed {sortArrows.tstamp}</a>
|
||||||
|
</th>
|
||||||
|
<th style="width:10rem;">Content</th>
|
||||||
|
<th style="width:9rem;"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<f:for each="{products}" as="p">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{p.title}
|
||||||
|
<f:if condition="{p.hidden}"> <span class="badge bg-secondary">hidden</span></f:if>
|
||||||
|
</td>
|
||||||
|
<td><small class="text-muted">{p.slug}</small></td>
|
||||||
|
<td>{p.changed}</td>
|
||||||
|
<td>
|
||||||
|
<span class="vitec-badge">{f:if(condition: p.hasTeaser, then: '✓', else: '–')} teaser</span>
|
||||||
|
<span class="vitec-badge">{f:if(condition: p.hasDescription, then: '✓', else: '–')} descr.</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a class="btn btn-primary btn-sm" href="{f:be.uri(route: 'web_vitecimport.product_edit', parameters: {uid: p.uid})}">Edit Product</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</f:for>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</f:section>
|
||||||
|
</html>
|
||||||
@@ -53,6 +53,11 @@ CREATE TABLE tx_vitec_domain_model_product (
|
|||||||
contentelementcta varchar(255) DEFAULT '' NOT NULL,
|
contentelementcta varchar(255) DEFAULT '' NOT NULL,
|
||||||
videofile int(11) unsigned DEFAULT '0' NOT NULL,
|
videofile int(11) unsigned DEFAULT '0' NOT NULL,
|
||||||
showdatapath smallint(5) unsigned DEFAULT '0' NOT NULL,
|
showdatapath smallint(5) unsigned DEFAULT '0' NOT NULL,
|
||||||
|
heroimage int(11) unsigned DEFAULT '0' NOT NULL,
|
||||||
|
description2 text,
|
||||||
|
capabilities text,
|
||||||
|
portfolio varchar(1024) DEFAULT '' NOT NULL,
|
||||||
|
textrelatedproducts text,
|
||||||
KEY parent (pid)
|
KEY parent (pid)
|
||||||
);
|
);
|
||||||
CREATE TABLE tx_vitec_domain_model_download (
|
CREATE TABLE tx_vitec_domain_model_download (
|
||||||
@@ -311,6 +316,7 @@ CREATE TABLE tx_vitec_import_mapping (
|
|||||||
model varchar(64) DEFAULT '' NOT NULL,
|
model varchar(64) DEFAULT '' NOT NULL,
|
||||||
identity_field varchar(64) DEFAULT 'slug' NOT NULL,
|
identity_field varchar(64) DEFAULT 'slug' NOT NULL,
|
||||||
mapping text,
|
mapping text,
|
||||||
|
record_aliases text,
|
||||||
PRIMARY KEY (uid),
|
PRIMARY KEY (uid),
|
||||||
KEY model (model)
|
KEY model (model)
|
||||||
);
|
);
|
||||||
@@ -325,3 +331,34 @@ CREATE TABLE tx_vitec_seo_research (
|
|||||||
payload mediumtext,
|
payload mediumtext,
|
||||||
PRIMARY KEY (uid)
|
PRIMARY KEY (uid)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Per-pair card copy for the related-products relation. Deliberately NOT a
|
||||||
|
-- column on tx_vitec_product_related_mm: DataHandler rewrites the MM rows on
|
||||||
|
-- every save of the product (delete + reinsert to apply sorting), which would
|
||||||
|
-- silently wipe any extra column. This side table survives that; orphaned rows
|
||||||
|
-- (relation later removed) are simply never joined. No TCA on purpose -
|
||||||
|
-- written by the product-text import module, read by ProductShowJsonRenderer.
|
||||||
|
--
|
||||||
|
CREATE TABLE tx_vitec_product_related_text (
|
||||||
|
product_uid int(11) DEFAULT '0' NOT NULL,
|
||||||
|
related_uid int(11) DEFAULT '0' NOT NULL,
|
||||||
|
cardtext text,
|
||||||
|
tstamp int(11) DEFAULT '0' NOT NULL,
|
||||||
|
PRIMARY KEY (product_uid, related_uid)
|
||||||
|
);
|
||||||
|
|
||||||
|
--
|
||||||
|
-- The last successfully loaded product workbook, one row per product - so
|
||||||
|
-- "Edit Product" reopens on the previous upload instead of an empty form.
|
||||||
|
-- Parsed JSON, not the binary file; same payload-storage pattern as
|
||||||
|
-- tx_vitec_seo_research. No TCA on purpose.
|
||||||
|
--
|
||||||
|
CREATE TABLE tx_vitec_product_workbook (
|
||||||
|
product_uid int(11) DEFAULT '0' NOT NULL,
|
||||||
|
filename varchar(255) DEFAULT '' NOT NULL,
|
||||||
|
payload mediumtext,
|
||||||
|
be_user int(11) DEFAULT '0' NOT NULL,
|
||||||
|
tstamp int(11) DEFAULT '0' NOT NULL,
|
||||||
|
PRIMARY KEY (product_uid)
|
||||||
|
);
|
||||||
|
|||||||
@@ -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-ARhFRF4y.js"></script>
|
<script type="module" crossorigin src="/_frontend/assets/index-DkP6_MI5.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/_frontend/assets/index-BUkc9k85.css">
|
<link rel="stylesheet" crossorigin href="/_frontend/assets/index-BIG4WK1s.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="page"></div>
|
<div id="page"></div>
|
||||||
|
|||||||
Reference in New Issue
Block a user