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
@@ -14,43 +14,63 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
*
|
||||
* Plain table without TCA (tx_vitec_import_mapping) - the records are pure
|
||||
* tool configuration, never edited through FormEngine.
|
||||
*
|
||||
* The same row also stores record aliases (CSV identity value -> record
|
||||
* uid) for rows whose name differs slightly from the TYPO3 record, so a
|
||||
* hand-made link survives every future delivery of the same CSV.
|
||||
*/
|
||||
final class MappingRepository
|
||||
{
|
||||
private const TABLE = 'tx_vitec_import_mapping';
|
||||
|
||||
/**
|
||||
* @return array{mapping: array<string,string>, identity: string}|null
|
||||
* @return array{mapping: array<string,string>, identity: string, aliases: array<string,int>}|null
|
||||
*/
|
||||
public function load(string $model): ?array
|
||||
{
|
||||
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::TABLE);
|
||||
$row = $qb->select('mapping', 'identity_field')->from(self::TABLE)
|
||||
$row = $qb->select('mapping', 'identity_field', 'record_aliases')->from(self::TABLE)
|
||||
->where($qb->expr()->eq('model', $qb->createNamedParameter($model, ParameterType::STRING)))
|
||||
->setMaxResults(1)
|
||||
->executeQuery()->fetchAssociative();
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
// A row may exist with aliases only (saveAliases before the first
|
||||
// saveMapping) - an unparsable mapping degrades to [], not to null.
|
||||
$mapping = json_decode((string)$row['mapping'], true);
|
||||
if (!is_array($mapping)) {
|
||||
return null;
|
||||
}
|
||||
$aliases = json_decode((string)($row['record_aliases'] ?? ''), true);
|
||||
return [
|
||||
'mapping' => array_map('strval', $mapping),
|
||||
'mapping' => is_array($mapping) ? array_map('strval', $mapping) : [],
|
||||
'identity' => (string)$row['identity_field'],
|
||||
'aliases' => is_array($aliases) ? array_map('intval', $aliases) : [],
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string,string> $mapping */
|
||||
public function save(string $model, array $mapping, string $identity): void
|
||||
{
|
||||
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable(self::TABLE);
|
||||
$values = [
|
||||
'mapping' => (string)json_encode($mapping, JSON_UNESCAPED_UNICODE),
|
||||
'identity_field' => $identity,
|
||||
'tstamp' => time(),
|
||||
];
|
||||
$this->upsert($model, $values);
|
||||
}
|
||||
|
||||
/** @param array<string,int> $aliases */
|
||||
public function saveAliases(string $model, array $aliases): void
|
||||
{
|
||||
$this->upsert($model, [
|
||||
'record_aliases' => (string)json_encode($aliases, JSON_UNESCAPED_UNICODE),
|
||||
'tstamp' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $values */
|
||||
private function upsert(string $model, array $values): void
|
||||
{
|
||||
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable(self::TABLE);
|
||||
$exists = $connection->count('uid', self::TABLE, ['model' => $model]) > 0;
|
||||
if ($exists) {
|
||||
$connection->update(self::TABLE, $values, ['model' => $model]);
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Evomedien\Vitec\Import;
|
||||
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
|
||||
/**
|
||||
* Reads one agency-delivered "Product Page Content" workbook into a structured
|
||||
* array the import module can work with.
|
||||
*
|
||||
* The workbook format (verified against the Aligo, Arqa and AV-over-IP&KVM
|
||||
* deliveries of 2026-08): a meta block in the top rows (URL, primary keyword,
|
||||
* intent, SEO approach), a component table whose header row carries "Component"
|
||||
* in column B, and a second sheet "Page SEO Check" with one header and one
|
||||
* value row.
|
||||
*
|
||||
* The component column is a controlled vocabulary and doubles as the page-type
|
||||
* detector: product files carry "Product Introduction", category files carry
|
||||
* "Category Introduction". Category files use the same file format but a
|
||||
* different vocabulary and target a page rather than a record - they are
|
||||
* recognised here and rejected by the import flow, never guessed at.
|
||||
*
|
||||
* Source selectors (what the persisted mapping stores) are stable strings:
|
||||
*
|
||||
* c:<Component>#<n>:<cell> one cell of the n-th occurrence of a component
|
||||
* (cell = title | body | cta)
|
||||
* m:<key> meta block (url, primaryKeyword, intent, seoApproach)
|
||||
* s:<Header> one column of the "Page SEO Check" value row
|
||||
*
|
||||
* They survive across deliveries because the vocabulary is stable - that is
|
||||
* what makes the saved mapping reusable for every next product.
|
||||
*/
|
||||
final class ProductXlsxReader
|
||||
{
|
||||
/**
|
||||
* Explicitly marked "Internal note - not website copy" in the deliveries;
|
||||
* never offered as an import source.
|
||||
*/
|
||||
private const HIDDEN_COMPONENTS = ['Technical Gap / Validation Note'];
|
||||
|
||||
/** Meta-block labels (column A, lowercased) => meta keys. */
|
||||
private const META_LABELS = [
|
||||
'url' => 'url',
|
||||
'primary keyword' => 'primaryKeyword',
|
||||
'intent' => 'intent',
|
||||
'seo approach' => 'seoApproach',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* pageType: string,
|
||||
* meta: array<string,string>,
|
||||
* components: array<int,array<string,mixed>>,
|
||||
* seo: array<string,string>
|
||||
* }
|
||||
*/
|
||||
public function parse(string $filePath): array
|
||||
{
|
||||
$reader = IOFactory::createReaderForFile($filePath);
|
||||
$reader->setReadDataOnly(true);
|
||||
$spreadsheet = $reader->load($filePath);
|
||||
|
||||
$sheet = $spreadsheet->getSheet(0);
|
||||
// Formatting flags off: raw values, no calculated formatting - the
|
||||
// deliveries are pure text.
|
||||
$rows = $sheet->toArray(null, false, false, false);
|
||||
|
||||
// The header row is FOUND, not assumed at row 9: the one whose column B
|
||||
// says "Component".
|
||||
$headerIndex = null;
|
||||
foreach ($rows as $i => $row) {
|
||||
if (trim((string)($row[1] ?? '')) === 'Component') {
|
||||
$headerIndex = $i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$meta = [];
|
||||
$components = [];
|
||||
|
||||
if ($headerIndex !== null) {
|
||||
foreach (array_slice($rows, 0, $headerIndex) as $row) {
|
||||
$label = strtolower(trim((string)($row[0] ?? '')));
|
||||
$value = trim((string)($row[1] ?? ''));
|
||||
if ($label !== '' && $value !== '' && isset(self::META_LABELS[$label])) {
|
||||
$meta[self::META_LABELS[$label]] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
$occurrences = [];
|
||||
foreach (array_slice($rows, $headerIndex + 1) as $row) {
|
||||
$component = trim((string)($row[1] ?? ''));
|
||||
if ($component === '') {
|
||||
continue;
|
||||
}
|
||||
$occurrences[$component] = ($occurrences[$component] ?? 0) + 1;
|
||||
$components[] = [
|
||||
'order' => (int)($row[0] ?? 0),
|
||||
'component' => $component,
|
||||
'occurrence' => $occurrences[$component],
|
||||
'title' => trim((string)($row[2] ?? '')),
|
||||
'body' => trim((string)($row[3] ?? '')),
|
||||
'cta' => trim((string)($row[4] ?? '')),
|
||||
'lengthNote' => trim((string)($row[5] ?? '')),
|
||||
'seoNote' => trim((string)($row[6] ?? '')),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$seo = [];
|
||||
$seoSheet = $spreadsheet->getSheetByName('Page SEO Check')
|
||||
?? ($spreadsheet->getSheetCount() > 1 ? $spreadsheet->getSheet(1) : null);
|
||||
if ($seoSheet !== null) {
|
||||
$seoRows = $seoSheet->toArray(null, false, false, false);
|
||||
$headers = $seoRows[0] ?? [];
|
||||
$values = $seoRows[1] ?? [];
|
||||
foreach ($headers as $i => $header) {
|
||||
$header = trim((string)$header);
|
||||
$value = trim((string)($values[$i] ?? ''));
|
||||
if ($header !== '' && $value !== '') {
|
||||
$seo[$header] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$spreadsheet->disconnectWorksheets();
|
||||
|
||||
return [
|
||||
'pageType' => $this->detectPageType($components),
|
||||
'meta' => $meta,
|
||||
'components' => $components,
|
||||
'seo' => $seo,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The selectable sources of a parsed workbook, grouped for the dropdown.
|
||||
* Every entry: selector, label, value (full) and preview (shortened).
|
||||
*
|
||||
* @param array<string,mixed> $parsed
|
||||
* @return array<string,array<int,array{selector:string,label:string,value:string,preview:string}>>
|
||||
*/
|
||||
public function sources(array $parsed): array
|
||||
{
|
||||
$groups = ['Meta' => [], 'Components' => [], 'Page SEO Check' => []];
|
||||
|
||||
foreach ($parsed['meta'] ?? [] as $key => $value) {
|
||||
$groups['Meta'][] = $this->entry('m:' . $key, 'Meta · ' . $key, (string)$value);
|
||||
}
|
||||
|
||||
foreach ($parsed['components'] ?? [] as $component) {
|
||||
$name = (string)$component['component'];
|
||||
if (in_array($name, self::HIDDEN_COMPONENTS, true)) {
|
||||
continue;
|
||||
}
|
||||
$suffix = ((int)$component['occurrence']) > 1 || $this->occursMultipleTimes($parsed, $name)
|
||||
? ' #' . $component['occurrence']
|
||||
: '';
|
||||
foreach (['title' => 'Title', 'body' => 'Body', 'cta' => 'CTA'] as $cell => $cellLabel) {
|
||||
$value = (string)($component[$cell] ?? '');
|
||||
if ($value === '') {
|
||||
continue;
|
||||
}
|
||||
$groups['Components'][] = $this->entry(
|
||||
'c:' . $name . '#' . $component['occurrence'] . ':' . $cell,
|
||||
$name . $suffix . ' · ' . $cellLabel,
|
||||
$value
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($parsed['seo'] ?? [] as $header => $value) {
|
||||
$groups['Page SEO Check'][] = $this->entry('s:' . $header, 'SEO · ' . $header, (string)$value);
|
||||
}
|
||||
|
||||
return array_filter($groups, static fn(array $g): bool => $g !== []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves one stored selector against a parsed workbook. null = the
|
||||
* selector points at nothing in THIS file (component missing or cell
|
||||
* empty) - the caller shows that instead of silently writing ''.
|
||||
*
|
||||
* @param array<string,mixed> $parsed
|
||||
*/
|
||||
public function valueFor(array $parsed, string $selector): ?string
|
||||
{
|
||||
if (str_starts_with($selector, 'm:')) {
|
||||
$value = $parsed['meta'][substr($selector, 2)] ?? null;
|
||||
return is_string($value) && $value !== '' ? $value : null;
|
||||
}
|
||||
if (str_starts_with($selector, 's:')) {
|
||||
$value = $parsed['seo'][substr($selector, 2)] ?? null;
|
||||
return is_string($value) && $value !== '' ? $value : null;
|
||||
}
|
||||
if (str_starts_with($selector, 'c:') && preg_match('/^c:(.+)#(\d+):(title|body|cta)$/', $selector, $m)) {
|
||||
foreach ($parsed['components'] ?? [] as $component) {
|
||||
if ((string)$component['component'] === $m[1] && (int)$component['occurrence'] === (int)$m[2]) {
|
||||
$value = (string)($component[$m[3]] ?? '');
|
||||
return $value !== '' ? $value : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $components */
|
||||
private function detectPageType(array $components): string
|
||||
{
|
||||
$names = array_column($components, 'component');
|
||||
if (in_array('Product Introduction', $names, true)) {
|
||||
return 'product';
|
||||
}
|
||||
if (in_array('Category Introduction', $names, true)) {
|
||||
return 'category';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $parsed */
|
||||
private function occursMultipleTimes(array $parsed, string $name): bool
|
||||
{
|
||||
$count = 0;
|
||||
foreach ($parsed['components'] ?? [] as $component) {
|
||||
if ((string)$component['component'] === $name && ++$count > 1) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @return array{selector:string,label:string,value:string,preview:string} */
|
||||
private function entry(string $selector, string $label, string $value): array
|
||||
{
|
||||
$flat = preg_replace('/\s+/u', ' ', $value) ?? $value;
|
||||
return [
|
||||
'selector' => $selector,
|
||||
'label' => $label,
|
||||
'value' => $value,
|
||||
'preview' => mb_strlen($flat) > 60 ? mb_substr($flat, 0, 57) . '…' : $flat,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user