Files
VITEC-website/packages/vitec/Classes/Command/ImportSuccessStoriesCommand.php
khaccount eae40f4286 Success-story migration + new plugins + JSON/UX polish
- Success Stories: full migration from old site (48/48, audited),
  markets n:n, quotation content block, columns content block,
  pretty SEO detail URLs via SuccessStoryPathRewrite middleware,
  list/detail split (list page 4 / story page), detailUrl/backUrl
- New plugins: VITEC Locations (grid/list/map + RTE map text),
  VITEC Customer Logos (color/bw logic, only-show-selected),
  VITEC Card (one plugin for product/story/market/solution
  with reloading FlexForm + custom backend preview renderer)
- Eventlist: layout dropdown (list/grid/teaserbar) in settings
- Hero section CB: Images/Video tabs, background video + overlay
- Product JSON: full category rootline (parents), fixed missing
  ConnectionPool import (all-products crash), category tree map
- Backend preview CSS: container-query responsive (narrow columns)
- Docs: ISO architecture spec, root + extension READMEs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 09:53:33 +02:00

709 lines
31 KiB
PHP
Executable File

<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use TYPO3\CMS\Core\Core\Bootstrap;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* One-time (idempotent) migration of the old vitec.com Success Stories into
* the new tx_vitec_domain_model_usecase model.
*
* Source: migrations JSON produced on the OLD site by
* migrations/export_success_stories.php, plus the staged original files under
* fileadmin/success_stories_import/ (same relative identifiers as on the old
* site).
*
* Mapping (see analysis in the session / architecture spec):
* pages(title,slug,SEO) -> usecase base + seo_*
* mask_web__markets_card -> subtitle, card_image, customer_logo
* mask_web__success_story__promo -> hero (bgimage, overlay color, theme)
* use_case element (APP) -> industries (sys_category) -> markets MM
* page content (flux tree) -> inline content_elements:
* text -> text CE (blockquote quotes -> vitec_quotation)
* image/textmedia -> image/textmedia CE + FAL
* html -> html CE
* 2-column flux rows -> vitec_columns content block
*
* Usage:
* vendor/bin/typo3 vitec:import-success-stories --dry-run
* vendor/bin/typo3 vitec:import-success-stories --only=meydan-racecourse
* vendor/bin/typo3 vitec:import-success-stories --force (reimport existing)
*/
#[AsCommand(
name: 'vitec:import-success-stories',
description: 'Import success stories from the old-website export JSON into the usecase model'
)]
final class ImportSuccessStoriesCommand extends Command
{
private const TABLE = 'tx_vitec_domain_model_usecase';
private const FILES_BASE = '/success_stories_import';
/** Old market2 token -> canonical industry title. */
private const INDUSTRY_TOKENS = [
'sports' => 'Sports', 'venues' => 'Venues', 'government' => 'Government',
'military' => 'Military', 'corporate' => 'Corporate', 'broadcast' => 'Broadcast',
'education' => 'Education', 'healthcare' => 'Healthcare',
'hospitality' => 'Hospitality & Leisure', 'leisure' => 'Hospitality & Leisure',
'accomodation' => 'Accommodation', 'accommodation' => 'Accommodation',
];
/** CTypes that never become content elements. */
private const SKIP_CTYPES = [
'shortcut', 'div', 'fluxbs5templates_buttonlink', 'mask_web__jumpmenu',
'mask_web__markets_card', 'mask_web__success_story__full_width_promo',
'mask_web__success_top_story', 'mask_use_case', 'header',
];
private const CONTAINER_CTYPES = ['fluxbs5templates_fluidrow', 'fluxbs5templates_container'];
private array $export = [];
private array $pagesByUid = [];
private array $contentByUid = [];
private array $childrenByParent = []; // parentCeUid => list of child CEs
private array $rootsByPid = []; // pageUid => list of colPos-0/5 CEs
private array $cardByPageUid = [];
private array $useCaseByKey = [];
private array $fileRefs = []; // "table:uid:field" => refs[]
private array $catTitleByUid = [];
private array $catsByCeUid = [];
private array $marketUidByTitle = [];
private array $fileUidCache = [];
private string $collectionTable = '';
protected function configure(): void
{
$this->addOption('file', null, InputOption::VALUE_REQUIRED, 'Path to success_stories_export.json', 'var/transient/success_stories_export.json');
$this->addOption('pid', null, InputOption::VALUE_REQUIRED, 'Storage pid for new usecase records (default: pid of existing records)');
$this->addOption('only', null, InputOption::VALUE_REQUIRED, 'Import only the story whose slug tail matches');
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Analyse and report only, write nothing');
$this->addOption('force', null, InputOption::VALUE_NONE, 'Delete + recreate stories that already exist (matched by slug)');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
Bootstrap::initializeBackendAuthentication();
$file = (string)$input->getOption('file');
if (!is_file($file)) {
$output->writeln("<error>Export file not found: $file</error>");
return Command::FAILURE;
}
$this->export = json_decode((string)file_get_contents($file), true) ?? [];
if (!isset($this->export['pages'])) {
$output->writeln('<error>Export JSON malformed (no pages key)</error>');
return Command::FAILURE;
}
$dryRun = (bool)$input->getOption('dry-run');
$force = (bool)$input->getOption('force');
$only = (string)($input->getOption('only') ?? '');
$this->buildIndexes();
$storagePid = $this->resolveStoragePid($input->getOption('pid'));
if ($storagePid <= 0) {
$output->writeln('<error>No storage pid — pass --pid or create one usecase record manually first.</error>');
return Command::FAILURE;
}
$this->collectionTable = (string)($GLOBALS['TCA']['tt_content']['columns']['vitec_items']['config']['foreign_table'] ?? '');
$rootUids = array_map(static fn(array $r): int => (int)$r['uid'], $this->export['storyRoots']);
$stories = array_values(array_filter(
$this->export['pages'],
static fn(array $p): bool => (int)($p['sys_language_uid'] ?? 0) === 0 && in_array((int)$p['pid'], $rootUids, true)
));
$output->writeln(sprintf(
'<info>%d stories in export · storage pid %d · %s</info>',
count($stories),
$storagePid,
$dryRun ? 'DRY-RUN' : 'LIVE'
));
if (!$dryRun) {
$this->ensureMarkets($output, $storagePid);
}
$summary = [];
foreach ($stories as $page) {
$slugTail = basename((string)$page['slug']);
if ($only !== '' && $slugTail !== $only) {
continue;
}
try {
$summary[] = $this->importStory($page, $storagePid, $dryRun, $force, $output);
} catch (\Throwable $e) {
$summary[] = [$slugTail, 'ERROR: ' . $e->getMessage(), 0, 0, 0];
$output->writeln("<error> $slugTail: {$e->getMessage()}</error>");
}
}
$output->writeln('');
$output->writeln(str_pad('story', 55) . str_pad('action', 12) . str_pad('CEs', 5) . str_pad('imgs', 6) . 'markets');
foreach ($summary as [$slug, $action, $ces, $imgs, $mk]) {
$output->writeln(str_pad(substr($slug, 0, 53), 55) . str_pad($action, 12) . str_pad((string)$ces, 5) . str_pad((string)$imgs, 6) . $mk);
}
return Command::SUCCESS;
}
// =========================================================== index building
private function buildIndexes(): void
{
foreach ($this->export['pages'] as $p) {
$this->pagesByUid[(int)$p['uid']] = $p;
}
$allCes = array_merge($this->export['content'], $this->export['storyElementsElsewhere']);
foreach ($allCes as $c) {
$this->contentByUid[(int)$c['uid']] = $c;
}
// flux tree: colPos >= 100 encodes parentUid*100 + column
foreach ($this->export['content'] as $c) {
$colPos = (int)($c['colPos'] ?? 0);
if ($colPos >= 100 && isset($this->contentByUid[intdiv($colPos, 100)])) {
$this->childrenByParent[intdiv($colPos, 100)][] = $c;
} else {
$this->rootsByPid[(int)$c['pid']][] = $c;
}
}
foreach ($this->childrenByParent as &$list) {
usort($list, static fn($a, $b) => [($a['colPos'] % 100), $a['sorting']] <=> [($b['colPos'] % 100), $b['sorting']]);
}
unset($list);
foreach ($this->rootsByPid as &$list) {
usort($list, static fn($a, $b) => $a['sorting'] <=> $b['sorting']);
}
unset($list);
// story card by target page uid (tx_mask_web__markets_card_link = "427" | "t3://page?uid=427")
foreach ($allCes as $c) {
if (($c['CType'] ?? '') !== 'mask_web__markets_card') {
continue;
}
$link = (string)($c['tx_mask_web__markets_card_link'] ?? '');
if (preg_match('/uid=(\d+)/', $link, $m)) {
$target = (int)$m[1];
} elseif (ctype_digit(trim($link))) {
$target = (int)trim($link);
} else {
continue;
}
// prefer the newest non-hidden card per target
$known = $this->cardByPageUid[$target] ?? null;
if ($known === null || ((int)$c['tstamp'] > (int)$known['tstamp'] && empty($c['hidden']))) {
$this->cardByPageUid[$target] = $c;
}
}
// use_case (APP) elements by normalised title
foreach ($allCes as $c) {
if (($c['CType'] ?? '') !== 'mask_use_case') {
continue;
}
foreach ([(string)($c['header'] ?? ''), (string)($c['tx_mask_tile_customer'] ?? '')] as $t) {
$k = $this->normTitle($t);
if ($k !== '' && !isset($this->useCaseByKey[$k])) {
$this->useCaseByKey[$k] = $c;
}
}
}
foreach ($this->export['fileReferences'] as $r) {
if (!empty($r['hidden'])) {
continue;
}
$this->fileRefs[$r['tablenames'] . ':' . $r['uid_foreign'] . ':' . $r['fieldname']][] = $r;
}
foreach ($this->export['categories'] as $c) {
$this->catTitleByUid[(int)$c['uid']] = (string)$c['title'];
}
foreach ($this->export['categoryAssignments'] as $a) {
if ($a['tablenames'] === 'tt_content') {
$this->catsByCeUid[(int)$a['uid_foreign']][] = $this->catTitleByUid[(int)$a['category_uid']] ?? '';
}
}
}
/** Resolve a content-blocks column name: prefixed variant if it exists in TCA. */
private function cbCol(string $table, string $name): string
{
return isset($GLOBALS['TCA'][$table]['columns']['vitec_' . $name]) ? 'vitec_' . $name : $name;
}
private function normTitle(string $t): string
{
return preg_replace('/[^a-z0-9]/', '', strtolower($t)) ?? '';
}
private function refs(string $table, int $uid, string $field): array
{
return $this->fileRefs[$table . ':' . $uid . ':' . $field] ?? [];
}
// ============================================================== market setup
private function resolveStoragePid(mixed $option): int
{
if ($option !== null && (int)$option > 0) {
return (int)$option;
}
$row = GeneralUtility::makeInstance(ConnectionPool::class)
->getConnectionForTable(self::TABLE)
->select(['pid'], self::TABLE, ['deleted' => 0], [], [], 1)
->fetchAssociative();
return (int)($row['pid'] ?? 0);
}
/** Ensure one market record per canonical industry; fill title->uid map. */
private function ensureMarkets(OutputInterface $output, int $fallbackPid): void
{
$conn = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tx_vitec_domain_model_market');
$rows = $conn->select(['uid', 'title', 'pid'], 'tx_vitec_domain_model_market', ['deleted' => 0])->fetchAllAssociative();
$marketPid = $fallbackPid;
foreach ($rows as $r) {
$this->marketUidByTitle[$this->normTitle((string)$r['title'])] = (int)$r['uid'];
$marketPid = (int)$r['pid'];
}
$canonical = array_unique(array_values(self::INDUSTRY_TOKENS));
$datamap = [];
foreach ($canonical as $i => $title) {
if (!isset($this->marketUidByTitle[$this->normTitle($title)])) {
$datamap['tx_vitec_domain_model_market']['NEWMARKET' . $i] = ['pid' => $marketPid, 'title' => $title];
}
}
if ($datamap !== []) {
$dh = GeneralUtility::makeInstance(DataHandler::class);
$dh->start($datamap, []);
$dh->process_datamap();
foreach ($datamap['tx_vitec_domain_model_market'] as $key => $fields) {
$uid = (int)($dh->substNEWwithIDs[$key] ?? 0);
if ($uid > 0) {
$this->marketUidByTitle[$this->normTitle($fields['title'])] = $uid;
$output->writeln(" <info>created market: {$fields['title']} ($uid)</info>");
}
}
}
}
/** @return string[] canonical industry titles for one story */
private function industriesForStory(array $page, ?array $card, ?array $useCase): array
{
$out = [];
if ($useCase !== null) {
foreach ($this->catsByCeUid[(int)$useCase['uid']] ?? [] as $t) {
if ($t !== '') {
$out[] = $t;
}
}
}
if ($out === [] && $card !== null) {
$tokens = preg_split('/\s+/', strtolower((string)($card['tx_mask_markets_card_market2'] ?? ''))) ?: [];
foreach ($tokens as $tok) {
if (isset(self::INDUSTRY_TOKENS[$tok])) {
$out[] = self::INDUSTRY_TOKENS[$tok];
}
}
}
return array_values(array_unique($out));
}
// =============================================================== story import
/** @return array{0:string,1:string,2:int,3:int,4:string} */
private function importStory(array $page, int $storagePid, bool $dryRun, bool $force, OutputInterface $output): array
{
$pageUid = (int)$page['uid'];
$slugTail = basename((string)$page['slug']);
$slug = '/' . $slugTail;
$card = $this->cardByPageUid[$pageUid] ?? null;
$useCase = $this->useCaseByKey[$this->normTitle((string)$page['title'])] ?? null;
$promo = null;
foreach ($this->rootsByPid[$pageUid] ?? [] as $ce) {
if ($ce['CType'] === 'mask_web__success_story__full_width_promo') {
$promo = $ce;
break;
}
}
$industries = $this->industriesForStory($page, $card, $useCase);
$ceSpecs = $this->transformContent($pageUid);
// ---- existing?
$conn = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable(self::TABLE);
$existing = $conn->select(['uid'], self::TABLE, ['slug' => $slug, 'deleted' => 0])->fetchAssociative()
?: $conn->select(['uid'], self::TABLE, ['slug' => $slugTail, 'deleted' => 0])->fetchAssociative();
$imgCount = 0;
foreach ($ceSpecs as $s) {
$imgCount += count($s['files'] ?? []);
foreach ($s['items'] ?? [] as $it) {
$imgCount += count($it['files'] ?? []);
}
}
if ($dryRun) {
$output->writeln(sprintf(
' <comment>%s</comment> ces=%d quotes=%d cols=%d imgs=%d markets=[%s] card=%s uc=%s hero=%s%s',
$slugTail,
count($ceSpecs),
count(array_filter($ceSpecs, fn($s) => $s['CType'] === 'vitec_quotation')),
count(array_filter($ceSpecs, fn($s) => $s['CType'] === 'vitec_columns')),
$imgCount,
implode(',', $industries),
$card ? 'y' : 'MISSING',
$useCase ? 'y' : '-',
$promo ? 'y' : 'MISSING',
$existing ? ' EXISTS(uid=' . $existing['uid'] . ')' : ''
));
return [$slugTail, $existing ? 'dry(exists)' : 'dry', count($ceSpecs), $imgCount, implode(',', $industries)];
}
if ($existing) {
if (!$force) {
return [$slugTail, 'skipped', 0, 0, ''];
}
$dh = GeneralUtility::makeInstance(DataHandler::class);
$dh->start([], [self::TABLE => [(int)$existing['uid'] => ['delete' => 1]]]);
$dh->process_cmdmap();
}
// ---- assemble datamap
$datamap = [];
$storyKey = 'NEWSTORY1';
$refCounter = 0;
$newRef = function (string $identifier, string $table, string $key, string $field, array $meta = []) use (&$datamap, &$refCounter, $storagePid): ?string {
$fileUid = $this->fileUidFor($identifier);
if ($fileUid === null) {
return null;
}
$refKey = 'NEWREF' . (++$refCounter);
$datamap['sys_file_reference'][$refKey] = array_filter([
'pid' => $storagePid,
'uid_local' => 'sys_file_' . $fileUid,
'tablenames' => $table,
'uid_foreign' => $key,
'fieldname' => $field,
'title' => $meta['title'] ?? null,
'alternative' => $meta['alternative'] ?? null,
'description' => $meta['description'] ?? null,
'crop' => $meta['crop'] ?? null,
], static fn($v) => $v !== null && $v !== '');
return $refKey;
};
$attach = function (array $refs, string $table, string $key, string $newField) use ($newRef): string {
$ids = [];
foreach ($refs as $r) {
$id = $newRef((string)$r['identifier'], $table, $key, $newField, $r);
if ($id !== null) {
$ids[] = $id;
}
}
return implode(',', $ids);
};
// story base fields
$subtitle = trim(strip_tags((string)($card['tx_mask_web__markets_card_frontttext'] ?? '')));
if ($subtitle === '' && $useCase !== null) {
$subtitle = (string)($useCase['tx_mask_tile_teaser'] ?? '');
}
$textColor = strtolower((string)($promo['tx_mask_web__success_story__full_width_promo_textcolor'] ?? ''));
$overlay = (string)($promo['tx_mask_web__success_story__full_width_promo_color'] ?? '');
$story = [
'pid' => $storagePid,
'title' => (string)$page['title'],
'slug' => $slug,
'subtitle' => $subtitle,
'teaser' => (string)($page['abstract'] ?? '') !== '' ? (string)$page['abstract'] : (string)($page['description'] ?? ''),
'seo_title' => (string)($page['seo_title'] ?? ''),
'seo_description' => (string)($page['description'] ?? ''),
'og_title' => (string)($page['og_title'] ?? ''),
'og_description' => (string)($page['og_description'] ?? ''),
'twitter_title' => (string)($page['twitter_title'] ?? ''),
'twitter_description' => (string)($page['twitter_description'] ?? ''),
'no_index' => (int)($page['no_index'] ?? 0),
'no_follow' => (int)($page['no_follow'] ?? 0),
'hero_overlay_color' => $overlay,
'hero_overlay_opacity' => $overlay !== '' ? 0.4 : 0,
'text_theme' => in_array($textColor, ['#fff', '#ffffff', 'white'], true) ? 'light' : 'dark',
'markets' => implode(',', array_filter(array_map(
fn(string $t): int => $this->marketUidByTitle[$this->normTitle($t)] ?? 0,
$industries
))),
];
// FAL: card image, logo, hero, og/twitter
if ($card !== null) {
$story['card_image'] = $attach(
array_slice($this->refs('tt_content', (int)$card['uid'], 'tx_mask_web__markets_card_frontimage'), 0, 1),
self::TABLE, $storyKey, 'card_image'
);
$story['customer_logo'] = $attach(
array_slice($this->refs('tt_content', (int)$card['uid'], 'tx_mask_web__markets_card_customerlogo'), 0, 1),
self::TABLE, $storyKey, 'customer_logo'
);
}
$heroRefs = $promo !== null
? $this->refs('tt_content', (int)$promo['uid'], 'tx_mask_web__success_story__full_width_promo_image')
: [];
if ($heroRefs === []) {
$heroRefs = $this->refs('pages', $pageUid, 'heroimage_big');
}
$story['hero_bgimage'] = $attach(array_slice($heroRefs, 0, 1), self::TABLE, $storyKey, 'hero_bgimage');
$story['og_image'] = $attach(array_slice($this->refs('pages', $pageUid, 'og_image'), 0, 1), self::TABLE, $storyKey, 'og_image');
$story['twitter_image'] = $attach(array_slice($this->refs('pages', $pageUid, 'twitter_image'), 0, 1), self::TABLE, $storyKey, 'twitter_image');
// content elements
$ceKeys = [];
$ceCounter = 0;
$itemCounter = 0;
foreach ($ceSpecs as $spec) {
$ceKey = 'NEWCE' . (++$ceCounter);
$fields = $spec['fields'];
$fields['pid'] = $storagePid;
$fields['CType'] = $spec['CType'];
$fields['colPos'] = 999;
foreach ($spec['files'] ?? [] as $f) {
$fields[$f['field']] = $attach($f['refs'], 'tt_content', $ceKey, $f['field']);
}
if (($spec['CType'] === 'vitec_columns') && $this->collectionTable !== '' && !empty($spec['items'])) {
$itemKeys = [];
foreach ($spec['items'] as $item) {
$itemKey = 'NEWITEM' . (++$itemCounter);
$itemFields = $item['fields'];
$itemFields['pid'] = $storagePid;
foreach ($item['files'] ?? [] as $f) {
$itemFields[$f['field']] = $attach($f['refs'], $this->collectionTable, $itemKey, $f['field']);
}
$datamap[$this->collectionTable][$itemKey] = $itemFields;
$itemKeys[] = $itemKey;
}
$fields[$this->cbCol('tt_content', 'items')] = implode(',', $itemKeys);
}
$datamap['tt_content'][$ceKey] = $fields;
$ceKeys[] = $ceKey;
}
$story['content_elements'] = implode(',', $ceKeys);
$datamap[self::TABLE][$storyKey] = $story;
$dh = GeneralUtility::makeInstance(DataHandler::class);
$dh->start($datamap, []);
$dh->process_datamap();
if ($dh->errorLog !== []) {
throw new \RuntimeException('DataHandler: ' . implode(' | ', array_slice($dh->errorLog, 0, 3)));
}
return [$slugTail, $existing ? 'reimported' : 'created', count($ceSpecs), $imgCount, implode(',', $industries)];
}
// ========================================================== content transform
/** @return array<int,array<string,mixed>> ordered CE specs */
private function transformContent(int $pageUid): array
{
$specs = [];
$roots = $this->rootsByPid[$pageUid] ?? [];
// intro (colPos 5): hero subline — first element, CTA buttons stripped
foreach ($roots as $ce) {
if ((int)$ce['colPos'] === 5 && $ce['CType'] === 'text' && !empty($ce['bodytext'])) {
$body = preg_replace('/<p[^>]*>(?:(?!<\/p>).)*btn(?:(?!<\/p>).)*<\/p>/is', '', (string)$ce['bodytext']) ?? '';
if (trim(strip_tags($body)) !== '') {
$specs[] = ['CType' => 'text', 'fields' => ['header' => '', 'bodytext' => trim($body)], 'files' => []];
}
}
}
foreach ($roots as $ce) {
if ((int)$ce['colPos'] === 0) {
$this->transformCe($ce, $specs);
}
}
return $specs;
}
private function transformCe(array $ce, array &$specs): void
{
$ctype = (string)$ce['CType'];
if (in_array($ctype, self::SKIP_CTYPES, true)) {
return;
}
if (in_array($ctype, self::CONTAINER_CTYPES, true)) {
$this->transformContainer($ce, $specs);
return;
}
switch ($ctype) {
case 'text':
$specs[] = $this->textOrQuoteSpec($ce);
break;
case 'image':
case 'textmedia':
$field = $ctype === 'image' ? 'image' : 'assets';
$spec = [
'CType' => $ctype,
'fields' => array_filter([
'header' => (string)($ce['header'] ?? ''),
'header_layout' => (int)($ce['header_layout'] ?? 0),
'bodytext' => (string)($ce['bodytext'] ?? ''),
], static fn($v) => $v !== '' && $v !== 0),
'files' => [],
];
$refs = $this->refs('tt_content', (int)$ce['uid'], $field);
if ($refs !== []) {
$spec['files'][] = ['field' => $field, 'refs' => $refs];
}
if ($spec['files'] !== [] || ($spec['fields']['bodytext'] ?? '') !== '') {
$specs[] = $spec;
}
break;
case 'html':
if (!empty($ce['bodytext'])) {
$specs[] = ['CType' => 'html', 'fields' => ['bodytext' => (string)$ce['bodytext']], 'files' => []];
}
break;
default:
// unknown → keep text-ish content if any
if (!empty($ce['bodytext']) || !empty($ce['header'])) {
$specs[] = ['CType' => 'text', 'fields' => [
'header' => (string)($ce['header'] ?? ''),
'bodytext' => (string)($ce['bodytext'] ?? ''),
], 'files' => []];
}
}
}
/** Two clean columns -> vitec_columns; everything else flattens in order. */
private function transformContainer(array $ce, array &$specs): void
{
$children = $this->childrenByParent[(int)$ce['uid']] ?? [];
if ($children === []) {
return;
}
$byCol = [];
foreach ($children as $child) {
$byCol[(int)$child['colPos'] % 100][] = $child;
}
ksort($byCol);
$isSimple = static fn(array $c): bool => in_array($c['CType'], ['text', 'image'], true)
&& stripos((string)($c['bodytext'] ?? ''), '<blockquote') === false;
$cols = array_keys($byCol);
$allSimple = count($cols) === 2 && count($children) <= 4
&& array_reduce($children, static fn($ok, $c) => $ok && $isSimple($c), true);
if ($allSimple) {
$items = [];
foreach ($byCol as $colIdx => $colCes) {
$side = $colIdx === $cols[0] ? 'left' : 'right';
foreach ($colCes as $c) {
$t = $this->collectionTable;
if ($c['CType'] === 'text') {
$items[] = ['fields' => [
$this->cbCol($t, 'column') => $side, $this->cbCol($t, 'item_type') => 'text',
$this->cbCol($t, 'headline') => (string)($c['header'] ?? ''),
$this->cbCol($t, 'text') => (string)($c['bodytext'] ?? ''),
], 'files' => []];
} else {
$refs = $this->refs('tt_content', (int)$c['uid'], 'image');
$items[] = ['fields' => [
$this->cbCol($t, 'column') => $side, $this->cbCol($t, 'item_type') => 'image',
$this->cbCol($t, 'headline') => (string)($c['header'] ?? ''),
], 'files' => $refs !== [] ? [['field' => $this->cbCol($t, 'image'), 'refs' => array_slice($refs, 0, 1)]] : []];
}
}
}
$specs[] = [
'CType' => 'vitec_columns',
'fields' => ['header' => (string)($ce['header'] ?? ''), $this->cbCol('tt_content', 'layout') => 'cols_50_50'],
'files' => [],
'items' => $items,
];
return;
}
// flatten: children first; the container header only becomes a heading
// when the container actually contributed content (skips e.g. the old
// "Related Success Stories" section whose children are all shortcuts).
$childSpecs = [];
foreach ($byCol as $colCes) {
foreach ($colCes as $child) {
$this->transformCe($child, $childSpecs);
}
}
$header = trim((string)($ce['header'] ?? ''));
if ($childSpecs !== [] && $header !== '' && !str_starts_with($header, '///')) {
$specs[] = ['CType' => 'text', 'fields' => ['header' => $header], 'files' => []];
}
foreach ($childSpecs as $cs) {
$specs[] = $cs;
}
}
/** blockquote text CEs become vitec_quotation; the rest stay text. */
private function textOrQuoteSpec(array $ce): array
{
$body = (string)($ce['bodytext'] ?? '');
$header = (string)($ce['header'] ?? '');
if (stripos($body, '<blockquote') !== false) {
$quote = $body;
$name = '';
$position = '';
if (preg_match('/<blockquote[^>]*>(.*)<\/blockquote>/is', $body, $m)) {
$inner = $m[1];
// attribution paragraph: <p><strong>— Name</strong><br/>Position</p>
if (preg_match('/<p>\s*<strong>\s*(?:—|&mdash;|-)?\s*(.*?)<\/strong>\s*(?:<br\s*\/?>)?\s*(.*?)<\/p>\s*$/is', $inner, $a)) {
$name = trim(strip_tags($a[1]));
$position = trim(strip_tags($a[2]));
$inner = (string)preg_replace('/<p>\s*<strong>\s*(?:—|&mdash;|-)?.*?<\/p>\s*$/is', '', $inner);
}
$quote = trim(strip_tags($inner));
$quote = trim($quote, "“”\"' \u{201C}\u{201D}");
}
return ['CType' => 'vitec_quotation', 'fields' => [
$this->cbCol('tt_content', 'quote') => $quote,
$this->cbCol('tt_content', 'quote_name') => $name,
$this->cbCol('tt_content', 'quote_position') => $position,
], 'files' => []];
}
return ['CType' => 'text', 'fields' => array_filter([
'header' => strcasecmp($header, 'Quote') === 0 ? '' : $header,
'header_layout' => (int)($ce['header_layout'] ?? 0),
'bodytext' => $body,
], static fn($v) => $v !== '' && $v !== 0), 'files' => []];
}
// ==================================================================== files
/** Old-site identifier -> new-site sys_file uid (staged under FILES_BASE). */
private function fileUidFor(string $identifier): ?int
{
if (isset($this->fileUidCache[$identifier])) {
return $this->fileUidCache[$identifier] ?: null;
}
try {
$file = GeneralUtility::makeInstance(ResourceFactory::class)
->getFileObjectFromCombinedIdentifier('1:' . self::FILES_BASE . $identifier);
$uid = $file !== null ? (int)$file->getUid() : 0;
} catch (\Throwable $e) {
$uid = 0;
}
$this->fileUidCache[$identifier] = $uid;
return $uid ?: null;
}
}