507 lines
20 KiB
PHP
507 lines
20 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Evomedien\Vitec\Command;
|
|
|
|
use Doctrine\DBAL\ArrayParameterType;
|
|
use Doctrine\DBAL\ParameterType;
|
|
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\ConnectionPool;
|
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
|
|
|
/**
|
|
* Converts the legacy FLUX layout of the imported "page as news" pages into the
|
|
* VITEC container elements.
|
|
*
|
|
* Background: the 47 press-release pages came over from the old site by T3D
|
|
* import. Their layout is built with `fluxbs5templates_container` and
|
|
* `fluxbs5templates_fluidrow`, neither of which exists here — in the backend they
|
|
* show as unknown types, in the JSON they fall through to `tt_content.default`.
|
|
*
|
|
* The nesting is the tricky part. FLUX does not use a parent column; it encodes
|
|
* the relation arithmetically as
|
|
*
|
|
* colPos = parentUid * 100 + columnIndex
|
|
*
|
|
* and impexp renumbers uids on import while leaving `colPos` untouched. A child
|
|
* therefore still points at the uid its parent had on the OLD site. The bridge is
|
|
* `tx_impexp_origuid`, which impexp writes on every imported record: resolving
|
|
* `floor(colPos / 100)` against it finds the parent again. Without that column
|
|
* populated this migration cannot run, and the command says so instead of
|
|
* guessing.
|
|
*
|
|
* What it does, per page:
|
|
* - fluidrow -> 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('<comment>DRY RUN - nothing will be written.</comment>');
|
|
}
|
|
|
|
$pages = $this->resolvePages($input, $output);
|
|
if ($pages === []) {
|
|
return Command::FAILURE;
|
|
}
|
|
$output->writeln(sprintf('Pages in scope: <info>%d</info>', count($pages)));
|
|
|
|
$rows = $this->loadContent(array_keys($pages));
|
|
if ($rows === []) {
|
|
$output->writeln('<comment>No content elements on those pages - nothing to do.</comment>');
|
|
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: <info>%d</info>, of them with tx_impexp_origuid: <info>%d</info>', count($rows), $withOrig));
|
|
if ($withOrig === 0) {
|
|
$output->writeln('<error>No element carries tx_impexp_origuid. The FLUX nesting cannot be resolved without it.</error>');
|
|
$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('<comment>%d nested elements have no parent in scope and stay where they are.</comment>', $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 -> <info>%s</info> (%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: <info>%d</info>', $created));
|
|
$output->writeln(sprintf('Top-level colPos remapped: <info>%d</info>', $remapped));
|
|
$output->writeln(sprintf('Pages given a layout with a Hero zone: <info>%d</info>', $relaid));
|
|
$output->writeln(sprintf('Children moved: <info>%d</info>', $moved));
|
|
$output->writeln(sprintf('Layout leftovers deleted (%s): <info>%d</info>', implode('/', self::DROP_CTYPES), $dropped));
|
|
foreach ($skipped as $line) {
|
|
$output->writeln('<comment>skipped: ' . $line . '</comment>');
|
|
}
|
|
if ($dryRun) {
|
|
$output->writeln('');
|
|
$output->writeln('<comment>Dry run - re-run without --dry-run to apply, then flush caches.</comment>');
|
|
}
|
|
|
|
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<int,array<string,mixed>>
|
|
*/
|
|
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('<error>No page with tx_impexp_origuid = %d found.</error>', $origUid));
|
|
$output->writeln('Pass --pid=<uid> with the parent page on this site instead.');
|
|
return [];
|
|
}
|
|
$pid = (int)$parent['uid'];
|
|
$output->writeln(sprintf('Parent page: uid <info>%d</info> "%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<int,array<string,mixed>>
|
|
*/
|
|
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<int,array<int,array<string,mixed>>> $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('#<field index="class-(?:md|lg)">\s*<value index="vDEF">([^<]*)</value>#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<int,array<string,mixed>> $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('<comment>--map-colpos could not be parsed, skipping the remap.</comment>');
|
|
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 -> <info>%d</info>%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<int,array<string,mixed>> $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 -> <info>%d</info> (%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<int,array<string,mixed>> $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 = ' <info>-> container</info>';
|
|
} elseif (in_array($cType, self::DROP_CTYPES, true)) {
|
|
$note = ' <comment>-> deleted</comment>';
|
|
} elseif ($cType === 'slickimage') {
|
|
$note = ' <comment>-> left as is, no counterpart here (decide separately)</comment>';
|
|
}
|
|
$output->writeln(sprintf(' %-34s %4d%s', $cType, $count, $note));
|
|
}
|
|
$output->writeln('');
|
|
}
|
|
}
|