Files
VITEC-website/packages/vitec/Classes/Command/AttachCardImagesCommand.php
T
orascheandClaude Fable 5 b0690c4cdc 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>
2026-10-27 15:10:24 +01:00

211 lines
9.8 KiB
PHP

<?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\Resource\StorageRepository;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Attaches card images (field vitec_image) to the vitec_card elements of
* the /products, /insights, /support and /contact subtrees (the cards the
* 2026-10-27 workbook imports created as text_only; decision Olli).
*
* Per visible card (container children included - editors may arrange the
* cards into grid containers, like on /support), in colPos/sorting order:
* - primary_cta = /product/<slug> -> the record's first product photo,
* falling back to its hero image; a record without any image gets the
* generic grey placeholder (reported)
* - every other card -> the numbered placeholder
* fileadmin/placeholders/pages/<pid>-<NN>.png, NN counted from 10 in
* card order per page (slots 01-04 and 08 are taken by the page
* conventions; the number doubles as the image wish list to Joe) -
* a missing numbered file falls back to the generic grey placeholder
* and is reported for generation
*
* A card that already has a vitec_image reference is NEVER touched, but it
* still advances the numbering, so re-runs stay stable. Editors swap the
* placeholder for the real asset on the element.
*
* vendor/bin/typo3 vitec:attach-card-images
* vendor/bin/typo3 vitec:attach-card-images --apply
*/
#[AsCommand(
name: 'vitec:attach-card-images',
description: 'Attach product photos / numbered placeholders to the workbook cards (dry-run by default)'
)]
final class AttachCardImagesCommand extends Command
{
private const ROOTS = ['/products', '/insights', '/support', '/contact'];
private const FIRST_SLOT = 10;
protected function configure(): void
{
$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');
$pool = GeneralUtility::makeInstance(ConnectionPool::class);
$pages = $pool->getConnectionForTable('pages');
$content = $pool->getConnectionForTable('tt_content');
$refs = $pool->getConnectionForTable('sys_file_reference');
$storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage();
$pageUids = [];
foreach (self::ROOTS as $rootSlug) {
foreach ($pages->fetchAllAssociative(
"SELECT uid, slug FROM pages WHERE deleted = 0 AND hidden = 0 AND doktype = 1
AND (slug = ? OR slug LIKE ?) AND sys_language_uid IN (0,-1) ORDER BY slug",
[$rootSlug, $rootSlug . '/%']
) as $row) {
$pageUids[(int)$row['uid']] = (string)$row['slug'];
}
}
$output->writeln(($apply ? 'APPLY' : 'DRY-RUN') . ' · ' . count($pageUids) . ' page(s) in ' . implode(' ', self::ROOTS));
$datamap = [];
$newIndex = 0;
$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, vitec_layout_variant FROM tt_content
WHERE deleted = 0 AND hidden = 0 AND pid = ? AND CType = 'vitec_card'
ORDER BY colPos, sorting",
[$pageUid]
);
if ($cards === []) {
continue;
}
$output->writeln($slug . ' (#' . $pageUid . '): ' . count($cards) . ' card(s)');
$slot = self::FIRST_SLOT - 1;
foreach ($cards as $card) {
$cardUid = (int)$card['uid'];
$cta = (string)$card['vitec_primary_cta'];
$isProduct = (bool)preg_match('#^/product/([a-z0-9+-]+)$#', $cta, $matches);
if (!$isProduct) {
$slot++;
}
$existing = (int)$refs->fetchOne(
"SELECT COUNT(*) FROM sys_file_reference WHERE deleted = 0 AND tablenames = 'tt_content'
AND fieldname = 'vitec_image' AND uid_foreign = ?",
[$cardUid]
);
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;
$note = '';
if ($isProduct) {
$identifier = $this->productImageIdentifier($pool, $matches[1]);
if ($identifier !== null) {
$stats['photo']++;
$note = 'product photo';
} else {
$identifier = 'placeholders/placeholder-grey.png';
$stats['grey']++;
$note = 'record ' . $matches[1] . ' has NO image - generic grey';
}
} else {
$identifier = 'placeholders/pages/' . $pageUid . '-' . $slot . '.png';
if ($storage->hasFile($identifier)) {
$stats['placeholder']++;
$note = 'numbered placeholder';
} else {
$stats['missing'][] = $pageUid . '-' . $slot;
$identifier = 'placeholders/placeholder-grey.png';
$stats['grey']++;
$note = 'numbered file MISSING (generate ' . $pageUid . '-' . $slot . '.png) - generic grey';
}
}
if (!$storage->hasFile($identifier)) {
$output->writeln(' <comment>! #' . $cardUid . ' "' . $card['header'] . '": ' . $identifier . ' missing - skipped</comment>');
continue;
}
$output->writeln(sprintf(' + #%d "%s" <- %s (%s)', $cardUid, (string)$card['header'], basename($identifier), $note));
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,
'table_local' => 'sys_file',
'uid_local' => $file->getUid(),
'tablenames' => 'tt_content',
'uid_foreign' => $cardUid,
'fieldname' => 'vitec_image',
];
}
}
$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) {
$output->writeln('DRY RUN - nothing written. Add --apply to write.');
return Command::SUCCESS;
}
if ($datamap === []) {
$output->writeln('Nothing to attach.');
return Command::SUCCESS;
}
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start($datamap, []);
$dataHandler->process_datamap();
if ($dataHandler->errorLog !== []) {
$output->writeln('<error>DataHandler: ' . implode(' | ', $dataHandler->errorLog) . '</error>');
return Command::FAILURE;
}
$output->writeln('<info>Done. Flush the frontend cache.</info>');
return Command::SUCCESS;
}
/** First product photo, falling back to the hero image; null when the record has neither. */
private function productImageIdentifier(ConnectionPool $pool, string $slug): ?string
{
$product = $pool->getConnectionForTable('tx_vitec_domain_model_product')->fetchAssociative(
'SELECT uid FROM tx_vitec_domain_model_product WHERE deleted = 0 AND slug = ?', [$slug]
);
if (!$product) {
return null;
}
foreach (['productimage', 'heroimage'] as $fieldName) {
$identifier = $pool->getConnectionForTable('sys_file_reference')->fetchOne(
"SELECT f.identifier FROM sys_file_reference r JOIN sys_file f ON f.uid = r.uid_local
WHERE r.deleted = 0 AND r.hidden = 0 AND r.tablenames = 'tx_vitec_domain_model_product'
AND r.fieldname = ? AND r.uid_foreign = ?
ORDER BY r.sorting_foreign LIMIT 1",
[$fieldName, (int)$product['uid']]
);
if ($identifier) {
return ltrim((string)$identifier, '/');
}
}
return null;
}
}