Cards visible in the FE; /products cards wrapped into 33/33/33 grids

- the freshly attached card images did not show: text_only is the
  explicit no-image variant, so vitec:attach-card-images now flips a
  card to "vertical" whenever it carries (or receives) an image; and
  the new placeholder PNGs sat in the sshfs 770 trap - fix-asset-perms
  covers them, with public/product-audit added to its targets
- new generic vitec:wrap-cards-grid --page=<slug>: wraps a page's cards
  into vitec_cols_33_33_33 containers (b13 columns 221/222/223), three
  per container, section-aware - every top-level intro paragraph starts
  a section, other element types close it, so the category headings on
  /products keep their grids beneath them. Existing grid containers are
  pooled and reused, surplus ones hidden
- the first apply aborted mid-move and left a half state (15 empty
  shells, three cards moved; diagnosis migrations/check_grid_447.php,
  kept) - v2 repairs such states by design: global container pool,
  hard asserts after the create pass, try/catch around the move pass,
  and a final "no card left top-level" verification
- applied on /products and verified live: 38 cards in 15 grids
  (sections 5/7/3/6/2/4/2/6/3 -> 2/3/1/2/1/2/1/2/1 containers), order
  intro -> grids -> banner -> faq intact, children delivered in the
  known items/contentElements shape

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
orascheandClaude Fable 5 committed 2026-10-27 15:10:24 +01:00
1 parent c8d653b39b
commit b0690c4cdc
4 files changed
+301 -5

No files matched your search

@@ -77,10 +77,10 @@ final class AttachCardImagesCommand extends Command
$datamap = [];
$newIndex = 0;
$stats = ['photo' => 0, 'placeholder' => 0, 'grey' => 0, 'kept' => 0, 'missing' => []];
$stats = ['photo' => 0, 'placeholder' => 0, 'grey' => 0, 'kept' => 0, 'variant' => 0, 'missing' => []];
foreach ($pageUids as $pageUid => $slug) {
$cards = $content->fetchAllAssociative(
"SELECT uid, header, vitec_primary_cta FROM tt_content
"SELECT uid, header, vitec_primary_cta, vitec_layout_variant FROM tt_content
WHERE deleted = 0 AND hidden = 0 AND pid = ? AND CType = 'vitec_card'
ORDER BY colPos, sorting",
[$pageUid]
@@ -104,6 +104,13 @@ final class AttachCardImagesCommand extends Command
);
if ($existing > 0) {
$stats['kept']++;
// a card that carries an image must not stay on the
// explicit no-image variant (found 2026-10-27: the FE
// correctly hides images on text_only)
if ((string)$card['vitec_layout_variant'] === 'text_only') {
$datamap['tt_content'][(string)$cardUid]['vitec_layout_variant'] = 'vertical';
$stats['variant']++;
}
continue;
}
$identifier = null;
@@ -138,6 +145,10 @@ final class AttachCardImagesCommand extends Command
if (!$apply) {
continue;
}
if ((string)$card['vitec_layout_variant'] === 'text_only') {
$datamap['tt_content'][(string)$cardUid]['vitec_layout_variant'] = 'vertical';
$stats['variant']++;
}
$file = $storage->getFile($identifier);
$datamap['sys_file_reference']['NEWCI' . (++$newIndex)] = [
'pid' => $pageUid,
@@ -150,8 +161,8 @@ final class AttachCardImagesCommand extends Command
}
}
$output->writeln(sprintf("\n%d product photo(s), %d numbered placeholder(s), %d generic grey, %d card(s) already had an image%s",
$stats['photo'], $stats['placeholder'], $stats['grey'], $stats['kept'],
$output->writeln(sprintf("\n%d product photo(s), %d numbered placeholder(s), %d generic grey, %d card(s) already had an image, %d variant flip(s) text_only->vertical%s",
$stats['photo'], $stats['placeholder'], $stats['grey'], $stats['kept'], $stats['variant'],
$stats['missing'] === [] ? '' : ' · MISSING numbered files: ' . implode(', ', $stats['missing'])));
if (!$apply) {
@@ -0,0 +1,243 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use TYPO3\CMS\Core\Core\Bootstrap;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Wraps the vitec_card elements of a page into vitec_cols_33_33_33 grid
* containers (b13 columns 221/222/223), three cards per container, exactly
* like the editor arranged the first products on /products by hand
* (request Olli 2026-10-27).
*
* Sections are respected: every top-level vitec_introparagraph in the main
* column starts a new section, its following cards are gridded beneath it;
* any other element type (text, banner, faq, plugin) closes the section.
* Grid containers the editor already created are reused in place, their
* card children are re-collected in order; surplus containers are hidden
* and reported. Works on any page - category pages included - via --page.
*
* vendor/bin/typo3 vitec:wrap-cards-grid --page=/products
* vendor/bin/typo3 vitec:wrap-cards-grid --page=/products --apply
*/
#[AsCommand(
name: 'vitec:wrap-cards-grid',
description: 'Wrap a page\'s cards into 33/33/33 grid containers per section (dry-run by default)'
)]
final class WrapCardsInGridCommand extends Command
{
private const CONTAINER = 'vitec_cols_33_33_33';
private const COLS = [221, 222, 223];
private const COLPOS_MAIN = 0;
protected function configure(): void
{
$this->addOption('page', null, InputOption::VALUE_REQUIRED, 'Slug of the page, e.g. /products');
$this->addOption('apply', null, InputOption::VALUE_NONE, 'Write changes (default: dry run)');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
Bootstrap::initializeBackendAuthentication();
$apply = (bool)$input->getOption('apply');
$slug = '/' . trim((string)$input->getOption('page'), '/ ');
$pool = GeneralUtility::makeInstance(ConnectionPool::class);
$pages = $pool->getConnectionForTable('pages');
$content = $pool->getConnectionForTable('tt_content');
$page = $pages->fetchAssociative('SELECT uid, title FROM pages WHERE deleted = 0 AND slug = ? AND sys_language_uid IN (0,-1)', [$slug]);
if (!$page) {
$output->writeln('<error>Page ' . $slug . ' not found.</error>');
return Command::FAILURE;
}
$pageUid = (int)$page['uid'];
$elements = $content->fetchAllAssociative(
'SELECT uid, CType, header, hidden FROM tt_content
WHERE deleted = 0 AND pid = ? AND colPos = ? AND tx_container_parent = 0 AND sys_language_uid IN (0,-1)
ORDER BY sorting',
[$pageUid, self::COLPOS_MAIN]
);
$output->writeln(($apply ? 'APPLY' : 'DRY-RUN') . sprintf(' · %s #%d · %d top-level element(s) in main', $slug, $pageUid, count($elements)));
// --- parse into an ordered plan -------------------------------------------------
// order entries: ['el', uid] | ['section', ['intro'=>?uid,'cards'=>[uids]]]
// Containers go into ONE global pool: a half-applied earlier run may
// have left empty grids anywhere on the page - every card gets an
// explicit column assignment anyway, so the shells are interchangeable.
$order = [];
$section = null;
$pool = [];
$flush = function () use (&$order, &$section): void {
if ($section !== null) {
$order[] = ['section', $section];
$section = null;
}
};
foreach ($elements as $element) {
$uid = (int)$element['uid'];
$ctype = (string)$element['CType'];
if ((int)$element['hidden'] === 1) {
continue; // hidden legacy stays untouched and unsequenced
}
if ($ctype === 'vitec_introparagraph') {
$flush();
$section = ['intro' => $uid, 'cards' => []];
continue;
}
if ($ctype === 'vitec_card') {
if ($section === null) {
$section = ['intro' => null, 'cards' => []];
}
$section['cards'][] = $uid;
continue;
}
if ($ctype === self::CONTAINER) {
$pool[] = $uid;
$children = $content->fetchAllAssociative(
"SELECT uid FROM tt_content WHERE deleted = 0 AND hidden = 0 AND tx_container_parent = ? AND CType = 'vitec_card' ORDER BY colPos, sorting",
[$uid]
);
if ($children !== []) {
if ($section === null) {
$section = ['intro' => null, 'cards' => []];
}
foreach ($children as $child) {
$section['cards'][] = (int)$child['uid'];
}
}
continue;
}
$flush();
$order[] = ['el', $uid];
}
$flush();
// --- plan containers per section --------------------------------------------------
$datamap = [];
$newIndex = 0;
$newContainers = [];
$sequenceTop = [];
$cardAssignments = []; // containerId(string) => [cardUid,...] max 3
$hiddenContainers = 0;
foreach ($order as [$type, $value]) {
if ($type === 'el') {
$sequenceTop[] = (string)$value;
continue;
}
$sectionPlan = $value;
if ($sectionPlan['intro'] !== null) {
$sequenceTop[] = (string)$sectionPlan['intro'];
}
$cards = $sectionPlan['cards'];
$needed = (int)ceil(count($cards) / 3);
for ($i = 0; $i < $needed; $i++) {
$containerId = array_shift($pool);
if ($containerId !== null) {
$containerId = (string)$containerId;
} else {
$containerId = 'NEWGRID' . (++$newIndex);
$newContainers[$containerId] = ['pid' => $pageUid, 'CType' => self::CONTAINER, 'colPos' => self::COLPOS_MAIN];
}
$sequenceTop[] = $containerId;
$cardAssignments[$containerId] = array_slice($cards, $i * 3, 3);
}
$intro = $sectionPlan['intro'] !== null ? '#' . $sectionPlan['intro'] : '(no intro)';
$output->writeln(sprintf(' section %s: %d card(s) -> %d container(s)', $intro, count($cards), $needed));
}
foreach ($pool as $surplus) {
$datamap['tt_content'][(string)(int)$surplus]['hidden'] = 1;
$hiddenContainers++;
}
if ($hiddenContainers > 0) {
$output->writeln(' <comment>' . $hiddenContainers . ' surplus grid container(s) hidden</comment>');
}
$output->writeln(sprintf(' plan: %d new container(s), %d card(s) distributed', count($newContainers), array_sum(array_map('count', $cardAssignments))));
if (!$apply) {
$output->writeln("\nDRY RUN - nothing written. Add --apply to write.");
return Command::SUCCESS;
}
// pass A: create missing containers
$idMap = [];
if ($newContainers !== []) {
$dhA = GeneralUtility::makeInstance(DataHandler::class);
$dhA->start(['tt_content' => $newContainers], []);
$dhA->process_datamap();
if ($dhA->errorLog !== []) {
$output->writeln('<error>DataHandler containers: ' . implode(' | ', $dhA->errorLog) . '</error>');
return Command::FAILURE;
}
foreach ($newContainers as $newId => $fields) {
$idMap[$newId] = (int)($dhA->substNEWwithIDs[$newId] ?? 0);
if ($idMap[$newId] <= 0) {
$output->writeln('<error>Container ' . $newId . ' was not created - aborting before any move.</error>');
return Command::FAILURE;
}
}
}
$resolve = static fn($id): int => str_starts_with((string)$id, 'NEWGRID') ? (int)($idMap[(string)$id] ?? 0) : (int)$id;
// pass B: move the cards into the container columns
foreach ($cardAssignments as $containerId => $cards) {
$containerUid = $resolve($containerId);
if ($containerUid <= 0) {
continue;
}
foreach ($cards as $i => $cardUid) {
$datamap['tt_content'][(string)$cardUid] = [
'colPos' => self::COLS[$i % 3],
'tx_container_parent' => $containerUid,
];
}
}
if ($datamap !== []) {
try {
$dhB = GeneralUtility::makeInstance(DataHandler::class);
$dhB->start($datamap, []);
$dhB->process_datamap();
} catch (\Throwable $e) {
$output->writeln('<error>Move pass threw: ' . get_class($e) . ' - ' . $e->getMessage() . '</error>');
return Command::FAILURE;
}
if ($dhB->errorLog !== []) {
$output->writeln('<error>DataHandler cards: ' . implode(' | ', $dhB->errorLog) . '</error>');
}
}
// top-level order
$sorting = 0;
foreach ($sequenceTop as $id) {
$uid = $resolve($id);
if ($uid > 0) {
$sorting += 256;
$content->update('tt_content', ['sorting' => $sorting], ['uid' => $uid]);
}
}
// card sorting inside the columns (one card per column and container)
foreach ($cardAssignments as $containerId => $cards) {
foreach ($cards as $cardUid) {
$content->update('tt_content', ['sorting' => 256], ['uid' => (int)$cardUid]);
}
}
$left = (int)$content->fetchOne(
"SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND hidden = 0 AND pid = ? AND CType = 'vitec_card' AND tx_container_parent = 0 AND colPos = ?",
[$pageUid, self::COLPOS_MAIN]
);
if ($left > 0) {
$output->writeln('<error>' . $left . ' card(s) are STILL top-level - inspect with migrations/check_grid_447.php</error>');
return Command::FAILURE;
}
$output->writeln('<info>Done - every card sits in a grid container. Flush the frontend cache.</info>');
return Command::SUCCESS;
}
}