diff --git a/.gitignore b/.gitignore index 5e36659..afa41fe 100644 --- a/.gitignore +++ b/.gitignore @@ -129,3 +129,7 @@ CLAUDE.local.md migrations/products_xlsx/* !migrations/products_xlsx/manifest.json /daten.md + +# Workbook inboxes: filled customer/agency workbooks live in the Nextcloud +migrations/submarkets_xlsx_in/* +migrations/subsolutions_xlsx_in/* diff --git a/migrations/submarkets_xlsx_in/Broadcast & Media.xlsx b/migrations/submarkets_xlsx_in/Broadcast & Media.xlsx deleted file mode 100644 index 06feb90..0000000 Binary files a/migrations/submarkets_xlsx_in/Broadcast & Media.xlsx and /dev/null differ diff --git a/migrations/subsolutions_xlsx_in/Control Room Platforms SOC NOC Command Centres.xlsx b/migrations/subsolutions_xlsx_in/Control Room Platforms SOC NOC Command Centres.xlsx deleted file mode 100644 index 1bd0334..0000000 Binary files a/migrations/subsolutions_xlsx_in/Control Room Platforms SOC NOC Command Centres.xlsx and /dev/null differ diff --git a/packages/vitec/Classes/Import/ProductFamilyAliases.php b/packages/vitec/Classes/Import/ProductFamilyAliases.php new file mode 100644 index 0000000..ff365e3 --- /dev/null +++ b/packages/vitec/Classes/Import/ProductFamilyAliases.php @@ -0,0 +1,57 @@ + plus, + * everything non-alphanumeric stripped. Values are product slugs - stable + * across environments, unlike uids. + */ +final class ProductFamilyAliases +{ + private const MAP = [ + 'vsnplatforms' => 'vsn-v3', + 'visionsc' => 'visionsc-uhd2', + 'visionio' => 'visionio-sd14', + 'wallcontrol' => 'aetria-wallcontrol', + 'imagegraphics' => 'image-4k', + 'xseries' => 'fx4', + 'endpoints' => 'avedia-end-points', + 'mgwproductfamily' => 'mgw-ace-encoder', + 'diamondip' => 'diamond-ip-og-ip-to-ip-encoder', + 'ogplatform' => 'mgw-diamond-og-sdi-composite-blade-encoder', + 'capturecards' => 'visionav-hd', + ]; + + /** + * @return array|null the aliased product row, or null when + * the label is no known family + */ + public static function resolve(string $normalisedLabel): ?array + { + $slug = self::MAP[$normalisedLabel] ?? null; + if ($slug === null) { + return null; + } + $row = GeneralUtility::makeInstance(ConnectionPool::class) + ->getConnectionForTable('tx_vitec_domain_model_product') + ->fetchAssociative( + 'SELECT * FROM tx_vitec_domain_model_product WHERE deleted = 0 AND slug = ?', + [$slug] + ); + return $row ?: null; + } +} diff --git a/packages/vitec/Classes/Import/SubmarketWorkbookImporter.php b/packages/vitec/Classes/Import/SubmarketWorkbookImporter.php index 596cc54..0be920a 100644 --- a/packages/vitec/Classes/Import/SubmarketWorkbookImporter.php +++ b/packages/vitec/Classes/Import/SubmarketWorkbookImporter.php @@ -34,8 +34,9 @@ use TYPO3\CMS\Core\Utility\GeneralUtility; * warning, never an abort). * * Idempotent: re-importing an unchanged workbook updates fields to the same - * values. Flip-card icons are NOTED but not attached yet - the icon set is - * still open with the front-end team. + * values. Flip-card icons attach fileadmin/icons/flow/.svg - labelled + * dummies until the real set REPLACES those files in the Filelist + * (2026-10-19), at which point every reference follows automatically. */ final class SubmarketWorkbookImporter { @@ -139,8 +140,8 @@ final class SubmarketWorkbookImporter 'vitec_video_title' => (string)($values['hero_video_title'] ?? ''), 'vitec_video_url' => (string)($values['hero_video_url'] ?? ''), ]); - $this->attachUrlFile($heroId, 'vitec_bgimage', (string)($values['hero_bgimage_url'] ?? ''), $submarket, $apply, $this->pidValue($pageId)); - $this->attachUrlFile($heroId, 'vitec_video_cover', (string)($values['hero_video_cover_url'] ?? ''), $submarket, $apply, $this->pidValue($pageId)); + $this->attachUrlFile($heroId, 'vitec_bgimage', (string)($values['hero_bgimage_url'] ?? ''), $submarket, $apply, $this->pidValue($pageId), 1); + $this->attachUrlFile($heroId, 'vitec_video_cover', (string)($values['hero_video_cover_url'] ?? ''), $submarket, $apply, $this->pidValue($pageId), 2); // --- slot 120: intro ---------------------------------------------------- $introId = $this->slotElement($pageId, $subPage, self::COLPOS_INTRO, 'vitec_marketintro'); @@ -150,7 +151,7 @@ final class SubmarketWorkbookImporter 'vitec_learnmore_target' => (string)($values['intro_learnmore_target'] ?? ''), 'vitec_show_portfolio' => strcasecmp(trim((string)($values['intro_portfolio'] ?? 'Show')), 'Hide') === 0 ? 0 : 1, ]); - $this->attachUrlFile($introId, 'vitec_image', (string)($values['intro_image_url'] ?? ''), $submarket, $apply, $this->pidValue($pageId)); + $this->attachUrlFile($introId, 'vitec_image', (string)($values['intro_image_url'] ?? ''), $submarket, $apply, $this->pidValue($pageId), 3); // --- slot 130: workflow (intro-paragraph + flip cards) ------------------ $flowHeaderId = $this->slotElement($pageId, $subPage, self::COLPOS_FLOW, 'vitec_introparagraph'); @@ -183,7 +184,7 @@ final class SubmarketWorkbookImporter '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++; } @@ -224,6 +225,10 @@ final class SubmarketWorkbookImporter $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']; @@ -240,6 +245,9 @@ final class SubmarketWorkbookImporter 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']; @@ -265,7 +273,7 @@ final class SubmarketWorkbookImporter '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'] ?? ''), $submarket, $apply, $this->pidValue($pageId)); + $this->attachUrlFile($insightsId, 'vitec_video_cover', (string)($values['insights_video_cover_url'] ?? ''), $submarket, $apply, $this->pidValue($pageId), 4); // --- market record link ----------------------------------------------------- if ($recordUid > 0 && !str_starts_with($pageId, 'NEW')) { @@ -526,10 +534,11 @@ final class SubmarketWorkbookImporter * 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 $submarket, bool $apply, int|string $pid = 0): void + private function attachUrlFile(string $elementId, string $fieldName, string $url, string $submarket, 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) { @@ -545,11 +554,13 @@ final class SubmarketWorkbookImporter ->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(); @@ -560,46 +571,117 @@ final class SubmarketWorkbookImporter file_put_contents($temp, $content); $file = $storage->addFile($temp, $folder, $fileName, DuplicationBehavior::REPLACE); - // Re-import idempotency BY CONTENT (lesson 2026-10-02: passing a - // fresh NEW reference every run made DataHandler STACK live - // references - two visible images after two runs). An existing - // element therefore reuses its first live reference; surplus - // live references on the same field - our own earlier duplicates - - // are deleted via cmdmap. - 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 rejects the reference (found 2026-10-02: downloads - // succeeded, attachments silently did not). - $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/.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 . ')'); + } + } + + /** + * 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'; } } diff --git a/packages/vitec/Classes/Import/SubsolutionWorkbookImporter.php b/packages/vitec/Classes/Import/SubsolutionWorkbookImporter.php index 6c15f8c..5152663 100644 --- a/packages/vitec/Classes/Import/SubsolutionWorkbookImporter.php +++ b/packages/vitec/Classes/Import/SubsolutionWorkbookImporter.php @@ -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>> */ @@ -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/.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'; } } diff --git a/packages/vitec/Documentation/Headless-JSON-Architecture.md b/packages/vitec/Documentation/Headless-JSON-Architecture.md index e18cf38..a367075 100755 --- a/packages/vitec/Documentation/Headless-JSON-Architecture.md +++ b/packages/vitec/Documentation/Headless-JSON-Architecture.md @@ -3,9 +3,9 @@ | | | |---|---| | **Document identifier** | EVO‑VITEC‑HL‑001 | -| **Version** | 1.21 | +| **Version** | 1.23 | | **Status** | Released | -| **Date** | 2026‑10‑07 | +| **Date** | 2026‑10‑09 | | **Applies to** | `evomedien/vitec` on TYPO3 v14.3 (headless) | | **Owner** | evomedien — VITEC relaunch |