vitec_cols_* matching the column count and Bootstrap widths
* - container -> vitec_container
* - children -> moved into the new container (tx_container_parent + colPos)
* - div / shortcut -> deleted (pure layout, and the 30 shortcuts all point at
* one shared CTA element that does not exist here)
* - everything else (text, html, image, header, uploads, textpic) is left alone;
* those are Core CTypes and render headless as they are.
*
* Records are written with plain DBAL rather than DataHandler on purpose: this is
* a one-shot structural migration where the exact colPos and sorting values are
* the point, and DataHandler would renumber them.
*
* ALWAYS run --dry-run first.
*/
#[AsCommand(
name: 'vitec:migrate-newspages',
description: 'Converts the FLUX layout of the imported news pages into VITEC containers.'
)]
class MigrateNewsPagesCommand extends Command
{
/**
* Column count -> [CType, [colPos per column]]. The widths come from the
* Bootstrap classes on the old row where they are readable; 50/50 is the
* fallback for two columns because it is by far the most common on the
* source pages.
*/
private const CONTAINERS = [
1 => ['vitec_container', [220]],
2 => ['vitec_cols_50_50', [211, 212]],
3 => ['vitec_cols_33_33_33', [221, 222, 223]],
4 => ['vitec_cols_25_25_25_25', [231, 232, 233, 234]],
];
private const TWO_COLUMN_VARIANTS = [
'66_33' => ['vitec_cols_66_33', [241, 242]],
'33_66' => ['vitec_cols_33_66', [251, 252]],
];
private const FLUX_ROW = 'fluxbs5templates_fluidrow';
private const FLUX_CONTAINER = 'fluxbs5templates_container';
/** Pure layout on the old site, no counterpart and no content here. */
private const DROP_CTYPES = ['div', 'shortcut'];
protected function configure(): void
{
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report only - nothing is written');
$this->addOption('parent-origuid', null, InputOption::VALUE_REQUIRED, 'Old uid of the parent page whose children are migrated', '106');
$this->addOption('pid', null, InputOption::VALUE_REQUIRED, 'Parent page uid on THIS site - overrides --parent-origuid');
$this->addOption('page', null, InputOption::VALUE_REQUIRED, 'Limit the run to a single page uid (for testing)');
$this->addOption('map-colpos', null, InputOption::VALUE_REQUIRED, 'Remap top-level colPos, "old:new" comma separated. 0 disables.', '5:1');
$this->addOption('page-layout', null, InputOption::VALUE_REQUIRED, 'Frontend layout to set on the migrated pages (0 = leave alone)', '14');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$dryRun = (bool)$input->getOption('dry-run');
if ($dryRun) {
$output->writeln('DRY RUN - nothing will be written.');
}
$pages = $this->resolvePages($input, $output);
if ($pages === []) {
return Command::FAILURE;
}
$output->writeln(sprintf('Pages in scope: %d', count($pages)));
$rows = $this->loadContent(array_keys($pages));
if ($rows === []) {
$output->writeln('No content elements on those pages - nothing to do.');
return Command::SUCCESS;
}
// The two lookups the whole migration rests on.
$byUid = [];
$byOrigUid = [];
foreach ($rows as $row) {
$byUid[(int)$row['uid']] = $row;
$orig = (int)($row['tx_impexp_origuid'] ?? 0);
if ($orig > 0) {
$byOrigUid[$orig] = $row;
}
}
$withOrig = count($byOrigUid);
$output->writeln(sprintf('Content elements: %d, of them with tx_impexp_origuid: %d', count($rows), $withOrig));
if ($withOrig === 0) {
$output->writeln('No element carries tx_impexp_origuid. The FLUX nesting cannot be resolved without it.');
$output->writeln('Re-run the T3D import so impexp records the original uids, or migrate the pages by hand.');
return Command::FAILURE;
}
$this->reportCTypes($rows, $output);
// Children grouped by the OLD parent uid encoded in their colPos.
$childrenByOldParent = [];
$orphans = 0;
foreach ($rows as $row) {
$colPos = (int)($row['colPos'] ?? 0);
if ($colPos < 1000) {
continue;
}
$oldParent = intdiv($colPos, 100);
if (!isset($byOrigUid[$oldParent])) {
$orphans++;
continue;
}
$childrenByOldParent[$oldParent][$colPos % 100][] = $row;
}
if ($orphans > 0) {
$output->writeln(sprintf('%d nested elements have no parent in scope and stay where they are.', $orphans));
}
$created = $moved = $dropped = 0;
$skipped = [];
foreach ($rows as $row) {
$cType = (string)$row['CType'];
if ($cType !== self::FLUX_ROW && $cType !== self::FLUX_CONTAINER) {
continue;
}
$oldUid = (int)($row['tx_impexp_origuid'] ?? 0);
$columns = $childrenByOldParent[$oldUid] ?? [];
ksort($columns);
$target = $this->pickContainer($cType, $row, $columns);
if ($target === null) {
$skipped[] = sprintf('uid %d (%s): no column occupied', (int)$row['uid'], $cType);
continue;
}
[$newCType, $colPosMap] = $target;
$output->writeln(sprintf(
' page %d: %s uid %d -> %s (%d column%s, %d children)',
(int)$row['pid'],
$cType === self::FLUX_ROW ? 'row' : 'container',
(int)$row['uid'],
$newCType,
count($colPosMap),
count($colPosMap) === 1 ? '' : 's',
array_sum(array_map('count', $columns))
));
if (!$dryRun) {
$this->convertElement((int)$row['uid'], $newCType);
}
$created++;
$columnIndex = 0;
foreach ($columns as $children) {
$newColPos = $colPosMap[$columnIndex] ?? end($colPosMap);
foreach ($children as $child) {
if (!$dryRun) {
$this->moveChild((int)$child['uid'], (int)$row['uid'], $newColPos);
}
$moved++;
}
$columnIndex++;
}
}
foreach ($rows as $row) {
if (!in_array((string)$row['CType'], self::DROP_CTYPES, true)) {
continue;
}
if (!$dryRun) {
$this->softDelete((int)$row['uid']);
}
$dropped++;
}
$remapped = $this->remapTopLevelColPos($rows, (string)$input->getOption('map-colpos'), $dryRun, $output);
$relaid = $this->applyPageLayout($pages, (int)$input->getOption('page-layout'), $dryRun, $output);
$output->writeln('');
$output->writeln(sprintf('Containers converted: %d', $created));
$output->writeln(sprintf('Top-level colPos remapped: %d', $remapped));
$output->writeln(sprintf('Pages given a layout with a Hero zone: %d', $relaid));
$output->writeln(sprintf('Children moved: %d', $moved));
$output->writeln(sprintf('Layout leftovers deleted (%s): %d', implode('/', self::DROP_CTYPES), $dropped));
foreach ($skipped as $line) {
$output->writeln('skipped: ' . $line . '');
}
if ($dryRun) {
$output->writeln('');
$output->writeln('Dry run - re-run without --dry-run to apply, then flush caches.');
}
return Command::SUCCESS;
}
/**
* Scope resolution. `--pid` wins; otherwise the parent page is found by the
* uid it had on the old site, which is what impexp stored.
*
* @return array>
*/
private function resolvePages(InputInterface $input, OutputInterface $output): array
{
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('pages');
$qb->getRestrictions()->removeAll();
$single = (int)$input->getOption('page');
if ($single > 0) {
$rows = $qb->select('*')->from('pages')
->where($qb->expr()->eq('uid', $qb->createNamedParameter($single, ParameterType::INTEGER)))
->executeQuery()->fetchAllAssociative();
return array_column($rows, null, 'uid');
}
$pid = (int)$input->getOption('pid');
if ($pid === 0) {
$origUid = (int)$input->getOption('parent-origuid');
$parent = $qb->select('uid', 'title')->from('pages')
->where(
$qb->expr()->eq('tx_impexp_origuid', $qb->createNamedParameter($origUid, ParameterType::INTEGER)),
$qb->expr()->eq('deleted', 0)
)
->executeQuery()->fetchAssociative();
if (!$parent) {
$output->writeln(sprintf('No page with tx_impexp_origuid = %d found.', $origUid));
$output->writeln('Pass --pid= with the parent page on this site instead.');
return [];
}
$pid = (int)$parent['uid'];
$output->writeln(sprintf('Parent page: uid %d "%s" (was %d on the old site)', $pid, (string)$parent['title'], $origUid));
}
$qb2 = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('pages');
$qb2->getRestrictions()->removeAll();
$rows = $qb2->select('*')->from('pages')
->where(
$qb2->expr()->eq('pid', $qb2->createNamedParameter($pid, ParameterType::INTEGER)),
$qb2->expr()->eq('deleted', 0)
)
->orderBy('sorting')
->executeQuery()->fetchAllAssociative();
return array_column($rows, null, 'uid');
}
/**
* @param int[] $pageUids
* @return array>
*/
private function loadContent(array $pageUids): array
{
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('tt_content');
$qb->getRestrictions()->removeAll();
return $qb->select('*')->from('tt_content')
->where(
$qb->expr()->in('pid', $qb->createNamedParameter($pageUids, ArrayParameterType::INTEGER)),
$qb->expr()->eq('deleted', 0)
)
->orderBy('pid')->addOrderBy('colPos')->addOrderBy('sorting')
->executeQuery()->fetchAllAssociative();
}
/**
* Decide which container an old FLUX element becomes.
*
* A row whose children all sit in one column becomes a single-column
* container rather than an empty half of a 50/50 grid - 36 of the source rows
* are like that, and carrying the empty column over would only produce holes.
*
* @param array>> $columns
* @return array{0:string,1:int[]}|null
*/
private function pickContainer(string $cType, array $row, array $columns): ?array
{
$occupied = count($columns);
if ($occupied === 0) {
return $cType === self::FLUX_CONTAINER ? self::CONTAINERS[1] : null;
}
if ($occupied === 1 || $cType === self::FLUX_CONTAINER) {
return self::CONTAINERS[1];
}
if ($occupied === 2) {
$variant = $this->detectTwoColumnVariant((string)($row['pi_flexform'] ?? ''));
if ($variant !== null) {
return self::TWO_COLUMN_VARIANTS[$variant];
}
}
return self::CONTAINERS[min($occupied, 4)] ?? self::CONTAINERS[4];
}
/**
* Reads the Bootstrap width of the first two columns out of the row FlexForm.
* `col-md-4` + `col-md-8` means 33/66, the mirror image means 66/33; anything
* else (including unreadable markup) falls through to the 50/50 default.
*/
private function detectTwoColumnVariant(string $flexForm): ?string
{
if ($flexForm === '') {
return null;
}
if (!preg_match_all('#\s*([^<]*)#i', $flexForm, $m)) {
return null;
}
$widths = [];
foreach ($m[1] as $class) {
if (preg_match('#col-(?:md|lg)-(\d+)#i', (string)$class, $w)) {
$widths[] = (int)$w[1];
}
}
if (count($widths) < 2) {
return null;
}
[$first, $second] = $widths;
if ($first <= 4 && $second >= 8) {
return '33_66';
}
if ($first >= 8 && $second <= 4) {
return '66_33';
}
return null;
}
/**
* The old site kept the page body in colPos 0 and the headline block in
* colPos 5. Here the zones are main = 0, hero = 1, sidebar = 2,
* preFooter = 3 (BackendLayoutDataProvider), so colPos 5 addresses a column
* that does not exist and its elements are invisible in the page module —
* 46 headers and 32 html blocks, one headline per page.
*
* Only genuinely top-level elements are touched: anything already sitting in
* a container carries tx_container_parent and a colPos >= 211, and remapping
* those would tear the containers apart.
*
* @param array> $rows
*/
private function remapTopLevelColPos(array $rows, string $map, bool $dryRun, OutputInterface $output): int
{
$map = trim($map);
if ($map === '' || $map === '0') {
return 0;
}
$pairs = [];
foreach (explode(',', $map) as $pair) {
$parts = explode(':', trim($pair));
if (count($parts) === 2 && is_numeric($parts[0]) && is_numeric($parts[1])) {
$pairs[(int)$parts[0]] = (int)$parts[1];
}
}
if ($pairs === []) {
$output->writeln('--map-colpos could not be parsed, skipping the remap.');
return 0;
}
$count = 0;
foreach ($rows as $row) {
$colPos = (int)($row['colPos'] ?? 0);
if (!isset($pairs[$colPos]) || (int)($row['tx_container_parent'] ?? 0) !== 0) {
continue;
}
$target = $pairs[$colPos];
$output->writeln(sprintf(
' page %d: %s uid %d colPos %d -> %d%s',
(int)$row['pid'],
(string)$row['CType'],
(int)$row['uid'],
$colPos,
$target,
$row['header'] ? ' "' . mb_substr(strip_tags((string)$row['header']), 0, 46) . '"' : ''
));
if (!$dryRun) {
GeneralUtility::makeInstance(ConnectionPool::class)
->getConnectionForTable('tt_content')
->update('tt_content', ['colPos' => $target], ['uid' => (int)$row['uid']]);
}
$count++;
}
return $count;
}
/**
* Moving the headline into colPos 1 only helps if the page actually shows a
* Hero column. `pages.layout` drives `backend_layout` through
* SyncBackendLayoutHook, so the frontend layout is the field to set; layout
* 14 ("News Detail V2") is exactly hero + main, which is the shape these
* pages have. Pages already on a layout whose zones include a hero are left
* alone.
*
* @param array> $pages
*/
private function applyPageLayout(array $pages, int $layout, bool $dryRun, OutputInterface $output): int
{
if ($layout <= 0) {
return 0;
}
$identifier = 'vitec__' . $layout;
$count = 0;
foreach ($pages as $page) {
if ((int)($page['layout'] ?? 0) === $layout && (string)($page['backend_layout'] ?? '') === $identifier) {
continue;
}
$output->writeln(sprintf(
' page %d "%s": layout %s -> %d (%s)',
(int)$page['uid'],
mb_substr((string)($page['title'] ?? ''), 0, 40),
(string)($page['layout'] ?? '0'),
$layout,
$identifier
));
if (!$dryRun) {
GeneralUtility::makeInstance(ConnectionPool::class)
->getConnectionForTable('pages')
->update('pages', [
'layout' => $layout,
'backend_layout' => $identifier,
'backend_layout_next_level' => $identifier,
], ['uid' => (int)$page['uid']]);
}
$count++;
}
return $count;
}
private function convertElement(int $uid, string $newCType): void
{
GeneralUtility::makeInstance(ConnectionPool::class)
->getConnectionForTable('tt_content')
->update('tt_content', ['CType' => $newCType, 'pi_flexform' => ''], ['uid' => $uid]);
}
private function moveChild(int $uid, int $containerUid, int $colPos): void
{
GeneralUtility::makeInstance(ConnectionPool::class)
->getConnectionForTable('tt_content')
->update('tt_content', ['tx_container_parent' => $containerUid, 'colPos' => $colPos], ['uid' => $uid]);
}
/** Soft delete, so a wrong call can be undone in the backend recycler. */
private function softDelete(int $uid): void
{
GeneralUtility::makeInstance(ConnectionPool::class)
->getConnectionForTable('tt_content')
->update('tt_content', ['deleted' => 1], ['uid' => $uid]);
}
/** @param array> $rows */
private function reportCTypes(array $rows, OutputInterface $output): void
{
$hist = [];
foreach ($rows as $row) {
$c = (string)$row['CType'];
$hist[$c] = ($hist[$c] ?? 0) + 1;
}
arsort($hist);
$output->writeln('CTypes found:');
foreach ($hist as $cType => $count) {
$note = '';
if ($cType === self::FLUX_ROW || $cType === self::FLUX_CONTAINER) {
$note = ' -> container';
} elseif (in_array($cType, self::DROP_CTYPES, true)) {
$note = ' -> deleted';
} elseif ($cType === 'slickimage') {
$note = ' -> left as is, no counterpart here (decide separately)';
}
$output->writeln(sprintf(' %-34s %4d%s', $cType, $count, $note));
}
$output->writeln('');
}
}