- 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>
709 lines
31 KiB
PHP
Executable File
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*(?:—|—|-)?\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*(?:—|—|-)?.*?<\/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;
|
|
}
|
|
}
|