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:
1 parent
12eb40614b
commit
7bd1161abf
28 files changed
+2594
-39
No files matched your search
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user