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:
2026-08-21 11:00:45 +02:00
parent 12eb40614b
commit 7bd1161abf
28 changed files with 2594 additions and 39 deletions

View File

@@ -22,6 +22,7 @@
"friendsoftypo3/headless": "^5.0@rc",
"georgringer/news": "^14.0",
"netzbewegung/nb-headless-content-blocks": "^0.0.23",
"phpoffice/phpspreadsheet": "^5.9",
"plan2net/backend-category-hierarchy": "^14.0",
"typo3/cms-backend": "^14.3",
"typo3/cms-belog": "^14.3",

353
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "09c5be5fca6a03ab5f0ed81662f20f36",
"content-hash": "727d61bcbce8571b4b05c61cf61813aa",
"packages": [
{
"name": "b13/container",
@@ -2155,6 +2155,84 @@
},
"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",
"version": "v4.7.2",
@@ -2228,6 +2306,113 @@
},
"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",
"version": "2.10.1",
@@ -2787,6 +2972,115 @@
},
"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",
"version": "2.3.3",
@@ -3417,6 +3711,57 @@
},
"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",
"version": "3.0.3",
@@ -9554,10 +9899,10 @@
},
"prefer-stable": false,
"prefer-lowest": false,
"platform": {},
"platform-dev": {},
"platform": [],
"platform-dev": [],
"platform-overrides": {
"php": "8.4.0"
},
"plugin-api-version": "2.9.0"
"plugin-api-version": "2.3.0"
}

BIN
migrations/aligo-test.xlsx Normal file

Binary file not shown.

View 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";
}

View File

@@ -31,6 +31,9 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
* used for matching) is persisted per model via MappingRepository.
* 3. A unified list shows CSV rows matched against the DB records:
* 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 -
* what is applied is the textarea, not the raw CSV.
* 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);
}
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') {
$this->apply($modelKey, $body);
}
@@ -187,7 +200,8 @@ final class ImportController
$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];
foreach ($union as $row) {
@@ -215,7 +229,8 @@ final class ImportController
'csvRowCount' => count($csv['rows'] ?? []),
'csvstate' => $csv !== null ? (string)json_encode($csv, JSON_UNESCAPED_UNICODE) : '',
'identity' => $identity,
'mappingSaved' => $saved !== null,
'mappingSaved' => ($saved['mapping'] ?? []) !== [],
'aliasCandidates' => $aliasCandidates,
'union' => $union,
'counts' => $counts,
'pid' => $this->registry->detectPid($model['table']),
@@ -246,12 +261,17 @@ final class ImportController
/**
* 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{columns:array<int,string>,rows:array<int,array<string,string>>}|null $csv
* @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)));
$records = $this->registry->loadRecords($table, array_keys($fields));
@@ -266,6 +286,7 @@ final class ImportController
$union = [];
$matchedUids = [];
$directUids = [];
$index = 0;
foreach ($csv['rows'] ?? [] as $raw) {
@@ -275,9 +296,20 @@ final class ImportController
}
$identityValue = mb_strtolower(trim((string)($payload[$identity] ?? '')));
$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) {
$matchedUids[$uid] = true;
if (!$viaAlias) {
$directUids[$uid] = true;
}
$record = $records[$uid];
$diff = [];
foreach ($payload as $field => $value) {
@@ -299,6 +331,9 @@ final class ImportController
'diff' => implode(', ', $diff),
'payload' => (string)json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'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

View File

@@ -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);
}
}

View File

@@ -720,6 +720,7 @@ class Product extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity
{
$this->categories = new ObjectStorage();
$this->productimage = new ObjectStorage();
$this->heroimage = new ObjectStorage();
$this->downloads = new ObjectStorage();
}
@@ -977,6 +978,98 @@ class Product extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity
{
$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;
}
/* --------------------------------------------------------------------- */
}

View File

@@ -14,43 +14,63 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
*
* Plain table without TCA (tx_vitec_import_mapping) - the records are pure
* 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
{
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
{
$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)))
->setMaxResults(1)
->executeQuery()->fetchAssociative();
if (!$row) {
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);
if (!is_array($mapping)) {
return null;
}
$aliases = json_decode((string)($row['record_aliases'] ?? ''), true);
return [
'mapping' => array_map('strval', $mapping),
'mapping' => is_array($mapping) ? array_map('strval', $mapping) : [],
'identity' => (string)$row['identity_field'],
'aliases' => is_array($aliases) ? array_map('intval', $aliases) : [],
];
}
/** @param array<string,string> $mapping */
public function save(string $model, array $mapping, string $identity): void
{
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable(self::TABLE);
$values = [
'mapping' => (string)json_encode($mapping, JSON_UNESCAPED_UNICODE),
'identity_field' => $identity,
'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;
if ($exists) {
$connection->update(self::TABLE, $values, ['model' => $model]);

View 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,
];
}
}

View 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);
}
}

View File

@@ -5,8 +5,16 @@ declare(strict_types=1);
namespace Evomedien\Vitec\PageTitle;
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 = '';

View File

@@ -22,8 +22,10 @@ use Evomedien\Vitec\UserFunc\CustomerlogosJsonRenderer;
use Evomedien\Vitec\UserFunc\FormsJsonRenderer;
use Evomedien\Vitec\UserFunc\ModelcardJsonRenderer;
use Evomedien\Vitec\UserFunc\NewsJsonRenderer;
use Evomedien\Vitec\UserFunc\ContainerBackgroundRenderer;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
/**
* 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 /
* 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
* so the surrounding JSON output stays clean.
*/
@@ -71,6 +79,38 @@ final class ContentElementResolver
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 = [
'vitec_productlist' => [ProductListJsonRenderer::class, 'products'],
'vitec_productshow' => [ProductShowJsonRenderer::class, 'product'],
@@ -143,7 +183,10 @@ final class ContentElementResolver
return null;
}
return self::normaliseRecord($row);
$element = self::normaliseRecord($row);
self::attachContainerPayload($row, $element, [$uid => true], 0);
return $element;
} catch (\Throwable $e) {
return null;
}
@@ -156,9 +199,12 @@ final class ContentElementResolver
* resolved to their headless JSON.
*
* @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>
*/
public static function normaliseRecord(array $record): array
public static function normaliseRecord(array $record, bool $isContainerChild = false): array
{
$data = [];
foreach ($record as $field => $value) {
@@ -168,6 +214,9 @@ final class ContentElementResolver
if (in_array($field, self::SYSTEM_FIELDS, true)) {
continue;
}
if ($isContainerChild && in_array($field, self::CONTAINER_FIELDS, true)) {
continue;
}
if (self::isEmpty($value) && !in_array($field, self::KEEP_IF_ZERO, true)) {
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
{
return $value === null || $value === '' || $value === 0 || $value === '0';

View 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,
];
}
}

View File

@@ -45,4 +45,26 @@ final class LinkResolver
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;
}
}
}

View File

@@ -205,11 +205,15 @@ class ProductListJsonRenderer
'subtitle' => (string)($product['subtitle'] ?? ''),
'video' => (string)($product['video'] ?? ''),
'applications' => RteResolver::html($product['applications'] ?? ''),
'capabilities' => RteResolver::html($product['capabilities'] ?? ''),
'description' => RteResolver::html($product['description'] ?? ''),
'description2' => RteResolver::html($product['description2'] ?? ''),
'highlights' => RteResolver::html($product['highlights'] ?? ''),
'textrelatedproducts' => RteResolver::html($product['textrelatedproducts'] ?? ''),
'shortcutpid' => (string)($product['shortcutpid'] ?? ''),
'contentelement' => \Evomedien\Vitec\Service\ContentElementResolver::resolveLink((string)($product['contentelement'] ?? '')),
'contentelementcta' => \Evomedien\Vitec\Service\ContentElementResolver::resolveLink((string)($product['contentelementcta'] ?? '')),
'portfolio' => \Evomedien\Vitec\Service\LinkResolver::typolinkUrl((string)($product['portfolio'] ?? '')),
'hideonapp' => (bool)($product['hideonapp'] ?? false),
'hideonwebsite' => (bool)($product['hideonwebsite'] ?? false),

View File

@@ -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 = [
'product' => $this->serializeProduct($product),
'layout' => $layout,
@@ -210,11 +223,15 @@ class ProductShowJsonRenderer
'subtitle' => (string)($product['subtitle'] ?? ''),
'video' => (string)($product['video'] ?? ''),
'applications' => RteResolver::html($product['applications'] ?? ''),
'capabilities' => RteResolver::html($product['capabilities'] ?? ''),
'description' => RteResolver::html($product['description'] ?? ''),
'description2' => RteResolver::html($product['description2'] ?? ''),
'highlights' => RteResolver::html($product['highlights'] ?? ''),
'textrelatedproducts' => RteResolver::html($product['textrelatedproducts'] ?? ''),
'shortcutpid' => (string)($product['shortcutpid'] ?? ''),
'contentelement' => \Evomedien\Vitec\Service\ContentElementResolver::resolveLink((string)($product['contentelement'] ?? '')),
'contentelementcta' => \Evomedien\Vitec\Service\ContentElementResolver::resolveLink((string)($product['contentelementcta'] ?? '')),
'portfolio' => \Evomedien\Vitec\Service\LinkResolver::typolinkUrl((string)($product['portfolio'] ?? '')),
'hideonapp' => (bool)($product['hideonapp'] ?? false),
'hideonwebsite' => (bool)($product['hideonwebsite'] ?? false),
@@ -230,6 +247,7 @@ class ProductShowJsonRenderer
'categories' => $this->getProductCategories($uid),
'images' => $this->getProductImages($uid),
'heroimage' => $this->getProductImages($uid, 'heroimage'),
'downloads' => $this->getProductDownloads($uid),
'ogimage' => $this->getProductOgImage($uid),
'videofile' => $this->getProductVideoFile($uid),
@@ -310,7 +328,7 @@ class ProductShowJsonRenderer
return '';
}
protected function getProductImages(int $productUid): array
protected function getProductImages(int $productUid, string $fieldName = 'productimage'): array
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_file_reference');
@@ -321,7 +339,7 @@ class ProductShowJsonRenderer
->where(
$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('fieldname', $queryBuilder->createNamedParameter('productimage', ParameterType::STRING)),
$queryBuilder->expr()->eq('fieldname', $queryBuilder->createNamedParameter($fieldName, ParameterType::STRING)),
$queryBuilder->expr()->eq('deleted', 0),
$queryBuilder->expr()->eq('hidden', 0)
)
@@ -805,6 +823,7 @@ class ProductShowJsonRenderer
$related = $queryBuilder
->select('p.uid', 'p.title', 'p.slug', 'p.subtitle', 'p.teaser', 'p.description')
->addSelectLiteral('t.cardtext')
->from('tx_vitec_domain_model_product', 'p')
->join(
'p',
@@ -812,6 +831,16 @@ class ProductShowJsonRenderer
'mm',
'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(
$queryBuilder->expr()->eq('mm.uid_local', $queryBuilder->createNamedParameter($productUid, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('p.deleted', 0),
@@ -831,6 +860,7 @@ class ProductShowJsonRenderer
'subtitle' => (string)($rel['subtitle'] ?? ''),
'teaser' => (string)($rel['teaser'] ?? ''),
'description' => RteResolver::html($rel['description'] ?? ''),
'cardtext' => (string)($rel['cardtext'] ?? ''),
'link' => '/product/' . (string)($rel['slug'] ?? ''),
'images' => $this->getProductImages($relUid),
];

View File

@@ -3,6 +3,7 @@
declare(strict_types=1);
use Evomedien\Vitec\Controller\Backend\ImportController;
use Evomedien\Vitec\Controller\Backend\ProductTextImportController;
use Evomedien\Vitec\Controller\Backend\WordPressImportController;
use Evomedien\Vitec\Controller\OgImageController;
@@ -34,6 +35,20 @@ return [
'target' => ImportController::class . '::seoUploadAction',
'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' => [

View File

@@ -25,5 +25,14 @@ return [
'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',
],
],
],
];

View File

@@ -252,3 +252,29 @@ tt_content {
# =============================================================================
page.10.fields.jsonLd = USER
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}
}
}
}

View File

@@ -23,12 +23,12 @@ return [
],
],
'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--;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--;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'],
],
'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' => [
'exclude' => true,
'label' => 'Select Content Element for Key Features Section',

View File

@@ -127,3 +127,28 @@ mod.web_layout.tt_content.preview {
# Hinweis: Content-Blocks-Previews werden automatisch aus
# packages/vitec/ContentBlocks/ContentElements/<n>/templates/backend-preview.html geladen.
# 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
}
}

View File

@@ -23,6 +23,7 @@
| 1.7 | 20260806 | **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 B4 duplication (three copies of the fallback) — consolidation target remains a shared fileresolver service (10.2). |
| 1.8 | 20260813 | **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 toplevel blocks (B13), and `appearance.layout` is represented differently on the two envelope paths (B14). `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). B11 and B12 recorded as resolved. |
| 1.9 | 20260815 | **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 | 20260818 | **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`, pagelevel shape) and `background` (7.4). Two record link handlers (`download`, `product`) added to the link browser, resolved serverside 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 B4 target (10.2). Backendonly: `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,
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`).
### 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`
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/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 pageslug prefix), so the detail subpage could never answer it. The browser URL is unchanged; a nonmatching 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)
- **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
the container TCA (Annex B5).
**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
(depthcapped at 5, cyclesafe); children are stripped of containerlevel fields as in
the pagelevel rendering; nested plugins resolve per 9.7.
### 7.5 Success Story (use case) detail payload
Produced by `UsecaseSerializer::serializeDetail()` — the reference implementation of
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
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 | readytoemit 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 detailonly: 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 Contenttype 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
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 serverside — `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>` | forceddownload endpoint (5.3), file lookup via `DownloadFileResolver` |
---
## 10 Maintainability and upgradesafety (ISO 25010)
@@ -1208,4 +1264,4 @@ remediation.
- Header convention — the uniform header section across CEs, plugins and containers.
- `Configuration/Sets/Vitecset/setup.typoscript` — the single TypoScript entry point.
*End of document EVOVITECHL001 v1.7.*
*End of document EVOVITECHL001 v1.10.*

View File

@@ -103,7 +103,7 @@ as the reference).
| Products | `vitec_productlist` / `vitec_productshow` | `ProductList/ProductShowJsonRenderer` | `products` / `product` |
| Success Stories | `vitec_usecaselist` / `vitec_usecaseshow` | `UsecaseList/ShowJsonRenderer``UsecaseSerializer` | `usecases` / `usecase` |
| 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` |
| Datasheets | `vitec_datasheets` | `DatasheetsJsonRenderer` | `datasheets` |
| Events | `vitec_eventlist` | `EventlistJsonRenderer` | `eventlist` |
@@ -113,6 +113,10 @@ as the reference).
| Forms | `vitec_contactform` / `vitec_demoform` / `vitec_helpdeskform` | `FormsJsonRenderer` | `form` |
| 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
`product`, `story`, `market` or `solution` plus a record, and every card field comes
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 |
| `faq` | Accordion; also feeds the `FAQPage` structured data |
| `columns` | Twocolumn layout (50/50 · 66/33 · 33/66) with peritem content |
| `quotation` | Quote / testimonial (shared header section, header optional) |
| `featured-content` | Featured teaser with image, colour overlay and CTA |
## Layout containers
@@ -179,9 +185,11 @@ Beyond `content[]`, every page response carries:
| `favicons` | `FaviconsJsonRenderer` — readytorender `<link>` descriptors plus `themeColor` |
| `jsonLd` | `PageJsonLdRenderer` (see below) |
Two frontend middlewares run before page resolution: `vitec/form-submission` (the form
endpoint) and `vitec/success-story-path-rewrite`, which lets the public SEO URL
`/success-stories/<slug>` resolve to the detail subpage without changing the browser URL.
Three frontend middlewares run before page resolution: `vitec/form-submission` (the form
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;
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 (JSONLD)
@@ -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
(Market, Solution, Product) with a persistable column mapper and a unified
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,
diffs it against the previous one and checks the CSV structure against the
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 |
|---|---|
| `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: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: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.
@@ -278,7 +300,7 @@ packages/vitec/
│ ├── Service/ # Serializers, ContentElementResolver, StructuredDataService
│ ├── DataProcessing/ # ContainerChildrenProcessor (container → items)
│ ├── Forms/ # FormDefinitions + Delivery/ (email, salesforce)
│ ├── Middleware/ # form endpoint, success-story path rewrite
│ ├── Middleware/ # form endpoint, success-story rewrite, download streaming
│ ├── Domain/Model|Repository/
│ ├── Controller/ # Extbase controllers (non-headless / backend)
│ └── Backend/ · View/ · Hook/ · EventListener/ · Tca/ · Preview/

View File

@@ -11,14 +11,21 @@
</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;">
<f:for each="{models}" as="m">
<li class="nav-item">
<a class="nav-link {f:if(condition: '{m.key} == {model.key}', then: 'active')}"
href="{f:be.uri(route: 'web_vitecimport', parameters: {model: m.key})}">{m.label}</a>
</li>
<f:if condition="{m.key} != 'product'">
<li class="nav-item">
<a class="nav-link {f:if(condition: '{m.key} == {model.key}', then: 'active')}"
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" 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>
@@ -134,6 +141,7 @@
<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:switch>
<f:if condition="{row.viaAlias}"><br /><span class="vitec-badge vitec-badge-info">linked</span></f:if>
</td>
<td>
<strong>{row.label}</strong>
@@ -141,6 +149,14 @@
<f:if condition="{row.status} == 'update'">
<br /><small class="text-muted">changes: {row.diff}</small>
</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">&mdash; not linked &mdash;</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>
<f:if condition="{row.status} != 'dbonly'">
@@ -156,10 +172,18 @@
<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"><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">
<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>
</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>

View File

@@ -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 &mdash; {product.title}</h2>
</div>
</div>
<p>
<a href="{f:be.uri(route: 'web_vitecimport.products')}">&larr; back to product list</a>
&nbsp;&nbsp;<small class="text-muted">uid {product.uid} &middot; {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> &middot; loaded {workbook.date} &middot;
</f:if>
<code>{metaUrl}</code> &middot; 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} &mdash; 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="">&mdash; do not import &mdash;</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} &mdash; {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">&mdash; none &mdash;</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 &mdash; nothing is written.
Only ticked rows are written on "Apply selected".
</p>
</f:if>
</form>
</f:section>
</html>

View File

@@ -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 &mdash; 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 &hellip;" />
</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>

View File

@@ -53,6 +53,11 @@ CREATE TABLE tx_vitec_domain_model_product (
contentelementcta varchar(255) DEFAULT '' NOT NULL,
videofile int(11) 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)
);
CREATE TABLE tx_vitec_domain_model_download (
@@ -311,6 +316,7 @@ CREATE TABLE tx_vitec_import_mapping (
model varchar(64) DEFAULT '' NOT NULL,
identity_field varchar(64) DEFAULT 'slug' NOT NULL,
mapping text,
record_aliases text,
PRIMARY KEY (uid),
KEY model (model)
);
@@ -325,3 +331,34 @@ CREATE TABLE tx_vitec_seo_research (
payload mediumtext,
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)
);

View File

@@ -25,8 +25,8 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<title>VITEC</title>
<script type="module" crossorigin src="/_frontend/assets/index-ARhFRF4y.js"></script>
<link rel="stylesheet" crossorigin href="/_frontend/assets/index-BUkc9k85.css">
<script type="module" crossorigin src="/_frontend/assets/index-DkP6_MI5.js"></script>
<link rel="stylesheet" crossorigin href="/_frontend/assets/index-BIG4WK1s.css">
</head>
<body>
<div id="page"></div>