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 + <p>-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 <noreply@anthropic.com>
This commit is contained in:
orascheandClaude Fable 5 committed 2026-09-24 15:50:16 +02:00
1 parent 6811463343
commit 6961254e57
5 files changed
+661 -9

No files matched your search

+6
View File
@@ -122,3 +122,9 @@ CLAUDE.local.md
# local developer scratch notes # local developer scratch notes
.dev-notes.md .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
+339
View File
@@ -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)"
}
}
}
@@ -42,6 +42,15 @@ final class ImportProductWorkbookCommand extends Command
{ {
private const TABLE = 'tx_vitec_domain_model_product'; 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( public function __construct(
private readonly ProductXlsxReader $reader, private readonly ProductXlsxReader $reader,
private readonly MappingRepository $mappingRepository, 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)); $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'] ?? []); // Defaults as the base, the stored module mapping as per-field
if ($mapping === []) { // override - a field the module never saved (its dropdown was empty
$mapping = ProductTextImportController::DEFAULT_MAPPING; // 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 = []; $data = [];
foreach ($mapping as $field => $selector) { 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); $value = $this->reader->valueFor($parsed, $selector);
if ($value === null) { if ($value === null) {
$output->writeln(sprintf('%-20s %-34s -> not in this file', $field, $selector)); $output->writeln(sprintf('%-20s %-34s -> not in this file', $field, $selector));
continue; continue;
} }
$old = (string)($product[$field] ?? ''); $old = (string)($product[$field] ?? '');
if (trim($old) === trim($value)) { if ($this->isUnchanged($old, $value)) {
$output->writeln(sprintf('%-20s %-34s -> unchanged', $field, $selector)); $output->writeln(sprintf('%-20s %-34s -> unchanged', $field, $selector));
continue; continue;
} }
@@ -134,6 +153,22 @@ final class ImportProductWorkbookCommand extends Command
return Command::SUCCESS; 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 */ /** @return array<string,mixed>|null */
private function loadProduct(string $slugOrUid): ?array 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))), '-');
}
}
@@ -23,8 +23,8 @@ return [
], ],
], ],
'types' => [ 'types' => [
'1' => ['showitem' => 'title, subtitle, slug, teaser, description, description2, applications, capabilities, highlights, contentelement, contentelementcta, downloads, portfolio, showdatapath, textrelatedproducts, relatedprodukt, solutions, '1' => ['showitem' => 'title, subtitle, slug, teaser, description, description2, applications, capabilities, highlights, contentelement, contentelementcta, portfolio, showdatapath, textrelatedproducts, relatedprodukt, solutions,
--div--;Resources, resources, datasheets, --div--;Resources, resources, datasheets, downloads,
--div--;SEO, seotitle, urltitle, seometa, keywords, structureddata, --div--;SEO, seotitle, urltitle, seometa, keywords, structureddata,
--div--;Images and Videos, heroimage, productimage, ogimage, video, videofile, --div--;Images and Videos, heroimage, productimage, ogimage, video, videofile,
--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:categories, categories, --div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:categories, categories,
@@ -174,7 +174,8 @@ return [
], ],
'downloads' => [ 'downloads' => [
'exclude' => true, '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' => [ 'config' => [
'type' => 'select', 'type' => 'select',
'renderType' => 'selectMultipleSideBySide', 'renderType' => 'selectMultipleSideBySide',
@@ -466,7 +467,7 @@ return [
'datasheets' => [ 'datasheets' => [
'exclude' => true, 'exclude' => true,
'label' => 'Datasheets', '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' => [ 'config' => [
'type' => 'select', 'type' => 'select',
'renderType' => 'selectMultipleSideBySide', 'renderType' => 'selectMultipleSideBySide',