Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43519505bc | ||
|
|
6961254e57 | ||
|
|
6811463343 |
No files matched your search
@@ -122,3 +122,9 @@ CLAUDE.local.md
|
||||
|
||||
# local developer scratch notes
|
||||
.dev-notes.md
|
||||
|
||||
# Agency product workbooks - working copies synced from Nextcloud
|
||||
# (Material/Products). The manifest IS tracked: it documents which
|
||||
# workbook feeds which product record.
|
||||
migrations/products_xlsx/*
|
||||
!migrations/products_xlsx/manifest.json
|
||||
@@ -0,0 +1,339 @@
|
||||
{
|
||||
"_doku": "Workbook -> Produkt-Zuordnung fuer vitec:import-product-workbooks. product = Slug des Zieldatensatzes; family = K-Ebene (Familienseite, wird NIE importiert); product null = ungeklaert (Kommando meldet und ueberspringt). Erzeugt 2026-09-24 aus der Analyse vom 18.09. + Sitemap-V2 Spalte K; von Hand pflegbar.",
|
||||
"workbooks": {
|
||||
"AV over IP and KVM Product Pages/Aligo_Product Page Content.xlsx": {
|
||||
"product": "aligo"
|
||||
},
|
||||
"AV over IP and KVM Product Pages/Arqa_Product Page Content.xlsx": {
|
||||
"product": "arqa"
|
||||
},
|
||||
"AV over IP and KVM Product Pages/QTX100_Product Page Content.xlsx": {
|
||||
"product": "qtx100"
|
||||
},
|
||||
"AV over IP and KVM Product Pages/RX1-C_Product Page Content.xlsx": {
|
||||
"product": "rx1c"
|
||||
},
|
||||
"AV over IP and KVM Product Pages/RX1-F_Product Page Content.xlsx": {
|
||||
"product": "rx1f"
|
||||
},
|
||||
"AV over IP and KVM Product Pages/RX100_Product Page Content.xlsx": {
|
||||
"product": "rx100"
|
||||
},
|
||||
"AV over IP and KVM Product Pages/TX1-C_Product Page Content.xlsx": {
|
||||
"product": "tx1c"
|
||||
},
|
||||
"AV over IP and KVM Product Pages/TX1-F_Product Page Content.xlsx": {
|
||||
"product": "tx1f"
|
||||
},
|
||||
"AV over IP and KVM Product Pages/TX100 SQX_Product Page Content.xlsx": {
|
||||
"product": "tx100-sqx"
|
||||
},
|
||||
"AV over IP and KVM Product Pages/TX100_Product Page Content.xlsx": {
|
||||
"product": "tx100"
|
||||
},
|
||||
"Control Room Platforms Product Pages/Aetria Command Center_Product Page Content.xlsx": {
|
||||
"product": "aetria-command-center"
|
||||
},
|
||||
"Control Room Platforms Product Pages/Aetria Network Manager_Product Page Content.xlsx": {
|
||||
"product": "aetria-network-manager"
|
||||
},
|
||||
"Control Room Platforms Product Pages/Aetria Touch_Product Page Content.xlsx": {
|
||||
"product": "aetria-touch"
|
||||
},
|
||||
"Control Room Platforms Product Pages/Aetria WallControl_Product Page Content.xlsx": {
|
||||
"product": "aetria-wallcontrol"
|
||||
},
|
||||
"Control Room Platforms Product Pages/Aetria Workstation_Product Page Content.xlsx": {
|
||||
"product": "aetria-workstation"
|
||||
},
|
||||
"Control Room Platforms Product Pages/Aetria_Product Page Content.xlsx": {
|
||||
"product": "aetria"
|
||||
},
|
||||
"Gateways and Transcoding Product Pages/IP Gateways_Product Page Content.xlsx": {
|
||||
"family": true,
|
||||
"note": "K-Ebene: ChannelLink (IP to IP gateways) - Familien-Seite /gateways-and-transcoding/ip-gateways/"
|
||||
},
|
||||
"Gateways and Transcoding Product Pages/PRISM Transcoder_Product Page Content.xlsx": {
|
||||
"family": true,
|
||||
"note": "K-Ebene: PRISM Transcoder"
|
||||
},
|
||||
"Gateways and Transcoding Product Pages/PRISM_1RU_Rack_Mount_Transcoder.xlsx": {
|
||||
"product": "prism-1ru-rack-mount-transcoder"
|
||||
},
|
||||
"Gateways and Transcoding Product Pages/RF Gateways_Product Page Content.xlsx": {
|
||||
"product": "45-series-rf-gateways",
|
||||
"note": "manuell 2026-09-24, PRUEFEN: einziger RF-Gateway-Record; Workbook-URL ist aber der Familien-Pfad"
|
||||
},
|
||||
"Gateways and Transcoding Product Pages/SFF_PRISM.xlsx": {
|
||||
"product": "sff-prism-small-format-appliance-transcoder"
|
||||
},
|
||||
"Graphics and Multi-Display Product Pages/IQS4 (4K splitter)_Product Page Content.xlsx": {
|
||||
"family": true,
|
||||
"note": "K-Ebene: IQS4 (4K splitter)"
|
||||
},
|
||||
"Graphics and Multi-Display Product Pages/Image Graphics Cards (multi-output GPU cards)_Product Page Content.xlsx": {
|
||||
"family": true,
|
||||
"note": "K-Ebene: Image Graphics Cards (multi-output GPU cards)"
|
||||
},
|
||||
"Graphics and Multi-Display Product Pages/Image_2K.xlsx": {
|
||||
"product": "image-2k"
|
||||
},
|
||||
"Graphics and Multi-Display Product Pages/Image_4K.xlsx": {
|
||||
"product": "image-4k"
|
||||
},
|
||||
"IPTV and Digital Signage Product Pages/Aligo Workstation.xlsx": {
|
||||
"product": "aligo-workstation"
|
||||
},
|
||||
"IPTV and Digital Signage Product Pages/ArtioSign_Product Page Content.xlsx": {
|
||||
"product": null,
|
||||
"note": "manuell zuordnen: verstecktes/abweichend benanntes Produkt ODER Familienseite"
|
||||
},
|
||||
"IPTV and Digital Signage Product Pages/ArtioView_Product Page Content.xlsx": {
|
||||
"product": null,
|
||||
"note": "manuell zuordnen: verstecktes/abweichend benanntes Produkt ODER Familienseite"
|
||||
},
|
||||
"IPTV and Digital Signage Product Pages/Avedia End-Points_Product Page Content.xlsx": {
|
||||
"product": "avedia-end-points"
|
||||
},
|
||||
"IPTV and Digital Signage Product Pages/Avedia Platform_Product Page Content.xlsx": {
|
||||
"product": "avedia-platform-iptv-signage-cmsserver"
|
||||
},
|
||||
"IPTV and Digital Signage Product Pages/Avedia_Modular_System.xlsx": {
|
||||
"family": true,
|
||||
"note": "K-Ebene: Avedia Modular System"
|
||||
},
|
||||
"IPTV and Digital Signage Product Pages/Avedia_Product_Page_Content.xlsx": {
|
||||
"family": true,
|
||||
"note": "K-Ebene: Avedia Platform (URL /iptv-and-digital-signage/avedia/); Produkt-Workbook ist Avedia Platform_Product Page Content"
|
||||
},
|
||||
"IPTV and Digital Signage Product Pages/ChannelLink_Product Page Content.xlsx": {
|
||||
"product": "channellink"
|
||||
},
|
||||
"IPTV and Digital Signage Product Pages/EZ TV End-Points.xlsx": {
|
||||
"product": "ez-tv-end-points-ep6"
|
||||
},
|
||||
"IPTV and Digital Signage Product Pages/EZ TV Platform CMS-Server.xlsx": {
|
||||
"product": "ez-tv-platform-iptv-signage-cmsserver",
|
||||
"note": "manuell 2026-09-24: das eigentliche Produkt-Workbook (URL .../ez-tv-platform-cms-server/)"
|
||||
},
|
||||
"IPTV and Digital Signage Product Pages/EZ TV Platform.xlsx": {
|
||||
"family": true,
|
||||
"note": "K-Ebene: EZ TV Platform (URL /platforms-and-end-points/ez-tv-platform/); Auto-Titel-Match auf den Produkt-Record war FALSCH, korrigiert 2026-09-24"
|
||||
},
|
||||
"IPTV and Digital Signage Product Pages/Media Players_Product Page Content.xlsx": {
|
||||
"product": null,
|
||||
"note": "manuell zuordnen: verstecktes/abweichend benanntes Produkt ODER Familienseite"
|
||||
},
|
||||
"IPTV and Digital Signage Product Pages/Operator Workstations.xlsx": {
|
||||
"family": true,
|
||||
"note": "K-Ebene: Operator Workstations"
|
||||
},
|
||||
"IPTV and Digital Signage Product Pages/X-Player End-Points.xlsx": {
|
||||
"product": "x-player-end-points-xp23-xp25-xp26"
|
||||
},
|
||||
"Infrastructure and Accessories Product Pages/Accessories & Cables.xlsx": {
|
||||
"family": true,
|
||||
"note": "K-Ebene: Accessories & Cables"
|
||||
},
|
||||
"Infrastructure and Accessories Product Pages/Chassis Systems.xlsx": {
|
||||
"product": null,
|
||||
"note": "manuell zuordnen: verstecktes/abweichend benanntes Produkt ODER Familienseite"
|
||||
},
|
||||
"Infrastructure and Accessories Product Pages/Express Backplanes.xlsx": {
|
||||
"family": true,
|
||||
"note": "K-Ebene: Express Backplanes"
|
||||
},
|
||||
"Video Capture Product Pages/ActiveSQX.xlsx": {
|
||||
"product": "activesqx"
|
||||
},
|
||||
"Video Capture Product Pages/ActiveSQX2.xlsx": {
|
||||
"product": "activesqx2"
|
||||
},
|
||||
"Video Capture Product Pages/Vision.xlsx": {
|
||||
"family": true,
|
||||
"note": "K-Ebene: Vision (DVI & SDI capture cards)"
|
||||
},
|
||||
"Video Capture Product Pages/VisionAV-HD.xlsx": {
|
||||
"product": "visionav-hd"
|
||||
},
|
||||
"Video Capture Product Pages/VisionAV-SDI.xlsx": {
|
||||
"product": "visionav-sdi"
|
||||
},
|
||||
"Video Capture Product Pages/VisionAV.xlsx": {
|
||||
"product": "visionav"
|
||||
},
|
||||
"Video Capture Product Pages/VisionHD4.xlsx": {
|
||||
"product": "visionhd4"
|
||||
},
|
||||
"Video Capture Product Pages/VisionIO-SDI4.xlsx": {
|
||||
"product": null,
|
||||
"note": "manuell zuordnen: verstecktes/abweichend benanntes Produkt ODER Familienseite"
|
||||
},
|
||||
"Video Capture Product Pages/VisionIO-XD2.xlsx": {
|
||||
"product": "visionio-xd2"
|
||||
},
|
||||
"Video Capture Product Pages/VisionIO.xlsx": {
|
||||
"family": true,
|
||||
"note": "K-Ebene: VisionIO (real-time capture + overlay)"
|
||||
},
|
||||
"Video Capture Product Pages/VisionLC-HD.xlsx": {
|
||||
"product": "visionlc-hd"
|
||||
},
|
||||
"Video Capture Product Pages/VisionLC-HD2.xlsx": {
|
||||
"product": "visionlc-hd2"
|
||||
},
|
||||
"Video Capture Product Pages/VisionLC-SDI.xlsx": {
|
||||
"product": "visionlc-sdi"
|
||||
},
|
||||
"Video Capture Product Pages/VisionLC.xlsx": {
|
||||
"family": true,
|
||||
"note": "K-Ebene: VisionLC (low-profile capture cards)"
|
||||
},
|
||||
"Video Capture Product Pages/VisionRGB-E1S.xlsx": {
|
||||
"product": "visionrgb-e1s"
|
||||
},
|
||||
"Video Capture Product Pages/VisionRGB-E2S.xlsx": {
|
||||
"product": "visionrgb-e2s"
|
||||
},
|
||||
"Video Capture Product Pages/VisionSC (scalable capture & processing)_Product Page Content.xlsx": {
|
||||
"family": true,
|
||||
"note": "K-Ebene: VisionSC (scalable capture & processing)"
|
||||
},
|
||||
"Video Capture Product Pages/VisionSC-A2.xlsx": {
|
||||
"product": "visionsc-a2"
|
||||
},
|
||||
"Video Capture Product Pages/VisionSC-DP2.xlsx": {
|
||||
"product": "visionsc-dp2"
|
||||
},
|
||||
"Video Capture Product Pages/VisionSC-HD4Plus.xlsx": {
|
||||
"product": "visionsc-hd4"
|
||||
},
|
||||
"Video Capture Product Pages/VisionSC-S2.xlsx": {
|
||||
"product": "visionsc-s2"
|
||||
},
|
||||
"Video Capture Product Pages/VisionSC-SDI4.xlsx": {
|
||||
"product": null,
|
||||
"note": "manuell zuordnen: verstecktes/abweichend benanntes Produkt ODER Familienseite"
|
||||
},
|
||||
"Video Capture Product Pages/VisionSC-UHD2.xlsx": {
|
||||
"product": "visionsc-uhd2"
|
||||
},
|
||||
"Video Capture Product Pages/VisionSD12.xlsx": {
|
||||
"product": "visionsd12"
|
||||
},
|
||||
"Video Capture Product Pages/VisionSD4Plus1S.xlsx": {
|
||||
"product": "visionsd4-1s"
|
||||
},
|
||||
"Video Capture Product Pages/VisionSD8.xlsx": {
|
||||
"product": "visionsd8"
|
||||
},
|
||||
"Video Encoding and Streaming Product Pages/38-Series Encoders_Product Page Content.xlsx": {
|
||||
"product": "38-series-encoders-modular-blade-systems"
|
||||
},
|
||||
"Video Encoding and Streaming Product Pages/Diamond_IP_OG.xlsx": {
|
||||
"product": "diamond-ip-og-ip-to-ip-encoder"
|
||||
},
|
||||
"Video Encoding and Streaming Product Pages/MGES6000.xlsx": {
|
||||
"product": "mges6000-broadcast-quality-hdsd-h264-quad-input-iptv-encoder"
|
||||
},
|
||||
"Video Encoding and Streaming Product Pages/MGES7000.xlsx": {
|
||||
"product": "mges7000-high-density-4kuhdhd-hevc-h264-iptv-encoding-blade"
|
||||
},
|
||||
"Video Encoding and Streaming Product Pages/MGW Ace Decoder.xlsx": {
|
||||
"product": "mgw-ace-decoder"
|
||||
},
|
||||
"Video Encoding and Streaming Product Pages/MGW Ace_Product Page Content.xlsx": {
|
||||
"product": "mgw-ace-encoder",
|
||||
"note": "manuell 2026-09-24: Encoder (Hero: ultra-low latency HEVC encoding); Praefix-Match war doppeldeutig (encoder/decoder)"
|
||||
},
|
||||
"Video Encoding and Streaming Product Pages/MGW Diamond OG.xlsx": {
|
||||
"product": null,
|
||||
"note": "KLAEREN mit Olli 2026-09-24: Record uid 20 heisst 'MGW Diamond+ OG' bei Slug mgw-diamond-og-sdi-blade-encoder (Titel/Slug widerspruechlich); gehoert dieses Workbook oder MGW_Diamond_Plus_OG.xlsx zu uid 20?"
|
||||
},
|
||||
"Video Encoding and Streaming Product Pages/MGW Diamond-H_Product Page Content.xlsx": {
|
||||
"product": "mgw-diamond-h-hdmi-encoder"
|
||||
},
|
||||
"Video Encoding and Streaming Product Pages/MGW Diamond-Hx OG.xlsx": {
|
||||
"product": "mgw-diamond-hx-og"
|
||||
},
|
||||
"Video Encoding and Streaming Product Pages/MGW Diamond_Product Page Content.xlsx": {
|
||||
"product": "mgw-diamond-sdi-encoder"
|
||||
},
|
||||
"Video Encoding and Streaming Product Pages/MGW Pico_Product Page Content.xlsx": {
|
||||
"product": "mgw-pico-portable-encoder"
|
||||
},
|
||||
"Video Encoding and Streaming Product Pages/MGW_Ace_Decoder_OG.xlsx": {
|
||||
"product": "mgw-ace-decoder-og-ultra-low-latency-blade-encoder"
|
||||
},
|
||||
"Video Encoding and Streaming Product Pages/MGW_Blade_System.xlsx": {
|
||||
"family": true,
|
||||
"note": "K-Ebene: MGW Blade System"
|
||||
},
|
||||
"Video Encoding and Streaming Product Pages/MGW_Diamond_Hx_OG.xlsx": {
|
||||
"product": "mgw-diamond-hx-og"
|
||||
},
|
||||
"Video Encoding and Streaming Product Pages/MGW_Diamond_Plus_OG.xlsx": {
|
||||
"product": null,
|
||||
"note": "KLAEREN mit Olli 2026-09-24: siehe MGW Diamond OG.xlsx - uid 20 traegt den Plus-Titel bei Plain-Slug"
|
||||
},
|
||||
"Video Encoding and Streaming Product Pages/PCIe Encoders_Product Page Content.xlsx": {
|
||||
"product": null,
|
||||
"note": "manuell zuordnen: verstecktes/abweichend benanntes Produkt ODER Familienseite"
|
||||
},
|
||||
"Video Encoding and Streaming Product Pages/VITEC OG_Product Page Content.xlsx": {
|
||||
"family": true,
|
||||
"note": "K-Ebene: VITEC OG Modular System (zweites Workbook derselben Familie neben VITEC_OG_Modular_System.xlsx)"
|
||||
},
|
||||
"Video Encoding and Streaming Product Pages/VITEC_OG_Modular_System.xlsx": {
|
||||
"family": true,
|
||||
"note": "K-Ebene: VITEC OG Modular System"
|
||||
},
|
||||
"Video Wall and Display Processing Product Pages/Fx4.xlsx": {
|
||||
"product": "fx4"
|
||||
},
|
||||
"Video Wall and Display Processing Product Pages/Fx4_HDR.xlsx": {
|
||||
"product": "fx4-hdr"
|
||||
},
|
||||
"Video Wall and Display Processing Product Pages/Fx4_SDI.xlsx": {
|
||||
"product": "fx4-sdi"
|
||||
},
|
||||
"Video Wall and Display Processing Product Pages/Genetec.xlsx": {
|
||||
"product": "genetec"
|
||||
},
|
||||
"Video Wall and Display Processing Product Pages/Hx4.xlsx": {
|
||||
"product": "hx4"
|
||||
},
|
||||
"Video Wall and Display Processing Product Pages/Milestone.xlsx": {
|
||||
"product": "milestone"
|
||||
},
|
||||
"Video Wall and Display Processing Product Pages/VMS Plugins & Integrations_Product Page Content.xlsx": {
|
||||
"family": true,
|
||||
"note": "K-Ebene: VMS Plugins & Integrations"
|
||||
},
|
||||
"Video Wall and Display Processing Product Pages/VSN V3_Product Page Content.xlsx": {
|
||||
"product": "vsn-v3"
|
||||
},
|
||||
"Video Wall and Display Processing Product Pages/VSNMicro 600_Product Page Content.xlsx": {
|
||||
"product": "vsnmicro-600"
|
||||
},
|
||||
"Video Wall and Display Processing Product Pages/VSNMini 300_Product Page Content.xlsx": {
|
||||
"product": "vsnmini-300"
|
||||
},
|
||||
"Video Wall and Display Processing Product Pages/VSN_Product Page Content.xlsx": {
|
||||
"family": true,
|
||||
"note": "K-Ebene: VSN"
|
||||
},
|
||||
"Video Wall and Display Processing Product Pages/Video Wall Management Software_Product Page Content.xlsx": {
|
||||
"family": true,
|
||||
"note": "K-Ebene: Video Wall Management Software"
|
||||
},
|
||||
"Video Wall and Display Processing Product Pages/WallControl_10.xlsx": {
|
||||
"product": null,
|
||||
"note": "manuell zuordnen: verstecktes/abweichend benanntes Produkt ODER Familienseite"
|
||||
},
|
||||
"Video Wall and Display Processing Product Pages/X-Series (Multi-display Processors)_Product Page Content.xlsx": {
|
||||
"family": true,
|
||||
"note": "K-Ebene: X-Series (Multi-display Processors)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,7 @@ final class AlignSolutionsCommand extends Command
|
||||
private const TABLE = 'tx_vitec_domain_model_solution';
|
||||
|
||||
/** Binding target: group => solutions, verbatim from the sheet (typo fixed). */
|
||||
private const TARGET = [
|
||||
public const TARGET = [
|
||||
'Control Room & Command Centre Solutions' => [
|
||||
'Control Room Platforms (SOC / NOC / Command Centres)',
|
||||
'Command & Control Visualisation',
|
||||
@@ -170,8 +170,10 @@ final class AlignSolutionsCommand extends Command
|
||||
}
|
||||
|
||||
$solutions = [];
|
||||
// is_group records (the 8 group-level cards, hybrid 2026-09-24) are
|
||||
// invisible to this command: they are neither targets nor prunable.
|
||||
foreach ($connection->fetchAllAssociative(
|
||||
'SELECT uid, title, slug FROM ' . self::TABLE . ' WHERE deleted = 0'
|
||||
'SELECT uid, title, slug FROM ' . self::TABLE . ' WHERE deleted = 0 AND is_group = 0'
|
||||
) as $row) {
|
||||
$solutions[(string)$row['title']] = ['uid' => (int)$row['uid'], 'slug' => (string)$row['slug']];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Evomedien\Vitec\Command;
|
||||
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use TYPO3\CMS\Core\Core\Bootstrap;
|
||||
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||
use TYPO3\CMS\Core\DataHandling\DataHandler;
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
|
||||
/**
|
||||
* Hybrid decision 2026-09-24: the eight solution GROUPS become selectable
|
||||
* records (is_group = 1) so the product `solutions` relation can point at a
|
||||
* group or at an individual Generic Solution - the agency workbooks
|
||||
* reference the group level in 98% of their Solution Cards.
|
||||
*
|
||||
* Per group (source of truth: AlignSolutionsCommand::TARGET keys):
|
||||
* - solution record, is_group = 1, slug from the title (collision-safe)
|
||||
* - categories := the group's own sys_category (below root "Solution")
|
||||
* - detail_page := the /solutions group page, matched by normalized title
|
||||
* (rule of vitec:map-solution-pages; `-N` duplicate slugs lose,
|
||||
* ambiguity is reported, never guessed)
|
||||
*
|
||||
* Group records are invisible to solution lists, to vitec:align-solutions
|
||||
* and to Solr - they exist for the product relation. Idempotent: existing
|
||||
* group records are left alone.
|
||||
*
|
||||
* vendor/bin/typo3 vitec:create-solution-groups --dry-run
|
||||
* vendor/bin/typo3 vitec:create-solution-groups
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'vitec:create-solution-groups',
|
||||
description: 'Create the 8 solution-group records (is_group=1) for the product solutions relation'
|
||||
)]
|
||||
final class CreateSolutionGroupsCommand extends Command
|
||||
{
|
||||
private const TABLE = 'tx_vitec_domain_model_solution';
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report only - nothing written');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
Bootstrap::initializeBackendAuthentication();
|
||||
$dryRun = (bool)$input->getOption('dry-run');
|
||||
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable(self::TABLE);
|
||||
|
||||
$groups = array_keys(AlignSolutionsCommand::TARGET);
|
||||
|
||||
// Existing records: titles for idempotency, slugs for collision checks.
|
||||
$existingByTitle = [];
|
||||
$usedSlugs = [];
|
||||
foreach ($connection->fetchAllAssociative(
|
||||
'SELECT uid, title, slug, is_group FROM ' . self::TABLE . ' WHERE deleted = 0'
|
||||
) as $row) {
|
||||
$existingByTitle[$this->normalize((string)$row['title'])] = $row;
|
||||
$usedSlugs[(string)$row['slug']] = true;
|
||||
}
|
||||
|
||||
$pids = $connection->fetchFirstColumn('SELECT pid FROM ' . self::TABLE . ' WHERE deleted = 0');
|
||||
$pidCounts = array_count_values(array_map('intval', $pids));
|
||||
arsort($pidCounts);
|
||||
$storagePid = (int)(array_key_first($pidCounts) ?? 0);
|
||||
if ($storagePid <= 0) {
|
||||
$output->writeln('<error>No storage pid detectable (no solution records?)</error>');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// Group categories below the root category "Solution".
|
||||
$rootCat = (int)($connection->fetchOne(
|
||||
"SELECT uid FROM sys_category WHERE deleted = 0 AND parent = 0 AND title = 'Solution'"
|
||||
) ?: 0);
|
||||
$catByTitle = [];
|
||||
if ($rootCat > 0) {
|
||||
foreach ($connection->fetchAllAssociative(
|
||||
'SELECT uid, title FROM sys_category WHERE deleted = 0 AND parent = ?',
|
||||
[$rootCat]
|
||||
) as $row) {
|
||||
$catByTitle[$this->normalize((string)$row['title'])] = (int)$row['uid'];
|
||||
}
|
||||
}
|
||||
|
||||
// Candidate pages: standard pages, hidden allowed (map-solution-pages rule).
|
||||
$pagesByTitle = [];
|
||||
foreach ($connection->fetchAllAssociative(
|
||||
'SELECT uid, title, slug FROM pages WHERE deleted = 0 AND doktype = 1'
|
||||
) as $row) {
|
||||
$pagesByTitle[$this->normalize((string)$row['title'])][] = $row;
|
||||
}
|
||||
|
||||
$datamap = [];
|
||||
$newIndex = 0;
|
||||
$created = 0;
|
||||
foreach ($groups as $group) {
|
||||
$key = $this->normalize($group);
|
||||
$existing = $existingByTitle[$key] ?? null;
|
||||
if ($existing !== null) {
|
||||
$state = ((int)$existing['is_group'] === 1) ? 'ok (is_group=1)' : '<error>KONFLIKT: Record gleichen Titels ohne is_group</error>';
|
||||
$output->writeln(sprintf(' %-45s existiert, uid %d - %s', $group, (int)$existing['uid'], $state));
|
||||
continue;
|
||||
}
|
||||
|
||||
$pageUid = 0;
|
||||
$candidates = $pagesByTitle[$key] ?? [];
|
||||
if (count($candidates) > 1) {
|
||||
// `-N` duplicate slugs lose the disambiguation.
|
||||
$candidates = array_values(array_filter(
|
||||
$candidates,
|
||||
static fn(array $p): bool => !preg_match('/-\d+$/', (string)$p['slug'])
|
||||
)) ?: $candidates;
|
||||
}
|
||||
if (count($candidates) === 1) {
|
||||
$pageUid = (int)$candidates[0]['uid'];
|
||||
} elseif (count($candidates) > 1) {
|
||||
$output->writeln(sprintf(' <comment>%s: Seite mehrdeutig (%d Kandidaten) - detail_page bleibt leer</comment>', $group, count($candidates)));
|
||||
} else {
|
||||
$output->writeln(sprintf(' <comment>%s: keine Gruppen-Seite gefunden - detail_page bleibt leer</comment>', $group));
|
||||
}
|
||||
|
||||
$slug = $this->slugFor($group, $usedSlugs);
|
||||
$fields = [
|
||||
'pid' => $storagePid,
|
||||
'title' => $group,
|
||||
'slug' => $slug,
|
||||
'is_group' => 1,
|
||||
'detail_page' => $pageUid,
|
||||
];
|
||||
$catUid = $catByTitle[$key] ?? 0;
|
||||
if ($catUid > 0) {
|
||||
$fields['categories'] = (string)$catUid;
|
||||
} else {
|
||||
$output->writeln(sprintf(' <comment>%s: keine Gruppen-Kategorie gefunden</comment>', $group));
|
||||
}
|
||||
|
||||
$output->writeln(sprintf(
|
||||
' %-45s %s slug=%s page=%s cat=%s',
|
||||
$group,
|
||||
$dryRun ? 'wuerde angelegt' : 'anlegen',
|
||||
$slug,
|
||||
$pageUid ?: '-',
|
||||
$catUid ?: '-'
|
||||
));
|
||||
$datamap[self::TABLE]['NEWGROUP' . (++$newIndex)] = $fields;
|
||||
$created++;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$output->writeln(sprintf('<info>DRY-RUN: %d Gruppen-Records wuerden angelegt.</info>', $created));
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
if ($datamap !== []) {
|
||||
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||
$dataHandler->start($datamap, []);
|
||||
$dataHandler->process_datamap();
|
||||
foreach ($dataHandler->errorLog as $error) {
|
||||
$output->writeln("<error>$error</error>");
|
||||
}
|
||||
}
|
||||
$output->writeln(sprintf('<info>%d Gruppen-Records angelegt.</info>', $created));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function normalize(string $title): string
|
||||
{
|
||||
$title = str_replace('&', 'and', mb_strtolower($title));
|
||||
$title = (string)preg_replace('/[^a-z0-9]+/', ' ', $title);
|
||||
return trim((string)preg_replace('/\s+/', ' ', $title));
|
||||
}
|
||||
|
||||
/** @param array<string,bool> $usedSlugs */
|
||||
private function slugFor(string $title, array &$usedSlugs): string
|
||||
{
|
||||
$slug = trim((string)preg_replace('/[^a-z0-9]+/', '-', str_replace('&', 'and', mb_strtolower($title))), '-');
|
||||
$candidate = $slug;
|
||||
$i = 1;
|
||||
while (isset($usedSlugs[$candidate])) {
|
||||
$candidate = $slug . '-group' . ($i > 1 ? '-' . $i : '');
|
||||
$i++;
|
||||
}
|
||||
$usedSlugs[$candidate] = true;
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,15 @@ final class ImportProductWorkbookCommand extends Command
|
||||
{
|
||||
private const TABLE = 'tx_vitec_domain_model_product';
|
||||
|
||||
/**
|
||||
* Never written by the workbook import, whatever the stored mapping
|
||||
* says: product titles are canonical per sitemap V2 (decision
|
||||
* 2026-09-04) - the 2026-09-24 batch dry run showed a stored module
|
||||
* mapping would have renamed six products. urltitle would only receive
|
||||
* a duplicate of the hero one-liner.
|
||||
*/
|
||||
private const PROTECTED_FIELDS = ['title', 'urltitle'];
|
||||
|
||||
public function __construct(
|
||||
private readonly ProductXlsxReader $reader,
|
||||
private readonly MappingRepository $mappingRepository,
|
||||
@@ -89,20 +98,30 @@ final class ImportProductWorkbookCommand extends Command
|
||||
$output->writeln(sprintf('<comment>URL mismatch: /%s (workbook) vs /%s (record) - check the target!</comment>', $url, $slug));
|
||||
}
|
||||
|
||||
$mapping = array_filter($this->mappingRepository->load('product_xlsx')['mapping'] ?? []);
|
||||
if ($mapping === []) {
|
||||
$mapping = ProductTextImportController::DEFAULT_MAPPING;
|
||||
}
|
||||
// Defaults as the base, the stored module mapping as per-field
|
||||
// override - a field the module never saved (its dropdown was empty
|
||||
// on an old-format workbook, e.g. `highlights`) still imports with
|
||||
// its default selector. Found in the 2026-09-24 batch run: the
|
||||
// stored mapping predated the highlights default and silently
|
||||
// dropped the field on all 69 products.
|
||||
$mapping = array_merge(
|
||||
ProductTextImportController::DEFAULT_MAPPING,
|
||||
array_filter($this->mappingRepository->load('product_xlsx')['mapping'] ?? [])
|
||||
);
|
||||
|
||||
$data = [];
|
||||
foreach ($mapping as $field => $selector) {
|
||||
if (in_array($field, self::PROTECTED_FIELDS, true)) {
|
||||
$output->writeln(sprintf('%-20s %-34s -> protected (canonical, never written)', $field, $selector));
|
||||
continue;
|
||||
}
|
||||
$value = $this->reader->valueFor($parsed, $selector);
|
||||
if ($value === null) {
|
||||
$output->writeln(sprintf('%-20s %-34s -> not in this file', $field, $selector));
|
||||
continue;
|
||||
}
|
||||
$old = (string)($product[$field] ?? '');
|
||||
if (trim($old) === trim($value)) {
|
||||
if ($this->isUnchanged($old, $value)) {
|
||||
$output->writeln(sprintf('%-20s %-34s -> unchanged', $field, $selector));
|
||||
continue;
|
||||
}
|
||||
@@ -134,6 +153,22 @@ final class ImportProductWorkbookCommand extends Command
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare tolerant of the RTE storage transform, which wraps bare
|
||||
* content in <p>...</p> and strips newlines on save - without this every
|
||||
* re-run reported already-imported richtext fields as WRITE (the known
|
||||
* false-positive from the module's preview).
|
||||
*/
|
||||
private function isUnchanged(string $old, string $new): bool
|
||||
{
|
||||
$normalize = static fn(string $s): string => trim((string)preg_replace('/\s+/u', ' ', $s));
|
||||
if ($normalize($old) === $normalize($new)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $normalize($old) === $normalize('<p>' . $new . '</p>');
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
private function loadProduct(string $slugOrUid): ?array
|
||||
{
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Evomedien\Vitec\Command;
|
||||
|
||||
use Evomedien\Vitec\Import\ProductXlsxReader;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\ArrayInput;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\BufferedOutput;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use TYPO3\CMS\Core\Core\Bootstrap;
|
||||
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
|
||||
/**
|
||||
* Phase-1 batch around vitec:import-product-workbook: apply every agency
|
||||
* workbook under migrations/products_xlsx/ to its product record, driven by
|
||||
* the tracked manifest.json (workbook path -> product slug; family entries
|
||||
* are the K-level landing pages and are never imported; product null means
|
||||
* unresolved - reported and skipped).
|
||||
*
|
||||
* The heavy lifting stays in the single command (same reader, same mapping,
|
||||
* same DataHandler path, workbook parses stored for the module) - this
|
||||
* command only orchestrates it per manifest entry and aggregates a report.
|
||||
*
|
||||
* Without --apply every run is a dry run. --init-manifest resolves entries
|
||||
* with product null against the FULL product table (hidden records too):
|
||||
* workbook-URL segment or filename against slug, normalized title, and as a
|
||||
* last resort a UNIQUE slug prefix (mgw-ace -> mgw-ace-encoder). Only
|
||||
* unambiguous hits are written back to the manifest.
|
||||
*
|
||||
* vendor/bin/typo3 vitec:import-product-workbooks --init-manifest
|
||||
* vendor/bin/typo3 vitec:import-product-workbooks
|
||||
* vendor/bin/typo3 vitec:import-product-workbooks --only=aligo
|
||||
* vendor/bin/typo3 vitec:import-product-workbooks --apply
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'vitec:import-product-workbooks',
|
||||
description: 'Batch: apply all agency workbooks to their product records via manifest.json (dry-run without --apply)'
|
||||
)]
|
||||
final class ImportProductWorkbooksCommand extends Command
|
||||
{
|
||||
private const TABLE = 'tx_vitec_domain_model_product';
|
||||
|
||||
public function __construct(private readonly ProductXlsxReader $reader)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('dir', null, InputOption::VALUE_REQUIRED, 'Workbook directory holding manifest.json', 'migrations/products_xlsx');
|
||||
$this->addOption('apply', null, InputOption::VALUE_NONE, 'Actually write - without this every run is a dry run');
|
||||
$this->addOption('only', null, InputOption::VALUE_REQUIRED, 'Process only entries whose path or product slug contains this substring');
|
||||
$this->addOption('init-manifest', null, InputOption::VALUE_NONE, 'Resolve product:null manifest entries against the full product table (writes the manifest, imports nothing)');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
Bootstrap::initializeBackendAuthentication();
|
||||
|
||||
$dir = rtrim((string)$input->getOption('dir'), '/');
|
||||
$manifestFile = $dir . '/manifest.json';
|
||||
if (!is_file($manifestFile)) {
|
||||
$output->writeln('<error>Manifest not found: ' . $manifestFile . '</error>');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$manifest = json_decode((string)file_get_contents($manifestFile), true);
|
||||
$entries = $manifest['workbooks'] ?? null;
|
||||
if (!is_array($entries)) {
|
||||
$output->writeln('<error>Manifest malformed (no workbooks key)</error>');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if ((bool)$input->getOption('init-manifest')) {
|
||||
return $this->initManifest($dir, $manifestFile, $manifest, $output);
|
||||
}
|
||||
|
||||
$apply = (bool)$input->getOption('apply');
|
||||
$only = (string)($input->getOption('only') ?? '');
|
||||
$single = $this->getApplication()?->find('vitec:import-product-workbook');
|
||||
if ($single === null) {
|
||||
$output->writeln('<error>vitec:import-product-workbook not available</error>');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$output->writeln(sprintf('<info>%d manifest entries · %s</info>', count($entries), $apply ? 'APPLY' : 'DRY-RUN'));
|
||||
|
||||
$families = 0;
|
||||
$unresolved = [];
|
||||
$processed = 0;
|
||||
$totalWrites = 0;
|
||||
$errors = 0;
|
||||
foreach ($entries as $relPath => $entry) {
|
||||
$slug = $entry['product'] ?? null;
|
||||
if (!empty($entry['family'])) {
|
||||
$families++;
|
||||
continue;
|
||||
}
|
||||
if ($slug === null || $slug === '') {
|
||||
$unresolved[] = $relPath;
|
||||
continue;
|
||||
}
|
||||
if ($only !== '' && !str_contains($relPath, $only) && !str_contains((string)$slug, $only)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$file = $dir . '/' . $relPath;
|
||||
$buffer = new BufferedOutput();
|
||||
$arguments = ['file' => $file, 'product' => (string)$slug];
|
||||
if (!$apply) {
|
||||
$arguments['--dry-run'] = true;
|
||||
}
|
||||
try {
|
||||
$exit = $single->run(new ArrayInput($arguments), $buffer);
|
||||
} catch (\Throwable $e) {
|
||||
$exit = 1;
|
||||
$buffer->writeln('EXCEPTION: ' . $e->getMessage());
|
||||
}
|
||||
$text = $buffer->fetch();
|
||||
$writes = substr_count($text, '-> WRITE');
|
||||
$unchanged = substr_count($text, '-> unchanged');
|
||||
$missing = substr_count($text, '-> not in this file');
|
||||
$mismatch = str_contains($text, 'URL mismatch');
|
||||
$processed++;
|
||||
$totalWrites += $writes;
|
||||
|
||||
$flags = [];
|
||||
if ($mismatch) {
|
||||
$flags[] = 'URL-MISMATCH';
|
||||
}
|
||||
if ($exit !== 0) {
|
||||
$flags[] = 'ERROR';
|
||||
$errors++;
|
||||
}
|
||||
$output->writeln(sprintf(
|
||||
'%-72s -> %-28s %d write, %d unchanged, %d n/a%s',
|
||||
mb_substr($relPath, 0, 72),
|
||||
mb_substr((string)$slug, 0, 28),
|
||||
$writes,
|
||||
$unchanged,
|
||||
$missing,
|
||||
$flags !== [] ? ' [' . implode(', ', $flags) . ']' : ''
|
||||
));
|
||||
if ($exit !== 0 || $mismatch || $output->isVerbose()) {
|
||||
foreach (explode("\n", trim($text)) as $line) {
|
||||
$output->writeln(' ' . $line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$output->writeln('');
|
||||
$output->writeln(sprintf(
|
||||
'<info>%d workbooks processed · %d field writes%s · %d errors · %d family workbooks skipped · %d unresolved</info>',
|
||||
$processed,
|
||||
$totalWrites,
|
||||
$apply ? '' : ' (dry run - nothing written)',
|
||||
$errors,
|
||||
$families,
|
||||
count($unresolved)
|
||||
));
|
||||
foreach ($unresolved as $relPath) {
|
||||
$output->writeln(' <comment>unresolved: ' . $relPath . '</comment>');
|
||||
}
|
||||
if ($apply && $totalWrites > 0) {
|
||||
$output->writeln('<info>Flush the cache: vendor/bin/typo3 cache:flush</info>');
|
||||
}
|
||||
|
||||
return $errors === 0 ? Command::SUCCESS : Command::FAILURE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve product:null entries against the full product table. Writes
|
||||
* only unambiguous hits back into the manifest; everything else stays
|
||||
* null with its note.
|
||||
*
|
||||
* @param array<string,mixed> $manifest
|
||||
*/
|
||||
private function initManifest(string $dir, string $manifestFile, array $manifest, OutputInterface $output): int
|
||||
{
|
||||
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable(self::TABLE);
|
||||
$products = $connection->fetchAllAssociative(
|
||||
'SELECT uid, slug, title, hidden FROM ' . self::TABLE . ' WHERE deleted = 0'
|
||||
);
|
||||
$bySlug = [];
|
||||
$byTitle = [];
|
||||
foreach ($products as $product) {
|
||||
$bySlug[(string)$product['slug']] = $product;
|
||||
$byTitle[$this->normalize((string)$product['title'])] = $product;
|
||||
}
|
||||
|
||||
$resolved = 0;
|
||||
foreach ($manifest['workbooks'] as $relPath => $entry) {
|
||||
if (!empty($entry['family']) || (($entry['product'] ?? null) !== null)) {
|
||||
continue;
|
||||
}
|
||||
$candidates = [];
|
||||
$file = $dir . '/' . $relPath;
|
||||
if (is_file($file)) {
|
||||
try {
|
||||
$parsed = $this->reader->parse($file);
|
||||
$segment = basename(rtrim((string)($parsed['meta']['url'] ?? ''), '/'));
|
||||
if ($segment !== '') {
|
||||
$candidates[] = $segment;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// unreadable file - fall through to the filename
|
||||
}
|
||||
}
|
||||
$stem = (string)preg_replace('/(_?Product[_ ]Page[_ ]Content|_US_English)/i', '', pathinfo($relPath, PATHINFO_FILENAME));
|
||||
$candidates[] = $this->slugify($stem);
|
||||
|
||||
$hit = null;
|
||||
$via = '';
|
||||
foreach ($candidates as $candidate) {
|
||||
if (isset($bySlug[$candidate])) {
|
||||
$hit = $bySlug[$candidate];
|
||||
$via = 'slug';
|
||||
break;
|
||||
}
|
||||
if (isset($byTitle[$this->normalize($candidate)])) {
|
||||
$hit = $byTitle[$this->normalize($candidate)];
|
||||
$via = 'titel';
|
||||
break;
|
||||
}
|
||||
$prefixHits = array_values(array_filter(
|
||||
$products,
|
||||
static fn(array $p): bool => str_starts_with((string)$p['slug'], $candidate . '-')
|
||||
));
|
||||
if (count($prefixHits) === 1) {
|
||||
$hit = $prefixHits[0];
|
||||
$via = 'slug-prefix';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($hit !== null) {
|
||||
$manifest['workbooks'][$relPath] = [
|
||||
'product' => (string)$hit['slug'],
|
||||
'note' => sprintf('auto-resolved (%s%s) 2026-09-24', $via, ((int)$hit['hidden'] === 1) ? ', Record versteckt!' : ''),
|
||||
];
|
||||
$resolved++;
|
||||
$output->writeln(sprintf(' %-70s -> %s (%s%s)', mb_substr($relPath, 0, 70), $hit['slug'], $via, ((int)$hit['hidden'] === 1) ? ', HIDDEN' : ''));
|
||||
} else {
|
||||
$output->writeln(' <comment>' . $relPath . ' -> weiterhin ungeklaert</comment>');
|
||||
}
|
||||
}
|
||||
|
||||
file_put_contents($manifestFile, json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n");
|
||||
$output->writeln(sprintf('<info>%d entries resolved, manifest updated.</info>', $resolved));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function normalize(string $title): string
|
||||
{
|
||||
$title = str_replace(['&', '-', '_'], ['and', ' ', ' '], mb_strtolower($title));
|
||||
$title = (string)preg_replace('/\s*\(.*?\)/', '', $title);
|
||||
$title = (string)preg_replace('/[^a-z0-9]+/', ' ', $title);
|
||||
return trim((string)preg_replace('/\s+/', ' ', $title));
|
||||
}
|
||||
|
||||
private function slugify(string $value): string
|
||||
{
|
||||
return trim((string)preg_replace('/[^a-z0-9]+/', '-', str_replace('&', 'and', mb_strtolower($value))), '-');
|
||||
}
|
||||
}
|
||||
@@ -723,6 +723,7 @@ class Product extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity
|
||||
$this->heroimage = new ObjectStorage();
|
||||
$this->downloads = new ObjectStorage();
|
||||
$this->solutions = new ObjectStorage();
|
||||
$this->datasheets = new ObjectStorage();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1097,6 +1098,29 @@ class Product extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity
|
||||
$this->solutions = $solutions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Datasheet downloads for the Resources tab (MM)
|
||||
*
|
||||
* @var ObjectStorage<\Evomedien\Vitec\Domain\Model\Download>
|
||||
*/
|
||||
protected $datasheets;
|
||||
|
||||
/**
|
||||
* @return ObjectStorage<\Evomedien\Vitec\Domain\Model\Download>
|
||||
*/
|
||||
public function getDatasheets(): ObjectStorage
|
||||
{
|
||||
return $this->datasheets;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ObjectStorage<\Evomedien\Vitec\Domain\Model\Download> $datasheets
|
||||
*/
|
||||
public function setDatasheets(ObjectStorage $datasheets): void
|
||||
{
|
||||
$this->datasheets = $datasheets;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------- */
|
||||
|
||||
}
|
||||
@@ -204,4 +204,22 @@ class Solution extends AbstractEntity
|
||||
{
|
||||
$this->categories = $categories;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group-level record (one of the 8 solution groups) - hybrid decision
|
||||
* 2026-09-24. Excluded from lists; selectable in product relations.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
protected $isGroup = false;
|
||||
|
||||
public function getIsGroup(): bool
|
||||
{
|
||||
return $this->isGroup;
|
||||
}
|
||||
|
||||
public function setIsGroup(bool $isGroup): void
|
||||
{
|
||||
$this->isGroup = $isGroup;
|
||||
}
|
||||
}
|
||||
@@ -947,6 +947,7 @@ class ProductListJsonRenderer
|
||||
'teaser' => (string)($solutionRow['teaser'] ?? ''),
|
||||
'description' => \Evomedien\Vitec\Service\RteResolver::html($solutionRow['description'] ?? ''),
|
||||
'detailUrl' => \Evomedien\Vitec\Service\LinkResolver::pageUrl((int)($solutionRow['detail_page'] ?? 0)),
|
||||
'isGroup' => (bool)($solutionRow['is_group'] ?? false),
|
||||
'image' => $serializer->image($solutionUid, 'image', 'tx_vitec_domain_model_solution', true),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -771,6 +771,7 @@ class ProductShowJsonRenderer
|
||||
'teaser' => (string)($solutionRow['teaser'] ?? ''),
|
||||
'description' => \Evomedien\Vitec\Service\RteResolver::html($solutionRow['description'] ?? ''),
|
||||
'detailUrl' => \Evomedien\Vitec\Service\LinkResolver::pageUrl((int)($solutionRow['detail_page'] ?? 0)),
|
||||
'isGroup' => (bool)($solutionRow['is_group'] ?? false),
|
||||
'image' => $serializer->image($solutionUid, 'image', 'tx_vitec_domain_model_solution', true),
|
||||
];
|
||||
}
|
||||
@@ -817,6 +818,37 @@ class ProductShowJsonRenderer
|
||||
];
|
||||
}
|
||||
|
||||
// Datasheets are their own MM selection of download records and join
|
||||
// the same envelope as ready-made cards: url is the public forced-
|
||||
// download route (slug route since 2026-08-21, uid route as
|
||||
// fallback), so the DownloadFileResolver rules apply unchanged -
|
||||
// a private or hidden download simply 404s.
|
||||
$datasheetQueryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class)
|
||||
->getQueryBuilderForTable('tx_vitec_domain_model_download');
|
||||
$datasheetRows = $datasheetQueryBuilder
|
||||
->select('d.uid', 'd.title', 'd.slug', 'd.teaser', 'd.icon')
|
||||
->from('tx_vitec_domain_model_download', 'd')
|
||||
->join('d', 'tx_vitec_product_datasheet_mm', 'mm', 'mm.uid_foreign = d.uid')
|
||||
->where(
|
||||
$datasheetQueryBuilder->expr()->eq('mm.uid_local', $datasheetQueryBuilder->createNamedParameter($productUid, \Doctrine\DBAL\ParameterType::INTEGER)),
|
||||
$datasheetQueryBuilder->expr()->eq('d.deleted', 0),
|
||||
$datasheetQueryBuilder->expr()->eq('d.hidden', 0)
|
||||
)
|
||||
->orderBy('mm.sorting', 'ASC')
|
||||
->executeQuery()
|
||||
->fetchAllAssociative();
|
||||
|
||||
foreach ($datasheetRows as $datasheetRow) {
|
||||
$slug = trim((string)($datasheetRow['slug'] ?? ''), "/ ");
|
||||
$items[] = [
|
||||
'type' => 'datasheet',
|
||||
'title' => (string)($datasheetRow['title'] ?? ''),
|
||||
'text' => (string)($datasheetRow['teaser'] ?? ''),
|
||||
'icon' => ((string)($datasheetRow['icon'] ?? '')) !== '' ? (string)$datasheetRow['icon'] : 'datasheet',
|
||||
'url' => $slug !== '' ? '/download/' . $slug : '/download/file/' . (int)$datasheetRow['uid'],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'layout' => count($items) > 3 ? 'carousel' : 'grid',
|
||||
'items' => $items,
|
||||
|
||||
@@ -126,7 +126,10 @@ class SolutionListJsonRenderer
|
||||
->from(self::TABLE)
|
||||
->where(
|
||||
$qb->expr()->eq('deleted', 0),
|
||||
$qb->expr()->eq('hidden', 0)
|
||||
$qb->expr()->eq('hidden', 0),
|
||||
// Group-level records (hybrid 2026-09-24) exist for the
|
||||
// product relation - never as a 40th+ list entry.
|
||||
$qb->expr()->eq('is_group', 0)
|
||||
)
|
||||
->orderBy('title', 'ASC')
|
||||
->executeQuery()
|
||||
|
||||
@@ -23,8 +23,8 @@ return [
|
||||
],
|
||||
],
|
||||
'types' => [
|
||||
'1' => ['showitem' => 'title, subtitle, slug, teaser, description, description2, applications, capabilities, highlights, contentelement, contentelementcta, downloads, portfolio, showdatapath, textrelatedproducts, relatedprodukt, solutions,
|
||||
--div--;Resources, resources,
|
||||
'1' => ['showitem' => 'title, subtitle, slug, teaser, description, description2, applications, capabilities, highlights, contentelement, contentelementcta, portfolio, showdatapath, textrelatedproducts, relatedprodukt, solutions,
|
||||
--div--;Resources, resources, datasheets, downloads,
|
||||
--div--;SEO, seotitle, urltitle, seometa, keywords, structureddata,
|
||||
--div--;Images and Videos, heroimage, productimage, ogimage, video, videofile,
|
||||
--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:categories, categories,
|
||||
@@ -174,7 +174,8 @@ return [
|
||||
],
|
||||
'downloads' => [
|
||||
'exclude' => true,
|
||||
'label' => 'Downloads',
|
||||
'label' => 'All Downloads (downloads section)',
|
||||
'description' => 'Every file related to this product - feeds the full downloads section of the detail page. Independent of the datasheet CARDS above: a datasheet may appear in both.',
|
||||
'config' => [
|
||||
'type' => 'select',
|
||||
'renderType' => 'selectMultipleSideBySide',
|
||||
@@ -463,6 +464,21 @@ return [
|
||||
],
|
||||
],
|
||||
],
|
||||
'datasheets' => [
|
||||
'exclude' => true,
|
||||
'label' => 'Datasheets',
|
||||
'description' => 'Curated download records shown as datasheet CARDS in the resources row (selection + order = card order, counts towards the carousel rule). Independent of "All Downloads" below, which feeds the full downloads section.',
|
||||
'config' => [
|
||||
'type' => 'select',
|
||||
'renderType' => 'selectMultipleSideBySide',
|
||||
'foreign_table' => 'tx_vitec_domain_model_download',
|
||||
'MM' => 'tx_vitec_product_datasheet_mm',
|
||||
'size' => 8,
|
||||
'maxitems' => 9999,
|
||||
'minitems' => 0,
|
||||
'enableMultiSelectFilterTextfield' => true,
|
||||
],
|
||||
],
|
||||
'productimage' => [
|
||||
'exclude' => true,
|
||||
'label' => 'Product Image(s)',
|
||||
|
||||
@@ -24,7 +24,7 @@ return [
|
||||
],
|
||||
'types' => [
|
||||
'1' => [
|
||||
'showitem' => 'title, slug, detail_page, subtitle, teaser, description, image,
|
||||
'showitem' => 'title, slug, detail_page, is_group, subtitle, teaser, description, image,
|
||||
--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:categories, categories,
|
||||
--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:language, sys_language_uid, l10n_parent, l10n_diffsource,
|
||||
--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:access, hidden, starttime, endtime',
|
||||
@@ -140,6 +140,16 @@ return [
|
||||
'maxitems' => 1,
|
||||
],
|
||||
],
|
||||
'is_group' => [
|
||||
'exclude' => true,
|
||||
'label' => 'Is Group',
|
||||
'description' => 'Group-level record (one of the 8 solution groups, hybrid decision 2026-09-24). Kept out of solution lists and vitec:align-solutions; selectable in the product "Related Solutions" field.',
|
||||
'config' => [
|
||||
'type' => 'check',
|
||||
'renderType' => 'checkboxToggle',
|
||||
'default' => 0,
|
||||
],
|
||||
],
|
||||
'subtitle' => [
|
||||
'exclude' => true,
|
||||
'label' => 'Subtitle',
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
/**
|
||||
* Resource cards on the product's Resources tab (decision 2026-09-24):
|
||||
* documentation / support / datasheet entries, several per type, each an
|
||||
* editable card (title, text, icon) around one link. Child table of
|
||||
* documentation / support entries, several per type, each an editable card
|
||||
* (title, text, icon) around one link. Datasheets are NOT an option here -
|
||||
* they are the `datasheets` MM selection of download records next to this
|
||||
* list and join the same JSON envelope as ready-made cards. Child table of
|
||||
* tx_vitec_domain_model_product via the `resources` inline field - edited
|
||||
* only there (hideTable), no Extbase model on purpose: the headless
|
||||
* renderers query the rows directly, the Extbase path never touches them.
|
||||
@@ -59,7 +61,6 @@ return [
|
||||
'items' => [
|
||||
['label' => 'Product Documentation', 'value' => 'documentation'],
|
||||
['label' => 'Technical Support', 'value' => 'support'],
|
||||
['label' => 'Datasheet', 'value' => 'datasheet'],
|
||||
],
|
||||
'default' => 'documentation',
|
||||
],
|
||||
@@ -97,7 +98,6 @@ return [
|
||||
'items' => [
|
||||
['label' => 'Document', 'value' => 'document'],
|
||||
['label' => 'Support', 'value' => 'support'],
|
||||
['label' => 'Datasheet', 'value' => 'datasheet'],
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
| 1.15 | 2026‑09‑11 | **Corpus change (additive, index side).** Download documents now carry the extracted file text: Solr 10 has no ExtractingRequestHandler, so a dedicated Apache Tika container on the Solr VPS (Caddy route `/tika/*`, own basic-auth credential) extracts PDF/Office contents during indexing (`TikaDownloadContentIndexer` on `BeforeDocumentIsProcessedForIndexingEvent`, fail-soft per 9.5, cached in `var/tika-cache` keyed on path+size+mtime). Datasheet specifications are now searchable ("625i", "genlock"). No interface change. |
|
||||
| 1.16 | 2026‑09‑11 | **New plugin** `vitec_productfinder` (`ProductFinderJsonRenderer`, payload key `productfinder`): the product category hierarchy on its own, as the data source for the filter selects above the product lists (7.15.3, 8). No change to `vitec_productlist` — filtering happens client side over the products already delivered, keyed on the category uids this payload carries. |
|
||||
| 1.17 | 2026‑09‑11 | **Interface change (additive).** The product list payload gained `successStories` (7.15.2.1): stories an editor attaches to a `sys_category` (tab *Options*, with its own layout), emitted in the list envelope beside `products`. Each card carries `forCategories` — the product categories it came from — so the front end can hide it with the same `?cat=`/`?subcat=` filter it applies to the products. Story links reuse the 7.5 rule, with the detail page taken from the element or the new site setting `vitec.storyDetailPid`. |
|
||||
| 1.18 | 2026‑09‑24 | **Interface change (additive).** Products gained `solutions` — an MM relation to solutions, emitted as solution cards in both product payloads — and a **Resources tab**: child table `tx_vitec_product_resource` with documentation / support / datasheet cards (editable title, text and icon around one link, resolved per 9.12; cards without a resolvable link are not emitted), delivered detail‑only as the `resources` list envelope whose `layout` switches to `carousel` above three cards (7.15). Editorial: the footer had been stuck at v1.15 since 1.16/1.17 (2026‑09‑11). |
|
||||
| 1.18 | 2026‑09‑24 | **Interface change (additive).** Products gained `solutions` — an MM relation to solutions, emitted as solution cards in both product payloads — and a **Resources tab**: child table `tx_vitec_product_resource` with documentation / support cards (editable title, text and icon around one link, resolved per 9.12; cards without a resolvable link are not emitted) plus a `datasheets` MM selection of download records rendered into the same envelope as `type: datasheet` cards, delivered detail‑only as the `resources` list envelope whose `layout` switches to `carousel` above three cards in total (7.15). The `solutions` relation accepts group‑level records: the eight groups exist as `is_group = 1` solutions (created by `vitec:create-solution-groups`, excluded from lists and from `vitec:align-solutions`) because the agency workbooks reference the group level. Editorial: the footer had been stuck at v1.15 since 1.16/1.17 (2026‑09‑11). |
|
||||
|
||||
This document is drafted in the style of, and adopts the terminology conventions of,
|
||||
ISO/IEC/IEEE 42010 (architecture description), ISO/IEC/IEEE 26514 (information for
|
||||
@@ -801,6 +801,12 @@ practice. Image resolution is delegated to `UsecaseSerializer::image()` per 9.2;
|
||||
renderer duplicates no FAL logic.
|
||||
|
||||
#### 7.13.3 Solution list payload (`vitec_solutionlist`)
|
||||
|
||||
> **NOTE (since v1.18)** Group‑level solution records (`is_group = 1`, the eight
|
||||
> thematic groups) never appear in this list. They exist to be selected in the
|
||||
> product `solutions` relation (7.15) and carry the group page as `detailUrl`;
|
||||
> `vitec:align-solutions` ignores them, `vitec:create-solution-groups` creates
|
||||
> them idempotently.
|
||||
Added in v1.9, and deliberately the same shape as 7.13.2 — `SolutionListJsonRenderer` emits
|
||||
under `content.solutions`:
|
||||
|
||||
@@ -898,8 +904,8 @@ Produced by `ProductShowJsonRenderer::serializeProduct()`. Members:
|
||||
| `downloads` | array | download records incl. the resolved `file` |
|
||||
| `ogimage`, `videofile` | object \| null | FAL singletons |
|
||||
| `relatedprodukt` | array | card shape `{uid, title, slug, subtitle, teaser, description, link, images}`, backend order |
|
||||
| `solutions` | array | solution card shape `{uid, title, slug, subtitle, teaser, description, detailUrl, image}` via `tx_vitec_product_solution_mm`, backend order |
|
||||
| `resources` | object | list envelope `{layout, items[]}` of hand‑written resource cards: `type` (`documentation\|support\|datasheet`), `title`, `text`, `icon`, resolved `url` (9.12); a card without a resolvable link is dropped; `layout` is `carousel` above three cards — **detail payload only** |
|
||||
| `solutions` | array | solution card shape `{uid, title, slug, subtitle, teaser, description, detailUrl, isGroup, image}` via `tx_vitec_product_solution_mm`, backend order; an editor may select individual Generic Solutions or the eight group‑level records (`isGroup: true`, linking to the group page) |
|
||||
| `resources` | object | list envelope `{layout, items[]}`: hand‑written cards (`type` `documentation\|support`, `title`, `text`, `icon`, resolved `url` per 9.12; a card without a resolvable link is dropped) followed by the `datasheets` MM selection of download records as `type: datasheet` cards (title/teaser/icon from the record, `url` = the public download route); `layout` is `carousel` above three cards in total — **detail payload only** |
|
||||
| `jsonLd` | string | ready‑to‑emit schema.org `@graph` (6.6), detail only |
|
||||
|
||||
#### 7.15.2 List payload (`vitec_productlist`, key `products`)
|
||||
|
||||
@@ -60,6 +60,7 @@ CREATE TABLE tx_vitec_domain_model_product (
|
||||
textrelatedproducts text,
|
||||
solutions int(11) DEFAULT '0' NOT NULL,
|
||||
resources int(11) DEFAULT '0' NOT NULL,
|
||||
datasheets int(11) DEFAULT '0' NOT NULL,
|
||||
KEY parent (pid)
|
||||
);
|
||||
CREATE TABLE tx_vitec_domain_model_download (
|
||||
@@ -106,6 +107,13 @@ CREATE TABLE tx_vitec_product_solution_mm (
|
||||
PRIMARY KEY (uid_local, uid_foreign)
|
||||
);
|
||||
|
||||
CREATE TABLE tx_vitec_product_datasheet_mm (
|
||||
uid_local INT(11) NOT NULL,
|
||||
uid_foreign INT(11) NOT NULL,
|
||||
sorting INT(11) DEFAULT '0' NOT NULL,
|
||||
PRIMARY KEY (uid_local, uid_foreign)
|
||||
);
|
||||
|
||||
CREATE TABLE tx_vitec_product_resource (
|
||||
uid int(11) NOT NULL auto_increment,
|
||||
pid int(11) DEFAULT '0' NOT NULL,
|
||||
@@ -190,6 +198,7 @@ CREATE TABLE tx_vitec_domain_model_solution (
|
||||
title varchar(255) DEFAULT '' NOT NULL,
|
||||
slug varchar(255) DEFAULT '' NOT NULL,
|
||||
detail_page int(11) DEFAULT '0' NOT NULL,
|
||||
is_group tinyint(4) DEFAULT '0' NOT NULL,
|
||||
subtitle varchar(255) DEFAULT '' NOT NULL,
|
||||
teaser varchar(255) DEFAULT '' NOT NULL,
|
||||
description text,
|
||||
|
||||
+27
-27
@@ -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.
|
||||
<Files "index.html">
|
||||
Header set Cache-Control "no-store, no-cache, must-revalidate"
|
||||
Header set Pragma "no-cache"
|
||||
Header set Expires "0"
|
||||
</Files>
|
||||
|
||||
# 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.
|
||||
<FilesMatch "\.(js|css|woff2?|ttf|eot|otf)$">
|
||||
Header set Cache-Control "public, max-age=31536000, immutable"
|
||||
</FilesMatch>
|
||||
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.
|
||||
<Files "index.html">
|
||||
Header set Cache-Control "no-store, no-cache, must-revalidate"
|
||||
Header set Pragma "no-cache"
|
||||
Header set Expires "0"
|
||||
</Files>
|
||||
|
||||
# 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.
|
||||
<FilesMatch "\.(js|css|woff2?|ttf|eot|otf)$">
|
||||
Header set Cache-Control "public, max-age=31536000, immutable"
|
||||
</FilesMatch>
|
||||
@@ -22,11 +22,9 @@
|
||||
<meta name="twitter:title" content="VITEC" />
|
||||
<meta name="twitter:description" content="Enterprise IPTV, video streaming, and digital signage solutions." />
|
||||
<link rel="icon" type="image/svg+xml" href="../fileadmin/icons/favicon.ico" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<title>VITEC</title>
|
||||
<script type="module" crossorigin src="/_frontend/assets/index-BhQWo9uW.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/_frontend/assets/index-CQJ1iyvA.css">
|
||||
<script type="module" crossorigin src="/_frontend/assets/index-B1-7ERRQ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/_frontend/assets/index-D5j0LX22.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="page"></div>␍
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
Reference in new issue
Block a user