From 6961254e572a4a066d03d1b2e5c2063a15fa1291 Mon Sep 17 00:00:00 2001 From: Oliver Rasche Date: Thu, 24 Sep 2026 15:50:16 +0200 Subject: [PATCH] Workbook import phase 1: manifest-driven batch command Implements the 18.09. strategy's phase 1 on top of the existing single importer (which stays the one write path): - new vitec:import-product-workbooks: runs vitec:import-product-workbook per manifest entry (in-process sub-command), aggregates a per-file report (writes/unchanged/na, URL-mismatch and error flags, details on -v or failure) plus a summary; without --apply every run is a dry run; --only filters - migrations/products_xlsx/ now holds working copies of all 100 agency workbooks in their category folders (git-ignored) plus the TRACKED manifest.json: workbook path -> product slug, family entries mark the K-level landing pages (never imported), product null = unresolved. Generated from the 18.09. analysis + sitemap column K: 68 product / 17 family / 15 open - --init-manifest resolves open entries against the full product table (hidden records included): URL segment/filename vs slug, normalized title, unique slug prefix; only unambiguous hits are written back - single command compare is now tolerant of the RTE storage transform (whitespace collapse +

-wrap simulation), fixing the known every-run-WRITE false positive on re-imports Post-dry-run hardening, applied the same day (69 products, 785 field writes across two runs, zero errors, verified against the live JSON): - PROTECTED_FIELDS title/urltitle in the single command: the stored module mapping would have overwritten six canonical sitemap-V2 product titles - the stored mapping predated the 18.09. defaults and silently dropped `highlights` on every product; the single command now merges DEFAULT_MAPPING under the stored mapping (defaults as base, stored as per-field override) - known limitation for phase 2: the g: composed fields report WRITE on every run (RTE transform alters ul/strong markup beyond the p-wrap normalisation) - the change watch must compare against stored imported values, not the database Co-Authored-By: Claude Fable 5 --- .gitignore | 6 + migrations/products_xlsx/manifest.json | 339 ++++++++++++++++++ .../Command/ImportProductWorkbookCommand.php | 45 ++- .../Command/ImportProductWorkbooksCommand.php | 271 ++++++++++++++ .../TCA/tx_vitec_domain_model_product.php | 9 +- 5 files changed, 661 insertions(+), 9 deletions(-) create mode 100644 migrations/products_xlsx/manifest.json create mode 100644 packages/vitec/Classes/Command/ImportProductWorkbooksCommand.php 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('URL mismatch: /%s (workbook) vs /%s (record) - check the target!', $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

...

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|null */ private function loadProduct(string $slugOrUid): ?array { diff --git a/packages/vitec/Classes/Command/ImportProductWorkbooksCommand.php b/packages/vitec/Classes/Command/ImportProductWorkbooksCommand.php new file mode 100644 index 0000000..0564a77 --- /dev/null +++ b/packages/vitec/Classes/Command/ImportProductWorkbooksCommand.php @@ -0,0 +1,271 @@ + 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('Manifest not found: ' . $manifestFile . ''); + return Command::FAILURE; + } + $manifest = json_decode((string)file_get_contents($manifestFile), true); + $entries = $manifest['workbooks'] ?? null; + if (!is_array($entries)) { + $output->writeln('Manifest malformed (no workbooks key)'); + 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('vitec:import-product-workbook not available'); + return Command::FAILURE; + } + + $output->writeln(sprintf('%d manifest entries · %s', 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( + '%d workbooks processed · %d field writes%s · %d errors · %d family workbooks skipped · %d unresolved', + $processed, + $totalWrites, + $apply ? '' : ' (dry run - nothing written)', + $errors, + $families, + count($unresolved) + )); + foreach ($unresolved as $relPath) { + $output->writeln(' unresolved: ' . $relPath . ''); + } + if ($apply && $totalWrites > 0) { + $output->writeln('Flush the cache: vendor/bin/typo3 cache:flush'); + } + + 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 $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(' ' . $relPath . ' -> weiterhin ungeklaert'); + } + } + + file_put_contents($manifestFile, json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"); + $output->writeln(sprintf('%d entries resolved, manifest updated.', $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))), '-'); + } +} diff --git a/packages/vitec/Configuration/TCA/tx_vitec_domain_model_product.php b/packages/vitec/Configuration/TCA/tx_vitec_domain_model_product.php index 9a32e89..7853d24 100755 --- a/packages/vitec/Configuration/TCA/tx_vitec_domain_model_product.php +++ b/packages/vitec/Configuration/TCA/tx_vitec_domain_model_product.php @@ -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, datasheets, + '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', @@ -466,7 +467,7 @@ return [ 'datasheets' => [ 'exclude' => true, 'label' => 'Datasheets', - 'description' => 'Download records shown as datasheet cards below the resource cards - same card row, counts towards the carousel rule', + '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',