Compare commits

...
3 Commits
Author SHA1 Message Date
orasche 43519505bc . 2026-09-24 17:13:09 +02:00
orascheandClaude Fable 5 6961254e57 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>
2026-09-24 15:50:16 +02:00
orascheandClaude Fable 5 6811463343 Product texts: mapping completed; product model: solutions + resources
Two work packages, one deploy.

Workbook field mapping completed (2026-09-18)
- highlights <- g:Technical Highlight (new spec-row component of the
  full agency delivery; composeGroup() is generic, no change needed)
- applications <- g:Why Choose Card (benefit cards as one block)
- seometa <- new composed source x:seometa (Introduction body trimmed
  at a word boundary to ~155 chars - the SEO sheet ships no meta
  description, a trimmed intro beats an empty tag)
- teaser stays the hero one-liner, settling the varchar(255) question
- fix: "Save mapping" now merges over the stored mapping instead of
  overwriting it - an empty dropdown no longer wipes a stored selector

Product model: solutions relation and Resources tab (2026-09-24)
- closes the gaps the full workbook walkthrough exposed (rows the
  import deliberately skips because the model had no place for them)
- solutions: MM multi select to tx_vitec_domain_model_solution
  (tx_vitec_product_solution_mm, relatedprodukt pattern), emitted by
  both product renderers as solution cards in the shared shape
- Resources tab: inline child table tx_vitec_product_resource - per
  entry a type (documentation/support), an editable card title/text,
  an icon (free text with a value-picker seed set) and one link
  (page/url/file/record via the link wizard). Deliberately no Extbase
  model: only the UserFunc path reads the rows.
- datasheets: own MM selection of download records below the resource
  list (tx_vitec_product_datasheet_mm); emitted into the SAME
  resources envelope as ready-made type=datasheet cards (title/teaser/
  icon from the record, url = the public /download/<slug> route, uid
  fallback - private/hidden downloads 404 as everywhere)
- JSON `resources` (detail payload only, like heroimage): list envelope
  {layout, items[]}; a hand-written card whose link is empty or
  unresolvable is not emitted; layout switches to carousel above three
  cards in total
- solutions hybrid (same day, after analysing all 100 workbooks: their
  Solution Cards reference the 8 GROUPS in 98% of cases): new is_group
  flag on the solution model, new idempotent CLI
  vitec:create-solution-groups (8 group records; title source is
  AlignSolutionsCommand::TARGET - now public -, category = the group's
  own category, detail_page = the /solutions group page with -N
  disambiguation). Group records are invisible to the solution list
  and to vitec:align-solutions and only selectable in the product
  relation; product solution cards carry isGroup
- spec raised to v1.18 (7.15 member table, 7.13.3 note, revision row;
  the footer had been stuck at v1.15 since 1.16/1.17)

Requires on the server:
    vendor/bin/typo3 database:updateschema "*.add,*.change"
    vendor/bin/typo3 cache:flush

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-24 14:47:04 +02:00
18 changed files with 986 additions and 24 deletions

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)"
}
}
}
@@ -49,7 +49,7 @@ final class AlignSolutionsCommand extends Command
private const TABLE = 'tx_vitec_domain_model_solution'; private const TABLE = 'tx_vitec_domain_model_solution';
/** Binding target: group => solutions, verbatim from the sheet (typo fixed). */ /** Binding target: group => solutions, verbatim from the sheet (typo fixed). */
private const TARGET = [ public const TARGET = [
'Control Room & Command Centre Solutions' => [ 'Control Room & Command Centre Solutions' => [
'Control Room Platforms (SOC / NOC / Command Centres)', 'Control Room Platforms (SOC / NOC / Command Centres)',
'Command & Control Visualisation', 'Command & Control Visualisation',
@@ -170,8 +170,10 @@ final class AlignSolutionsCommand extends Command
} }
$solutions = []; $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( 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) { ) as $row) {
$solutions[(string)$row['title']] = ['uid' => (int)$row['uid'], 'slug' => (string)$row['slug']]; $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'; 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))), '-');
}
}
@@ -723,6 +723,7 @@ class Product extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity
$this->heroimage = new ObjectStorage(); $this->heroimage = new ObjectStorage();
$this->downloads = new ObjectStorage(); $this->downloads = new ObjectStorage();
$this->solutions = 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; $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; $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'] ?? ''), 'teaser' => (string)($solutionRow['teaser'] ?? ''),
'description' => \Evomedien\Vitec\Service\RteResolver::html($solutionRow['description'] ?? ''), 'description' => \Evomedien\Vitec\Service\RteResolver::html($solutionRow['description'] ?? ''),
'detailUrl' => \Evomedien\Vitec\Service\LinkResolver::pageUrl((int)($solutionRow['detail_page'] ?? 0)), '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), 'image' => $serializer->image($solutionUid, 'image', 'tx_vitec_domain_model_solution', true),
]; ];
} }
@@ -771,6 +771,7 @@ class ProductShowJsonRenderer
'teaser' => (string)($solutionRow['teaser'] ?? ''), 'teaser' => (string)($solutionRow['teaser'] ?? ''),
'description' => \Evomedien\Vitec\Service\RteResolver::html($solutionRow['description'] ?? ''), 'description' => \Evomedien\Vitec\Service\RteResolver::html($solutionRow['description'] ?? ''),
'detailUrl' => \Evomedien\Vitec\Service\LinkResolver::pageUrl((int)($solutionRow['detail_page'] ?? 0)), '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), '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 [ return [
'layout' => count($items) > 3 ? 'carousel' : 'grid', 'layout' => count($items) > 3 ? 'carousel' : 'grid',
'items' => $items, 'items' => $items,
@@ -126,7 +126,10 @@ class SolutionListJsonRenderer
->from(self::TABLE) ->from(self::TABLE)
->where( ->where(
$qb->expr()->eq('deleted', 0), $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') ->orderBy('title', 'ASC')
->executeQuery() ->executeQuery()
@@ -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, --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',
@@ -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' => [ 'productimage' => [
'exclude' => true, 'exclude' => true,
'label' => 'Product Image(s)', 'label' => 'Product Image(s)',
@@ -24,7 +24,7 @@ return [
], ],
'types' => [ 'types' => [
'1' => [ '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: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:language, sys_language_uid, l10n_parent, l10n_diffsource,
--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:access, hidden, starttime, endtime', --div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:access, hidden, starttime, endtime',
@@ -140,6 +140,16 @@ return [
'maxitems' => 1, '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' => [ 'subtitle' => [
'exclude' => true, 'exclude' => true,
'label' => 'Subtitle', 'label' => 'Subtitle',
@@ -2,8 +2,10 @@
/** /**
* Resource cards on the product's Resources tab (decision 2026-09-24): * Resource cards on the product's Resources tab (decision 2026-09-24):
* documentation / support / datasheet entries, several per type, each an * documentation / support entries, several per type, each an editable card
* editable card (title, text, icon) around one link. Child table of * (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 * tx_vitec_domain_model_product via the `resources` inline field - edited
* only there (hideTable), no Extbase model on purpose: the headless * only there (hideTable), no Extbase model on purpose: the headless
* renderers query the rows directly, the Extbase path never touches them. * renderers query the rows directly, the Extbase path never touches them.
@@ -59,7 +61,6 @@ return [
'items' => [ 'items' => [
['label' => 'Product Documentation', 'value' => 'documentation'], ['label' => 'Product Documentation', 'value' => 'documentation'],
['label' => 'Technical Support', 'value' => 'support'], ['label' => 'Technical Support', 'value' => 'support'],
['label' => 'Datasheet', 'value' => 'datasheet'],
], ],
'default' => 'documentation', 'default' => 'documentation',
], ],
@@ -97,7 +98,6 @@ return [
'items' => [ 'items' => [
['label' => 'Document', 'value' => 'document'], ['label' => 'Document', 'value' => 'document'],
['label' => 'Support', 'value' => 'support'], ['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.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.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.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, 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 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. renderer duplicates no FAL logic.
#### 7.13.3 Solution list payload (`vitec_solutionlist`) #### 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 Added in v1.9, and deliberately the same shape as 7.13.2 — `SolutionListJsonRenderer` emits
under `content.solutions`: under `content.solutions`:
@@ -898,8 +904,8 @@ Produced by `ProductShowJsonRenderer::serializeProduct()`. Members:
| `downloads` | array | download records incl. the resolved `file` | | `downloads` | array | download records incl. the resolved `file` |
| `ogimage`, `videofile` | object \| null | FAL singletons | | `ogimage`, `videofile` | object \| null | FAL singletons |
| `relatedprodukt` | array | card shape `{uid, title, slug, subtitle, teaser, description, link, images}`, backend order | | `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 | | `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[]}` 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** | | `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 | | `jsonLd` | string | ready‑to‑emit schema.org `@graph` (6.6), detail only |
#### 7.15.2 List payload (`vitec_productlist`, key `products`) #### 7.15.2 List payload (`vitec_productlist`, key `products`)
+9
View File
@@ -60,6 +60,7 @@ CREATE TABLE tx_vitec_domain_model_product (
textrelatedproducts text, textrelatedproducts text,
solutions int(11) DEFAULT '0' NOT NULL, solutions int(11) DEFAULT '0' NOT NULL,
resources int(11) DEFAULT '0' NOT NULL, resources int(11) DEFAULT '0' NOT NULL,
datasheets int(11) DEFAULT '0' NOT NULL,
KEY parent (pid) KEY parent (pid)
); );
CREATE TABLE tx_vitec_domain_model_download ( CREATE TABLE tx_vitec_domain_model_download (
@@ -106,6 +107,13 @@ CREATE TABLE tx_vitec_product_solution_mm (
PRIMARY KEY (uid_local, uid_foreign) 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 ( CREATE TABLE tx_vitec_product_resource (
uid int(11) NOT NULL auto_increment, uid int(11) NOT NULL auto_increment,
pid int(11) DEFAULT '0' NOT NULL, pid int(11) DEFAULT '0' NOT NULL,
@@ -190,6 +198,7 @@ CREATE TABLE tx_vitec_domain_model_solution (
title varchar(255) DEFAULT '' NOT NULL, title varchar(255) DEFAULT '' NOT NULL,
slug varchar(255) DEFAULT '' NOT NULL, slug varchar(255) DEFAULT '' NOT NULL,
detail_page int(11) DEFAULT '0' NOT NULL, detail_page int(11) DEFAULT '0' NOT NULL,
is_group tinyint(4) DEFAULT '0' NOT NULL,
subtitle varchar(255) DEFAULT '' NOT NULL, subtitle varchar(255) DEFAULT '' NOT NULL,
teaser varchar(255) DEFAULT '' NOT NULL, teaser varchar(255) DEFAULT '' NOT NULL,
description text, description text,
+2 -4
View File
@@ -22,11 +22,9 @@
<meta name="twitter:title" content="VITEC" /> <meta name="twitter:title" content="VITEC" />
<meta name="twitter:description" content="Enterprise IPTV, video streaming, and digital signage solutions." /> <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="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> <title>VITEC</title>
<script type="module" crossorigin src="/_frontend/assets/index-BhQWo9uW.js"></script> <script type="module" crossorigin src="/_frontend/assets/index-B1-7ERRQ.js"></script>
<link rel="stylesheet" crossorigin href="/_frontend/assets/index-CQJ1iyvA.css"> <link rel="stylesheet" crossorigin href="/_frontend/assets/index-D5j0LX22.css">
</head> </head>
<body> <body>
<div id="page"></div> <div id="page"></div>
-1
View File
@@ -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