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
{
@@ -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 &apos;,
// und die RTE-Persistenz escapte das & gleich nochmal (&amp;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 &apos;,
// und die RTE-Persistenz escapte das & gleich nochmal (&amp;apos; im JSON)
return $text === '' ? '' : '<p>' . htmlspecialchars($text, ENT_NOQUOTES | ENT_HTML5) . '</p>';
}
private function norm(string $value): string