Files
VITEC-website/packages/vitec/Classes/Command/ImportDatapathStoriesCommand.php
orascheandClaude Fable 5 77661bf0b6 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>
2026-11-03 18:29:56 +01:00

754 lines
35 KiB
PHP

<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use TYPO3\CMS\Core\Core\Bootstrap;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Resource\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);
}
}