diff --git a/migrations/fill_test_subsolution.php b/migrations/fill_test_subsolution.php new file mode 100644 index 0000000..e9e859e --- /dev/null +++ b/migrations/fill_test_subsolution.php @@ -0,0 +1,146 @@ +getSheetByName('Lists'); +$column = static function (string $col) use ($lists): array { + $values = []; + for ($r = 1; $r <= $lists->getHighestRow(); $r++) { + $v = trim((string)$lists->getCell($col . $r)->getValue()); + if ($v !== '') { + $values[] = $v; + } + } + return $values; +}; +$stories = $column('A'); +$products = $column('B'); + +/** First list entry matching each keyword, in keyword order, no duplicates. */ +$pick = static function (array $list, array $keywords, int $max) : array { + $chosen = []; + foreach ($keywords as $keyword) { + foreach ($list as $entry) { + if (stripos($entry, $keyword) !== false && !in_array($entry, $chosen, true)) { + $chosen[] = $entry; + break; + } + } + if (count($chosen) >= $max) { + break; + } + } + return $chosen; +}; + +$storyPicks = $pick($stories, ['National Defense', 'Defense Visual', 'ISR Video Mission', 'Disease Control', 'Parliament', 'Police', 'Government'], 3); +$productPicks = $pick($products, ['VSN V3', 'VSNMicro', 'VSNMini', 'Aligo', 'QTX100', 'WallControl', 'Fx4'], 4); +$featured = array_shift($productPicks) ?? ''; + +// ---- content ---------------------------------------------------------------- +$values = [ + 'hero_subline' => 'Unified, low-latency video visualisation for security, network and command operations - built for 24/7 reliability.', + 'hero_cta_label' => 'Talk to an expert', + 'hero_cta_url' => '/contact', + 'hero_bgimage_url' => 'https://www.vitec.com/fileadmin/user_upload/gov_16-9.jpg', + + 'intro_headline' => 'Every source, every screen, one operational picture', + 'intro_text' => 'Security operations centres, network operations centres and command centres all face the same challenge: operators must see and act on many live sources at once - CCTV, IP streams, dashboards, sensor data and broadcast feeds - without delay and without interruption. VITEC control room platforms bring these sources together on configurable video walls and operator workstations. VSN Series controllers render any mix of HDMI, DisplayPort, SDI and IP inputs, Aligo extends the wall to remote positions with visually lossless 4K and sub-millisecond latency, and WallControl 10 lets operators rearrange layouts in seconds as situations evolve.', + 'intro_image_url' => 'https://www.vitec.com/fileadmin/user_upload/control-room_control-launch.png', + + 'flow_headline' => 'Built for mission-critical operations', + 'flow_subtext' => 'VITEC control room platforms are designed for environments where every second counts and downtime is not an option. Key advantages include:', + + 'flow_card1_icon' => 'capture', + 'flow_card1_title' => 'Ingest', + 'flow_card1_front' => 'Bring every source onto the wall: HDMI, DisplayPort, SDI and IP streams.', + 'flow_card1_back' => 'Vision and ActiveSQX capture cards ingest local and networked sources side by side, so CCTV, PCs, dashboards and broadcast feeds share one display surface.', + + 'flow_card2_icon' => 'view', + 'flow_card2_title' => 'Visualise', + 'flow_card2_front' => 'Build any wall layout and change it in seconds with WallControl 10.', + 'flow_card2_back' => 'Operators drag and drop sources, recall saved layouts per shift or incident, and scale from a single screen to walls of 24 displays and beyond.', + + 'flow_card3_icon' => 'distribute', + 'flow_card3_title' => 'Extend', + 'flow_card3_front' => 'Take the wall to every operator position with Aligo AVoIP.', + 'flow_card3_back' => 'Visually lossless 4K60 over 10Gb networks with sub-millisecond latency, plus KVM control of remote sources - ideal for supervisor desks and overflow rooms.', + + 'flow_card4_icon' => 'respond', + 'flow_card4_title' => 'Stay resilient', + 'flow_card4_front' => 'Rack-mounted VSN V3 controllers with dual-redundant power for 24/7 duty.', + 'flow_card4_back' => 'Hardware-based rendering keeps playback smooth and synchronised, while redundant power supplies keep the picture up when it matters most.', + + 'testi_headline' => 'Trusted in control rooms worldwide', + + 'prod_featured' => $featured, + + 'insights_text1' => 'Modern control rooms are moving from fixed matrix switching to flexible, IP-based visualisation - for faster situational awareness, simpler expansion and lower operating costs.', + 'insights_text2' => 'See how organisations in defense, government and critical infrastructure use VITEC technology to keep operators informed and in control:', + 'insights_cta_label' => 'Explore success stories', + 'insights_cta_target' => '/success-stories', + + 'seo_title' => 'Control Room Platforms for SOC, NOC & Command Centres | VITEC', + 'seo_description' => 'Unify CCTV, IP streams and data on resilient video walls: VITEC VSN controllers, Aligo AVoIP and WallControl 10 for SOC, NOC and command centres.', +]; +foreach ($storyPicks as $i => $story) { + $values['testi_story_' . ($i + 1)] = $story; +} +foreach ($productPicks as $i => $product) { + $values['prod_card_' . ($i + 1)] = $product; +} + +// ---- write via named ranges ------------------------------------------------- +$missing = []; +foreach ($values as $name => $value) { + $range = $spreadsheet->getNamedRange($name); + if ($range === null || $range->getWorksheet() === null) { + $missing[] = $name; + continue; + } + $cell = str_replace('$', '', (string)$range->getValue()); + $cell = str_contains($cell, '!') ? substr($cell, strpos($cell, '!') + 1) : $cell; + $range->getWorksheet()->getCell($cell)->setValue($value); +} + +@mkdir(dirname($target), 0775, true); +(new Xlsx($spreadsheet))->save($target); +$spreadsheet->disconnectWorksheets(); +@chmod($target, 0664); + +echo "Written: " . $target . "\n"; +echo "Featured product: " . ($featured !== '' ? $featured : '(none found!)') . "\n"; +echo "Product cards: " . ($productPicks !== [] ? implode(' | ', $productPicks) : '(none found)') . "\n"; +echo "Case studies: " . ($storyPicks !== [] ? implode(' | ', $storyPicks) : '(none found)') . "\n"; +if ($missing !== []) { + echo "WARNING - named ranges not found: " . implode(', ', $missing) . "\n"; +} diff --git a/migrations/submarkets_xlsx_out/Corporate & Enterprise/Corporate Environments.xlsx b/migrations/submarkets_xlsx_out/Corporate & Enterprise/Corporate Environments.xlsx new file mode 100644 index 0000000..42e50f7 Binary files /dev/null and b/migrations/submarkets_xlsx_out/Corporate & Enterprise/Corporate Environments.xlsx differ diff --git a/migrations/submarkets_xlsx_out/Corporate & Enterprise/Enterprise Operations.xlsx b/migrations/submarkets_xlsx_out/Corporate & Enterprise/Enterprise Operations.xlsx new file mode 100644 index 0000000..e20c1e2 Binary files /dev/null and b/migrations/submarkets_xlsx_out/Corporate & Enterprise/Enterprise Operations.xlsx differ diff --git a/migrations/submarkets_xlsx_out/Defense Government & Public Sector/Defense Military.xlsx b/migrations/submarkets_xlsx_out/Defense Government & Public Sector/Defense Military.xlsx new file mode 100644 index 0000000..95c772e Binary files /dev/null and b/migrations/submarkets_xlsx_out/Defense Government & Public Sector/Defense Military.xlsx differ diff --git a/migrations/submarkets_xlsx_out/Defense Government & Public Sector/Emergency Services.xlsx b/migrations/submarkets_xlsx_out/Defense Government & Public Sector/Emergency Services.xlsx new file mode 100644 index 0000000..14579be Binary files /dev/null and b/migrations/submarkets_xlsx_out/Defense Government & Public Sector/Emergency Services.xlsx differ diff --git a/migrations/submarkets_xlsx_out/Defense Government & Public Sector/Government.xlsx b/migrations/submarkets_xlsx_out/Defense Government & Public Sector/Government.xlsx new file mode 100644 index 0000000..63e5890 Binary files /dev/null and b/migrations/submarkets_xlsx_out/Defense Government & Public Sector/Government.xlsx differ diff --git a/migrations/submarkets_xlsx_out/Energy Industrial & Resources/Manufacturing.xlsx b/migrations/submarkets_xlsx_out/Energy Industrial & Resources/Manufacturing.xlsx new file mode 100644 index 0000000..82e2f5c Binary files /dev/null and b/migrations/submarkets_xlsx_out/Energy Industrial & Resources/Manufacturing.xlsx differ diff --git a/migrations/submarkets_xlsx_out/Energy Industrial & Resources/Mining.xlsx b/migrations/submarkets_xlsx_out/Energy Industrial & Resources/Mining.xlsx new file mode 100644 index 0000000..e66e823 Binary files /dev/null and b/migrations/submarkets_xlsx_out/Energy Industrial & Resources/Mining.xlsx differ diff --git a/migrations/submarkets_xlsx_out/Energy Industrial & Resources/Oil & Gas.xlsx b/migrations/submarkets_xlsx_out/Energy Industrial & Resources/Oil & Gas.xlsx new file mode 100644 index 0000000..8ffd9cb Binary files /dev/null and b/migrations/submarkets_xlsx_out/Energy Industrial & Resources/Oil & Gas.xlsx differ diff --git a/migrations/submarkets_xlsx_out/Energy Industrial & Resources/Utilities.xlsx b/migrations/submarkets_xlsx_out/Energy Industrial & Resources/Utilities.xlsx new file mode 100644 index 0000000..98d419e Binary files /dev/null and b/migrations/submarkets_xlsx_out/Energy Industrial & Resources/Utilities.xlsx differ diff --git a/migrations/submarkets_xlsx_out/Healthcare & Education/Education.xlsx b/migrations/submarkets_xlsx_out/Healthcare & Education/Education.xlsx new file mode 100644 index 0000000..4d18fd5 Binary files /dev/null and b/migrations/submarkets_xlsx_out/Healthcare & Education/Education.xlsx differ diff --git a/migrations/submarkets_xlsx_out/Healthcare & Education/Healthcare.xlsx b/migrations/submarkets_xlsx_out/Healthcare & Education/Healthcare.xlsx new file mode 100644 index 0000000..c59f958 Binary files /dev/null and b/migrations/submarkets_xlsx_out/Healthcare & Education/Healthcare.xlsx differ diff --git a/migrations/submarkets_xlsx_out/Media Broadcast & Telecom/Broadcast & Media.xlsx b/migrations/submarkets_xlsx_out/Media Broadcast & Telecom/Broadcast & Media.xlsx index 712332a..da14691 100644 Binary files a/migrations/submarkets_xlsx_out/Media Broadcast & Telecom/Broadcast & Media.xlsx and b/migrations/submarkets_xlsx_out/Media Broadcast & Telecom/Broadcast & Media.xlsx differ diff --git a/migrations/submarkets_xlsx_out/Media Broadcast & Telecom/Telecoms & Networks.xlsx b/migrations/submarkets_xlsx_out/Media Broadcast & Telecom/Telecoms & Networks.xlsx new file mode 100644 index 0000000..781d2ad Binary files /dev/null and b/migrations/submarkets_xlsx_out/Media Broadcast & Telecom/Telecoms & Networks.xlsx differ diff --git a/migrations/submarkets_xlsx_out/Retail Hospitality & Leisure/Accomodation hotels.xlsx b/migrations/submarkets_xlsx_out/Retail Hospitality & Leisure/Accomodation hotels.xlsx new file mode 100644 index 0000000..d982708 Binary files /dev/null and b/migrations/submarkets_xlsx_out/Retail Hospitality & Leisure/Accomodation hotels.xlsx differ diff --git a/migrations/submarkets_xlsx_out/Retail Hospitality & Leisure/Hospitality.xlsx b/migrations/submarkets_xlsx_out/Retail Hospitality & Leisure/Hospitality.xlsx new file mode 100644 index 0000000..8b839f7 Binary files /dev/null and b/migrations/submarkets_xlsx_out/Retail Hospitality & Leisure/Hospitality.xlsx differ diff --git a/migrations/submarkets_xlsx_out/Retail Hospitality & Leisure/Retail.xlsx b/migrations/submarkets_xlsx_out/Retail Hospitality & Leisure/Retail.xlsx new file mode 100644 index 0000000..c68cf38 Binary files /dev/null and b/migrations/submarkets_xlsx_out/Retail Hospitality & Leisure/Retail.xlsx differ diff --git a/migrations/submarkets_xlsx_out/Security & Surveillance/Control rooms.xlsx b/migrations/submarkets_xlsx_out/Security & Surveillance/Control rooms.xlsx new file mode 100644 index 0000000..363c5d8 Binary files /dev/null and b/migrations/submarkets_xlsx_out/Security & Surveillance/Control rooms.xlsx differ diff --git a/migrations/submarkets_xlsx_out/Security & Surveillance/Surveillance & Monitoring.xlsx b/migrations/submarkets_xlsx_out/Security & Surveillance/Surveillance & Monitoring.xlsx new file mode 100644 index 0000000..918d19b Binary files /dev/null and b/migrations/submarkets_xlsx_out/Security & Surveillance/Surveillance & Monitoring.xlsx differ diff --git a/migrations/submarkets_xlsx_out/Sports Venues & Entertainment/Casinos & Sports Books.xlsx b/migrations/submarkets_xlsx_out/Sports Venues & Entertainment/Casinos & Sports Books.xlsx new file mode 100644 index 0000000..c1f1e70 Binary files /dev/null and b/migrations/submarkets_xlsx_out/Sports Venues & Entertainment/Casinos & Sports Books.xlsx differ diff --git a/migrations/submarkets_xlsx_out/Sports Venues & Entertainment/Events & Experiences.xlsx b/migrations/submarkets_xlsx_out/Sports Venues & Entertainment/Events & Experiences.xlsx new file mode 100644 index 0000000..cb43d5f Binary files /dev/null and b/migrations/submarkets_xlsx_out/Sports Venues & Entertainment/Events & Experiences.xlsx differ diff --git a/migrations/submarkets_xlsx_out/Sports Venues & Entertainment/Motorsport.xlsx b/migrations/submarkets_xlsx_out/Sports Venues & Entertainment/Motorsport.xlsx new file mode 100644 index 0000000..c60a838 Binary files /dev/null and b/migrations/submarkets_xlsx_out/Sports Venues & Entertainment/Motorsport.xlsx differ diff --git a/migrations/submarkets_xlsx_out/Sports Venues & Entertainment/Sports Clubs.xlsx b/migrations/submarkets_xlsx_out/Sports Venues & Entertainment/Sports Clubs.xlsx new file mode 100644 index 0000000..8f01572 Binary files /dev/null and b/migrations/submarkets_xlsx_out/Sports Venues & Entertainment/Sports Clubs.xlsx differ diff --git a/migrations/submarkets_xlsx_out/Sports Venues & Entertainment/Venues arenas theatres and stadia.xlsx b/migrations/submarkets_xlsx_out/Sports Venues & Entertainment/Venues arenas theatres and stadia.xlsx new file mode 100644 index 0000000..464c00b Binary files /dev/null and b/migrations/submarkets_xlsx_out/Sports Venues & Entertainment/Venues arenas theatres and stadia.xlsx differ diff --git a/migrations/submarkets_xlsx_out/Transport & Infrastructure/Aviation.xlsx b/migrations/submarkets_xlsx_out/Transport & Infrastructure/Aviation.xlsx new file mode 100644 index 0000000..66e8f7b Binary files /dev/null and b/migrations/submarkets_xlsx_out/Transport & Infrastructure/Aviation.xlsx differ diff --git a/migrations/submarkets_xlsx_out/Transport & Infrastructure/Traffic & Smart Mobility.xlsx b/migrations/submarkets_xlsx_out/Transport & Infrastructure/Traffic & Smart Mobility.xlsx new file mode 100644 index 0000000..41c51bb Binary files /dev/null and b/migrations/submarkets_xlsx_out/Transport & Infrastructure/Traffic & Smart Mobility.xlsx 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 new file mode 100644 index 0000000..1bd0334 Binary files /dev/null and b/migrations/subsolutions_xlsx_in/Control Room Platforms SOC NOC Command Centres.xlsx differ diff --git a/migrations/subsolutions_xlsx_out/Control Room & Command Centre Solutions/Command & Control Visualisation.xlsx b/migrations/subsolutions_xlsx_out/Control Room & Command Centre Solutions/Command & Control Visualisation.xlsx new file mode 100644 index 0000000..6b6fa5d Binary files /dev/null and b/migrations/subsolutions_xlsx_out/Control Room & Command Centre Solutions/Command & Control Visualisation.xlsx differ diff --git a/migrations/subsolutions_xlsx_out/Control Room & Command Centre Solutions/Control Room Platforms SOC NOC Command Centres.xlsx b/migrations/subsolutions_xlsx_out/Control Room & Command Centre Solutions/Control Room Platforms SOC NOC Command Centres.xlsx new file mode 100644 index 0000000..906d606 Binary files /dev/null and b/migrations/subsolutions_xlsx_out/Control Room & Command Centre Solutions/Control Room Platforms SOC NOC Command Centres.xlsx differ diff --git a/migrations/subsolutions_xlsx_out/Control Room & Command Centre Solutions/Crisis & Incident Management Centres.xlsx b/migrations/subsolutions_xlsx_out/Control Room & Command Centre Solutions/Crisis & Incident Management Centres.xlsx new file mode 100644 index 0000000..6d43019 Binary files /dev/null and b/migrations/subsolutions_xlsx_out/Control Room & Command Centre Solutions/Crisis & Incident Management Centres.xlsx differ diff --git a/migrations/subsolutions_xlsx_out/Control Room & Command Centre Solutions/Operations Management Environments.xlsx b/migrations/subsolutions_xlsx_out/Control Room & Command Centre Solutions/Operations Management Environments.xlsx new file mode 100644 index 0000000..e2b1189 Binary files /dev/null and b/migrations/subsolutions_xlsx_out/Control Room & Command Centre Solutions/Operations Management Environments.xlsx differ diff --git a/migrations/subsolutions_xlsx_out/Digital Signage & Content Delivery/Advertising & Sponsorship Displays.xlsx b/migrations/subsolutions_xlsx_out/Digital Signage & Content Delivery/Advertising & Sponsorship Displays.xlsx new file mode 100644 index 0000000..ebcbd8b Binary files /dev/null and b/migrations/subsolutions_xlsx_out/Digital Signage & Content Delivery/Advertising & Sponsorship Displays.xlsx differ diff --git a/migrations/subsolutions_xlsx_out/Digital Signage & Content Delivery/Digital Signage Networks.xlsx b/migrations/subsolutions_xlsx_out/Digital Signage & Content Delivery/Digital Signage Networks.xlsx new file mode 100644 index 0000000..8465960 Binary files /dev/null and b/migrations/subsolutions_xlsx_out/Digital Signage & Content Delivery/Digital Signage Networks.xlsx differ diff --git a/migrations/subsolutions_xlsx_out/Digital Signage & Content Delivery/Passenger Public Information Displays.xlsx b/migrations/subsolutions_xlsx_out/Digital Signage & Content Delivery/Passenger Public Information Displays.xlsx new file mode 100644 index 0000000..ebeba10 Binary files /dev/null and b/migrations/subsolutions_xlsx_out/Digital Signage & Content Delivery/Passenger Public Information Displays.xlsx differ diff --git a/migrations/subsolutions_xlsx_out/Digital Signage & Content Delivery/Targeted Content Delivery.xlsx b/migrations/subsolutions_xlsx_out/Digital Signage & Content Delivery/Targeted Content Delivery.xlsx new file mode 100644 index 0000000..4287fbb Binary files /dev/null and b/migrations/subsolutions_xlsx_out/Digital Signage & Content Delivery/Targeted Content Delivery.xlsx differ diff --git a/migrations/subsolutions_xlsx_out/Digital Signage & Content Delivery/Wayfinding & Messaging.xlsx b/migrations/subsolutions_xlsx_out/Digital Signage & Content Delivery/Wayfinding & Messaging.xlsx new file mode 100644 index 0000000..f8c8a69 Binary files /dev/null and b/migrations/subsolutions_xlsx_out/Digital Signage & Content Delivery/Wayfinding & Messaging.xlsx differ diff --git a/migrations/subsolutions_xlsx_out/Enterprise Communications & Engagament/Corporate Broadcasting.xlsx b/migrations/subsolutions_xlsx_out/Enterprise Communications & Engagament/Corporate Broadcasting.xlsx new file mode 100644 index 0000000..3828868 Binary files /dev/null and b/migrations/subsolutions_xlsx_out/Enterprise Communications & Engagament/Corporate Broadcasting.xlsx differ diff --git a/migrations/subsolutions_xlsx_out/Enterprise Communications & Engagament/Guest Visitor Engagement.xlsx b/migrations/subsolutions_xlsx_out/Enterprise Communications & Engagament/Guest Visitor Engagement.xlsx new file mode 100644 index 0000000..811afcd Binary files /dev/null and b/migrations/subsolutions_xlsx_out/Enterprise Communications & Engagament/Guest Visitor Engagement.xlsx differ diff --git a/migrations/subsolutions_xlsx_out/Enterprise Communications & Engagament/Internal Communications IPTV.xlsx b/migrations/subsolutions_xlsx_out/Enterprise Communications & Engagament/Internal Communications IPTV.xlsx new file mode 100644 index 0000000..0ee91ab Binary files /dev/null and b/migrations/subsolutions_xlsx_out/Enterprise Communications & Engagament/Internal Communications IPTV.xlsx differ diff --git a/migrations/subsolutions_xlsx_out/Enterprise Communications & Engagament/Multi-location Communication Networks.xlsx b/migrations/subsolutions_xlsx_out/Enterprise Communications & Engagament/Multi-location Communication Networks.xlsx new file mode 100644 index 0000000..3024aa5 Binary files /dev/null and b/migrations/subsolutions_xlsx_out/Enterprise Communications & Engagament/Multi-location Communication Networks.xlsx differ diff --git a/migrations/subsolutions_xlsx_out/Enterprise Communications & Engagament/Staff Messaging Systems.xlsx b/migrations/subsolutions_xlsx_out/Enterprise Communications & Engagament/Staff Messaging Systems.xlsx new file mode 100644 index 0000000..0532083 Binary files /dev/null and b/migrations/subsolutions_xlsx_out/Enterprise Communications & Engagament/Staff Messaging Systems.xlsx differ diff --git a/migrations/subsolutions_xlsx_out/Real-time Monitoring & Situational Awareness/Incident Detection & Response.xlsx b/migrations/subsolutions_xlsx_out/Real-time Monitoring & Situational Awareness/Incident Detection & Response.xlsx new file mode 100644 index 0000000..6a418f6 Binary files /dev/null and b/migrations/subsolutions_xlsx_out/Real-time Monitoring & Situational Awareness/Incident Detection & Response.xlsx differ diff --git a/migrations/subsolutions_xlsx_out/Real-time Monitoring & Situational Awareness/Multi-source Video Monitoring.xlsx b/migrations/subsolutions_xlsx_out/Real-time Monitoring & Situational Awareness/Multi-source Video Monitoring.xlsx new file mode 100644 index 0000000..e8ac301 Binary files /dev/null and b/migrations/subsolutions_xlsx_out/Real-time Monitoring & Situational Awareness/Multi-source Video Monitoring.xlsx differ diff --git a/migrations/subsolutions_xlsx_out/Real-time Monitoring & Situational Awareness/Real-time Data Monitoring CCTV IoT sensors.xlsx b/migrations/subsolutions_xlsx_out/Real-time Monitoring & Situational Awareness/Real-time Data Monitoring CCTV IoT sensors.xlsx new file mode 100644 index 0000000..9d3a4f5 Binary files /dev/null and b/migrations/subsolutions_xlsx_out/Real-time Monitoring & Situational Awareness/Real-time Data Monitoring CCTV IoT sensors.xlsx differ diff --git a/migrations/subsolutions_xlsx_out/Real-time Monitoring & Situational Awareness/Situational Awareness Platforms.xlsx b/migrations/subsolutions_xlsx_out/Real-time Monitoring & Situational Awareness/Situational Awareness Platforms.xlsx new file mode 100644 index 0000000..423e6c3 Binary files /dev/null and b/migrations/subsolutions_xlsx_out/Real-time Monitoring & Situational Awareness/Situational Awareness Platforms.xlsx differ diff --git a/migrations/subsolutions_xlsx_out/Real-time Monitoring & Situational Awareness/Surveillance & Operational Visibility.xlsx b/migrations/subsolutions_xlsx_out/Real-time Monitoring & Situational Awareness/Surveillance & Operational Visibility.xlsx new file mode 100644 index 0000000..11fbe43 Binary files /dev/null and b/migrations/subsolutions_xlsx_out/Real-time Monitoring & Situational Awareness/Surveillance & Operational Visibility.xlsx differ diff --git a/migrations/subsolutions_xlsx_out/Security & Surveillance Solutions/Incident Response Systems.xlsx b/migrations/subsolutions_xlsx_out/Security & Surveillance Solutions/Incident Response Systems.xlsx new file mode 100644 index 0000000..4ead70e Binary files /dev/null and b/migrations/subsolutions_xlsx_out/Security & Surveillance Solutions/Incident Response Systems.xlsx differ diff --git a/migrations/subsolutions_xlsx_out/Security & Surveillance Solutions/Perimeter Monitoring.xlsx b/migrations/subsolutions_xlsx_out/Security & Surveillance Solutions/Perimeter Monitoring.xlsx new file mode 100644 index 0000000..95472b9 Binary files /dev/null and b/migrations/subsolutions_xlsx_out/Security & Surveillance Solutions/Perimeter Monitoring.xlsx differ diff --git a/migrations/subsolutions_xlsx_out/Security & Surveillance Solutions/Secure Monitoring Environments.xlsx b/migrations/subsolutions_xlsx_out/Security & Surveillance Solutions/Secure Monitoring Environments.xlsx new file mode 100644 index 0000000..f1fb065 Binary files /dev/null and b/migrations/subsolutions_xlsx_out/Security & Surveillance Solutions/Secure Monitoring Environments.xlsx differ diff --git a/migrations/subsolutions_xlsx_out/Security & Surveillance Solutions/Security Operations Centres SOC.xlsx b/migrations/subsolutions_xlsx_out/Security & Surveillance Solutions/Security Operations Centres SOC.xlsx new file mode 100644 index 0000000..8e36170 Binary files /dev/null and b/migrations/subsolutions_xlsx_out/Security & Surveillance Solutions/Security Operations Centres SOC.xlsx differ diff --git a/migrations/subsolutions_xlsx_out/Security & Surveillance Solutions/Surveillance & CCTV Integration.xlsx b/migrations/subsolutions_xlsx_out/Security & Surveillance Solutions/Surveillance & CCTV Integration.xlsx new file mode 100644 index 0000000..45c1dad Binary files /dev/null and b/migrations/subsolutions_xlsx_out/Security & Surveillance Solutions/Surveillance & CCTV Integration.xlsx differ diff --git a/migrations/subsolutions_xlsx_out/Video Capture Encoding & Processing/Contribution & Remote Production.xlsx b/migrations/subsolutions_xlsx_out/Video Capture Encoding & Processing/Contribution & Remote Production.xlsx new file mode 100644 index 0000000..5abd174 Binary files /dev/null and b/migrations/subsolutions_xlsx_out/Video Capture Encoding & Processing/Contribution & Remote Production.xlsx differ diff --git a/migrations/subsolutions_xlsx_out/Video Capture Encoding & Processing/Live Video Capture.xlsx b/migrations/subsolutions_xlsx_out/Video Capture Encoding & Processing/Live Video Capture.xlsx new file mode 100644 index 0000000..5cedc63 Binary files /dev/null and b/migrations/subsolutions_xlsx_out/Video Capture Encoding & Processing/Live Video Capture.xlsx differ diff --git a/migrations/subsolutions_xlsx_out/Video Capture Encoding & Processing/Signal Processing & Conversion.xlsx b/migrations/subsolutions_xlsx_out/Video Capture Encoding & Processing/Signal Processing & Conversion.xlsx new file mode 100644 index 0000000..33b0e65 Binary files /dev/null and b/migrations/subsolutions_xlsx_out/Video Capture Encoding & Processing/Signal Processing & Conversion.xlsx differ diff --git a/migrations/subsolutions_xlsx_out/Video Capture Encoding & Processing/Transcoding & Optimisation.xlsx b/migrations/subsolutions_xlsx_out/Video Capture Encoding & Processing/Transcoding & Optimisation.xlsx new file mode 100644 index 0000000..ed9a4a7 Binary files /dev/null and b/migrations/subsolutions_xlsx_out/Video Capture Encoding & Processing/Transcoding & Optimisation.xlsx differ diff --git a/migrations/subsolutions_xlsx_out/Video Capture Encoding & Processing/Video Encoding Decoding.xlsx b/migrations/subsolutions_xlsx_out/Video Capture Encoding & Processing/Video Encoding Decoding.xlsx new file mode 100644 index 0000000..eed2c80 Binary files /dev/null and b/migrations/subsolutions_xlsx_out/Video Capture Encoding & Processing/Video Encoding Decoding.xlsx differ diff --git a/migrations/subsolutions_xlsx_out/Video Distribution & Streaming/AV-over-IP Distribution.xlsx b/migrations/subsolutions_xlsx_out/Video Distribution & Streaming/AV-over-IP Distribution.xlsx new file mode 100644 index 0000000..35ca746 Binary files /dev/null and b/migrations/subsolutions_xlsx_out/Video Distribution & Streaming/AV-over-IP Distribution.xlsx differ diff --git a/migrations/subsolutions_xlsx_out/Video Distribution & Streaming/IPTV Distribution.xlsx b/migrations/subsolutions_xlsx_out/Video Distribution & Streaming/IPTV Distribution.xlsx new file mode 100644 index 0000000..3be51dd Binary files /dev/null and b/migrations/subsolutions_xlsx_out/Video Distribution & Streaming/IPTV Distribution.xlsx differ diff --git a/migrations/subsolutions_xlsx_out/Video Distribution & Streaming/Internal & External Broadcast Distribution.xlsx b/migrations/subsolutions_xlsx_out/Video Distribution & Streaming/Internal & External Broadcast Distribution.xlsx new file mode 100644 index 0000000..e971296 Binary files /dev/null and b/migrations/subsolutions_xlsx_out/Video Distribution & Streaming/Internal & External Broadcast Distribution.xlsx differ diff --git a/migrations/subsolutions_xlsx_out/Video Distribution & Streaming/Multi-site Video Delivery.xlsx b/migrations/subsolutions_xlsx_out/Video Distribution & Streaming/Multi-site Video Delivery.xlsx new file mode 100644 index 0000000..2816464 Binary files /dev/null and b/migrations/subsolutions_xlsx_out/Video Distribution & Streaming/Multi-site Video Delivery.xlsx differ diff --git a/migrations/subsolutions_xlsx_out/Video Distribution & Streaming/Secure Video Streaming low latency.xlsx b/migrations/subsolutions_xlsx_out/Video Distribution & Streaming/Secure Video Streaming low latency.xlsx new file mode 100644 index 0000000..f93745e Binary files /dev/null and b/migrations/subsolutions_xlsx_out/Video Distribution & Streaming/Secure Video Streaming low latency.xlsx differ diff --git a/migrations/subsolutions_xlsx_out/Video Wall & Data Visualisation/Data Aggregation & Display.xlsx b/migrations/subsolutions_xlsx_out/Video Wall & Data Visualisation/Data Aggregation & Display.xlsx new file mode 100644 index 0000000..859e40b Binary files /dev/null and b/migrations/subsolutions_xlsx_out/Video Wall & Data Visualisation/Data Aggregation & Display.xlsx differ diff --git a/migrations/subsolutions_xlsx_out/Video Wall & Data Visualisation/Multi-display Visualisation.xlsx b/migrations/subsolutions_xlsx_out/Video Wall & Data Visualisation/Multi-display Visualisation.xlsx new file mode 100644 index 0000000..f6cd38b Binary files /dev/null and b/migrations/subsolutions_xlsx_out/Video Wall & Data Visualisation/Multi-display Visualisation.xlsx differ diff --git a/migrations/subsolutions_xlsx_out/Video Wall & Data Visualisation/Operational Intelligence Displays.xlsx b/migrations/subsolutions_xlsx_out/Video Wall & Data Visualisation/Operational Intelligence Displays.xlsx new file mode 100644 index 0000000..0b51ee6 Binary files /dev/null and b/migrations/subsolutions_xlsx_out/Video Wall & Data Visualisation/Operational Intelligence Displays.xlsx differ diff --git a/migrations/subsolutions_xlsx_out/Video Wall & Data Visualisation/Real-time Dashboards.xlsx b/migrations/subsolutions_xlsx_out/Video Wall & Data Visualisation/Real-time Dashboards.xlsx new file mode 100644 index 0000000..a371a95 Binary files /dev/null and b/migrations/subsolutions_xlsx_out/Video Wall & Data Visualisation/Real-time Dashboards.xlsx differ diff --git a/migrations/subsolutions_xlsx_out/Video Wall & Data Visualisation/Video Wall Platforms.xlsx b/migrations/subsolutions_xlsx_out/Video Wall & Data Visualisation/Video Wall Platforms.xlsx new file mode 100644 index 0000000..02391c2 Binary files /dev/null and b/migrations/subsolutions_xlsx_out/Video Wall & Data Visualisation/Video Wall Platforms.xlsx differ diff --git a/packages/vitec/Classes/Command/GenerateSubsolutionWorkbooksCommand.php b/packages/vitec/Classes/Command/GenerateSubsolutionWorkbooksCommand.php new file mode 100644 index 0000000..3269943 --- /dev/null +++ b/packages/vitec/Classes/Command/GenerateSubsolutionWorkbooksCommand.php @@ -0,0 +1,492 @@ +addOption('out', null, InputOption::VALUE_REQUIRED, 'Output directory', 'migrations/subsolutions_xlsx_out'); + $this->addOption('only', null, InputOption::VALUE_REQUIRED, 'Only sub-solutions whose title contains this substring'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + Bootstrap::initializeBackendAuthentication(); + + $outDir = rtrim((string)$input->getOption('out'), '/'); + $only = mb_strtolower((string)($input->getOption('only') ?? '')); + + $groups = $this->solutionGroups(); + if ($groups === []) { + $output->writeln('No sub-solution pages found below /solutions.'); + return Command::FAILURE; + } + $stories = $this->titles('tx_vitec_domain_model_usecase', false); + $products = $this->titles('tx_vitec_domain_model_product', true); + $recordByTitle = $this->solutionRecordsByNormTitle(); + + $count = 0; + foreach ($groups as $group) { + foreach ($group['children'] as $subsolution) { + if ($only !== '' && !str_contains(mb_strtolower($subsolution), $only)) { + continue; + } + $record = $recordByTitle[$this->norm($subsolution)] ?? null; + $dir = $outDir . '/' . $this->fileSafe($group['title']); + GeneralUtility::mkdir_deep($dir); + $file = $dir . '/' . $this->fileSafe($subsolution) . '.xlsx'; + $this->writeWorkbook($file, $subsolution, $group['title'], $record, $stories, $products); + $output->writeln(sprintf('%s (record: %s)', $file, $record ? '#' . $record['uid'] : 'KEINER')); + $count++; + } + } + $output->writeln(sprintf("\n%d workbook(s) written to %s/", $count, $outDir)); + return Command::SUCCESS; + } + + /** + * @param array{uid:int,title:string}|null $record + * @param string[] $stories + * @param string[] $products + */ + private function writeWorkbook(string $file, string $subsolution, string $group, ?array $record, array $stories, array $products): void + { + $spreadsheet = new Spreadsheet(); + + // ---- Instructions (VITEC look) -------------------------------------- + $info = $spreadsheet->getActiveSheet(); + $info->setTitle('Read me'); + $info->getColumnDimension('A')->setWidth(3); + $info->getColumnDimension('B')->setWidth(100); + $info->getColumnDimension('C')->setWidth(3); + $info->setShowGridlines(false); + + $info->mergeCells('A1:C3'); + $info->setCellValue('A1', 'VITEC · Solution Detail Page'); + $info->getStyle('A1:C3')->getFill()->setFillType('solid')->getStartColor()->setARGB(self::COLOR_NAVY); + $info->getStyle('A1')->getFont()->setBold(true)->setSize(20)->getColor()->setARGB('FFFFFFFF'); + $info->getStyle('A1:C3')->getAlignment()->setVertical(Alignment::VERTICAL_CENTER)->setIndent(1); + $info->mergeCells('A4:C4'); + $info->getStyle('A4:C4')->getFill()->setFillType('solid')->getStartColor()->setARGB(self::COLOR_ORANGE); + $info->getRowDimension(4)->setRowHeight(4); + + $info->mergeCells('A6:C6'); + $info->setCellValue('A6', 'This workbook feeds exactly ONE page: ' . $subsolution . ' (solution area: ' . $group . ')'); + $info->getStyle('A6')->getFont()->setBold(true)->setSize(12)->getColor()->setARGB(self::COLOR_NAVY); + + $infoBlocks = [ + ['HOW TO FILL IT IN', [ + 'Only the yellow cells are editable - everything else is locked on purpose.', + 'Cells with a dropdown arrow: please PICK from the list, do not type.', + 'Character limits are design hints, not hard stops - you may see a friendly warning.', + 'Rows you do not need (cards, stories, products): simply leave them empty.', + ]], + ['IMAGES & VIDEOS', [ + 'These fields take a URL (Dropbox, WeTransfer, any web link).', + 'We download, review and crop everything on our side - your page goes live', + 'even if an image is still pending.', + 'The Industry Insights video card is optional: leave the video URL empty', + 'and the whole card simply does not appear.', + ]], + ['YOUR REFERENCE', [ + 'The sheet "Page layout" shows the final page design with every section labelled -', + 'the section numbers there match the blocks in the "Content" sheet.', + ]], + ['WHEN YOU ARE DONE', [ + 'Send the file back. It is imported automatically, and we review every page', + 'before it goes live.', + ]], + ]; + $infoRow = 8; + foreach ($infoBlocks as [$blockTitle, $blockLines]) { + $info->setCellValue('B' . $infoRow, $blockTitle); + $info->getStyle('B' . $infoRow)->getFont()->setBold(true)->setSize(11)->getColor()->setARGB(self::COLOR_ORANGE); + $infoRow++; + foreach ($blockLines as $line) { + $info->setCellValue('B' . $infoRow, $line); + $infoRow++; + } + $infoRow++; + } + $info->getProtection()->setSheet(true); + + // ---- Page layout reference (Figma screenshot) ----------------------- + $referencePng = GeneralUtility::getFileAbsFileName('EXT:vitec/Resources/Private/SubmarketTemplate/subsolution-reference.png'); + if (is_file($referencePng)) { + $layout = $spreadsheet->createSheet(); + $layout->setTitle('Page layout'); + $layout->setShowGridlines(false); + $layout->getColumnDimension('A')->setWidth(34); + $layout->setCellValue('A1', 'This is how the page will look.'); + $layout->getStyle('A1')->getFont()->setBold(true)->setSize(12)->getColor()->setARGB(self::COLOR_NAVY); + $layout->setCellValue('A2', 'Labels match the Content sheet.'); + $labels = [4 => 'SECTION 1 - HERO', 11 => 'SECTION 2 - INTRO', 21 => 'SECTION 3 - ADVANTAGE CARDS', 32 => 'SECTION 4 - TESTIMONIALS', 42 => 'SECTION 5 - PRODUCTS', 55 => 'SECTION 6 - INDUSTRY INSIGHTS']; + foreach ($labels as $labelRow => $label) { + $layout->setCellValue('A' . $labelRow, $label . ' →'); + $layout->getStyle('A' . $labelRow)->getFont()->setBold(true)->setSize(9)->getColor()->setARGB(self::COLOR_ORANGE); + } + $drawing = new \PhpOffice\PhpSpreadsheet\Worksheet\Drawing(); + $drawing->setName('Page design'); + $drawing->setPath($referencePng); + $drawing->setCoordinates('B2'); + $drawing->setWorksheet($layout); + $layout->getProtection()->setSheet(true); + } + + // ---- Content sheet -------------------------------------------------- + $sheet = $spreadsheet->createSheet(); + $sheet->setTitle('Content'); + $sheet->getColumnDimension('A')->setWidth(28); + $sheet->getColumnDimension('B')->setWidth(64); + $sheet->getColumnDimension('C')->setWidth(22); + $sheet->getColumnDimension('D')->setWidth(64); + $sheet->getProtection()->setSheet(true); + $sheet->getStyle('A1:D200')->getAlignment()->setVertical(Alignment::VERTICAL_TOP); + $sheet->getStyle('B1:B200')->getAlignment()->setWrapText(true); + $sheet->getStyle('D1:D200')->getAlignment()->setWrapText(true); + + $row = 1; + $sheet->setCellValue('A' . $row, 'Sub-solution'); + $sheet->setCellValue('B' . $row, $subsolution); + $sheet->setCellValue('C' . $row, 'Solution area'); + $sheet->setCellValue('D' . $row, $group); + $sheet->getStyle('A1:D1')->getFont()->setBold(true); + $row += 2; + + // Section 1 - Hero (no video teaser in the solution design) + $row = $this->sectionHeader($sheet, $row, 'SECTION 1 - HERO (the page title is set by us)'); + $row = $this->inputRow($spreadsheet, $sheet, $row, 'Subline *', 'hero_subline', 'max ~90 characters', 120); + $row = $this->inputRow($spreadsheet, $sheet, $row, 'CTA button label', 'hero_cta_label', 'empty = "Get in touch"', 40); + $row = $this->inputRow($spreadsheet, $sheet, $row, 'CTA button link (URL)', 'hero_cta_url', 'empty = /contact'); + $row = $this->inputRow($spreadsheet, $sheet, $row, 'Background image URL', 'hero_bgimage_url', '1920x600, dark; we review'); + $row++; + + // Section 2 - Intro (headline + longer text, image right) + $row = $this->sectionHeader($sheet, $row, 'SECTION 2 - INTRO'); + $row = $this->inputRow($spreadsheet, $sheet, $row, 'Headline', 'intro_headline', 'max ~90 characters', 120); + $row = $this->inputRow($spreadsheet, $sheet, $row, 'Intro text *', 'intro_text', 'max ~700 characters, plain text', 900, 4); + $row = $this->inputRow($spreadsheet, $sheet, $row, 'Image URL (right)', 'intro_image_url', '636x469 (4:3); we review'); + $row++; + + // Section 3 - Advantages (flip cards) + $row = $this->sectionHeader($sheet, $row, 'SECTION 3 - ADVANTAGE FLIP CARDS'); + $row = $this->inputRow($spreadsheet, $sheet, $row, 'Headline *', 'flow_headline', 'max ~70 characters', 90); + $row = $this->inputRow($spreadsheet, $sheet, $row, 'Subtext', 'flow_subtext', 'max ~230 characters', 300, 2); + $sheet->setCellValue('A' . $row, 'Cards - one per row, leave surplus rows empty:'); + $sheet->getStyle('A' . $row)->getFont()->setItalic(true); + $row++; + $sheet->setCellValue('A' . $row, 'Icon'); + $sheet->setCellValue('B' . $row, 'Title (short, e.g. "Distribute")'); + $sheet->setCellValue('C' . $row, 'Front text (~80 characters)'); + $sheet->setCellValue('D' . $row, 'Back text - flip side (~200 characters)'); + $sheet->getStyle('A' . $row . ':D' . $row)->getFont()->setBold(true); + $row++; + for ($i = 1; $i <= self::CARD_ROWS; $i++) { + $this->unlock($sheet, 'A' . $row); + $this->dropdown($sheet, 'A' . $row, 'Icons'); + $this->name($spreadsheet, $sheet, 'flow_card' . $i . '_icon', 'A' . $row); + $this->unlock($sheet, 'B' . $row); + $this->name($spreadsheet, $sheet, 'flow_card' . $i . '_title', 'B' . $row); + $this->unlock($sheet, 'C' . $row); + $this->name($spreadsheet, $sheet, 'flow_card' . $i . '_front', 'C' . $row); + $sheet->getColumnDimension('C')->setWidth(40); + $this->unlock($sheet, 'D' . $row); + $this->name($spreadsheet, $sheet, 'flow_card' . $i . '_back', 'D' . $row); + $row++; + } + $row++; + + // Section 4 - Testimonials + $row = $this->sectionHeader($sheet, $row, 'SECTION 4 - TESTIMONIALS (pick case studies; quotes are maintained by VITEC)'); + $row = $this->inputRow($spreadsheet, $sheet, $row, 'Headline', 'testi_headline', 'empty = "Testimonials from the Field"', 90); + for ($i = 1; $i <= self::STORY_ROWS; $i++) { + $sheet->setCellValue('A' . $row, 'Case study ' . $i); + $this->unlock($sheet, 'B' . $row); + $this->dropdown($sheet, 'B' . $row, 'Stories'); + $this->name($spreadsheet, $sheet, 'testi_story_' . $i, 'B' . $row); + $row++; + } + $row++; + + // Section 5 - Products + $row = $this->sectionHeader($sheet, $row, 'SECTION 5 - PRODUCTS (pick from the list; all copy comes from our product data)'); + $sheet->setCellValue('A' . $row, 'Featured product *'); + $this->unlock($sheet, 'B' . $row); + $this->dropdown($sheet, 'B' . $row, 'Products'); + $this->name($spreadsheet, $sheet, 'prod_featured', 'B' . $row); + $row++; + for ($i = 1; $i <= self::PRODUCT_ROWS; $i++) { + $sheet->setCellValue('A' . $row, 'Product card ' . $i); + $this->unlock($sheet, 'B' . $row); + $this->dropdown($sheet, 'B' . $row, 'Products'); + $this->name($spreadsheet, $sheet, 'prod_card_' . $i, 'B' . $row); + $row++; + } + $row++; + + // Section 6 - Insights + $row = $this->sectionHeader($sheet, $row, 'SECTION 6 - INDUSTRY INSIGHTS'); + $row = $this->inputRow($spreadsheet, $sheet, $row, 'Paragraph 1', 'insights_text1', 'max ~250 characters', 300, 2); + $row = $this->inputRow($spreadsheet, $sheet, $row, 'Paragraph 2', 'insights_text2', 'max ~250 characters', 300, 2); + $row = $this->inputRow($spreadsheet, $sheet, $row, 'CTA label', 'insights_cta_label', 'empty = no button', 40); + $row = $this->inputRow($spreadsheet, $sheet, $row, 'CTA target', 'insights_cta_target', 'anchor or URL; empty = /success-stories', 0, 1, 'Anchors'); + $row = $this->inputRow($spreadsheet, $sheet, $row, 'Video card: person', 'insights_video_person', 'optional', 80); + $row = $this->inputRow($spreadsheet, $sheet, $row, 'Video card: role / title', 'insights_video_role', '', 120); + $row = $this->inputRow($spreadsheet, $sheet, $row, 'Video card: video title', 'insights_video_title', '', 120); + $row = $this->inputRow($spreadsheet, $sheet, $row, 'Video card: video URL', 'insights_video_url', 'empty = no video card'); + $row = $this->inputRow($spreadsheet, $sheet, $row, 'Video card: cover image URL', 'insights_video_cover_url', ''); + $row++; + + // SEO + $row = $this->sectionHeader($sheet, $row, 'SEO (optional - we compose sensible defaults if empty)'); + $row = $this->inputRow($spreadsheet, $sheet, $row, 'SEO title', 'seo_title', 'ca. 60 characters', 80); + $this->inputRow($spreadsheet, $sheet, $row, 'SEO description', 'seo_description', 'ca. 155 characters', 200, 2); + + // ---- Lists (hidden) ------------------------------------------------- + $lists = $spreadsheet->createSheet(); + $lists->setTitle('Lists'); + $this->fillList($spreadsheet, $lists, 'A', 'Stories', $stories); + $this->fillList($spreadsheet, $lists, 'B', 'Products', $products); + $this->fillList($spreadsheet, $lists, 'C', 'Icons', self::ICONS); + $this->fillList($spreadsheet, $lists, 'D', 'Anchors', ['workflow', 'testimonials', 'products', 'insights']); + $lists->setSheetState(Worksheet::SHEETSTATE_HIDDEN); + + // ---- Meta (hidden) - the importer's ground truth -------------------- + $meta = $spreadsheet->createSheet(); + $meta->setTitle('Meta'); + $meta->setCellValue('A1', 'subsolution'); + $meta->setCellValue('B1', $subsolution); + $meta->setCellValue('A2', 'group'); + $meta->setCellValue('B2', $group); + $meta->setCellValue('A3', 'record_uid'); + $meta->setCellValue('B3', $record ? (int)$record['uid'] : 0); + $meta->setCellValue('A4', 'generated'); + $meta->setCellValue('B4', date('Y-m-d H:i')); + $meta->setCellValue('A5', 'template_version'); + $meta->setCellValue('B5', '1'); + foreach (['subsolution' => 'B1', 'group' => 'B2', 'record_uid' => 'B3', 'template_version' => 'B5'] as $metaName => $cell) { + $spreadsheet->addNamedRange(new NamedRange('meta_' . $metaName, $meta, '$' . substr($cell, 0, 1) . '$' . substr($cell, 1))); + } + $meta->setSheetState(Worksheet::SHEETSTATE_HIDDEN); + + $spreadsheet->setActiveSheetIndex(1); // Content + (new Xlsx($spreadsheet))->save($file); + $spreadsheet->disconnectWorksheets(); + } + + // ---- sheet helpers ------------------------------------------------------ + + private function sectionHeader(Worksheet $sheet, int $row, string $title): int + { + $sheet->setCellValue('A' . $row, $title); + $sheet->mergeCells('A' . $row . ':D' . $row); + $style = $sheet->getStyle('A' . $row); + $style->getFont()->setBold(true)->getColor()->setARGB('FFFFFFFF'); + $style->getFill()->setFillType('solid')->getStartColor()->setARGB(self::COLOR_SECTION); + return $row + 1; + } + + private function inputRow( + Spreadsheet $spreadsheet, + Worksheet $sheet, + int $row, + string $label, + string $rangeName, + string $hint, + int $maxLength = 0, + int $rowHeightLines = 1, + string $listName = '' + ): int { + $sheet->setCellValue('A' . $row, $label); + $this->unlock($sheet, 'B' . $row); + if ($listName !== '') { + $this->dropdown($sheet, 'B' . $row, $listName, $listName === 'Anchors'); + } + if ($hint !== '') { + $sheet->setCellValue('C' . $row, $hint); + $sheet->getStyle('C' . $row)->getFont()->setItalic(true)->setSize(9); + } + if ($maxLength > 0) { + $validation = $sheet->getCell('B' . $row)->getDataValidation(); + $validation->setType(DataValidation::TYPE_TEXTLENGTH) + ->setOperator(DataValidation::OPERATOR_LESSTHANOREQUAL) + ->setFormula1((string)$maxLength) + ->setAllowBlank(true) + ->setShowErrorMessage(true) + ->setErrorStyle(DataValidation::STYLE_WARNING) + ->setErrorTitle('A bit long') + ->setError('The design is tuned for shorter copy here - this may get cropped.'); + } + if ($rowHeightLines > 1) { + $sheet->getRowDimension($row)->setRowHeight(15 * $rowHeightLines); + } + $this->name($spreadsheet, $sheet, $rangeName, 'B' . $row); + return $row + 1; + } + + private function unlock(Worksheet $sheet, string $cell): void + { + $sheet->getStyle($cell)->getProtection()->setLocked(Protection::PROTECTION_UNPROTECTED); + $sheet->getStyle($cell)->getFill()->setFillType('solid')->getStartColor()->setARGB(self::COLOR_INPUT); + } + + private function dropdown(Worksheet $sheet, string $cell, string $listName, bool $allowFreeText = false): void + { + $validation = $sheet->getCell($cell)->getDataValidation(); + $validation->setType(DataValidation::TYPE_LIST) + ->setFormula1($listName) + ->setAllowBlank(true) + ->setShowDropDown(true) + ->setShowErrorMessage(!$allowFreeText) + ->setErrorStyle($allowFreeText ? DataValidation::STYLE_INFORMATION : DataValidation::STYLE_STOP) + ->setErrorTitle('Please pick from the list') + ->setError($allowFreeText ? 'Anchors come from the list - URLs may be typed freely.' : 'This field only accepts values from the dropdown list.'); + } + + /** @param string[] $values */ + private function fillList(Spreadsheet $spreadsheet, Worksheet $lists, string $column, string $name, array $values): void + { + $rowIndex = 1; + foreach ($values as $value) { + $lists->setCellValue($column . $rowIndex, $value); + $rowIndex++; + } + $last = max(1, $rowIndex - 1); + $spreadsheet->addNamedRange(new NamedRange($name, $lists, '$' . $column . '$1:$' . $column . '$' . $last)); + } + + private function name(Spreadsheet $spreadsheet, Worksheet $sheet, string $rangeName, string $cell): void + { + preg_match('/^([A-Z]+)(\d+)$/', $cell, $m); + $spreadsheet->addNamedRange(new NamedRange($rangeName, $sheet, '$' . $m[1] . '$' . $m[2])); + } + + // ---- data --------------------------------------------------------------- + + /** + * Solution areas with their sub-solutions, read from the PAGE TREE below + * /solutions - the sub-solution pages already exist as (empty) shells, + * so the tree is the ground truth here (markets use the category tree). + * + * @return array + */ + private function solutionGroups(): array + { + $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages'); + $root = $connection->fetchAssociative("SELECT uid FROM pages WHERE deleted = 0 AND slug = '/solutions'"); + if (!$root) { + return []; + } + $groups = []; + $mains = $connection->fetchAllAssociative( + 'SELECT uid, title FROM pages WHERE deleted = 0 AND doktype = 1 AND sys_language_uid IN (0,-1) AND pid = ? ORDER BY sorting', + [(int)$root['uid']] + ); + foreach ($mains as $main) { + $children = $connection->fetchFirstColumn( + 'SELECT title FROM pages WHERE deleted = 0 AND doktype = 1 AND sys_language_uid IN (0,-1) AND pid = ? ORDER BY sorting', + [(int)$main['uid']] + ); + if ($children !== []) { + $groups[] = ['title' => (string)$main['title'], 'children' => array_map('strval', $children)]; + } + } + return $groups; + } + + /** @return array normalized title => record */ + private function solutionRecordsByNormTitle(): array + { + $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tx_vitec_domain_model_solution'); + $byTitle = []; + foreach ($connection->fetchAllAssociative('SELECT uid, title FROM tx_vitec_domain_model_solution WHERE deleted = 0') as $row) { + $byTitle[$this->norm((string)$row['title'])] = ['uid' => (int)$row['uid'], 'title' => (string)$row['title']]; + } + return $byTitle; + } + + /** @return string[] */ + private function titles(string $table, bool $productFilters): array + { + $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($table); + $where = 'deleted = 0 AND hidden = 0'; + if ($productFilters) { + $where .= ' AND legacy = 0 AND supportproduct = 0 AND hideonwebsite = 0 AND subproduct = 0'; + } + return array_map('strval', $connection->fetchFirstColumn( + 'SELECT title FROM ' . $table . ' WHERE ' . $where . ' ORDER BY title' + )); + } + + private function norm(string $value): string + { + return (string)preg_replace('/[^a-z0-9]+/', '', str_replace('+', 'plus', mb_strtolower($value))); + } + + private function fileSafe(string $value): string + { + $safe = (string)preg_replace('/[^A-Za-z0-9 _&\\-]+/', '', $value); + return trim((string)preg_replace('/\s+/', ' ', $safe)); + } +} diff --git a/packages/vitec/Classes/Command/ImportSubsolutionWorkbooksCommand.php b/packages/vitec/Classes/Command/ImportSubsolutionWorkbooksCommand.php new file mode 100644 index 0000000..f13074c --- /dev/null +++ b/packages/vitec/Classes/Command/ImportSubsolutionWorkbooksCommand.php @@ -0,0 +1,91 @@ +addOption('dir', null, InputOption::VALUE_REQUIRED, 'Directory with filled workbooks', 'migrations/subsolutions_xlsx_in'); + $this->addOption('only', null, InputOption::VALUE_REQUIRED, 'Only files whose name contains this substring'); + $this->addOption('apply', null, InputOption::VALUE_NONE, 'Write changes (default: dry run)'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + Bootstrap::initializeBackendAuthentication(); + $apply = (bool)$input->getOption('apply'); + $only = mb_strtolower((string)($input->getOption('only') ?? '')); + $dir = rtrim((string)$input->getOption('dir'), '/'); + + if (!is_dir($dir)) { + $output->writeln('Directory not found: ' . $dir . ''); + return Command::FAILURE; + } + $files = []; + $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir)); + foreach ($iterator as $fileInfo) { + $name = $fileInfo->getFilename(); + if ($fileInfo->isFile() && str_ends_with($name, '.xlsx') && !str_starts_with($name, '~$')) { + if ($only === '' || str_contains(mb_strtolower($name), $only)) { + $files[] = $fileInfo->getPathname(); + } + } + } + sort($files); + if ($files === []) { + $output->writeln('No workbooks found in ' . $dir); + return Command::SUCCESS; + } + $output->writeln(count($files) . ' workbook(s) · ' . ($apply ? 'APPLY' : 'DRY-RUN')); + + $errors = 0; + foreach ($files as $file) { + try { + $result = $this->importer->importFile($file, $apply); + $output->writeln(sprintf("\n%s (%s / %s)", $result['file'], $result['group'], $result['subsolution'])); + foreach ($result['report'] as $line) { + $output->writeln(' ' . $line . ''); + } + $output->writeln(sprintf(' page %s · %d element(s) touched · %s', + $result['newPage'] && $result['pageUid'] === 0 ? 'NEW' : '#' . $result['pageUid'], + $result['elements'], + $apply ? 'written.' : 'dry run')); + } catch (\Throwable $e) { + $errors++; + $output->writeln('' . basename($file) . ': ' . $e->getMessage() . ''); + } + } + if (!$apply) { + $output->writeln("\nDRY RUN - nothing written. Add --apply to write."); + } + return $errors > 0 ? Command::FAILURE : Command::SUCCESS; + } +} diff --git a/packages/vitec/Classes/Controller/Backend/SubsolutionImportController.php b/packages/vitec/Classes/Controller/Backend/SubsolutionImportController.php new file mode 100644 index 0000000..ad647ef --- /dev/null +++ b/packages/vitec/Classes/Controller/Backend/SubsolutionImportController.php @@ -0,0 +1,179 @@ +render($request, []); + } + + public function subsolutionUploadAction(ServerRequestInterface $request): ResponseInterface + { + $uploads = $request->getUploadedFiles()['workbooks'] ?? []; + if (!is_array($uploads)) { + $uploads = [$uploads]; + } + $dir = $this->inboxPath(); + if (!is_dir($dir)) { + GeneralUtility::mkdir_deep($dir); + } + $stored = []; + foreach ($uploads as $upload) { + if ($upload === null || $upload->getError() !== UPLOAD_ERR_OK) { + continue; + } + $name = $this->safeName((string)$upload->getClientFilename()); + if ($name === '') { + $this->message('Skipped a file that is not an .xlsx workbook.', ContextualFeedbackSeverity::WARNING); + continue; + } + $upload->moveTo($dir . '/' . $name); + GeneralUtility::fixPermissions($dir . '/' . $name); + $stored[] = $name; + } + if ($stored !== []) { + $this->message(count($stored) . ' workbook(s) stored: ' . implode(', ', $stored), ContextualFeedbackSeverity::OK); + } else { + $this->message('No workbook uploaded.', ContextualFeedbackSeverity::WARNING); + } + return new RedirectResponse((string)$this->uriBuilder->buildUriFromRoute('web_vitecimport.subsolutions'), 303); + } + + public function subsolutionProcessAction(ServerRequestInterface $request): ResponseInterface + { + $body = (array)$request->getParsedBody(); + $apply = (string)($body['op'] ?? 'dry') === 'apply'; + $selected = array_map('strval', (array)($body['files'] ?? [])); + if ($selected === []) { + $this->message('No workbook selected.', ContextualFeedbackSeverity::WARNING); + return $this->render($request, []); + } + + $available = array_flip(array_column($this->workbooks(), 'name')); + $results = []; + foreach ($selected as $name) { + // Whitelist against the actual directory listing - the submitted + // name never touches the filesystem on its own (no traversal). + if (!isset($available[$name])) { + $results[] = ['file' => $name, 'error' => 'File not found in the inbox.']; + continue; + } + try { + $results[] = $this->importer->importFile($this->inboxPath() . '/' . $name, $apply); + } catch (\Throwable $e) { + $results[] = ['file' => $name, 'error' => $e->getMessage()]; + } + } + + $errors = count(array_filter($results, static fn(array $r): bool => isset($r['error']))); + $this->message( + sprintf('%d workbook(s) processed (%s)%s.', + count($results), + $apply ? 'APPLY' : 'dry run', + $errors > 0 ? ', ' . $errors . ' with errors' : '' + ), + $errors > 0 ? ContextualFeedbackSeverity::WARNING : ContextualFeedbackSeverity::OK + ); + return $this->render($request, $results); + } + + // ------------------------------------------------------------ internals + + /** @param array> $results */ + private function render(ServerRequestInterface $request, array $results): ResponseInterface + { + $view = $this->moduleTemplateFactory->create($request); + $this->pageRenderer->addCssFile('EXT:vitec/Resources/Public/Css/backend-import.css'); + $view->assignMultiple([ + 'workbooks' => $this->workbooks(), + 'inbox' => self::INBOX, + 'results' => $results, + 'hasResults' => $results !== [], + ]); + return $view->renderResponse('Import/Subsolutions'); + } + + /** @return array */ + private function workbooks(): array + { + $dir = $this->inboxPath(); + if (!is_dir($dir)) { + return []; + } + $rows = []; + foreach (scandir($dir) ?: [] as $name) { + if (!str_ends_with($name, '.xlsx') || str_starts_with($name, '~$')) { + continue; + } + $path = $dir . '/' . $name; + if (!is_file($path)) { + continue; + } + $rows[] = [ + 'name' => $name, + 'size' => sprintf('%.0f KB', filesize($path) / 1024), + 'modified' => date('Y-m-d H:i', (int)filemtime($path)), + ]; + } + usort($rows, static fn(array $a, array $b): int => strcasecmp($a['name'], $b['name'])); + return $rows; + } + + private function inboxPath(): string + { + return Environment::getProjectPath() . '/' . self::INBOX; + } + + /** Client filename reduced to a safe basename; '' when it is no .xlsx. */ + private function safeName(string $clientFilename): string + { + $name = basename(str_replace('\\', '/', trim($clientFilename))); + if (!str_ends_with(mb_strtolower($name), '.xlsx') || str_starts_with($name, '~$') || str_starts_with($name, '.')) { + return ''; + } + return (string)preg_replace('/[^\w.\-() ]+/u', '_', $name); + } + + private function message(string $text, ContextualFeedbackSeverity $severity): void + { + $message = GeneralUtility::makeInstance(FlashMessage::class, $text, '', $severity, true); + $this->flashMessageService->getMessageQueueByIdentifier()->addMessage($message); + } +} diff --git a/packages/vitec/Classes/Import/SubmarketWorkbookImporter.php b/packages/vitec/Classes/Import/SubmarketWorkbookImporter.php index d984961..596cc54 100644 --- a/packages/vitec/Classes/Import/SubmarketWorkbookImporter.php +++ b/packages/vitec/Classes/Import/SubmarketWorkbookImporter.php @@ -59,6 +59,8 @@ final class SubmarketWorkbookImporter private array $datamap = []; /** @var array>> */ private array $cmdmap = []; + /** @var array colPos => element ids (uid or NEW id) in page order */ + private array $sequence = []; private int $newCounter = 0; /** @@ -70,6 +72,7 @@ final class SubmarketWorkbookImporter $this->report = []; $this->datamap = []; $this->cmdmap = []; + $this->sequence = []; $spreadsheet = IOFactory::load($file); $values = $this->readNamedValues($spreadsheet); @@ -171,6 +174,7 @@ final class SubmarketWorkbookImporter $this->datamap['tt_content'][$cardId]['colPos'] = self::COLPOS_FLOW; $this->datamap['tt_content'][$cardId]['CType'] = 'vitec_card'; } + $this->sequence[self::COLPOS_FLOW][] = (string)$cardId; $this->setFields('tt_content', (string)$cardId, [ 'vitec_layout_variant' => 'flipcard', 'header' => $title, @@ -297,6 +301,7 @@ final class SubmarketWorkbookImporter if ($dataHandler->errorLog !== []) { throw new \RuntimeException('DataHandler: ' . implode(' | ', $dataHandler->errorLog)); } + $this->applySequence($dataHandler->substNEWwithIDs); if ($isNewPage && isset($dataHandler->substNEWwithIDs[$pageId])) { $result['pageUid'] = (int)$dataHandler->substNEWwithIDs[$pageId]; } @@ -438,6 +443,7 @@ final class SubmarketWorkbookImporter if ($subPage !== null) { $existing = $this->slotElements((int)$subPage['uid'], $colPos, $ctype); if ($existing !== []) { + $this->sequence[$colPos][] = (string)$existing[0]; return (string)$existing[0]; } } @@ -447,9 +453,36 @@ final class SubmarketWorkbookImporter 'colPos' => $colPos, 'CType' => $ctype, ]; + $this->sequence[$colPos][] = $id; return $id; } + /** + * Writes tt_content.sorting in page order. Necessary because DataHandler + * puts every NEW record created with a positive pid at the TOP of its + * column - slots holding several elements (130: intro paragraph + cards, + * 150: feature + list) came out reversed (found 2026-10-07 on the + * sub-solution side). `sorting` is no TCA column, so it cannot travel + * through the datamap; a direct update is the only way. Runs on every + * apply, so a re-import also repairs pages imported before this fix. + * + * @param array $substNEWwithIDs + */ + private function applySequence(array $substNEWwithIDs): void + { + $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tt_content'); + foreach ($this->sequence as $ids) { + $sorting = 0; + foreach ($ids as $id) { + $uid = str_starts_with($id, 'NEW') ? (int)($substNEWwithIDs[$id] ?? 0) : (int)$id; + if ($uid > 0) { + $sorting += 256; + $connection->update('tt_content', ['sorting' => $sorting], ['uid' => $uid]); + } + } + } + } + /** @return int[] uids in sorting order */ private function slotElements(int $pageUid, int $colPos, string $ctype): array { diff --git a/packages/vitec/Classes/Import/SubsolutionWorkbookImporter.php b/packages/vitec/Classes/Import/SubsolutionWorkbookImporter.php new file mode 100644 index 0000000..6c15f8c --- /dev/null +++ b/packages/vitec/Classes/Import/SubsolutionWorkbookImporter.php @@ -0,0 +1,620 @@ +/ - they are switched to layout 5; missing pages are + * created HIDDEN as with markets. + * - intro carries a headline (vitec_intro_headline) and no learn-more / + * portfolio (solution design); hero has no video teaser. + * - detail_page is set on tx_vitec_domain_model_solution. + * + * Shared lessons (same as the market importer): named ranges first, then + * label-scan over the locked column A; content-idempotent file attach; + * sys_file_reference needs pid + tablenames + uid_foreign. + */ +final class SubsolutionWorkbookImporter +{ + private const LAYOUT = 5; + private const COLPOS_HERO = 1; + private const COLPOS_MAIN = 0; + private const CARD_ROWS = 8; + private const STORY_ROWS = 6; + private const PRODUCT_ROWS = 9; + + /** Story detail page; mirrors the site setting vitec.storyDetailPid. */ + private const STORY_SINGLE_PID = 26; + + /** @var string[] */ + private array $report = []; + /** @var array>> */ + private array $datamap = []; + /** @var array>> */ + private array $cmdmap = []; + /** @var array colPos => element ids (uid or NEW id) in page order */ + private array $sequence = []; + private int $newCounter = 0; + + /** + * @return array{file:string,group:string,subsolution:string,newPage:bool,pageUid:int,elements:int,applied:bool,report:string[]} + * @throws \RuntimeException on fatal problems + */ + public function importFile(string $file, bool $apply): array + { + $this->report = []; + $this->datamap = []; + $this->cmdmap = []; + $this->sequence = []; + + $spreadsheet = IOFactory::load($file); + $values = $this->readNamedValues($spreadsheet); + $subsolution = trim((string)($values['meta_subsolution'] ?? '')); + $group = trim((string)($values['meta_group'] ?? '')); + $recordUid = (int)($values['meta_record_uid'] ?? 0); + $spreadsheet->disconnectWorksheets(); + if ($subsolution === '' || $group === '') { + throw new \RuntimeException('Meta sheet missing - not a generated sub-solution workbook?'); + } + + // --- resolve pages --------------------------------------------------- + $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages'); + $solutionsRoot = $connection->fetchAssociative("SELECT uid FROM pages WHERE deleted = 0 AND slug = '/solutions'"); + if (!$solutionsRoot) { + throw new \RuntimeException('Page /solutions not found.'); + } + $mainPage = $this->childByTitle((int)$solutionsRoot['uid'], $group); + if ($mainPage === null) { + throw new \RuntimeException('Solution area page "' . $group . '" not found below /solutions.'); + } + $subPage = $this->childByTitle((int)$mainPage['uid'], $subsolution); + + if ($subPage === null) { + $pageId = $this->newId(); + $this->datamap['pages'][$pageId] = [ + 'pid' => (int)$mainPage['uid'], + 'title' => $subsolution, + 'doktype' => 1, + 'layout' => self::LAYOUT, + 'hidden' => 1, + 'backend_layout' => 'vitec__' . self::LAYOUT, + ]; + $this->report[] = 'page: CREATE (hidden, review & publish editorially)'; + } else { + $pageId = (string)(int)$subPage['uid']; + $updates = []; + if ((int)$subPage['layout'] !== self::LAYOUT) { + $updates['layout'] = self::LAYOUT; + $updates['backend_layout'] = 'vitec__' . self::LAYOUT; + $this->report[] = 'page: switch layout -> ' . self::LAYOUT . ' (was ' . (int)$subPage['layout'] . ')'; + } + if ($updates !== []) { + $this->datamap['pages'][$pageId] = $updates; + } + } + + // --- SEO on the page -------------------------------------------------- + $seoTitle = trim((string)($values['seo_title'] ?? '')); + $seoDescription = trim((string)($values['seo_description'] ?? '')); + if ($seoTitle !== '') { + $this->datamap['pages'][$pageId]['seo_title'] = $seoTitle; + } + if ($seoDescription !== '') { + $this->datamap['pages'][$pageId]['description'] = $seoDescription; + } + + // --- hero zone (colPos 1) --------------------------------------------- + // The blocks below are registered in PAGE ORDER ($this->sequence) and + // their sorting is written explicitly after DataHandler ran - see + // applySequence() for why the creation order cannot be relied on. + $heroId = $this->slotElement($pageId, $subPage, self::COLPOS_HERO, 'vitec_markethero'); + $this->setFields('tt_content', $heroId, [ + 'vitec_subline' => (string)($values['hero_subline'] ?? ''), + '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)); + + // --- main column: intro ------------------------------------------------- + $introId = $this->slotElement($pageId, $subPage, self::COLPOS_MAIN, 'vitec_marketintro'); + $this->setFields('tt_content', $introId, [ + 'vitec_intro_headline' => (string)($values['intro_headline'] ?? ''), + '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)); + + // --- main column: advantages (intro-paragraph + flip cards) -------------- + $flowHeaderId = $this->slotElement($pageId, $subPage, self::COLPOS_MAIN, 'vitec_introparagraph'); + $this->setFields('tt_content', $flowHeaderId, [ + 'header' => (string)($values['flow_headline'] ?? ''), + 'bodytext' => (string)($values['flow_subtext'] ?? ''), + ]); + $existingCards = $subPage ? $this->slotElements((int)$subPage['uid'], self::COLPOS_MAIN, 'vitec_card') : []; + $cardIndex = 0; + for ($i = 1; $i <= self::CARD_ROWS; $i++) { + $title = trim((string)($values['flow_card' . $i . '_title'] ?? '')); + $front = trim((string)($values['flow_card' . $i . '_front'] ?? '')); + $back = trim((string)($values['flow_card' . $i . '_back'] ?? '')); + $icon = trim((string)($values['flow_card' . $i . '_icon'] ?? '')); + if ($title === '' && $front === '') { + continue; + } + $cardId = $existingCards[$cardIndex] ?? $this->newId(); + if (!isset($existingCards[$cardIndex])) { + $this->datamap['tt_content'][$cardId]['pid'] = $this->pidValue($pageId); + $this->datamap['tt_content'][$cardId]['colPos'] = self::COLPOS_MAIN; + $this->datamap['tt_content'][$cardId]['CType'] = 'vitec_card'; + } + $this->sequence[self::COLPOS_MAIN][] = (string)$cardId; + $this->setFields('tt_content', (string)$cardId, [ + 'vitec_layout_variant' => 'flipcard', + 'header' => $title, + 'bodytext' => $front, + 'vitec_back_headline' => $title, + 'vitec_back_text' => $back, + ]); + if ($icon !== '') { + $this->report[] = 'card ' . $i . ': icon "' . $icon . '" noted - icon set still pending, attach later'; + } + $cardIndex++; + } + if (count($existingCards) > $cardIndex) { + $this->report[] = 'advantages: ' . (count($existingCards) - $cardIndex) . ' existing card(s) beyond the workbook - left untouched, review editorially'; + } + + // --- main column: testimonials ------------------------------------------- + $storyUids = []; + foreach (range(1, self::STORY_ROWS) as $i) { + $storyTitle = trim((string)($values['testi_story_' . $i] ?? '')); + if ($storyTitle === '') { + continue; + } + $story = $this->recordByTitle('tx_vitec_domain_model_usecase', $storyTitle); + if ($story === null) { + $this->report[] = 'testimonial ' . $i . ': story "' . $storyTitle . '" NOT FOUND - skipped'; + continue; + } + if (trim((string)$story['quote']) === '') { + $this->report[] = 'testimonial: "' . $storyTitle . '" has NO QUOTE yet - maintain on the story record'; + } + $storyUids[] = (int)$story['uid']; + } + $testiId = $this->slotElement($pageId, $subPage, self::COLPOS_MAIN, 'vitec_usecaselist'); + $this->setFields('tt_content', $testiId, [ + 'header' => (string)($values['testi_headline'] ?? ''), + 'pi_flexform' => $this->flex([ + 'settings.layout' => 'testimonials', + 'settings.usecases' => implode(',', $storyUids), + 'settings.singlePid' => (string)self::STORY_SINGLE_PID, + 'settings.showtoolbar' => '0', + ]), + ]); + + // --- main column: products ------------------------------------------------- + $featuredTitle = trim((string)($values['prod_featured'] ?? '')); + $featuredUid = 0; + if ($featuredTitle !== '') { + $featured = $this->recordByTitle('tx_vitec_domain_model_product', $featuredTitle); + $featured === null + ? $this->report[] = 'featured product "' . $featuredTitle . '" NOT FOUND' + : $featuredUid = (int)$featured['uid']; + } + $featureId = $this->slotElement($pageId, $subPage, self::COLPOS_MAIN, 'vitec_productfeature'); + $this->setFields('tt_content', $featureId, [ + 'pi_flexform' => $this->flex(['settings.product' => (string)$featuredUid]), + ]); + + $productUids = []; + foreach (range(1, self::PRODUCT_ROWS) as $i) { + $productTitle = trim((string)($values['prod_card_' . $i] ?? '')); + if ($productTitle === '') { + continue; + } + $product = $this->recordByTitle('tx_vitec_domain_model_product', $productTitle); + $product === null + ? $this->report[] = 'product card ' . $i . ': "' . $productTitle . '" NOT FOUND - skipped' + : $productUids[] = (int)$product['uid']; + } + $listId = $this->slotElement($pageId, $subPage, self::COLPOS_MAIN, 'vitec_productlist'); + $this->setFields('tt_content', $listId, [ + 'pi_flexform' => $this->flex([ + 'settings.selectedproducts' => implode(',', $productUids), + 'settings.layout' => '0', + 'settings.showtoolbar' => '0', + ]), + ]); + + // --- main column: insights --------------------------------------------------- + $insightsId = $this->slotElement($pageId, $subPage, self::COLPOS_MAIN, 'vitec_marketinsights'); + $this->setFields('tt_content', $insightsId, [ + 'vitec_text1' => (string)($values['insights_text1'] ?? ''), + 'vitec_text2' => (string)($values['insights_text2'] ?? ''), + 'vitec_cta_label' => (string)($values['insights_cta_label'] ?? ''), + 'vitec_cta_target' => (string)($values['insights_cta_target'] ?? ''), + 'vitec_video_person' => (string)($values['insights_video_person'] ?? ''), + 'vitec_video_role' => (string)($values['insights_video_role'] ?? ''), + '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)); + + // --- solution record link ----------------------------------------------------- + if ($recordUid > 0 && !str_starts_with($pageId, 'NEW')) { + $this->datamap['tx_vitec_domain_model_solution'][$recordUid]['detail_page'] = (int)$pageId; + } elseif ($recordUid > 0) { + $this->datamap['tx_vitec_domain_model_solution'][$recordUid]['detail_page'] = $pageId; + } else { + $this->report[] = 'no solution record for this sub-solution - detail_page not set'; + } + + $isNewPage = str_starts_with($pageId, 'NEW'); + $result = [ + 'file' => basename($file), + 'group' => $group, + 'subsolution' => $subsolution, + 'newPage' => $isNewPage, + 'pageUid' => $isNewPage ? 0 : (int)$pageId, + 'elements' => count($this->datamap['tt_content'] ?? []), + 'applied' => $apply, + 'report' => $this->report, + ]; + + if (!$apply) { + return $result; + } + + $dataHandler = GeneralUtility::makeInstance(DataHandler::class); + $dataHandler->start($this->datamap, $this->cmdmap); + $dataHandler->process_datamap(); + if ($this->cmdmap !== []) { + $dataHandler->process_cmdmap(); + } + if ($dataHandler->errorLog !== []) { + throw new \RuntimeException('DataHandler: ' . implode(' | ', $dataHandler->errorLog)); + } + $this->applySequence($dataHandler->substNEWwithIDs); + if ($isNewPage && isset($dataHandler->substNEWwithIDs[$pageId])) { + $result['pageUid'] = (int)$dataHandler->substNEWwithIDs[$pageId]; + } + $result['report'] = $this->report; + return $result; + } + + // ---- helpers ------------------------------------------------------------- + + /** @return array name => first-cell value */ + private function readNamedValues(Spreadsheet $spreadsheet): array + { + $values = []; + foreach ($spreadsheet->getNamedRanges() as $namedRange) { + $name = $namedRange->getName(); + $sheet = $namedRange->getWorksheet(); + if ($sheet === null) { + continue; + } + $range = str_replace('$', '', (string)$namedRange->getValue()); + $range = str_contains($range, '!') ? substr($range, strpos($range, '!') + 1) : $range; + $cell = str_contains($range, ':') ? substr($range, 0, strpos($range, ':')) : $range; + $values[$name] = $sheet->getCell($cell)->getValue(); + } + if (isset($values['meta_subsolution'])) { + return $values; + } + + // Names are gone - scan by the LOCKED labels in column A instead + // (same Excel round-trip lesson as the sub-market importer). + $meta = $spreadsheet->getSheetByName('Meta'); + $content = $spreadsheet->getSheetByName('Content'); + if ($meta === null || $content === null) { + return $values; // caller reports "Meta sheet missing" + } + $this->report[] = 'NOTE: defined names were stripped (Excel save) - reading by column-A labels instead'; + $values['meta_subsolution'] = $meta->getCell('B1')->getValue(); + $values['meta_group'] = $meta->getCell('B2')->getValue(); + $values['meta_record_uid'] = $meta->getCell('B3')->getValue(); + + $section = 0; + $inCardTable = false; + $cardIndex = 0; + $highestRow = min(200, (int)$content->getHighestRow()); + for ($rowNum = 1; $rowNum <= $highestRow; $rowNum++) { + $label = mb_strtolower(trim((string)$content->getCell('A' . $rowNum)->getValue())); + if (preg_match('/^section (\d)/', $label, $m)) { + $section = (int)$m[1]; + $inCardTable = false; + continue; + } + if ($inCardTable && $section === 3) { + $icon = trim((string)$content->getCell('A' . $rowNum)->getValue()); + $title = trim((string)$content->getCell('B' . $rowNum)->getValue()); + $front = trim((string)$content->getCell('C' . $rowNum)->getValue()); + $back = trim((string)$content->getCell('D' . $rowNum)->getValue()); + if ($icon === '' && $title === '' && $front === '' && $back === '') { + continue; + } + $cardIndex++; + if ($cardIndex <= self::CARD_ROWS) { + $values['flow_card' . $cardIndex . '_icon'] = $icon; + $values['flow_card' . $cardIndex . '_title'] = $title; + $values['flow_card' . $cardIndex . '_front'] = $front; + $values['flow_card' . $cardIndex . '_back'] = $back; + } + continue; + } + if ($label === '') { + continue; + } + if ($section === 3 && $label === 'icon') { + $inCardTable = true; + continue; + } + $label = rtrim($label, ' *'); + $b = $content->getCell('B' . $rowNum)->getValue(); + $name = match (true) { + $label === 'subline' => 'hero_subline', + $label === 'cta button label' => 'hero_cta_label', + $label === 'cta button link (url)' => 'hero_cta_url', + $label === 'background image url' => 'hero_bgimage_url', + $label === 'headline' && $section === 2 => 'intro_headline', + $label === 'intro text' => 'intro_text', + $label === 'image url (right)' => 'intro_image_url', + $label === 'headline' && $section === 3 => 'flow_headline', + $label === 'subtext' => 'flow_subtext', + $label === 'headline' && $section === 4 => 'testi_headline', + str_starts_with($label, 'case study ') => 'testi_story_' . (int)substr($label, 11), + $label === 'featured product' => 'prod_featured', + str_starts_with($label, 'product card ') => 'prod_card_' . (int)substr($label, 13), + $label === 'paragraph 1' => 'insights_text1', + $label === 'paragraph 2' => 'insights_text2', + $label === 'cta label' => 'insights_cta_label', + $label === 'cta target' => 'insights_cta_target', + $label === 'video card: person' => 'insights_video_person', + $label === 'video card: role / title' => 'insights_video_role', + $label === 'video card: video title' => 'insights_video_title', + $label === 'video card: video url' => 'insights_video_url', + $label === 'video card: cover image url' => 'insights_video_cover_url', + $label === 'seo title' => 'seo_title', + $label === 'seo description' => 'seo_description', + default => null, + }; + if ($name !== null && !array_key_exists($name, $values)) { + $values[$name] = $b; + } + } + return $values; + } + + /** @return array{uid:int,layout:int}|null */ + private function childByTitle(int $parentUid, string $title): ?array + { + $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages'); + foreach ($connection->fetchAllAssociative( + 'SELECT uid, title, layout FROM pages WHERE deleted = 0 AND pid = ? AND sys_language_uid IN (0,-1)', [$parentUid] + ) as $row) { + if ($this->norm((string)$row['title']) === $this->norm($title)) { + return ['uid' => (int)$row['uid'], 'layout' => (int)$row['layout']]; + } + } + return null; + } + + /** Existing element uid for a single-element slot, or a NEW id. */ + private function slotElement(string $pageId, ?array $subPage, int $colPos, string $ctype): string + { + if ($subPage !== null) { + $existing = $this->slotElements((int)$subPage['uid'], $colPos, $ctype); + if ($existing !== []) { + $this->sequence[$colPos][] = (string)$existing[0]; + return (string)$existing[0]; + } + } + $id = $this->newId(); + $this->datamap['tt_content'][$id] = [ + 'pid' => $this->pidValue($pageId), + 'colPos' => $colPos, + 'CType' => $ctype, + ]; + $this->sequence[$colPos][] = $id; + return $id; + } + + /** + * Writes tt_content.sorting in page order. Necessary because DataHandler + * puts every NEW record created with a positive pid at the TOP of its + * column - a page built in one run came out exactly reversed (found + * 2026-10-07). `sorting` is no TCA column, so it cannot travel through + * the datamap; a direct update is the only way. Runs on every apply, so + * a re-import also repairs pages imported before this fix. + * + * @param array $substNEWwithIDs + */ + private function applySequence(array $substNEWwithIDs): void + { + $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tt_content'); + foreach ($this->sequence as $ids) { + $sorting = 0; + foreach ($ids as $id) { + $uid = str_starts_with($id, 'NEW') ? (int)($substNEWwithIDs[$id] ?? 0) : (int)$id; + if ($uid > 0) { + $sorting += 256; + $connection->update('tt_content', ['sorting' => $sorting], ['uid' => $uid]); + } + } + } + } + + /** @return int[] uids in sorting order */ + private function slotElements(int $pageUid, int $colPos, string $ctype): array + { + $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tt_content'); + return array_map('intval', $connection->fetchFirstColumn( + 'SELECT uid FROM tt_content WHERE deleted = 0 AND pid = ? AND colPos = ? AND CType = ? ORDER BY sorting', + [$pageUid, $colPos, $ctype] + )); + } + + /** @param array $fields */ + private function setFields(string $table, string $id, array $fields): void + { + foreach ($fields as $field => $value) { + $this->datamap[$table][$id][$field] = $value; + } + } + + /** NEW page ids must stay strings in datamap pid references. */ + private function pidValue(string $pageId): int|string + { + return str_starts_with($pageId, 'NEW') ? $pageId : (int)$pageId; + } + + /** @return array|null */ + private function recordByTitle(string $table, string $title): ?array + { + $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($table); + foreach ($connection->fetchAllAssociative( + 'SELECT * FROM ' . $table . ' WHERE deleted = 0', [] + ) as $row) { + if ($this->norm((string)$row['title']) === $this->norm($title)) { + return $row; + } + } + return null; + } + + /** + * Download a URL into fileadmin/subsolution_import// and attach it + * 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 + { + $url = trim($url); + if ($url === '' || !str_starts_with($url, 'http')) { + return; + } + if (!$apply) { + $this->report[] = $fieldName . ': would download ' . $url; + return; + } + try { + $response = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Http\RequestFactory::class) + ->request($url, 'GET', ['timeout' => 20]); + if ($response->getStatusCode() !== 200) { + $this->report[] = $fieldName . ': DOWNLOAD FAILED (HTTP ' . $response->getStatusCode() . ' for ' . $url . ') - attach editorially'; + return; + } + $content = (string)$response->getBody(); + if ($content === '') { + $this->report[] = $fieldName . ': DOWNLOAD FAILED (empty response from ' . $url . ') - attach editorially'; + return; + } + $storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage(); + $folderPath = 'subsolution_import/' . mb_strtolower((string)preg_replace('/[^A-Za-z0-9]+/', '-', $subsolution)); + $folder = $storage->hasFolder($folderPath) ? $storage->getFolder($folderPath) : $storage->createFolder($folderPath); + $fileName = basename(parse_url($url, PHP_URL_PATH) ?: '') ?: ($fieldName . '.bin'); + $temp = GeneralUtility::tempnam('subsolution_'); + 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'; + } catch (\Throwable $e) { + $this->report[] = $fieldName . ': DOWNLOAD FAILED (' . $e->getMessage() . ') - attach editorially'; + } + } + + /** @return array live refs on one field, oldest first */ + private function liveReferences(int $elementUid, string $fieldName): array + { + $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('sys_file_reference'); + return array_map( + static fn(array $r): array => ['uid' => (int)$r['uid'], 'uid_local' => (int)$r['uid_local']], + $connection->fetchAllAssociative( + "SELECT uid, uid_local FROM sys_file_reference + WHERE deleted = 0 AND hidden = 0 AND tablenames = 'tt_content' + AND fieldname = ? AND uid_foreign = ? ORDER BY uid", + [$fieldName, $elementUid] + ) + ); + } + + /** + * DataHandler flexform array for sheet sDEF. + * + * @param array $settings + * @return array + */ + private function flex(array $settings): array + { + $fields = []; + foreach ($settings as $key => $value) { + $fields[$key] = ['vDEF' => $value]; + } + return ['data' => ['sDEF' => ['lDEF' => $fields]]]; + } + + private function newId(): string + { + return 'NEW' . str_pad((string)(++$this->newCounter), 6, '0', STR_PAD_LEFT) . substr(md5(uniqid('', true)), 0, 6); + } + + private function norm(string $value): string + { + return (string)preg_replace('/[^a-z0-9]+/', '', str_replace('+', 'plus', mb_strtolower($value))); + } +} diff --git a/packages/vitec/Configuration/Backend/Modules.php b/packages/vitec/Configuration/Backend/Modules.php index 475cf9c..854efaa 100644 --- a/packages/vitec/Configuration/Backend/Modules.php +++ b/packages/vitec/Configuration/Backend/Modules.php @@ -5,6 +5,7 @@ declare(strict_types=1); use Evomedien\Vitec\Controller\Backend\ImportController; use Evomedien\Vitec\Controller\Backend\ProductTextImportController; use Evomedien\Vitec\Controller\Backend\SubmarketImportController; +use Evomedien\Vitec\Controller\Backend\SubsolutionImportController; use Evomedien\Vitec\Controller\Backend\WordPressImportController; use Evomedien\Vitec\Controller\OgImageController; @@ -65,6 +66,17 @@ return [ 'target' => SubmarketImportController::class . '::submarketProcessAction', 'methods' => ['POST'], ], + 'subsolutions' => [ + 'target' => SubsolutionImportController::class . '::subsolutionsAction', + ], + 'subsolution_upload' => [ + 'target' => SubsolutionImportController::class . '::subsolutionUploadAction', + 'methods' => ['POST'], + ], + 'subsolution_process' => [ + 'target' => SubsolutionImportController::class . '::subsolutionProcessAction', + 'methods' => ['POST'], + ], ], ], 'web_vitecwpimport' => [ diff --git a/packages/vitec/ContentBlocks/ContentElements/market-hero/language/labels.xlf b/packages/vitec/ContentBlocks/ContentElements/market-hero/language/labels.xlf index c8ff6b9..9e098e9 100644 --- a/packages/vitec/ContentBlocks/ContentElements/market-hero/language/labels.xlf +++ b/packages/vitec/ContentBlocks/ContentElements/market-hero/language/labels.xlf @@ -1,8 +1,8 @@ - + - VITEC · Market Hero + VITEC · Market/Solution Hero Sub-market template, section 1: subline, CTA, background image, optional video teaser. The H1 is the page title. Subline Max ~90 characters visible (two lines in the design). diff --git a/packages/vitec/ContentBlocks/ContentElements/market-insights/language/labels.xlf b/packages/vitec/ContentBlocks/ContentElements/market-insights/language/labels.xlf index 9cec40d..7e2cced 100644 --- a/packages/vitec/ContentBlocks/ContentElements/market-insights/language/labels.xlf +++ b/packages/vitec/ContentBlocks/ContentElements/market-insights/language/labels.xlf @@ -1,8 +1,8 @@ - + - VITEC · Market Insights + VITEC · Market/Solution Insights Sub-market template, section 6: two paragraphs, optional CTA, optional video card with person. Paragraph 1 Plain text, max ~250 characters. diff --git a/packages/vitec/ContentBlocks/ContentElements/market-intro/config.yaml b/packages/vitec/ContentBlocks/ContentElements/market-intro/config.yaml index 0ca3664..697c5ea 100644 --- a/packages/vitec/ContentBlocks/ContentElements/market-intro/config.yaml +++ b/packages/vitec/ContentBlocks/ContentElements/market-intro/config.yaml @@ -5,6 +5,12 @@ prefixFields: true prefixType: vendor fields: + # Optional headline above the text - the solution detail design has one, + # the sub-market design does not (leave empty there). + - identifier: intro_headline + type: Text + max: 120 + - identifier: intro_text type: Textarea rows: 6 diff --git a/packages/vitec/ContentBlocks/ContentElements/market-intro/language/labels.xlf b/packages/vitec/ContentBlocks/ContentElements/market-intro/language/labels.xlf index 16cd284..bdce08f 100644 --- a/packages/vitec/ContentBlocks/ContentElements/market-intro/language/labels.xlf +++ b/packages/vitec/ContentBlocks/ContentElements/market-intro/language/labels.xlf @@ -2,8 +2,10 @@ - VITEC · Market Intro - Sub-market template, section 2: intro text, image right, optional learn-more button, portfolio button toggle. + VITEC · Market/Solution Intro + Detail template section 2 (sub-markets AND sub-solutions): optional headline, intro text, image right, optional learn-more button, portfolio button toggle. + Headline (optional) + Used on solution detail pages; sub-market pages leave it empty. Intro text Plain text, max ~350 characters. No HTML. Image (right) diff --git a/packages/vitec/Resources/Private/SubmarketTemplate/subsolution-reference.png b/packages/vitec/Resources/Private/SubmarketTemplate/subsolution-reference.png new file mode 100644 index 0000000..09675f7 Binary files /dev/null and b/packages/vitec/Resources/Private/SubmarketTemplate/subsolution-reference.png differ diff --git a/packages/vitec/Resources/Private/Templates/Import/Index.html b/packages/vitec/Resources/Private/Templates/Import/Index.html index 834b942..bc9c263 100644 --- a/packages/vitec/Resources/Private/Templates/Import/Index.html +++ b/packages/vitec/Resources/Private/Templates/Import/Index.html @@ -29,6 +29,9 @@ Sub-Markets + + Sub-Solutions + SEO Research diff --git a/packages/vitec/Resources/Private/Templates/Import/Products.html b/packages/vitec/Resources/Private/Templates/Import/Products.html index 0ce2ac5..f32b266 100644 --- a/packages/vitec/Resources/Private/Templates/Import/Products.html +++ b/packages/vitec/Resources/Private/Templates/Import/Products.html @@ -26,6 +26,9 @@ Sub-Markets + + Sub-Solutions + SEO Research diff --git a/packages/vitec/Resources/Private/Templates/Import/Seo.html b/packages/vitec/Resources/Private/Templates/Import/Seo.html index f727c50..68ed665 100644 --- a/packages/vitec/Resources/Private/Templates/Import/Seo.html +++ b/packages/vitec/Resources/Private/Templates/Import/Seo.html @@ -21,6 +21,9 @@ Sub-Markets + + Sub-Solutions + SEO Research diff --git a/packages/vitec/Resources/Private/Templates/Import/Submarkets.html b/packages/vitec/Resources/Private/Templates/Import/Submarkets.html index 0d0c819..8ba5d55 100644 --- a/packages/vitec/Resources/Private/Templates/Import/Submarkets.html +++ b/packages/vitec/Resources/Private/Templates/Import/Submarkets.html @@ -21,6 +21,9 @@ Sub-Markets + + Sub-Solutions + SEO Research diff --git a/packages/vitec/Resources/Private/Templates/Import/Subsolutions.html b/packages/vitec/Resources/Private/Templates/Import/Subsolutions.html new file mode 100644 index 0000000..f5ee668 --- /dev/null +++ b/packages/vitec/Resources/Private/Templates/Import/Subsolutions.html @@ -0,0 +1,144 @@ + + + + + + + + + VITEC Import — Sub-Solutions + + + + + + CSV Import + + + Products + + + Sub-Markets + + + Sub-Solutions + + + SEO Research + + + + + + Upload filled workbooks + + + + + + + Upload + + + Multiple files allowed. A file with the same name replaces the stored one ({inbox}/). + + + + + + + + Workbooks in the inbox + + + + + + + + + File + Size + Uploaded / changed + + + + + + + {w.name} + {w.size} + {w.modified} + + + + + Dry run (no changes) + Apply — write pages + + Always dry-run first. Apply updates existing elements in place, creates missing ones, never deletes. + New pages are created HIDDEN for editorial review. + + + + + No workbooks yet — upload Joe's filled .xlsx files above. + + + + + + + + + + + {r.file} + + + ERROR + + + — {r.group} / {r.subsolution} + + written + dry run + + + + + + + + {r.error} + + + + Page: + + + #{r.pageUid} — open in page module + + + NEW (would be created hidden) + + + · {r.elements} element(s) touched + + + + + {line} + + + + + + + + + + + + diff --git a/public/_frontend/.htaccess b/public/_frontend/.htaccess index 15135c6..52d7f05 100644 --- a/public/_frontend/.htaccess +++ b/public/_frontend/.htaccess @@ -1,27 +1,27 @@ -RewriteEngine On -RewriteBase /_frontend/ - -RewriteRule ^index\.html$ - [L] - -RewriteCond %{REQUEST_FILENAME} -f [OR] -RewriteCond %{REQUEST_FILENAME} -d -RewriteRule ^ - [L] - -RewriteRule ^ index.html [L] - -# ── Cache headers ──────────────────────────────────────────────────────────── - -# index.html: never cache — always fetch fresh so the browser picks up -# the latest hashed JS/CSS filenames after a deploy. - - Header set Cache-Control "no-store, no-cache, must-revalidate" - Header set Pragma "no-cache" - Header set Expires "0" - - -# Hashed assets (JS, CSS, fonts, images inside /assets/): -# Vite appends a content hash to every filename, so these are immutable. -# Cache them for 1 year — a new deploy produces new filenames automatically. - - Header set Cache-Control "public, max-age=31536000, immutable" - +RewriteEngine On +RewriteBase /_frontend/ + +RewriteRule ^index\.html$ - [L] + +RewriteCond %{REQUEST_FILENAME} -f [OR] +RewriteCond %{REQUEST_FILENAME} -d +RewriteRule ^ - [L] + +RewriteRule ^ index.html [L] + +# ── Cache headers ──────────────────────────────────────────────────────────── + +# index.html: never cache — always fetch fresh so the browser picks up +# the latest hashed JS/CSS filenames after a deploy. + + Header set Cache-Control "no-store, no-cache, must-revalidate" + Header set Pragma "no-cache" + Header set Expires "0" + + +# Hashed assets (JS, CSS, fonts, images inside /assets/): +# Vite appends a content hash to every filename, so these are immutable. +# Cache them for 1 year — a new deploy produces new filenames automatically. + + Header set Cache-Control "public, max-age=31536000, immutable" + diff --git a/public/_frontend/index.html b/public/_frontend/index.html index 05b296d..dd1d6fd 100644 --- a/public/_frontend/index.html +++ b/public/_frontend/index.html @@ -23,8 +23,8 @@ VITEC - - + +
No workbooks yet — upload Joe's filled .xlsx files above.
{r.error}
+ Page: + + + #{r.pageUid} — open in page module + + + NEW (would be created hidden) + + + · {r.elements} element(s) touched +