Sub-solutions filled from Joe's 39 workbooks; dummy assets wired

- Joe's 39 "- Completed" workbooks applied to all 39 sub-solution pages
  (#467-#515); both workbook inboxes are now gitignored and the tracked
  test workbooks untracked - filled deliveries live in the Nextcloud
- new Import\ProductFamilyAliases: the workbooks reference product
  FAMILIES in the product slots; 11 labels map to a representative
  product, consulted by BOTH importers after the exact title match and
  reported as "-> family alias"
- flip-card icons: dummy SVGs fileadmin/icons/flow/<name>.svg for Joe's
  10-name vocabulary, attached by the new attachIconFile() through the
  shared idempotent attachFalFile() (extracted from attachUrlFile);
  real icons later REPLACE those files in the Filelist (same sys_file
  uid), every card follows without a re-import
- image slots: numbered grey placeholders per page and slot
  (fileadmin/placeholders/pages/<pid>-NN.png, the id rendered into the
  image; generic grey as fallback). An existing reference is never
  overwritten; a real URL on re-import swaps the placeholder away
- page properties per sub-solution: og/twitter title + description
  mirror the SEO fields, canonical_link points at the page itself,
  abstract derived from the intro copy (~300 chars, word boundary),
  keywords from the curated PAGE_KEYWORDS set
- spec head-matter fix rides along (version table was stuck at 1.21,
  real state 1.23)

Open afterwards: 3 missing Datapath stories + re-apply, quotes on 16
story records (list to Joe), slug slash on page 467.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
orascheandClaude Fable 5 committed 2026-10-19 18:51:21 +02:00
1 parent 74502e60a3
commit d115ffb144
7 files changed
+401 -86

No files matched your search

@@ -49,6 +49,54 @@ final class SubsolutionWorkbookImporter
/** Story detail page; mirrors the site setting vitec.storyDetailPid. */
private const STORY_SINGLE_PID = 26;
/**
* Curated page keywords per sub-solution (Claude, 2026-10-19 - Olli asked
* for generated keywords in the page properties). Keys normalised like
* norm(); versioned here so a re-import stays idempotent and the set is
* reviewable in one place.
*/
private const PAGE_KEYWORDS = [
'controlroomplatformssocnoccommandcentres' => 'control room platform, soc video wall, noc visualisation, command centre technology, control room video management',
'commandcontrolvisualisation' => 'command and control visualisation, c2 video wall, mission control displays, situational command centre',
'operationsmanagementenvironments' => 'operations management, operations control room, operational video platform, operations visualisation',
'crisisincidentmanagementcentres' => 'crisis management centre, incident management, emergency operations centre, eoc video wall',
'multisourcevideomonitoring' => 'multi-source video monitoring, video wall monitoring, multiviewer, simultaneous video sources',
'realtimedatamonitoringcctviotsensors' => 'real-time data monitoring, cctv monitoring, iot sensor visualisation, live data feeds',
'situationalawarenessplatforms' => 'situational awareness, common operating picture, real-time awareness platform, decision support displays',
'surveillanceoperationalvisibility' => 'surveillance visibility, operational monitoring, video surveillance integration, live operational oversight',
'incidentdetectionresponse' => 'incident detection, incident response, alarm visualisation, rapid response video',
'iptvdistribution' => 'iptv distribution, enterprise iptv, iptv system, video over ip, iptv headend, multicast video distribution',
'avoveripdistribution' => 'av over ip, av-over-ip distribution, networked av, video distribution network, av signal distribution',
'securevideostreaminglowlatency' => 'secure video streaming, low latency streaming, encrypted video transport, srt streaming, mission-critical streaming',
'multisitevideodelivery' => 'multi-site video delivery, video distribution multiple locations, enterprise video network, wan video delivery',
'internalexternalbroadcastdistribution' => 'broadcast distribution, internal broadcast, corporate broadcast delivery, live broadcast distribution',
'videoencodingdecoding' => 'video encoder, video decoder, h.264 encoding, hevc encoding, hardware video encoding, video decoding appliance',
'livevideocapture' => 'live video capture, video capture cards, sdi capture, hdmi capture, real-time video ingest',
'signalprocessingconversion' => 'video signal processing, signal conversion, video format conversion, sdi hdmi conversion',
'transcodingoptimisation' => 'video transcoding, stream optimisation, format transcoding, bitrate optimisation, iptv transcoder',
'contributionremoteproduction' => 'contribution encoding, remote production, remi production, live contribution links, broadcast contribution',
'videowallplatforms' => 'video wall platform, video wall controller, video wall processor, large-scale display wall',
'multidisplayvisualisation' => 'multi-display visualisation, multi-screen output, display wall graphics, multi-monitor controller',
'realtimedashboards' => 'real-time dashboards, kpi displays, live data dashboards, operational dashboards',
'dataaggregationdisplay' => 'data aggregation, data visualisation wall, multi-source data display, information aggregation',
'operationalintelligencedisplays' => 'operational intelligence, intelligence displays, business operations visualisation, insight walls',
'digitalsignagenetworks' => 'digital signage network, enterprise signage, signage cms, networked digital displays',
'passengerpublicinformationdisplays' => 'passenger information displays, public information displays, pids, transport signage, flight information displays',
'wayfindingmessaging' => 'digital wayfinding, wayfinding signage, visitor messaging, directional displays',
'advertisingsponsorshipdisplays' => 'advertising displays, sponsorship signage, dooh advertising, venue advertising screens',
'targetedcontentdelivery' => 'targeted content delivery, personalised signage, audience targeting displays, contextual content',
'internalcommunicationsiptv' => 'internal communications iptv, corporate iptv, employee communications video, workplace tv',
'staffmessagingsystems' => 'staff messaging, employee messaging displays, internal messaging system, workforce communication',
'corporatebroadcasting' => 'corporate broadcasting, enterprise video broadcast, town hall streaming, executive broadcasts',
'guestvisitorengagement' => 'guest engagement, visitor engagement displays, hospitality iptv, welcome screens',
'multilocationcommunicationnetworks' => 'multi-location communications, distributed video network, multi-site messaging, global video delivery',
'surveillancecctvintegration' => 'cctv integration, surveillance system integration, vms video wall, camera feed integration',
'securityoperationscentressoc' => 'security operations centre, soc displays, security monitoring wall, soc video integration',
'perimetermonitoring' => 'perimeter monitoring, perimeter security video, fence line surveillance, site protection monitoring',
'incidentresponsesystems' => 'incident response system, security incident video, response coordination displays, alert management',
'securemonitoringenvironments' => 'secure monitoring, classified video environment, secure control room, accredited video systems',
];
/** @var string[] */
private array $report = [];
/** @var array<string,array<string|int,array<string,mixed>>> */
@@ -117,14 +165,33 @@ final class SubsolutionWorkbookImporter
}
// --- SEO on the page --------------------------------------------------
// Full page-properties set (Olli, 2026-10-19): OG/Twitter mirror the
// SEO fields, canonical points at the page itself, the abstract is
// derived from the intro copy, keywords come from PAGE_KEYWORDS.
$seoTitle = trim((string)($values['seo_title'] ?? ''));
$seoDescription = trim((string)($values['seo_description'] ?? ''));
if ($seoTitle !== '') {
$this->datamap['pages'][$pageId]['seo_title'] = $seoTitle;
$this->datamap['pages'][$pageId]['og_title'] = $seoTitle;
$this->datamap['pages'][$pageId]['twitter_title'] = $seoTitle;
}
if ($seoDescription !== '') {
$this->datamap['pages'][$pageId]['description'] = $seoDescription;
$this->datamap['pages'][$pageId]['og_description'] = $seoDescription;
$this->datamap['pages'][$pageId]['twitter_description'] = $seoDescription;
}
if (!str_starts_with($pageId, 'NEW')) {
$this->datamap['pages'][$pageId]['canonical_link'] = 't3://page?uid=' . $pageId;
}
$abstract = $this->abstractFrom((string)($values['intro_text'] ?? ''), $seoDescription);
if ($abstract !== '') {
$this->datamap['pages'][$pageId]['abstract'] = $abstract;
}
$keywords = self::PAGE_KEYWORDS[$this->norm($subsolution)] ?? '';
if ($keywords !== '') {
$this->datamap['pages'][$pageId]['keywords'] = $keywords;
}
$this->report[] = 'page meta: og/twitter + canonical + abstract (' . mb_strlen($abstract) . ' chars) + keywords' . ($keywords === '' ? ' MISSING (no curated set for "' . $subsolution . '")' : '');
// --- hero zone (colPos 1) ---------------------------------------------
// The blocks below are registered in PAGE ORDER ($this->sequence) and
@@ -136,7 +203,7 @@ final class SubsolutionWorkbookImporter
'vitec_cta_label' => (string)($values['hero_cta_label'] ?? ''),
'vitec_cta_link' => (string)($values['hero_cta_url'] ?? ''),
]);
$this->attachUrlFile($heroId, 'vitec_bgimage', (string)($values['hero_bgimage_url'] ?? ''), $subsolution, $apply, $this->pidValue($pageId));
$this->attachUrlFile($heroId, 'vitec_bgimage', (string)($values['hero_bgimage_url'] ?? ''), $subsolution, $apply, $this->pidValue($pageId), 1);
// --- main column: intro -------------------------------------------------
$introId = $this->slotElement($pageId, $subPage, self::COLPOS_MAIN, 'vitec_marketintro');
@@ -145,7 +212,7 @@ final class SubsolutionWorkbookImporter
'vitec_intro_text' => (string)($values['intro_text'] ?? ''),
'vitec_show_portfolio' => 0,
]);
$this->attachUrlFile($introId, 'vitec_image', (string)($values['intro_image_url'] ?? ''), $subsolution, $apply, $this->pidValue($pageId));
$this->attachUrlFile($introId, 'vitec_image', (string)($values['intro_image_url'] ?? ''), $subsolution, $apply, $this->pidValue($pageId), 2);
// --- main column: advantages (intro-paragraph + flip cards) --------------
$flowHeaderId = $this->slotElement($pageId, $subPage, self::COLPOS_MAIN, 'vitec_introparagraph');
@@ -178,7 +245,7 @@ final class SubsolutionWorkbookImporter
'vitec_back_text' => $back,
]);
if ($icon !== '') {
$this->report[] = 'card ' . $i . ': icon "' . $icon . '" noted - icon set still pending, attach later';
$this->attachIconFile((string)$cardId, $i, $icon, $apply, $this->pidValue($pageId));
}
$cardIndex++;
}
@@ -219,6 +286,10 @@ final class SubsolutionWorkbookImporter
$featuredUid = 0;
if ($featuredTitle !== '') {
$featured = $this->recordByTitle('tx_vitec_domain_model_product', $featuredTitle);
// Family labels (Joe's vocabulary) resolve to their representative product.
if ($featured === null && ($featured = ProductFamilyAliases::resolve($this->norm($featuredTitle))) !== null) {
$this->report[] = 'featured product "' . $featuredTitle . '" -> family alias "' . $featured['title'] . '"';
}
$featured === null
? $this->report[] = 'featured product "' . $featuredTitle . '" NOT FOUND'
: $featuredUid = (int)$featured['uid'];
@@ -235,6 +306,9 @@ final class SubsolutionWorkbookImporter
continue;
}
$product = $this->recordByTitle('tx_vitec_domain_model_product', $productTitle);
if ($product === null && ($product = ProductFamilyAliases::resolve($this->norm($productTitle))) !== null) {
$this->report[] = 'product card ' . $i . ': "' . $productTitle . '" -> family alias "' . $product['title'] . '"';
}
$product === null
? $this->report[] = 'product card ' . $i . ': "' . $productTitle . '" NOT FOUND - skipped'
: $productUids[] = (int)$product['uid'];
@@ -260,7 +334,7 @@ final class SubsolutionWorkbookImporter
'vitec_video_title' => (string)($values['insights_video_title'] ?? ''),
'vitec_video_url' => (string)($values['insights_video_url'] ?? ''),
]);
$this->attachUrlFile($insightsId, 'vitec_video_cover', (string)($values['insights_video_cover_url'] ?? ''), $subsolution, $apply, $this->pidValue($pageId));
$this->attachUrlFile($insightsId, 'vitec_video_cover', (string)($values['insights_video_cover_url'] ?? ''), $subsolution, $apply, $this->pidValue($pageId), 3);
// --- solution record link -----------------------------------------------------
if ($recordUid > 0 && !str_starts_with($pageId, 'NEW')) {
@@ -510,10 +584,11 @@ final class SubsolutionWorkbookImporter
* as the element's file field. Editorial review happens in the backend;
* a failed download is a warning. Skipped entirely in dry runs.
*/
private function attachUrlFile(string $elementId, string $fieldName, string $url, string $subsolution, bool $apply, int|string $pid = 0): void
private function attachUrlFile(string $elementId, string $fieldName, string $url, string $subsolution, bool $apply, int|string $pid = 0, int $slot = 0): void
{
$url = trim($url);
if ($url === '' || !str_starts_with($url, 'http')) {
$this->attachPlaceholder($elementId, $fieldName, $apply, $pid, $slot);
return;
}
if (!$apply) {
@@ -525,11 +600,13 @@ final class SubsolutionWorkbookImporter
->request($url, 'GET', ['timeout' => 20]);
if ($response->getStatusCode() !== 200) {
$this->report[] = $fieldName . ': DOWNLOAD FAILED (HTTP ' . $response->getStatusCode() . ' for ' . $url . ') - attach editorially';
$this->attachPlaceholder($elementId, $fieldName, $apply, $pid, $slot);
return;
}
$content = (string)$response->getBody();
if ($content === '') {
$this->report[] = $fieldName . ': DOWNLOAD FAILED (empty response from ' . $url . ') - attach editorially';
$this->attachPlaceholder($elementId, $fieldName, $apply, $pid, $slot);
return;
}
$storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage();
@@ -540,41 +617,136 @@ final class SubsolutionWorkbookImporter
file_put_contents($temp, $content);
$file = $storage->addFile($temp, $folder, $fileName, DuplicationBehavior::REPLACE);
// Re-import idempotency BY CONTENT (lesson 2026-10-02): reuse the
// first live reference, delete our own surplus duplicates.
if (!str_starts_with($elementId, 'NEW')) {
$existingRefs = $this->liveReferences((int)$elementId, $fieldName);
if ($existingRefs !== []) {
$first = array_shift($existingRefs);
if ((int)$first['uid_local'] === $file->getUid()) {
$this->report[] = $fieldName . ': ' . $fileName . ' already attached - unchanged';
} else {
$this->datamap['sys_file_reference'][(int)$first['uid']] = ['uid_local' => $file->getUid()];
$this->report[] = $fieldName . ': ' . $fileName . ' swapped onto the existing reference';
}
foreach ($existingRefs as $surplus) {
$this->cmdmap['sys_file_reference'][(int)$surplus['uid']]['delete'] = 1;
$this->report[] = $fieldName . ': removed duplicate reference #' . $surplus['uid'];
}
return;
}
}
$refId = $this->newId();
// pid, tablenames and uid_foreign are REQUIRED - without them
// DataHandler silently rejects the reference (lesson 2026-10-02).
$this->datamap['sys_file_reference'][$refId] = [
'pid' => $pid,
'table_local' => 'sys_file',
'uid_local' => $file->getUid(),
'tablenames' => 'tt_content',
'fieldname' => $fieldName,
'uid_foreign' => str_starts_with($elementId, 'NEW') ? $elementId : (int)$elementId,
];
$this->datamap['tt_content'][$elementId][$fieldName] = $refId;
$this->report[] = $fieldName . ': downloaded ' . $fileName . ' - review editorially';
$state = $this->attachFalFile($elementId, $fieldName, $file, $pid);
$this->report[] = match ($state) {
'unchanged' => $fieldName . ': ' . $fileName . ' already attached - unchanged',
'swapped' => $fieldName . ': ' . $fileName . ' swapped onto the existing reference',
default => $fieldName . ': downloaded ' . $fileName . ' - review editorially',
};
} catch (\Throwable $e) {
$this->report[] = $fieldName . ': DOWNLOAD FAILED (' . $e->getMessage() . ') - attach editorially';
$this->attachPlaceholder($elementId, $fieldName, $apply, $pid, $slot);
}
}
/**
* Attach one already-indexed FAL file to an element's file field,
* re-import-idempotent BY CONTENT (lesson 2026-10-02): reuse the first
* live reference, delete our own surplus duplicates. Returns 'unchanged',
* 'swapped' or 'attached' for the caller's report line.
*/
private function attachFalFile(string $elementId, string $fieldName, \TYPO3\CMS\Core\Resource\File $file, int|string $pid): string
{
if (!str_starts_with($elementId, 'NEW')) {
$existingRefs = $this->liveReferences((int)$elementId, $fieldName);
if ($existingRefs !== []) {
$first = array_shift($existingRefs);
$state = 'unchanged';
if ((int)$first['uid_local'] !== $file->getUid()) {
$this->datamap['sys_file_reference'][(int)$first['uid']] = ['uid_local' => $file->getUid()];
$state = 'swapped';
}
foreach ($existingRefs as $surplus) {
$this->cmdmap['sys_file_reference'][(int)$surplus['uid']]['delete'] = 1;
$this->report[] = $fieldName . ': removed duplicate reference #' . $surplus['uid'];
}
return $state;
}
}
$refId = $this->newId();
// pid, tablenames and uid_foreign are REQUIRED - without them
// DataHandler silently rejects the reference (lesson 2026-10-02).
$this->datamap['sys_file_reference'][$refId] = [
'pid' => $pid,
'table_local' => 'sys_file',
'uid_local' => $file->getUid(),
'tablenames' => 'tt_content',
'fieldname' => $fieldName,
'uid_foreign' => str_starts_with($elementId, 'NEW') ? $elementId : (int)$elementId,
];
$this->datamap['tt_content'][$elementId][$fieldName] = $refId;
return 'attached';
}
/**
* Attach a flow-card icon by its workbook name. The SVGs under
* fileadmin/icons/flow/<name>.svg start as labelled DUMMIES (2026-10-19);
* the real icons later REPLACE those files in the Filelist module (same
* sys_file uid), so every card follows without a re-import. An unknown
* name is reported and skipped - probably a workbook typo.
*/
private function attachIconFile(string $elementId, int $cardNo, string $iconName, bool $apply, int|string $pid): void
{
$identifier = 'icons/flow/' . mb_strtolower(trim($iconName)) . '.svg';
$storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage();
if (!$storage->hasFile($identifier)) {
$this->report[] = 'card ' . $cardNo . ': icon "' . $iconName . '" has no file fileadmin/' . $identifier . ' - NOT attached (typo in the workbook?)';
return;
}
if (!$apply) {
$this->report[] = 'card ' . $cardNo . ': icon "' . $iconName . '" -> would attach ' . $identifier;
return;
}
$state = $this->attachFalFile($elementId, 'vitec_icon', $storage->getFile($identifier), $pid);
if ($state !== 'unchanged') {
$this->report[] = 'card ' . $cardNo . ': icon "' . $iconName . '" ' . ($state === 'swapped' ? 'swapped onto the existing reference' : 'attached (' . $identifier . ')');
}
}
/**
* Page abstract from the intro copy: tags stripped, whitespace collapsed,
* cut at a word boundary near 300 characters; the SEO description is the
* fallback when the workbook carries no intro text.
*/
private function abstractFrom(string $introHtml, string $fallback): string
{
$plain = trim((string)preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags($introHtml), ENT_QUOTES | ENT_HTML5)));
if ($plain === '') {
return $fallback;
}
if (mb_strlen($plain) <= 300) {
return $plain;
}
$cut = mb_substr($plain, 0, 300);
$pos = mb_strrpos($cut, ' ');
return rtrim(mb_substr($cut, 0, $pos ?: 300), ' ,;:.') . "\u{2026}";
}
/**
* Grey stand-in for an empty or failed image slot (Olli, 2026-10-19):
* every image slot carries a visible placeholder until the real asset
* arrives. A slot that already carries ANY reference - a real image from
* an earlier run or attached editorially - is never touched; a later
* re-import with a real URL swaps the placeholder away through
* attachFalFile's content idempotency.
*/
private function attachPlaceholder(string $elementId, string $fieldName, bool $apply, int|string $pid, int $slot = 0): void
{
if (!str_starts_with($elementId, 'NEW') && $this->liveReferences((int)$elementId, $fieldName) !== []) {
return;
}
$storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage();
// Numbered per page and slot - "#501-02" is rendered INTO the image -
// so a review can name the exact asset to deliver. The generic grey
// file covers pages without a pre-rendered set (e.g. brand-new ones).
$identifier = null;
if ($slot > 0 && !str_starts_with((string)$pid, 'NEW')) {
$candidate = 'placeholders/pages/' . $pid . '-' . str_pad((string)$slot, 2, '0', STR_PAD_LEFT) . '.png';
if ($storage->hasFile($candidate)) {
$identifier = $candidate;
}
}
$identifier ??= 'placeholders/' . ($fieldName === 'vitec_bgimage' ? 'placeholder-grey-wide.png' : 'placeholder-grey.png');
if (!$storage->hasFile($identifier)) {
$this->report[] = $fieldName . ': empty in the workbook and fileadmin/' . $identifier . ' is missing - left empty';
return;
}
if (!$apply) {
$this->report[] = $fieldName . ': empty in the workbook - would attach ' . $identifier;
return;
}
if ($this->attachFalFile($elementId, $fieldName, $storage->getFile($identifier), $pid) !== 'unchanged') {
$this->report[] = $fieldName . ': placeholder ' . basename($identifier) . ' attached - swap in the real asset later';
}
}