Files
VITEC-website/packages/vitec/Classes/Controller/Backend/ProductTextImportController.php
Oliver Rasche 5499ca54af Align product records with sitemap V2; product family model; CLI workbook import
- vitec:migrate-product-structure: exact V2 titles, Milestone typo fix (incl.
  slug), QTX100 stray VSN category removed, Aligo record moved to its own
  category with slug /product/aligo, new Aligo Workstation record takes over
  /product/aligo-workstation
- product families (sitemap column K) are ordinary content pages now:
  vitec:remove-family-records deletes the interim landing records again,
  reset_subproduct_flags.php clears the misused subproduct flag (the list
  renderer has always excluded subproduct=1)
- vitec:import-product-workbook: non-interactive counterpart of the module
  import (same reader, mapping and DataHandler path); Arqa imported, Aligo
  re-applied with the corrected capabilities markup
- ProductXlsxReader: new :copy cell selector (Body Copy with CTA fallback)
  absorbs the agency's column drift; default teaser mapping uses it
- ProductListJsonRenderer: selected categories now match their whole subtree,
  results ordered by category tree position, sheet order within a category
- read-only diagnostics: check_product_structure.php, check_productlist_ces.php
2026-09-04 12:59:17 +02:00

728 lines
33 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',
'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'])) {
$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);
}
// ------------------------------------------------- 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;
$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');
// 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);
} 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);
}
}