[container CType, first container colPos - 1, valid gridelements columns] */ private const LAYOUTS = [ 'halves' => ['ep_container_halves', 200, [21, 22]], 'thirds' => ['ep_container_thirds', 210, [31, 32, 33]], 'quarters' => ['ep_container_columns_quarters', 220, [41, 42, 43, 44]], 'halfAndTwoQuarters' => ['ep_container_half_and_two_quarters', 230, [51, 52, 53]], 'twoQuartersAndHalf' => ['ep_container_two_quarters_and_half', 240, [61, 62, 63]], ]; /** * Content types whose template renders the header with settings.defaultHeaderType for layout 0 */ private const DEFAULT_HEADER_TYPES = ['text', 'textmedia', 'textpic']; private const RESET = [ 'tx_gridelements_container' => 0, 'tx_gridelements_columns' => 0, 'tx_gridelements_children' => 0, 'tx_gridelements_backend_layout' => '', ]; public function __construct( private readonly ConnectionPool $connectionPool, ) { parent::__construct(); } protected function configure(): void { $this ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Only show what would change') ->addOption('report', null, InputOption::VALUE_REQUIRED, 'Write every row that is not migrated 1:1 to this CSV file'); } protected function execute(InputInterface $input, OutputInterface $output): int { $connection = $this->connectionPool->getConnectionForTable('tt_content'); if (!$this->hasGridelementsColumns($connection)) { $output->writeln('No tx_gridelements_* columns in tt_content, nothing to do.'); return Command::SUCCESS; } // Plain SQL on purpose: Connection::select() would apply the frontend restrictions (deleted, hidden, …) $grids = []; foreach ($connection->executeQuery('SELECT * FROM tt_content WHERE CType = ?', ['gridelements_pi1'])->iterateAssociative() as $row) { $grids[(int)$row['uid']] = $row; } $children = $connection->executeQuery( 'SELECT * FROM tt_content WHERE tx_gridelements_container > 0 AND CType <> ? ORDER BY uid', ['gridelements_pi1'] )->fetchAllAssociative(); if ((int)$connection->executeQuery('SELECT COUNT(*) FROM tt_content WHERE CType = ? AND sys_language_uid > 0', ['gridelements_pi1'])->fetchOne() > 0) { $output->writeln('Translated grids found; translations are not handled by this command.'); return Command::FAILURE; } $updates = []; $counts = []; $report = []; $count = static function (string $key) use (&$counts): void { $counts[$key] = ($counts[$key] ?? 0) + 1; }; foreach ($grids as $uid => $grid) { $layout = (string)$grid['tx_gridelements_backend_layout']; if (isset(self::LAYOUTS[$layout])) { $updates[$uid] = ['CType' => self::LAYOUTS[$layout][0], 'pi_flexform' => ''] + self::RESET; $count('grid ' . $layout . ((int)$grid['deleted'] ? ' (deleted)' : '')); continue; } // A grid without layout renders nothing; keep it restorable as an empty container $updates[$uid] = ['CType' => 'ep_container_halves', 'pi_flexform' => '', 'deleted' => 1] + self::RESET; $count('grid without layout → deleted'); if (!(int)$grid['deleted']) { $report[] = [$grid, 'grid', 'grid without layout', 'deleted']; } } foreach ($children as $child) { $uid = (int)$child['uid']; $parent = $grids[(int)$child['tx_gridelements_container']] ?? null; $column = (int)$child['tx_gridelements_columns']; if ((int)$child['colPos'] >= 0) { // Rendered in its page column, not by the grid (gridelements only queries colPos -1) $updates[$uid] = self::RESET; $count('child outside the grid (colPos ' . $child['colPos'] . '), stays'); if (!(int)$child['deleted']) { $report[] = [$child, 'page column', 'colPos ' . $child['colPos'] . ' instead of -1', 'unchanged, grid fields cleared']; } continue; } if ($parent !== null && (int)$child['pid'] !== (int)$parent['pid']) { // A container only renders children of its own page $parent = null; } $config = self::LAYOUTS[(string)($parent['tx_gridelements_backend_layout'] ?? '')] ?? null; if ($parent !== null && $config !== null && (int)$child['colPos'] === -1 && in_array($column, $config[2], true)) { $updates[$uid] = [ 'tx_container_parent' => (int)$parent['uid'], 'colPos' => $config[1] + $column % 10, ] + self::RESET; $count('A: child migrated' . ((int)$child['deleted'] ? ' (deleted)' : '')); // Inside a grid, TYPO3 10 did not apply settings.defaultHeaderType, so the header of these // types stayed invisible with layout "default". Keep it invisible, and visible as such to editors. if ((int)$child['header_layout'] === 0 && trim((string)$child['header']) !== '' && in_array($child['CType'], self::DEFAULT_HEADER_TYPES, true)) { $updates[$uid]['header_layout'] = 100; $count('A: header layout default → hidden'); } continue; } // B (column not in the layout) and C (parent is no grid): invisible in TYPO3 10, soft deleted $group = $parent === null ? 'C' : 'B'; $reason = $parent === null ? 'parent ' . $child['tx_gridelements_container'] . ' is not a grid on the same page' : 'column ' . $column . ' (colPos ' . $child['colPos'] . ') not in layout ' . $parent['tx_gridelements_backend_layout']; $updates[$uid] = ['deleted' => 1] + self::RESET; $count($group . ': child deleted' . ((int)$child['deleted'] ? ' (was deleted already)' : '')); if (!(int)$child['deleted']) { $report[] = [$child, $group, $reason, 'deleted']; } } // Other rows still carrying grid values (e.g. a former grid whose CType was changed by hand) $leftovers = (int)$connection->executeQuery( 'SELECT COUNT(*) FROM tt_content WHERE (tx_gridelements_children > 0 OR tx_gridelements_backend_layout NOT IN (\'\', \'0\')) AND CType <> ? AND tx_gridelements_container = 0', ['gridelements_pi1'] )->fetchOne(); ksort($counts); foreach ($counts as $label => $number) { $output->writeln(sprintf('%6d %s', $number, $label)); } if ($leftovers > 0) { $output->writeln(sprintf('%6d other rows with grid values, cleared', $leftovers)); } if ($input->getOption('report')) { $this->writeReport((string)$input->getOption('report'), $report); $output->writeln(sprintf('Report: %d rows in %s', count($report), $input->getOption('report'))); } if ($updates === [] && $leftovers === 0) { $output->writeln('Nothing to migrate.'); return Command::SUCCESS; } if ($input->getOption('dry-run')) { $output->writeln('Dry run, nothing written.'); return Command::SUCCESS; } $connection->transactional(function (Connection $connection) use ($updates): void { foreach ($updates as $uid => $values) { $connection->update('tt_content', $values, ['uid' => $uid]); } $connection->executeStatement( 'UPDATE tt_content SET tx_gridelements_children = 0, tx_gridelements_backend_layout = \'\' WHERE tx_gridelements_children > 0 OR tx_gridelements_backend_layout NOT IN (\'\', \'0\')' ); }); $output->writeln(sprintf('%d rows migrated. Next: referenceindex:update, container:integrity, cache:flush', count($updates))); return Command::SUCCESS; } private function hasGridelementsColumns(Connection $connection): bool { return array_key_exists('tx_gridelements_container', $connection->createSchemaManager()->listTableColumns('tt_content')); } /** * @param list, 1: string, 2: string, 3: string}> $rows */ private function writeReport(string $file, array $rows): void { $titles = []; foreach ($this->connectionPool->getConnectionForTable('pages')->select(['uid', 'title'], 'pages', ['sys_language_uid' => 0])->iterateAssociative() as $page) { $titles[(int)$page['uid']] = $page['title']; } $handle = fopen($file, 'wb'); fputcsv($handle, ['uid', 'pid', 'page', 'CType', 'header', 'hidden', 'group', 'reason', 'action'], escape: ''); foreach ($rows as [$row, $group, $reason, $action]) { fputcsv($handle, [ $row['uid'], $row['pid'], $titles[(int)$row['pid']] ?? '', $row['CType'], $row['header'], $row['hidden'], $group, $reason, $action, ], escape: ''); } fclose($handle); } }