Products run: missing records, asset re-run, audit page, category pages
- new vitec:create-missing-products: MGW Diamond TOUGH, MGW Pico TOUGH, MGW Pico+ TOUGH and X-View Integrated Displays as visible records (teaser drafts, pid and sys_category assignments copied from the sibling product; titles equal the Dropbox folder names so the image importer matches immediately). IQS4 stays out - its delivery folder is empty, question to Joe - asset re-run via vitec:import-product-images after re-staging the Dropbox delivery (the misfiled Diamond-OG banner corrected into the OG inbox folder again): new photos for 95-Series, Avedia chassis, SFF PRISM and the new TOUGH/X-View records - new migrations/build_product_audit.php: generates the internal product-asset audit at /product-audit/ (same basic auth, same-origin thumbnails) - one row per record with thumbnail, detail link, badges for hero/photos/texts/resources/datasheets/solutions, flags, stats and client-side filters; re-run any time - new vitec:import-product-category-pages: builds the nine /products/<category> pages (frontend layout 9, hero/main/preFooter) from the agency "Product Category Pages" workbooks - intro paragraphs, Why-VITEC / product / solution cards, the comparison rows as ONE text element with a plain table (no comparison CType exists; FE styles it), prefooter banner to /contact, full page properties, page sorting per sitemap 3.1-3.9; the empty shells #455 and #457 are reused. Product card links resolve record-first (exact title, stripped parenthesis, family alias, unique slug prefix); ProductFamilyAliases gains ten category-level family labels. Solution cards tolerate US/UK spelling (center/centre, visualization/ visualisation). Applied and verified live (nine routes, links, tables, meta) - known gaps reported, queued for Joe: IQS4, Media Players (93/94), Chassis Systems, Express Backplanes, Accessories & Cables, PCIe Encoders, VMS Plugins & Integrations; the Vision family card awaits a flagship decision Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8497dad06a
commit
e2d4367d7a
5 files changed
+877
No files matched your search
@@ -0,0 +1,125 @@
|
||||
<?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;
|
||||
|
||||
/**
|
||||
* Creates the four product records the agency delivery references but the
|
||||
* database never had (decision Olli 2026-10-27; they were a sitemap-tracker
|
||||
* question before, their asset folders arrived with the Dropbox delivery):
|
||||
*
|
||||
* MGW Diamond TOUGH, MGW Pico TOUGH, MGW Pico+ TOUGH,
|
||||
* X-View Integrated Displays
|
||||
*
|
||||
* Each new record copies pid and sys_category assignments from a named
|
||||
* SIBLING product (so catalog panel and product finder place it correctly)
|
||||
* and is created VISIBLE (dev decision Olli 2026-10-27) with a short
|
||||
* placeholder teaser (Claude draft, English) - replace it once the agency
|
||||
* texts arrive. Titles equal
|
||||
* the Dropbox folder names, so a vitec:import-product-images re-run attaches
|
||||
* the delivered banner/carousel assets immediately.
|
||||
*
|
||||
* IQS4 stays out on purpose: its delivery folder is empty - question to Joe.
|
||||
*
|
||||
* vendor/bin/typo3 vitec:create-missing-products
|
||||
* vendor/bin/typo3 vitec:create-missing-products --apply
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'vitec:create-missing-products',
|
||||
description: 'Create the missing TOUGH / X-View product records (dry-run by default)'
|
||||
)]
|
||||
final class CreateMissingProductsCommand extends Command
|
||||
{
|
||||
private const TABLE = 'tx_vitec_domain_model_product';
|
||||
|
||||
/** title (= Dropbox folder name), slug, sibling slug (pid + categories donor), teaser draft */
|
||||
private const PRODUCTS = [
|
||||
['MGW Diamond TOUGH', 'mgw-diamond-tough', 'mgw-diamond-sdi-encoder',
|
||||
'Rugged four-channel HEVC/H.264 encoder built for harsh and mobile operational environments.'],
|
||||
['MGW Pico TOUGH', 'mgw-pico-tough', 'mgw-pico-portable-encoder',
|
||||
'Rugged portable H.264 encoder for low-latency video contribution in the field.'],
|
||||
['MGW Pico+ TOUGH', 'mgw-pico-plus-tough', 'mgw-pico-portable-encoder',
|
||||
'Rugged portable HEVC/H.264 encoder for demanding field deployments.'],
|
||||
['X-View Integrated Displays', 'x-view-integrated-displays', 'x-player-end-points-xp23-xp25-xp26',
|
||||
'Commercial displays with an integrated IPTV end-point for TV and signage delivery.'],
|
||||
];
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('apply', null, InputOption::VALUE_NONE, 'Write changes (default: dry run)');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
Bootstrap::initializeBackendAuthentication();
|
||||
$apply = (bool)$input->getOption('apply');
|
||||
$pool = GeneralUtility::makeInstance(ConnectionPool::class);
|
||||
$conn = $pool->getConnectionForTable(self::TABLE);
|
||||
$output->writeln(($apply ? 'APPLY' : 'DRY-RUN'));
|
||||
|
||||
$datamap = [];
|
||||
$index = 0;
|
||||
foreach (self::PRODUCTS as [$title, $slug, $siblingSlug, $teaser]) {
|
||||
$existing = $conn->fetchAssociative(
|
||||
'SELECT uid FROM ' . self::TABLE . ' WHERE deleted = 0 AND (slug = ? OR title = ?)', [$slug, $title]
|
||||
);
|
||||
if ($existing) {
|
||||
$output->writeln(sprintf(' = "%s" exists (#%d) - skipped', $title, (int)$existing['uid']));
|
||||
continue;
|
||||
}
|
||||
$sibling = $conn->fetchAssociative(
|
||||
'SELECT uid, pid FROM ' . self::TABLE . ' WHERE deleted = 0 AND slug = ?', [$siblingSlug]
|
||||
);
|
||||
if (!$sibling) {
|
||||
$output->writeln(sprintf(' <error>! sibling %s not found - "%s" skipped</error>', $siblingSlug, $title));
|
||||
continue;
|
||||
}
|
||||
$categories = $pool->getConnectionForTable('sys_category_record_mm')->fetchFirstColumn(
|
||||
"SELECT uid_local FROM sys_category_record_mm
|
||||
WHERE tablenames = ? AND fieldname = 'categories' AND uid_foreign = ? ORDER BY sorting_foreign",
|
||||
[self::TABLE, (int)$sibling['uid']]
|
||||
);
|
||||
$datamap[self::TABLE]['NEWPROD' . (++$index)] = [
|
||||
'pid' => (int)$sibling['pid'],
|
||||
'title' => $title,
|
||||
'slug' => $slug,
|
||||
'teaser' => $teaser,
|
||||
'categories' => implode(',', array_map('intval', $categories)),
|
||||
];
|
||||
$output->writeln(sprintf(' + "%s" (slug %s, teaser draft) - pid %d + %d categorie(s) from sibling #%d',
|
||||
$title, $slug, (int)$sibling['pid'], count($categories), (int)$sibling['uid']));
|
||||
}
|
||||
|
||||
if (!$apply) {
|
||||
$output->writeln("\nDRY RUN - nothing written. Add --apply, then re-run vitec:import-product-images.");
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
if ($datamap === []) {
|
||||
$output->writeln('Nothing to create.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||
$dataHandler->start($datamap, []);
|
||||
$dataHandler->process_datamap();
|
||||
if ($dataHandler->errorLog !== []) {
|
||||
$output->writeln('<error>DataHandler: ' . implode(' | ', $dataHandler->errorLog) . '</error>');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
foreach ($dataHandler->substNEWwithIDs as $newUid) {
|
||||
$output->writeln(' created #' . (int)$newUid);
|
||||
}
|
||||
$output->writeln('<info>Done. Now run vitec:import-product-images (dry-run, then --apply) to attach the assets; replace the teaser drafts once the agency texts arrive.</info>');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Evomedien\Vitec\Command;
|
||||
|
||||
use Evomedien\Vitec\Import\ProductFamilyAliases;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Builds the nine product CATEGORY pages under /products from the agency
|
||||
* workbooks "Product Category Pages/*.xlsx" (Nextcloud Material/Products,
|
||||
* delivery 2026-10; staged in migrations/product_categories_xlsx_in/).
|
||||
*
|
||||
* Workbook shape (sheet 1): a meta block (URL, Primary keyword, ...), then
|
||||
* component rows "Order | Component | Heading | Body | CTA/Card copy | Note".
|
||||
* Mapping onto existing CTypes, zones per frontend layout 9 "Product Main
|
||||
* Category" (hero = colPos 1, main = 0, preFooter = 3):
|
||||
*
|
||||
* Hero - H1 -> vitec_herosection (CTA label, anchor is FE)
|
||||
* Category Introduction,
|
||||
* Use Case, Why VITEC,
|
||||
* Product Finder intro,
|
||||
* Product Range intro,
|
||||
* Related Solutions intro -> vitec_introparagraph
|
||||
* Why VITEC Card -> vitec_card (text_only)
|
||||
* Comparison Table rows -> ONE core `text` element with a plain
|
||||
* <table> (product / type / description) -
|
||||
* no comparison CType exists; FE styles it
|
||||
* Product Card -> vitec_card, link resolved record-first
|
||||
* (exact title, stripped parenthesis, family
|
||||
* alias, unique slug prefix) -> /product/<slug>
|
||||
* Solution Card -> vitec_card, link to the solution group page
|
||||
* (title norm, visualization/visualisation
|
||||
* tolerated)
|
||||
* Pre-footer CTA -> vitec_ctabanner (centered/orange, primary
|
||||
* -> /contact)
|
||||
*
|
||||
* Pages are created VISIBLE (dev decision) with slug from the workbook URL
|
||||
* row; /products/video-capture (#457, empty shell) is reused. Page
|
||||
* properties: seo/og/twitter title + description (hero copy), canonical,
|
||||
* abstract (category intro), keywords (primary keyword). Content-idempotent
|
||||
* via the slot/sequence pattern of the sibling importers.
|
||||
*
|
||||
* vendor/bin/typo3 vitec:import-product-category-pages
|
||||
* vendor/bin/typo3 vitec:import-product-category-pages --only=capture --apply
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'vitec:import-product-category-pages',
|
||||
description: 'Build the /products category pages from the agency workbooks (dry-run by default)'
|
||||
)]
|
||||
final class ImportProductCategoryPagesCommand extends Command
|
||||
{
|
||||
private const LAYOUT = 9;
|
||||
private const COLPOS_HERO = 1;
|
||||
private const COLPOS_MAIN = 0;
|
||||
private const COLPOS_PREFOOTER = 3;
|
||||
|
||||
/** sitemap order 3.1-3.9 -> page sorting under /products */
|
||||
private const CATEGORY_ORDER = [
|
||||
'iptv-and-digital-signage', 'video-encoding-and-streaming', 'gateways-and-transcoding',
|
||||
'control-room-platforms', 'av-over-ip-and-kvm', 'video-wall-and-display-processing',
|
||||
'graphics-and-multi-display', 'video-capture', 'infrastructure-and-accessories',
|
||||
];
|
||||
|
||||
/** @var array<string,array<string|int,array<string,mixed>>> */
|
||||
private array $datamap = [];
|
||||
/** @var array<int,string[]> colPos => ordered ids (per page run) */
|
||||
private array $sequence = [];
|
||||
/** @var array<string,int[]> */
|
||||
private array $slotCursor = [];
|
||||
private array $report = [];
|
||||
private int $newCounter = 0;
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('dir', null, InputOption::VALUE_REQUIRED, 'Workbook directory', 'migrations/product_categories_xlsx_in');
|
||||
$this->addOption('only', null, InputOption::VALUE_REQUIRED, 'Filename substring filter', '');
|
||||
$this->addOption('apply', null, InputOption::VALUE_NONE, 'Write changes (default: dry run)');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
Bootstrap::initializeBackendAuthentication();
|
||||
$apply = (bool)$input->getOption('apply');
|
||||
$only = mb_strtolower((string)$input->getOption('only'));
|
||||
$dir = rtrim((string)$input->getOption('dir'), '/');
|
||||
$pool = GeneralUtility::makeInstance(ConnectionPool::class);
|
||||
$pages = $pool->getConnectionForTable('pages');
|
||||
|
||||
$productsRoot = $pages->fetchAssociative("SELECT uid FROM pages WHERE deleted = 0 AND slug = '/products'");
|
||||
if (!$productsRoot) {
|
||||
$output->writeln('<error>/products not found.</error>');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$rootUid = (int)$productsRoot['uid'];
|
||||
$files = glob($dir . '/*.xlsx') ?: [];
|
||||
$files = array_values(array_filter($files, static fn(string $f): bool => !str_starts_with(basename($f), '~$')
|
||||
&& ($only === '' || str_contains(mb_strtolower(basename($f)), $only))));
|
||||
$output->writeln(($apply ? 'APPLY' : 'DRY-RUN') . ' · /products #' . $rootUid . ' · ' . count($files) . ' workbook(s)');
|
||||
|
||||
$pageUidBySlugTail = [];
|
||||
foreach ($files as $file) {
|
||||
try {
|
||||
$result = $this->importOne($file, $rootUid, $apply, $output);
|
||||
if ($result !== null) {
|
||||
$pageUidBySlugTail[$result['slugTail']] = $result['pageUid'];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$output->writeln('<error> ' . basename($file) . ': ' . $e->getMessage() . '</error>');
|
||||
}
|
||||
}
|
||||
|
||||
if ($apply && $pageUidBySlugTail !== []) {
|
||||
$sorting = 0;
|
||||
foreach (self::CATEGORY_ORDER as $tail) {
|
||||
$sorting += 256;
|
||||
if (isset($pageUidBySlugTail[$tail])) {
|
||||
$pages->update('pages', ['sorting' => $sorting], ['uid' => $pageUidBySlugTail[$tail]]);
|
||||
}
|
||||
}
|
||||
$output->writeln('<info>Done. Flush the frontend cache.</info>');
|
||||
} elseif (!$apply) {
|
||||
$output->writeln("\nDRY RUN - nothing written. Add --apply to write.");
|
||||
}
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/** @return array{slugTail:string,pageUid:int}|null */
|
||||
private function importOne(string $file, int $rootUid, bool $apply, OutputInterface $output): ?array
|
||||
{
|
||||
$this->datamap = [];
|
||||
$this->sequence = [];
|
||||
$this->slotCursor = [];
|
||||
$this->report = [];
|
||||
$pool = GeneralUtility::makeInstance(ConnectionPool::class);
|
||||
$pages = $pool->getConnectionForTable('pages');
|
||||
|
||||
[$meta, $components] = $this->readSheet($file);
|
||||
$url = rtrim((string)($meta['url'] ?? ''), '/ ');
|
||||
if ($url === '' || !str_starts_with($url, '/products/')) {
|
||||
$output->writeln('<comment> ' . basename($file) . ': no /products/... URL row - skipped</comment>');
|
||||
return null;
|
||||
}
|
||||
$slugTail = substr($url, strlen('/products/'));
|
||||
$hero = $this->firstComponent($components, 'hero');
|
||||
$title = trim((string)($hero['heading'] ?? '')) !== '' ? trim((string)$hero['heading']) : $slugTail;
|
||||
|
||||
// --- page -----------------------------------------------------------------
|
||||
$existing = $pages->fetchAssociative('SELECT uid, title, layout FROM pages WHERE deleted = 0 AND slug = ? AND sys_language_uid IN (0,-1)', [$url]);
|
||||
if ($existing) {
|
||||
$pageId = (string)(int)$existing['uid'];
|
||||
if ((int)$existing['layout'] !== self::LAYOUT) {
|
||||
$this->datamap['pages'][$pageId]['layout'] = self::LAYOUT;
|
||||
$this->report[] = 'layout -> ' . self::LAYOUT . ' (was ' . (int)$existing['layout'] . ')';
|
||||
}
|
||||
} else {
|
||||
$pageId = $this->newId();
|
||||
$this->datamap['pages'][$pageId] = [
|
||||
'pid' => $rootUid, 'doktype' => 1, 'hidden' => 0,
|
||||
'title' => $title, 'slug' => $url, 'layout' => self::LAYOUT,
|
||||
];
|
||||
$this->report[] = 'page CREATED (visible)';
|
||||
}
|
||||
$pageUid = ctype_digit($pageId) ? (int)$pageId : 0;
|
||||
|
||||
// --- hero -------------------------------------------------------------------
|
||||
if ($hero !== null) {
|
||||
$id = $this->slotNext($pageUid, self::COLPOS_HERO, 'vitec_herosection', $pageId);
|
||||
$this->setFields($id, [
|
||||
'header' => (string)$hero['heading'], 'header_layout' => 1,
|
||||
'bodytext' => $this->p((string)$hero['body']),
|
||||
'vitec_cta_label' => (string)$hero['cta'],
|
||||
]);
|
||||
}
|
||||
|
||||
// --- main column, in workbook order ------------------------------------------
|
||||
$comparisonRows = [];
|
||||
foreach ($components as $component) {
|
||||
$kind = mb_strtolower($component['component']);
|
||||
if (str_starts_with($kind, 'hero')) {
|
||||
continue;
|
||||
}
|
||||
if (str_contains($kind, 'comparison table')) {
|
||||
$comparisonRows[] = $component;
|
||||
continue;
|
||||
}
|
||||
if (str_contains($kind, 'why vitec card')) {
|
||||
$id = $this->slotNext($pageUid, self::COLPOS_MAIN, 'vitec_card', $pageId);
|
||||
$this->setFields($id, [
|
||||
'vitec_layout_variant' => 'text_only',
|
||||
'header' => (string)$component['heading'],
|
||||
'bodytext' => $this->p((string)$component['cta']),
|
||||
'vitec_primary_cta_label' => '',
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
if (str_starts_with($kind, 'product card')) {
|
||||
$this->flushComparison($comparisonRows, $pageUid, $pageId);
|
||||
$cardTitle = (string)$component['heading'];
|
||||
$fields = [
|
||||
'vitec_layout_variant' => 'text_only',
|
||||
'header' => $cardTitle,
|
||||
'bodytext' => $this->p($this->stripCtaSuffix((string)$component['cta'])),
|
||||
'vitec_primary_cta_label' => 'Learn more',
|
||||
];
|
||||
$product = $this->resolveProduct($cardTitle);
|
||||
if ($product !== null) {
|
||||
$fields['vitec_primary_cta'] = '/product/' . $product['slug'];
|
||||
$this->report[] = 'product card "' . $cardTitle . '" -> /product/' . $product['slug'];
|
||||
} else {
|
||||
$this->report[] = 'product card "' . $cardTitle . '": NO record resolved - link left empty';
|
||||
}
|
||||
$id = $this->slotNext($pageUid, self::COLPOS_MAIN, 'vitec_card', $pageId);
|
||||
$this->setFields($id, $fields);
|
||||
continue;
|
||||
}
|
||||
if (str_starts_with($kind, 'solution card')) {
|
||||
$cardTitle = (string)$component['heading'];
|
||||
$fields = [
|
||||
'vitec_layout_variant' => 'text_only',
|
||||
'header' => $cardTitle,
|
||||
'bodytext' => $this->p($this->stripCtaSuffix((string)$component['cta'])),
|
||||
'vitec_primary_cta_label' => 'Learn more',
|
||||
];
|
||||
$solutionUid = $this->resolveSolutionPage($cardTitle);
|
||||
if ($solutionUid > 0) {
|
||||
$fields['vitec_primary_cta'] = 't3://page?uid=' . $solutionUid;
|
||||
$this->report[] = 'solution card "' . $cardTitle . '" -> page #' . $solutionUid;
|
||||
} else {
|
||||
$this->report[] = 'solution card "' . $cardTitle . '": NO solution page resolved - link left empty';
|
||||
}
|
||||
$id = $this->slotNext($pageUid, self::COLPOS_MAIN, 'vitec_card', $pageId);
|
||||
$this->setFields($id, $fields);
|
||||
continue;
|
||||
}
|
||||
if (str_starts_with($kind, 'pre-footer')) {
|
||||
$this->flushComparison($comparisonRows, $pageUid, $pageId);
|
||||
$id = $this->slotNext($pageUid, self::COLPOS_PREFOOTER, 'vitec_ctabanner', $pageId);
|
||||
$this->setFields($id, [
|
||||
'header' => (string)$component['heading'],
|
||||
'bodytext' => $this->p((string)$component['body']),
|
||||
'vitec_primary_cta_label' => (string)$component['cta'] !== '' ? (string)$component['cta'] : 'Contact us',
|
||||
'vitec_primary_cta' => $this->contactLink(),
|
||||
'vitec_layout_variant' => 'centered',
|
||||
'vitec_background_variant' => 'orange',
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
// every other named section is an intro paragraph in main
|
||||
$this->flushComparison($comparisonRows, $pageUid, $pageId);
|
||||
$id = $this->slotNext($pageUid, self::COLPOS_MAIN, 'vitec_introparagraph', $pageId);
|
||||
$this->setFields($id, [
|
||||
'header' => (string)$component['heading'],
|
||||
'bodytext' => $this->p((string)$component['body']),
|
||||
]);
|
||||
}
|
||||
$this->flushComparison($comparisonRows, $pageUid, $pageId);
|
||||
|
||||
// --- page properties ----------------------------------------------------------
|
||||
$heroBody = trim((string)($hero['body'] ?? ''));
|
||||
$intro = $this->firstComponent($components, 'category introduction');
|
||||
$seoTitle = $title . ' | VITEC';
|
||||
$props = [
|
||||
'seo_title' => $seoTitle, 'og_title' => $seoTitle, 'twitter_title' => $seoTitle,
|
||||
'description' => $heroBody, 'og_description' => $heroBody, 'twitter_description' => $heroBody,
|
||||
'abstract' => mb_substr(trim((string)($intro['body'] ?? $heroBody)), 0, 300),
|
||||
'keywords' => (string)($meta['keyword'] ?? ''),
|
||||
];
|
||||
if ($pageUid > 0) {
|
||||
$props['canonical_link'] = 't3://page?uid=' . $pageUid;
|
||||
}
|
||||
foreach ($props as $field => $value) {
|
||||
$this->datamap['pages'][$pageId][$field] = $value;
|
||||
}
|
||||
$this->report[] = 'page meta: seo/og/twitter + abstract + keywords ("' . ($meta['keyword'] ?? '') . '")';
|
||||
|
||||
$output->writeln(sprintf('%s -> %s (%s)', basename($file), $url, $existing ? '#' . (int)$existing['uid'] : 'NEW'));
|
||||
foreach ($this->report as $line) {
|
||||
$output->writeln(' ' . $line);
|
||||
}
|
||||
if (!$apply) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||
$dataHandler->start($this->datamap, []);
|
||||
$dataHandler->process_datamap();
|
||||
if ($dataHandler->errorLog !== []) {
|
||||
throw new \RuntimeException('DataHandler: ' . implode(' | ', $dataHandler->errorLog));
|
||||
}
|
||||
$resolvedPageUid = $pageUid > 0 ? $pageUid : (int)($dataHandler->substNEWwithIDs[$pageId] ?? 0);
|
||||
// canonical for NEW pages needs the real uid
|
||||
if ($pageUid === 0 && $resolvedPageUid > 0) {
|
||||
GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages')
|
||||
->update('pages', ['canonical_link' => 't3://page?uid=' . $resolvedPageUid], ['uid' => $resolvedPageUid]);
|
||||
}
|
||||
$this->applySequence($dataHandler->substNEWwithIDs, $resolvedPageUid);
|
||||
return ['slugTail' => $slugTail, 'pageUid' => $resolvedPageUid];
|
||||
}
|
||||
|
||||
/** One `text` element holding the comparison rows as a plain table. */
|
||||
private function flushComparison(array &$rows, int $pageUid, string $pageId): void
|
||||
{
|
||||
if ($rows === []) {
|
||||
return;
|
||||
}
|
||||
$body = '<table class="vitec-comparison"><thead><tr><th>Product</th><th>Type</th><th>Description</th></tr></thead><tbody>';
|
||||
foreach ($rows as $row) {
|
||||
$body .= '<tr><td>' . htmlspecialchars((string)$row['heading'], ENT_QUOTES | ENT_HTML5) . '</td>'
|
||||
. '<td>' . htmlspecialchars((string)$row['body'], ENT_QUOTES | ENT_HTML5) . '</td>'
|
||||
. '<td>' . htmlspecialchars((string)$row['cta'], ENT_QUOTES | ENT_HTML5) . '</td></tr>';
|
||||
}
|
||||
$body .= '</tbody></table>';
|
||||
$id = $this->slotNext($pageUid, self::COLPOS_MAIN, 'text', $pageId);
|
||||
$this->setFields($id, ['header' => '', 'bodytext' => $body]);
|
||||
$this->report[] = 'comparison table: ' . count($rows) . ' row(s) as one text element (plain <table>, FE styles it)';
|
||||
$rows = [];
|
||||
}
|
||||
|
||||
// ---- resolution -----------------------------------------------------------------
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
private function resolveProduct(string $cardTitle): ?array
|
||||
{
|
||||
$conn = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tx_vitec_domain_model_product');
|
||||
$rows = $conn->fetchAllAssociative('SELECT uid, title, slug FROM tx_vitec_domain_model_product WHERE deleted = 0');
|
||||
$full = $this->norm($cardTitle);
|
||||
$stripped = $this->norm((string)preg_replace('/\s*\(.*$/', '', $cardTitle));
|
||||
foreach ([$full, $stripped] as $needle) {
|
||||
foreach ($rows as $row) {
|
||||
if ($this->norm((string)$row['title']) === $needle) {
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
}
|
||||
$alias = ProductFamilyAliases::resolve($stripped);
|
||||
if ($alias !== null) {
|
||||
return $alias;
|
||||
}
|
||||
// unique slug prefix (activesqx -> activesqx2)
|
||||
$matches = array_values(array_filter($rows, fn(array $row): bool => str_starts_with(
|
||||
$this->norm((string)$row['slug']), $stripped
|
||||
)));
|
||||
return count($matches) === 1 ? $matches[0] : null;
|
||||
}
|
||||
|
||||
/** Workbook solution names that differ from the page titles beyond spelling. */
|
||||
private const SOLUTION_ALIASES = [
|
||||
// key is post-norm (visualization->visualisation already applied)
|
||||
'videowallandvisualisationsolutions' => 'videowallanddatavisualisation',
|
||||
];
|
||||
|
||||
private function resolveSolutionPage(string $cardTitle): int
|
||||
{
|
||||
$needle = $this->norm($cardTitle);
|
||||
$needle = self::SOLUTION_ALIASES[$needle] ?? $needle;
|
||||
$pages = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages');
|
||||
$solutions = $pages->fetchAssociative("SELECT uid FROM pages WHERE deleted = 0 AND slug = '/solutions'");
|
||||
if (!$solutions) {
|
||||
return 0;
|
||||
}
|
||||
foreach ($pages->fetchAllAssociative(
|
||||
'SELECT uid, title FROM pages WHERE deleted = 0 AND pid = ? AND sys_language_uid IN (0,-1)', [(int)$solutions['uid']]
|
||||
) as $row) {
|
||||
if ($this->norm((string)$row['title']) === $needle) {
|
||||
return (int)$row['uid'];
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function contactLink(): string
|
||||
{
|
||||
$pages = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages');
|
||||
$row = $pages->fetchAssociative("SELECT uid FROM pages WHERE deleted = 0 AND slug = '/contact'");
|
||||
return $row ? 't3://page?uid=' . (int)$row['uid'] : '';
|
||||
}
|
||||
|
||||
// ---- workbook reading --------------------------------------------------------------
|
||||
|
||||
/** @return array{0:array<string,string>,1:array<int,array{component:string,heading:string,body:string,cta:string}>} */
|
||||
private function readSheet(string $file): array
|
||||
{
|
||||
$spreadsheet = IOFactory::load($file);
|
||||
$sheet = $spreadsheet->getSheet(0);
|
||||
$rows = $sheet->toArray(null, false, false, false);
|
||||
$spreadsheet->disconnectWorksheets();
|
||||
$meta = [];
|
||||
$components = [];
|
||||
$inTable = false;
|
||||
foreach ($rows as $row) {
|
||||
$a = trim((string)($row[0] ?? ''));
|
||||
$b = trim((string)($row[1] ?? ''));
|
||||
if (!$inTable) {
|
||||
if (mb_strtolower($a) === 'url') {
|
||||
$meta['url'] = $b;
|
||||
}
|
||||
if (mb_strtolower($a) === 'primary keyword') {
|
||||
$meta['keyword'] = $b;
|
||||
}
|
||||
if (mb_strtolower($a) === 'order') {
|
||||
$inTable = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ($b === '') {
|
||||
continue;
|
||||
}
|
||||
$components[] = [
|
||||
'component' => $b,
|
||||
'heading' => trim((string)($row[2] ?? '')),
|
||||
'body' => trim((string)($row[3] ?? '')),
|
||||
'cta' => trim((string)($row[4] ?? '')),
|
||||
];
|
||||
}
|
||||
return [$meta, $components];
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,string>> $components */
|
||||
private function firstComponent(array $components, string $needle): ?array
|
||||
{
|
||||
foreach ($components as $component) {
|
||||
if (str_contains(mb_strtolower($component['component']), $needle)) {
|
||||
return $component;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function stripCtaSuffix(string $copy): string
|
||||
{
|
||||
return trim((string)preg_replace('/\s*\|\s*CTA:.*$/i', '', $copy));
|
||||
}
|
||||
|
||||
// ---- element plumbing (pattern of the sibling importers) ------------------------------
|
||||
|
||||
private function slotNext(int $pageUid, int $colPos, string $ctype, string $pageId): string
|
||||
{
|
||||
$key = $colPos . '/' . $ctype;
|
||||
if (!isset($this->slotCursor[$key])) {
|
||||
$this->slotCursor[$key] = $pageUid > 0 ? $this->slotElements($pageUid, $colPos, $ctype) : [];
|
||||
}
|
||||
$uid = array_shift($this->slotCursor[$key]);
|
||||
if ($uid !== null) {
|
||||
$this->sequence[$colPos][] = (string)$uid;
|
||||
return (string)$uid;
|
||||
}
|
||||
$id = $this->newId();
|
||||
$this->datamap['tt_content'][$id] = [
|
||||
'pid' => ctype_digit($pageId) ? (int)$pageId : $pageId,
|
||||
'colPos' => $colPos,
|
||||
'CType' => $ctype,
|
||||
];
|
||||
$this->sequence[$colPos][] = $id;
|
||||
return $id;
|
||||
}
|
||||
|
||||
/** @return int[] */
|
||||
private function slotElements(int $pageUid, int $colPos, string $ctype): array
|
||||
{
|
||||
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tt_content');
|
||||
return array_map('intval', $connection->fetchFirstColumn(
|
||||
'SELECT uid FROM tt_content WHERE deleted = 0 AND pid = ? AND colPos = ? AND CType = ? ORDER BY sorting',
|
||||
[$pageUid, $colPos, $ctype]
|
||||
));
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $fields */
|
||||
private function setFields(string $id, array $fields): void
|
||||
{
|
||||
foreach ($fields as $field => $value) {
|
||||
$this->datamap['tt_content'][$id][$field] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string,int> $substNEWwithIDs */
|
||||
private function applySequence(array $substNEWwithIDs, int $pageUid): void
|
||||
{
|
||||
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tt_content');
|
||||
foreach ($this->sequence as $ids) {
|
||||
$sorting = 0;
|
||||
foreach ($ids as $id) {
|
||||
$uid = str_starts_with($id, 'NEW') ? (int)($substNEWwithIDs[$id] ?? 0) : (int)$id;
|
||||
if ($uid > 0) {
|
||||
$sorting += 256;
|
||||
$connection->update('tt_content', ['sorting' => $sorting, 'pid' => $pageUid], ['uid' => $uid]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function p(string $text): string
|
||||
{
|
||||
$text = trim($text);
|
||||
return $text === '' ? '' : '<p>' . htmlspecialchars($text, ENT_QUOTES | ENT_HTML5) . '</p>';
|
||||
}
|
||||
|
||||
private function norm(string $value): string
|
||||
{
|
||||
$value = mb_strtolower($value);
|
||||
// US/UK tolerance - the workbooks write Center/Visualization, the
|
||||
// page titles Centre/Visualisation
|
||||
$value = str_replace(['visualization', 'center'], ['visualisation', 'centre'], $value);
|
||||
return (string)preg_replace('/[^a-z0-9]+/', '', str_replace(['+', '&'], ['plus', 'and'], $value));
|
||||
}
|
||||
|
||||
private function newId(): string
|
||||
{
|
||||
return 'NEWCAT' . (++$this->newCounter);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user