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:
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
|
||||
{
|
||||
|
||||
@@ -31,10 +31,13 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
* touched - editors swap the image in the page properties.
|
||||
*
|
||||
* --depth=2 targets the grandchildren (e.g. the sub-markets below
|
||||
* /markets/<market>) instead of the children.
|
||||
* /markets/<market>) instead of the children; --depth=3 reaches through two
|
||||
* levels (sysfolders pass as intermediate parents, so the product overview
|
||||
* pages below /products > category folder > group folder are covered).
|
||||
*
|
||||
* vendor/bin/typo3 vitec:attach-page-media --root=/support
|
||||
* vendor/bin/typo3 vitec:attach-page-media --root=/markets --depth=2 --apply
|
||||
* vendor/bin/typo3 vitec:attach-page-media --root=/products --depth=3 --apply
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'vitec:attach-page-media',
|
||||
@@ -45,7 +48,7 @@ final class AttachPageMediaCommand extends Command
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('root', null, InputOption::VALUE_REQUIRED, 'Slug of the root page, e.g. /support');
|
||||
$this->addOption('depth', null, InputOption::VALUE_REQUIRED, '1 = children of the root, 2 = their children', '1');
|
||||
$this->addOption('depth', null, InputOption::VALUE_REQUIRED, '1 = children of the root, 2 = their children, 3 = one level further', '1');
|
||||
$this->addOption('apply', null, InputOption::VALUE_NONE, 'Write changes (default: dry run)');
|
||||
}
|
||||
|
||||
@@ -62,11 +65,14 @@ final class AttachPageMediaCommand extends Command
|
||||
$output->writeln('<error>Root page ' . $rootSlug . ' not found.</error>');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$depth = max(1, min(2, (int)$input->getOption('depth')));
|
||||
$depth = max(1, min(3, (int)$input->getOption('depth')));
|
||||
$parents = [(int)$root['uid']];
|
||||
for ($level = 1; $level <= $depth; $level++) {
|
||||
// intermediate levels pass through sysfolders (254); only real
|
||||
// pages (doktype 1) are targets on the final level
|
||||
$doktypes = $level < $depth ? '1, 254' : '1';
|
||||
$children = $pages->fetchAllAssociative(
|
||||
'SELECT uid, pid, title FROM pages WHERE deleted = 0 AND hidden = 0 AND doktype = 1 AND pid IN (' . implode(',', $parents ?: [0]) . ') AND sys_language_uid IN (0,-1) ORDER BY pid, sorting'
|
||||
'SELECT uid, pid, title, doktype FROM pages WHERE deleted = 0 AND hidden = 0 AND doktype IN (' . $doktypes . ') AND pid IN (' . implode(',', $parents ?: [0]) . ') AND sys_language_uid IN (0,-1) ORDER BY pid, sorting'
|
||||
);
|
||||
$parents = array_map('intval', array_column($children, 'uid'));
|
||||
}
|
||||
|
||||
@@ -888,13 +888,13 @@ final class BuildContentPagesCommand extends Command
|
||||
private function p(string $text): string
|
||||
{
|
||||
$text = trim($text);
|
||||
return $text === '' ? '' : '<p>' . htmlspecialchars($text, ENT_QUOTES | ENT_HTML5) . '</p>';
|
||||
return $text === '' ? '' : '<p>' . htmlspecialchars($text, ENT_NOQUOTES | ENT_HTML5) . '</p>';
|
||||
}
|
||||
|
||||
/** @param string[] $items */
|
||||
private function ul(array $items): string
|
||||
{
|
||||
$li = array_map(fn(string $item): string => '<li>' . htmlspecialchars($item, ENT_QUOTES | ENT_HTML5) . '</li>', $items);
|
||||
$li = array_map(fn(string $item): string => '<li>' . htmlspecialchars($item, ENT_NOQUOTES | ENT_HTML5) . '</li>', $items);
|
||||
return '<ul>' . implode('', $li) . '</ul>';
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ 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;
|
||||
|
||||
/**
|
||||
@@ -29,9 +30,11 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
* Catalog reads it): editors rename, reorder or move overview pages there.
|
||||
* A and B are sysfolders - never routed, never in navigation menus. The 20
|
||||
* overview pages get flat slugs (decision Olli 2026-11-02) and, when they
|
||||
* are new or still empty, generated content: hero (page title), one intro
|
||||
* paragraph per grouping title (or "Product range"), a product card per
|
||||
* sub-product (record teaser, link /product/<slug>), contact pre-footer.
|
||||
* are new or still empty, generated content: hero (page title, placeholder
|
||||
* background - 0026-FE), one intro paragraph per grouping title (or
|
||||
* "Product range"), a product card per sub-product (record teaser, link
|
||||
* /product/<slug>), contact pre-footer. Existing pages whose hero carries
|
||||
* no image get the placeholder backfilled; any existing reference wins.
|
||||
* Afterwards run vitec:wrap-cards-grid per page and vitec:attach-card-images.
|
||||
*
|
||||
* Decisions without customer confirmation (Olli: proceed, nothing deleted):
|
||||
@@ -175,6 +178,7 @@ final class BuildProductOverviewsCommand extends Command
|
||||
/** @var string[] */
|
||||
private array $report = [];
|
||||
private int $newCounter = 0;
|
||||
private bool $apply = false;
|
||||
/** @var array<int,array{uid:int,title:string,slug:string,teaser:string}>|null */
|
||||
private ?array $products = null;
|
||||
|
||||
@@ -186,7 +190,7 @@ final class BuildProductOverviewsCommand extends Command
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
Bootstrap::initializeBackendAuthentication();
|
||||
$apply = (bool)$input->getOption('apply');
|
||||
$apply = $this->apply = (bool)$input->getOption('apply');
|
||||
$pages = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages');
|
||||
$root = $pages->fetchAssociative("SELECT uid FROM pages WHERE deleted = 0 AND slug = '/products' AND sys_language_uid IN (0,-1)");
|
||||
if (!$root) {
|
||||
@@ -309,6 +313,8 @@ final class BuildProductOverviewsCommand extends Command
|
||||
$contentCount > 0 ? ' - content left untouched' : ' - content generated');
|
||||
if ($contentCount === 0) {
|
||||
$this->content($pageId, $entry, $cards);
|
||||
} else {
|
||||
$this->backfillHeroImage((int)$pageId);
|
||||
}
|
||||
} else {
|
||||
$pageId = $this->newId();
|
||||
@@ -373,10 +379,29 @@ final class BuildProductOverviewsCommand extends Command
|
||||
*/
|
||||
private function content(string $pageId, array $entry, array $cards): void
|
||||
{
|
||||
$this->element($pageId, self::COLPOS_HERO, 'vitec_herosection', [
|
||||
$heroId = $this->element($pageId, self::COLPOS_HERO, 'vitec_herosection', [
|
||||
'header' => (string)$entry['page'], 'header_layout' => 1,
|
||||
'bodytext' => isset($entry['desc']) ? $this->p(ucfirst((string)$entry['desc'])) : '',
|
||||
]);
|
||||
// no overview page goes live with an empty fullscreen hero (0026-FE):
|
||||
// the numbered placeholder when the page already has a uid, else the
|
||||
// generic wide grey one (swap the real asset in later)
|
||||
$placeholder = $this->heroPlaceholder($pageId);
|
||||
if ($placeholder === null) {
|
||||
$this->report[] = ' ! hero: no placeholder file in fileadmin - left empty';
|
||||
} elseif ($this->apply) {
|
||||
$refId = $this->newId();
|
||||
$this->datamap['sys_file_reference'][$refId] = [
|
||||
'pid' => $this->pid($pageId),
|
||||
'table_local' => 'sys_file',
|
||||
'uid_local' => GeneralUtility::makeInstance(StorageRepository::class)
|
||||
->getDefaultStorage()->getFile($placeholder)->getUid(),
|
||||
];
|
||||
$this->datamap['tt_content'][$heroId]['hero_bgimage'] = $refId;
|
||||
$this->report[] = ' hero: attach ' . $placeholder;
|
||||
} else {
|
||||
$this->report[] = ' hero: would attach ' . $placeholder;
|
||||
}
|
||||
if (!isset($cards[0]['group'])) {
|
||||
$this->element($pageId, self::COLPOS_MAIN, 'vitec_introparagraph', ['header' => 'Product range', 'bodytext' => '']);
|
||||
}
|
||||
@@ -412,11 +437,72 @@ final class BuildProductOverviewsCommand extends Command
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $fields */
|
||||
private function element(string $pageId, int $colPos, string $ctype, array $fields): void
|
||||
private function element(string $pageId, int $colPos, string $ctype, array $fields): string
|
||||
{
|
||||
$id = $this->newId();
|
||||
$this->datamap['tt_content'][$id] = ['pid' => $this->pid($pageId), 'colPos' => $colPos, 'CType' => $ctype] + $fields;
|
||||
$this->contentOrder[$pageId][$colPos][] = $id;
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches the placeholder hero background to an EXISTING overview page
|
||||
* whose hero has no image yet (0026-FE: all 20 pages rendered an empty
|
||||
* fullscreen hero). A hero that carries ANY reference is never touched -
|
||||
* editors swap images in the element.
|
||||
*/
|
||||
private function backfillHeroImage(int $pageUid): void
|
||||
{
|
||||
$pool = GeneralUtility::makeInstance(ConnectionPool::class);
|
||||
$heroUid = (int)$pool->getConnectionForTable('tt_content')->fetchOne(
|
||||
"SELECT uid FROM tt_content WHERE deleted = 0 AND pid = ? AND CType = 'vitec_herosection' ORDER BY colPos, sorting LIMIT 1",
|
||||
[$pageUid]
|
||||
);
|
||||
if ($heroUid === 0) {
|
||||
return;
|
||||
}
|
||||
$existing = (int)$pool->getConnectionForTable('sys_file_reference')->fetchOne(
|
||||
"SELECT COUNT(*) FROM sys_file_reference WHERE deleted = 0 AND tablenames = 'tt_content' AND fieldname = 'hero_bgimage' AND uid_foreign = ?",
|
||||
[$heroUid]
|
||||
);
|
||||
if ($existing > 0) {
|
||||
return;
|
||||
}
|
||||
$placeholder = $this->heroPlaceholder((string)$pageUid);
|
||||
if ($placeholder === null) {
|
||||
$this->report[] = ' ! hero #' . $heroUid . ': empty and no placeholder file - left empty';
|
||||
return;
|
||||
}
|
||||
if (!$this->apply) {
|
||||
$this->report[] = ' hero #' . $heroUid . ': empty, would attach ' . $placeholder;
|
||||
return;
|
||||
}
|
||||
$refId = $this->newId();
|
||||
$this->datamap['sys_file_reference'][$refId] = [
|
||||
'pid' => $pageUid,
|
||||
'table_local' => 'sys_file',
|
||||
'uid_local' => GeneralUtility::makeInstance(StorageRepository::class)
|
||||
->getDefaultStorage()->getFile($placeholder)->getUid(),
|
||||
'tablenames' => 'tt_content',
|
||||
'uid_foreign' => $heroUid,
|
||||
'fieldname' => 'hero_bgimage',
|
||||
];
|
||||
$this->datamap['tt_content'][$heroUid]['hero_bgimage'] = 1;
|
||||
$this->report[] = ' hero #' . $heroUid . ': attach ' . $placeholder;
|
||||
}
|
||||
|
||||
/** The hero placeholder identifier for a page: numbered when available, generic wide grey otherwise, null when neither file exists. */
|
||||
private function heroPlaceholder(string $pageId): ?string
|
||||
{
|
||||
$storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage();
|
||||
$candidates = ctype_digit($pageId) ? ['placeholders/pages/' . $pageId . '-01.png'] : [];
|
||||
$candidates[] = 'placeholders/placeholder-grey-wide.png';
|
||||
foreach ($candidates as $identifier) {
|
||||
if ($storage->hasFile($identifier)) {
|
||||
return $identifier;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---- lookups -----------------------------------------------------------------------
|
||||
|
||||
@@ -79,6 +79,24 @@ final class BuildProductPortfolioCommand extends Command
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$pageUid = (int)$root['uid'];
|
||||
|
||||
// Guard (2026-10-28, Ticket 0014-FE): nach vitec:wrap-cards-in-grid
|
||||
// stecken die Karten IN vitec_cols-Containern (colPos 221-223), wo
|
||||
// der Slot-Cursor sie nicht sieht - ein Re-Run hat deshalb die 15
|
||||
// Container versteckt und 38 Duplikat-Karten erzeugt (Repair:
|
||||
// migrations/fix_products_grid.php). Auf einer gewickelten Seite
|
||||
// darf dieser Builder nicht mehr laufen.
|
||||
$containerCount = (int)GeneralUtility::makeInstance(ConnectionPool::class)
|
||||
->getConnectionForTable('tt_content')->fetchOne(
|
||||
"SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND pid = ? AND CType LIKE 'vitec_cols_%'",
|
||||
[$pageUid]
|
||||
);
|
||||
if ($containerCount > 0) {
|
||||
$output->writeln('<error>/products ist grid-gewickelt (' . $containerCount
|
||||
. ' vitec_cols-Container). Ein Re-Run wuerde Karten duplizieren und die Grids verstecken - Abbruch.'
|
||||
. ' Fuer Einzelaenderungen die Karten im BE pflegen oder erst die Grids aufloesen.</error>');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
// the page sits on layout 0 (Default, main only) - the portfolio
|
||||
// needs hero/main/preFooter: 1 "Index Page" (diagnosis 2026-10-27:
|
||||
// the page's own content was all deleted, the rendered payload came
|
||||
@@ -391,7 +409,9 @@ final class BuildProductPortfolioCommand extends Command
|
||||
private function p(string $text): string
|
||||
{
|
||||
$text = trim($text);
|
||||
return $text === '' ? '' : '<p>' . htmlspecialchars($text, ENT_QUOTES | ENT_HTML5) . '</p>';
|
||||
// ENT_NOQUOTES (2026-10-28, Ticket 0014-FE): ENT_QUOTES machte ' zu ',
|
||||
// und die RTE-Persistenz escapte das & gleich nochmal (&apos; im JSON)
|
||||
return $text === '' ? '' : '<p>' . htmlspecialchars($text, ENT_NOQUOTES | ENT_HTML5) . '</p>';
|
||||
}
|
||||
|
||||
private function norm(string $value): string
|
||||
|
||||
@@ -30,7 +30,8 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
* the Dropbox folder names, so a vitec:import-product-images re-run attaches
|
||||
* the delivered banner/carousel assets immediately.
|
||||
*
|
||||
* IQS4 stays out on purpose: its delivery folder is empty - question to Joe.
|
||||
* 2026-10-28: IQS4 and VisionSC-SDI4 added - the fresh Dropbox dump
|
||||
* delivered their banners, and both datasheets already sit in the pool.
|
||||
*
|
||||
* vendor/bin/typo3 vitec:create-missing-products
|
||||
* vendor/bin/typo3 vitec:create-missing-products --apply
|
||||
@@ -53,6 +54,11 @@ final class CreateMissingProductsCommand extends Command
|
||||
'Rugged portable HEVC/H.264 encoder for demanding field deployments.'],
|
||||
['X-View Integrated Displays', 'x-view-integrated-displays', 'x-player-end-points-xp23-xp25-xp26',
|
||||
'Commercial displays with an integrated IPTV end-point for TV and signage delivery.'],
|
||||
// 2026-10-28 (delivery brought banners; datasheets already in the pool)
|
||||
['IQS4', 'iqs4', 'image-4k',
|
||||
'Distribute a single 4K source across multiple displays with a compact 4K splitter.'],
|
||||
['VisionSC-SDI4', 'visionsc-sdi4', 'visionsc-s2',
|
||||
'Four-channel SDI capture card for professional video and visualization workflows.'],
|
||||
];
|
||||
|
||||
protected function configure(): void
|
||||
|
||||
@@ -0,0 +1,753 @@
|
||||
<?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\Enum\DuplicationBehavior;
|
||||
use TYPO3\CMS\Core\Resource\StorageRepository;
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
|
||||
/**
|
||||
* Imports the 22 Datapath success stories from the 2026-10-28 Dropbox
|
||||
* delivery (staged by the session into migrations/datapath_stories_in/
|
||||
* <slug>/{story.docx, story.pdf, images/*}) into the usecase model.
|
||||
*
|
||||
* The docx files share one structure (verified on Edinburgh / Woori):
|
||||
* 1st paragraph -> title
|
||||
* 2nd paragraph -> subtitle (one-liner)
|
||||
* 1st IntenseQuote -> record quote, the two short lines after it
|
||||
* quote_person / quote_role
|
||||
* known section names -> text elements (Background, The Challenge,
|
||||
* The Solution, The Result, ... - plain
|
||||
* paragraphs, no heading styles in the files)
|
||||
* ListParagraph runs -> <ul> inside the section body
|
||||
* further IntenseQuote -> vitec_quotation elements; attribution from
|
||||
* the preceding "... said:" line or the short
|
||||
* "Name, Role" line after the quote
|
||||
*
|
||||
* Per story: usecase record (pid/detail_page from the existing records),
|
||||
* hero_bgimage + card_image from the first staged image, remaining images
|
||||
* (max 4) as image elements, the staged Collateral pdf as a NEW download
|
||||
* record plus a vitec_downloadcard (layout story_download, header
|
||||
* Downloads) - the same shape the migrated vitec.com stories have after
|
||||
* the 2026-10-28 plugin conversion. Files land in
|
||||
* fileadmin/success_stories_import/datapath/<slug>/.
|
||||
*
|
||||
* Idempotent: a story whose normalised title already exists gets an
|
||||
* asset-only backfill (logo/hero/card/images where missing) instead.
|
||||
* Markets stay unassigned on purpose (editorial; reported).
|
||||
*
|
||||
* vendor/bin/typo3 vitec:import-datapath-stories [--only=woori] [--apply]
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'vitec:import-datapath-stories',
|
||||
description: 'Import the Datapath success-story delivery (docx + images + pdf) into the usecase model (dry-run by default)'
|
||||
)]
|
||||
final class ImportDatapathStoriesCommand extends Command
|
||||
{
|
||||
private const TABLE = 'tx_vitec_domain_model_usecase';
|
||||
private const DOWNLOAD_TABLE = 'tx_vitec_domain_model_download';
|
||||
private const COLPOS = 999;
|
||||
private const MAX_IMAGE_ELEMENTS = 4;
|
||||
private const FILES_TARGET = 'success_stories_import/datapath';
|
||||
|
||||
private const SECTION_NAMES = [
|
||||
'background', 'the challenge', 'challenge', 'the solution', 'solution',
|
||||
'the result', 'result', 'the results', 'results', 'future plans',
|
||||
'future expansion', 'the future', 'key features', 'benefits',
|
||||
'the benefits', 'conclusion', 'summary',
|
||||
];
|
||||
|
||||
private int $newCounter = 0;
|
||||
private bool $repairQuotes = false;
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('dir', null, InputOption::VALUE_REQUIRED, 'Inbox directory', 'migrations/datapath_stories_in');
|
||||
$this->addOption('only', null, InputOption::VALUE_REQUIRED, 'Folder-name substring filter', '');
|
||||
$this->addOption('apply', null, InputOption::VALUE_NONE, 'Write changes (default: dry run)');
|
||||
$this->addOption('repair-quotes', null, InputOption::VALUE_NONE, 'Repair pass 0017-FE: move lead attribution into quote_person/quote_role, delete the bogus quotation elements, strip "Updated on" remnants');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
Bootstrap::initializeBackendAuthentication();
|
||||
$apply = (bool)$input->getOption('apply');
|
||||
$this->repairQuotes = (bool)$input->getOption('repair-quotes');
|
||||
$only = mb_strtolower((string)$input->getOption('only'));
|
||||
$dir = rtrim((string)$input->getOption('dir'), '/');
|
||||
$pool = GeneralUtility::makeInstance(ConnectionPool::class);
|
||||
|
||||
$existing = [];
|
||||
$pidCounts = [];
|
||||
$detailCounts = [];
|
||||
foreach ($pool->getConnectionForTable(self::TABLE)->fetchAllAssociative(
|
||||
'SELECT uid, pid, title, detail_page FROM ' . self::TABLE . ' WHERE deleted = 0'
|
||||
) as $row) {
|
||||
$existing[$this->norm((string)$row['title'])] = (int)$row['uid'];
|
||||
$pidCounts[(int)$row['pid']] = ($pidCounts[(int)$row['pid']] ?? 0) + 1;
|
||||
if ((int)$row['detail_page'] > 0) {
|
||||
$detailCounts[(int)$row['detail_page']] = ($detailCounts[(int)$row['detail_page']] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
arsort($pidCounts);
|
||||
arsort($detailCounts);
|
||||
$storagePid = (int)(array_key_first($pidCounts) ?? 0);
|
||||
$detailPage = (int)(array_key_first($detailCounts) ?? 0);
|
||||
|
||||
$downloadPids = array_count_values(array_map('intval', $pool->getConnectionForTable(self::DOWNLOAD_TABLE)
|
||||
->fetchFirstColumn('SELECT pid FROM ' . self::DOWNLOAD_TABLE . ' WHERE deleted = 0')));
|
||||
arsort($downloadPids);
|
||||
$downloadPid = (int)(array_key_first($downloadPids) ?? $storagePid);
|
||||
|
||||
$folders = array_filter(glob($dir . '/*', GLOB_ONLYDIR) ?: [], static fn(string $f): bool =>
|
||||
is_file($f . '/story.docx') && ($only === '' || str_contains(mb_strtolower(basename($f)), $only)));
|
||||
$output->writeln(sprintf('%s · %d story folder(s) · storage pid %d · detail page %d · download pid %d',
|
||||
$apply ? 'APPLY' : 'DRY-RUN', count($folders), $storagePid, $detailPage, $downloadPid));
|
||||
|
||||
$created = 0;
|
||||
foreach ($folders as $folder) {
|
||||
try {
|
||||
$created += $this->importOne($folder, $existing, $storagePid, $detailPage, $downloadPid, $apply, $output) ? 1 : 0;
|
||||
} catch (\Throwable $e) {
|
||||
$output->writeln('<error> ' . basename($folder) . ': ' . get_class($e) . ' - ' . $e->getMessage() . '</error>');
|
||||
}
|
||||
}
|
||||
// --- repair pass: story downloadcards whose FlexForm still carries a
|
||||
// literal NEW id (first import wrote the placeholder unsubstituted) -
|
||||
// resolve via "<story title> Success Story" and write the real uid
|
||||
$content = $pool->getConnectionForTable('tt_content');
|
||||
$downloadByNorm = [];
|
||||
foreach ($pool->getConnectionForTable(self::DOWNLOAD_TABLE)->fetchAllAssociative(
|
||||
'SELECT uid, title FROM ' . self::DOWNLOAD_TABLE . ' WHERE deleted = 0'
|
||||
) as $download) {
|
||||
$downloadByNorm[$this->norm((string)$download['title'])] = (int)$download['uid'];
|
||||
}
|
||||
$repairMap = [];
|
||||
foreach ($content->fetchAllAssociative(
|
||||
"SELECT c.uid, u.title FROM tt_content c
|
||||
JOIN " . self::TABLE . " u ON u.uid = c.tx_vitec_usecase_content AND u.deleted = 0
|
||||
WHERE c.deleted = 0 AND c.CType = 'vitec_downloadcard' AND c.pi_flexform LIKE '%NEWDS%'"
|
||||
) as $card) {
|
||||
$target = $downloadByNorm[$this->norm((string)$card['title'] . ' success story')] ?? 0;
|
||||
if ($target > 0) {
|
||||
$repairMap['tt_content'][(string)(int)$card['uid']]['pi_flexform'] =
|
||||
['data' => ['sDEF' => ['lDEF' => ['settings.download' => ['vDEF' => (string)$target]]]]];
|
||||
$output->writeln(' ~ REPAIR card #' . $card['uid'] . ' ("' . $card['title'] . '") -> download #' . $target);
|
||||
} else {
|
||||
$output->writeln(' <comment>! REPAIR card #' . $card['uid'] . ' ("' . $card['title'] . '"): kein Download gefunden</comment>');
|
||||
}
|
||||
}
|
||||
if ($apply && $repairMap !== []) {
|
||||
$repairHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||
$repairHandler->start($repairMap, []);
|
||||
$repairHandler->process_datamap();
|
||||
}
|
||||
|
||||
$output->writeln($apply
|
||||
? "<info>$created story record(s) created. Flush the frontend cache; assign markets editorially; re-run the sub-solution import for the testimonials.</info>"
|
||||
: "\nDRY RUN - nothing written. Add --apply to write.");
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/** @param array<string,int> $existing */
|
||||
private function importOne(string $folder, array $existing, int $storagePid, int $detailPage, int $downloadPid, bool $apply, OutputInterface $output): bool
|
||||
{
|
||||
$slug = basename($folder);
|
||||
$doc = $this->parseDocx($folder . '/story.docx');
|
||||
$title = $doc['title'];
|
||||
if ($title === '') {
|
||||
$output->writeln('<comment> ' . $slug . ': kein Titel im docx - uebersprungen</comment>');
|
||||
return false;
|
||||
}
|
||||
if (isset($existing[$this->norm($title)])) {
|
||||
if ($this->repairQuotes) {
|
||||
$this->repairStoryQuotes($existing[$this->norm($title)], $slug, $doc, $apply, $output);
|
||||
} else {
|
||||
$this->backfillAssets($existing[$this->norm($title)], $slug, $folder, $storagePid, $apply, $output);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
$images = array_values(array_filter(glob($folder . '/images/*') ?: [], 'is_file'));
|
||||
$pdf = is_file($folder . '/story.pdf') ? $folder . '/story.pdf' : null;
|
||||
$output->writeln(sprintf('+ "%s" (%d Sektion(en), %d Zitat(e)%s, %d Bild(er), %s)',
|
||||
$title, count($doc['sections']), count($doc['quotes']),
|
||||
$doc['lead']['quote'] !== '' ? ' + Lead-Quote' : '',
|
||||
count($images), $pdf ? 'PDF' : 'KEIN PDF'));
|
||||
foreach ($doc['report'] as $line) {
|
||||
$output->writeln(' ' . $line);
|
||||
}
|
||||
if (!$apply) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage();
|
||||
$targetFolder = $this->ensureFolder($storage, self::FILES_TARGET . '/' . $slug);
|
||||
|
||||
$datamap = [];
|
||||
$elementIds = [];
|
||||
|
||||
// --- sections as text elements -------------------------------------------------
|
||||
foreach ($doc['sections'] as $section) {
|
||||
if ($section['type'] === 'text') {
|
||||
$id = $this->newId();
|
||||
$datamap['tt_content'][$id] = [
|
||||
'pid' => $storagePid, 'CType' => 'text', 'colPos' => self::COLPOS,
|
||||
'header' => $section['header'], 'header_layout' => 2,
|
||||
'bodytext' => $section['body'],
|
||||
];
|
||||
$elementIds[] = $id;
|
||||
} else { // quotation
|
||||
$id = $this->newId();
|
||||
$datamap['tt_content'][$id] = [
|
||||
'pid' => $storagePid, 'CType' => 'vitec_quotation', 'colPos' => self::COLPOS,
|
||||
'vitec_quote' => $section['quote'],
|
||||
'vitec_quote_name' => $section['name'],
|
||||
'vitec_quote_position' => $section['position'],
|
||||
];
|
||||
$elementIds[] = $id;
|
||||
}
|
||||
}
|
||||
|
||||
// --- image elements (beyond hero/card) ------------------------------------------
|
||||
$heroRef = null;
|
||||
$cardRef = null;
|
||||
foreach ($images as $i => $imagePath) {
|
||||
$file = $storage->addFile($imagePath, $targetFolder, basename($imagePath), DuplicationBehavior::REPLACE, false);
|
||||
if ($i === 0) {
|
||||
$heroRef = $this->fileRef($datamap, $file->getUid(), $storagePid);
|
||||
$cardRef = $this->fileRef($datamap, $file->getUid(), $storagePid);
|
||||
continue;
|
||||
}
|
||||
if ($i > self::MAX_IMAGE_ELEMENTS) {
|
||||
continue;
|
||||
}
|
||||
$id = $this->newId();
|
||||
$datamap['tt_content'][$id] = [
|
||||
'pid' => $storagePid, 'CType' => 'image', 'colPos' => self::COLPOS,
|
||||
'image' => $this->fileRef($datamap, $file->getUid(), $storagePid),
|
||||
];
|
||||
$elementIds[] = $id;
|
||||
}
|
||||
|
||||
// --- download record (OWN DataHandler pass: NEW ids are NOT
|
||||
// substituted inside FlexForm values, found 2026-10-28 - the card
|
||||
// needs the REAL uid) + downloadcard --------------------------------------------
|
||||
if ($pdf !== null) {
|
||||
$pdfName = 'VITEC_' . $slug . '_USE-CASE.pdf';
|
||||
$pdfFile = $storage->addFile($pdf, $targetFolder, $pdfName, DuplicationBehavior::REPLACE, false);
|
||||
$downloadId = $this->newId();
|
||||
$downloadMap = [];
|
||||
$downloadMap[self::DOWNLOAD_TABLE][$downloadId] = [
|
||||
'pid' => $downloadPid,
|
||||
'title' => $title . ' Success Story',
|
||||
'slug' => $this->slugify($title . ' success story'),
|
||||
'file' => $this->fileRef($downloadMap, $pdfFile->getUid(), $downloadPid),
|
||||
];
|
||||
$downloadHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||
$downloadHandler->start($downloadMap, []);
|
||||
$downloadHandler->process_datamap();
|
||||
$downloadUid = (int)($downloadHandler->substNEWwithIDs[$downloadId] ?? 0);
|
||||
if ($downloadUid > 0) {
|
||||
$cardId = $this->newId();
|
||||
$datamap['tt_content'][$cardId] = [
|
||||
'pid' => $storagePid, 'CType' => 'vitec_downloadcard', 'colPos' => self::COLPOS,
|
||||
'header' => 'Downloads',
|
||||
'pi_flexform' => ['data' => ['sDEF' => ['lDEF' => [
|
||||
'settings.download' => ['vDEF' => (string)$downloadUid],
|
||||
'settings.layout' => ['vDEF' => 'story_download'],
|
||||
]]]],
|
||||
];
|
||||
$elementIds[] = $cardId;
|
||||
}
|
||||
}
|
||||
|
||||
// --- the record -------------------------------------------------------------------
|
||||
$recordId = $this->newId();
|
||||
$record = [
|
||||
'pid' => $storagePid,
|
||||
'title' => $title,
|
||||
'slug' => $this->slugify($title),
|
||||
'subtitle' => $doc['subtitle'],
|
||||
'teaser' => $doc['teaser'],
|
||||
'quote' => $doc['lead']['quote'],
|
||||
'quote_person' => $doc['lead']['name'],
|
||||
'quote_role' => $doc['lead']['position'],
|
||||
'seo_title' => $title,
|
||||
'seo_description' => $doc['subtitle'],
|
||||
'content_elements' => implode(',', $elementIds),
|
||||
];
|
||||
if ($detailPage > 0) {
|
||||
$record['detail_page'] = $detailPage;
|
||||
}
|
||||
if ($heroRef !== null) {
|
||||
$record['hero_bgimage'] = $heroRef;
|
||||
$record['card_image'] = $cardRef;
|
||||
}
|
||||
$datamap[self::TABLE][$recordId] = $record;
|
||||
|
||||
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||
$dataHandler->start($datamap, []);
|
||||
$dataHandler->process_datamap();
|
||||
if ($dataHandler->errorLog !== []) {
|
||||
throw new \RuntimeException('DataHandler: ' . implode(' | ', array_slice($dataHandler->errorLog, 0, 3)));
|
||||
}
|
||||
$uid = (int)($dataHandler->substNEWwithIDs[$recordId] ?? 0);
|
||||
$output->writeln(' -> Record #' . $uid . ' angelegt');
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset backfill for an already imported story: the 2026-10-28 delivery
|
||||
* keeps the web images AND the customer logo in the Links/ folders,
|
||||
* which the first staging pass had excluded. Content-idempotent -
|
||||
* attaches customer_logo / hero_bgimage / card_image only when the
|
||||
* field has no reference yet and adds image elements only when the
|
||||
* story has none; existing references are never touched.
|
||||
*/
|
||||
private function backfillAssets(int $uid, string $slug, string $folder, int $storagePid, bool $apply, OutputInterface $output): void
|
||||
{
|
||||
$pool = GeneralUtility::makeInstance(ConnectionPool::class);
|
||||
$refs = $pool->getConnectionForTable('sys_file_reference')->fetchFirstColumn(
|
||||
"SELECT DISTINCT fieldname FROM sys_file_reference
|
||||
WHERE deleted = 0 AND tablenames = ? AND uid_foreign = ?
|
||||
AND fieldname IN ('customer_logo', 'hero_bgimage', 'card_image')",
|
||||
[self::TABLE, $uid]
|
||||
);
|
||||
$imageElements = (int)$pool->getConnectionForTable('tt_content')->fetchOne(
|
||||
"SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND CType = 'image' AND tx_vitec_usecase_content = ?",
|
||||
[$uid]
|
||||
);
|
||||
|
||||
$images = array_values(array_filter(glob($folder . '/images/*') ?: [], 'is_file'));
|
||||
$logos = array_values(array_filter(glob($folder . '/logo.*') ?: [], 'is_file'));
|
||||
|
||||
$needLogo = $logos !== [] && !in_array('customer_logo', $refs, true);
|
||||
$needHero = $images !== [] && !in_array('hero_bgimage', $refs, true);
|
||||
$needCard = $images !== [] && !in_array('card_image', $refs, true);
|
||||
$newImageCount = ($imageElements === 0 && count($images) > 1)
|
||||
? min(self::MAX_IMAGE_ELEMENTS, count($images) - 1)
|
||||
: 0;
|
||||
if (!$needLogo && !$needHero && !$needCard && $newImageCount === 0) {
|
||||
$output->writeln(' = #' . $uid . ' ' . $slug . ': Assets vollstaendig - nichts zu tun');
|
||||
return;
|
||||
}
|
||||
$output->writeln(sprintf(' ~ #%d %s: Backfill %s%s%s%s', $uid, $slug,
|
||||
$needLogo ? '[logo] ' : '', $needHero ? '[hero] ' : '', $needCard ? '[card] ' : '',
|
||||
$newImageCount > 0 ? '[' . $newImageCount . ' Bild-Element(e)]' : ''));
|
||||
if (!$apply) {
|
||||
return;
|
||||
}
|
||||
|
||||
$storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage();
|
||||
$targetFolder = $this->ensureFolder($storage, self::FILES_TARGET . '/' . $slug);
|
||||
$datamap = [];
|
||||
$recordFields = [];
|
||||
|
||||
if ($needLogo) {
|
||||
$logoName = 'logo-' . $slug . '.' . mb_strtolower(pathinfo($logos[0], PATHINFO_EXTENSION));
|
||||
$logoFile = $storage->addFile($logos[0], $targetFolder, $logoName, DuplicationBehavior::REPLACE, false);
|
||||
$recordFields['customer_logo'] = $this->fileRefFull($datamap, $logoFile->getUid(), $storagePid, $uid, 'customer_logo');
|
||||
}
|
||||
if ($needHero || $needCard) {
|
||||
$first = $storage->addFile($images[0], $targetFolder, basename($images[0]), DuplicationBehavior::REPLACE, false);
|
||||
if ($needHero) {
|
||||
$recordFields['hero_bgimage'] = $this->fileRefFull($datamap, $first->getUid(), $storagePid, $uid, 'hero_bgimage');
|
||||
}
|
||||
if ($needCard) {
|
||||
$recordFields['card_image'] = $this->fileRefFull($datamap, $first->getUid(), $storagePid, $uid, 'card_image');
|
||||
}
|
||||
}
|
||||
if ($newImageCount > 0) {
|
||||
// append via the parent CSV (existing children in sorting order +
|
||||
// the new elements) so the inline count stays correct; new images
|
||||
// land BEFORE a trailing downloadcard
|
||||
$children = array_map('intval', $pool->getConnectionForTable('tt_content')->fetchFirstColumn(
|
||||
'SELECT uid FROM tt_content WHERE deleted = 0 AND tx_vitec_usecase_content = ? ORDER BY sorting',
|
||||
[$uid]
|
||||
));
|
||||
$newIds = [];
|
||||
foreach (array_slice($images, 1, $newImageCount) as $imagePath) {
|
||||
$file = $storage->addFile($imagePath, $targetFolder, basename($imagePath), DuplicationBehavior::REPLACE, false);
|
||||
$id = $this->newId();
|
||||
$datamap['tt_content'][$id] = [
|
||||
'pid' => $storagePid, 'CType' => 'image', 'colPos' => self::COLPOS,
|
||||
'image' => $this->fileRef($datamap, $file->getUid(), $storagePid),
|
||||
];
|
||||
$newIds[] = $id;
|
||||
}
|
||||
$last = $children !== [] ? (int)end($children) : 0;
|
||||
$lastType = $last > 0 ? (string)$pool->getConnectionForTable('tt_content')->fetchOne(
|
||||
'SELECT CType FROM tt_content WHERE uid = ?', [$last]) : '';
|
||||
if ($lastType === 'vitec_downloadcard') {
|
||||
array_pop($children);
|
||||
$csv = array_merge($children, $newIds, [$last]);
|
||||
} else {
|
||||
$csv = array_merge($children, $newIds);
|
||||
}
|
||||
$recordFields['content_elements'] = implode(',', array_map('strval', $csv));
|
||||
}
|
||||
|
||||
$datamap[self::TABLE][(string)$uid] = $recordFields;
|
||||
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||
$dataHandler->start($datamap, []);
|
||||
$dataHandler->process_datamap();
|
||||
if ($dataHandler->errorLog !== []) {
|
||||
throw new \RuntimeException('DataHandler: ' . implode(' | ', array_slice($dataHandler->errorLog, 0, 3)));
|
||||
}
|
||||
$output->writeln(' -> Backfill geschrieben');
|
||||
}
|
||||
|
||||
/** sys_file_reference NEW id for a field of an EXISTING parent record. */
|
||||
private function fileRefFull(array &$datamap, int $fileUid, int $pid, int $uidForeign, string $fieldname): string
|
||||
{
|
||||
$refId = $this->newId();
|
||||
$datamap['sys_file_reference'][$refId] = [
|
||||
'pid' => $pid,
|
||||
'table_local' => 'sys_file',
|
||||
'uid_local' => $fileUid,
|
||||
'tablenames' => self::TABLE,
|
||||
'uid_foreign' => $uidForeign,
|
||||
'fieldname' => $fieldname,
|
||||
];
|
||||
return $refId;
|
||||
}
|
||||
/** Short, non-sentence line right after a quote = attribution (name/role). */
|
||||
private function isAttributionLine(array $paragraph, int $maxLen): bool
|
||||
{
|
||||
$text = (string)$paragraph['text'];
|
||||
if ($text === '' || mb_strlen($text) > $maxLen + 60) {
|
||||
return false;
|
||||
}
|
||||
$first = trim((string)($paragraph['lines'][0] ?? $text));
|
||||
if ($first === '' || mb_strlen($first) > $maxLen || str_ends_with($first, '.')) {
|
||||
return false;
|
||||
}
|
||||
if (count(preg_split('/\s+/u', $first) ?: []) > 8) {
|
||||
return false;
|
||||
}
|
||||
return !in_array(mb_strtolower(rtrim($text, ':')), self::SECTION_NAMES, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Repair pass (0017-FE Punkt 3 + 5a) for an already imported story:
|
||||
* writes the docx lead attribution into quote_person/quote_role (only
|
||||
* when empty), deletes the quotation elements that carry just the name
|
||||
* or role (or the glued combination) as their quote, and strips the
|
||||
* "NN | YYYY Updated on NN | YYYY" footer from text elements.
|
||||
*/
|
||||
private function repairStoryQuotes(int $uid, string $slug, array $doc, bool $apply, OutputInterface $output): void
|
||||
{
|
||||
$pool = GeneralUtility::makeInstance(ConnectionPool::class);
|
||||
$record = $pool->getConnectionForTable(self::TABLE)->fetchAssociative(
|
||||
'SELECT uid, quote_person, quote_role FROM ' . self::TABLE . ' WHERE uid = ?', [$uid]
|
||||
);
|
||||
if (!$record) {
|
||||
return;
|
||||
}
|
||||
$name = (string)$doc['lead']['name'];
|
||||
$position = (string)$doc['lead']['position'];
|
||||
|
||||
// normalise for the record (element matching below uses the RAW
|
||||
// parsed strings): trailing commas, "Name, Role" in ONE line, and a
|
||||
// single line that is clearly a role ("FIA Head of ...") -> role only
|
||||
$person = trim($name, " \t,");
|
||||
$role = trim($position, " \t,");
|
||||
if ($role === '' && str_contains($person, ',')) {
|
||||
[$person, $role] = $this->splitAttribution($person);
|
||||
}
|
||||
if ($role === '' && $person !== ''
|
||||
&& count(preg_split('/\s+/u', $person) ?: []) >= 3
|
||||
&& preg_match('/\b(Head|Manager|Director|President|CEO|CTO|Chief|Officer|Engineer|Architect|Strategy)\b/i', $person)) {
|
||||
$role = $person;
|
||||
$person = '';
|
||||
}
|
||||
|
||||
$datamap = [];
|
||||
$cmdmap = [];
|
||||
$actions = [];
|
||||
|
||||
if ($person !== '' && (string)$record['quote_person'] === '') {
|
||||
$datamap[self::TABLE][(string)$uid]['quote_person'] = $person;
|
||||
$actions[] = 'person="' . $person . '"';
|
||||
}
|
||||
if ($role !== '' && (string)$record['quote_role'] === '') {
|
||||
$datamap[self::TABLE][(string)$uid]['quote_role'] = $role;
|
||||
$actions[] = 'role="' . $role . '"';
|
||||
}
|
||||
|
||||
// bogus quotation elements: quote == name / role / glued combination
|
||||
$targets = array_filter([
|
||||
$this->norm($name),
|
||||
$this->norm($position),
|
||||
$this->norm($name . $position),
|
||||
], static fn(string $v): bool => $v !== '');
|
||||
if ($targets !== []) {
|
||||
foreach ($pool->getConnectionForTable('tt_content')->fetchAllAssociative(
|
||||
"SELECT uid, vitec_quote FROM tt_content
|
||||
WHERE deleted = 0 AND CType = 'vitec_quotation' AND tx_vitec_usecase_content = ?",
|
||||
[$uid]
|
||||
) as $element) {
|
||||
if (in_array($this->norm((string)$element['vitec_quote']), $targets, true)) {
|
||||
$cmdmap['tt_content'][(int)$element['uid']]['delete'] = 1;
|
||||
$actions[] = 'delete quotation #' . $element['uid'] . ' ("' . mb_substr((string)$element['vitec_quote'], 0, 40) . '")';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// "Updated on" remnants inside text elements (incl. glued into <li>)
|
||||
foreach ($pool->getConnectionForTable('tt_content')->fetchAllAssociative(
|
||||
"SELECT uid, bodytext FROM tt_content
|
||||
WHERE deleted = 0 AND CType = 'text' AND tx_vitec_usecase_content = ?
|
||||
AND bodytext LIKE '%Updated on%'",
|
||||
[$uid]
|
||||
) as $element) {
|
||||
$body = (string)$element['bodytext'];
|
||||
$clean = (string)preg_replace('/\s*\d{1,2}\s*\|\s*\d{4}\s*Updated on\s*\d{1,2}\s*\|\s*\d{4}\.?/iu', '', $body);
|
||||
$clean = (string)preg_replace('/<(p|li)>\s*<\/\1>/u', '', $clean);
|
||||
if ($clean !== $body) {
|
||||
$datamap['tt_content'][(string)(int)$element['uid']]['bodytext'] = $clean;
|
||||
$actions[] = 'strip "Updated on" in #' . $element['uid'];
|
||||
}
|
||||
}
|
||||
|
||||
if ($actions === []) {
|
||||
$output->writeln(' = #' . $uid . ' ' . $slug . ': nichts zu reparieren');
|
||||
return;
|
||||
}
|
||||
$output->writeln(' ~ #' . $uid . ' ' . $slug . ':');
|
||||
foreach ($actions as $action) {
|
||||
$output->writeln(' - ' . $action);
|
||||
}
|
||||
if (!$apply) {
|
||||
return;
|
||||
}
|
||||
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||
$dataHandler->start($datamap, $cmdmap);
|
||||
if ($datamap !== []) {
|
||||
$dataHandler->process_datamap();
|
||||
}
|
||||
if ($cmdmap !== []) {
|
||||
$dataHandler->process_cmdmap();
|
||||
}
|
||||
if ($dataHandler->errorLog !== []) {
|
||||
throw new \RuntimeException('DataHandler: ' . implode(' | ', array_slice($dataHandler->errorLog, 0, 3)));
|
||||
}
|
||||
$output->writeln(' -> repariert');
|
||||
}
|
||||
/** sys_file_reference NEW id for use in a parent FAL field. */
|
||||
private function fileRef(array &$datamap, int $fileUid, int $pid): string
|
||||
{
|
||||
$refId = $this->newId();
|
||||
$datamap['sys_file_reference'][$refId] = [
|
||||
'pid' => $pid,
|
||||
'table_local' => 'sys_file',
|
||||
'uid_local' => $fileUid,
|
||||
];
|
||||
return $refId;
|
||||
}
|
||||
|
||||
private function ensureFolder(\TYPO3\CMS\Core\Resource\ResourceStorage $storage, string $path): \TYPO3\CMS\Core\Resource\Folder
|
||||
{
|
||||
$folder = $storage->getRootLevelFolder();
|
||||
foreach (explode('/', trim($path, '/')) as $segment) {
|
||||
$folder = $folder->hasFolder($segment) ? $folder->getSubfolder($segment) : $folder->createFolder($segment);
|
||||
}
|
||||
return $folder;
|
||||
}
|
||||
|
||||
// ---- docx parsing ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @return array{title:string,subtitle:string,teaser:string,
|
||||
* lead:array{quote:string,name:string,position:string},
|
||||
* sections:array<int,array<string,string>>,quotes:array<int,int>,report:string[]}
|
||||
*/
|
||||
private function parseDocx(string $file): array
|
||||
{
|
||||
$zip = new \ZipArchive();
|
||||
if ($zip->open($file) !== true) {
|
||||
throw new \RuntimeException('docx laesst sich nicht oeffnen');
|
||||
}
|
||||
$xml = (string)$zip->getFromName('word/document.xml');
|
||||
$zip->close();
|
||||
$dom = new \DOMDocument();
|
||||
$dom->loadXML($xml);
|
||||
$xpath = new \DOMXPath($dom);
|
||||
$w = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
|
||||
$xpath->registerNamespace('w', $w);
|
||||
|
||||
$paragraphs = [];
|
||||
foreach ($xpath->query('//w:body/w:p') as $p) {
|
||||
$text = '';
|
||||
foreach ($xpath->query('.//w:t|.//w:br', $p) as $node) {
|
||||
// w:br as line break - attribution lines come as "Name<br>Role"
|
||||
// in some files and glued without it the names stick together
|
||||
// ("Shawn HanssonCEO, ...", ticket 0017-FE)
|
||||
$text .= $node->localName === 'br' ? "\n" : $node->textContent;
|
||||
}
|
||||
$text = trim((string)preg_replace('/[ \t]+/u', ' ', $text));
|
||||
$text = (string)preg_replace('/\s*\n\s*/u', "\n", $text);
|
||||
// import footer "04 | 2025 Updated on 10 | 2026" (0017-FE 5a)
|
||||
$text = trim((string)preg_replace('/\s*\d{1,2}\s*\|\s*\d{4}\s*Updated on\s*\d{1,2}\s*\|\s*\d{4}\.?\s*$/iu', '', $text));
|
||||
if ($text === '') {
|
||||
continue;
|
||||
}
|
||||
$styleNode = $xpath->query('./w:pPr/w:pStyle/@w:val', $p)->item(0);
|
||||
$paragraphs[] = [
|
||||
'text' => str_replace("\n", ' ', $text),
|
||||
'lines' => explode("\n", $text),
|
||||
'style' => $styleNode ? (string)$styleNode->nodeValue : '',
|
||||
];
|
||||
}
|
||||
|
||||
$report = [];
|
||||
$title = $paragraphs[0]['text'] ?? '';
|
||||
$subtitle = $paragraphs[1]['text'] ?? '';
|
||||
$lead = ['quote' => '', 'name' => '', 'position' => ''];
|
||||
$index = 2;
|
||||
if (isset($paragraphs[$index]) && $paragraphs[$index]['style'] === 'IntenseQuote') {
|
||||
$lead['quote'] = $this->unquote($paragraphs[$index]['text']);
|
||||
$index++;
|
||||
// attribution lines are often ALSO IntenseQuote-styled in these
|
||||
// files (0017-FE: they became bogus quotation elements) and may
|
||||
// be glued into ONE paragraph via w:br
|
||||
if (isset($paragraphs[$index]) && $this->isAttributionLine($paragraphs[$index], 60)) {
|
||||
$lines = $paragraphs[$index]['lines'];
|
||||
$lead['name'] = trim((string)$lines[0]);
|
||||
if (count($lines) > 1) {
|
||||
$lead['position'] = trim(implode(', ', array_map('trim', array_slice($lines, 1))));
|
||||
}
|
||||
$index++;
|
||||
if ($lead['position'] === '' && isset($paragraphs[$index]) && $this->isAttributionLine($paragraphs[$index], 100)) {
|
||||
$lead['position'] = $paragraphs[$index]['text'];
|
||||
$index++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$report[] = 'HINWEIS: kein Lead-Quote an Position 3';
|
||||
}
|
||||
|
||||
// sections
|
||||
$sections = [];
|
||||
$quotes = [];
|
||||
$current = null;
|
||||
$body = []; // items: ['p', html] | ['ul', [li,...]]
|
||||
$flushBody = function () use (&$sections, &$current, &$body): void {
|
||||
if ($current === null && $body === []) {
|
||||
return;
|
||||
}
|
||||
$html = '';
|
||||
foreach ($body as [$kind, $value]) {
|
||||
$html .= $kind === 'ul'
|
||||
? '<ul>' . implode('', array_map(static fn(string $li): string => '<li>' . htmlspecialchars($li, ENT_NOQUOTES | ENT_HTML5) . '</li>', $value)) . '</ul>'
|
||||
: '<p>' . htmlspecialchars($value, ENT_NOQUOTES | ENT_HTML5) . '</p>';
|
||||
}
|
||||
if ($html !== '' || $current !== null) {
|
||||
$sections[] = ['type' => 'text', 'header' => (string)$current, 'body' => $html];
|
||||
}
|
||||
$body = [];
|
||||
};
|
||||
|
||||
for ($i = $index; $i < count($paragraphs); $i++) {
|
||||
$paragraph = $paragraphs[$i];
|
||||
$lower = mb_strtolower(rtrim($paragraph['text'], ':'));
|
||||
if ($paragraph['style'] !== 'IntenseQuote' && in_array($lower, self::SECTION_NAMES, true)) {
|
||||
$flushBody();
|
||||
$current = $paragraph['text'];
|
||||
continue;
|
||||
}
|
||||
if ($paragraph['style'] === 'IntenseQuote') {
|
||||
// attribution BEFORE the quote ("... said:" / "... continues:")
|
||||
$name = '';
|
||||
$position = '';
|
||||
$last = $body !== [] ? $body[count($body) - 1] : null;
|
||||
if ($last !== null && $last[0] === 'p' && preg_match('/^(.{3,90}?)\s+(said|says|continues|adds|explains)\s*:?\s*$/iu', $last[1], $m)) {
|
||||
[$name, $position] = $this->splitAttribution($m[1]);
|
||||
array_pop($body);
|
||||
} elseif (isset($paragraphs[$i + 1]) && $paragraphs[$i + 1]['style'] !== 'IntenseQuote') {
|
||||
$next = $paragraphs[$i + 1]['text'];
|
||||
$nextLower = mb_strtolower(rtrim($next, ':'));
|
||||
if (mb_strlen($next) <= 90 && str_contains($next, ',') && !in_array($nextLower, self::SECTION_NAMES, true)) {
|
||||
[$name, $position] = $this->splitAttribution($next);
|
||||
$i++;
|
||||
}
|
||||
}
|
||||
$flushBody();
|
||||
$current = null;
|
||||
$sections[] = ['type' => 'quote', 'quote' => $this->unquote($paragraph['text']), 'name' => $name, 'position' => $position];
|
||||
$quotes[] = 1;
|
||||
continue;
|
||||
}
|
||||
if ($paragraph['style'] === 'ListParagraph') {
|
||||
$last = $body !== [] ? count($body) - 1 : -1;
|
||||
if ($last >= 0 && $body[$last][0] === 'ul') {
|
||||
$body[$last][1][] = $paragraph['text'];
|
||||
} else {
|
||||
$body[] = ['ul', [$paragraph['text']]];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ($current === null && $sections === []) {
|
||||
$report[] = 'HINWEIS: Absatz vor erster Sektion: "' . mb_substr($paragraph['text'], 0, 50) . '..."';
|
||||
}
|
||||
$body[] = ['p', $paragraph['text']];
|
||||
}
|
||||
$flushBody();
|
||||
|
||||
// teaser from the first text section
|
||||
$teaser = '';
|
||||
foreach ($sections as $section) {
|
||||
if ($section['type'] === 'text' && preg_match('/<p>(.*?)<\/p>/s', $section['body'], $m)) {
|
||||
$teaser = html_entity_decode($m[1], ENT_NOQUOTES | ENT_HTML5);
|
||||
if (mb_strlen($teaser) > 300) {
|
||||
$teaser = mb_substr($teaser, 0, 300);
|
||||
$teaser = mb_substr($teaser, 0, (int)mb_strrpos($teaser, ' ')) . ' ...';
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return ['title' => $title, 'subtitle' => $subtitle, 'teaser' => $teaser,
|
||||
'lead' => $lead, 'sections' => $sections, 'quotes' => $quotes, 'report' => $report];
|
||||
}
|
||||
|
||||
/** @return array{0:string,1:string} name, position */
|
||||
private function splitAttribution(string $value): array
|
||||
{
|
||||
$parts = explode(',', $value, 2);
|
||||
return [trim($parts[0]), trim($parts[1] ?? '')];
|
||||
}
|
||||
|
||||
private function unquote(string $value): string
|
||||
{
|
||||
// multibyte-safe strip of straight and curly quotes at both ends
|
||||
return trim((string)preg_replace('/^[\s"\'\x{201C}\x{201D}\x{2018}\x{2019}]+|[\s"\'\x{201C}\x{201D}\x{2018}\x{2019}]+$/u', '', $value));
|
||||
}
|
||||
|
||||
private function slugify(string $value): string
|
||||
{
|
||||
$value = str_replace(['+', '&'], ['plus', 'and'], mb_strtolower(trim($value)));
|
||||
return trim((string)preg_replace('/[^a-z0-9]+/', '-', $value), '-');
|
||||
}
|
||||
|
||||
private function norm(string $value): string
|
||||
{
|
||||
return (string)preg_replace('/[^a-z0-9]+/', '', str_replace(['+', '&'], ['plus', 'and'], mb_strtolower($value)));
|
||||
}
|
||||
|
||||
private function newId(): string
|
||||
{
|
||||
return 'NEWDS' . (++$this->newCounter);
|
||||
}
|
||||
}
|
||||
@@ -275,7 +275,7 @@ final class ImportMarketsMainPageCommand extends Command
|
||||
|
||||
private function p(string $text): string
|
||||
{
|
||||
return '<p>' . htmlspecialchars($text, ENT_QUOTES | ENT_HTML5) . '</p>';
|
||||
return '<p>' . htmlspecialchars($text, ENT_NOQUOTES | ENT_HTML5) . '</p>';
|
||||
}
|
||||
|
||||
private function norm(string $value): string
|
||||
|
||||
@@ -173,6 +173,24 @@ final class ImportProductCategoryPagesCommand extends Command
|
||||
}
|
||||
$pageUid = ctype_digit($pageId) ? (int)$pageId : 0;
|
||||
|
||||
// Guard (2026-10-28, Ticket 0014-FE): nach vitec:wrap-cards-in-grid
|
||||
// stecken die Karten IN vitec_cols-Containern (colPos 221-223), wo
|
||||
// der Slot-Cursor sie nicht sieht - der IQS4-Re-Run hat so auf pid
|
||||
// 569 den Kartensatz dupliziert (Repair: fix_products_grid.php).
|
||||
// Eine gewickelte Seite wird uebersprungen statt beschaedigt.
|
||||
if ($pageUid > 0) {
|
||||
$containerCount = (int)GeneralUtility::makeInstance(ConnectionPool::class)
|
||||
->getConnectionForTable('tt_content')->fetchOne(
|
||||
"SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND pid = ? AND CType LIKE 'vitec_cols_%'",
|
||||
[$pageUid]
|
||||
);
|
||||
if ($containerCount > 0) {
|
||||
$this->report[] = 'SKIP: Seite ist grid-gewickelt (' . $containerCount
|
||||
. ' Container) - Re-Import wuerde Karten duplizieren. Inhalte im BE pflegen oder Grids erst aufloesen.';
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- hero -------------------------------------------------------------------
|
||||
if ($hero !== null) {
|
||||
$id = $this->slotNext($pageUid, self::COLPOS_HERO, 'vitec_herosection', $pageId);
|
||||
@@ -316,9 +334,9 @@ final class ImportProductCategoryPagesCommand extends Command
|
||||
}
|
||||
$body = '<table class="vitec-comparison"><thead><tr><th>Product</th><th>Type</th><th>Description</th></tr></thead><tbody>';
|
||||
foreach ($rows as $row) {
|
||||
$body .= '<tr><td>' . htmlspecialchars((string)$row['heading'], ENT_QUOTES | ENT_HTML5) . '</td>'
|
||||
. '<td>' . htmlspecialchars((string)$row['body'], ENT_QUOTES | ENT_HTML5) . '</td>'
|
||||
. '<td>' . htmlspecialchars((string)$row['cta'], ENT_QUOTES | ENT_HTML5) . '</td></tr>';
|
||||
$body .= '<tr><td>' . htmlspecialchars((string)$row['heading'], ENT_NOQUOTES | ENT_HTML5) . '</td>'
|
||||
. '<td>' . htmlspecialchars((string)$row['body'], ENT_NOQUOTES | ENT_HTML5) . '</td>'
|
||||
. '<td>' . htmlspecialchars((string)$row['cta'], ENT_NOQUOTES | ENT_HTML5) . '</td></tr>';
|
||||
}
|
||||
$body .= '</tbody></table>';
|
||||
$id = $this->slotNext($pageUid, self::COLPOS_MAIN, 'text', $pageId);
|
||||
@@ -502,7 +520,9 @@ final class ImportProductCategoryPagesCommand extends Command
|
||||
private function p(string $text): string
|
||||
{
|
||||
$text = trim($text);
|
||||
return $text === '' ? '' : '<p>' . htmlspecialchars($text, ENT_QUOTES | ENT_HTML5) . '</p>';
|
||||
// ENT_NOQUOTES (2026-10-28, Ticket 0014-FE): ENT_QUOTES machte ' zu ',
|
||||
// und die RTE-Persistenz escapte das & gleich nochmal (&apos; im JSON)
|
||||
return $text === '' ? '' : '<p>' . htmlspecialchars($text, ENT_NOQUOTES | ENT_HTML5) . '</p>';
|
||||
}
|
||||
|
||||
private function norm(string $value): string
|
||||
|
||||
@@ -36,7 +36,9 @@ use TYPO3\CMS\Extbase\Service\ImageService;
|
||||
*
|
||||
* Version 1 (2026-10-09) listed sys_category groups with their products;
|
||||
* the nine old category pages are plain pages below /products and are not
|
||||
* part of this tree.
|
||||
* part of this tree. The transitional v1 fields (categories[].uid/link,
|
||||
* groups[].uid/link/products, products[].teaser) are gone since 2026-11-03 -
|
||||
* the front end reads the pure v2 shape (ticket 0024-FE).
|
||||
*/
|
||||
#[AsEventListener(identifier: 'vitec/megamenu-product-catalog')]
|
||||
final class ProvideProductCatalog
|
||||
@@ -117,10 +119,9 @@ final class ProvideProductCatalog
|
||||
}
|
||||
$items[] = $item;
|
||||
}
|
||||
$groups[] = ['title' => (string)$groupRow['title'], 'items' => $items] + $this->legacyGroupFields($items);
|
||||
$groups[] = ['title' => (string)$groupRow['title'], 'items' => $items];
|
||||
}
|
||||
// legacy (v1) fields - see legacyGroupFields()
|
||||
$categories[] = ['uid' => (int)$categoryRow['uid'], 'title' => (string)$categoryRow['title'], 'link' => self::PRODUCTS_SLUG, 'groups' => $groups];
|
||||
$categories[] = ['title' => (string)$categoryRow['title'], 'groups' => $groups];
|
||||
}
|
||||
|
||||
// shortcuts: target = a group of this tree (opens that group) or an
|
||||
@@ -158,33 +159,6 @@ final class ProvideProductCatalog
|
||||
return $categories;
|
||||
}
|
||||
|
||||
/**
|
||||
* TRANSITIONAL (2026-11-02): the deployed front end still reads the v1
|
||||
* shape (`groups[].products` with title/teaser/link, `groups[].link`)
|
||||
* and crashed the WHOLE site with v2 alone (white page). Each group
|
||||
* therefore also carries the products of its overview pages, deduplicated
|
||||
* by link, and the link of its first overview page. Remove once the
|
||||
* front end has switched to `items` (briefing 2026-11-02).
|
||||
*
|
||||
* @param array<int,array<string,mixed>> $items
|
||||
* @return array{uid:int,link:string,products:array<int,array<string,mixed>>}
|
||||
*/
|
||||
private function legacyGroupFields(array $items): array
|
||||
{
|
||||
$products = [];
|
||||
$link = '';
|
||||
foreach ($items as $item) {
|
||||
if ($item['type'] !== 'page') {
|
||||
continue;
|
||||
}
|
||||
$link = $link !== '' ? $link : (string)$item['link'];
|
||||
foreach ($item['products'] as $product) {
|
||||
$products[$product['link']] ??= $product;
|
||||
}
|
||||
}
|
||||
return ['uid' => 0, 'link' => $link, 'products' => array_values($products)];
|
||||
}
|
||||
|
||||
private function productsRootUid(): int
|
||||
{
|
||||
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('pages');
|
||||
@@ -235,7 +209,7 @@ final class ProvideProductCatalog
|
||||
return [];
|
||||
}
|
||||
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tt_content');
|
||||
$rows = $queryBuilder->select('pid', 'header', 'bodytext', 'vitec_primary_cta')->from('tt_content')
|
||||
$rows = $queryBuilder->select('pid', 'header', 'vitec_primary_cta')->from('tt_content')
|
||||
->where(
|
||||
$queryBuilder->expr()->in('pid', $queryBuilder->createNamedParameter($pageUids, ArrayParameterType::INTEGER)),
|
||||
$queryBuilder->expr()->eq('CType', $queryBuilder->createNamedParameter('vitec_card')),
|
||||
@@ -247,7 +221,6 @@ final class ProvideProductCatalog
|
||||
foreach ($rows as $row) {
|
||||
$byPage[(int)$row['pid']][] = [
|
||||
'title' => (string)$row['header'],
|
||||
'teaser' => trim((string)preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags((string)$row['bodytext']), ENT_QUOTES | ENT_HTML5, 'UTF-8'))),
|
||||
'link' => (string)$row['vitec_primary_cta'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -85,15 +85,57 @@ final class SubmarketWorkbookImporter
|
||||
throw new \RuntimeException('Meta sheet missing - not a generated sub-market workbook?');
|
||||
}
|
||||
|
||||
// Meta sheets can be stale template copies (2026-10-05: all 24 shared
|
||||
// record 32; 2026-10-28: "Broadcast & Media.xlsx" still shipped the
|
||||
// complete Corporate meta block). The FILENAME names the agency's
|
||||
// actual intent - when it disagrees with the sheet, trust it, drop
|
||||
// the copied record link and re-resolve the record by title.
|
||||
$fileTitle = (string)preg_replace('/\.xlsx$/i', '', basename($file));
|
||||
if ($this->norm($fileTitle) !== $this->norm($submarket)) {
|
||||
$this->report[] = 'META REPAIR: sheet says "' . $submarket . '" (group "' . $group . '", record ' . $recordUid
|
||||
. ') - stale template copy, using filename "' . $fileTitle . '"';
|
||||
$submarket = $fileTitle;
|
||||
$group = '';
|
||||
$recordUid = 0;
|
||||
foreach (GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tx_vitec_domain_model_market')
|
||||
->fetchAllAssociative('SELECT uid, title FROM tx_vitec_domain_model_market WHERE deleted = 0') as $marketRow) {
|
||||
if ($this->norm((string)$marketRow['title']) === $this->norm($submarket)) {
|
||||
$recordUid = $recordUid === 0 ? (int)$marketRow['uid'] : -1; // -1 = ambiguous
|
||||
}
|
||||
}
|
||||
$recordUid = max(0, $recordUid);
|
||||
if ($recordUid > 0) {
|
||||
$this->report[] = 'market record re-resolved by title: #' . $recordUid;
|
||||
}
|
||||
}
|
||||
|
||||
// --- resolve pages ---------------------------------------------------
|
||||
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages');
|
||||
$marketsRoot = $connection->fetchAssociative("SELECT uid FROM pages WHERE deleted = 0 AND slug = '/markets'");
|
||||
if (!$marketsRoot) {
|
||||
throw new \RuntimeException('Page /markets not found.');
|
||||
}
|
||||
$mainPage = $this->childByTitle((int)$marketsRoot['uid'], $group);
|
||||
$mainPage = $group !== '' ? $this->childByTitle((int)$marketsRoot['uid'], $group) : null;
|
||||
if ($mainPage === null) {
|
||||
throw new \RuntimeException('Main market page "' . $group . '" not found below /markets.');
|
||||
// repaired meta (or unknown group): locate the ONE main market
|
||||
// that already has a sub-page with this title
|
||||
$matches = [];
|
||||
foreach ($connection->fetchAllAssociative(
|
||||
'SELECT uid, title FROM pages WHERE deleted = 0 AND pid = ? AND sys_language_uid IN (0,-1)',
|
||||
[(int)$marketsRoot['uid']]
|
||||
) as $mainRow) {
|
||||
if ($this->childByTitle((int)$mainRow['uid'], $submarket) !== null) {
|
||||
$matches[] = $mainRow;
|
||||
}
|
||||
}
|
||||
if (count($matches) === 1) {
|
||||
$mainPage = $this->childByTitle((int)$marketsRoot['uid'], (string)$matches[0]['title']);
|
||||
$this->report[] = 'group resolved via page tree: "' . $matches[0]['title'] . '"';
|
||||
}
|
||||
}
|
||||
if ($mainPage === null) {
|
||||
throw new \RuntimeException('Main market page "' . $group . '" not found below /markets'
|
||||
. ($group === '' ? ' (and no main market has a sub-page titled "' . $submarket . '")' : '') . '.');
|
||||
}
|
||||
$subPage = $this->childByTitle((int)$mainPage['uid'], $submarket);
|
||||
|
||||
|
||||
Reference in new issue
Block a user