Product import

This commit is contained in:
2026-08-28 13:57:54 +02:00
parent 7442d10e68
commit 7833e1b668
21 changed files with 763 additions and 185 deletions

View File

@@ -15,6 +15,7 @@ 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;
@@ -50,28 +51,50 @@ final class ProductTextImportController
/**
* 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.
* 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'];
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 = [
'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',
'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',
'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'];
@@ -252,6 +275,16 @@ final class ProductTextImportController
$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 = [];
@@ -271,6 +304,11 @@ final class ProductTextImportController
// 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) {
@@ -352,6 +390,60 @@ final class ProductTextImportController
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
/**
@@ -439,6 +531,9 @@ final class ProductTextImportController
$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,

View File

@@ -144,7 +144,7 @@ final class ProductXlsxReader
*/
public function sources(array $parsed): array
{
$groups = ['Meta' => [], 'Components' => [], 'Page SEO Check' => []];
$groups = ['Meta' => [], 'Components' => [], 'Composed' => [], 'Page SEO Check' => []];
foreach ($parsed['meta'] ?? [] as $key => $value) {
$groups['Meta'][] = $this->entry('m:' . $key, 'Meta · ' . $key, (string)$value);
@@ -169,6 +169,47 @@ final class ProductXlsxReader
$value
);
}
// Section-intro rows ("... — H2") additionally as ONE ready RTE
// value: heading plus paragraph in the exact markup the finished
// MGW-Diamond product stores in `textrelatedproducts` - imported
// and hand-written intros then render identically.
if (str_ends_with($name, '— H2')) {
$pair = $this->composePair($component);
if ($pair !== null) {
$groups['Composed'][] = $this->entry(
'p:' . $name . '#' . $component['occurrence'],
$name . $suffix . ' · Title+Body as HTML',
$pair
);
}
}
}
// Composed sources: a component occurring several times can be pulled
// as ONE value - every occurrence as <h3> + bullets/paragraph, prefixed
// with the section heading. Built for the Key Capability Groups, whose
// three rows land in the single `capabilities` field; that is the shape
// the finished product pages already use.
$counts = [];
foreach ($parsed['components'] ?? [] as $component) {
$counts[(string)$component['component']] = ((int)($component['occurrence'] ?? 1));
}
foreach ($counts as $name => $count) {
if ($count < 2 || in_array($name, self::HIDDEN_COMPONENTS, true)) {
continue;
}
$html = $this->composeGroup($parsed, $name);
if ($html !== null) {
$groups['Composed'][] = $this->entry('g:' . $name, $name . ' · all ' . $count . ' as HTML', $html);
}
}
// Cross-component composite for the SEO title: hero title, em dash,
// introduction heading - "Aligo — High-performance AV over IP and KVM".
$seoTitle = $this->composeSeoTitle($parsed);
if ($seoTitle !== null) {
$groups['Composed'][] = $this->entry('x:seotitle', 'Hero title — Intro heading (SEO title)', $seoTitle);
}
foreach ($parsed['seo'] ?? [] as $header => $value) {
@@ -191,6 +232,20 @@ final class ProductXlsxReader
$value = $parsed['meta'][substr($selector, 2)] ?? null;
return is_string($value) && $value !== '' ? $value : null;
}
if (str_starts_with($selector, 'g:')) {
return $this->composeGroup($parsed, substr($selector, 2));
}
if (str_starts_with($selector, 'p:') && preg_match('/^p:(.+)#(\d+)$/', $selector, $m)) {
foreach ($parsed['components'] ?? [] as $component) {
if ((string)$component['component'] === $m[1] && (int)$component['occurrence'] === (int)$m[2]) {
return $this->composePair($component);
}
}
return null;
}
if ($selector === 'x:seotitle') {
return $this->composeSeoTitle($parsed);
}
if (str_starts_with($selector, 's:')) {
$value = $parsed['seo'][substr($selector, 2)] ?? null;
return is_string($value) && $value !== '' ? $value : null;
@@ -206,6 +261,121 @@ final class ProductXlsxReader
return null;
}
/**
* SEO title composite: the hero title (the product name), an em dash, and
* the introduction heading. Plain text - `seotitle` is a plain input, no
* escaping wanted here.
*
* @param array<string,mixed> $parsed
*/
private function composeSeoTitle(array $parsed): ?string
{
$hero = null;
$intro = null;
foreach ($parsed['components'] ?? [] as $component) {
$name = (string)($component['component'] ?? '');
if ($name === 'Hero — H1' && (int)($component['occurrence'] ?? 1) === 1) {
$hero = trim((string)($component['title'] ?? ''));
} elseif ($name === 'Product Introduction' && (int)($component['occurrence'] ?? 1) === 1) {
$intro = trim((string)($component['title'] ?? ''));
}
}
if ($hero === null || $hero === '' || $intro === null || $intro === '') {
return null;
}
return $hero . ' — ' . $intro;
}
/**
* One component row as a heading/paragraph pair. The markup - centred h3
* with an inner span, centred p - is copied verbatim from what the
* finished MGW-Diamond product carries in `textrelatedproducts`, so the
* front end renders imported intros exactly like the hand-made one.
*
* @param array<string,mixed> $component
*/
private function composePair(array $component): ?string
{
$title = trim((string)($component['title'] ?? ''));
$body = trim((string)($component['body'] ?? ''));
if ($body === '') {
$body = trim((string)($component['cta'] ?? ''));
}
$body = trim((string)preg_replace('/\|\s*CTA:.*$/su', '', $body));
if ($title === '' || $body === '') {
return null;
}
return '<h3 class="text-center"><span>' . htmlspecialchars($title, ENT_QUOTES) . '</span></h3>' . "\n"
. '<p class="text-center">' . htmlspecialchars($body, ENT_QUOTES) . '</p>';
}
/**
* All occurrences of one component, composed into a single HTML block in
* the exact markup the hand-made MGW-Diamond `capabilities` field uses
* (the front end styles only that shape): the immediately preceding
* "... — H2" row (found by ORDER, not by name - "Key Capability Group" vs
* "Key Capabilities — H2" makes name matching fragile) becomes
* <h3 class="text-center"><span>, each occurrence's title a
* <p><strong> group heading, and a body whose text uses "•" bullets
* becomes a <ul>. Plain text otherwise.
*
* @param array<string,mixed> $parsed
*/
private function composeGroup(array $parsed, string $name): ?string
{
$components = $parsed['components'] ?? [];
$members = array_values(array_filter(
$components,
static fn(array $c): bool => (string)($c['component'] ?? '') === $name
));
if ($members === []) {
return null;
}
$html = '';
$firstOrder = (int)($members[0]['order'] ?? 0);
foreach ($components as $component) {
if ((int)($component['order'] ?? 0) === $firstOrder - 1
&& str_ends_with((string)($component['component'] ?? ''), '— H2')
&& trim((string)($component['title'] ?? '')) !== ''
) {
$html .= '<h3 class="text-center"><span>'
. htmlspecialchars(trim((string)$component['title']), ENT_QUOTES)
. '</span></h3>' . "\n";
break;
}
}
foreach ($members as $member) {
$title = trim((string)($member['title'] ?? ''));
$body = trim((string)($member['body'] ?? ''));
if ($body === '') {
$body = trim((string)($member['cta'] ?? ''));
}
// Card copy carries a "| CTA: <label>" suffix; the label is
// display-only everywhere else and would be junk inside a <p>.
$body = trim((string)preg_replace('/\|\s*CTA:.*$/su', '', $body));
if ($title !== '') {
$html .= '<p><strong>' . htmlspecialchars($title, ENT_QUOTES) . '</strong></p>';
}
if ($body === '') {
continue;
}
if (str_contains($body, '•')) {
$items = array_filter(array_map('trim', explode('•', $body)));
$html .= '<ul>';
foreach ($items as $item) {
$html .= '<li>' . htmlspecialchars($item, ENT_QUOTES) . '</li>';
}
$html .= '</ul>';
} else {
$html .= '<p>' . htmlspecialchars($body, ENT_QUOTES) . '</p>';
}
}
return $html !== '' ? $html : null;
}
/** @param array<int,array<string,mixed>> $components */
private function detectPageType(array $components): string
{

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Seo;
use FriendsOfTYPO3\Headless\Seo\MetaHandler;
use Psr\Http\Message\ServerRequestInterface;
/**
* Mirrors the real page <title> into `meta.title` of the page JSON.
*
* Why: `meta.title` is rendered from TypoScript (lib.meta) BEFORE the page
* content, so on product/usecase detail pages it still shows the generic
* page title ("Product") - the record's seotitle only reaches the
* PageTitleProvider while the content renders. Headless fixes `seo.title`
* afterwards through this handler; we extend it so `meta.title` gets the
* same final value. Registered via the MetaHandlerInterface alias in
* Services.yaml, which covers both the cacheable listener and the
* USER_INT middleware path.
*/
class VitecMetaHandler extends MetaHandler
{
public function process(ServerRequestInterface $request, array $content): array
{
$content = parent::process($request, $content);
$title = trim((string)($content['seo']['title'] ?? ''));
if ($title !== '' && is_array($content['meta'] ?? null)) {
$content['meta']['title'] = $title;
}
return $content;
}
}