Batch of 2026-10-28/29 plus 2026-11-03: - NEW vitec:import-datapath-stories: 22 story packages from the agency dump (docx + Links/ assets) as usecase records with content, quotes, images, customer logos, download cards; --repair-quotes normalizes attribution lines; asset backfill for existing stories - /products grid incident repaired (fix_products_grid, fix_graphics_grid, check_grid_page): containers unhidden, 46 duplicate cards soft-deleted, section order restored; BuildProductPortfolio and ImportProductCategoryPages now GUARD against re-runs on pages that already carry vitec_cols_% containers - ' double-encoding fixed: render-pipeline importers write literal apostrophes (ENT_NOQUOTES), fix_double_entities literalizes existing rows - ticket quick wins (ticket_quickwins_20261028), broadcast market page cleanup, AttachCardImages market-card handling, SubmarketWorkbook META REPAIR, story content/video fixes (fix_story_*) - ProvideProductCatalog: transitional v1 fields removed (categories uid/link, legacyGroupFields, item products teaser) - pure v2 shape (ticket 0024-FE); search renderer keeps its teaser - BuildProductOverviews: heroes get the numbered placeholder background (placeholders/pages/<uid>-01.png, wide grey fallback) - backfill on existing pages with empty hero_bgimage, attached at creation for new pages; existing references always win (ticket 0026-FE); 40 placeholder PNGs shipped to fileadmin (not tracked) - AttachPageMedia: --depth=3 with sysfolders as pass-through parents for the overview pages' -08 menu tiles - NEW migrations/fec_isolation_test.sh: two-user live test proving fe_comment ticket isolation (backend verified tight, cross-user sighting traced to the FE page-JSON cache, ticket 0027-BE) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
279 lines
14 KiB
PHP
279 lines
14 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
|
|
*
|
|
* Market pages (0010-FE): an "Explore <Market>" card whose primary_cta
|
|
* points to a /markets/... page gets that TARGET page's first media file
|
|
* (mirrors the marketlist cards); these cards keep their text_only
|
|
* variant (the FE styles them as market cards). Flipcards are skipped
|
|
* entirely - they carry icons, never photos.
|
|
*
|
|
* 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', '/markets'];
|
|
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, 'pagemedia' => 0, 'placeholder' => 0, 'grey' => 0, 'kept' => 0, 'variant' => 0, 'upgraded' => 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'];
|
|
if ((string)$card['vitec_layout_variant'] === 'flipcard') {
|
|
continue; // flipcards carry icons (vitec_icon), never photos
|
|
}
|
|
$cta = (string)$card['vitec_primary_cta'];
|
|
$isProduct = (bool)preg_match('#^/product/([a-z0-9+-]+)$#', $cta, $matches);
|
|
if (!$isProduct) {
|
|
$slot++;
|
|
}
|
|
$existingRef = $refs->fetchAssociative(
|
|
"SELECT r.uid, r.uid_local, f.identifier FROM sys_file_reference r
|
|
JOIN sys_file f ON f.uid = r.uid_local
|
|
WHERE r.deleted = 0 AND r.tablenames = 'tt_content'
|
|
AND r.fieldname = 'vitec_image' AND r.uid_foreign = ?
|
|
ORDER BY r.sorting_foreign LIMIT 1",
|
|
[$cardUid]
|
|
);
|
|
if ($existingRef) {
|
|
// upgrade pass (2026-10-28): a product card that got the
|
|
// generic grey because its record had no image swaps to
|
|
// the real photo once the delivery arrived
|
|
if ($isProduct && str_ends_with((string)$existingRef['identifier'], 'placeholder-grey.png')) {
|
|
$photo = $this->productImageIdentifier($pool, $matches[1]);
|
|
if ($photo !== null && $storage->hasFile($photo)) {
|
|
$output->writeln(sprintf(' ^ #%d "%s": grau -> %s', $cardUid, (string)$card['header'], basename($photo)));
|
|
$stats['upgraded']++;
|
|
if ($apply) {
|
|
$datamap['sys_file_reference'][(string)(int)$existingRef['uid']] = [
|
|
'uid_local' => $storage->getFile($photo)->getUid(),
|
|
];
|
|
}
|
|
if ((string)$card['vitec_layout_variant'] === 'text_only') {
|
|
$datamap['tt_content'][(string)$cardUid]['vitec_layout_variant'] = 'vertical';
|
|
$stats['variant']++;
|
|
}
|
|
continue;
|
|
}
|
|
}
|
|
$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 = '';
|
|
$isMarketCard = false;
|
|
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';
|
|
}
|
|
} elseif (($pageMedia = $this->marketPageMediaIdentifier($pool, $cta)) !== null) {
|
|
// 0010-FE: mirror the marketlist cards, which show the
|
|
// target sub-market page's media
|
|
$identifier = $pageMedia;
|
|
$isMarketCard = true;
|
|
$stats['pagemedia']++;
|
|
$note = 'target page media';
|
|
} 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;
|
|
}
|
|
// market cards keep text_only - the FE keys its market-card
|
|
// styling on that variant (0010-FE) and renders the image anyway
|
|
if (!$isMarketCard && (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 target page media, %d numbered placeholder(s), %d generic grey, %d card(s) already had an image, %d grey->photo upgrade(s), %d variant flip(s) text_only->vertical%s",
|
|
$stats['photo'], $stats['pagemedia'], $stats['placeholder'], $stats['grey'], $stats['kept'], $stats['upgraded'], $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 media file of the /markets/... page the card links to (plain
|
|
* slug or t3://page?uid=N); null when the target is no market page or
|
|
* has no media.
|
|
*/
|
|
private function marketPageMediaIdentifier(ConnectionPool $pool, string $cta): ?string
|
|
{
|
|
$pages = $pool->getConnectionForTable('pages');
|
|
$page = null;
|
|
if (preg_match('#^t3://page\?uid=(\d+)#', $cta, $m)) {
|
|
$page = $pages->fetchAssociative('SELECT uid, slug FROM pages WHERE deleted = 0 AND uid = ?', [(int)$m[1]]);
|
|
} elseif (preg_match('#^/markets/[a-z0-9+/-]+$#', $cta)) {
|
|
$page = $pages->fetchAssociative('SELECT uid, slug FROM pages WHERE deleted = 0 AND slug = ? AND sys_language_uid IN (0,-1)', [$cta]);
|
|
}
|
|
if (!$page || !str_starts_with((string)$page['slug'], '/markets/')) {
|
|
return null;
|
|
}
|
|
$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 = 'pages'
|
|
AND r.fieldname = 'media' AND r.uid_foreign = ?
|
|
ORDER BY r.sorting_foreign LIMIT 1",
|
|
[(int)$page['uid']]
|
|
);
|
|
return $identifier ? ltrim((string)$identifier, '/') : null;
|
|
}
|
|
/** 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;
|
|
}
|
|
}
|