* "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. */ private const DEFAULT_MAPPING = [ 'seotitle' => 'x:seotitle', 'teaser' => 'c:Hero — H1#1:body', '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|null $product * @param array|null $parsed * @param array $submittedMap map from the form; wins over saved/default */ private function renderTexts(ServerRequestInterface $request, ?array $product, ?array $parsed, array $submittedMap): ResponseInterface { $targets = $this->targetFields(); $saved = $this->mappingRepository->load(self::MAPPING_KEY)['mapping'] ?? []; $rows = []; $sources = []; $slugMismatch = null; if ($parsed !== null) { $sources = $this->reader->sources($parsed); // Workbook URL vs record slug - informational only (slug is not a // target), but a mismatch usually means "wrong product picked". $url = trim((string)($parsed['meta']['url'] ?? ''), '/'); $slug = trim((string)($product['slug'] ?? ''), '/'); if ($url !== '' && $slug !== '' && !str_ends_with($url, $slug)) { $slugMismatch = '/' . $url . ' (workbook) vs /' . $slug . ' (record)'; } } // Related-product cards: match preselection order is submitted choice -> // remembered alias -> exact title -> none. Submitted choices are read // from the request here so the dropdowns survive a "Refresh preview". $relatedRows = []; $productOptions = []; if ($parsed !== null && $product !== null) { $productUid = (int)$product['uid']; $productOptions = $this->productOptions($productUid); $submittedRelated = array_map('intval', (array)(((array)$request->getParsedBody())['related'] ?? [])); $aliases = $this->mappingRepository->load(self::MAPPING_KEY)['aliases'] ?? []; $byTitle = []; foreach ($productOptions as $option) { $byTitle[mb_strtolower($option['title'])] = $option['uid']; } $existingRelated = array_flip($this->relatedUidsOf($productUid)); foreach ($this->relatedCards($parsed) as $index => $card) { $chosen = $submittedRelated[$index] ?? 0; $status = $chosen > 0 ? 'manual' : 'none'; if ($chosen === 0 && isset($aliases[$card['title']])) { $chosen = (int)$aliases[$card['title']]; $status = 'alias'; } elseif ($chosen === 0 && isset($byTitle[mb_strtolower($card['title'])])) { $chosen = $byTitle[mb_strtolower($card['title'])]; $status = 'title'; } $old = $chosen > 0 ? $this->existingCardText($productUid, $chosen) : ''; $relatedRows[] = [ 'index' => $index, 'title' => $card['title'], 'cta' => $card['cta'], 'preview' => $this->preview($card['cardtext']), 'selected' => $chosen, 'status' => $status, 'isRelated' => $chosen > 0 && isset($existingRelated[$chosen]), 'oldPreview' => $this->preview($old), 'changed' => $chosen > 0 && trim($old) !== trim($card['cardtext']), ]; } } foreach ($targets as $field => $label) { $selector = $submittedMap[$field] ?? $saved[$field] ?? self::DEFAULT_MAPPING[$field] ?? ''; $old = (string)($product[$field] ?? ''); $new = ($parsed !== null && $selector !== '') ? $this->reader->valueFor($parsed, $selector) : null; $changed = $new !== null && trim($new) !== trim($old); $rows[] = [ 'field' => $field, 'label' => $label, 'selector' => $selector, 'old' => $old, 'oldPreview' => $this->preview($old), 'new' => $new, 'newPreview' => $new === null ? '' : $this->preview($new), 'changed' => $changed, ]; } $view = $this->moduleTemplateFactory->create($request); $this->pageRenderer->addCssFile('EXT:vitec/Resources/Public/Css/backend-import.css'); // 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 $parsed */ private function saveWorkbook(int $productUid, array $parsed, string $filename): void { $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tx_vitec_product_workbook'); $values = [ 'filename' => mb_substr($filename, 0, 255), 'payload' => (string)json_encode($parsed, JSON_UNESCAPED_UNICODE), 'be_user' => (int)($GLOBALS['BE_USER']->user['uid'] ?? 0), 'tstamp' => time(), ]; if ($connection->count('product_uid', 'tx_vitec_product_workbook', ['product_uid' => $productUid]) > 0) { $connection->update('tx_vitec_product_workbook', $values, ['product_uid' => $productUid]); } else { $connection->insert('tx_vitec_product_workbook', $values + ['product_uid' => $productUid]); } } /** @return array{filename:string,date:string}|null display info for the stored workbook */ private function workbookInfo(int $productUid): ?array { if ($productUid <= 0) { return null; } $qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('tx_vitec_product_workbook'); $row = $qb->select('filename', 'tstamp')->from('tx_vitec_product_workbook') ->where($qb->expr()->eq('product_uid', $qb->createNamedParameter($productUid, ParameterType::INTEGER))) ->executeQuery()->fetchAssociative(); if (!$row) { return null; } return [ 'filename' => (string)$row['filename'], 'date' => date('Y-m-d H:i', (int)$row['tstamp']), ]; } /** @return array field => label, without excluded targets */ private function targetFields(): array { $fields = $this->registry->importableFields(self::TABLE); foreach (self::EXCLUDED_TARGETS as $excluded) { unset($fields[$excluded]); } return $fields; } /** @return array|null */ private function loadProduct(int $uid): ?array { if ($uid <= 0) { return null; } $qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::TABLE); $row = $qb->select('*')->from(self::TABLE) ->where( $qb->expr()->eq('uid', $qb->createNamedParameter($uid, ParameterType::INTEGER)), $qb->expr()->eq('deleted', 0) ) ->executeQuery()->fetchAssociative(); return $row ?: null; } /** * The "Related Product Card" components of a parsed workbook. The card copy * arrives as "Explore QTX100 ... | CTA: Learn more" - text and CTA label are * split here; the CTA label has no storage yet and is display-only. * * @param array $parsed * @return array */ private function relatedCards(array $parsed): array { $cards = []; foreach ($parsed['components'] ?? [] as $component) { if ((string)($component['component'] ?? '') !== 'Related Product Card') { continue; } // The agency uses the columns loosely: text components carry their // copy in "Body Copy" (D), card components in "CTA / Card Copy" (E). // Verified in the raw sheet - D of a card row is an empty // self-closing cell. Take D when filled, fall back to E. $raw = trim((string)($component['body'] ?? '')); if ($raw === '') { $raw = trim((string)($component['cta'] ?? '')); } $cta = ''; if (preg_match('/^(.*?)\|\s*CTA:\s*(.+)$/su', $raw, $m)) { $raw = $m[1]; $cta = trim($m[2]); } $cards[] = [ 'title' => trim((string)($component['title'] ?? '')), 'cardtext' => trim($raw), 'cta' => $cta, ]; } return $cards; } /** @return array all products except the edited one */ private function productOptions(int $excludeUid): array { $qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::TABLE); $rows = $qb->select('uid', 'title')->from(self::TABLE) ->where( $qb->expr()->eq('deleted', 0), $qb->expr()->neq('uid', $qb->createNamedParameter($excludeUid, ParameterType::INTEGER)) ) ->orderBy('title', 'ASC') ->executeQuery()->fetchAllAssociative(); return array_map(static fn(array $r): array => ['uid' => (int)$r['uid'], 'title' => (string)$r['title']], $rows); } /** @return int[] related product uids in MM sorting order */ private function relatedUidsOf(int $productUid): array { $qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('tx_vitec_product_related_mm'); $uids = $qb->select('uid_foreign')->from('tx_vitec_product_related_mm') ->where($qb->expr()->eq('uid_local', $qb->createNamedParameter($productUid, ParameterType::INTEGER))) ->orderBy('sorting', 'ASC') ->executeQuery()->fetchFirstColumn(); return array_map('intval', $uids); } private function existingCardText(int $productUid, int $relatedUid): string { $qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('tx_vitec_product_related_text'); $text = $qb->select('cardtext')->from('tx_vitec_product_related_text') ->where( $qb->expr()->eq('product_uid', $qb->createNamedParameter($productUid, ParameterType::INTEGER)), $qb->expr()->eq('related_uid', $qb->createNamedParameter($relatedUid, ParameterType::INTEGER)) ) ->executeQuery()->fetchOne(); return is_string($text) ? $text : ''; } private function upsertCardText(int $productUid, int $relatedUid, string $text): void { $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tx_vitec_product_related_text'); $keys = ['product_uid' => $productUid, 'related_uid' => $relatedUid]; if ($connection->count('product_uid', 'tx_vitec_product_related_text', $keys) > 0) { $connection->update('tx_vitec_product_related_text', ['cardtext' => $text, 'tstamp' => time()], $keys); } else { $connection->insert('tx_vitec_product_related_text', $keys + ['cardtext' => $text, 'tstamp' => time()]); } } private function preview(string $value): string { $flat = preg_replace('/\s+/u', ' ', trim($value)) ?? $value; return mb_strlen($flat) > 160 ? mb_substr($flat, 0, 157) . '…' : $flat; } private function message(string $text, ContextualFeedbackSeverity $severity): void { $message = GeneralUtility::makeInstance(FlashMessage::class, $text, '', $severity, true); $this->flashMessageService->getMessageQueueByIdentifier()->addMessage($message); } }