Compare commits
3
Commits
ad1f3c179c
...
2c0da281bd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c0da281bd | ||
|
|
bd7a145afa | ||
|
|
73e4298b75 |
No files matched your search
@@ -43,7 +43,7 @@ return [
|
||||
'1' => [
|
||||
'showitem' => '
|
||||
--div--;' . $ll . 'tab.general,
|
||||
hidden, title, link, layout, tree_display,
|
||||
hidden, title, link, layout, tree_display, source_pages, source_categories,
|
||||
--div--;' . $ll . 'tab.columns,
|
||||
menu_columns,
|
||||
--div--;' . $ll . 'tab.teasers,
|
||||
@@ -131,6 +131,27 @@ return [
|
||||
],
|
||||
],
|
||||
],
|
||||
// data-driven layouts: where the site package's listener reads its
|
||||
// records from - the extension only passes the entry row on
|
||||
'source_pages' => [
|
||||
'displayCond' => 'FIELD:layout:IN:catalog,spotlight',
|
||||
'label' => $ll . 'source_pages',
|
||||
'description' => $ll . 'source_pages.description',
|
||||
'config' => [
|
||||
'type' => 'group',
|
||||
'allowed' => 'pages',
|
||||
'size' => 3,
|
||||
'maxitems' => 20,
|
||||
],
|
||||
],
|
||||
'source_categories' => [
|
||||
'displayCond' => 'FIELD:layout:IN:catalog,spotlight',
|
||||
'label' => $ll . 'source_categories',
|
||||
'description' => $ll . 'source_categories.description',
|
||||
'config' => [
|
||||
'type' => 'category',
|
||||
],
|
||||
],
|
||||
|
||||
'menu_columns' => [
|
||||
'label' => $ll . 'menu_columns',
|
||||
|
||||
@@ -117,6 +117,13 @@ final class ProvideCatalog
|
||||
`getEntry()` hands the listener the raw entry row (to tell several catalog
|
||||
entries apart), `getStage()` the content stage.
|
||||
|
||||
**Data source fields** (catalog and spotlight entries): *Data source
|
||||
folders* (`source_pages`, comma list of page uids) and *Data source
|
||||
categories* (`source_categories`, sys_category MM with
|
||||
`tablenames = tx_evomegamenujson_entry`, `fieldname = source_categories`).
|
||||
The extension only stores them; the listener reads them from the entry row
|
||||
and decides what they mean (empty = its own default).
|
||||
|
||||
### Layout `pagetree`
|
||||
|
||||
A structure panel that can never age against the site: the columns mirror
|
||||
@@ -156,9 +163,13 @@ The VITEC site package ships `ProvideInsightsSpotlight`, which supplies
|
||||
}
|
||||
```
|
||||
|
||||
- `news`: the three latest news records (storage pid 11, type 0 - the Solr
|
||||
convention), `link` = `/news/article/<path_segment>` per the NewsDetail
|
||||
route enhancer, image from `fal_media` as processed 640px webp.
|
||||
- `news`: the three latest news records (type 0 - the Solr convention)
|
||||
from the entry's *Data source folders* (empty = 11 + 442) and, when set,
|
||||
in one of its *Data source categories*; `link` =
|
||||
`/news/article/<path_segment>` per the NewsDetail route enhancer, image
|
||||
from `fal_media` as processed 640px webp. Pages carrying the menu are
|
||||
tagged with the news and event tables, so saving such a record refreshes
|
||||
the menu.
|
||||
- `events`: the next two events (`hideonwebsite` = 0, upcoming by
|
||||
eventstart/eventend), `link` = the record's `eventurl` or the events
|
||||
page, image processed the same way.
|
||||
|
||||
@@ -27,6 +27,10 @@
|
||||
<trans-unit id="tx_evomegamenujson_entry.layout.catalog"><source>Catalog (data-driven: categories and items from the site package, teaser cards as featured tiles)</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_entry.layout.pagetree"><source>Page tree (data-driven: one column per child of the linked page, its children as items; curated columns are ignored)</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_entry.layout.spotlight"><source>Spotlight (data-driven: left-rail nav from the linked page's children plus live visual cards - latest news, next events - supplied by the site package; curated columns are ignored)</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_entry.source_pages"><source>Data source folders</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_entry.source_pages.description"><source>For data-driven panels: the folders the site package reads its records from (e.g. news folders). Empty = the site package's default.</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_entry.source_categories"><source>Data source categories</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_entry.source_categories.description"><source>For data-driven panels: only records in one of these categories. Empty = no category restriction.</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_entry.tree_display"><source>Page tree display</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_entry.tree_display.description"><source>Columns: one column per child page with its children as links. List + cards: the child pages as a list on the left, the children of the hovered one as image cards on the right (images from the page media).</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_entry.tree_display.columns"><source>Columns side by side</source></trans-unit>
|
||||
|
||||
@@ -9,6 +9,8 @@ CREATE TABLE tx_evomegamenujson_entry (
|
||||
link varchar(1024) DEFAULT '' NOT NULL,
|
||||
layout varchar(30) DEFAULT 'columns' NOT NULL,
|
||||
tree_display varchar(30) DEFAULT 'columns' NOT NULL,
|
||||
source_pages text,
|
||||
source_categories int(11) unsigned DEFAULT '0' NOT NULL,
|
||||
menu_columns int(11) DEFAULT '0' NOT NULL,
|
||||
menu_teasers int(11) DEFAULT '0' NOT NULL,
|
||||
featured_enable tinyint(1) unsigned DEFAULT '0' NOT NULL,
|
||||
|
||||
@@ -21,21 +21,38 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
* below /products:
|
||||
*
|
||||
* /products
|
||||
* [folder] category (A, menu label) e.g. "Platforms and End-Points"
|
||||
* [folder] sub-category (B, menu label) e.g. "End-Points"
|
||||
* category page (A, /products/<slug>) e.g. "Platforms and End-Points"
|
||||
* sub-category page (B, /products/<slug>) e.g. "End-Points"
|
||||
* overview page (C, /products/<slug>) e.g. "X-Player End-Points"
|
||||
* [shortcut] menu shortcut e.g. "Video Wall Controllers"
|
||||
*
|
||||
* The page tree is the single source of the products menu (ProvideProduct
|
||||
* Catalog reads it): editors rename, reorder or move overview pages there.
|
||||
* A and B are sysfolders - never routed, never in navigation menus. The 20
|
||||
* overview pages get flat slugs (decision Olli 2026-11-02) and, when they
|
||||
* are new or still empty, generated content: hero (page title, placeholder
|
||||
* All pages get flat slugs (decision Olli 2026-11-02). Overview pages, when
|
||||
* new or still empty, get generated content: hero (page title, placeholder
|
||||
* background - 0026-FE), one intro paragraph per grouping title (or
|
||||
* "Product range"), a product card per sub-product (record teaser, link
|
||||
* /product/<slug>), contact pre-footer. Existing pages whose hero carries
|
||||
* no image get the placeholder backfilled; any existing reference wins.
|
||||
* Afterwards run vitec:wrap-cards-grid per page and vitec:attach-card-images.
|
||||
* Afterwards run vitec:wrap-cards-grid per page.
|
||||
*
|
||||
* A and B were sysfolders (menu labels) until 2026-11-05; Olli: every
|
||||
* category and sub-category must be a clickable page. Decisions 2026-11-05:
|
||||
* - ADOPT: six sub-categories ARE a 2026-10 category page (Joe's category
|
||||
* workbooks; their "Why VITEC" cards match the sub-category icon sets).
|
||||
* The page moves into the folder's place, keeps content, slug and SEO,
|
||||
* gets the sitemap name as nav_title; the folder's children move below
|
||||
* it and the emptied folder is deleted.
|
||||
* - MERGE: "Capture & Graphics" gets the main column of BOTH matching
|
||||
* pages (Graphics & Multi-Display, Video Capture) copied below each other.
|
||||
* - the rest (3 categories, End-Points, RF Gateway) are converted folders
|
||||
* with generated content: hero, "Product range", one card per child page
|
||||
* (title, child list, the child's page media, link), contact pre-footer.
|
||||
* - the 2026-10 pages without a place (Graphics & Multi-Display, Video
|
||||
* Capture, Infrastructure & Accessories) leave the menus (nav_hide) but
|
||||
* stay reachable.
|
||||
* Folders become pages and pages move by direct UPDATE - not through the
|
||||
* DataHandler, whose slug hook (EXT:redirects) would rewrite the flat slugs.
|
||||
*
|
||||
* Decisions without customer confirmation (Olli: proceed, nothing deleted):
|
||||
* - the sitemap's Vision names (UHD3, SD15, HD5, ...) map onto the
|
||||
@@ -152,6 +169,43 @@ final class BuildProductOverviewsCommand extends Command
|
||||
]],
|
||||
];
|
||||
|
||||
/** category (A) and sub-category (B) pages built here: flat slugs like the overview pages */
|
||||
private const SECTION_SLUGS = [
|
||||
'Platforms and End-Points' => 'platforms-and-end-points',
|
||||
'End-Points' => 'end-points',
|
||||
'IP Video Streaming, Distribution & KVM' => 'ip-video-streaming-distribution-and-kvm',
|
||||
'RF Gateway' => 'rf-gateway',
|
||||
'Video Wall Controllers & Management' => 'video-wall-controllers-and-management',
|
||||
'Capture & Graphics' => 'capture-and-graphics',
|
||||
];
|
||||
|
||||
/**
|
||||
* Sub-categories that ARE a 2026-10 category page (Joe's category
|
||||
* workbooks, imported 2026-10-27 - their "Why VITEC" cards match the
|
||||
* agency's sub-category icon sets 1:1). Decision Olli 2026-11-05: the
|
||||
* page moves into the tree in place of the folder and keeps content,
|
||||
* slug and SEO; the sitemap name becomes its nav_title (menu label).
|
||||
*/
|
||||
private const ADOPT = [
|
||||
'IPTV Distribution & Signage Platform' => '/products/iptv-and-digital-signage',
|
||||
'Control Room Platform' => '/products/control-room-platforms',
|
||||
'Encoders & Decoders' => '/products/video-encoding-and-streaming',
|
||||
'IP Gateway & Transcoder' => '/products/gateways-and-transcoding',
|
||||
'AVoIP & KVM' => '/products/av-over-ip-and-kvm',
|
||||
'Video Walls' => '/products/video-wall-and-display-processing',
|
||||
];
|
||||
|
||||
/** sub-category built from the content of two category pages (main column of both, in order) */
|
||||
private const MERGE = [
|
||||
'Capture & Graphics' => ['/products/graphics-and-multi-display', '/products/video-capture'],
|
||||
];
|
||||
|
||||
/** 2026-10 category pages without a place in the tree: stay reachable, leave the menus */
|
||||
private const LEGACY_CATEGORY_SLUGS = [
|
||||
'/products/graphics-and-multi-display', '/products/video-capture',
|
||||
'/products/infrastructure-and-accessories',
|
||||
];
|
||||
|
||||
/**
|
||||
* Sitemap names that do not exist as records - mapped onto the existing
|
||||
* record by slug. The Vision "+1" names are almost certainly an Excel
|
||||
@@ -179,6 +233,24 @@ final class BuildProductOverviewsCommand extends Command
|
||||
private array $report = [];
|
||||
private int $newCounter = 0;
|
||||
private bool $apply = false;
|
||||
/** @var array<string,string> section page id => title */
|
||||
private array $sectionTitles = [];
|
||||
/** @var array<string,array<int,array<string,mixed>>> section page id => child specs (card order) */
|
||||
private array $sectionChildren = [];
|
||||
/** @var string[] section page ids that get generated content (new or empty) */
|
||||
private array $sectionsToFill = [];
|
||||
/** @var array<int,array<string,mixed>> folder uid => page fields, written by direct UPDATE */
|
||||
private array $conversions = [];
|
||||
/** @var int[] legacy category pages that leave the menus */
|
||||
private array $legacyToHide = [];
|
||||
/** @var array<int,array{page:int,folder:int,parent:int,navTitle:string}> adopted 2026-10 pages, applied by direct UPDATE */
|
||||
private array $adoptions = [];
|
||||
/** @var array<string,string> sub-category id => id of the node its children currently hang on (the old folder) */
|
||||
private array $lookupParent = [];
|
||||
/** @var array<string,int[]> merge target id => source page uids */
|
||||
private array $merges = [];
|
||||
/** @var array<string,string> overview slug => product names (card text on the sub-category page) */
|
||||
private array $overviewNames = [];
|
||||
/** @var array<int,array{uid:int,title:string,slug:string,teaser:string}>|null */
|
||||
private ?array $products = null;
|
||||
|
||||
@@ -205,23 +277,42 @@ final class BuildProductOverviewsCommand extends Command
|
||||
$shortcuts = []; // [id, spec]
|
||||
|
||||
foreach (self::STRUCTURE as [$categoryTitle, $groups]) {
|
||||
$categoryId = $this->folder($rootId, $categoryTitle, 'category');
|
||||
$categoryId = $this->section($rootId, $categoryTitle, 'category');
|
||||
$categoryChildren = [];
|
||||
foreach ($groups as [$groupTitle, $entries]) {
|
||||
$groupId = $this->folder($categoryId, $groupTitle, 'sub-category');
|
||||
$groupId = $this->section($categoryId, $groupTitle, 'sub-category');
|
||||
$groupFolderIds[$groupTitle] = $groupId;
|
||||
$groupChildren = [];
|
||||
foreach ($entries as $entry) {
|
||||
if (isset($entry['shortcut'])) {
|
||||
$shortcuts[] = [$this->shortcutPage($groupId, $entry['shortcut']), $entry];
|
||||
$groupChildren[] = ['shortcut' => $entry];
|
||||
continue;
|
||||
}
|
||||
$overviewBySlug[$entry['slug']] = $this->overviewPage($groupId, $entry);
|
||||
$groupChildren[] = [
|
||||
'id' => $overviewBySlug[$entry['slug']],
|
||||
'title' => (string)$entry['page'],
|
||||
'text' => $this->overviewNames[$entry['slug']] ?? '',
|
||||
];
|
||||
}
|
||||
$this->sectionChildren[$groupId] = $groupChildren;
|
||||
$categoryChildren[] = [
|
||||
'id' => $groupId,
|
||||
'title' => $groupTitle,
|
||||
'text' => implode(', ', array_column(array_filter($groupChildren, static fn(array $c): bool => isset($c['id'])), 'title')),
|
||||
];
|
||||
}
|
||||
$this->sectionChildren[$categoryId] = $categoryChildren;
|
||||
}
|
||||
|
||||
// shortcut targets (a group folder or an overview page)
|
||||
// shortcut targets (a sub-category page or an overview page); on the
|
||||
// sub-category page the shortcut becomes a card linking its target
|
||||
$targetOf = function (array $spec) use ($groupFolderIds, $overviewBySlug): ?string {
|
||||
return isset($spec['toGroup']) ? ($groupFolderIds[$spec['toGroup']] ?? null) : ($overviewBySlug[$spec['toPage']] ?? null);
|
||||
};
|
||||
foreach ($shortcuts as [$shortcutId, $spec]) {
|
||||
$target = isset($spec['toGroup']) ? ($groupFolderIds[$spec['toGroup']] ?? null) : ($overviewBySlug[$spec['toPage']] ?? null);
|
||||
$target = $targetOf($spec);
|
||||
if ($target === null) {
|
||||
$this->report[] = ' ! shortcut "' . $spec['shortcut'] . '": target not found';
|
||||
continue;
|
||||
@@ -229,6 +320,25 @@ final class BuildProductOverviewsCommand extends Command
|
||||
$this->datamap['pages'][$shortcutId]['shortcut'] = ctype_digit($target) ? (int)$target : $target;
|
||||
$this->datamap['pages'][$shortcutId]['shortcut_mode'] = 0;
|
||||
}
|
||||
foreach ($this->sectionChildren as $sectionId => $children) {
|
||||
foreach ($children as $i => $child) {
|
||||
if (isset($child['shortcut'])) {
|
||||
$target = $targetOf($child['shortcut']);
|
||||
$this->sectionChildren[$sectionId][$i] = $target === null ? null : [
|
||||
'id' => $target,
|
||||
'title' => (string)$child['shortcut']['shortcut'],
|
||||
'text' => (string)($child['shortcut']['toGroup'] ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
$this->sectionChildren[$sectionId] = array_values(array_filter($this->sectionChildren[$sectionId]));
|
||||
}
|
||||
|
||||
$this->sectionMeta();
|
||||
foreach ($this->sectionsToFill as $sectionId) {
|
||||
$this->sectionContent($sectionId);
|
||||
}
|
||||
$this->legacyCategoryPages();
|
||||
|
||||
foreach ($this->report as $line) {
|
||||
$output->writeln($line);
|
||||
@@ -238,6 +348,24 @@ final class BuildProductOverviewsCommand extends Command
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
// folders -> pages and legacy nav_hide by direct UPDATE (see class
|
||||
// comment: the DataHandler slug hook would rewrite child slugs)
|
||||
$pageConnection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages');
|
||||
foreach ($this->conversions as $uid => $fields) {
|
||||
$pageConnection->update('pages', $fields, ['uid' => $uid]);
|
||||
}
|
||||
foreach ($this->adoptions as $adoption) {
|
||||
$pageConnection->update('pages', ['pid' => $adoption['parent'], 'nav_hide' => 0, 'hidden' => 0, 'nav_title' => $adoption['navTitle']], ['uid' => $adoption['page']]);
|
||||
$pageConnection->update('pages', ['pid' => $adoption['parent']], ['l10n_parent' => $adoption['page']]);
|
||||
if ($adoption['folder'] > 0) {
|
||||
$pageConnection->update('pages', ['pid' => $adoption['page']], ['pid' => $adoption['folder'], 'deleted' => 0]);
|
||||
$pageConnection->update('pages', ['deleted' => 1], ['uid' => $adoption['folder']]);
|
||||
}
|
||||
}
|
||||
foreach ($this->legacyToHide as $uid) {
|
||||
$pageConnection->update('pages', ['nav_hide' => 1], ['uid' => $uid]);
|
||||
}
|
||||
|
||||
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||
$dataHandler->start($this->datamap, []);
|
||||
$dataHandler->process_datamap();
|
||||
@@ -246,35 +374,291 @@ final class BuildProductOverviewsCommand extends Command
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$this->applyOrder($dataHandler->substNEWwithIDs);
|
||||
$this->copyMergedContent($dataHandler->substNEWwithIDs, $output);
|
||||
|
||||
$output->writeln("\n<info>Written.</info> Next, per overview page:");
|
||||
foreach ($overviewBySlug as $slug => $id) {
|
||||
$output->writeln(' vendor/bin/typo3 vitec:wrap-cards-grid --page=/products/' . $slug . ' --apply');
|
||||
$output->writeln("\n<info>Written.</info> Next, per page with generated cards:");
|
||||
foreach ($this->sectionsToFill as $id) {
|
||||
$slug = $this->conversions[(int)$id]['slug'] ?? ('/products/' . self::SECTION_SLUGS[$this->sectionTitles[$id]]);
|
||||
$output->writeln(' vendor/bin/typo3 vitec:wrap-cards-grid --page=' . $slug . ' --apply');
|
||||
}
|
||||
$output->writeln('then: vendor/bin/typo3 vitec:attach-card-images --apply && vendor/bin/typo3 cache:flush');
|
||||
$output->writeln('(overview pages as needed: --page=/products/<slug>), then vitec:build-why-vitec-bands and cache:flush');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
// ---- structure -------------------------------------------------------------------
|
||||
|
||||
private function folder(string $parentId, string $title, string $kind): string
|
||||
/**
|
||||
* A category (A) or sub-category (B) page, found by title below its
|
||||
* parent. A sysfolder from the 2026-11-02 build is queued for conversion
|
||||
* into a page; an empty page is queued for generated content.
|
||||
*/
|
||||
private function section(string $parentId, string $title, string $kind): string
|
||||
{
|
||||
$existing = ctype_digit($parentId) ? $this->childByTitle((int)$parentId, $title, self::DOKTYPE_FOLDER) : null;
|
||||
if ($existing !== null) {
|
||||
$this->report[] = sprintf('%s folder "%s" #%d (exists)', $kind, $title, $existing);
|
||||
$this->pageOrder[$parentId][] = (string)$existing;
|
||||
return (string)$existing;
|
||||
$existing = ctype_digit($parentId)
|
||||
? $this->childByTitle((int)$parentId, $title, [self::DOKTYPE_FOLDER, self::DOKTYPE_PAGE]) : null;
|
||||
if (isset(self::ADOPT[$title])) {
|
||||
return $this->adopt($parentId, $title, $existing);
|
||||
}
|
||||
$slug = '/products/' . self::SECTION_SLUGS[$title];
|
||||
if ($existing === null) {
|
||||
$id = $this->newId();
|
||||
$this->datamap['pages'][$id] = ['pid' => $this->pid($parentId), 'doktype' => self::DOKTYPE_FOLDER, 'title' => $title, 'hidden' => 0];
|
||||
$this->datamap['pages'][$id] = [
|
||||
'pid' => $this->pid($parentId), 'doktype' => self::DOKTYPE_PAGE, 'hidden' => 0,
|
||||
'title' => $title, 'slug' => $slug, 'layout' => self::LAYOUT,
|
||||
];
|
||||
$this->pageOrder[$parentId][] = $id;
|
||||
$this->report[] = sprintf('%s folder "%s" CREATE', $kind, $title);
|
||||
$this->sectionTitles[$id] = $title;
|
||||
$this->sectionsToFill[] = $id;
|
||||
$this->report[] = sprintf('%s page "%s" %s CREATE', $kind, $title, $slug);
|
||||
return $id;
|
||||
}
|
||||
|
||||
$uid = $existing['uid'];
|
||||
$id = (string)$uid;
|
||||
$this->pageOrder[$parentId][] = $id;
|
||||
$this->sectionTitles[$id] = $title;
|
||||
if ($existing['doktype'] === self::DOKTYPE_FOLDER) {
|
||||
$taken = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages')->fetchOne(
|
||||
'SELECT uid FROM pages WHERE deleted = 0 AND slug = ? AND uid <> ? AND sys_language_uid IN (0,-1)',
|
||||
[$slug, $uid]
|
||||
);
|
||||
if ($taken !== false) {
|
||||
$this->report[] = sprintf('%s folder "%s" #%d: <error>slug %s taken by #%d - NOT converted</error>', $kind, $title, $uid, $slug, (int)$taken);
|
||||
return $id;
|
||||
}
|
||||
$this->conversions[$uid] = [
|
||||
'doktype' => self::DOKTYPE_PAGE, 'slug' => $slug, 'layout' => self::LAYOUT,
|
||||
'nav_hide' => 0, 'hidden' => 0,
|
||||
];
|
||||
$this->report[] = sprintf('%s folder "%s" #%d -> page %s', $kind, $title, $uid, $slug);
|
||||
// a redirect on the slug wins over the page (2026-11-05: the 2026-10
|
||||
// stub redirect /products/platforms-and-end-points hid the new page)
|
||||
$redirect = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('sys_redirect')->fetchAssociative(
|
||||
'SELECT uid, target FROM sys_redirect WHERE deleted = 0 AND disabled = 0 AND source_path IN (?, ?)',
|
||||
[$slug, $slug . '/']
|
||||
);
|
||||
if ($redirect) {
|
||||
$this->report[] = sprintf(' <error>! redirect #%d %s -> %s overrides this page - disable it (Redirects module)</error>', (int)$redirect['uid'], $slug, $redirect['target']);
|
||||
}
|
||||
} else {
|
||||
$this->report[] = sprintf('%s page "%s" #%d (exists)', $kind, $title, $uid);
|
||||
}
|
||||
$contentCount = (int)GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tt_content')
|
||||
->fetchOne('SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND pid = ?', [$uid]);
|
||||
if ($contentCount === 0) {
|
||||
$this->sectionsToFill[] = $id;
|
||||
} else {
|
||||
$this->report[] = sprintf(' (%d elements - content left untouched)', $contentCount);
|
||||
}
|
||||
if (isset(self::MERGE[$title])) {
|
||||
$this->merges[$id] = [];
|
||||
foreach (self::MERGE[$title] as $sourceSlug) {
|
||||
$source = $this->pageBySlug($sourceSlug);
|
||||
if ($source === null) {
|
||||
$this->report[] = ' ! merge source ' . $sourceSlug . ' not found';
|
||||
continue;
|
||||
}
|
||||
$this->merges[$id][] = $source;
|
||||
}
|
||||
}
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* A 2026-10 category page takes the sub-category's place (ADOPT): it
|
||||
* moves below the category at the folder's position, the folder's
|
||||
* children move below it, the emptied folder is deleted. Content, slug
|
||||
* and SEO of the page stay; nav_title = sitemap name.
|
||||
*
|
||||
* @param array{uid:int,doktype:int}|null $folder
|
||||
*/
|
||||
private function adopt(string $parentId, string $title, ?array $folder): string
|
||||
{
|
||||
$page = $this->pageBySlug(self::ADOPT[$title]);
|
||||
if ($page === null) {
|
||||
$this->report[] = sprintf('sub-category "%s": <error>page %s not found - NOT adopted</error>', $title, self::ADOPT[$title]);
|
||||
return $folder !== null ? (string)$folder['uid'] : $this->newId();
|
||||
}
|
||||
$id = (string)$page;
|
||||
$this->pageOrder[$parentId][] = $id;
|
||||
$this->lookupParent[$id] = $folder !== null && $folder['doktype'] === self::DOKTYPE_FOLDER ? (string)$folder['uid'] : $id;
|
||||
if (!ctype_digit($parentId)) {
|
||||
$this->report[] = sprintf('sub-category "%s": <error>category not built yet - run again</error>', $title);
|
||||
return $id;
|
||||
}
|
||||
$this->adoptions[] = [
|
||||
'page' => $page,
|
||||
'folder' => $folder !== null && $folder['doktype'] === self::DOKTYPE_FOLDER ? $folder['uid'] : 0,
|
||||
'parent' => (int)$parentId,
|
||||
'navTitle' => $title,
|
||||
];
|
||||
$this->report[] = sprintf(
|
||||
'sub-category "%s" = %s #%d (content, slug, SEO stay; menu title "%s")%s',
|
||||
$title, self::ADOPT[$title], $page, $title,
|
||||
$folder !== null && $folder['doktype'] === self::DOKTYPE_FOLDER ? '; folder #' . $folder['uid'] . ' -> children move, folder deleted' : ''
|
||||
);
|
||||
return $id;
|
||||
}
|
||||
|
||||
private function pageBySlug(string $slug): ?int
|
||||
{
|
||||
$uid = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages')->fetchOne(
|
||||
'SELECT uid FROM pages WHERE deleted = 0 AND slug = ? AND sys_language_uid IN (0,-1) ORDER BY uid', [$slug]
|
||||
);
|
||||
return $uid !== false ? (int)$uid : null;
|
||||
}
|
||||
|
||||
/** SEO/OG title, description and abstract of the section pages, from their children. */
|
||||
private function sectionMeta(): void
|
||||
{
|
||||
foreach ($this->sectionTitles as $id => $title) {
|
||||
$names = array_column($this->sectionChildren[$id] ?? [], 'title');
|
||||
$description = 'VITEC ' . $title . ($names !== [] ? ': ' . implode(', ', $names) : '') . '.';
|
||||
$meta = [
|
||||
'seo_title' => $title . ' | VITEC', 'description' => $description,
|
||||
'og_title' => $title . ' | VITEC', 'og_description' => $description,
|
||||
'abstract' => implode(', ', $names),
|
||||
];
|
||||
if (isset($this->conversions[(int)$id])) {
|
||||
$this->conversions[(int)$id] += $meta;
|
||||
} elseif (isset($this->datamap['pages'][$id])) {
|
||||
$this->datamap['pages'][$id] += $meta;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generated content of a new or empty category / sub-category page:
|
||||
* hero, "Product range", one card per child page (title, child list as
|
||||
* text, the child's first page media as image, link), contact pre-footer.
|
||||
*/
|
||||
private function sectionContent(string $pageId): void
|
||||
{
|
||||
$title = $this->sectionTitles[$pageId];
|
||||
$this->report[] = sprintf('content for "%s" (%s):', $title, ctype_digit($pageId) ? '#' . $pageId : 'new page');
|
||||
$heroId = $this->element($pageId, self::COLPOS_HERO, 'vitec_herosection', ['header' => $title, 'header_layout' => 1, 'bodytext' => '']);
|
||||
$placeholder = $this->heroPlaceholder($pageId);
|
||||
if ($placeholder !== null) {
|
||||
$this->report[] = ' hero: ' . ($this->apply ? 'attach ' : 'would attach ') . $placeholder;
|
||||
if ($this->apply) {
|
||||
$refId = $this->newId();
|
||||
$this->datamap['sys_file_reference'][$refId] = [
|
||||
'pid' => $this->pid($pageId),
|
||||
'table_local' => 'sys_file',
|
||||
'uid_local' => GeneralUtility::makeInstance(StorageRepository::class)
|
||||
->getDefaultStorage()->getFile($placeholder)->getUid(),
|
||||
];
|
||||
$this->datamap['tt_content'][$heroId]['vitec_hero_bgimage'] = $refId;
|
||||
}
|
||||
}
|
||||
if (isset($this->merges[$pageId])) {
|
||||
// MERGE target: the main column is copied from the source pages after the run
|
||||
$this->report[] = ' main column: copied from ' . count($this->merges[$pageId]) . ' page(s) #' . implode(', #', $this->merges[$pageId]) . ' (after the write)';
|
||||
} else {
|
||||
$this->element($pageId, self::COLPOS_MAIN, 'vitec_introparagraph', ['header' => 'Product range', 'bodytext' => '']);
|
||||
}
|
||||
foreach (isset($this->merges[$pageId]) ? [] : ($this->sectionChildren[$pageId] ?? []) as $child) {
|
||||
$fields = [
|
||||
'vitec_layout_variant' => 'text_only',
|
||||
'header' => (string)$child['title'],
|
||||
'bodytext' => $this->p((string)$child['text']),
|
||||
'vitec_primary_cta_label' => 'Learn more',
|
||||
'vitec_primary_cta' => ctype_digit((string)$child['id']) ? 't3://page?uid=' . $child['id'] : '',
|
||||
];
|
||||
$fileUid = ctype_digit((string)$child['id']) ? $this->pageMediaFile((int)$child['id']) : 0;
|
||||
$cardId = $this->element($pageId, self::COLPOS_MAIN, 'vitec_card', $fields);
|
||||
if ($fileUid > 0) {
|
||||
$this->datamap['tt_content'][$cardId]['vitec_layout_variant'] = 'vertical';
|
||||
if ($this->apply) {
|
||||
$refId = $this->newId();
|
||||
$this->datamap['sys_file_reference'][$refId] = ['pid' => $this->pid($pageId), 'table_local' => 'sys_file', 'uid_local' => $fileUid];
|
||||
$this->datamap['tt_content'][$cardId]['vitec_image'] = $refId;
|
||||
}
|
||||
}
|
||||
$this->report[] = sprintf(' card "%s"%s%s', $child['title'], $fields['vitec_primary_cta'] !== '' ? ' -> ' . $fields['vitec_primary_cta'] : ' (link after the first run)', $fileUid > 0 ? ' + page image' : '');
|
||||
}
|
||||
$this->element($pageId, self::COLPOS_PREFOOTER, 'vitec_ctabanner', [
|
||||
'header' => 'Talk to a VITEC expert',
|
||||
'bodytext' => $this->p('Not sure which product fits your project? Our team helps you choose.'),
|
||||
'vitec_primary_cta_label' => 'Contact us',
|
||||
'vitec_primary_cta' => $this->contactLink(),
|
||||
'vitec_layout_variant' => 'centered',
|
||||
'vitec_background_variant' => 'orange',
|
||||
]);
|
||||
}
|
||||
|
||||
/** The 2026-10 category pages below /products leave the menus (nav_hide), stay reachable. */
|
||||
private function legacyCategoryPages(): void
|
||||
{
|
||||
$pages = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages');
|
||||
foreach (self::LEGACY_CATEGORY_SLUGS as $slug) {
|
||||
$row = $pages->fetchAssociative('SELECT uid, nav_hide FROM pages WHERE deleted = 0 AND slug = ? AND sys_language_uid IN (0,-1)', [$slug]);
|
||||
if (!$row) {
|
||||
$this->report[] = 'legacy ' . $slug . ': not found';
|
||||
} elseif ((int)$row['nav_hide'] === 0) {
|
||||
$this->legacyToHide[] = (int)$row['uid'];
|
||||
$this->report[] = sprintf('legacy %s #%d: hide in menus (stays reachable)', $slug, (int)$row['uid']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* MERGE: copies the visible top-level main-column elements of the source
|
||||
* pages, in order, below each other onto the (new, previously empty)
|
||||
* target page. One DataHandler copy per element, each placed after the
|
||||
* previous copy; EXT:container copies a grid's children along. Re-runs
|
||||
* do nothing - the target is only filled while it is empty.
|
||||
*
|
||||
* @param array<string,int> $subst
|
||||
*/
|
||||
private function copyMergedContent(array $subst, OutputInterface $output): void
|
||||
{
|
||||
$content = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tt_content');
|
||||
foreach ($this->merges as $targetId => $sources) {
|
||||
$targetId = (string)$targetId;
|
||||
if (!in_array($targetId, $this->sectionsToFill, true)) {
|
||||
continue;
|
||||
}
|
||||
$targetUid = ctype_digit($targetId) ? (int)$targetId : (int)($subst[$targetId] ?? 0);
|
||||
if ($targetUid === 0) {
|
||||
continue;
|
||||
}
|
||||
$after = 0;
|
||||
$copied = 0;
|
||||
foreach ($sources as $sourceUid) {
|
||||
$elements = $content->fetchFirstColumn(
|
||||
'SELECT uid FROM tt_content WHERE deleted = 0 AND hidden = 0 AND pid = ? AND colPos = ?
|
||||
AND (tx_container_parent IS NULL OR tx_container_parent = 0) ORDER BY sorting, uid',
|
||||
[$sourceUid, self::COLPOS_MAIN]
|
||||
);
|
||||
foreach ($elements as $elementUid) {
|
||||
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||
$dataHandler->start([], ['tt_content' => [(int)$elementUid => ['copy' => $after > 0 ? -$after : $targetUid]]]);
|
||||
$dataHandler->process_cmdmap();
|
||||
$newUid = (int)($dataHandler->copyMappingArray_merged['tt_content'][(int)$elementUid] ?? 0);
|
||||
if ($newUid > 0) {
|
||||
$after = $newUid;
|
||||
$copied++;
|
||||
}
|
||||
}
|
||||
}
|
||||
$output->writeln(sprintf('merged %d element(s) onto #%d', $copied, $targetUid));
|
||||
}
|
||||
}
|
||||
|
||||
/** sys_file uid of a page's first media reference, 0 when none */
|
||||
private function pageMediaFile(int $pageUid): int
|
||||
{
|
||||
return (int)GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('sys_file_reference')->fetchOne(
|
||||
"SELECT uid_local FROM sys_file_reference WHERE deleted = 0 AND hidden = 0 AND tablenames = 'pages' AND fieldname = 'media' AND uid_foreign = ? ORDER BY sorting_foreign LIMIT 1",
|
||||
[$pageUid]
|
||||
);
|
||||
}
|
||||
|
||||
private function shortcutPage(string $groupId, string $title): string
|
||||
{
|
||||
$existing = ctype_digit($groupId) ? $this->childByTitle((int)$groupId, $title, self::DOKTYPE_SHORTCUT) : null;
|
||||
$lookup = $this->lookupParent[$groupId] ?? $groupId; // an adopted page's shortcuts still hang on the old folder
|
||||
$existing = ctype_digit($lookup) ? ($this->childByTitle((int)$lookup, $title, [self::DOKTYPE_SHORTCUT])['uid'] ?? null) : null;
|
||||
if ($existing !== null) {
|
||||
$this->report[] = sprintf(' shortcut "%s" #%d (exists, target refreshed)', $title, $existing);
|
||||
$this->pageOrder[$groupId][] = (string)$existing;
|
||||
@@ -296,13 +680,14 @@ final class BuildProductOverviewsCommand extends Command
|
||||
'SELECT uid, pid FROM pages WHERE deleted = 0 AND slug = ? AND sys_language_uid IN (0,-1)', [$slug]
|
||||
);
|
||||
[$cards, $names] = $this->resolveItems($entry['items']);
|
||||
$this->overviewNames[$entry['slug']] = implode(', ', $names);
|
||||
|
||||
if ($existing) {
|
||||
$pageId = (string)(int)$existing['uid'];
|
||||
$this->pageOrder[$groupId][] = $pageId;
|
||||
$updates = [];
|
||||
if ((string)(int)$existing['pid'] !== $groupId) {
|
||||
$updates['pid'] = $this->pid($groupId); // moved into its folder
|
||||
if (!in_array((string)(int)$existing['pid'], [$groupId, $this->lookupParent[$groupId] ?? $groupId], true)) {
|
||||
$updates['pid'] = $this->pid($groupId); // moved into its sub-category
|
||||
}
|
||||
if ($updates !== []) {
|
||||
$this->datamap['pages'][$pageId] = $updates;
|
||||
@@ -509,13 +894,18 @@ final class BuildProductOverviewsCommand extends Command
|
||||
|
||||
// ---- lookups -----------------------------------------------------------------------
|
||||
|
||||
private function childByTitle(int $parentUid, string $title, int $doktype): ?int
|
||||
/**
|
||||
* @param int[] $doktypes
|
||||
* @return array{uid:int,doktype:int}|null
|
||||
*/
|
||||
private function childByTitle(int $parentUid, string $title, array $doktypes): ?array
|
||||
{
|
||||
$uid = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages')->fetchOne(
|
||||
'SELECT uid FROM pages WHERE deleted = 0 AND pid = ? AND title = ? AND doktype = ? AND sys_language_uid IN (0,-1) ORDER BY uid',
|
||||
[$parentUid, $title, $doktype]
|
||||
$row = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages')->fetchAssociative(
|
||||
'SELECT uid, doktype FROM pages WHERE deleted = 0 AND pid = ? AND title = ? AND doktype IN (' . implode(',', array_map('intval', $doktypes)) . ')
|
||||
AND sys_language_uid IN (0,-1) ORDER BY uid',
|
||||
[$parentUid, $title]
|
||||
);
|
||||
return $uid !== false ? (int)$uid : null;
|
||||
return $row ? ['uid' => (int)$row['uid'], 'doktype' => (int)$row['doktype']] : null;
|
||||
}
|
||||
|
||||
/** @return array<int,array{uid:int,title:string,slug:string,teaser:string}> */
|
||||
|
||||
@@ -25,9 +25,13 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
* -> "Scalable architecture"); bodytext stays empty until the agency
|
||||
* delivers copy (0036: a card without text is fine).
|
||||
*
|
||||
* The band lands directly AFTER the first element of the main column (the
|
||||
* intro). Idempotent: a page already carrying a "Why VITEC?" introparagraph
|
||||
* is skipped - editors own the band from then on. The Vision sub-group icon
|
||||
* The band lands at the TOP of the main column, before the "Product range"
|
||||
* intro and its cards (0042-FE: placed after the intro it split heading and
|
||||
* cards; fix_why_band_position.php corrected the first seven pages).
|
||||
* Idempotent: a page already carrying a "Why VITEC?" introparagraph gets
|
||||
* no second band; unused icons of its context go onto existing cards whose
|
||||
* title matches the icon theme (2026-11-05: the adopted category pages'
|
||||
* workbook cards) - nothing else is touched. The Vision sub-group icon
|
||||
* sets (vision, visionav) stay unassigned: ONE page, TWO sets - editorial
|
||||
* decision, reported only.
|
||||
*
|
||||
@@ -44,7 +48,7 @@ final class BuildWhyVitecBandsCommand extends Command
|
||||
private const HEADER = 'Why VITEC?';
|
||||
private const COLPOS = 0;
|
||||
|
||||
/** staged icon context => overview page slug */
|
||||
/** staged icon context => overview (or, since 2026-11-05, sub-category) page slug */
|
||||
private const BANDS = [
|
||||
'activesqx' => '/products/activesqx',
|
||||
'aetria' => '/products/aetria',
|
||||
@@ -53,8 +57,18 @@ final class BuildWhyVitecBandsCommand extends Command
|
||||
'image-graphics-cards' => '/products/image-graphics-cards',
|
||||
'operator-workstations' => '/products/operator-workstations',
|
||||
'prism-transcoder' => '/products/prism-transcoder',
|
||||
// sub-category pages = the adopted 2026-10 category pages (2026-11-05);
|
||||
// they already carry a "Why VITEC?" section from Joe's workbooks whose
|
||||
// cards ARE these icon themes -> icons go onto the existing cards
|
||||
'avoip-and-kvm' => '/products/av-over-ip-and-kvm',
|
||||
'control-room-platform' => '/products/control-room-platforms',
|
||||
'ip-gateway-and-transcoder' => '/products/gateways-and-transcoding',
|
||||
'iptv-distribution-and-signange-platform' => '/products/iptv-and-digital-signage',
|
||||
];
|
||||
|
||||
/** words ignored when an icon theme is matched against a card title */
|
||||
private const STOPWORDS = ['and', 'for', 'the', 'of', 'to', 'a', 'an', 'in', 'on', 'with'];
|
||||
|
||||
/** acronyms kept upper-case when the filename theme becomes a card title */
|
||||
private const ACRONYMS = ['KVM', 'IPTV', 'AV', 'HD', 'SDI', 'IP', 'SQX', 'RF', 'VSN', 'HDMI', 'DVI'];
|
||||
|
||||
@@ -85,15 +99,6 @@ final class BuildWhyVitecBandsCommand extends Command
|
||||
}
|
||||
$pageUid = (int)$page['uid'];
|
||||
|
||||
$existing = (int)$pool->getConnectionForTable('tt_content')->fetchOne(
|
||||
"SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND pid = ? AND CType = 'vitec_introparagraph' AND header = ?",
|
||||
[$pageUid, self::HEADER]
|
||||
);
|
||||
if ($existing > 0) {
|
||||
$output->writeln($slug . ': band exists - untouched');
|
||||
continue;
|
||||
}
|
||||
|
||||
$folder = self::ICON_BASE . $context;
|
||||
$icons = [];
|
||||
if ($storage->hasFolder($folder)) {
|
||||
@@ -108,6 +113,18 @@ final class BuildWhyVitecBandsCommand extends Command
|
||||
continue;
|
||||
}
|
||||
|
||||
$existing = (int)$pool->getConnectionForTable('tt_content')->fetchOne(
|
||||
"SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND pid = ? AND CType = 'vitec_introparagraph' AND header = ?",
|
||||
[$pageUid, self::HEADER]
|
||||
);
|
||||
if ($existing > 0) {
|
||||
// the page has its "Why VITEC?" section already (own band or
|
||||
// a workbook import): put the icons onto matching cards
|
||||
// without one; everything else stays untouched
|
||||
$built += $this->iconsOntoExistingCards($pageUid, $slug, $icons, $apply, $output);
|
||||
continue;
|
||||
}
|
||||
|
||||
$output->writeln(sprintf("\n<info>%s</info> (#%d): band with %d icon card(s)", $slug, $pageUid, count($icons)));
|
||||
foreach ($icons as $icon) {
|
||||
$output->writeln(' "' . $icon['title'] . '" (' . basename($icon['identifier']) . ')');
|
||||
@@ -160,11 +177,10 @@ final class BuildWhyVitecBandsCommand extends Command
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// final order: first element (intro), then the band, then the rest
|
||||
// final order: the band first, then the existing main column
|
||||
$order = array_merge(
|
||||
array_slice($existingUids, 0, 1),
|
||||
array_map(static fn(string $id) => (string)($dataHandler->substNEWwithIDs[$id] ?? '0'), $newIds),
|
||||
array_slice($existingUids, 1)
|
||||
$existingUids
|
||||
);
|
||||
$sorting = 0;
|
||||
foreach ($order as $uid) {
|
||||
@@ -182,6 +198,97 @@ final class BuildWhyVitecBandsCommand extends Command
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Icons onto the existing cards of a page whose title matches an icon
|
||||
* theme ("Centralized management" ~ CentralManagement: every word of
|
||||
* the theme is a prefix of a card-title word or vice versa, same word
|
||||
* count). Only cards without an icon; they become icon_card like the
|
||||
* generated bands. Returns 1 when anything was (or would be) attached.
|
||||
*
|
||||
* @param array<int,array{identifier:string,title:string}> $icons
|
||||
*/
|
||||
private function iconsOntoExistingCards(int $pageUid, string $slug, array $icons, bool $apply, OutputInterface $output): int
|
||||
{
|
||||
$pool = GeneralUtility::makeInstance(ConnectionPool::class);
|
||||
$cards = $pool->getConnectionForTable('tt_content')->fetchAllAssociative(
|
||||
"SELECT c.uid, c.header FROM tt_content c
|
||||
WHERE c.deleted = 0 AND c.pid = ? AND c.CType = 'vitec_card'
|
||||
AND NOT EXISTS (SELECT 1 FROM sys_file_reference r WHERE r.deleted = 0 AND r.tablenames = 'tt_content'
|
||||
AND r.fieldname = 'vitec_icon' AND r.uid_foreign = c.uid)
|
||||
ORDER BY c.uid",
|
||||
[$pageUid]
|
||||
);
|
||||
$storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage();
|
||||
// icons already on a card of this page (e.g. the generated bands)
|
||||
$used = array_map('intval', $pool->getConnectionForTable('sys_file_reference')->fetchFirstColumn(
|
||||
"SELECT r.uid_local FROM sys_file_reference r JOIN tt_content c ON c.uid = r.uid_foreign
|
||||
WHERE r.deleted = 0 AND r.tablenames = 'tt_content' AND r.fieldname = 'vitec_icon' AND c.deleted = 0 AND c.pid = ?",
|
||||
[$pageUid]
|
||||
));
|
||||
$datamap = [];
|
||||
$lines = [];
|
||||
foreach ($icons as $icon) {
|
||||
if (in_array($storage->getFile($icon['identifier'])->getUid(), $used, true)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($cards as $k => $card) {
|
||||
if (!$this->sameTheme($icon['title'], (string)$card['header'])) {
|
||||
continue;
|
||||
}
|
||||
$lines[] = sprintf(' card #%d "%s" <- %s', (int)$card['uid'], $card['header'], basename($icon['identifier']));
|
||||
$refId = $this->newId();
|
||||
$datamap['sys_file_reference'][$refId] = [
|
||||
'pid' => $pageUid,
|
||||
'table_local' => 'sys_file',
|
||||
'uid_local' => $storage->getFile($icon['identifier'])->getUid(),
|
||||
];
|
||||
$datamap['tt_content'][(int)$card['uid']] = ['vitec_icon' => $refId, 'vitec_layout_variant' => 'icon_card'];
|
||||
unset($cards[$k]);
|
||||
continue 2;
|
||||
}
|
||||
$lines[] = ' <comment>no card for "' . $icon['title'] . '" (' . basename($icon['identifier']) . ')</comment>';
|
||||
}
|
||||
if ($lines === []) {
|
||||
$output->writeln($slug . ': band complete - untouched');
|
||||
return 0;
|
||||
}
|
||||
$output->writeln(sprintf("\n<info>%s</info> (#%d): \"Why VITEC?\" exists - icons onto its cards", $slug, $pageUid));
|
||||
foreach ($lines as $line) {
|
||||
$output->writeln($line);
|
||||
}
|
||||
if ($datamap === []) {
|
||||
return 0;
|
||||
}
|
||||
if ($apply) {
|
||||
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||
$dataHandler->start($datamap, []);
|
||||
$dataHandler->process_datamap();
|
||||
if ($dataHandler->errorLog !== []) {
|
||||
$output->writeln('<error>DataHandler: ' . implode(' | ', $dataHandler->errorLog) . '</error>');
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
private function sameTheme(string $iconTitle, string $cardTitle): bool
|
||||
{
|
||||
$words = fn(string $text): array => array_values(array_diff(
|
||||
preg_split('/[^a-z0-9]+/', mb_strtolower($text), -1, PREG_SPLIT_NO_EMPTY) ?: [],
|
||||
self::STOPWORDS
|
||||
));
|
||||
$a = $words($iconTitle);
|
||||
$b = $words($cardTitle);
|
||||
if ($a === [] || count($a) !== count($b)) {
|
||||
return false;
|
||||
}
|
||||
foreach ($a as $i => $word) {
|
||||
if (!str_starts_with($b[$i], $word) && !str_starts_with($word, $b[$i])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** "ScalableArchitecture" -> "Scalable architecture", acronyms kept ("KVMCapability" -> "KVM capability") */
|
||||
private function themeTitle(string $theme): string
|
||||
{
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Evomedien\Vitec\Command;
|
||||
|
||||
use Evomedien\Vitec\Service\SsrFlagService;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Shows (and on --sync repairs) the SSR routing state: the Site Set
|
||||
* switch "vitec.ssr.enabled" versus the flag file that .htaccess
|
||||
* evaluates. The SyncSsrFlag middleware does the same sync on every
|
||||
* request, so this command is mainly for diagnostics and scripting.
|
||||
*
|
||||
* vendor/bin/typo3 vitec:ssr-flag # status only
|
||||
* vendor/bin/typo3 vitec:ssr-flag --sync # align flag with setting
|
||||
*/
|
||||
#[AsCommand(name: 'vitec:ssr-flag', description: 'Show/sync the SSR routing flag against the vitec.ssr.enabled site setting')]
|
||||
final class SsrFlagCommand extends Command
|
||||
{
|
||||
public function __construct(private readonly SsrFlagService $ssrFlagService)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('sync', null, InputOption::VALUE_NONE, 'Create/remove the flag file so it matches the setting');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$setting = $this->ssrFlagService->isEnabledInSettings();
|
||||
$flag = $this->ssrFlagService->isFlagSet();
|
||||
|
||||
$output->writeln('Setting vitec.ssr.enabled: ' . ($setting ? 'ON' : 'off'));
|
||||
$output->writeln('Flag ' . $this->ssrFlagService->getFlagPath() . ': ' . ($flag ? 'EXISTS (SSR routing active)' : 'absent (SPA as before)'));
|
||||
|
||||
if ($setting === $flag) {
|
||||
$output->writeln('<info>In sync.</info>');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
if (!$input->getOption('sync')) {
|
||||
$output->writeln('<comment>OUT OF SYNC - run with --sync (or just hit any page: the middleware repairs it).</comment>');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$new = $this->ssrFlagService->sync();
|
||||
$output->writeln('<info>Synced: flag is now ' . ($new ? 'SET - SSR routing active.' : 'removed - SPA as before.') . '</info>');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,12 @@ declare(strict_types=1);
|
||||
|
||||
namespace Evomedien\Vitec\EventListener;
|
||||
|
||||
use Doctrine\DBAL\ArrayParameterType;
|
||||
use Doctrine\DBAL\ParameterType;
|
||||
use Evomedien\EvoMegamenuJson\Event\BuildSpotlightEvent;
|
||||
use TYPO3\CMS\Core\Attribute\AsEventListener;
|
||||
use TYPO3\CMS\Core\Cache\CacheDataCollectorInterface;
|
||||
use TYPO3\CMS\Core\Cache\CacheTag;
|
||||
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||
use TYPO3\CMS\Core\Database\Query\Restriction\FrontendRestrictionContainer;
|
||||
use TYPO3\CMS\Core\Resource\FileRepository;
|
||||
@@ -19,17 +22,23 @@ use TYPO3\CMS\Extbase\Service\ImageService;
|
||||
* news and the next events as image cards, queried live - editors touch
|
||||
* news and event records, never the menu.
|
||||
*
|
||||
* Conventions mirrored from the site set: news live on pid 11 (type 0 only,
|
||||
* same filter as the Solr indexing TS), their details render on page 21
|
||||
* through the NewsDetail route enhancer (/news/article/<path_segment>).
|
||||
* Conventions mirrored from the site set: news are type 0 only (same filter
|
||||
* as the Solr indexing TS), their details render on page 21 through the
|
||||
* NewsDetail route enhancer (/news/article/<path_segment>).
|
||||
* Events come from the vitec event table; an event links to its own
|
||||
* eventurl when set, otherwise to the events page.
|
||||
*
|
||||
* News sources (2026-11-05, ticket 0046-FE: the newer posts sit in folder
|
||||
* 442, the query only read 11): the entry's "Data source folders" and
|
||||
* "Data source categories" decide, editable in the menu record. No folder
|
||||
* set = 11 + 442 (like the /insights/news plugin), no category = all.
|
||||
*/
|
||||
#[AsEventListener(identifier: 'vitec/megamenu-insights-spotlight')]
|
||||
final class ProvideInsightsSpotlight
|
||||
{
|
||||
private const NEWS_TABLE = 'tx_news_domain_model_news';
|
||||
private const NEWS_STORAGE_PID = 11;
|
||||
private const DEFAULT_NEWS_PIDS = [11, 442];
|
||||
private const ENTRY_TABLE = 'tx_evomegamenujson_entry';
|
||||
private const NEWS_DETAIL_PATH = '/news/article/';
|
||||
private const EVENT_TABLE = 'tx_vitec_domain_model_event';
|
||||
private const EVENTS_PAGE = '/insights/events';
|
||||
@@ -44,25 +53,83 @@ final class ProvideInsightsSpotlight
|
||||
public function __invoke(BuildSpotlightEvent $event): void
|
||||
{
|
||||
$stage = $event->getStage();
|
||||
$entry = $event->getEntry();
|
||||
// group field: "11,442" (older notation "pages_11" tolerated)
|
||||
$pids = array_values(array_filter(array_map(
|
||||
static fn(string $value): int => (int)preg_replace('/^\D+_/', '', trim($value)),
|
||||
explode(',', (string)($entry['source_pages'] ?? ''))
|
||||
)));
|
||||
$event->setSpotlight([
|
||||
'news' => $this->latestNews($stage),
|
||||
'news' => $this->latestNews($stage, $pids ?: self::DEFAULT_NEWS_PIDS, $this->entryCategories((int)($entry['uid'] ?? 0))),
|
||||
'events' => $this->upcomingEvents($stage),
|
||||
]);
|
||||
$this->tagPageCache();
|
||||
}
|
||||
|
||||
/** @return array<int,array<string,mixed>> */
|
||||
private function latestNews(int $stage): array
|
||||
/**
|
||||
* The menu sits in every cached page; tagging the page with the news and
|
||||
* event tables lets the DataHandler refresh those pages as soon as an
|
||||
* editor saves a news or event record (it flushes the tag `<table>`).
|
||||
*/
|
||||
private function tagPageCache(): void
|
||||
{
|
||||
try {
|
||||
$collector = ($GLOBALS['TYPO3_REQUEST'] ?? null)?->getAttribute('frontend.cache.collector');
|
||||
if ($collector instanceof CacheDataCollectorInterface) {
|
||||
$collector->addCacheTags(new CacheTag(self::NEWS_TABLE), new CacheTag(self::EVENT_TABLE));
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// untagged, the menu only ages until the next flush
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Categories picked in the entry's "Data source categories".
|
||||
*
|
||||
* @return int[]
|
||||
*/
|
||||
private function entryCategories(int $entryUid): array
|
||||
{
|
||||
if ($entryUid <= 0) {
|
||||
return [];
|
||||
}
|
||||
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_category_record_mm');
|
||||
return array_map('intval', $queryBuilder->select('uid_local')->from('sys_category_record_mm')
|
||||
->where(
|
||||
$queryBuilder->expr()->eq('tablenames', $queryBuilder->createNamedParameter(self::ENTRY_TABLE)),
|
||||
$queryBuilder->expr()->eq('fieldname', $queryBuilder->createNamedParameter('source_categories')),
|
||||
$queryBuilder->expr()->eq('uid_foreign', $queryBuilder->createNamedParameter($entryUid, ParameterType::INTEGER))
|
||||
)
|
||||
->executeQuery()->fetchFirstColumn());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int[] $pids news folders
|
||||
* @param int[] $categories only news in one of these; empty = all
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
private function latestNews(int $stage, array $pids, array $categories): array
|
||||
{
|
||||
$queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::NEWS_TABLE);
|
||||
$queryBuilder->setRestrictions(GeneralUtility::makeInstance(FrontendRestrictionContainer::class));
|
||||
try {
|
||||
$rows = $queryBuilder->select('uid', 'title', 'teaser', 'datetime', 'path_segment')
|
||||
$queryBuilder->select('uid', 'title', 'teaser', 'datetime', 'path_segment')
|
||||
->from(self::NEWS_TABLE)
|
||||
->where(
|
||||
$queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter(self::NEWS_STORAGE_PID, ParameterType::INTEGER)),
|
||||
$queryBuilder->expr()->in('pid', $queryBuilder->createNamedParameter($pids, ArrayParameterType::INTEGER)),
|
||||
$queryBuilder->expr()->eq('type', $queryBuilder->createNamedParameter('0'))
|
||||
)
|
||||
->orderBy('datetime', 'DESC')
|
||||
);
|
||||
if ($categories !== []) {
|
||||
$mm = $this->connectionPool->getQueryBuilderForTable('sys_category_record_mm');
|
||||
$mm->select('uid_foreign')->from('sys_category_record_mm')
|
||||
->where(
|
||||
$mm->expr()->eq('tablenames', $queryBuilder->createNamedParameter(self::NEWS_TABLE)),
|
||||
$mm->expr()->eq('fieldname', $queryBuilder->createNamedParameter('categories')),
|
||||
$mm->expr()->in('uid_local', $queryBuilder->createNamedParameter($categories, ArrayParameterType::INTEGER))
|
||||
);
|
||||
$queryBuilder->andWhere($queryBuilder->expr()->in('uid', $mm->getSQL()));
|
||||
}
|
||||
$rows = $queryBuilder->orderBy('datetime', 'DESC')
|
||||
->setMaxResults(self::NEWS_COUNT)
|
||||
->executeQuery()->fetchAllAssociative();
|
||||
} catch (\Throwable) {
|
||||
|
||||
@@ -16,8 +16,8 @@ use TYPO3\CMS\Extbase\Service\ImageService;
|
||||
* version 2 (2026-11-02, agency "Product Menu Prototype"): three levels,
|
||||
* the menu ends at the product OVERVIEW pages.
|
||||
*
|
||||
* categories[] (A, menu label) = sysfolders directly below /products
|
||||
* groups[] (B, menu label) = sysfolders below those
|
||||
* categories[] (A) = category pages directly below /products
|
||||
* groups[] (B) = sub-category pages below those
|
||||
* items[] type "page" = overview pages (/products/<slug>)
|
||||
* type "shortcut" = shortcut pages, resolved to a target
|
||||
* group or overview page of this tree
|
||||
@@ -29,6 +29,13 @@ use TYPO3\CMS\Extbase\Service\ImageService;
|
||||
* pages the visitor reaches from the overview page; the menu does not show
|
||||
* them, the count and the list serve the "N sub-products" line and search.
|
||||
*
|
||||
* A and B were sysfolders (menu labels only) until 2026-11-05; Olli: every
|
||||
* category and sub-category is a clickable page. Both levels now carry
|
||||
* `link` (additive; '' while a level is still a folder). Folders are still
|
||||
* read, so the menu survives a half-converted tree; only groups with items
|
||||
* and categories with such groups count (keeps other pages below /products,
|
||||
* e.g. the 2026-10 category pages, out of the menu).
|
||||
*
|
||||
* Next to the tree: viewAll (/products) and the type-ahead endpoint. The
|
||||
* boxes of the left column ("Product highlight", "Product news") are the
|
||||
* entry's editorial teaser cards, not data from here (decision Olli
|
||||
@@ -81,8 +88,8 @@ final class ProvideProductCatalog
|
||||
if ($root === 0) {
|
||||
return [];
|
||||
}
|
||||
$categoryRows = $this->children([$root], [self::DOKTYPE_FOLDER]);
|
||||
$groupRows = $this->children(array_column($categoryRows, 'uid'), [self::DOKTYPE_FOLDER]);
|
||||
$categoryRows = $this->children([$root], [self::DOKTYPE_PAGE, self::DOKTYPE_FOLDER]);
|
||||
$groupRows = $this->children(array_column($categoryRows, 'uid'), [self::DOKTYPE_PAGE, self::DOKTYPE_FOLDER]);
|
||||
$itemRows = $this->children(array_column($groupRows, 'uid'), [self::DOKTYPE_PAGE, self::DOKTYPE_SHORTCUT]);
|
||||
$overviewUids = array_map('intval', array_column(
|
||||
array_filter($itemRows, static fn(array $r): bool => (int)$r['doktype'] === self::DOKTYPE_PAGE), 'uid'
|
||||
@@ -92,6 +99,12 @@ final class ProvideProductCatalog
|
||||
// positions for shortcut resolution: folder uid => [c, g], page uid => [c, g, p]
|
||||
$groupPos = [];
|
||||
$pagePos = [];
|
||||
// only the products tree: groups with items, categories with such groups
|
||||
$filledGroups = array_map('intval', array_column($itemRows, 'pid'));
|
||||
$groupRows = array_values(array_filter($groupRows, static fn(array $row): bool => in_array((int)$row['uid'], $filledGroups, true)));
|
||||
$filledCategories = array_map('intval', array_column($groupRows, 'pid'));
|
||||
$categoryRows = array_values(array_filter($categoryRows, static fn(array $row): bool => in_array((int)$row['uid'], $filledCategories, true)));
|
||||
|
||||
$categories = [];
|
||||
foreach ($categoryRows as $c => $categoryRow) {
|
||||
$groups = [];
|
||||
@@ -119,9 +132,9 @@ final class ProvideProductCatalog
|
||||
}
|
||||
$items[] = $item;
|
||||
}
|
||||
$groups[] = ['title' => (string)$groupRow['title'], 'items' => $items];
|
||||
$groups[] = ['title' => (string)$groupRow['title'], 'link' => $this->sectionLink($groupRow), 'items' => $items];
|
||||
}
|
||||
$categories[] = ['title' => (string)$categoryRow['title'], 'groups' => $groups];
|
||||
$categories[] = ['title' => (string)$categoryRow['title'], 'link' => $this->sectionLink($categoryRow), 'groups' => $groups];
|
||||
}
|
||||
|
||||
// shortcuts: target = a group of this tree (opens that group) or an
|
||||
@@ -159,6 +172,12 @@ final class ProvideProductCatalog
|
||||
return $categories;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $row category or sub-category page row; '' while it is a folder */
|
||||
private function sectionLink(array $row): string
|
||||
{
|
||||
return (int)$row['doktype'] === self::DOKTYPE_PAGE ? (string)$row['slug'] : '';
|
||||
}
|
||||
|
||||
private function productsRootUid(): int
|
||||
{
|
||||
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('pages');
|
||||
@@ -173,7 +192,9 @@ final class ProvideProductCatalog
|
||||
|
||||
/**
|
||||
* Visible child pages (frontend restrictions: hidden/deleted/start/end),
|
||||
* nav_hide respected, in page tree order.
|
||||
* nav_hide respected, in page tree order. `title` is the menu label:
|
||||
* nav_title when set (the adopted 2026-10 category pages carry the
|
||||
* sitemap name there, their page title stays the SEO/H1 one).
|
||||
*
|
||||
* @param int[] $parents
|
||||
* @param int[] $doktypes
|
||||
@@ -185,7 +206,7 @@ final class ProvideProductCatalog
|
||||
return [];
|
||||
}
|
||||
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('pages');
|
||||
return $queryBuilder->select('uid', 'pid', 'title', 'slug', 'doktype', 'shortcut')->from('pages')
|
||||
$rows = $queryBuilder->select('uid', 'pid', 'title', 'nav_title', 'slug', 'doktype', 'shortcut')->from('pages')
|
||||
->where(
|
||||
$queryBuilder->expr()->in('pid', $queryBuilder->createNamedParameter(array_map('intval', $parents), ArrayParameterType::INTEGER)),
|
||||
$queryBuilder->expr()->in('doktype', $queryBuilder->createNamedParameter($doktypes, ArrayParameterType::INTEGER)),
|
||||
@@ -194,6 +215,12 @@ final class ProvideProductCatalog
|
||||
)
|
||||
->orderBy('pid')->addOrderBy('sorting')
|
||||
->executeQuery()->fetchAllAssociative();
|
||||
foreach ($rows as &$row) {
|
||||
if (trim((string)($row['nav_title'] ?? '')) !== '') {
|
||||
$row['title'] = $row['nav_title'];
|
||||
}
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Evomedien\Vitec\Middleware;
|
||||
|
||||
use Evomedien\Vitec\Service\SsrFlagService;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
|
||||
/**
|
||||
* Self-healing sync of the SSR routing flag (see SsrFlagService).
|
||||
*
|
||||
* Registered for BOTH stacks: backend, so flipping the Site Set switch
|
||||
* takes effect with the very next backend request (the module reload
|
||||
* right after saving), and frontend, so /_json traffic repairs the flag
|
||||
* even if nobody is logged into the backend. When flag and setting
|
||||
* agree - the normal case - this costs a single file_exists() per
|
||||
* request.
|
||||
*/
|
||||
final class SyncSsrFlag implements MiddlewareInterface
|
||||
{
|
||||
public function __construct(private readonly SsrFlagService $ssrFlagService)
|
||||
{
|
||||
}
|
||||
|
||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||
{
|
||||
$this->ssrFlagService->syncQuietly();
|
||||
return $handler->handle($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Evomedien\Vitec\Service;
|
||||
|
||||
use TYPO3\CMS\Core\Core\Environment;
|
||||
use TYPO3\CMS\Core\Exception\SiteNotFoundException;
|
||||
use TYPO3\CMS\Core\Site\SiteFinder;
|
||||
|
||||
/**
|
||||
* Keeps the SSR routing flag file in sync with the site setting
|
||||
* "vitec.ssr.enabled" (Site Set switch, editable in the backend under
|
||||
* Site Management > Settings).
|
||||
*
|
||||
* The flag file is what public/.htaccess checks per request (RewriteCond
|
||||
* ... -f): if it exists, page requests are proxied to the SSR node on the
|
||||
* Solr VPS; if not, the SPA fallback serves _frontend/index.html as
|
||||
* before. TYPO3 itself never routes differently - the flag only steers
|
||||
* Apache, which is why a plain file is used: zero runtime cost in the
|
||||
* hot path and trivially inspectable on the server.
|
||||
*
|
||||
* Saving site settings in the TYPO3 v14 backend fires NO event (core
|
||||
* SiteWriter::writeSettings dispatches nothing, checked 2026-11-09), so
|
||||
* the sync runs self-healingly from the SyncSsrFlag middleware on every
|
||||
* frontend and backend request - a mismatch costs one file operation
|
||||
* once, agreement costs a file_exists(). The vitec:ssr-flag CLI command
|
||||
* offers the same sync plus a status view.
|
||||
*/
|
||||
final class SsrFlagService
|
||||
{
|
||||
public function __construct(private readonly SiteFinder $siteFinder)
|
||||
{
|
||||
}
|
||||
|
||||
public function getFlagPath(): string
|
||||
{
|
||||
return Environment::getVarPath() . '/vitec-ssr-on.flag';
|
||||
}
|
||||
|
||||
/**
|
||||
* True as soon as ANY site enables the setting (there is only the
|
||||
* "vitec" site; iterating keeps us independent of the identifier).
|
||||
*/
|
||||
public function isEnabledInSettings(): bool
|
||||
{
|
||||
try {
|
||||
foreach ($this->siteFinder->getAllSites() as $site) {
|
||||
if ((bool)$site->getSettings()->get('vitec.ssr.enabled', false)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (SiteNotFoundException) {
|
||||
// No sites configured - treat as disabled.
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public function isFlagSet(): bool
|
||||
{
|
||||
return is_file($this->getFlagPath());
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates or removes the flag file so it matches the setting.
|
||||
*
|
||||
* @return bool|null The new state if something changed, null if the
|
||||
* flag already matched the setting.
|
||||
*/
|
||||
public function sync(): ?bool
|
||||
{
|
||||
$desired = $this->isEnabledInSettings();
|
||||
if ($desired === $this->isFlagSet()) {
|
||||
return null;
|
||||
}
|
||||
if ($desired) {
|
||||
file_put_contents(
|
||||
$this->getFlagPath(),
|
||||
"SSR routing is ON (site setting vitec.ssr.enabled).\n"
|
||||
. "Managed by SsrFlagService - do not create/delete manually,\n"
|
||||
. "flip the switch in Site Management > Settings instead.\n"
|
||||
. 'Last sync: ' . date('c') . "\n"
|
||||
);
|
||||
} else {
|
||||
@unlink($this->getFlagPath());
|
||||
}
|
||||
return $desired;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync variant for the request hot path: never lets an IO hiccup
|
||||
* (read-only FS, concurrent unlink ...) break request handling.
|
||||
*/
|
||||
public function syncQuietly(): void
|
||||
{
|
||||
try {
|
||||
$this->sync();
|
||||
} catch (\Throwable) {
|
||||
// Intentionally silent - next request retries.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,8 +77,10 @@ final class ProductMenuSearchJsonRenderer
|
||||
}
|
||||
|
||||
/**
|
||||
* The overview pages of the products menu tree (/products > folder >
|
||||
* folder > page, see ProvideProductCatalog) with their menu path.
|
||||
* The overview pages of the products menu tree (/products > category >
|
||||
* sub-category > page, see ProvideProductCatalog) with their menu path.
|
||||
* Category and sub-category are pages since 2026-11-05 (folders before -
|
||||
* both are read).
|
||||
*
|
||||
* @return array<int,array{uid:int,title:string,link:string,path:string[]}> page uid => overview
|
||||
*/
|
||||
@@ -96,7 +98,7 @@ final class ProductMenuSearchJsonRenderer
|
||||
return [];
|
||||
}
|
||||
$queryBuilder = $pool->getQueryBuilderForTable('pages');
|
||||
return $queryBuilder->select('uid', 'pid', 'title', 'slug')->from('pages')
|
||||
$rows = $queryBuilder->select('uid', 'pid', 'title', 'nav_title', 'slug')->from('pages')
|
||||
->where(
|
||||
$queryBuilder->expr()->in('pid', $queryBuilder->createNamedParameter($parents, ArrayParameterType::INTEGER)),
|
||||
$queryBuilder->expr()->in('doktype', $queryBuilder->createNamedParameter($doktypes, ArrayParameterType::INTEGER)),
|
||||
@@ -104,9 +106,16 @@ final class ProductMenuSearchJsonRenderer
|
||||
)
|
||||
->orderBy('sorting')
|
||||
->executeQuery()->fetchAllAssociative();
|
||||
// menu label = nav_title when set (same rule as ProvideProductCatalog)
|
||||
foreach ($rows as &$row) {
|
||||
if (trim((string)($row['nav_title'] ?? '')) !== '') {
|
||||
$row['title'] = $row['nav_title'];
|
||||
}
|
||||
}
|
||||
return $rows;
|
||||
};
|
||||
$categories = array_column($level([$root], [254]), null, 'uid');
|
||||
$groups = array_column($level(array_keys($categories), [254]), null, 'uid');
|
||||
$categories = array_column($level([$root], [1, 254]), null, 'uid');
|
||||
$groups = array_column($level(array_keys($categories), [1, 254]), null, 'uid');
|
||||
$overviews = [];
|
||||
foreach ($level(array_keys($groups), [1]) as $page) {
|
||||
$group = $groups[(int)$page['pid']];
|
||||
|
||||
@@ -46,5 +46,23 @@ return [
|
||||
'typo3/cms-frontend/site',
|
||||
],
|
||||
],
|
||||
// Self-healing sync of the SSR routing flag file against the
|
||||
// Site Set switch vitec.ssr.enabled (no core event fires on
|
||||
// settings save, hence per-request sync; cheap file_exists when
|
||||
// nothing changed). Also registered for the backend below.
|
||||
'vitec/sync-ssr-flag' => [
|
||||
'target' => \Evomedien\Vitec\Middleware\SyncSsrFlag::class,
|
||||
'after' => [
|
||||
'typo3/cms-core/normalized-params-attribute',
|
||||
],
|
||||
],
|
||||
],
|
||||
'backend' => [
|
||||
'vitec/sync-ssr-flag' => [
|
||||
'target' => \Evomedien\Vitec\Middleware\SyncSsrFlag::class,
|
||||
'after' => [
|
||||
'typo3/cms-core/normalized-params-attribute',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
@@ -132,3 +132,10 @@ settings:
|
||||
category: seo
|
||||
type: string
|
||||
default: ''
|
||||
|
||||
vitec.ssr.enabled:
|
||||
label: 'SSR delivery (serve pages from the SSR node)'
|
||||
description: 'ON: page requests are proxied by .htaccess to the SSR server (React Router SSR on the Solr VPS) - the flag file var/vitec-ssr-on.flag is kept in sync automatically within seconds. OFF: pages are served as today (SPA shell from _frontend/). JSON under /_json, /typo3, /api etc. are never affected. Safe rollback switch.'
|
||||
category: VITEC
|
||||
type: bool
|
||||
default: false
|
||||
@@ -331,6 +331,17 @@ tt_content {
|
||||
page.10.fields.jsonLd = USER
|
||||
page.10.fields.jsonLd.userFunc = Evomedien\Vitec\UserFunc\PageJsonLdRenderer->render
|
||||
|
||||
# =============================================================================
|
||||
# No browser caching of the page JSON (2026-11-05). EXT:headless sets
|
||||
# page.config.sendCacheHeaders = 1, and TYPO3 14 caches a page for ONE YEAR
|
||||
# by default (CacheLifetimeCalculator::defaultCacheTimeout) - every /_json/
|
||||
# response went out with "Cache-Control: max-age=~31536000", so browsers
|
||||
# kept an old menu/page for up to a year (found when the clickable product
|
||||
# categories did not show up). The server-side page cache is unaffected;
|
||||
# without the header, .htaccess sends application/json "access plus 0 seconds".
|
||||
# =============================================================================
|
||||
page.config.sendCacheHeaders = 0
|
||||
|
||||
############################################################
|
||||
# Frontend resolution for the record links from the link
|
||||
# browser (page.tsconfig TCEMAIN.linkHandler):
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* VITEC meaning of the megamenu's data-source fields (2026-11-05, ticket
|
||||
* 0046-FE): the Insights spotlight (ProvideInsightsSpotlight) reads its
|
||||
* "Latest news" cards from these folders and categories.
|
||||
*/
|
||||
|
||||
defined('TYPO3') or die();
|
||||
|
||||
if (isset($GLOBALS['TCA']['tx_evomegamenujson_entry']['columns']['source_pages'])) {
|
||||
$GLOBALS['TCA']['tx_evomegamenujson_entry']['columns']['source_pages']['description']
|
||||
= 'Insights: news folders for the "Latest news" cards. Empty = 11 + 442 (like the /insights/news list).';
|
||||
$GLOBALS['TCA']['tx_evomegamenujson_entry']['columns']['source_categories']['description']
|
||||
= 'Insights: only news in one of these categories (e.g. Blog Post, Press Release). Empty = all categories.';
|
||||
}
|
||||
@@ -3,9 +3,9 @@
|
||||
| | |
|
||||
|---|---|
|
||||
| **Document identifier** | EVO‑VITEC‑HL‑001 |
|
||||
| **Version** | 1.30 |
|
||||
| **Version** | 1.32 |
|
||||
| **Status** | Released |
|
||||
| **Date** | 2026‑11‑02 |
|
||||
| **Date** | 2026‑11‑05 |
|
||||
| **Applies to** | `evomedien/vitec` on TYPO3 v14.3 (headless) |
|
||||
| **Owner** | evomedien — VITEC relaunch |
|
||||
|
||||
@@ -43,6 +43,8 @@
|
||||
| 1.28 | 2026‑11‑02 | **Interface change (breaking for the products menu, additive elsewhere).** Product section rebuilt after the agency sitemap/menu prototype of 2026-11-02: 20 product overview pages `/products/<slug>` grouped by sysfolders (3 categories → 9 groups) below /products. The megamenu `catalog` payload is now **version 2**: `categories[].groups[].items[]` of type `page` (overview page with `productCount`/`products`/`image`) or `shortcut` (`target` indices), plus `viewAll` and `news`; the menu no longer lists products. Transitional v1 fields (`groups[].products` etc.) stay until the front end has switched. The product type-ahead (`?type=7385`) gained `overviews` and a per-product `overview` with menu path. |
|
||||
| 1.29 | 2026‑11‑02 | **Interface change (removal within the unreleased catalog v2).** `catalog.news` dropped (decision: no automatic news in the products menu). Both promo boxes of the products panel, "Product highlight" and "Product news", are the entry's editorial `teasers` (`kicker` = box label). Megamenu caching: pages carrying the menu are now tagged with the menu tables, so saving menu records refreshes every page; data read from the page tree still needs a cache flush. |
|
||||
| 1.30 | 2026‑11‑02 | **Interface change (additive).** Megamenu `pagetree` entries carry `display` (`columns` \| `rail`) and, for `rail`, `cardSize` (`regular` \| `compact`): Markets and Solutions switch to a left list with the sub-pages as image cards on hover. Page-tree items gained `image` (page media) and `teaser` (abstract) from stage 2; the sub-solution pages received their hero background as page media (sub-markets had page media already). |
|
||||
| 1.31 | 2026‑11‑05 | **No interface change (data source).** Megamenu Insights `spotlight.news` read only news folder 11; the newer posts live in 442 (ticket 0046-FE). Catalog and spotlight entries gained the editor fields *Data source folders* and *Data source categories*; the Insights listener reads news from them (empty = 11 + 442, no category filter), newest `datetime` first, three cards. Pages carrying the menu are tagged with the news and event tables, so a saved news or event record refreshes the menu. Contract unchanged. |
|
||||
| 1.32 | 2026‑11‑05 | **Interface change (additive).** Products menu: categories and sub-categories are clickable pages (were sysfolders/labels): `categories[].link` and `groups[].link`. Six sub-categories are the 2026-10 category pages (content/slug/SEO kept, sitemap name as nav_title = menu `title`); 3 categories, End-Points, RF Gateway and Capture & Graphics (content of Graphics & Multi-Display + Video Capture) are new pages; Graphics & Multi-Display, Video Capture and Infrastructure & Accessories left the menus (reachable). Breadcrumbs run through category and sub-category. Spec text on the removed v1.28 transitional fields corrected. **Caching:** `/_json/` responses no longer carry browser cache headers (`page.config.sendCacheHeaders = 0`) - EXT:headless enables them and TYPO3 14's default page lifetime of one year made browsers keep every JSON response up to a year; front ends should request with `cache: 'no-cache'` to get past copies cached before. |
|
||||
|
||||
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
|
||||
@@ -1210,20 +1212,21 @@ tile or `null`.
|
||||
**Products menu (megamenu entry `layout: "catalog"`, version 2 since v1.28).**
|
||||
The catalog payload is produced by the `ProvideProductCatalog` listener of
|
||||
EXT:evo_megamenu_json’s `BuildCatalogEvent` from the page tree below
|
||||
`/products`: sysfolders on the first level are the menu **categories**,
|
||||
sysfolders below them the **groups** (both labels, never pages), and the
|
||||
pages below the groups the **overview pages** (`/products/<slug>`, built by
|
||||
`/products`: the pages on the first level are the menu **categories**, the
|
||||
pages below them the **groups** (sub-categories), and the pages below the
|
||||
groups the **overview pages** (`/products/<slug>`, built by
|
||||
`vitec:build-product-overviews` from the agency sitemap of 2026-11-02) or
|
||||
**shortcut** pages. The menu ends at the overview pages; product detail
|
||||
pages are reached from there.
|
||||
**shortcut** pages. Since v1.32 categories and groups are clickable pages
|
||||
with `link` (they were sysfolders/labels before); product detail pages are
|
||||
reached from the overview pages.
|
||||
|
||||
```json
|
||||
"catalog": {
|
||||
"version": 2,
|
||||
"search": { "endpoint": "/_json/?type=7385", "param": "q", "minChars": 2, "placeholder": "Search products" },
|
||||
"viewAll": { "title": "View all products", "link": "/products" },
|
||||
"categories": [ { "title": "IP Video Streaming, Distribution & KVM",
|
||||
"groups": [ { "title": "Encoders & Decoders",
|
||||
"categories": [ { "title": "IP Video Streaming, Distribution & KVM", "link": "/products/ip-video-streaming-distribution-and-kvm",
|
||||
"groups": [ { "title": "Encoders & Decoders", "link": "/products/video-encoding-and-streaming",
|
||||
"items": [
|
||||
{ "type": "page", "uid": 587, "title": "Appliances", "link": "/products/appliances",
|
||||
"productCount": 5, "products": [ { "title": "MGW Diamond-H", "link": "/product/…" } ],
|
||||
@@ -1233,10 +1236,18 @@ pages are reached from there.
|
||||
}
|
||||
```
|
||||
|
||||
- `categories[].link` / `groups[].link` (v1.32, additive): the category /
|
||||
sub-category page; `""` only while a level is still a folder. `title` is
|
||||
the menu label = the page's *nav_title* when set: six sub-categories are
|
||||
the 2026-10 category pages (Joe's workbooks - content, slug and SEO kept,
|
||||
e.g. "Encoders & Decoders" → `/products/video-encoding-and-streaming`,
|
||||
H1 "Video Encoding & Streaming"). Breadcrumbs run
|
||||
Home › Products › category › sub-category › overview page.
|
||||
- `items[].type` is `page` (an overview page) or `shortcut`. A shortcut's
|
||||
`target` holds zero-based indices into `categories` / `groups` / `items`:
|
||||
with `page` it points to an overview page (and `link` is that page),
|
||||
without `page` it opens a whole group (`link` is `""`).
|
||||
without `page` it opens a whole group (`link` is `""`; the group's own
|
||||
page is `groups[].link` of the target).
|
||||
- `products` are the product cards of the overview page that link a detail
|
||||
page (container children included, creation order); `productCount` is
|
||||
their number - a card without a resolvable product is not counted.
|
||||
@@ -1250,15 +1261,9 @@ pages are reached from there.
|
||||
- Hidden pages and pages with *Hide in menu* drop out; order = page tree
|
||||
order. Version 1 (2026-10-09, sys_category groups with products) is no
|
||||
longer the source.
|
||||
- **Transitional v1 fields (deprecated, removed once the front end reads
|
||||
`items`).** The deployed front end still evaluated the v1 shape for the
|
||||
active state of the Products trigger and crashed the whole site without
|
||||
it. Until it has switched, v2 additionally carries: `categories[].uid`
|
||||
(folder uid) and `categories[].link` (`/products`); `groups[].uid` (`0`),
|
||||
`groups[].link` (link of the group's first overview page) and
|
||||
`groups[].products` (the products of all its overview pages, deduplicated
|
||||
by link); and a `teaser` (card text, plain) on every product. Do not build
|
||||
on these fields.
|
||||
- The transitional v1 fields of v1.28 (`groups[].products`, `uid`s,
|
||||
product `teaser`) were removed on 2026-11-03 after the front end switched
|
||||
(0024-FE / 0029-BE). The `link` fields of v1.32 are new and permanent.
|
||||
|
||||
**Markets and Solutions menus (megamenu `layout: "pagetree"`, `display`
|
||||
since v1.30).** Page-tree entries build their `columns` live from the
|
||||
@@ -1705,4 +1710,4 @@ remediation.
|
||||
- Header convention — the uniform header section across CEs, plugins and containers.
|
||||
- `Configuration/Sets/Vitecset/setup.typoscript` — the single TypoScript entry point.
|
||||
|
||||
*End of document EVO‑VITEC‑HL‑001 v1.30.*
|
||||
*End of document EVO‑VITEC‑HL‑001 v1.32.*
|
||||
@@ -345,6 +345,29 @@ AddDefaultCharset utf-8
|
||||
RewriteCond %{QUERY_STRING} __typo3_install
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteRule ^.*$ %{ENV:CWD}index.php [QSA,L]
|
||||
# --- VITEC SSR routing (flag-gated) -----------------------------------
|
||||
# Active ONLY while the flag file below exists. Mirrors the SPA
|
||||
# fallback's exclusions but PROXIES page requests to the SSR node on
|
||||
# the Solr VPS instead of serving the React shell (mod_proxy incl.
|
||||
# https target verified on this host 2026-11-09, see Nextcloud
|
||||
# vitec-solr/ssr/README-SSR-Setup.md). The flag is written/removed by
|
||||
# EXT:vitec (Site Set switch "vitec.ssr.enabled" -> SsrFlagService,
|
||||
# synced by the SyncSsrFlag middleware / vitec:ssr-flag command).
|
||||
# Emergency off without TYPO3: delete the flag file.
|
||||
RewriteCond /usr/www/users/vitecevo/live/var/vitec-ssr-on.flag -f
|
||||
RewriteRule ^$ https://vitec-ssr.evomedien.de/ [P,L]
|
||||
RewriteCond /usr/www/users/vitecevo/live/var/vitec-ssr-on.flag -f
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteCond %{REQUEST_FILENAME} !-l
|
||||
RewriteCond %{REQUEST_URI} !^/_json(/|$)
|
||||
RewriteCond %{REQUEST_URI} !^/typo3(/|$)
|
||||
RewriteCond %{REQUEST_URI} !^/download/
|
||||
RewriteCond %{REQUEST_URI} !^/api/
|
||||
RewriteCond %{REQUEST_URI} !^/_frontend/
|
||||
RewriteCond %{REQUEST_URI} !^/sitemap
|
||||
RewriteRule ^(.*)$ https://vitec-ssr.evomedien.de/$1 [P,L]
|
||||
# ----------------------------------------------------------------------
|
||||
# The root path needs its own rule: for "/" REQUEST_FILENAME is the
|
||||
# docroot DIRECTORY, so the !-d guard below skips it and DirectoryIndex
|
||||
# would hand it to TYPO3 (JSON). The homepage JSON lives at /_json/.
|
||||
|
||||
Reference in new issue
Block a user