[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);
}
}