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:
1 parent
c8d653b39b
commit
b0690c4cdc
4 files changed
+301
-5
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user