diff --git a/fix-asset-perms.sh b/fix-asset-perms.sh
index ad2a0f4..7d59fa9 100755
--- a/fix-asset-perms.sh
+++ b/fix-asset-perms.sh
@@ -20,7 +20,8 @@ echo "[perms] Project root: $(pwd)"
# importer output lands there (success_stories_import, blog-import, downloads,
# user_upload/products ...) and hits the same 403 otherwise. Found 2026-09-11,
# when the imported success-story images stayed invisible in the megamenu.
-TARGETS="public/_assets public/fileadmin packages/vitec/Resources/Public"
+# public/product-audit: generated report page (build_product_audit.php), same 403 trap
+TARGETS="public/_assets public/fileadmin public/product-audit packages/vitec/Resources/Public"
echo "[perms] Setting files 644 / directories 755 under $TARGETS ..."
find $TARGETS -type f -exec chmod 644 {} \; 2>/dev/null
diff --git a/migrations/check_grid_447.php b/migrations/check_grid_447.php
new file mode 100644
index 0000000..19d24d2
--- /dev/null
+++ b/migrations/check_grid_447.php
@@ -0,0 +1,41 @@
+ PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
+);
+
+echo "=== all vitec_card / vitec_cols_33_33_33 / vitec_introparagraph on pid 447 ===\n";
+$rows = $pdo->query(
+ "SELECT uid, CType, LEFT(header,32) h, colPos, tx_container_parent par, sorting, hidden, deleted
+ FROM tt_content WHERE pid = 447 AND CType IN ('vitec_card','vitec_cols_33_33_33','vitec_introparagraph')
+ ORDER BY deleted, colPos, sorting, uid"
+)->fetchAll();
+foreach ($rows as $r) {
+ printf(" #%-6d %-22s col=%-4d par=%-6d sort=%-6d hid=%d del=%d %s\n",
+ $r['uid'], $r['CType'], $r['colPos'], $r['par'], $r['sorting'], $r['hidden'], $r['deleted'], $r['h']);
+}
+echo "\ncounts: " . count($rows) . " rows\n";
diff --git a/packages/vitec/Classes/Command/AttachCardImagesCommand.php b/packages/vitec/Classes/Command/AttachCardImagesCommand.php
index 0b6a2c8..9902e0c 100644
--- a/packages/vitec/Classes/Command/AttachCardImagesCommand.php
+++ b/packages/vitec/Classes/Command/AttachCardImagesCommand.php
@@ -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) {
diff --git a/packages/vitec/Classes/Command/WrapCardsInGridCommand.php b/packages/vitec/Classes/Command/WrapCardsInGridCommand.php
new file mode 100644
index 0000000..6753fd0
--- /dev/null
+++ b/packages/vitec/Classes/Command/WrapCardsInGridCommand.php
@@ -0,0 +1,243 @@
+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('Page ' . $slug . ' not found.');
+ 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(' ' . $hiddenContainers . ' surplus grid container(s) hidden');
+ }
+ $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('DataHandler containers: ' . implode(' | ', $dhA->errorLog) . '');
+ return Command::FAILURE;
+ }
+ foreach ($newContainers as $newId => $fields) {
+ $idMap[$newId] = (int)($dhA->substNEWwithIDs[$newId] ?? 0);
+ if ($idMap[$newId] <= 0) {
+ $output->writeln('Container ' . $newId . ' was not created - aborting before any move.');
+ 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('Move pass threw: ' . get_class($e) . ' - ' . $e->getMessage() . '');
+ return Command::FAILURE;
+ }
+ if ($dhB->errorLog !== []) {
+ $output->writeln('DataHandler cards: ' . implode(' | ', $dhB->errorLog) . '');
+ }
+ }
+ // 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('' . $left . ' card(s) are STILL top-level - inspect with migrations/check_grid_447.php');
+ return Command::FAILURE;
+ }
+ $output->writeln('Done - every card sits in a grid container. Flush the frontend cache.');
+ return Command::SUCCESS;
+ }
+}