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(''); } }