Product text import from agency XLSX workbooks

VITEC Import module: Products tab is now a searchable/sortable product
overview. "Edit Product" opens an XLSX upload with per-field source
mapping (component + cell), old/new preview and checkbox apply via
DataHandler; the mapping and manual matches are persisted and preselect
the next workbook. Category workbooks are detected and rejected.

- new: ProductXlsxReader (PhpSpreadsheet), ProductTextImportController,
  Products/ProductTexts templates, 4 module routes
- related products: per-pair card text in new side table
  tx_vitec_product_related_text (survives MM rewrites), emitted as
  `cardtext` in the product JSON; missing MM relations added add-only
- card copy read from Body Copy (D) with fallback to CTA/Card Copy (E) -
  the workbooks fill either depending on row type
- product detail page <title> now uses seotitle with title fallback
  (provider made singleton and fed from the JSON renderer)
- per-user recent-search badges; last loaded workbook stored per product
  (tx_vitec_product_workbook), Edit Product reopens on it
- composer: add phpoffice/phpspreadsheet ^5.9
- diagnostics: migrations/check_workbook.php
This commit is contained in:
2026-08-21 11:00:45 +02:00
parent 12eb40614b
commit 7bd1161abf
28 changed files with 2594 additions and 39 deletions

View File

@@ -31,6 +31,9 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
* used for matching) is persisted per model via MappingRepository.
* 3. A unified list shows CSV rows matched against the DB records:
* new / update (with the differing fields) / unchanged / db-only.
* Rows the identity match misses can be linked by hand to an existing
* record (alias select on every "new" row); the links persist per
* model next to the column mapping and are re-applied on upload.
* Each importable row carries an editable JSON payload textarea -
* what is applied is the textarea, not the raw CSV.
* 4. Checked rows are written through DataHandler (BE user permissions
@@ -113,6 +116,16 @@ final class ImportController
$this->flash('Mapping stored for this model - it will be preselected on the next upload.', 'Mapping saved', true);
}
if ($op === 'saveAliases' && $csv !== null && $mapping !== null && ($identity ?? '') !== '') {
$this->saveAliases(
$modelKey,
$csv,
$mapping,
(string)$identity,
is_array($body['alias'] ?? null) ? $body['alias'] : []
);
}
if ($op === 'apply') {
$this->apply($modelKey, $body);
}
@@ -187,7 +200,8 @@ final class ImportController
$identity = $saved['identity'] ?? (isset($fields['slug']) ? 'slug' : 'title');
}
$union = $this->buildUnion($model['table'], $fields, $csv, $mapping, $identity);
['rows' => $union, 'candidates' => $aliasCandidates] =
$this->buildUnion($model['table'], $fields, $csv, $mapping, $identity, $saved['aliases'] ?? []);
$counts = ['new' => 0, 'update' => 0, 'unchanged' => 0, 'dbonly' => 0];
foreach ($union as $row) {
@@ -215,7 +229,8 @@ final class ImportController
'csvRowCount' => count($csv['rows'] ?? []),
'csvstate' => $csv !== null ? (string)json_encode($csv, JSON_UNESCAPED_UNICODE) : '',
'identity' => $identity,
'mappingSaved' => $saved !== null,
'mappingSaved' => ($saved['mapping'] ?? []) !== [],
'aliasCandidates' => $aliasCandidates,
'union' => $union,
'counts' => $counts,
'pid' => $this->registry->detectPid($model['table']),
@@ -246,12 +261,17 @@ final class ImportController
/**
* Merge CSV rows and DB records into one status-annotated list.
*
* Matching order per CSV row: identity field first, then the stored
* aliases. Alias candidates are all records the identity match did not
* claim - those are what the per-row select in the template offers.
*
* @param array<string,string> $fields
* @param array{columns:array<int,string>,rows:array<int,array<string,string>>}|null $csv
* @param array<string,string> $mapping
* @return array<int,array<string,mixed>>
* @param array<string,int> $aliases
* @return array{rows: array<int,array<string,mixed>>, candidates: array<int,array{uid:int,title:string}>}
*/
private function buildUnion(string $table, array $fields, ?array $csv, array $mapping, string $identity): array
private function buildUnion(string $table, array $fields, ?array $csv, array $mapping, string $identity, array $aliases): array
{
$mappedFields = array_values(array_unique(array_values($mapping)));
$records = $this->registry->loadRecords($table, array_keys($fields));
@@ -266,6 +286,7 @@ final class ImportController
$union = [];
$matchedUids = [];
$directUids = [];
$index = 0;
foreach ($csv['rows'] ?? [] as $raw) {
@@ -275,9 +296,20 @@ final class ImportController
}
$identityValue = mb_strtolower(trim((string)($payload[$identity] ?? '')));
$uid = $identityValue !== '' ? ($byIdentity[$identityValue] ?? 0) : 0;
$viaAlias = false;
if ($uid === 0 && $identityValue !== '') {
$aliasUid = (int)($aliases[$identityValue] ?? 0);
if ($aliasUid > 0 && isset($records[$aliasUid])) {
$uid = $aliasUid;
$viaAlias = true;
}
}
if ($uid > 0) {
$matchedUids[$uid] = true;
if (!$viaAlias) {
$directUids[$uid] = true;
}
$record = $records[$uid];
$diff = [];
foreach ($payload as $field => $value) {
@@ -299,6 +331,9 @@ final class ImportController
'diff' => implode(', ', $diff),
'payload' => (string)json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'checked' => $status === 'new' || $status === 'update',
'viaAlias' => $viaAlias,
'aliasable' => $viaAlias || $status === 'new',
'aliasUid' => $viaAlias ? $uid : 0,
];
}
@@ -317,7 +352,61 @@ final class ImportController
];
}
return $union;
$candidates = [];
foreach ($records as $uid => $record) {
if (isset($directUids[$uid])) {
continue;
}
$candidates[] = ['uid' => $uid, 'title' => (string)($record['title'] ?? $uid)];
}
usort($candidates, static fn(array $a, array $b): int => strcasecmp($a['title'], $b['title']));
return ['rows' => $union, 'candidates' => $candidates];
}
/**
* Merge the alias selects into the stored aliases. Key is the CSV
* identity value (lowercased, like the matcher sees it), value the
* record uid; uid 0 removes a link. Untouched keys stay - a CSV that
* lacks a row must not lose that row's link.
*
* @param array{columns:array<int,string>,rows:array<int,array<string,string>>} $csv
* @param array<string,string> $mapping
* @param array<int|string,mixed> $input row index => uid, from the form
*/
private function saveAliases(string $modelKey, array $csv, array $mapping, string $identity, array $input): void
{
$aliases = $this->mappingRepository->load($modelKey)['aliases'] ?? [];
$changed = 0;
foreach ($input as $rowIndex => $selectedUid) {
$raw = $csv['rows'][(int)$rowIndex] ?? null;
if ($raw === null) {
continue;
}
$payload = [];
foreach ($mapping as $column => $field) {
$payload[$field] = (string)($raw[$column] ?? '');
}
$key = mb_strtolower(trim((string)($payload[$identity] ?? '')));
if ($key === '') {
continue;
}
$uid = (int)$selectedUid;
$current = (int)($aliases[$key] ?? 0);
if ($uid > 0 && $uid !== $current) {
$aliases[$key] = $uid;
$changed++;
} elseif ($uid === 0 && $current > 0) {
unset($aliases[$key]);
$changed++;
}
}
$this->mappingRepository->saveAliases($modelKey, $aliases);
$this->flash(
sprintf('%d record link(s) changed, %d stored for this model in total.', $changed, count($aliases)),
'Record links saved',
true
);
}
// ---------------------------------------------------------------- apply

View File

@@ -0,0 +1,630 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Controller\Backend;
use Doctrine\DBAL\ParameterType;
use Evomedien\Vitec\Import\ImportModelRegistry;
use Evomedien\Vitec\Import\MappingRepository;
use Evomedien\Vitec\Import\ProductXlsxReader;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Http\RedirectResponse;
use TYPO3\CMS\Core\Messaging\FlashMessage;
use TYPO3\CMS\Core\Messaging\FlashMessageService;
use TYPO3\CMS\Core\Page\PageRenderer;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Products flow of the "VITEC Import" module: import agency-delivered
* "Product Page Content" workbooks into tx_vitec_domain_model_product.
*
* Flow (per the 2026-08-21 plan): product overview (searchable, sortable) ->
* "Edit Product" -> upload the product's XLSX -> one row per importable product
* field with a dropdown choosing the Excel source (component + cell) -> tick ->
* apply through DataHandler.
*
* The chosen mapping is persisted once, globally (MappingRepository, key
* `product_xlsx`) - the component vocabulary is stable across deliveries, so
* every next product's file arrives pre-mapped.
*
* Stateless like the CSV flow: the parsed workbook travels through the form as
* a hidden JSON field (~10 KB per file), no session, no temp file.
*
* Lives in its own controller to keep the already-large ImportController from
* growing; same module, same look (backend-import.css), same patterns.
*/
#[AsController]
final class ProductTextImportController
{
private const TABLE = 'tx_vitec_domain_model_product';
private const MAPPING_KEY = 'product_xlsx';
/**
* Never offered as import targets. `slug` changes URLs - that is redirect
* territory, not a text import; the workbook URL is checked against the
* record's slug informationally instead.
*/
private const EXCLUDED_TARGETS = ['slug'];
/**
* Pre-seed for the very first mapping (no row in tx_vitec_import_mapping
* yet). Editors change everything in the dropdowns; this only saves the
* first manual pass. Keys are product columns, values source selectors as
* ProductXlsxReader defines them.
*/
private const DEFAULT_MAPPING = [
'subtitle' => 'c:Hero — H1#1:body',
'teaser' => 'c:Product Introduction#1:body',
'description' => 'c:Product Overview — H2#1:body',
'cta' => 'c:Pre-footer CTA — H2#1:cta',
'keywords' => 'm:primaryKeyword',
'key1' => 'c:Why Choose Card#1:title',
'apptext1' => 'c:Why Choose Card#1:body',
'key2' => 'c:Why Choose Card#2:title',
'apptext2' => 'c:Why Choose Card#2:body',
'key3' => 'c:Why Choose Card#3:title',
'apptext3' => 'c:Why Choose Card#3:body',
];
private const SORTABLE = ['title', 'slug', 'tstamp'];
public function __construct(
private readonly ModuleTemplateFactory $moduleTemplateFactory,
private readonly ImportModelRegistry $registry,
private readonly MappingRepository $mappingRepository,
private readonly ProductXlsxReader $reader,
private readonly FlashMessageService $flashMessageService,
private readonly PageRenderer $pageRenderer,
private readonly UriBuilder $uriBuilder,
) {}
// ------------------------------------------------------------- overview
public function productsAction(ServerRequestInterface $request): ResponseInterface
{
$params = array_merge($request->getQueryParams(), (array)$request->getParsedBody());
$q = trim((string)($params['q'] ?? ''));
$sort = in_array((string)($params['sort'] ?? ''), self::SORTABLE, true) ? (string)$params['sort'] : 'title';
$dir = strtolower((string)($params['dir'] ?? 'asc')) === 'desc' ? 'desc' : 'asc';
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::TABLE);
$qb->select('uid', 'title', 'slug', 'tstamp', 'hidden', 'teaser', 'description')
->from(self::TABLE)
->where($qb->expr()->eq('deleted', 0))
->orderBy($sort, $dir === 'desc' ? 'DESC' : 'ASC')
->addOrderBy('title', 'ASC');
if ($q !== '') {
$like = '%' . $qb->escapeLikeWildcards($q) . '%';
$qb->andWhere($qb->expr()->or(
$qb->expr()->like('title', $qb->createNamedParameter($like, ParameterType::STRING)),
$qb->expr()->like('slug', $qb->createNamedParameter($like, ParameterType::STRING))
));
}
$rows = $qb->executeQuery()->fetchAllAssociative();
$products = array_map(static fn(array $r): array => [
'uid' => (int)$r['uid'],
'title' => (string)$r['title'],
'slug' => (string)$r['slug'],
'hidden' => (bool)$r['hidden'],
'changed' => date('Y-m-d H:i', (int)$r['tstamp']),
'hasTeaser' => trim((string)$r['teaser']) !== '',
'hasDescription' => trim((string)$r['description']) !== '',
], $rows);
// Last searches as one-click badges, kept per backend user in their uc
// (no schema, survives logout). Only searches that actually found
// something are remembered - a typo makes a useless badge.
$backendUser = $GLOBALS['BE_USER'] ?? null;
$searchHistory = $backendUser !== null
? array_values(array_filter(array_map('strval', (array)($backendUser->uc['vitec_import_product_searches'] ?? []))))
: [];
if ($backendUser !== null && $q !== '' && $products !== []) {
$searchHistory = array_merge([$q], array_diff($searchHistory, [$q]));
$searchHistory = array_slice($searchHistory, 0, 10);
$backendUser->uc['vitec_import_product_searches'] = $searchHistory;
$backendUser->writeUC();
}
// Precomputed per column: the dir a header link should use next, and
// the arrow it shows now. Keeps nested inline conditions out of Fluid.
$sortLinks = [];
$sortArrows = [];
foreach (self::SORTABLE as $column) {
$sortLinks[$column] = ($sort === $column && $dir === 'asc') ? 'desc' : 'asc';
$sortArrows[$column] = $sort === $column ? ($dir === 'asc' ? '▲' : '▼') : '';
}
$view = $this->moduleTemplateFactory->create($request);
$this->pageRenderer->addCssFile('EXT:vitec/Resources/Public/Css/backend-import.css');
$view->assignMultiple([
'models' => $this->registry->all(),
'products' => $products,
'q' => $q,
'searchHistory' => $searchHistory,
'sortLinks' => $sortLinks,
'sortArrows' => $sortArrows,
'total' => count($products),
]);
return $view->renderResponse('Import/Products');
}
// ------------------------------------------------------------ edit/upload
public function productEditAction(ServerRequestInterface $request): ResponseInterface
{
$uid = (int)($request->getQueryParams()['uid'] ?? 0);
$product = $this->loadProduct($uid);
if ($product === null) {
$this->message('No product with uid ' . $uid . '.', ContextualFeedbackSeverity::ERROR);
return new RedirectResponse((string)$this->uriBuilder->buildUriFromRoute('web_vitecimport.products'), 303);
}
// Reopen on the last successfully loaded workbook (null when there was
// none yet) - the editor lands in the mapping view, not an empty form.
return $this->renderTexts($request, $product, $this->loadWorkbook($uid), []);
}
// -------------------------------------------------------------- preview
public function productPreviewAction(ServerRequestInterface $request): ResponseInterface
{
$body = (array)$request->getParsedBody();
$uid = (int)($body['uid'] ?? 0);
$product = $this->loadProduct($uid);
if ($product === null) {
return new RedirectResponse((string)$this->uriBuilder->buildUriFromRoute('web_vitecimport.products'), 303);
}
// Fresh upload wins; otherwise the hidden state field carries the
// already-parsed workbook (mapping refresh without re-upload).
$parsed = null;
$freshFilename = '';
$upload = $request->getUploadedFiles()['xlsxfile'] ?? null;
if ($upload !== null && $upload->getError() === UPLOAD_ERR_OK) {
$freshFilename = (string)$upload->getClientFilename();
$tmp = GeneralUtility::tempnam('vitec_xlsx_');
try {
$upload->moveTo($tmp);
$parsed = $this->reader->parse($tmp);
} catch (\Throwable $e) {
$this->message('The file could not be read as a spreadsheet: ' . $e->getMessage(), ContextualFeedbackSeverity::ERROR);
return $this->renderTexts($request, $product, null, []);
} finally {
if (file_exists($tmp)) {
@unlink($tmp);
}
}
} elseif (($body['parsed'] ?? '') !== '') {
$decoded = json_decode((string)$body['parsed'], true);
$parsed = is_array($decoded) ? $decoded : null;
}
if ($parsed === null) {
$parsed = $this->loadWorkbook($uid);
}
if ($parsed === null) {
$this->message('Upload a workbook first.', ContextualFeedbackSeverity::WARNING);
return $this->renderTexts($request, $product, null, []);
}
// Category and unknown files are rejected, not guessed at. Category
// pages are handled separately (own vocabulary, target is a page).
$pageType = (string)($parsed['pageType'] ?? 'unknown');
if ($pageType !== 'product') {
$this->message(
$pageType === 'category'
? 'This is a CATEGORY content file - category pages are handled separately. Nothing was imported.'
: 'This file does not match the known product content format (no "Product Introduction" component). Nothing was imported.',
ContextualFeedbackSeverity::ERROR
);
return $this->renderTexts($request, $product, null, []);
}
// A fresh, valid product workbook replaces the stored one for this
// product - that is what "Edit Product" reopens on next time.
if ($freshFilename !== '') {
$this->saveWorkbook($uid, $parsed, $freshFilename);
}
return $this->renderTexts($request, $product, $parsed, (array)($body['map'] ?? []));
}
// ---------------------------------------------------------------- apply
public function productApplyAction(ServerRequestInterface $request): ResponseInterface
{
$body = (array)$request->getParsedBody();
$uid = (int)($body['uid'] ?? 0);
$product = $this->loadProduct($uid);
$parsed = json_decode((string)($body['parsed'] ?? ''), true);
if ($product === null || !is_array($parsed)) {
$this->message('Apply failed: missing product or workbook state.', ContextualFeedbackSeverity::ERROR);
return new RedirectResponse((string)$this->uriBuilder->buildUriFromRoute('web_vitecimport.products'), 303);
}
$map = array_map('strval', (array)($body['map'] ?? []));
$apply = array_map('strval', (array)($body['apply'] ?? []));
$targets = $this->targetFields();
$data = [];
foreach ($apply as $field) {
$selector = $map[$field] ?? '';
if ($selector === '' || !isset($targets[$field])) {
continue;
}
$value = $this->reader->valueFor($parsed, $selector);
if ($value !== null) {
$data[$field] = $value;
}
}
// Related-product cards: one chosen target record per ticked Excel card.
// A card mapped onto the edited product itself is ignored - a product
// cannot be related to itself.
$relatedChoices = array_map('intval', (array)($body['related'] ?? []));
$applyRelated = array_map('intval', (array)($body['applyRelated'] ?? []));
$relatedCards = $this->relatedCards($parsed);
$relatedTodo = [];
foreach ($applyRelated as $index) {
$chosen = $relatedChoices[$index] ?? 0;
if ($chosen > 0 && $chosen !== $uid && isset($relatedCards[$index])) {
$relatedTodo[$index] = $chosen;
}
}
if ($data === [] && $relatedTodo === []) {
$this->message('Nothing ticked - no fields were written.', ContextualFeedbackSeverity::WARNING);
return $this->renderTexts($request, $product, $parsed, $map);
}
if ($data !== []) {
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start([self::TABLE => [(string)$uid => $data]], []);
$dataHandler->process_datamap();
if ($dataHandler->errorLog !== []) {
$this->message('DataHandler: ' . implode(' | ', $dataHandler->errorLog), ContextualFeedbackSeverity::ERROR);
return $this->renderTexts($request, $product, $parsed, $map);
}
}
$relationsAdded = 0;
if ($relatedTodo !== []) {
// Missing MM relations are ADDED, existing ones and their order are
// never touched, nothing is removed. Written through DataHandler
// rather than into the MM table directly, so the relation counter
// in `relatedprodukt` stays correct and FormEngine keeps agreeing
// with the database.
$current = $this->relatedUidsOf($uid);
$merged = $current;
foreach ($relatedTodo as $chosen) {
if (!in_array($chosen, $merged, true)) {
$merged[] = $chosen;
$relationsAdded++;
}
}
if ($merged !== $current) {
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start([self::TABLE => [(string)$uid => ['relatedprodukt' => implode(',', $merged)]]], []);
$dataHandler->process_datamap();
if ($dataHandler->errorLog !== []) {
$this->message('DataHandler (relations): ' . implode(' | ', $dataHandler->errorLog), ContextualFeedbackSeverity::ERROR);
return $this->renderTexts($request, $product, $parsed, $map);
}
}
// Card texts live in the side table (survives MM rewrites), and the
// editor's manual title->record choices are remembered as aliases,
// so the next workbook arrives preselected.
$aliases = $this->mappingRepository->load(self::MAPPING_KEY)['aliases'] ?? [];
foreach ($relatedTodo as $index => $chosen) {
$this->upsertCardText($uid, $chosen, $relatedCards[$index]['cardtext']);
$aliases[$relatedCards[$index]['title']] = $chosen;
}
$this->mappingRepository->saveAliases(self::MAPPING_KEY, $aliases);
}
if (!empty($body['saveMapping'])) {
$this->mappingRepository->save(self::MAPPING_KEY, array_filter($map), '-');
}
$summary = [];
if ($data !== []) {
$summary[] = count($data) . ' field(s) written (' . implode(', ', array_keys($data)) . ')';
}
if ($relatedTodo !== []) {
$summary[] = count($relatedTodo) . ' related-product card(s), ' . $relationsAdded . ' relation(s) added';
}
$this->message(
sprintf('"%s": %s.', $product['title'], implode('; ', $summary)),
ContextualFeedbackSeverity::OK
);
// Re-render with fresh DB values: every applied row now shows as
// unchanged, which doubles as the visual confirmation.
return $this->renderTexts($request, $this->loadProduct($uid), $parsed, $map);
}
// ------------------------------------------------------------ internals
/**
* @param array<string,mixed>|null $product
* @param array<string,mixed>|null $parsed
* @param array<string,string> $submittedMap map from the form; wins over saved/default
*/
private function renderTexts(ServerRequestInterface $request, ?array $product, ?array $parsed, array $submittedMap): ResponseInterface
{
$targets = $this->targetFields();
$saved = $this->mappingRepository->load(self::MAPPING_KEY)['mapping'] ?? [];
$rows = [];
$sources = [];
$slugMismatch = null;
if ($parsed !== null) {
$sources = $this->reader->sources($parsed);
// Workbook URL vs record slug - informational only (slug is not a
// target), but a mismatch usually means "wrong product picked".
$url = trim((string)($parsed['meta']['url'] ?? ''), '/');
$slug = trim((string)($product['slug'] ?? ''), '/');
if ($url !== '' && $slug !== '' && !str_ends_with($url, $slug)) {
$slugMismatch = '/' . $url . ' (workbook) vs /' . $slug . ' (record)';
}
}
// Related-product cards: match preselection order is submitted choice ->
// remembered alias -> exact title -> none. Submitted choices are read
// from the request here so the dropdowns survive a "Refresh preview".
$relatedRows = [];
$productOptions = [];
if ($parsed !== null && $product !== null) {
$productUid = (int)$product['uid'];
$productOptions = $this->productOptions($productUid);
$submittedRelated = array_map('intval', (array)(((array)$request->getParsedBody())['related'] ?? []));
$aliases = $this->mappingRepository->load(self::MAPPING_KEY)['aliases'] ?? [];
$byTitle = [];
foreach ($productOptions as $option) {
$byTitle[mb_strtolower($option['title'])] = $option['uid'];
}
$existingRelated = array_flip($this->relatedUidsOf($productUid));
foreach ($this->relatedCards($parsed) as $index => $card) {
$chosen = $submittedRelated[$index] ?? 0;
$status = $chosen > 0 ? 'manual' : 'none';
if ($chosen === 0 && isset($aliases[$card['title']])) {
$chosen = (int)$aliases[$card['title']];
$status = 'alias';
} elseif ($chosen === 0 && isset($byTitle[mb_strtolower($card['title'])])) {
$chosen = $byTitle[mb_strtolower($card['title'])];
$status = 'title';
}
$old = $chosen > 0 ? $this->existingCardText($productUid, $chosen) : '';
$relatedRows[] = [
'index' => $index,
'title' => $card['title'],
'cta' => $card['cta'],
'preview' => $this->preview($card['cardtext']),
'selected' => $chosen,
'status' => $status,
'isRelated' => $chosen > 0 && isset($existingRelated[$chosen]),
'oldPreview' => $this->preview($old),
'changed' => $chosen > 0 && trim($old) !== trim($card['cardtext']),
];
}
}
foreach ($targets as $field => $label) {
$selector = $submittedMap[$field] ?? $saved[$field] ?? self::DEFAULT_MAPPING[$field] ?? '';
$old = (string)($product[$field] ?? '');
$new = ($parsed !== null && $selector !== '') ? $this->reader->valueFor($parsed, $selector) : null;
$changed = $new !== null && trim($new) !== trim($old);
$rows[] = [
'field' => $field,
'label' => $label,
'selector' => $selector,
'old' => $old,
'oldPreview' => $this->preview($old),
'new' => $new,
'newPreview' => $new === null ? '' : $this->preview($new),
'changed' => $changed,
];
}
$view = $this->moduleTemplateFactory->create($request);
$this->pageRenderer->addCssFile('EXT:vitec/Resources/Public/Css/backend-import.css');
$view->assignMultiple([
'product' => $product,
'parsed' => $parsed,
'parsedJson' => $parsed !== null ? (string)json_encode($parsed, JSON_UNESCAPED_UNICODE) : '',
'sources' => $sources,
'rows' => $rows,
'slugMismatch' => $slugMismatch,
'relatedRows' => $relatedRows,
'productOptions' => $productOptions,
'pageType' => $parsed['pageType'] ?? null,
'metaUrl' => $parsed['meta']['url'] ?? '',
'workbook' => $this->workbookInfo((int)($product['uid'] ?? 0)),
]);
return $view->renderResponse('Import/ProductTexts');
}
/** The stored parse of the last loaded workbook, null when there is none. */
private function loadWorkbook(int $productUid): ?array
{
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('tx_vitec_product_workbook');
$payload = $qb->select('payload')->from('tx_vitec_product_workbook')
->where($qb->expr()->eq('product_uid', $qb->createNamedParameter($productUid, ParameterType::INTEGER)))
->executeQuery()->fetchOne();
if (!is_string($payload) || $payload === '') {
return null;
}
$decoded = json_decode($payload, true);
return is_array($decoded) ? $decoded : null;
}
/** @param array<string,mixed> $parsed */
private function saveWorkbook(int $productUid, array $parsed, string $filename): void
{
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tx_vitec_product_workbook');
$values = [
'filename' => mb_substr($filename, 0, 255),
'payload' => (string)json_encode($parsed, JSON_UNESCAPED_UNICODE),
'be_user' => (int)($GLOBALS['BE_USER']->user['uid'] ?? 0),
'tstamp' => time(),
];
if ($connection->count('product_uid', 'tx_vitec_product_workbook', ['product_uid' => $productUid]) > 0) {
$connection->update('tx_vitec_product_workbook', $values, ['product_uid' => $productUid]);
} else {
$connection->insert('tx_vitec_product_workbook', $values + ['product_uid' => $productUid]);
}
}
/** @return array{filename:string,date:string}|null display info for the stored workbook */
private function workbookInfo(int $productUid): ?array
{
if ($productUid <= 0) {
return null;
}
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('tx_vitec_product_workbook');
$row = $qb->select('filename', 'tstamp')->from('tx_vitec_product_workbook')
->where($qb->expr()->eq('product_uid', $qb->createNamedParameter($productUid, ParameterType::INTEGER)))
->executeQuery()->fetchAssociative();
if (!$row) {
return null;
}
return [
'filename' => (string)$row['filename'],
'date' => date('Y-m-d H:i', (int)$row['tstamp']),
];
}
/** @return array<string,string> field => label, without excluded targets */
private function targetFields(): array
{
$fields = $this->registry->importableFields(self::TABLE);
foreach (self::EXCLUDED_TARGETS as $excluded) {
unset($fields[$excluded]);
}
return $fields;
}
/** @return array<string,mixed>|null */
private function loadProduct(int $uid): ?array
{
if ($uid <= 0) {
return null;
}
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::TABLE);
$row = $qb->select('*')->from(self::TABLE)
->where(
$qb->expr()->eq('uid', $qb->createNamedParameter($uid, ParameterType::INTEGER)),
$qb->expr()->eq('deleted', 0)
)
->executeQuery()->fetchAssociative();
return $row ?: null;
}
/**
* The "Related Product Card" components of a parsed workbook. The card copy
* arrives as "Explore QTX100 ... | CTA: Learn more" - text and CTA label are
* split here; the CTA label has no storage yet and is display-only.
*
* @param array<string,mixed> $parsed
* @return array<int,array{title:string,cardtext:string,cta:string}>
*/
private function relatedCards(array $parsed): array
{
$cards = [];
foreach ($parsed['components'] ?? [] as $component) {
if ((string)($component['component'] ?? '') !== 'Related Product Card') {
continue;
}
// The agency uses the columns loosely: text components carry their
// copy in "Body Copy" (D), card components in "CTA / Card Copy" (E).
// Verified in the raw sheet - D of a card row is an empty
// self-closing cell. Take D when filled, fall back to E.
$raw = trim((string)($component['body'] ?? ''));
if ($raw === '') {
$raw = trim((string)($component['cta'] ?? ''));
}
$cta = '';
if (preg_match('/^(.*?)\|\s*CTA:\s*(.+)$/su', $raw, $m)) {
$raw = $m[1];
$cta = trim($m[2]);
}
$cards[] = [
'title' => trim((string)($component['title'] ?? '')),
'cardtext' => trim($raw),
'cta' => $cta,
];
}
return $cards;
}
/** @return array<int,array{uid:int,title:string}> all products except the edited one */
private function productOptions(int $excludeUid): array
{
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::TABLE);
$rows = $qb->select('uid', 'title')->from(self::TABLE)
->where(
$qb->expr()->eq('deleted', 0),
$qb->expr()->neq('uid', $qb->createNamedParameter($excludeUid, ParameterType::INTEGER))
)
->orderBy('title', 'ASC')
->executeQuery()->fetchAllAssociative();
return array_map(static fn(array $r): array => ['uid' => (int)$r['uid'], 'title' => (string)$r['title']], $rows);
}
/** @return int[] related product uids in MM sorting order */
private function relatedUidsOf(int $productUid): array
{
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('tx_vitec_product_related_mm');
$uids = $qb->select('uid_foreign')->from('tx_vitec_product_related_mm')
->where($qb->expr()->eq('uid_local', $qb->createNamedParameter($productUid, ParameterType::INTEGER)))
->orderBy('sorting', 'ASC')
->executeQuery()->fetchFirstColumn();
return array_map('intval', $uids);
}
private function existingCardText(int $productUid, int $relatedUid): string
{
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('tx_vitec_product_related_text');
$text = $qb->select('cardtext')->from('tx_vitec_product_related_text')
->where(
$qb->expr()->eq('product_uid', $qb->createNamedParameter($productUid, ParameterType::INTEGER)),
$qb->expr()->eq('related_uid', $qb->createNamedParameter($relatedUid, ParameterType::INTEGER))
)
->executeQuery()->fetchOne();
return is_string($text) ? $text : '';
}
private function upsertCardText(int $productUid, int $relatedUid, string $text): void
{
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tx_vitec_product_related_text');
$keys = ['product_uid' => $productUid, 'related_uid' => $relatedUid];
if ($connection->count('product_uid', 'tx_vitec_product_related_text', $keys) > 0) {
$connection->update('tx_vitec_product_related_text', ['cardtext' => $text, 'tstamp' => time()], $keys);
} else {
$connection->insert('tx_vitec_product_related_text', $keys + ['cardtext' => $text, 'tstamp' => time()]);
}
}
private function preview(string $value): string
{
$flat = preg_replace('/\s+/u', ' ', trim($value)) ?? $value;
return mb_strlen($flat) > 160 ? mb_substr($flat, 0, 157) . '…' : $flat;
}
private function message(string $text, ContextualFeedbackSeverity $severity): void
{
$message = GeneralUtility::makeInstance(FlashMessage::class, $text, '', $severity, true);
$this->flashMessageService->getMessageQueueByIdentifier()->addMessage($message);
}
}

View File

@@ -720,6 +720,7 @@ class Product extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity
{
$this->categories = new ObjectStorage();
$this->productimage = new ObjectStorage();
$this->heroimage = new ObjectStorage();
$this->downloads = new ObjectStorage();
}
@@ -977,6 +978,98 @@ class Product extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity
{
$this->relatedprodukt = $relatedprodukt;
}
/**
* Hero images
*
* @var ObjectStorage<FileReference>
* @TYPO3\CMS\Extbase\Annotation\ORM\Cascade("remove")
*/
protected $heroimage;
/**
* Second description
*
* @var string
*/
protected $description2 = '';
/**
* Capabilities
*
* @var string
*/
protected $capabilities = '';
/**
* Portfolio link (typolink parameter)
*
* @var string
*/
protected $portfolio = '';
/**
* @return ObjectStorage<FileReference>
*/
public function getHeroimage(): ObjectStorage
{
return $this->heroimage;
}
/**
* @param ObjectStorage<FileReference> $heroimage
*/
public function setHeroimage(ObjectStorage $heroimage): void
{
$this->heroimage = $heroimage;
}
public function getDescription2(): string
{
return $this->description2;
}
public function setDescription2(string $description2): void
{
$this->description2 = $description2;
}
public function getCapabilities(): string
{
return $this->capabilities;
}
public function setCapabilities(string $capabilities): void
{
$this->capabilities = $capabilities;
}
public function getPortfolio(): string
{
return $this->portfolio;
}
public function setPortfolio(string $portfolio): void
{
$this->portfolio = $portfolio;
}
/**
* Intro text above the related products
*
* @var string
*/
protected $textrelatedproducts = '';
public function getTextrelatedproducts(): string
{
return $this->textrelatedproducts;
}
public function setTextrelatedproducts(string $textrelatedproducts): void
{
$this->textrelatedproducts = $textrelatedproducts;
}
/* --------------------------------------------------------------------- */
}

View File

@@ -14,43 +14,63 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
*
* Plain table without TCA (tx_vitec_import_mapping) - the records are pure
* tool configuration, never edited through FormEngine.
*
* The same row also stores record aliases (CSV identity value -> record
* uid) for rows whose name differs slightly from the TYPO3 record, so a
* hand-made link survives every future delivery of the same CSV.
*/
final class MappingRepository
{
private const TABLE = 'tx_vitec_import_mapping';
/**
* @return array{mapping: array<string,string>, identity: string}|null
* @return array{mapping: array<string,string>, identity: string, aliases: array<string,int>}|null
*/
public function load(string $model): ?array
{
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::TABLE);
$row = $qb->select('mapping', 'identity_field')->from(self::TABLE)
$row = $qb->select('mapping', 'identity_field', 'record_aliases')->from(self::TABLE)
->where($qb->expr()->eq('model', $qb->createNamedParameter($model, ParameterType::STRING)))
->setMaxResults(1)
->executeQuery()->fetchAssociative();
if (!$row) {
return null;
}
// A row may exist with aliases only (saveAliases before the first
// saveMapping) - an unparsable mapping degrades to [], not to null.
$mapping = json_decode((string)$row['mapping'], true);
if (!is_array($mapping)) {
return null;
}
$aliases = json_decode((string)($row['record_aliases'] ?? ''), true);
return [
'mapping' => array_map('strval', $mapping),
'mapping' => is_array($mapping) ? array_map('strval', $mapping) : [],
'identity' => (string)$row['identity_field'],
'aliases' => is_array($aliases) ? array_map('intval', $aliases) : [],
];
}
/** @param array<string,string> $mapping */
public function save(string $model, array $mapping, string $identity): void
{
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable(self::TABLE);
$values = [
'mapping' => (string)json_encode($mapping, JSON_UNESCAPED_UNICODE),
'identity_field' => $identity,
'tstamp' => time(),
];
$this->upsert($model, $values);
}
/** @param array<string,int> $aliases */
public function saveAliases(string $model, array $aliases): void
{
$this->upsert($model, [
'record_aliases' => (string)json_encode($aliases, JSON_UNESCAPED_UNICODE),
'tstamp' => time(),
]);
}
/** @param array<string,mixed> $values */
private function upsert(string $model, array $values): void
{
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable(self::TABLE);
$exists = $connection->count('uid', self::TABLE, ['model' => $model]) > 0;
if ($exists) {
$connection->update(self::TABLE, $values, ['model' => $model]);

View File

@@ -0,0 +1,245 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Import;
use PhpOffice\PhpSpreadsheet\IOFactory;
/**
* Reads one agency-delivered "Product Page Content" workbook into a structured
* array the import module can work with.
*
* The workbook format (verified against the Aligo, Arqa and AV-over-IP&KVM
* deliveries of 2026-08): a meta block in the top rows (URL, primary keyword,
* intent, SEO approach), a component table whose header row carries "Component"
* in column B, and a second sheet "Page SEO Check" with one header and one
* value row.
*
* The component column is a controlled vocabulary and doubles as the page-type
* detector: product files carry "Product Introduction", category files carry
* "Category Introduction". Category files use the same file format but a
* different vocabulary and target a page rather than a record - they are
* recognised here and rejected by the import flow, never guessed at.
*
* Source selectors (what the persisted mapping stores) are stable strings:
*
* c:<Component>#<n>:<cell> one cell of the n-th occurrence of a component
* (cell = title | body | cta)
* m:<key> meta block (url, primaryKeyword, intent, seoApproach)
* s:<Header> one column of the "Page SEO Check" value row
*
* They survive across deliveries because the vocabulary is stable - that is
* what makes the saved mapping reusable for every next product.
*/
final class ProductXlsxReader
{
/**
* Explicitly marked "Internal note - not website copy" in the deliveries;
* never offered as an import source.
*/
private const HIDDEN_COMPONENTS = ['Technical Gap / Validation Note'];
/** Meta-block labels (column A, lowercased) => meta keys. */
private const META_LABELS = [
'url' => 'url',
'primary keyword' => 'primaryKeyword',
'intent' => 'intent',
'seo approach' => 'seoApproach',
];
/**
* @return array{
* pageType: string,
* meta: array<string,string>,
* components: array<int,array<string,mixed>>,
* seo: array<string,string>
* }
*/
public function parse(string $filePath): array
{
$reader = IOFactory::createReaderForFile($filePath);
$reader->setReadDataOnly(true);
$spreadsheet = $reader->load($filePath);
$sheet = $spreadsheet->getSheet(0);
// Formatting flags off: raw values, no calculated formatting - the
// deliveries are pure text.
$rows = $sheet->toArray(null, false, false, false);
// The header row is FOUND, not assumed at row 9: the one whose column B
// says "Component".
$headerIndex = null;
foreach ($rows as $i => $row) {
if (trim((string)($row[1] ?? '')) === 'Component') {
$headerIndex = $i;
break;
}
}
$meta = [];
$components = [];
if ($headerIndex !== null) {
foreach (array_slice($rows, 0, $headerIndex) as $row) {
$label = strtolower(trim((string)($row[0] ?? '')));
$value = trim((string)($row[1] ?? ''));
if ($label !== '' && $value !== '' && isset(self::META_LABELS[$label])) {
$meta[self::META_LABELS[$label]] = $value;
}
}
$occurrences = [];
foreach (array_slice($rows, $headerIndex + 1) as $row) {
$component = trim((string)($row[1] ?? ''));
if ($component === '') {
continue;
}
$occurrences[$component] = ($occurrences[$component] ?? 0) + 1;
$components[] = [
'order' => (int)($row[0] ?? 0),
'component' => $component,
'occurrence' => $occurrences[$component],
'title' => trim((string)($row[2] ?? '')),
'body' => trim((string)($row[3] ?? '')),
'cta' => trim((string)($row[4] ?? '')),
'lengthNote' => trim((string)($row[5] ?? '')),
'seoNote' => trim((string)($row[6] ?? '')),
];
}
}
$seo = [];
$seoSheet = $spreadsheet->getSheetByName('Page SEO Check')
?? ($spreadsheet->getSheetCount() > 1 ? $spreadsheet->getSheet(1) : null);
if ($seoSheet !== null) {
$seoRows = $seoSheet->toArray(null, false, false, false);
$headers = $seoRows[0] ?? [];
$values = $seoRows[1] ?? [];
foreach ($headers as $i => $header) {
$header = trim((string)$header);
$value = trim((string)($values[$i] ?? ''));
if ($header !== '' && $value !== '') {
$seo[$header] = $value;
}
}
}
$spreadsheet->disconnectWorksheets();
return [
'pageType' => $this->detectPageType($components),
'meta' => $meta,
'components' => $components,
'seo' => $seo,
];
}
/**
* The selectable sources of a parsed workbook, grouped for the dropdown.
* Every entry: selector, label, value (full) and preview (shortened).
*
* @param array<string,mixed> $parsed
* @return array<string,array<int,array{selector:string,label:string,value:string,preview:string}>>
*/
public function sources(array $parsed): array
{
$groups = ['Meta' => [], 'Components' => [], 'Page SEO Check' => []];
foreach ($parsed['meta'] ?? [] as $key => $value) {
$groups['Meta'][] = $this->entry('m:' . $key, 'Meta · ' . $key, (string)$value);
}
foreach ($parsed['components'] ?? [] as $component) {
$name = (string)$component['component'];
if (in_array($name, self::HIDDEN_COMPONENTS, true)) {
continue;
}
$suffix = ((int)$component['occurrence']) > 1 || $this->occursMultipleTimes($parsed, $name)
? ' #' . $component['occurrence']
: '';
foreach (['title' => 'Title', 'body' => 'Body', 'cta' => 'CTA'] as $cell => $cellLabel) {
$value = (string)($component[$cell] ?? '');
if ($value === '') {
continue;
}
$groups['Components'][] = $this->entry(
'c:' . $name . '#' . $component['occurrence'] . ':' . $cell,
$name . $suffix . ' · ' . $cellLabel,
$value
);
}
}
foreach ($parsed['seo'] ?? [] as $header => $value) {
$groups['Page SEO Check'][] = $this->entry('s:' . $header, 'SEO · ' . $header, (string)$value);
}
return array_filter($groups, static fn(array $g): bool => $g !== []);
}
/**
* Resolves one stored selector against a parsed workbook. null = the
* selector points at nothing in THIS file (component missing or cell
* empty) - the caller shows that instead of silently writing ''.
*
* @param array<string,mixed> $parsed
*/
public function valueFor(array $parsed, string $selector): ?string
{
if (str_starts_with($selector, 'm:')) {
$value = $parsed['meta'][substr($selector, 2)] ?? null;
return is_string($value) && $value !== '' ? $value : null;
}
if (str_starts_with($selector, 's:')) {
$value = $parsed['seo'][substr($selector, 2)] ?? null;
return is_string($value) && $value !== '' ? $value : null;
}
if (str_starts_with($selector, 'c:') && preg_match('/^c:(.+)#(\d+):(title|body|cta)$/', $selector, $m)) {
foreach ($parsed['components'] ?? [] as $component) {
if ((string)$component['component'] === $m[1] && (int)$component['occurrence'] === (int)$m[2]) {
$value = (string)($component[$m[3]] ?? '');
return $value !== '' ? $value : null;
}
}
}
return null;
}
/** @param array<int,array<string,mixed>> $components */
private function detectPageType(array $components): string
{
$names = array_column($components, 'component');
if (in_array('Product Introduction', $names, true)) {
return 'product';
}
if (in_array('Category Introduction', $names, true)) {
return 'category';
}
return 'unknown';
}
/** @param array<string,mixed> $parsed */
private function occursMultipleTimes(array $parsed, string $name): bool
{
$count = 0;
foreach ($parsed['components'] ?? [] as $component) {
if ((string)$component['component'] === $name && ++$count > 1) {
return true;
}
}
return false;
}
/** @return array{selector:string,label:string,value:string,preview:string} */
private function entry(string $selector, string $label, string $value): array
{
$flat = preg_replace('/\s+/u', ' ', $value) ?? $value;
return [
'selector' => $selector,
'label' => $label,
'value' => $value,
'preview' => mb_strlen($flat) > 60 ? mb_substr($flat, 0, 57) . '…' : $flat,
];
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Middleware;
use Evomedien\Vitec\Service\DownloadFileResolver;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use TYPO3\CMS\Core\Http\Response;
use TYPO3\CMS\Core\Http\Stream;
/**
* Forced file download for download records: /download/file/<uid>
*
* This is the frontend target of the "Download" record links from the link
* browser (config.recordLinks.download builds exactly this path). A direct
* fileadmin URL would open PDFs inline; this endpoint streams the file with
* Content-Disposition: attachment, so the browser saves it.
*
* The file lookup lives in Service\DownloadFileResolver (FAL -> Collateral
* naming convention -> filepath column). Unknown uid, hidden record or
* missing file fall through to the regular pipeline - the reply is then the
* normal 404 page, never a broken download.
*/
final class DownloadFileMiddleware implements MiddlewareInterface
{
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
try {
$path = $request->getUri()->getPath();
if (preg_match('#^/download/file/(\\d+)/?$#', $path, $matches) === 1) {
$file = DownloadFileResolver::resolve((int)$matches[1]);
if ($file !== null) {
$filename = str_replace(['"', "\r", "\n"], '', $file['name']);
return new Response(
new Stream($file['path'], 'rb'),
200,
[
'Content-Type' => $file['mimeType'] !== '' ? $file['mimeType'] : 'application/octet-stream',
'Content-Length' => (string)$file['size'],
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
]
);
}
}
} catch (\Throwable $e) {
// fall through to the regular pipeline
}
return $handler->handle($request);
}
}

View File

@@ -5,8 +5,16 @@ declare(strict_types=1);
namespace Evomedien\Vitec\PageTitle;
use TYPO3\CMS\Core\PageTitle\AbstractPageTitleProvider;
use TYPO3\CMS\Core\SingletonInterface;
final class ProductPageTitleProvider extends AbstractPageTitleProvider
/**
* Singleton on purpose: whoever sets the title (ProductShowJsonRenderer in
* headless mode, ProductController in classic mode) and the
* PageTitleProviderManager that later reads it both obtain the provider via
* GeneralUtility::makeInstance(). Without the singleton those are two
* different instances and the title set here is never seen by the manager.
*/
final class ProductPageTitleProvider extends AbstractPageTitleProvider implements SingletonInterface
{
private string $seotitle = '';

View File

@@ -22,8 +22,10 @@ use Evomedien\Vitec\UserFunc\CustomerlogosJsonRenderer;
use Evomedien\Vitec\UserFunc\FormsJsonRenderer;
use Evomedien\Vitec\UserFunc\ModelcardJsonRenderer;
use Evomedien\Vitec\UserFunc\NewsJsonRenderer;
use Evomedien\Vitec\UserFunc\ContainerBackgroundRenderer;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
/**
* Resolves a TYPO3 typolink string (as stored by `inputLink` fields like
@@ -38,6 +40,12 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
* just like container children, so a referenced productlist / productshow /
* usecaselist / usecaseshow appears with its full headless JSON in `data`.
*
* A linked CONTAINER (vitec_container, vitec_cols_*, vitec_cards_carousel)
* additionally carries its resolved `background` and its children under
* `items` - the exact shape ContainerChildrenProcessor emits for page-level
* containers, so the frontend reuses its container component. Nested
* containers recurse, depth-capped and cycle-safe.
*
* Exception-safe: every public entry point returns `null` on any failure
* so the surrounding JSON output stays clean.
*/
@@ -71,6 +79,38 @@ final class ContentElementResolver
private const KEEP_IF_ZERO = ['header_layout'];
/**
* Container-level fields - stripped from CHILD `data` like the page-level
* ContainerChildrenProcessor does (they carry non-empty defaults and
* belong to the parent). The linked container itself keeps them.
*/
private const CONTAINER_FIELDS = [
'tx_vitec_gap',
'tx_vitec_bg_variant', 'tx_vitec_bg_image', 'tx_vitec_bg_size',
'tx_vitec_bg_size_percent', 'tx_vitec_bg_position',
'tx_vitec_bg_pos_top', 'tx_vitec_bg_pos_bottom',
'tx_vitec_bg_pos_left', 'tx_vitec_bg_pos_right',
'tx_vitec_col1_align', 'tx_vitec_col1_justify',
'tx_vitec_col2_align', 'tx_vitec_col2_justify',
'tx_vitec_col3_align', 'tx_vitec_col3_justify',
'tx_vitec_col4_align', 'tx_vitec_col4_justify',
];
/**
* Per container CType: colPos value => 1-based column number, for the
* parent's tx_vitec_col{N}_align/justify - same map as the processor.
*/
private const COLPOS_TO_COLUMN = [
'vitec_cols_50_50' => [211 => 1, 212 => 2],
'vitec_cols_33_66' => [251 => 1, 252 => 2],
'vitec_cols_66_33' => [241 => 1, 242 => 2],
'vitec_cols_33_33_33' => [221 => 1, 222 => 2, 223 => 3],
'vitec_cols_25_25_25_25' => [231 => 1, 232 => 2, 233 => 3, 234 => 4],
];
/** Recursion cap for nested containers. */
private const MAX_CONTAINER_DEPTH = 5;
private const PLUGIN_RENDERERS = [
'vitec_productlist' => [ProductListJsonRenderer::class, 'products'],
'vitec_productshow' => [ProductShowJsonRenderer::class, 'product'],
@@ -143,7 +183,10 @@ final class ContentElementResolver
return null;
}
return self::normaliseRecord($row);
$element = self::normaliseRecord($row);
self::attachContainerPayload($row, $element, [$uid => true], 0);
return $element;
} catch (\Throwable $e) {
return null;
}
@@ -156,9 +199,12 @@ final class ContentElementResolver
* resolved to their headless JSON.
*
* @param array<string,mixed> $record
* @param bool $isContainerChild strip container-level fields (bg, gap,
* col flex) like the page-level processor
* does for its children
* @return array<string,mixed>
*/
public static function normaliseRecord(array $record): array
public static function normaliseRecord(array $record, bool $isContainerChild = false): array
{
$data = [];
foreach ($record as $field => $value) {
@@ -168,6 +214,9 @@ final class ContentElementResolver
if (in_array($field, self::SYSTEM_FIELDS, true)) {
continue;
}
if ($isContainerChild && in_array($field, self::CONTAINER_FIELDS, true)) {
continue;
}
if (self::isEmpty($value) && !in_array($field, self::KEEP_IF_ZERO, true)) {
continue;
}
@@ -279,6 +328,107 @@ final class ContentElementResolver
}
}
/**
* If the element has container children (tx_container_parent), attach the
* resolved `background` and the children as `items` in the page-level
* shape: [{config: {colPos, align?, justify?}, contentElements: [...]}].
* Recurses into nested containers; $visited guards against cycles.
*
* @param array<string,mixed> $record raw tt_content row
* @param array<string,mixed> $element normalised element, modified in place
* @param array<int,bool> $visited uids already on this path
*/
private static function attachContainerPayload(array $record, array &$element, array $visited, int $depth): void
{
$background = self::resolveContainerBackground($record);
if ($background !== null) {
$element['background'] = $background;
}
if ($depth >= self::MAX_CONTAINER_DEPTH) {
return;
}
try {
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tt_content');
$rows = $qb
->select('*')
->from('tt_content')
->where(
$qb->expr()->eq('tx_container_parent', $qb->createNamedParameter((int)$record['uid'], ParameterType::INTEGER)),
$qb->expr()->eq('pid', $qb->createNamedParameter((int)($record['pid'] ?? 0), ParameterType::INTEGER)),
$qb->expr()->eq('sys_language_uid', $qb->createNamedParameter((int)($record['sys_language_uid'] ?? 0), ParameterType::INTEGER))
)
->orderBy('colPos')
->addOrderBy('sorting')
->executeQuery()
->fetchAllAssociative();
} catch (\Throwable $e) {
return;
}
if ($rows === []) {
return;
}
$byColPos = [];
foreach ($rows as $childRow) {
$childUid = (int)$childRow['uid'];
$child = self::normaliseRecord($childRow, true);
if (!isset($visited[$childUid])) {
$childVisited = $visited;
$childVisited[$childUid] = true;
self::attachContainerPayload($childRow, $child, $childVisited, $depth + 1);
}
$byColPos[(int)$childRow['colPos']][] = $child;
}
ksort($byColPos);
$colMap = self::COLPOS_TO_COLUMN[(string)($record['CType'] ?? '')] ?? [];
$items = [];
foreach ($byColPos as $colPos => $contentElements) {
$config = ['colPos' => $colPos];
$columnNo = $colMap[$colPos] ?? null;
if ($columnNo !== null) {
$config['align'] = (string)($record['tx_vitec_col' . $columnNo . '_align'] ?? 'stretch');
$config['justify'] = (string)($record['tx_vitec_col' . $columnNo . '_justify'] ?? 'flex-start');
}
$items[] = [
'config' => $config,
'contentElements' => $contentElements,
];
}
$element['items'] = $items;
}
/**
* Resolved {image, size, position} of a container row - the same payload
* ContainerBackgroundRenderer emits in the page JSON. Null without image.
*
* @param array<string,mixed> $record
* @return array<string,mixed>|null
*/
private static function resolveContainerBackground(array $record): ?array
{
try {
$cObj = GeneralUtility::makeInstance(ContentObjectRenderer::class);
$cObj->start($record, 'tt_content');
$renderer = GeneralUtility::makeInstance(ContainerBackgroundRenderer::class);
$renderer->setContentObjectRenderer($cObj);
$json = $renderer->render('', []);
if ($json === '') {
return null;
}
$decoded = json_decode($json, true);
return is_array($decoded) ? $decoded : null;
} catch (\Throwable $e) {
return null;
}
}
private static function isEmpty(mixed $value): bool
{
return $value === null || $value === '' || $value === 0 || $value === '0';

View File

@@ -0,0 +1,226 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Service;
use Doctrine\DBAL\ParameterType;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Resolves a download record to its file on disk.
*
* Resolution order is the one the three download renderers use for their
* `file` payload - FAL reference, then the Collateral naming convention,
* then the filepath column - so a record link and the JSON always point at
* the same file. The renderers still carry private copies of this logic for
* their richer payload (thumbnail, title, url); consolidating them onto this
* service is the standing 10.2 goal (Annex B-4).
*
* Contract: null means "no file". Never throws.
*/
final class DownloadFileResolver
{
/**
* @return array{path: string, name: string, size: int, mimeType: string}|null
*/
public static function resolve(int $downloadUid): ?array
{
if ($downloadUid <= 0) {
return null;
}
try {
$download = self::loadDownload($downloadUid);
if ($download === null) {
return null;
}
$path = self::resolveByFal($downloadUid)
?? self::resolveByConvention(
(string)($download['fileprefix'] ?? ''),
self::fileType($downloadUid)
)
?? self::resolveByFilepath((string)($download['filepath'] ?? ''));
return $path !== null ? self::describe($path) : null;
} catch (\Throwable $e) {
return null;
}
}
/** @return array<string,mixed>|null */
private static function loadDownload(int $uid): ?array
{
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_vitec_domain_model_download');
$row = $qb
->select('uid', 'fileprefix', 'filepath')
->from('tx_vitec_domain_model_download')
->where(
$qb->expr()->eq('uid', $qb->createNamedParameter($uid, ParameterType::INTEGER)),
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0)
)
->setMaxResults(1)
->executeQuery()
->fetchAssociative();
return $row ?: null;
}
private static function resolveByFal(int $downloadUid): ?string
{
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_file_reference');
$row = $qb
->select('f.identifier')
->from('sys_file_reference', 'fr')
->join('fr', 'sys_file', 'f', 'fr.uid_local = f.uid')
->where(
$qb->expr()->eq('fr.tablenames', $qb->createNamedParameter('tx_vitec_domain_model_download', ParameterType::STRING)),
$qb->expr()->eq('fr.fieldname', $qb->createNamedParameter('file', ParameterType::STRING)),
$qb->expr()->eq('fr.uid_foreign', $qb->createNamedParameter($downloadUid, ParameterType::INTEGER)),
$qb->expr()->eq('fr.deleted', 0),
$qb->expr()->eq('f.missing', 0)
)
->orderBy('fr.sorting_foreign', 'ASC')
->setMaxResults(1)
->executeQuery()
->fetchAssociative();
$identifier = (string)($row['identifier'] ?? '');
if ($identifier === '') {
return null;
}
$path = Environment::getPublicPath() . '/fileadmin' . $identifier;
return is_file($path) && is_readable($path) ? $path : null;
}
/**
* Highest revision of <prefix>__<filetype>__<NN>-<letters>.<ext> in the
* Collateral directory - same pattern and ranking as the renderers.
*/
private static function resolveByConvention(string $fileprefix, string $filetype): ?string
{
$fileprefix = trim($fileprefix);
$filetype = trim($filetype);
if ($fileprefix === '' || $filetype === '') {
return null;
}
$baseDir = Environment::getPublicPath() . '/fileadmin/downloads/Collateral';
if (!is_dir($baseDir) || !is_readable($baseDir)) {
return null;
}
$pattern = '/^' . preg_quote($fileprefix, '/') . '__' . preg_quote($filetype, '/')
. '__(\\d+)-([A-Za-z]+)\\.([A-Za-z0-9]+)$/';
$entries = scandir($baseDir);
if ($entries === false) {
return null;
}
$bestFile = null;
$bestNumber = -1;
$bestLetterRank = -1;
foreach ($entries as $entry) {
if (!is_string($entry) || !preg_match($pattern, $entry, $matches)) {
continue;
}
$number = (int)$matches[1];
$letterRank = self::letterSequenceToRank((string)$matches[2]);
if ($number > $bestNumber || ($number === $bestNumber && $letterRank > $bestLetterRank)) {
$bestNumber = $number;
$bestLetterRank = $letterRank;
$bestFile = $entry;
}
}
if ($bestFile === null) {
return null;
}
$path = $baseDir . '/' . $bestFile;
return is_file($path) && is_readable($path) ? $path : null;
}
private static function resolveByFilepath(string $filepath): ?string
{
$filepath = trim($filepath);
if ($filepath === '') {
return null;
}
$path = Environment::getPublicPath() . '/' . ltrim($filepath, '/');
return is_file($path) && is_readable($path) ? $path : null;
}
/** Filetype segment from the assigned filetype category (parent 4). */
private static function fileType(int $downloadUid): string
{
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_category');
$categories = $qb
->select('c.filetype')
->from('sys_category', 'c')
->join(
'c',
'sys_category_record_mm',
'mm',
'mm.uid_local = c.uid AND mm.tablenames = ' .
$qb->createNamedParameter('tx_vitec_domain_model_download', ParameterType::STRING) .
' AND mm.fieldname = ' .
$qb->createNamedParameter('categories', ParameterType::STRING)
)
->where(
$qb->expr()->eq('mm.uid_foreign', $qb->createNamedParameter($downloadUid, ParameterType::INTEGER)),
$qb->expr()->eq('c.deleted', 0),
$qb->expr()->eq('c.hidden', 0),
$qb->expr()->eq('c.parent', $qb->createNamedParameter(4, ParameterType::INTEGER))
)
->orderBy('mm.sorting', 'ASC')
->executeQuery()
->fetchAllAssociative();
foreach ($categories as $category) {
$filetype = trim((string)($category['filetype'] ?? ''));
if ($filetype !== '') {
return $filetype;
}
}
return '';
}
/** "A" = 1 ... "Z" = 26, "AA" = 27 - revision letters as a rank. */
private static function letterSequenceToRank(string $letters): int
{
$rank = 0;
foreach (str_split(strtoupper($letters)) as $char) {
$rank = $rank * 26 + (ord($char) - 64);
}
return $rank;
}
/** @return array{path: string, name: string, size: int, mimeType: string} */
private static function describe(string $path): array
{
$mimeType = function_exists('mime_content_type') ? (string)(mime_content_type($path) ?: '') : '';
return [
'path' => $path,
'name' => basename($path),
'size' => (int)(filesize($path) ?: 0),
'mimeType' => $mimeType,
];
}
}

View File

@@ -45,4 +45,26 @@ final class LinkResolver
return null;
}
}
/**
* Resolves a full typolink parameter (t3://page..., t3://file..., an
* external URL, mailto:...) to a URL. Same contract as pageUrl():
* null means "no link", and a failure never throws.
*/
public static function typolinkUrl(string $parameter): ?string
{
$parameter = trim($parameter);
if ($parameter === '') {
return null;
}
try {
$cObj = GeneralUtility::makeInstance(ContentObjectRenderer::class);
$url = $cObj->typoLink_URL(['parameter' => $parameter]);
return $url !== '' ? $url : null;
} catch (\Throwable $e) {
return null;
}
}
}

View File

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

View File

@@ -164,6 +164,19 @@ class ProductShowJsonRenderer
: '';
}
// Page <title> for the detail page: the product's seotitle, falling
// back to its title. Fed from here because in headless mode the
// Extbase showAction (which used to do this) never runs; headless's
// MetaHandler reads the provider chain when composing `seo`.
$pageTitle = trim((string)($product['seotitle'] ?? ''));
if ($pageTitle === '') {
$pageTitle = trim((string)($product['title'] ?? ''));
}
if ($pageTitle !== '') {
GeneralUtility::makeInstance(\Evomedien\Vitec\PageTitle\ProductPageTitleProvider::class)
->setSeoTitle($pageTitle);
}
$response = [
'product' => $this->serializeProduct($product),
'layout' => $layout,
@@ -210,11 +223,15 @@ class ProductShowJsonRenderer
'subtitle' => (string)($product['subtitle'] ?? ''),
'video' => (string)($product['video'] ?? ''),
'applications' => RteResolver::html($product['applications'] ?? ''),
'capabilities' => RteResolver::html($product['capabilities'] ?? ''),
'description' => RteResolver::html($product['description'] ?? ''),
'description2' => RteResolver::html($product['description2'] ?? ''),
'highlights' => RteResolver::html($product['highlights'] ?? ''),
'textrelatedproducts' => RteResolver::html($product['textrelatedproducts'] ?? ''),
'shortcutpid' => (string)($product['shortcutpid'] ?? ''),
'contentelement' => \Evomedien\Vitec\Service\ContentElementResolver::resolveLink((string)($product['contentelement'] ?? '')),
'contentelementcta' => \Evomedien\Vitec\Service\ContentElementResolver::resolveLink((string)($product['contentelementcta'] ?? '')),
'portfolio' => \Evomedien\Vitec\Service\LinkResolver::typolinkUrl((string)($product['portfolio'] ?? '')),
'hideonapp' => (bool)($product['hideonapp'] ?? false),
'hideonwebsite' => (bool)($product['hideonwebsite'] ?? false),
@@ -230,6 +247,7 @@ class ProductShowJsonRenderer
'categories' => $this->getProductCategories($uid),
'images' => $this->getProductImages($uid),
'heroimage' => $this->getProductImages($uid, 'heroimage'),
'downloads' => $this->getProductDownloads($uid),
'ogimage' => $this->getProductOgImage($uid),
'videofile' => $this->getProductVideoFile($uid),
@@ -310,7 +328,7 @@ class ProductShowJsonRenderer
return '';
}
protected function getProductImages(int $productUid): array
protected function getProductImages(int $productUid, string $fieldName = 'productimage'): array
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_file_reference');
@@ -321,7 +339,7 @@ class ProductShowJsonRenderer
->where(
$queryBuilder->expr()->eq('uid_foreign', $queryBuilder->createNamedParameter($productUid, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('tablenames', $queryBuilder->createNamedParameter('tx_vitec_domain_model_product', ParameterType::STRING)),
$queryBuilder->expr()->eq('fieldname', $queryBuilder->createNamedParameter('productimage', ParameterType::STRING)),
$queryBuilder->expr()->eq('fieldname', $queryBuilder->createNamedParameter($fieldName, ParameterType::STRING)),
$queryBuilder->expr()->eq('deleted', 0),
$queryBuilder->expr()->eq('hidden', 0)
)
@@ -805,6 +823,7 @@ class ProductShowJsonRenderer
$related = $queryBuilder
->select('p.uid', 'p.title', 'p.slug', 'p.subtitle', 'p.teaser', 'p.description')
->addSelectLiteral('t.cardtext')
->from('tx_vitec_domain_model_product', 'p')
->join(
'p',
@@ -812,6 +831,16 @@ class ProductShowJsonRenderer
'mm',
'mm.uid_foreign = p.uid'
)
// Per-pair card copy from the workbook import. A separate table
// rather than an MM column, because DataHandler rewrites the MM
// rows on every product save - see tx_vitec_product_related_text
// in ext_tables.sql.
->leftJoin(
'mm',
'tx_vitec_product_related_text',
't',
't.product_uid = mm.uid_local AND t.related_uid = p.uid'
)
->where(
$queryBuilder->expr()->eq('mm.uid_local', $queryBuilder->createNamedParameter($productUid, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('p.deleted', 0),
@@ -831,6 +860,7 @@ class ProductShowJsonRenderer
'subtitle' => (string)($rel['subtitle'] ?? ''),
'teaser' => (string)($rel['teaser'] ?? ''),
'description' => RteResolver::html($rel['description'] ?? ''),
'cardtext' => (string)($rel['cardtext'] ?? ''),
'link' => '/product/' . (string)($rel['slug'] ?? ''),
'images' => $this->getProductImages($relUid),
];