An unwritable --report path ended in a TypeError from fputcsv() after the counts were printed. The command now says so and exits with an error before anything is migrated. Page titles in the report include hidden pages (Connection::select() applied the frontend restrictions). Co-Authored-By: Claude Opus 5.5 <[email protected]>
219 lines
10 KiB
PHP
219 lines
10 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace EP\EpTheme\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\Database\Connection;
|
|
use TYPO3\CMS\Core\Database\ConnectionPool;
|
|
|
|
/**
|
|
* Turns gridelements grids into b13/container elements (see .ddev/plans/GRIDELEMENTS-TO-CONTAINER.md).
|
|
*
|
|
* Plain SQL instead of DataHandler: no hooks, no history, deterministic. Idempotent: every migrated row
|
|
* loses its tx_gridelements_* values, so a second run finds nothing to do.
|
|
*/
|
|
#[AsCommand(name: 'ep:migrate-gridelements', description: 'Migrates gridelements grids and their children to container elements')]
|
|
class MigrateGridelementsCommand extends Command
|
|
{
|
|
/**
|
|
* Gridelements layout => [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('<error>Translated grids found; translations are not handled by this command.</error>');
|
|
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')) {
|
|
if (!$this->writeReport((string)$input->getOption('report'), $report)) {
|
|
$output->writeln(sprintf('<error>Cannot write the report to %s, nothing migrated.</error>', $input->getOption('report')));
|
|
return Command::FAILURE;
|
|
}
|
|
$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('<comment>Dry run, nothing written.</comment>');
|
|
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('<info>%d rows migrated.</info> 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<array{0: array<string, mixed>, 1: string, 2: string, 3: string}> $rows
|
|
*/
|
|
private function writeReport(string $file, array $rows): bool
|
|
{
|
|
$handle = @fopen($file, 'wb');
|
|
if ($handle === false) {
|
|
return false;
|
|
}
|
|
$titles = [];
|
|
foreach ($this->connectionPool->getConnectionForTable('pages')->executeQuery('SELECT uid, title FROM pages WHERE sys_language_uid = 0')->iterateAssociative() as $page) {
|
|
$titles[(int)$page['uid']] = $page['title'];
|
|
}
|
|
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: '');
|
|
}
|
|
return fclose($handle);
|
|
}
|
|
}
|