News und News-Pages Importer
This commit is contained in:
114435
migrations/news_export.json
Normal file
114435
migrations/news_export.json
Normal file
File diff suppressed because one or more lines are too long
491
packages/vitec/Classes/Command/ImportNewsCommand.php
Normal file
491
packages/vitec/Classes/Command/ImportNewsCommand.php
Normal file
@@ -0,0 +1,491 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Evomedien\Vitec\Command;
|
||||
|
||||
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\Core\Bootstrap;
|
||||
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||
use TYPO3\CMS\Core\DataHandling\DataHandler;
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
|
||||
/**
|
||||
* Imports the news of the old vitec.com into EXT:news on this installation.
|
||||
*
|
||||
* Source is migrations/news_export.json, written by migrations/export_news.php
|
||||
* on the old site (plain PDO, read-only). Both installations run EXT:news, so
|
||||
* this is a record-level migration rather than a translation between models.
|
||||
*
|
||||
* Identity: `import_source` + `import_id` — the two fields EXT:news carries for
|
||||
* exactly this purpose. Matching on them rather than on `path_segment` keeps a
|
||||
* re-run correct even after the editors have changed a slug. Existing records
|
||||
* are left alone unless --force is given, so the command can be run repeatedly
|
||||
* while the old site keeps publishing.
|
||||
*
|
||||
* Two record types come over:
|
||||
* - type 0 (171 records) — ordinary articles, everything is in the record
|
||||
* - type 1 (47 records) — "page as news": the body lives on a TYPO3 page that
|
||||
* was brought over by T3D import beforehand. `internalurl` still points at
|
||||
* the page uid of the OLD site, so it is rewritten via `tx_impexp_origuid`,
|
||||
* which impexp wrote on every imported page. A record whose page cannot be
|
||||
* found is skipped and reported — a news item linking into the void is worse
|
||||
* than a visible gap.
|
||||
*
|
||||
* Deliberately not imported: tags (the tag table is empty on the source, the 149
|
||||
* MM rows are orphans of a deleted record), `related` and `related_links` (none
|
||||
* exist), and the single `fal_media` reference (one PDF, faster by hand).
|
||||
*
|
||||
* ALWAYS run --dry-run first.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'vitec:import-news',
|
||||
description: 'Imports the old-site news into EXT:news, linking the "page as news" records to their imported pages.'
|
||||
)]
|
||||
class ImportNewsCommand extends Command
|
||||
{
|
||||
private const TABLE = 'tx_news_domain_model_news';
|
||||
private const CATEGORY_TABLE = 'sys_category';
|
||||
private const IMPORT_SOURCE = 'vitec-legacy';
|
||||
|
||||
/** Scalar fields carried over 1:1 when present in the export. */
|
||||
private const PLAIN_FIELDS = [
|
||||
'title', 'teaser', 'bodytext', 'datetime', 'archive', 'istopnews',
|
||||
'author', 'author_email', 'path_segment', 'type', 'externalurl',
|
||||
'description', 'keywords', 'alternative_title', 'sitemap_changefreq',
|
||||
'sitemap_priority',
|
||||
];
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('file', null, InputOption::VALUE_REQUIRED, 'Export JSON', 'migrations/news_export.json');
|
||||
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report only - nothing is written');
|
||||
$this->addOption('pid', null, InputOption::VALUE_REQUIRED, 'Storage pid for the news records (0 = auto-detect)', '0');
|
||||
$this->addOption('force', null, InputOption::VALUE_NONE, 'Update records that already exist');
|
||||
$this->addOption('only', null, InputOption::VALUE_REQUIRED, 'Import only the record with this path_segment');
|
||||
$this->addOption('category-parent', null, InputOption::VALUE_REQUIRED, 'Parent uid for categories that have to be created (0 = root)', '0');
|
||||
$this->addOption('skip-categories', null, InputOption::VALUE_NONE, 'Do not touch category assignments');
|
||||
$this->addOption('category', null, InputOption::VALUE_REQUIRED, 'Assign these sys_category uids (comma separated) to every record and ignore the old taxonomy');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
// DataHandler checks backend permissions, and on CLI there is no backend
|
||||
// user - without this every write fails with "Attempt to modify table
|
||||
// ... without permission". Same first line as the other import commands.
|
||||
Bootstrap::initializeBackendAuthentication();
|
||||
|
||||
$dryRun = (bool)$input->getOption('dry-run');
|
||||
if ($dryRun) {
|
||||
$output->writeln('<comment>DRY RUN - nothing will be written.</comment>');
|
||||
}
|
||||
|
||||
$export = $this->loadExport((string)$input->getOption('file'), $output);
|
||||
if ($export === null) {
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$news = $export['news'] ?? [];
|
||||
$output->writeln(sprintf('Export: <info>%d</info> news records, written %s', count($news), (string)($export['meta']['exportedAt'] ?? '?')));
|
||||
|
||||
$pid = (int)$input->getOption('pid') ?: $this->detectPid();
|
||||
if ($pid <= 0) {
|
||||
$output->writeln('<error>No storage pid found. Pass --pid=<uid> with the news sysfolder.</error>');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$output->writeln(sprintf('Storage pid: <info>%d</info>', $pid));
|
||||
|
||||
$pageMap = $this->buildPageMap();
|
||||
$output->writeln(sprintf('Imported pages found via tx_impexp_origuid: <info>%d</info>', count($pageMap)));
|
||||
if ($pageMap === []) {
|
||||
$output->writeln('<comment>No imported pages found - every "page as news" record will be skipped.</comment>');
|
||||
}
|
||||
|
||||
// A fixed category list wins over the old taxonomy. The source rubrics
|
||||
// are a 2012-era scheme whose leaf names collide with the market tree
|
||||
// here, so pinning everything to one editorial category is both safer
|
||||
// and closer to how the new site is organised.
|
||||
$fixedCategories = $this->parseUidList((string)$input->getOption('category'));
|
||||
$categoryMap = [];
|
||||
if ($fixedCategories !== '') {
|
||||
$output->writeln(sprintf('Categories: every record gets <info>%s</info>; the old taxonomy is ignored.', $fixedCategories));
|
||||
$this->reportCategoryTitles($fixedCategories, $output);
|
||||
} elseif (!$input->getOption('skip-categories')) {
|
||||
$categoryMap = $this->buildCategoryMap($export, (int)$input->getOption('category-parent'), $pid, $dryRun, $output);
|
||||
}
|
||||
|
||||
$only = (string)$input->getOption('only');
|
||||
$force = (bool)$input->getOption('force');
|
||||
|
||||
// Pass 1 - default language. Translations need their parent to exist.
|
||||
$created = $updated = $existing = $skipped = 0;
|
||||
$uidByOldUid = [];
|
||||
$missingPages = [];
|
||||
|
||||
foreach ($news as $row) {
|
||||
if ((int)($row['sys_language_uid'] ?? 0) > 0) {
|
||||
continue;
|
||||
}
|
||||
if ($only !== '' && (string)($row['path_segment'] ?? '') !== $only) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$oldUid = (int)$row['uid'];
|
||||
$existingUid = $this->findByImportId($oldUid);
|
||||
if ($existingUid > 0) {
|
||||
$uidByOldUid[$oldUid] = $existingUid;
|
||||
if (!$force) {
|
||||
$existing++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$data = $this->buildPayload($row, $pid, $categoryMap, $export, $fixedCategories);
|
||||
|
||||
if ((int)($row['type'] ?? 0) === 1) {
|
||||
$oldPage = $this->extractPageUid((string)($row['internalurl'] ?? ''));
|
||||
$newPage = $pageMap[$oldPage] ?? 0;
|
||||
if ($newPage === 0) {
|
||||
$missingPages[] = sprintf('%s (old page %d)', (string)$row['path_segment'], $oldPage);
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
$data['internalurl'] = 't3://page?uid=' . $newPage;
|
||||
$output->writeln(sprintf(' <info>page-news</info> %-52s -> page %d', mb_substr((string)$row['path_segment'], 0, 52), $newPage));
|
||||
}
|
||||
|
||||
$newUid = $this->write($existingUid, $data, $dryRun, $output, (string)$row['path_segment']);
|
||||
if ($newUid === 0) {
|
||||
continue;
|
||||
}
|
||||
$uidByOldUid[$oldUid] = $newUid;
|
||||
$existingUid > 0 ? $updated++ : $created++;
|
||||
}
|
||||
|
||||
// Pass 2 - translations, now that every parent has a uid on this side.
|
||||
$translations = 0;
|
||||
foreach ($news as $row) {
|
||||
if ((int)($row['sys_language_uid'] ?? 0) <= 0) {
|
||||
continue;
|
||||
}
|
||||
$parentOld = (int)($row['l10n_parent'] ?? 0);
|
||||
$parentNew = $uidByOldUid[$parentOld] ?? $this->findByImportId($parentOld);
|
||||
if ($parentNew === 0) {
|
||||
$output->writeln(sprintf(' <comment>translation of old uid %d skipped: parent not imported</comment>', $parentOld));
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
$oldUid = (int)$row['uid'];
|
||||
$existingUid = $this->findByImportId($oldUid);
|
||||
if ($existingUid > 0 && !$force) {
|
||||
$existing++;
|
||||
continue;
|
||||
}
|
||||
$data = $this->buildPayload($row, $pid, $categoryMap, $export, $fixedCategories);
|
||||
$data['sys_language_uid'] = (int)$row['sys_language_uid'];
|
||||
$data['l10n_parent'] = $parentNew;
|
||||
if ($this->write($existingUid, $data, $dryRun, $output, (string)$row['path_segment']) > 0) {
|
||||
$translations++;
|
||||
}
|
||||
}
|
||||
|
||||
$output->writeln('');
|
||||
$output->writeln(sprintf('%s: <info>%d created</info>, %d updated, %d already present, %d translations, %d skipped',
|
||||
$dryRun ? 'DRY-RUN' : 'Done', $created, $updated, $existing, $translations, $skipped));
|
||||
foreach ($missingPages as $line) {
|
||||
$output->writeln('<comment>no imported page for: ' . $line . '</comment>');
|
||||
}
|
||||
if (!$force && $existing > 0) {
|
||||
$output->writeln('<comment>Use --force to update the records that already exist.</comment>');
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
private function loadExport(string $file, OutputInterface $output): ?array
|
||||
{
|
||||
$path = $file;
|
||||
if (!is_file($path)) {
|
||||
$path = rtrim(\TYPO3\CMS\Core\Core\Environment::getProjectPath(), '/') . '/' . ltrim($file, '/');
|
||||
}
|
||||
if (!is_file($path)) {
|
||||
$output->writeln(sprintf('<error>Export not found: %s</error>', $file));
|
||||
return null;
|
||||
}
|
||||
$data = json_decode((string)file_get_contents($path), true);
|
||||
if (!is_array($data) || !isset($data['news'])) {
|
||||
$output->writeln('<error>Export could not be parsed or carries no "news" block.</error>');
|
||||
return null;
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for one record. Empty strings are kept out so a field the export
|
||||
* dropped as empty does not overwrite something an editor filled in here.
|
||||
*
|
||||
* @param array<string,mixed> $row
|
||||
* @param array<int,int> $categoryMap
|
||||
* @param array<string,mixed> $export
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function buildPayload(array $row, int $pid, array $categoryMap, array $export, string $fixedCategories = ''): array
|
||||
{
|
||||
$data = [
|
||||
'pid' => $pid,
|
||||
'hidden' => (int)($row['hidden'] ?? 0),
|
||||
'import_source' => self::IMPORT_SOURCE,
|
||||
'import_id' => (int)$row['uid'],
|
||||
];
|
||||
foreach (self::PLAIN_FIELDS as $field) {
|
||||
if (array_key_exists($field, $row) && $row[$field] !== null && $row[$field] !== '') {
|
||||
$data[$field] = $row[$field];
|
||||
}
|
||||
}
|
||||
if ($fixedCategories !== '') {
|
||||
$data['categories'] = $fixedCategories;
|
||||
} elseif ($categoryMap !== []) {
|
||||
$categories = [];
|
||||
foreach ($export['categoryAssignments'] ?? [] as $mm) {
|
||||
if ((int)$mm['uid_foreign'] !== (int)$row['uid']) {
|
||||
continue;
|
||||
}
|
||||
$mapped = $categoryMap[(int)$mm['category_uid']] ?? 0;
|
||||
if ($mapped > 0) {
|
||||
$categories[] = $mapped;
|
||||
}
|
||||
}
|
||||
if ($categories !== []) {
|
||||
$data['categories'] = implode(',', array_unique($categories));
|
||||
}
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $data */
|
||||
private function write(int $existingUid, array $data, bool $dryRun, OutputInterface $output, string $label): int
|
||||
{
|
||||
if ($dryRun) {
|
||||
return $existingUid > 0 ? $existingUid : -1;
|
||||
}
|
||||
$id = $existingUid > 0 ? (string)$existingUid : 'NEW' . substr(md5($label . microtime()), 0, 12);
|
||||
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||
$dataHandler->start([self::TABLE => [$id => $data]], []);
|
||||
$dataHandler->process_datamap();
|
||||
if ($dataHandler->errorLog !== []) {
|
||||
$output->writeln(sprintf(' <error>%s: %s</error>', $label, implode(' | ', $dataHandler->errorLog)));
|
||||
return 0;
|
||||
}
|
||||
return $existingUid > 0 ? $existingUid : (int)($dataHandler->substNEWwithIDs[$id] ?? 0);
|
||||
}
|
||||
|
||||
/** old page uid (from tx_impexp_origuid) => uid on this site */
|
||||
private function buildPageMap(): array
|
||||
{
|
||||
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('pages');
|
||||
$qb->getRestrictions()->removeAll();
|
||||
$rows = $qb->select('uid', 'tx_impexp_origuid')->from('pages')
|
||||
->where(
|
||||
$qb->expr()->gt('tx_impexp_origuid', $qb->createNamedParameter(0, ParameterType::INTEGER)),
|
||||
$qb->expr()->eq('deleted', 0)
|
||||
)
|
||||
->executeQuery()->fetchAllAssociative();
|
||||
|
||||
$map = [];
|
||||
foreach ($rows as $row) {
|
||||
$map[(int)$row['tx_impexp_origuid']] = (int)$row['uid'];
|
||||
}
|
||||
return $map;
|
||||
}
|
||||
|
||||
/** `t3://page?uid=N` or a bare uid - both forms occur in the source data. */
|
||||
private function extractPageUid(string $internalUrl): int
|
||||
{
|
||||
$internalUrl = trim($internalUrl);
|
||||
if (preg_match('#t3://page\?uid=(\d+)#i', $internalUrl, $m)) {
|
||||
return (int)$m[1];
|
||||
}
|
||||
return ctype_digit($internalUrl) ? (int)$internalUrl : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Old category uid => uid here. Matched by title, because uids are
|
||||
* meaningless across installations. Missing categories are created below
|
||||
* --category-parent so the assignments do not silently vanish.
|
||||
*
|
||||
* @param array<string,mixed> $export
|
||||
* @return array<int,int>
|
||||
*/
|
||||
private function buildCategoryMap(array $export, int $parent, int $pid, bool $dryRun, OutputInterface $output): array
|
||||
{
|
||||
$categories = $export['categories'] ?? [];
|
||||
if ($categories === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::CATEGORY_TABLE);
|
||||
$qb->getRestrictions()->removeAll();
|
||||
$rows = $qb->select('uid', 'title', 'parent')->from(self::CATEGORY_TABLE)
|
||||
->where($qb->expr()->eq('deleted', 0))
|
||||
->executeQuery()->fetchAllAssociative();
|
||||
|
||||
$titleByUid = [];
|
||||
foreach ($rows as $row) {
|
||||
$titleByUid[(int)$row['uid']] = (string)$row['title'];
|
||||
}
|
||||
|
||||
// With --category-parent the title match is restricted to that subtree.
|
||||
// Matching site-wide is what makes this dangerous: the old news rubrics
|
||||
// carry market names (Education, Government, Healthcare, ...) that also
|
||||
// exist in the market taxonomy, and a bare title match would file press
|
||||
// releases under markets.
|
||||
$scope = $parent > 0 ? $this->collectDescendants($parent, $rows) : null;
|
||||
|
||||
$byTitle = [];
|
||||
foreach ($rows as $row) {
|
||||
if ($scope !== null && !isset($scope[(int)$row['uid']])) {
|
||||
continue;
|
||||
}
|
||||
$byTitle[mb_strtolower(trim((string)$row['title']))] = (int)$row['uid'];
|
||||
}
|
||||
if ($scope !== null) {
|
||||
$output->writeln(sprintf('Category match restricted to the subtree below uid %d ("%s"), %d candidates.',
|
||||
$parent, $titleByUid[$parent] ?? '?', count($byTitle)));
|
||||
} else {
|
||||
$output->writeln('<comment>Category match is site-wide - pass --category-parent=<uid> to restrict it to one subtree.</comment>');
|
||||
}
|
||||
|
||||
$map = [];
|
||||
$found = $missing = 0;
|
||||
foreach ($categories as $cat) {
|
||||
$title = trim((string)$cat['title']);
|
||||
$key = mb_strtolower($title);
|
||||
if (isset($byTitle[$key])) {
|
||||
$hit = $byTitle[$key];
|
||||
$map[(int)$cat['uid']] = $hit;
|
||||
$found++;
|
||||
$parentUid = 0;
|
||||
foreach ($rows as $row) {
|
||||
if ((int)$row['uid'] === $hit) {
|
||||
$parentUid = (int)$row['parent'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
$output->writeln(sprintf(' category "%s" -> <info>uid %d</info> (below "%s")',
|
||||
$title, $hit, $parentUid > 0 ? ($titleByUid[$parentUid] ?? '?') : 'root'));
|
||||
continue;
|
||||
}
|
||||
$output->writeln(sprintf(' category <comment>"%s"</comment> does not exist here%s', $title, $dryRun ? '' : ' - creating'));
|
||||
$missing++;
|
||||
if ($dryRun) {
|
||||
continue;
|
||||
}
|
||||
$id = 'NEW' . substr(md5('cat' . $title), 0, 12);
|
||||
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||
$dataHandler->start([self::CATEGORY_TABLE => [$id => ['pid' => $parent > 0 ? 0 : $pid, 'parent' => $parent, 'title' => $title]]], []);
|
||||
$dataHandler->process_datamap();
|
||||
$newUid = (int)($dataHandler->substNEWwithIDs[$id] ?? 0);
|
||||
if ($newUid > 0) {
|
||||
$map[(int)$cat['uid']] = $newUid;
|
||||
$byTitle[$key] = $newUid;
|
||||
}
|
||||
}
|
||||
$output->writeln(sprintf('Categories: <info>%d matched</info>, %d missing', $found, $missing));
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* The category plus every descendant, as a uid => true lookup. Iterative and
|
||||
* seen-guarded so a parent pointing back up the tree cannot loop.
|
||||
*
|
||||
* @param array<int,array<string,mixed>> $rows
|
||||
* @return array<int,true>
|
||||
*/
|
||||
private function collectDescendants(int $uid, array $rows): array
|
||||
{
|
||||
$childrenByParent = [];
|
||||
foreach ($rows as $row) {
|
||||
$childrenByParent[(int)$row['parent']][] = (int)$row['uid'];
|
||||
}
|
||||
$collected = [$uid => true];
|
||||
$queue = [$uid];
|
||||
while ($queue !== []) {
|
||||
$current = array_shift($queue);
|
||||
foreach ($childrenByParent[$current] ?? [] as $child) {
|
||||
if (isset($collected[$child])) {
|
||||
continue;
|
||||
}
|
||||
$collected[$child] = true;
|
||||
$queue[] = $child;
|
||||
}
|
||||
}
|
||||
return $collected;
|
||||
}
|
||||
|
||||
/** "54, 55" -> "54,55"; anything non-numeric is dropped. */
|
||||
private function parseUidList(string $value): string
|
||||
{
|
||||
$uids = [];
|
||||
foreach (explode(',', $value) as $part) {
|
||||
$part = trim($part);
|
||||
if ($part !== '' && ctype_digit($part) && (int)$part > 0) {
|
||||
$uids[] = (int)$part;
|
||||
}
|
||||
}
|
||||
return implode(',', array_unique($uids));
|
||||
}
|
||||
|
||||
/**
|
||||
* Prints the titles behind the uids given via --category, so a typo shows up
|
||||
* in the dry run instead of after 218 records have been filed wrongly.
|
||||
*/
|
||||
private function reportCategoryTitles(string $uidList, OutputInterface $output): void
|
||||
{
|
||||
foreach (explode(',', $uidList) as $uid) {
|
||||
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::CATEGORY_TABLE);
|
||||
$qb->getRestrictions()->removeAll();
|
||||
$row = $qb->select('uid', 'title', 'parent')->from(self::CATEGORY_TABLE)
|
||||
->where(
|
||||
$qb->expr()->eq('uid', $qb->createNamedParameter((int)$uid, ParameterType::INTEGER)),
|
||||
$qb->expr()->eq('deleted', 0)
|
||||
)
|
||||
->executeQuery()->fetchAssociative();
|
||||
if (!$row) {
|
||||
$output->writeln(sprintf(' <error>uid %s: no such category</error>', $uid));
|
||||
continue;
|
||||
}
|
||||
$output->writeln(sprintf(' uid %s = <info>"%s"</info>', $uid, (string)$row['title']));
|
||||
}
|
||||
}
|
||||
|
||||
private function findByImportId(int $oldUid): int
|
||||
{
|
||||
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::TABLE);
|
||||
$qb->getRestrictions()->removeAll();
|
||||
$uid = $qb->select('uid')->from(self::TABLE)
|
||||
->where(
|
||||
$qb->expr()->eq('import_id', $qb->createNamedParameter($oldUid, ParameterType::INTEGER)),
|
||||
$qb->expr()->eq('import_source', $qb->createNamedParameter(self::IMPORT_SOURCE)),
|
||||
$qb->expr()->eq('deleted', 0)
|
||||
)
|
||||
->setMaxResults(1)->executeQuery()->fetchOne();
|
||||
return (int)($uid ?: 0);
|
||||
}
|
||||
|
||||
/** Most-used pid of the news records already here; 0 when there are none. */
|
||||
private function detectPid(): int
|
||||
{
|
||||
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::TABLE);
|
||||
$qb->getRestrictions()->removeAll();
|
||||
$rows = $qb->select('pid')->addSelectLiteral('COUNT(*) AS cnt')->from(self::TABLE)
|
||||
->where($qb->expr()->eq('deleted', 0))
|
||||
->groupBy('pid')->orderBy('cnt', 'DESC')->setMaxResults(1)
|
||||
->executeQuery()->fetchAssociative();
|
||||
return (int)($rows['pid'] ?? 0);
|
||||
}
|
||||
}
|
||||
506
packages/vitec/Classes/Command/MigrateNewsPagesCommand.php
Normal file
506
packages/vitec/Classes/Command/MigrateNewsPagesCommand.php
Normal file
@@ -0,0 +1,506 @@
|
||||
<?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('');
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,9 @@
|
||||
| | |
|
||||
|---|---|
|
||||
| **Document identifier** | EVO‑VITEC‑HL‑001 |
|
||||
| **Version** | 1.7 |
|
||||
| **Version** | 1.8 |
|
||||
| **Status** | Released |
|
||||
| **Date** | 2026‑08‑05 |
|
||||
| **Date** | 2026‑08‑13 |
|
||||
| **Applies to** | `evomedien/vitec` on TYPO3 v14.3 (headless) |
|
||||
| **Owner** | evomedien — VITEC relaunch |
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
| 1.5 | 2026‑08‑05 | **Defect fix, output‑changing:** richtext fields were emitted as raw database content by every VITEC UserFunc renderer, leaving `t3://` links unresolved in the JSON. New `RteResolver` service and mandatory convention 9.11; applied at all 19 richtext call sites across 11 renderers. Duplication register 10.2 updated. |
|
||||
| 1.6 | 2026‑08‑06 | **Interface change (additive):** `tx_vitec_domain_model_download` gained a second category field `type` (display taxonomy; MM rows distinguished by `fieldname`), emitted as the string `type` in the downloadcard, downloadcardcollection and datasheets payloads — analogous to `filetype`. New CLI command `vitec:import-downloads` migrates the old‑site downloads (Collateral directory only; idempotent by slug; files fetched resumably; duplicate `file_url`s merged). |
|
||||
| 1.7 | 2026‑08‑06 | **Robustness:** the import normalizes legacy filenames on fetch so every imported file matches the version convention (`__NN_A` → `__NN-A`, `___NN` → `__NN`, `__NNA` → `__NN-A`, bare `__NN` → `__NN-A` as initial revision), and the three download renderers gained a `filepath` fallback in `getDownloadFile()` (FAL → convention → filepath) as a safety net for anything that still escapes it. Extends the B‑4 duplication (three copies of the fallback) — consolidation target remains a shared file‑resolver service (10.2). |
|
||||
| 1.8 | 2026‑08‑13 | **Defect fix and interface change (additive).** Content Blocks never carried the Core *Appearance* tab: `layout`, `frame_class` — including the VITEC frame classes — `space_before_class`, `space_after_class`, `sectionIndex` and `linkToTop` were unreachable for editors on all nine blocks. Added centrally for every `vitec_*` type (7.7); the `appearance` envelope is unchanged, its values were merely always default. Side effect: those six columns now also appear raw inside `data` on top‑level blocks (B‑13), and `appearance.layout` is represented differently on the two envelope paths (B‑14). `intro-paragraph` gained `background_color` (7.7). `vitec_eventlist` gained the layout `regions`, emitting a `regions` array built from the region categories below parent 104; the event payload is specified for the first time (7.14). B‑11 and B‑12 recorded as resolved. |
|
||||
|
||||
This document is drafted in the style of, and adopts the terminology conventions of,
|
||||
ISO/IEC/IEEE 42010 (architecture description), ISO/IEC/IEEE 26514 (information for
|
||||
@@ -487,6 +488,43 @@ its collection items are resolved explicitly by `UsecaseSerializer::resolveColum
|
||||
into `{ header…, layout, columns: { left: [], right: [] } }`, with the collection
|
||||
storage table discovered from TCA (`foreign_table`) rather than hard‑coded.
|
||||
|
||||
**Field naming.** Fields carry the `vitec_` vendor prefix in **storage**; the JSON key is
|
||||
the plain YAML identifier. `ArrayRecursiveToArray` decorates every key with
|
||||
`TcaFieldDefinition->identifier`, so the column `vitec_background_color` is emitted as
|
||||
`background_color`. A front end **shall** key on the identifier, never on the column name.
|
||||
|
||||
**Field types are whitelisted.** `ArrayRecursiveToArray::processStringField()` switches over
|
||||
the Content Blocks field types and drops anything that reaches its `default` branch. A new
|
||||
field type therefore **shall** be verified against the live JSON before it is relied upon;
|
||||
deriving it from the block definition alone is insufficient.
|
||||
|
||||
**Appearance (since v1.8).** Content Blocks builds its own `showitem`
|
||||
(`TcaGenerator::getContentElementStandardShowItem`) and appends only the *Extended* tab, so
|
||||
the Core *Appearance* tab was absent from every block.
|
||||
`Configuration/TCA/Overrides/tt_content.php` now appends
|
||||
`--div--;core.form.tabs:appearance` together with the **Core
|
||||
palettes** `frames` and `appearanceLinks` to every `vitec_*` type whose `showitem` lacks
|
||||
`--palette--;;frames`, positioned ahead of the *Extended* tab. The loop is idempotent, skips
|
||||
the Extbase plugins (which inherit the tab from `tt_content` `types['header']`) and covers
|
||||
future blocks automatically.
|
||||
|
||||
Redefining those palettes inside a block's `config.yaml` is **prohibited**: TCA palettes are
|
||||
global per identifier, so a second definition of `frames` collides with the Core one — the
|
||||
failure mode is a page module that throws `RecordPropertyNotFoundException` at runtime for
|
||||
*existing* elements, not a build error.
|
||||
|
||||
`sectionIndex` and `linkToTop` are **not** part of the `appearance` envelope — `lib.appearance`
|
||||
carries `layout`, `frameClass`, `spaceBefore` and `spaceAfter` only — and therefore do not
|
||||
reach the JSON. `sectionIndex` still governs the Core section‑menu element; `linkToTop` is
|
||||
inert in headless operation.
|
||||
|
||||
**Colour fields.** `intro-paragraph` carries `background_color` (`type: Color`): lower‑case
|
||||
hex, empty string when unset, with the VITEC palette offered as `valuePicker` presets. Hex
|
||||
is stored lower case deliberately, because the native colour picker emits lower case and an
|
||||
upper‑case preset would store one colour under two spellings. A block can therefore receive
|
||||
a background by **two** mechanisms — this field and the `vitec-bg-*` frame classes. The
|
||||
precedence between them is a front‑end decision and is deliberately not fixed here.
|
||||
|
||||
### 7.8 Forms
|
||||
|
||||
#### 7.8.1 Form plugin payload
|
||||
@@ -726,6 +764,65 @@ renderer duplicates no FAL logic.
|
||||
A Solution list counterpart does not exist yet. When it is added it **should** reuse this
|
||||
shape under `content.solutions`.
|
||||
|
||||
### 7.14 Event list payload (`vitec_eventlist`)
|
||||
`EventlistJsonRenderer` emits, under `content.eventlist`:
|
||||
|
||||
```jsonc
|
||||
{ "events": [
|
||||
{ "uid": 1, "title": "…", "slug": "…", "teaser": "…",
|
||||
"description": "<p>…</p>", // RTE HTML, resolved per 9.11
|
||||
"eventstart": "2027-07-09", // ISO date, null when unset
|
||||
"eventend": "2027-07-09",
|
||||
"venue": "…", "booth": "…", "city": "…", "country": "…",
|
||||
"attendancemode": "offline", // offline | online | mixed
|
||||
"eventstatus": "scheduled",
|
||||
"eventurl": "…", "meetinglink": "…",
|
||||
"image": { "url": "…", "srcset": [ … ] },
|
||||
"categories": [ { "uid": 107, "title": "…", "description": "…" } ] } ],
|
||||
"settings": { "layout": "list", // list | grid | teaserbar | regions
|
||||
"showpast": false, "daysinadvance": 0, "limit": 0 },
|
||||
"pastEvents": [ … ], // only when showpast is set
|
||||
"regions": [ … ] } // only when layout = regions
|
||||
```
|
||||
|
||||
"Upcoming" means `eventend >= today`, or `eventstart >= today` when no end date is set;
|
||||
ordering is `eventstart` ascending. `pastEvents` inverts both and is present only when the
|
||||
FlexForm flag `showpast` is set.
|
||||
|
||||
#### 7.14.1 Region payload (`layout: regions`)
|
||||
For the `regions` layout the renderer additionally emits one entry per **region** — the
|
||||
`sys_category` records directly below the parent category **104** — that has at least one
|
||||
upcoming event:
|
||||
|
||||
```jsonc
|
||||
{ "uid": 107, "title": "European Events",
|
||||
"description": "", // plain text; sys_category is not a richtext field
|
||||
"eventCount": 2,
|
||||
"image": { "url": "…", "srcset": [ … ] }, // the next event's image, i.e. the region logo
|
||||
"nextEvent": { "uid": 1, "title": "…", "slug": "…",
|
||||
"eventstart": "2027-07-09", "eventend": "2027-07-09",
|
||||
"eventurl": "…" } }
|
||||
```
|
||||
|
||||
Normative behaviour:
|
||||
|
||||
- A region **without** an upcoming event is omitted entirely. `showpast` does not
|
||||
reinstate it — the layout answers "what is coming up where", not "what happened".
|
||||
- An event filed under a **descendant** of a region counts for that region. The category
|
||||
branch is resolved in PHP from a single `sys_category` read, per 9.2's preference for one
|
||||
query over per‑level recursion.
|
||||
- Ordering is the **backend sorting** of the categories, so the carousel order is editor
|
||||
controlled. `nextEvent.eventstart` is included so a front end may sort chronologically
|
||||
instead.
|
||||
- `daysinadvance` applies. **`limit` does not**: it caps events, and applying it before the
|
||||
grouping would silently drop whole regions.
|
||||
- The parent category id is the constant `REGION_PARENT_CATEGORY` in the renderer, not a
|
||||
FlexForm setting — a hard‑coded literal in the sense of B‑5, accepted here because it
|
||||
belongs to the content model rather than to a single content element.
|
||||
|
||||
An event carrying no category belongs to no region and is invisible to this layout while
|
||||
remaining present in `events`.
|
||||
|
||||
---
|
||||
|
||||
## 8 Content‑type catalogue
|
||||
@@ -742,7 +839,7 @@ shape under `content.solutions`.
|
||||
| `vitec_downloadcard` | `< lib.…WithHeader` | DownloadcardJsonRenderer | `downloadcard` | detail |
|
||||
| `vitec_downloadcardcollection` | `< lib.…WithHeader` | DownloadcardcollectionJsonRenderer | `downloadcardcollection` | list |
|
||||
| `vitec_datasheets` | `< lib.…WithHeader` | DatasheetsJsonRenderer | `datasheets` | list |
|
||||
| `vitec_eventlist` | `=< lib.…WithHeader` ⚠ | EventlistJsonRenderer | `eventlist` | list |
|
||||
| `vitec_eventlist` | `=< lib.…WithHeader` ⚠ | EventlistJsonRenderer | `eventlist` | list (7.14) |
|
||||
| `vitec_locationlist` | `< lib.…WithHeader` | LocationsJsonRenderer | `locations` | list (global) |
|
||||
| `vitec_customerlogos` | `< lib.…WithHeader` | CustomerlogosJsonRenderer | `customerlogos` | list |
|
||||
| `vitec_modelcard` | `< lib.…WithHeader` | ModelcardJsonRenderer | `card` | detail (4 model types) |
|
||||
@@ -1040,11 +1137,33 @@ remediation.
|
||||
as intended. Related: `ModelcardJsonRenderer` serves `market` and `solution` from one
|
||||
shared branch and therefore emits no `detailUrl` either — the card payload (7.9) cannot
|
||||
link to a market detail page until this is resolved.
|
||||
**Resolved 2026‑08‑10:** `detail_page` added to Solution with the identical TCA shape and
|
||||
emitted by `SolutionShowJsonRenderer`; the shared `market | solution` branch of
|
||||
`ModelcardJsonRenderer` now serialises `detailUrl`, so market cards link as well.
|
||||
- **B‑12 — `detailUrl()` duplicated** in `MarketListJsonRenderer` and
|
||||
`MarketShowJsonRenderer`, and a third near‑identical `resolveLink()` lives in
|
||||
`LocationsJsonRenderer` (7.10). Three copies of "resolve a link server side" is the
|
||||
smallest concrete case of B‑4; it is the natural seed for the `LinkResolver` service
|
||||
that 10.2 calls for.
|
||||
**Resolved 2026‑08‑10:** `Service\LinkResolver::pageUrl()` is now the single
|
||||
implementation, with five callers. The contract is **`null` = no link**; the two Usecase
|
||||
renderers keep a thin wrapper appending `?? ''` because they build paths by concatenation.
|
||||
`LocationsJsonRenderer` and `NewsJsonRenderer` stay outside it deliberately — the first
|
||||
resolves a whole `parameter` construct, the second builds slug paths plus a canonical, and
|
||||
neither is a page‑uid‑to‑URL mapping.
|
||||
- **B‑13 — Appearance columns duplicated inside `data`.** Since the *Appearance* tab was
|
||||
added (7.7), `Record::toArray()` resolves the six palette fields as part of the record, so
|
||||
**top‑level** Content Blocks emit `layout`, `frame_class`, `space_before_class`,
|
||||
`space_after_class`, `sectionIndex` and `linkToTop` raw inside `data` in addition to the
|
||||
processed `appearance` object. Container children are unaffected, being serialised by
|
||||
`ContainerChildrenProcessor` rather than `RecordToArray`. Harmless but redundant, and the
|
||||
raw and processed representations of `layout` differ (B‑14). Removable through a
|
||||
`ModifyArrayRecursiveToArrayEvent` listener.
|
||||
- **B‑14 — `appearance.layout` is not represented uniformly.** On the TypoScript path a
|
||||
`CASE` in `lib.appearance` maps the field to `default` / `layout-1` / `layout-2` /
|
||||
`layout-3`; container children serialised by `ContainerChildrenProcessor` carry the raw
|
||||
value (`"0"`). A front end consuming both paths must accept either form. Predates v1.8;
|
||||
recorded here because the Appearance tab makes the field editable in the first place.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user