News und News-Pages Importer

This commit is contained in:
2026-08-13 17:00:53 +02:00
parent 8d3ac08a37
commit f515de54ad
4 changed files with 115554 additions and 3 deletions

114435
migrations/news_export.json Normal file

File diff suppressed because one or more lines are too long

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

View 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('');
}
}

View File

@@ -3,9 +3,9 @@
| | | | | |
|---|---| |---|---|
| **Document identifier** | EVOVITECHL001 | | **Document identifier** | EVOVITECHL001 |
| **Version** | 1.7 | | **Version** | 1.8 |
| **Status** | Released | | **Status** | Released |
| **Date** | 20260805 | | **Date** | 20260813 |
| **Applies to** | `evomedien/vitec` on TYPO3 v14.3 (headless) | | **Applies to** | `evomedien/vitec` on TYPO3 v14.3 (headless) |
| **Owner** | evomedien — VITEC relaunch | | **Owner** | evomedien — VITEC relaunch |
@@ -21,6 +21,7 @@
| 1.5 | 20260805 | **Defect fix, outputchanging:** 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.5 | 20260805 | **Defect fix, outputchanging:** 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 | 20260806 | **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 oldsite downloads (Collateral directory only; idempotent by slug; files fetched resumably; duplicate `file_url`s merged). | | 1.6 | 20260806 | **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 oldsite downloads (Collateral directory only; idempotent by slug; files fetched resumably; duplicate `file_url`s merged). |
| 1.7 | 20260806 | **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 B4 duplication (three copies of the fallback) — consolidation target remains a shared fileresolver service (10.2). | | 1.7 | 20260806 | **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 B4 duplication (three copies of the fallback) — consolidation target remains a shared fileresolver service (10.2). |
| 1.8 | 20260813 | **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 toplevel blocks (B13), and `appearance.layout` is represented differently on the two envelope paths (B14). `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). B11 and B12 recorded as resolved. |
This document is drafted in the style of, and adopts the terminology conventions of, 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 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 into `{ header…, layout, columns: { left: [], right: [] } }`, with the collection
storage table discovered from TCA (`foreign_table`) rather than hardcoded. storage table discovered from TCA (`foreign_table`) rather than hardcoded.
**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 sectionmenu element; `linkToTop` is
inert in headless operation.
**Colour fields.** `intro-paragraph` carries `background_color` (`type: Color`): lowercase
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
uppercase 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 frontend decision and is deliberately not fixed here.
### 7.8 Forms ### 7.8 Forms
#### 7.8.1 Form plugin payload #### 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 A Solution list counterpart does not exist yet. When it is added it **should** reuse this
shape under `content.solutions`. 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 perlevel 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 hardcoded literal in the sense of B5, 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 Contenttype catalogue ## 8 Contenttype catalogue
@@ -742,7 +839,7 @@ shape under `content.solutions`.
| `vitec_downloadcard` | `< lib.…WithHeader` | DownloadcardJsonRenderer | `downloadcard` | detail | | `vitec_downloadcard` | `< lib.…WithHeader` | DownloadcardJsonRenderer | `downloadcard` | detail |
| `vitec_downloadcardcollection` | `< lib.…WithHeader` | DownloadcardcollectionJsonRenderer | `downloadcardcollection` | list | | `vitec_downloadcardcollection` | `< lib.…WithHeader` | DownloadcardcollectionJsonRenderer | `downloadcardcollection` | list |
| `vitec_datasheets` | `< lib.…WithHeader` | DatasheetsJsonRenderer | `datasheets` | 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_locationlist` | `< lib.…WithHeader` | LocationsJsonRenderer | `locations` | list (global) |
| `vitec_customerlogos` | `< lib.…WithHeader` | CustomerlogosJsonRenderer | `customerlogos` | list | | `vitec_customerlogos` | `< lib.…WithHeader` | CustomerlogosJsonRenderer | `customerlogos` | list |
| `vitec_modelcard` | `< lib.…WithHeader` | ModelcardJsonRenderer | `card` | detail (4 model types) | | `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 as intended. Related: `ModelcardJsonRenderer` serves `market` and `solution` from one
shared branch and therefore emits no `detailUrl` either — the card payload (7.9) cannot shared branch and therefore emits no `detailUrl` either — the card payload (7.9) cannot
link to a market detail page until this is resolved. link to a market detail page until this is resolved.
**Resolved 20260810:** `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.
- **B12 — `detailUrl()` duplicated** in `MarketListJsonRenderer` and - **B12 — `detailUrl()` duplicated** in `MarketListJsonRenderer` and
`MarketShowJsonRenderer`, and a third nearidentical `resolveLink()` lives in `MarketShowJsonRenderer`, and a third nearidentical `resolveLink()` lives in
`LocationsJsonRenderer` (7.10). Three copies of "resolve a link server side" is the `LocationsJsonRenderer` (7.10). Three copies of "resolve a link server side" is the
smallest concrete case of B4; it is the natural seed for the `LinkResolver` service smallest concrete case of B4; it is the natural seed for the `LinkResolver` service
that 10.2 calls for. that 10.2 calls for.
**Resolved 20260810:** `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 pageuidtoURL mapping.
- **B13 — 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
**toplevel** 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 (B14). Removable through a
`ModifyArrayRecursiveToArrayEvent` listener.
- **B14 — `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.
--- ---