diff --git a/composer.json b/composer.json index 9ff23bc..276eba8 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/composer.lock b/composer.lock index 6db0f0e..0085145 100644 --- a/composer.lock +++ b/composer.lock @@ -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" } diff --git a/migrations/aligo-test.xlsx b/migrations/aligo-test.xlsx new file mode 100644 index 0000000..bae5877 Binary files /dev/null and b/migrations/aligo-test.xlsx differ diff --git a/migrations/check_workbook.php b/migrations/check_workbook.php new file mode 100644 index 0000000..533f682 --- /dev/null +++ b/migrations/check_workbook.php @@ -0,0 +1,51 @@ +\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"; +} diff --git a/packages/vitec/Classes/Controller/Backend/ImportController.php b/packages/vitec/Classes/Controller/Backend/ImportController.php index 2773c4f..857feee 100644 --- a/packages/vitec/Classes/Controller/Backend/ImportController.php +++ b/packages/vitec/Classes/Controller/Backend/ImportController.php @@ -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 $fields * @param array{columns:array,rows:array>}|null $csv * @param array $mapping - * @return array> + * @param array $aliases + * @return array{rows: array>, candidates: array} */ - 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,rows:array>} $csv + * @param array $mapping + * @param array $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 diff --git a/packages/vitec/Classes/Controller/Backend/ProductTextImportController.php b/packages/vitec/Classes/Controller/Backend/ProductTextImportController.php new file mode 100644 index 0000000..f1a2a48 --- /dev/null +++ b/packages/vitec/Classes/Controller/Backend/ProductTextImportController.php @@ -0,0 +1,630 @@ + + * "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|null $product + * @param array|null $parsed + * @param array $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 $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 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|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 $parsed + * @return array + */ + 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 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); + } +} diff --git a/packages/vitec/Classes/Domain/Model/Product.php b/packages/vitec/Classes/Domain/Model/Product.php index 50c4aff..fdb25da 100755 --- a/packages/vitec/Classes/Domain/Model/Product.php +++ b/packages/vitec/Classes/Domain/Model/Product.php @@ -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 + * @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 + */ + public function getHeroimage(): ObjectStorage + { + return $this->heroimage; + } + + /** + * @param ObjectStorage $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; + } + /* --------------------------------------------------------------------- */ } diff --git a/packages/vitec/Classes/Import/MappingRepository.php b/packages/vitec/Classes/Import/MappingRepository.php index 9998156..27bf8bf 100644 --- a/packages/vitec/Classes/Import/MappingRepository.php +++ b/packages/vitec/Classes/Import/MappingRepository.php @@ -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, identity: string}|null + * @return array{mapping: array, identity: string, aliases: array}|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 $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 $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 $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]); diff --git a/packages/vitec/Classes/Import/ProductXlsxReader.php b/packages/vitec/Classes/Import/ProductXlsxReader.php new file mode 100644 index 0000000..1880861 --- /dev/null +++ b/packages/vitec/Classes/Import/ProductXlsxReader.php @@ -0,0 +1,245 @@ +#: one cell of the n-th occurrence of a component + * (cell = title | body | cta) + * m: meta block (url, primaryKeyword, intent, seoApproach) + * s:
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, + * components: array>, + * seo: array + * } + */ + 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 $parsed + * @return array> + */ + 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 $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> $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 $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, + ]; + } +} diff --git a/packages/vitec/Classes/Middleware/DownloadFileMiddleware.php b/packages/vitec/Classes/Middleware/DownloadFileMiddleware.php new file mode 100644 index 0000000..e9fd9dc --- /dev/null +++ b/packages/vitec/Classes/Middleware/DownloadFileMiddleware.php @@ -0,0 +1,56 @@ + + * + * 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); + } +} diff --git a/packages/vitec/Classes/PageTitle/ProductPageTitleProvider.php b/packages/vitec/Classes/PageTitle/ProductPageTitleProvider.php index a43be6e..a53d148 100644 --- a/packages/vitec/Classes/PageTitle/ProductPageTitleProvider.php +++ b/packages/vitec/Classes/PageTitle/ProductPageTitleProvider.php @@ -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 = ''; diff --git a/packages/vitec/Classes/Service/ContentElementResolver.php b/packages/vitec/Classes/Service/ContentElementResolver.php index e5844b1..88cb79d 100755 --- a/packages/vitec/Classes/Service/ContentElementResolver.php +++ b/packages/vitec/Classes/Service/ContentElementResolver.php @@ -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 $record + * @param bool $isContainerChild strip container-level fields (bg, gap, + * col flex) like the page-level processor + * does for its children * @return array */ - 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 $record raw tt_content row + * @param array $element normalised element, modified in place + * @param array $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 $record + * @return array|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'; diff --git a/packages/vitec/Classes/Service/DownloadFileResolver.php b/packages/vitec/Classes/Service/DownloadFileResolver.php new file mode 100644 index 0000000..2b887a5 --- /dev/null +++ b/packages/vitec/Classes/Service/DownloadFileResolver.php @@ -0,0 +1,226 @@ +|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 ____-. 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, + ]; + } +} diff --git a/packages/vitec/Classes/Service/LinkResolver.php b/packages/vitec/Classes/Service/LinkResolver.php index cfc2a46..610f2b0 100644 --- a/packages/vitec/Classes/Service/LinkResolver.php +++ b/packages/vitec/Classes/Service/LinkResolver.php @@ -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; + } + } } diff --git a/packages/vitec/Classes/UserFunc/ProductListJsonRenderer.php b/packages/vitec/Classes/UserFunc/ProductListJsonRenderer.php index 25512c0..8e92d2c 100755 --- a/packages/vitec/Classes/UserFunc/ProductListJsonRenderer.php +++ b/packages/vitec/Classes/UserFunc/ProductListJsonRenderer.php @@ -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), diff --git a/packages/vitec/Classes/UserFunc/ProductShowJsonRenderer.php b/packages/vitec/Classes/UserFunc/ProductShowJsonRenderer.php index 77f4330..d56687b 100755 --- a/packages/vitec/Classes/UserFunc/ProductShowJsonRenderer.php +++ b/packages/vitec/Classes/UserFunc/ProductShowJsonRenderer.php @@ -164,6 +164,19 @@ class ProductShowJsonRenderer : ''; } + // Page 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), ]; diff --git a/packages/vitec/Configuration/Backend/Modules.php b/packages/vitec/Configuration/Backend/Modules.php index 2d67d72..a1a0fe9 100644 --- a/packages/vitec/Configuration/Backend/Modules.php +++ b/packages/vitec/Configuration/Backend/Modules.php @@ -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' => [ diff --git a/packages/vitec/Configuration/RequestMiddlewares.php b/packages/vitec/Configuration/RequestMiddlewares.php index 740ce85..42f7aa6 100755 --- a/packages/vitec/Configuration/RequestMiddlewares.php +++ b/packages/vitec/Configuration/RequestMiddlewares.php @@ -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', + ], + ], ], ]; diff --git a/packages/vitec/Configuration/Sets/Vitecset/setup.typoscript b/packages/vitec/Configuration/Sets/Vitecset/setup.typoscript index 0b52957..d9ad61c 100755 --- a/packages/vitec/Configuration/Sets/Vitecset/setup.typoscript +++ b/packages/vitec/Configuration/Sets/Vitecset/setup.typoscript @@ -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} + } + } +} diff --git a/packages/vitec/Configuration/TCA/tx_vitec_domain_model_product.php b/packages/vitec/Configuration/TCA/tx_vitec_domain_model_product.php index b1ddb93..e5f5fac 100755 --- a/packages/vitec/Configuration/TCA/tx_vitec_domain_model_product.php +++ b/packages/vitec/Configuration/TCA/tx_vitec_domain_model_product.php @@ -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', diff --git a/packages/vitec/Configuration/page.tsconfig b/packages/vitec/Configuration/page.tsconfig index b477a16..6548810 100755 --- a/packages/vitec/Configuration/page.tsconfig +++ b/packages/vitec/Configuration/page.tsconfig @@ -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 + } +} diff --git a/packages/vitec/Documentation/Headless-JSON-Architecture.md b/packages/vitec/Documentation/Headless-JSON-Architecture.md index eb7c392..a17749f 100755 --- a/packages/vitec/Documentation/Headless-JSON-Architecture.md +++ b/packages/vitec/Documentation/Headless-JSON-Architecture.md @@ -23,6 +23,7 @@ | 1.7 | 2026‑08‑06 | **Robustness:** the import normalizes legacy filenames on fetch so every imported file matches the version convention (`__NN_A` → `__NN-A`, `___NN` → `__NN`, `__NNA` → `__NN-A`, bare `__NN` → `__NN-A` as initial revision), and the three download renderers gained a `filepath` fallback in `getDownloadFile()` (FAL → convention → filepath) as a safety net for anything that still escapes it. Extends the B‑4 duplication (three copies of the fallback) — consolidation target remains a shared file‑resolver service (10.2). | | 1.8 | 2026‑08‑13 | **Defect fix and interface change (additive).** Content Blocks never carried the Core *Appearance* tab: `layout`, `frame_class` — including the VITEC frame classes — `space_before_class`, `space_after_class`, `sectionIndex` and `linkToTop` were unreachable for editors on all nine blocks. Added centrally for every `vitec_*` type (7.7); the `appearance` envelope is unchanged, its values were merely always default. Side effect: those six columns now also appear raw inside `data` on top‑level blocks (B‑13), and `appearance.layout` is represented differently on the two envelope paths (B‑14). `intro-paragraph` gained `background_color` (7.7). `vitec_eventlist` gained the layout `regions`, emitting a `regions` array built from the region categories below parent 104; the event payload is specified for the first time (7.14). B‑11 and B‑12 recorded as resolved. | | 1.9 | 2026‑08‑15 | **Interface change, partly breaking.** The four list plugins were unified: every one of them now emits an object carrying `layout`, the new `showToolbar` flag and its payload array. `vitec_usecaselist` and `vitec_productlist` previously emitted a **bare array** — front ends reading them have to move one level down (7.3, 7.14, 8). New plugin **`vitec_solutionlist`** (`SolutionListJsonRenderer`, key `solutions`), the counterpart 7.13.2 had been asking for since v1.2. `tx_vitec_domain_model_usecase` gained `detail_page`, emitted as the resolved `detailUrl` in the story card shape and therefore also in the `vitec_modelcard` story branch, which had carried no link at all until now. Noted: `vitec_productlist` had a configurable `layout` that was never serialised, and its vocabulary (`0`–`3`) differs from the other lists. | +| 1.10 | 2026‑08‑18 | **Interface change (additive).** `tx_vitec_domain_model_product` gained five fields: `heroimage` (multiple FAL images, detail payload only), the richtext fields `description2`, `capabilities` and `textrelatedproducts`, and `portfolio` (TCA `link`) — emitted as a **resolved URL** through the new `LinkResolver::typolinkUrl()`. The product payloads are specified for the first time (7.15). Links in `contentelement` / `contentelementcta` that point at a **container** now resolve its children (`items`, page‑level shape) and `background` (7.4). Two record link handlers (`download`, `product`) added to the link browser, resolved server‑side per the new Clause 9.12; new middleware `vitec/download-file` streams `/download/file/<uid>` as a forced download (5.3), file lookup consolidated into `DownloadFileResolver` — first step towards the B‑4 target (10.2). Backend‑only: `relatedprodukt` moved from the Misc tab to General. Editorial: the document footer had been stuck at v1.7 since v1.8. | This document is drafted in the style of, and adopts the terminology conventions of, 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 page‑slug prefix), so the detail subpage could never answer it. The browser URL is unchanged; a non‑matching slug leaves the request untouched; any exception leaves the request untouched. | +| `vitec/download-file` | Answers `GET /download/file/<uid>`: resolves the download record's file (`DownloadFileResolver`: FAL → Collateral naming convention → `filepath`) and streams it with `Content-Disposition: attachment` (Clause 9.12). Unknown uid, hidden record or missing file fall through to normal page resolution. | ### 5.4 Architectural principles (rationale) - **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 B‑5). +**Linked containers (since v1.10).** When a link resolved through +`ContentElementResolver` (e.g. `contentelement` / `contentelementcta`, 7.15) points at +a container, the resolver attaches the container's children under `items` in exactly +the column shape above, plus the resolved `background`. Nested containers recurse +(depth‑capped at 5, cycle‑safe); children are stripped of container‑level fields as in +the page‑level rendering; nested plugins resolve per 9.7. + ### 7.5 Success Story (use case) detail payload 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 | ready‑to‑emit schema.org `@graph` (6.6), detail only | + +#### 7.15.2 List payload (`vitec_productlist`, key `products`) + +List envelope per 7.3 (`layout`, `showToolbar`, `products[]`) — since v1.9. Each item +carries the member set of 7.15.1 **except** `heroimage` and `jsonLd`. `heroimage` is +deliberately detail‑only: one further FAL query per product would buy data a list +rarely shows. The `layout` vocabulary remains `0`–`3` (see the v1.9 note). + --- ## 8 Content‑type catalogue @@ -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 server‑side — `LinkResolver::pageUrl()` for page uids, +`LinkResolver::typolinkUrl()` for full typolink parameters — under the shared +contract **null means "no link"**: a hidden or deleted target resolves to null, +never to a broken URL. + +The link browser offers two record link handlers (`TCEMAIN.linkHandler`, +`Configuration/page.tsconfig`), stored as `t3://record?identifier=<key>&uid=<n>` +and resolved by `config.recordLinks` in `setup.typoscript`: + +| Identifier | Resolves to | Notes | +|---|---|---| +| `product` | `/product/<slug>` | same convention as the `link` member (7.15); assumes the product detail page (uid 10) answers `/product` | +| `download` | `/download/file/<uid>` | forced‑download endpoint (5.3), file lookup via `DownloadFileResolver` | + --- ## 10 Maintainability and upgrade‑safety (ISO 25010) @@ -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 EVO‑VITEC‑HL‑001 v1.7.* +*End of document EVO‑VITEC‑HL‑001 v1.10.* diff --git a/packages/vitec/README.md b/packages/vitec/README.md index 6ea6dbd..f99fff1 100755 --- a/packages/vitec/README.md +++ b/packages/vitec/README.md @@ -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` | Two‑column layout (50/50 · 66/33 · 33/66) with per‑item content | +| `quotation` | Quote / testimonial (shared header section, header optional) | +| `featured-content` | Featured teaser with image, colour overlay and CTA | ## Layout containers @@ -179,9 +185,11 @@ Beyond `content[]`, every page response carries: | `favicons` | `FaviconsJsonRenderer` — ready‑to‑render `<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 (JSON‑LD) @@ -194,17 +202,31 @@ endpoint) and `vitec/success-story-path-rewrite`, which lets the public SEO URL **Backend module "VITEC Import"** (Web menu): CSV import per domain model (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/ diff --git a/packages/vitec/Resources/Private/Templates/Import/Index.html b/packages/vitec/Resources/Private/Templates/Import/Index.html index b750582..dd4edba 100644 --- a/packages/vitec/Resources/Private/Templates/Import/Index.html +++ b/packages/vitec/Resources/Private/Templates/Import/Index.html @@ -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">— not linked —</option> + <f:for each="{aliasCandidates}" as="cand"> + <option value="{cand.uid}" {f:if(condition: '{cand.uid} == {row.aliasUid}', then: 'selected')}>{cand.title} (uid {cand.uid})</option> + </f:for> + </select> + </f:if> </td> <td> <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> diff --git a/packages/vitec/Resources/Private/Templates/Import/ProductTexts.html b/packages/vitec/Resources/Private/Templates/Import/ProductTexts.html new file mode 100644 index 0000000..4e2ebfe --- /dev/null +++ b/packages/vitec/Resources/Private/Templates/Import/ProductTexts.html @@ -0,0 +1,190 @@ +<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" + data-namespace-typo3-fluid="true"> + +<f:layout name="Backend/Default" /> + +<f:section name="main"> + +<div class="module-docheader-bar module-docheader-bar-navigation"> + <div class="module-docheader-bar-column-left"> + <h2 class="t3js-title-inlineedit">Edit Product — {product.title}</h2> + </div> +</div> + +<p> + <a href="{f:be.uri(route: 'web_vitecimport.products')}">← back to product list</a> +   <small class="text-muted">uid {product.uid} · {product.slug}</small> +</p> + +<form action="{f:be.uri(route: 'web_vitecimport.product_preview')}" method="post" enctype="multipart/form-data"> + <input type="hidden" name="uid" value="{product.uid}" /> + <input type="hidden" name="parsed" value="{parsedJson}" /> + + <!-- Upload --> + <div class="card" style="margin-bottom:1rem;"> + <div class="card-body"> + <div class="row row-cols-auto align-items-center g-2"> + <div class="col"> + <input type="file" class="form-control" name="xlsxfile" accept=".xlsx,.xls" /> + </div> + <div class="col"> + <button type="submit" class="btn btn-default">Load workbook</button> + </div> + <f:if condition="{parsed}"> + <div class="col text-muted"> + <f:if condition="{workbook}"> + <strong>{workbook.filename}</strong> · loaded {workbook.date} · + </f:if> + <code>{metaUrl}</code> · page type <strong>{pageType}</strong> + </div> + </f:if> + </div> + </div> + </div> + + <f:if condition="{parsed}"> + + <f:if condition="{slugMismatch}"> + <div class="callout callout-warning"> + <div class="callout-body"> + <strong>URL mismatch:</strong> {slugMismatch} — is this the right product for this workbook? + </div> + </div> + </f:if> + + <!-- Mapping + preview --> + <table class="table table-striped"> + <thead> + <tr> + <th style="width:11rem;">Product field</th> + <th style="width:26rem;">Source in workbook</th> + <th>Current value</th> + <th>New value</th> + <th style="width:5rem;">Apply</th> + </tr> + </thead> + <tbody> + <f:for each="{rows}" as="row"> + <tr> + <td><strong>{row.label}</strong><br /><small class="text-muted">{row.field}</small></td> + <td> + <select class="form-select form-select-sm" name="map[{row.field}]"> + <option value="">— do not import —</option> + <f:for each="{sources}" as="group" key="groupName"> + <optgroup label="{groupName}"> + <f:for each="{group}" as="src"> + <option value="{src.selector}" {f:if(condition: '{src.selector} == {row.selector}', then: 'selected="selected"')}>{src.label} — {src.preview}</option> + </f:for> + </optgroup> + </f:for> + </select> + </td> + <td><small>{row.oldPreview}</small></td> + <td> + <f:if condition="{row.new}"> + <f:then> + <small>{row.newPreview}</small> + <f:if condition="{row.changed}"><br /><span class="badge bg-warning">differs</span></f:if> + </f:then> + <f:else> + <f:if condition="{row.selector}"> + <small class="text-muted">not in this file</small> + </f:if> + </f:else> + </f:if> + </td> + <td> + <input class="form-check-input" type="checkbox" name="apply[]" value="{row.field}" + {f:if(condition: row.changed, then: 'checked="checked"')} /> + </td> + </tr> + </f:for> + </tbody> + </table> + + <f:if condition="{relatedRows}"> + <div class="card" style="margin-bottom:1rem;"> + <div class="card-header">Related products from the workbook</div> + <div class="card-body"> + <p class="text-muted"> + Each ticked card writes its text and adds the missing relation + (existing relations and their order are never touched, nothing is removed). + Your manual choices are remembered for the next workbook. + </p> + <table class="table table-striped"> + <thead> + <tr> + <th style="width:11rem;">Card in workbook</th> + <th style="width:20rem;">Target product</th> + <th>Card text (new)</th> + <th>Card text (current)</th> + <th style="width:5rem;">Apply</th> + </tr> + </thead> + <tbody> + <f:for each="{relatedRows}" as="rel"> + <tr> + <td> + <strong>{rel.title}</strong> + <f:if condition="{rel.cta}"><br /><small class="text-muted">CTA: {rel.cta}</small></f:if> + </td> + <td> + <select class="form-select form-select-sm" name="related[{rel.index}]"> + <option value="0">— none —</option> + <f:for each="{productOptions}" as="opt"> + <option value="{opt.uid}" {f:if(condition: '{opt.uid} == {rel.selected}', then: 'selected="selected"')}>{opt.title}</option> + </f:for> + </select> + <f:if condition="{rel.status} == 'alias'"><span class="badge bg-info">remembered</span></f:if> + <f:if condition="{rel.status} == 'title'"><span class="badge bg-success">title match</span></f:if> + <f:if condition="{rel.status} == 'none'"><span class="badge bg-warning">no match</span></f:if> + <f:if condition="{rel.selected}"> + <f:if condition="{rel.isRelated}"> + <f:else><span class="badge bg-secondary">relation will be added</span></f:else> + </f:if> + </f:if> + </td> + <td><small>{rel.preview}</small></td> + <td> + <small>{rel.oldPreview}</small> + <f:if condition="{rel.changed}"><br /><span class="badge bg-warning">differs</span></f:if> + </td> + <td> + <input class="form-check-input" type="checkbox" name="applyRelated[]" value="{rel.index}" + {f:if(condition: rel.changed, then: 'checked="checked"')} /> + </td> + </tr> + </f:for> + </tbody> + </table> + </div> + </div> + </f:if> + + <div class="row row-cols-auto align-items-center g-2" style="margin-bottom:2rem;"> + <div class="col"> + <button type="submit" class="btn btn-default">Refresh preview</button> + </div> + <div class="col"> + <button type="submit" class="btn btn-primary" + formaction="{f:be.uri(route: 'web_vitecimport.product_apply')}">Apply selected</button> + </div> + <div class="col"> + <div class="form-check"> + <input class="form-check-input" type="checkbox" name="saveMapping" id="saveMapping" value="1" checked="checked" /> + <label class="form-check-label" for="saveMapping">Save mapping for the next product</label> + </div> + </div> + </div> + + <p class="text-muted"> + "Refresh preview" re-reads the values after changing a dropdown — nothing is written. + Only ticked rows are written on "Apply selected". + </p> + + </f:if> + +</form> + +</f:section> +</html> diff --git a/packages/vitec/Resources/Private/Templates/Import/Products.html b/packages/vitec/Resources/Private/Templates/Import/Products.html new file mode 100644 index 0000000..9aefaf2 --- /dev/null +++ b/packages/vitec/Resources/Private/Templates/Import/Products.html @@ -0,0 +1,99 @@ +<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" + data-namespace-typo3-fluid="true"> + +<f:layout name="Backend/Default" /> + +<f:section name="main"> + +<div class="module-docheader-bar module-docheader-bar-navigation"> + <div class="module-docheader-bar-column-left"> + <h2 class="t3js-title-inlineedit">VITEC Import — Products</h2> + </div> +</div> + +<!-- Tabs: Markets/Solutions stay on the CSV flow, Products is this overview --> +<ul class="nav nav-tabs" style="margin-bottom:1rem;"> + <f:for each="{models}" as="m"> + <f:if condition="{m.key} != 'product'"> + <li class="nav-item"> + <a class="nav-link" href="{f:be.uri(route: 'web_vitecimport', parameters: {model: m.key})}">{m.label}</a> + </li> + </f:if> + </f:for> + <li class="nav-item"> + <a class="nav-link active" href="{f:be.uri(route: 'web_vitecimport.products')}">Products</a> + </li> + <li class="nav-item"> + <a class="nav-link" href="{f:be.uri(route: 'web_vitecimport.seo')}">SEO Research</a> + </li> +</ul> + +<!-- Search --> +<div class="card" style="margin-bottom:1rem;"> + <div class="card-body"> + <form action="{f:be.uri(route: 'web_vitecimport.products')}" method="post" class="row row-cols-auto align-items-center g-2"> + <div class="col"> + <input type="text" class="form-control" name="q" value="{q}" placeholder="Search title or slug …" /> + </div> + <div class="col"> + <button type="submit" class="btn btn-default">Search</button> + </div> + <f:if condition="{q}"> + <div class="col"> + <a class="btn btn-link" href="{f:be.uri(route: 'web_vitecimport.products')}">Reset</a> + </div> + </f:if> + <div class="col text-muted">{total} products</div> + </form> + <f:if condition="{searchHistory}"> + <div style="margin-top:0.6rem;"> + <small class="text-muted" style="margin-right:0.4rem;">Recent:</small> + <f:for each="{searchHistory}" as="term"> + <a class="badge {f:if(condition: '{term} == {q}', then: 'bg-primary', else: 'bg-secondary')}" + style="text-decoration:none;margin-right:0.3rem;" + href="{f:be.uri(route: 'web_vitecimport.products', parameters: {q: term})}">{term}</a> + </f:for> + </div> + </f:if> + </div> +</div> + +<table class="table table-striped table-hover"> + <thead> + <tr> + <th> + <a href="{f:be.uri(route: 'web_vitecimport.products', parameters: {q: q, sort: 'title', dir: sortLinks.title})}">Title {sortArrows.title}</a> + </th> + <th> + <a href="{f:be.uri(route: 'web_vitecimport.products', parameters: {q: q, sort: 'slug', dir: sortLinks.slug})}">Slug {sortArrows.slug}</a> + </th> + <th style="width:10rem;"> + <a href="{f:be.uri(route: 'web_vitecimport.products', parameters: {q: q, sort: 'tstamp', dir: sortLinks.tstamp})}">Changed {sortArrows.tstamp}</a> + </th> + <th style="width:10rem;">Content</th> + <th style="width:9rem;"></th> + </tr> + </thead> + <tbody> + <f:for each="{products}" as="p"> + <tr> + <td> + {p.title} + <f:if condition="{p.hidden}"> <span class="badge bg-secondary">hidden</span></f:if> + </td> + <td><small class="text-muted">{p.slug}</small></td> + <td>{p.changed}</td> + <td> + <span class="vitec-badge">{f:if(condition: p.hasTeaser, then: '✓', else: '–')} teaser</span> + <span class="vitec-badge">{f:if(condition: p.hasDescription, then: '✓', else: '–')} descr.</span> + </td> + <td> + <a class="btn btn-primary btn-sm" href="{f:be.uri(route: 'web_vitecimport.product_edit', parameters: {uid: p.uid})}">Edit Product</a> + </td> + </tr> + </f:for> + </tbody> +</table> + +</f:section> +</html> diff --git a/packages/vitec/ext_tables.sql b/packages/vitec/ext_tables.sql index e4ec048..157e4c0 100755 --- a/packages/vitec/ext_tables.sql +++ b/packages/vitec/ext_tables.sql @@ -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) +); diff --git a/public/_frontend/index.html b/public/_frontend/index.html index c561421..dca7925 100644 --- a/public/_frontend/index.html +++ b/public/_frontend/index.html @@ -25,8 +25,8 @@ <link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <title>VITEC - - + +