diff --git a/.gitignore b/.gitignore
index fa82588..1067c2a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
diff --git a/migrations/products_xlsx/manifest.json b/migrations/products_xlsx/manifest.json
new file mode 100644
index 0000000..5ba97b3
--- /dev/null
+++ b/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)"
+ }
+ }
+}
\ No newline at end of file
diff --git a/packages/vitec/Classes/Command/ImportProductWorkbookCommand.php b/packages/vitec/Classes/Command/ImportProductWorkbookCommand.php
index 32d2e39..115d8f5 100644
--- a/packages/vitec/Classes/Command/ImportProductWorkbookCommand.php
+++ b/packages/vitec/Classes/Command/ImportProductWorkbookCommand.php
@@ -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('
...
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('' . $new . '
'); + } + /** @return array