Files
VITEC-website/packages/vitec/Classes/Controller/Backend/ProductTextImportController.php
T
orasche f0b0694842 - New extension fe_comment (Evomedien, framework-agnostic): floating page
feedback widget served from /api/fe-comment/widget.js, note pad in
  localStorage, submissions mailed via /api/fe-comment/submit with
  recipients from the extension configuration; classic sites get the
  script injected via AssetCollector when the jsonOutput setting is off.
  An enabled master switch serves the widget EMPTY when off (no 404, no
  console errors) and rejects submissions - the go-live kill switch, no
  front-end build change needed
- URL swap finished: /_json prefix middleware, SPA fallback live with a
  dedicated root rule (REQUEST_FILENAME of / is a directory) and a
  query-string exception for the TYPO3 v14 install tool entry
  (?__typo3_install on the site root - the backend Settings/Environment
  modules depend on it); /api/ as a whole stays with TYPO3
- MailMessage::send() no longer exists in v14: fe_comment and the form
  EmailDelivery (latently broken since the core upgrade) now send through
  the Mailer service. Mail transport moved to SMTP against the local exim
  so delivery errors surface instead of bouncing into the blackhole -
  exim verifies the envelope sender, which therefore must be a routable
  existing address (transport config lives in the untracked settings.php)
- Related-product card texts editable on the product record (table rebuilt
  with uid/pid, TCA + inline field, one-time migration script); shared
  RteCompare for the module badge and the CLI comparison - batch dry-run
  now reports 0 writes instead of flagging every richtext field
- Workbook clarification cases executed: VisionIO-SD14, VisionSC-SD14 and
  both Diamond OG records imported (40 fields, live-verified); ArtioSign
  and ArtioView created hidden (uid 109/110) via
  create_artio_records.php - the product table has no sorting column;
  WallControl_10 mapping REVERTED and parked: the dry run revealed
  aetria-wallcontrol already carries its own agency workbook, which
  WallControl_10 would have overwritten. Manifest now 75 product /
  24 family / 1 open
2026-09-30 13:59:55 +02:00

759 lines
35 KiB
PHP

<?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\JsonResponse;
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. The flags and the shortcut pid are
* editorial switches that will never come out of a workbook (decision
* 2026-08-28) - a text import writing "1" into hideonwebsite would silently
* unpublish a product. Only this XLSX flow filters them; the CSV flow keeps
* the full field list.
*/
private const EXCLUDED_TARGETS = [
'slug',
'hideonapp',
'hideonwebsite',
'hideonproducts',
'hideondatasheets',
'shortcutpid',
'shortcut',
'legacy',
'supportproduct',
'subproduct',
];
/**
* 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.
*
* Verified against the finished MGW-Diamond-H page (2026-08-28): the hero
* one-liner lives in `teaser` (subtitle is unused there), the ~45-70-word
* introduction in `description`, the ~80-120-word overview in
* `description2`, and the three Key Capability Groups land as ONE HTML
* block in `capabilities` - hence the composed `g:` selector. Why-Choose
* cards, resources, pre-footer and hero CTA are page content elements or
* template copy on the finished page, not record fields, and therefore
* have no defaults here. An earlier version of this seed mapped onto
* `subtitle` and onto columns that exist only in the DB but not in the
* TCA (`cta`, `key1-3`, `apptext1-3`) - both corrected.
*/
public const DEFAULT_MAPPING = [
'seotitle' => 'x:seotitle',
// :copy = body with cta fallback - Aligo carries the hero one-liner
// in "Body Copy", Arqa in "CTA / Card Copy" (column drift).
'teaser' => 'c:Hero — H1#1:copy',
'description' => 'c:Product Introduction#1:body',
'description2' => 'c:Product Overview — H2#1:body',
'capabilities' => 'g:Key Capability Group',
// Decisions 2026-09-18 (field walkthrough completed): the new
// 'Technical Highlight' spec rows and the Why-Choose benefit cards
// each compose into one block; meta description is trimmed from the
// intro because the SEO sheet still ships none.
'highlights' => 'g:Technical Highlight',
'applications' => 'g:Why Choose Card',
'seometa' => 'x:seometa',
'textrelatedproducts' => 'p:Related Products — H2#1',
'keywords' => 'm:primaryKeyword',
];
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'] ?? []));
// A per-row "Save" button writes exactly its own row; the checkbox
// state of every other row is deliberately ignored then. Only one
// submit button ever posts its value, so the two can't both be set.
$applySingle = trim((string)($body['applySingle'] ?? ''));
$applyRelatedSingle = trim((string)($body['applyRelatedSingle'] ?? ''));
if ($applySingle !== '') {
$apply = [$applySingle];
} elseif ($applyRelatedSingle !== '') {
$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'] ?? []));
if ($applySingle !== '') {
$applyRelated = [];
} elseif ($applyRelatedSingle !== '') {
$applyRelated = [(int)$applyRelatedSingle];
}
$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'])) {
// Merge instead of overwrite: a field whose dropdown was empty on
// THIS submit keeps its stored selector (this is how `teaser` got
// lost once). Clearing a mapping on purpose means picking another
// source, not leaving the select blank.
$stored = $this->mappingRepository->load(self::MAPPING_KEY)['mapping'] ?? [];
$this->mappingRepository->save(self::MAPPING_KEY, array_filter($map) + $stored, '-');
}
$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);
}
// ------------------------------------------------- inline field editing
/**
* AJAX (vitec_product_field_get): the FULL raw value of one product field
* for the click-to-edit cell - the cell itself only shows a truncated
* preview. RTE fields return their stored HTML; editing is deliberately
* source-level (decision 2026-08-28: no inline WYSIWYG).
*/
public function fieldGet(ServerRequestInterface $request): ResponseInterface
{
$params = $request->getQueryParams();
$uid = (int)($params['uid'] ?? 0);
$field = (string)($params['field'] ?? '');
$product = $this->loadProduct($uid);
$targets = $this->targetFields();
if ($product === null || !isset($targets[$field])) {
return new JsonResponse(['success' => false, 'message' => 'Unknown product or field.']);
}
return new JsonResponse([
'success' => true,
'value' => (string)($product[$field] ?? ''),
]);
}
/**
* AJAX (vitec_product_field_save): write one product field. Same target
* whitelist and the same DataHandler path as the form apply - RTE
* transformations and record history included. Responds with the freshly
* re-read value so the cell preview shows what was actually stored.
*/
public function fieldSave(ServerRequestInterface $request): ResponseInterface
{
$body = (array)$request->getParsedBody();
$uid = (int)($body['uid'] ?? 0);
$field = (string)($body['field'] ?? '');
$value = (string)($body['value'] ?? '');
$targets = $this->targetFields();
if ($this->loadProduct($uid) === null || !isset($targets[$field])) {
return new JsonResponse(['success' => false, 'message' => 'Unknown product or field.']);
}
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start([self::TABLE => [(string)$uid => [$field => $value]]], []);
$dataHandler->process_datamap();
if ($dataHandler->errorLog !== []) {
return new JsonResponse(['success' => false, 'message' => implode(' | ', $dataHandler->errorLog)]);
}
$fresh = (string)($this->loadProduct($uid)[$field] ?? '');
return new JsonResponse([
'success' => true,
'value' => $fresh,
'preview' => $this->preview($fresh),
]);
}
// ------------------------------------------------------------ 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;
// RTE-tolerant, shared with the CLI - a plain trim comparison
// flagged "differs" on pure storage-transform noise (inter-tag
// whitespace, <p> wrap, entity re-encoding).
$changed = $new !== null && !\Evomedien\Vitec\Service\RteCompare::equal($old, $new);
$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');
// Click-to-edit for the "Current value" column; a module because the
// backend CSP blocks inline handlers.
$this->pageRenderer->loadJavaScriptModule('@evomedien/vitec/product-inline-edit.js');
$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);
return;
}
// Since the table became TCA-visible (inline on the product,
// 2026-09-28) new rows need pid, crdate and the pair's MM sorting -
// the relation is written right before this call, so it exists.
$pid = (int)$connection->fetchOne(
'SELECT pid FROM ' . self::TABLE . ' WHERE uid = ?', [$productUid]
);
$sorting = (int)$connection->fetchOne(
'SELECT sorting FROM tx_vitec_product_related_mm WHERE uid_local = ? AND uid_foreign = ?',
[$productUid, $relatedUid]
);
$connection->insert('tx_vitec_product_related_text', $keys + [
'cardtext' => $text,
'tstamp' => time(),
'crdate' => time(),
'pid' => $pid,
'sorting' => $sorting,
]);
}
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);
}
}