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>
754 lines
35 KiB
PHP
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);
|
|
}
|
|
}
|