Datapath stories, grid repairs, menu v2 pure, hero placeholders, fec test

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>
This commit is contained in:
orascheandClaude Fable 5 committed 2026-11-03 18:29:56 +01:00
1 parent 9211c8a395
commit 77661bf0b6
22 files changed
+2103 -63

No files matched your search

@@ -32,6 +32,12 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
* 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.
@@ -45,7 +51,7 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
)]
final class AttachCardImagesCommand extends Command
{
private const ROOTS = ['/products', '/insights', '/support', '/contact'];
private const ROOTS = ['/products', '/insights', '/support', '/contact', '/markets'];
private const FIRST_SLOT = 10;
protected function configure(): void
@@ -77,7 +83,7 @@ final class AttachCardImagesCommand extends Command
$datamap = [];
$newIndex = 0;
$stats = ['photo' => 0, 'placeholder' => 0, 'grey' => 0, 'kept' => 0, 'variant' => 0, 'missing' => []];
$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
@@ -92,17 +98,43 @@ final class AttachCardImagesCommand extends Command
$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++;
}
$existing = (int)$refs->fetchOne(
"SELECT COUNT(*) FROM sys_file_reference WHERE deleted = 0 AND tablenames = 'tt_content'
AND fieldname = 'vitec_image' AND uid_foreign = ?",
$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 ($existing > 0) {
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
@@ -115,6 +147,7 @@ final class AttachCardImagesCommand extends Command
}
$identifier = null;
$note = '';
$isMarketCard = false;
if ($isProduct) {
$identifier = $this->productImageIdentifier($pool, $matches[1]);
if ($identifier !== null) {
@@ -125,6 +158,13 @@ final class AttachCardImagesCommand extends Command
$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)) {
@@ -145,7 +185,9 @@ final class AttachCardImagesCommand extends Command
if (!$apply) {
continue;
}
if ((string)$card['vitec_layout_variant'] === 'text_only') {
// 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']++;
}
@@ -161,8 +203,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, %d variant flip(s) text_only->vertical%s",
$stats['photo'], $stats['placeholder'], $stats['grey'], $stats['kept'], $stats['variant'],
$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) {
@@ -184,6 +226,32 @@ final class AttachCardImagesCommand extends Command
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
{