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
@@ -22,8 +22,10 @@ use Evomedien\Vitec\UserFunc\CustomerlogosJsonRenderer;
|
||||
use Evomedien\Vitec\UserFunc\FormsJsonRenderer;
|
||||
use Evomedien\Vitec\UserFunc\ModelcardJsonRenderer;
|
||||
use Evomedien\Vitec\UserFunc\NewsJsonRenderer;
|
||||
use Evomedien\Vitec\UserFunc\ContainerBackgroundRenderer;
|
||||
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
|
||||
|
||||
/**
|
||||
* Resolves a TYPO3 typolink string (as stored by `inputLink` fields like
|
||||
@@ -38,6 +40,12 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
* just like container children, so a referenced productlist / productshow /
|
||||
* usecaselist / usecaseshow appears with its full headless JSON in `data`.
|
||||
*
|
||||
* A linked CONTAINER (vitec_container, vitec_cols_*, vitec_cards_carousel)
|
||||
* additionally carries its resolved `background` and its children under
|
||||
* `items` - the exact shape ContainerChildrenProcessor emits for page-level
|
||||
* containers, so the frontend reuses its container component. Nested
|
||||
* containers recurse, depth-capped and cycle-safe.
|
||||
*
|
||||
* Exception-safe: every public entry point returns `null` on any failure
|
||||
* so the surrounding JSON output stays clean.
|
||||
*/
|
||||
@@ -71,6 +79,38 @@ final class ContentElementResolver
|
||||
|
||||
private const KEEP_IF_ZERO = ['header_layout'];
|
||||
|
||||
/**
|
||||
* Container-level fields - stripped from CHILD `data` like the page-level
|
||||
* ContainerChildrenProcessor does (they carry non-empty defaults and
|
||||
* belong to the parent). The linked container itself keeps them.
|
||||
*/
|
||||
private const CONTAINER_FIELDS = [
|
||||
'tx_vitec_gap',
|
||||
'tx_vitec_bg_variant', 'tx_vitec_bg_image', 'tx_vitec_bg_size',
|
||||
'tx_vitec_bg_size_percent', 'tx_vitec_bg_position',
|
||||
'tx_vitec_bg_pos_top', 'tx_vitec_bg_pos_bottom',
|
||||
'tx_vitec_bg_pos_left', 'tx_vitec_bg_pos_right',
|
||||
'tx_vitec_col1_align', 'tx_vitec_col1_justify',
|
||||
'tx_vitec_col2_align', 'tx_vitec_col2_justify',
|
||||
'tx_vitec_col3_align', 'tx_vitec_col3_justify',
|
||||
'tx_vitec_col4_align', 'tx_vitec_col4_justify',
|
||||
];
|
||||
|
||||
/**
|
||||
* Per container CType: colPos value => 1-based column number, for the
|
||||
* parent's tx_vitec_col{N}_align/justify - same map as the processor.
|
||||
*/
|
||||
private const COLPOS_TO_COLUMN = [
|
||||
'vitec_cols_50_50' => [211 => 1, 212 => 2],
|
||||
'vitec_cols_33_66' => [251 => 1, 252 => 2],
|
||||
'vitec_cols_66_33' => [241 => 1, 242 => 2],
|
||||
'vitec_cols_33_33_33' => [221 => 1, 222 => 2, 223 => 3],
|
||||
'vitec_cols_25_25_25_25' => [231 => 1, 232 => 2, 233 => 3, 234 => 4],
|
||||
];
|
||||
|
||||
/** Recursion cap for nested containers. */
|
||||
private const MAX_CONTAINER_DEPTH = 5;
|
||||
|
||||
private const PLUGIN_RENDERERS = [
|
||||
'vitec_productlist' => [ProductListJsonRenderer::class, 'products'],
|
||||
'vitec_productshow' => [ProductShowJsonRenderer::class, 'product'],
|
||||
@@ -143,7 +183,10 @@ final class ContentElementResolver
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::normaliseRecord($row);
|
||||
$element = self::normaliseRecord($row);
|
||||
self::attachContainerPayload($row, $element, [$uid => true], 0);
|
||||
|
||||
return $element;
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
@@ -156,9 +199,12 @@ final class ContentElementResolver
|
||||
* resolved to their headless JSON.
|
||||
*
|
||||
* @param array<string,mixed> $record
|
||||
* @param bool $isContainerChild strip container-level fields (bg, gap,
|
||||
* col flex) like the page-level processor
|
||||
* does for its children
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public static function normaliseRecord(array $record): array
|
||||
public static function normaliseRecord(array $record, bool $isContainerChild = false): array
|
||||
{
|
||||
$data = [];
|
||||
foreach ($record as $field => $value) {
|
||||
@@ -168,6 +214,9 @@ final class ContentElementResolver
|
||||
if (in_array($field, self::SYSTEM_FIELDS, true)) {
|
||||
continue;
|
||||
}
|
||||
if ($isContainerChild && in_array($field, self::CONTAINER_FIELDS, true)) {
|
||||
continue;
|
||||
}
|
||||
if (self::isEmpty($value) && !in_array($field, self::KEEP_IF_ZERO, true)) {
|
||||
continue;
|
||||
}
|
||||
@@ -279,6 +328,107 @@ final class ContentElementResolver
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If the element has container children (tx_container_parent), attach the
|
||||
* resolved `background` and the children as `items` in the page-level
|
||||
* shape: [{config: {colPos, align?, justify?}, contentElements: [...]}].
|
||||
* Recurses into nested containers; $visited guards against cycles.
|
||||
*
|
||||
* @param array<string,mixed> $record raw tt_content row
|
||||
* @param array<string,mixed> $element normalised element, modified in place
|
||||
* @param array<int,bool> $visited uids already on this path
|
||||
*/
|
||||
private static function attachContainerPayload(array $record, array &$element, array $visited, int $depth): void
|
||||
{
|
||||
$background = self::resolveContainerBackground($record);
|
||||
if ($background !== null) {
|
||||
$element['background'] = $background;
|
||||
}
|
||||
|
||||
if ($depth >= self::MAX_CONTAINER_DEPTH) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
|
||||
->getQueryBuilderForTable('tt_content');
|
||||
$rows = $qb
|
||||
->select('*')
|
||||
->from('tt_content')
|
||||
->where(
|
||||
$qb->expr()->eq('tx_container_parent', $qb->createNamedParameter((int)$record['uid'], ParameterType::INTEGER)),
|
||||
$qb->expr()->eq('pid', $qb->createNamedParameter((int)($record['pid'] ?? 0), ParameterType::INTEGER)),
|
||||
$qb->expr()->eq('sys_language_uid', $qb->createNamedParameter((int)($record['sys_language_uid'] ?? 0), ParameterType::INTEGER))
|
||||
)
|
||||
->orderBy('colPos')
|
||||
->addOrderBy('sorting')
|
||||
->executeQuery()
|
||||
->fetchAllAssociative();
|
||||
} catch (\Throwable $e) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($rows === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$byColPos = [];
|
||||
foreach ($rows as $childRow) {
|
||||
$childUid = (int)$childRow['uid'];
|
||||
$child = self::normaliseRecord($childRow, true);
|
||||
if (!isset($visited[$childUid])) {
|
||||
$childVisited = $visited;
|
||||
$childVisited[$childUid] = true;
|
||||
self::attachContainerPayload($childRow, $child, $childVisited, $depth + 1);
|
||||
}
|
||||
$byColPos[(int)$childRow['colPos']][] = $child;
|
||||
}
|
||||
ksort($byColPos);
|
||||
|
||||
$colMap = self::COLPOS_TO_COLUMN[(string)($record['CType'] ?? '')] ?? [];
|
||||
$items = [];
|
||||
foreach ($byColPos as $colPos => $contentElements) {
|
||||
$config = ['colPos' => $colPos];
|
||||
$columnNo = $colMap[$colPos] ?? null;
|
||||
if ($columnNo !== null) {
|
||||
$config['align'] = (string)($record['tx_vitec_col' . $columnNo . '_align'] ?? 'stretch');
|
||||
$config['justify'] = (string)($record['tx_vitec_col' . $columnNo . '_justify'] ?? 'flex-start');
|
||||
}
|
||||
$items[] = [
|
||||
'config' => $config,
|
||||
'contentElements' => $contentElements,
|
||||
];
|
||||
}
|
||||
|
||||
$element['items'] = $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolved {image, size, position} of a container row - the same payload
|
||||
* ContainerBackgroundRenderer emits in the page JSON. Null without image.
|
||||
*
|
||||
* @param array<string,mixed> $record
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
private static function resolveContainerBackground(array $record): ?array
|
||||
{
|
||||
try {
|
||||
$cObj = GeneralUtility::makeInstance(ContentObjectRenderer::class);
|
||||
$cObj->start($record, 'tt_content');
|
||||
$renderer = GeneralUtility::makeInstance(ContainerBackgroundRenderer::class);
|
||||
$renderer->setContentObjectRenderer($cObj);
|
||||
$json = $renderer->render('', []);
|
||||
if ($json === '') {
|
||||
return null;
|
||||
}
|
||||
$decoded = json_decode($json, true);
|
||||
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static function isEmpty(mixed $value): bool
|
||||
{
|
||||
return $value === null || $value === '' || $value === 0 || $value === '0';
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Evomedien\Vitec\Service;
|
||||
|
||||
use Doctrine\DBAL\ParameterType;
|
||||
use TYPO3\CMS\Core\Core\Environment;
|
||||
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
|
||||
/**
|
||||
* Resolves a download record to its file on disk.
|
||||
*
|
||||
* Resolution order is the one the three download renderers use for their
|
||||
* `file` payload - FAL reference, then the Collateral naming convention,
|
||||
* then the filepath column - so a record link and the JSON always point at
|
||||
* the same file. The renderers still carry private copies of this logic for
|
||||
* their richer payload (thumbnail, title, url); consolidating them onto this
|
||||
* service is the standing 10.2 goal (Annex B-4).
|
||||
*
|
||||
* Contract: null means "no file". Never throws.
|
||||
*/
|
||||
final class DownloadFileResolver
|
||||
{
|
||||
/**
|
||||
* @return array{path: string, name: string, size: int, mimeType: string}|null
|
||||
*/
|
||||
public static function resolve(int $downloadUid): ?array
|
||||
{
|
||||
if ($downloadUid <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$download = self::loadDownload($downloadUid);
|
||||
if ($download === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$path = self::resolveByFal($downloadUid)
|
||||
?? self::resolveByConvention(
|
||||
(string)($download['fileprefix'] ?? ''),
|
||||
self::fileType($downloadUid)
|
||||
)
|
||||
?? self::resolveByFilepath((string)($download['filepath'] ?? ''));
|
||||
|
||||
return $path !== null ? self::describe($path) : null;
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
private static function loadDownload(int $uid): ?array
|
||||
{
|
||||
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
|
||||
->getQueryBuilderForTable('tx_vitec_domain_model_download');
|
||||
$row = $qb
|
||||
->select('uid', 'fileprefix', 'filepath')
|
||||
->from('tx_vitec_domain_model_download')
|
||||
->where(
|
||||
$qb->expr()->eq('uid', $qb->createNamedParameter($uid, ParameterType::INTEGER)),
|
||||
$qb->expr()->eq('deleted', 0),
|
||||
$qb->expr()->eq('hidden', 0)
|
||||
)
|
||||
->setMaxResults(1)
|
||||
->executeQuery()
|
||||
->fetchAssociative();
|
||||
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
private static function resolveByFal(int $downloadUid): ?string
|
||||
{
|
||||
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
|
||||
->getQueryBuilderForTable('sys_file_reference');
|
||||
$row = $qb
|
||||
->select('f.identifier')
|
||||
->from('sys_file_reference', 'fr')
|
||||
->join('fr', 'sys_file', 'f', 'fr.uid_local = f.uid')
|
||||
->where(
|
||||
$qb->expr()->eq('fr.tablenames', $qb->createNamedParameter('tx_vitec_domain_model_download', ParameterType::STRING)),
|
||||
$qb->expr()->eq('fr.fieldname', $qb->createNamedParameter('file', ParameterType::STRING)),
|
||||
$qb->expr()->eq('fr.uid_foreign', $qb->createNamedParameter($downloadUid, ParameterType::INTEGER)),
|
||||
$qb->expr()->eq('fr.deleted', 0),
|
||||
$qb->expr()->eq('f.missing', 0)
|
||||
)
|
||||
->orderBy('fr.sorting_foreign', 'ASC')
|
||||
->setMaxResults(1)
|
||||
->executeQuery()
|
||||
->fetchAssociative();
|
||||
|
||||
$identifier = (string)($row['identifier'] ?? '');
|
||||
if ($identifier === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$path = Environment::getPublicPath() . '/fileadmin' . $identifier;
|
||||
|
||||
return is_file($path) && is_readable($path) ? $path : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Highest revision of <prefix>__<filetype>__<NN>-<letters>.<ext> in the
|
||||
* Collateral directory - same pattern and ranking as the renderers.
|
||||
*/
|
||||
private static function resolveByConvention(string $fileprefix, string $filetype): ?string
|
||||
{
|
||||
$fileprefix = trim($fileprefix);
|
||||
$filetype = trim($filetype);
|
||||
if ($fileprefix === '' || $filetype === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$baseDir = Environment::getPublicPath() . '/fileadmin/downloads/Collateral';
|
||||
if (!is_dir($baseDir) || !is_readable($baseDir)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$pattern = '/^' . preg_quote($fileprefix, '/') . '__' . preg_quote($filetype, '/')
|
||||
. '__(\\d+)-([A-Za-z]+)\\.([A-Za-z0-9]+)$/';
|
||||
|
||||
$entries = scandir($baseDir);
|
||||
if ($entries === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$bestFile = null;
|
||||
$bestNumber = -1;
|
||||
$bestLetterRank = -1;
|
||||
foreach ($entries as $entry) {
|
||||
if (!is_string($entry) || !preg_match($pattern, $entry, $matches)) {
|
||||
continue;
|
||||
}
|
||||
$number = (int)$matches[1];
|
||||
$letterRank = self::letterSequenceToRank((string)$matches[2]);
|
||||
if ($number > $bestNumber || ($number === $bestNumber && $letterRank > $bestLetterRank)) {
|
||||
$bestNumber = $number;
|
||||
$bestLetterRank = $letterRank;
|
||||
$bestFile = $entry;
|
||||
}
|
||||
}
|
||||
|
||||
if ($bestFile === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$path = $baseDir . '/' . $bestFile;
|
||||
|
||||
return is_file($path) && is_readable($path) ? $path : null;
|
||||
}
|
||||
|
||||
private static function resolveByFilepath(string $filepath): ?string
|
||||
{
|
||||
$filepath = trim($filepath);
|
||||
if ($filepath === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$path = Environment::getPublicPath() . '/' . ltrim($filepath, '/');
|
||||
|
||||
return is_file($path) && is_readable($path) ? $path : null;
|
||||
}
|
||||
|
||||
/** Filetype segment from the assigned filetype category (parent 4). */
|
||||
private static function fileType(int $downloadUid): string
|
||||
{
|
||||
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
|
||||
->getQueryBuilderForTable('sys_category');
|
||||
$categories = $qb
|
||||
->select('c.filetype')
|
||||
->from('sys_category', 'c')
|
||||
->join(
|
||||
'c',
|
||||
'sys_category_record_mm',
|
||||
'mm',
|
||||
'mm.uid_local = c.uid AND mm.tablenames = ' .
|
||||
$qb->createNamedParameter('tx_vitec_domain_model_download', ParameterType::STRING) .
|
||||
' AND mm.fieldname = ' .
|
||||
$qb->createNamedParameter('categories', ParameterType::STRING)
|
||||
)
|
||||
->where(
|
||||
$qb->expr()->eq('mm.uid_foreign', $qb->createNamedParameter($downloadUid, ParameterType::INTEGER)),
|
||||
$qb->expr()->eq('c.deleted', 0),
|
||||
$qb->expr()->eq('c.hidden', 0),
|
||||
$qb->expr()->eq('c.parent', $qb->createNamedParameter(4, ParameterType::INTEGER))
|
||||
)
|
||||
->orderBy('mm.sorting', 'ASC')
|
||||
->executeQuery()
|
||||
->fetchAllAssociative();
|
||||
|
||||
foreach ($categories as $category) {
|
||||
$filetype = trim((string)($category['filetype'] ?? ''));
|
||||
if ($filetype !== '') {
|
||||
return $filetype;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/** "A" = 1 ... "Z" = 26, "AA" = 27 - revision letters as a rank. */
|
||||
private static function letterSequenceToRank(string $letters): int
|
||||
{
|
||||
$rank = 0;
|
||||
foreach (str_split(strtoupper($letters)) as $char) {
|
||||
$rank = $rank * 26 + (ord($char) - 64);
|
||||
}
|
||||
|
||||
return $rank;
|
||||
}
|
||||
|
||||
/** @return array{path: string, name: string, size: int, mimeType: string} */
|
||||
private static function describe(string $path): array
|
||||
{
|
||||
$mimeType = function_exists('mime_content_type') ? (string)(mime_content_type($path) ?: '') : '';
|
||||
|
||||
return [
|
||||
'path' => $path,
|
||||
'name' => basename($path),
|
||||
'size' => (int)(filesize($path) ?: 0),
|
||||
'mimeType' => $mimeType,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -45,4 +45,26 @@ final class LinkResolver
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a full typolink parameter (t3://page..., t3://file..., an
|
||||
* external URL, mailto:...) to a URL. Same contract as pageUrl():
|
||||
* null means "no link", and a failure never throws.
|
||||
*/
|
||||
public static function typolinkUrl(string $parameter): ?string
|
||||
{
|
||||
$parameter = trim($parameter);
|
||||
if ($parameter === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$cObj = GeneralUtility::makeInstance(ContentObjectRenderer::class);
|
||||
$url = $cObj->typoLink_URL(['parameter' => $parameter]);
|
||||
|
||||
return $url !== '' ? $url : null;
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user