Compare commits
11 Commits
d846ba2007
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 55f7f82a42 | |||
| c90649fc1e | |||
| 8c881d153c | |||
| 5bb4e374b4 | |||
| e9eaa62d89 | |||
| c91063d6f3 | |||
| 4a49196bcc | |||
| 5aad13e635 | |||
| 5499ca54af | |||
| 7833e1b668 | |||
| 7442d10e68 |
@@ -18,6 +18,7 @@
|
||||
"apache-solr-for-typo3/solr": "^14.0@RC",
|
||||
"b13/container": "^3.1",
|
||||
"b13/typo3-updater": "^1.1",
|
||||
"evomedien/evo-megamenu-json": "^1.0.0",
|
||||
"evomedien/vitec": "^1.0.1",
|
||||
"friendsoftypo3/content-blocks": "^2.4",
|
||||
"friendsoftypo3/headless": "^5.0@rc",
|
||||
|
||||
1013
composer.lock
generated
1013
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,6 @@ dependencies:
|
||||
- typo3/form
|
||||
- typo3/seo-sitemap
|
||||
- friendsoftypo3/headless
|
||||
- friendsoftypo3/headless-mixed
|
||||
- nb-headless-content-blocks/headless-content-blocks
|
||||
- evomedien/vitecset
|
||||
- vitec/content-blocks-bundle
|
||||
@@ -24,6 +23,7 @@ dependencies:
|
||||
- apache-solr-for-typo3/solr-open-search
|
||||
- apache-solr-for-typo3/solr-stylesheets
|
||||
- apache-solr-for-typo3/solr-bootstrap-css
|
||||
- evomedien/megamenu
|
||||
frontendBase: ''
|
||||
headless: 1
|
||||
languages:
|
||||
|
||||
@@ -2,3 +2,5 @@ vitec.debugMode: true
|
||||
my.example.setting: Test
|
||||
menu.footer.pageUids: '1,2,3'
|
||||
menu.meta.pageUids: '5'
|
||||
megamenu.contentUid: 10366
|
||||
vitec.storyDetailPid: 26
|
||||
|
||||
@@ -16,9 +16,15 @@ set -u
|
||||
cd "$(dirname "$0")" || exit 1
|
||||
|
||||
echo "[perms] Project root: $(pwd)"
|
||||
echo "[perms] Setting files 644 / directories 755 under Resources/Public + _assets ..."
|
||||
find public/_assets public/fileadmin/icons packages/vitec/Resources/Public -type f -exec chmod 644 {} \; 2>/dev/null
|
||||
find public/_assets public/fileadmin/icons packages/vitec/Resources/Public -type d -exec chmod 755 {} \; 2>/dev/null
|
||||
# public/fileadmin as a whole, not just icons: every editorial upload and every
|
||||
# importer output lands there (success_stories_import, blog-import, downloads,
|
||||
# user_upload/products ...) and hits the same 403 otherwise. Found 2026-09-11,
|
||||
# when the imported success-story images stayed invisible in the megamenu.
|
||||
TARGETS="public/_assets public/fileadmin packages/vitec/Resources/Public"
|
||||
|
||||
echo "[perms] Setting files 644 / directories 755 under $TARGETS ..."
|
||||
find $TARGETS -type f -exec chmod 644 {} \; 2>/dev/null
|
||||
find $TARGETS -type d -exec chmod 755 {} \; 2>/dev/null
|
||||
|
||||
echo "[perms] Flushing TYPO3 caches ..."
|
||||
if [ -x vendor/bin/typo3 ]; then
|
||||
|
||||
75
migrations/apply_seo_meta.php
Normal file
75
migrations/apply_seo_meta.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Fill empty seotitle/seometa product fields from a scraped mapping file
|
||||
* (migrations/seo_meta_vitec.json - source: the live vitec.com product
|
||||
* pages, 2026-09-04). Fill-up ONLY: fields that already hold a value are
|
||||
* never overwritten.
|
||||
*
|
||||
* Dry run: php migrations/apply_seo_meta.php
|
||||
* Write: php migrations/apply_seo_meta.php --apply
|
||||
*/
|
||||
|
||||
$apply = in_array('--apply', $argv, true);
|
||||
|
||||
$root = null;
|
||||
$dir = __DIR__;
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
if (is_file($dir . '/config/system/settings.php')) {
|
||||
$root = $dir;
|
||||
break;
|
||||
}
|
||||
$dir = dirname($dir);
|
||||
}
|
||||
if ($root === null) {
|
||||
exit("Could not locate project root above " . __DIR__ . "\n");
|
||||
}
|
||||
|
||||
$map = json_decode((string)file_get_contents(__DIR__ . '/seo_meta_vitec.json'), true);
|
||||
if (!is_array($map)) {
|
||||
exit("seo_meta_vitec.json missing or invalid.\n");
|
||||
}
|
||||
unset($map['_quelle']);
|
||||
|
||||
$settings = require $root . '/config/system/settings.php';
|
||||
$db = $settings['DB']['Connections']['Default'];
|
||||
$pdo = new PDO(
|
||||
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''),
|
||||
$db['user'] ?? '',
|
||||
$db['password'] ?? '',
|
||||
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
|
||||
);
|
||||
|
||||
$select = $pdo->prepare("SELECT uid, title, seotitle, seometa FROM tx_vitec_domain_model_product WHERE deleted = 0 AND slug = ?");
|
||||
$update = $pdo->prepare("UPDATE tx_vitec_domain_model_product SET seotitle = ?, seometa = ?, tstamp = ? WHERE uid = ?");
|
||||
|
||||
$written = 0;
|
||||
foreach ($map as $slug => $meta) {
|
||||
$select->execute([$slug]);
|
||||
$product = $select->fetch();
|
||||
if (!$product) {
|
||||
echo "FEHLT $slug - kein Produkt\n";
|
||||
continue;
|
||||
}
|
||||
$newTitle = trim((string)$product['seotitle']) === '' ? trim((string)($meta['seotitle'] ?? '')) : (string)$product['seotitle'];
|
||||
$newMeta = trim((string)$product['seometa']) === '' ? trim((string)($meta['seometa'] ?? '')) : (string)$product['seometa'];
|
||||
$changes = [];
|
||||
if ($newTitle !== (string)$product['seotitle']) {
|
||||
$changes[] = 'seotitle';
|
||||
}
|
||||
if ($newMeta !== (string)$product['seometa']) {
|
||||
$changes[] = 'seometa';
|
||||
}
|
||||
if ($changes === []) {
|
||||
echo "ok {$product['title']} - beide Felder bereits gefuellt oder keine Daten\n";
|
||||
continue;
|
||||
}
|
||||
printf("%s %s (uid %d): %s\n", $apply ? 'WRITE ' : 'plan ', $product['title'], $product['uid'], implode(' + ', $changes));
|
||||
if ($apply) {
|
||||
$update->execute([$newTitle, $newMeta, time(), (int)$product['uid']]);
|
||||
}
|
||||
$written++;
|
||||
}
|
||||
printf("\n%d Produkt(e) %s.%s\n", $written, $apply ? 'geschrieben' : 'geplant', $apply ? ' Cache leeren: vendor/bin/typo3 cache:flush' : ' Mit --apply schreiben.');
|
||||
23
migrations/check_dup_solution_pages.php
Normal file
23
migrations/check_dup_solution_pages.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/** Read-only: show the duplicate solution page branches (2026-09-04). */
|
||||
$root = null; $dir = __DIR__;
|
||||
for ($i = 0; $i < 5; $i++) { if (is_file($dir . '/config/system/settings.php')) { $root = $dir; break; } $dir = dirname($dir); }
|
||||
if ($root === null) { exit("no root\n"); }
|
||||
$s = require $root . '/config/system/settings.php';
|
||||
$db = $s['DB']['Connections']['Default'];
|
||||
$pdo = new PDO(sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''), $db['user'] ?? '', $db['password'] ?? '', [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]);
|
||||
$uids = [481,482,483,484,485,486,487,488,489,490,491,492,493,494,495,496,497,498,499,500];
|
||||
$rows = $pdo->query('SELECT uid, pid, title, slug, hidden, crdate, sorting FROM pages WHERE uid IN (' . implode(',', $uids) . ') ORDER BY pid, sorting')->fetchAll();
|
||||
$parents = [];
|
||||
foreach ($rows as $r) { $parents[(int)$r['pid']] = true; }
|
||||
foreach (array_keys($parents) as $pid) {
|
||||
$p = $pdo->query("SELECT uid, pid, title, slug, hidden, crdate FROM pages WHERE uid = $pid")->fetch();
|
||||
printf("ELTERN %d | pid %d | %s | /%s | %s| angelegt %s\n", $p['uid'], $p['pid'], $p['title'], trim((string)$p['slug'],'/'), $p['hidden'] ? 'HIDDEN ' : '', date('Y-m-d H:i', (int)$p['crdate']));
|
||||
// Kinderzahl + Inhalt vorhanden?
|
||||
foreach ($rows as $r) {
|
||||
if ((int)$r['pid'] !== (int)$p['uid']) { continue; }
|
||||
$ce = (int)$pdo->query('SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND pid = ' . (int)$r['uid'])->fetchColumn();
|
||||
printf(" %d | %-42s | /%s | %s| %d CE | angelegt %s\n", $r['uid'], $r['title'], trim((string)$r['slug'],'/'), $r['hidden'] ? 'HIDDEN ' : '', $ce, date('Y-m-d H:i', (int)$r['crdate']));
|
||||
}
|
||||
}
|
||||
51
migrations/check_megamenu_schema.php
Normal file
51
migrations/check_megamenu_schema.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Read-only: is the evo_megamenu_json schema in place?
|
||||
* Checks the four menu tables and the inline column on tt_content.
|
||||
*
|
||||
* Usage: php migrations/check_megamenu_schema.php
|
||||
*/
|
||||
|
||||
$root = null;
|
||||
$dir = __DIR__;
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
if (is_file($dir . '/config/system/settings.php')) { $root = $dir; break; }
|
||||
$dir = dirname($dir);
|
||||
}
|
||||
if ($root === null) { exit("Could not locate project root above " . __DIR__ . "\n"); }
|
||||
|
||||
$settings = require $root . '/config/system/settings.php';
|
||||
$db = $settings['DB']['Connections']['Default'];
|
||||
$pdo = new PDO(
|
||||
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''),
|
||||
$db['user'] ?? '', $db['password'] ?? '',
|
||||
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
|
||||
);
|
||||
|
||||
$tables = [
|
||||
'tx_evomegamenujson_entry',
|
||||
'tx_evomegamenujson_column',
|
||||
'tx_evomegamenujson_item',
|
||||
'tx_evomegamenujson_teaser',
|
||||
];
|
||||
$missing = 0;
|
||||
foreach ($tables as $table) {
|
||||
$exists = (bool)$pdo->query("SHOW TABLES LIKE " . $pdo->quote($table))->fetchColumn();
|
||||
$count = $exists ? (int)$pdo->query("SELECT COUNT(*) FROM `$table`")->fetchColumn() : 0;
|
||||
printf("%-32s %s%s\n", $table, $exists ? 'OK' : 'FEHLT', $exists ? " ($count Datensaetze)" : '');
|
||||
$missing += $exists ? 0 : 1;
|
||||
}
|
||||
|
||||
$col = $pdo->query("SHOW COLUMNS FROM tt_content LIKE 'tx_evomegamenujson_entries'")->fetch();
|
||||
printf("%-32s %s\n", 'tt_content.tx_..._entries', $col ? 'OK' : 'FEHLT');
|
||||
$missing += $col ? 0 : 1;
|
||||
|
||||
$ce = (int)$pdo->query("SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND CType = 'evomegamenujson_megamenu'")->fetchColumn();
|
||||
printf("%-32s %d\n", 'Megamenu-Elemente angelegt', $ce);
|
||||
|
||||
echo "\n" . ($missing === 0
|
||||
? "Schema vollstaendig - Plugin kann angelegt werden.\n"
|
||||
: "$missing Objekt(e) fehlen - Schema-Update noetig.\n");
|
||||
110
migrations/check_product_structure.php
Normal file
110
migrations/check_product_structure.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Read-only: dump the current product structure - the sys_category tree and
|
||||
* every product record with its category relations - to compare against the
|
||||
* agency's "Merged site map" V2 (Products tab, columns J/K/L).
|
||||
*
|
||||
* Usage: php migrations/check_product_structure.php
|
||||
*/
|
||||
|
||||
$root = null;
|
||||
$dir = __DIR__;
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
if (is_file($dir . '/config/system/settings.php')) {
|
||||
$root = $dir;
|
||||
break;
|
||||
}
|
||||
$dir = dirname($dir);
|
||||
}
|
||||
if ($root === null) {
|
||||
exit("Could not locate project root above " . __DIR__ . "\n");
|
||||
}
|
||||
|
||||
$settings = require $root . '/config/system/settings.php';
|
||||
$db = $settings['DB']['Connections']['Default'];
|
||||
$pdo = new PDO(
|
||||
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''),
|
||||
$db['user'] ?? '',
|
||||
$db['password'] ?? '',
|
||||
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
|
||||
);
|
||||
|
||||
// --- sys_category tree ------------------------------------------------------
|
||||
$cats = $pdo->query(
|
||||
"SELECT uid, parent, title, hidden, sorting FROM sys_category WHERE deleted = 0 ORDER BY parent, sorting"
|
||||
)->fetchAll();
|
||||
$byParent = [];
|
||||
foreach ($cats as $cat) {
|
||||
$byParent[(int)$cat['parent']][] = $cat;
|
||||
}
|
||||
|
||||
// product count per category (MM to our product table)
|
||||
$counts = [];
|
||||
foreach ($pdo->query(
|
||||
"SELECT mm.uid_local AS cat, COUNT(*) AS n
|
||||
FROM sys_category_record_mm mm
|
||||
JOIN tx_vitec_domain_model_product p ON p.uid = mm.uid_foreign AND p.deleted = 0
|
||||
WHERE mm.tablenames = 'tx_vitec_domain_model_product' AND mm.fieldname = 'categories'
|
||||
GROUP BY mm.uid_local"
|
||||
) as $row) {
|
||||
$counts[(int)$row['cat']] = (int)$row['n'];
|
||||
}
|
||||
|
||||
echo "=== sys_category tree (uid | title | hidden? | #products) ===\n";
|
||||
$printTree = function (int $parent, int $depth) use (&$printTree, $byParent, $counts): void {
|
||||
foreach ($byParent[$parent] ?? [] as $cat) {
|
||||
$uid = (int)$cat['uid'];
|
||||
printf(
|
||||
"%s%d | %s%s%s\n",
|
||||
str_repeat(' ', $depth),
|
||||
$uid,
|
||||
$cat['title'],
|
||||
$cat['hidden'] ? ' | HIDDEN' : '',
|
||||
isset($counts[$uid]) ? ' | ' . $counts[$uid] . ' products' : ''
|
||||
);
|
||||
$printTree($uid, $depth + 1);
|
||||
}
|
||||
};
|
||||
$printTree(0, 0);
|
||||
|
||||
// --- products with their categories ----------------------------------------
|
||||
$products = $pdo->query(
|
||||
"SELECT uid, title, slug, hidden, legacy, subproduct, supportproduct
|
||||
FROM tx_vitec_domain_model_product WHERE deleted = 0 ORDER BY title"
|
||||
)->fetchAll();
|
||||
|
||||
$catTitles = [];
|
||||
foreach ($cats as $cat) {
|
||||
$catTitles[(int)$cat['uid']] = $cat['title'];
|
||||
}
|
||||
$prodCats = [];
|
||||
foreach ($pdo->query(
|
||||
"SELECT uid_foreign AS product, uid_local AS cat
|
||||
FROM sys_category_record_mm
|
||||
WHERE tablenames = 'tx_vitec_domain_model_product' AND fieldname = 'categories'"
|
||||
) as $row) {
|
||||
$prodCats[(int)$row['product']][] = $catTitles[(int)$row['cat']] ?? ('?' . $row['cat']);
|
||||
}
|
||||
|
||||
echo "\n=== products (uid | title | slug | flags | categories) ===\n";
|
||||
foreach ($products as $p) {
|
||||
$uid = (int)$p['uid'];
|
||||
$flags = [];
|
||||
foreach (['hidden', 'legacy', 'subproduct', 'supportproduct'] as $flag) {
|
||||
if ($p[$flag]) {
|
||||
$flags[] = $flag;
|
||||
}
|
||||
}
|
||||
printf(
|
||||
"%d | %s | %s | %s | %s\n",
|
||||
$uid,
|
||||
$p['title'],
|
||||
$p['slug'],
|
||||
$flags === [] ? '-' : implode(',', $flags),
|
||||
isset($prodCats[$uid]) ? implode(' + ', $prodCats[$uid]) : 'KEINE KATEGORIE'
|
||||
);
|
||||
}
|
||||
printf("\n%d categories, %d products total\n", count($cats), count($products));
|
||||
69
migrations/check_productlist_ces.php
Normal file
69
migrations/check_productlist_ces.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Read-only: print the FlexForm settings (categories, allproducts, layout)
|
||||
* of every vitec_productlist content element, plus which of the configured
|
||||
* category uids still exist and how many products they carry.
|
||||
*
|
||||
* Usage: php migrations/check_productlist_ces.php
|
||||
*/
|
||||
|
||||
$root = null;
|
||||
$dir = __DIR__;
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
if (is_file($dir . '/config/system/settings.php')) {
|
||||
$root = $dir;
|
||||
break;
|
||||
}
|
||||
$dir = dirname($dir);
|
||||
}
|
||||
if ($root === null) {
|
||||
exit("Could not locate project root above " . __DIR__ . "\n");
|
||||
}
|
||||
|
||||
$settings = require $root . '/config/system/settings.php';
|
||||
$db = $settings['DB']['Connections']['Default'];
|
||||
$pdo = new PDO(
|
||||
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''),
|
||||
$db['user'] ?? '',
|
||||
$db['password'] ?? '',
|
||||
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
|
||||
);
|
||||
|
||||
$ces = $pdo->query(
|
||||
"SELECT uid, pid, header, hidden, pi_flexform FROM tt_content
|
||||
WHERE CType = 'vitec_productlist' AND deleted = 0 ORDER BY pid, sorting"
|
||||
)->fetchAll();
|
||||
|
||||
$flexValue = function (string $xml, string $field): string {
|
||||
if (preg_match('/<field index="settings\.' . preg_quote($field, '/') . '">.*?<value index="vDEF">(.*?)<\/value>/s', $xml, $m)) {
|
||||
return trim($m[1]);
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
foreach ($ces as $ce) {
|
||||
printf("CE %d (pid %d)%s | header: %s\n", $ce['uid'], $ce['pid'], $ce['hidden'] ? ' HIDDEN' : '', $ce['header'] ?: '-');
|
||||
$cats = $flexValue((string)$ce['pi_flexform'], 'categories');
|
||||
printf(" categories: %s | allproducts: %s | layout: %s\n",
|
||||
$cats === '' ? '(leer)' : $cats,
|
||||
$flexValue((string)$ce['pi_flexform'], 'allproducts') ?: '0',
|
||||
$flexValue((string)$ce['pi_flexform'], 'layout') ?: '-'
|
||||
);
|
||||
foreach (array_filter(array_map('intval', explode(',', $cats))) as $catUid) {
|
||||
$cat = $pdo->query("SELECT title, deleted, hidden FROM sys_category WHERE uid = " . $catUid)->fetch();
|
||||
$count = (int)$pdo->query(
|
||||
"SELECT COUNT(DISTINCT p.uid) FROM sys_category_record_mm mm
|
||||
JOIN tx_vitec_domain_model_product p ON p.uid = mm.uid_foreign AND p.deleted = 0
|
||||
WHERE mm.uid_local = $catUid AND mm.tablenames = 'tx_vitec_domain_model_product' AND mm.fieldname = 'categories'"
|
||||
)->fetchColumn();
|
||||
printf(" cat %d: %s | %d products\n",
|
||||
$catUid,
|
||||
$cat ? ($cat['title'] . ($cat['deleted'] ? ' [DELETED]' : '') . ($cat['hidden'] ? ' [HIDDEN]' : '')) : 'EXISTIERT NICHT',
|
||||
$count
|
||||
);
|
||||
}
|
||||
}
|
||||
echo count($ces) . " productlist elements total\n";
|
||||
50
migrations/check_solutions.php
Normal file
50
migrations/check_solutions.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Read-only: dump every solution record (tx_vitec_domain_model_solution) -
|
||||
* for the comparison against the "Generic Solutions" tab of the merged
|
||||
* sitemap (2026-09-04).
|
||||
*
|
||||
* Usage: php migrations/check_solutions.php
|
||||
*/
|
||||
|
||||
$root = null;
|
||||
$dir = __DIR__;
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
if (is_file($dir . '/config/system/settings.php')) {
|
||||
$root = $dir;
|
||||
break;
|
||||
}
|
||||
$dir = dirname($dir);
|
||||
}
|
||||
if ($root === null) {
|
||||
exit("Could not locate project root above " . __DIR__ . "\n");
|
||||
}
|
||||
|
||||
$settings = require $root . '/config/system/settings.php';
|
||||
$db = $settings['DB']['Connections']['Default'];
|
||||
$pdo = new PDO(
|
||||
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''),
|
||||
$db['user'] ?? '',
|
||||
$db['password'] ?? '',
|
||||
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
|
||||
);
|
||||
|
||||
$rows = $pdo->query(
|
||||
"SELECT uid, title, slug, hidden, detail_page, teaser <> '' AS has_teaser, description <> '' AS has_description
|
||||
FROM tx_vitec_domain_model_solution WHERE deleted = 0 ORDER BY title"
|
||||
)->fetchAll();
|
||||
|
||||
echo "=== solutions (uid | title | slug | flags) ===\n";
|
||||
foreach ($rows as $row) {
|
||||
printf("%3d | %-55s | %-45s | %s%s%s%s\n",
|
||||
$row['uid'], $row['title'], $row['slug'],
|
||||
$row['hidden'] ? 'HIDDEN ' : '',
|
||||
$row['detail_page'] ? ('detail_page=' . $row['detail_page'] . ' ') : '',
|
||||
$row['has_teaser'] ? 'teaser ' : '',
|
||||
$row['has_description'] ? 'desc' : ''
|
||||
);
|
||||
}
|
||||
echo count($rows) . " solutions total\n";
|
||||
69
migrations/check_typoscript_templates.php
Normal file
69
migrations/check_typoscript_templates.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Read-only: TypoScript template records and their static includes.
|
||||
* A static include that pulls headless in AFTER the site sets resets the
|
||||
* whole `page` object and silently drops page-level fields.
|
||||
*
|
||||
* Usage: php migrations/check_typoscript_templates.php
|
||||
*/
|
||||
|
||||
$root = null;
|
||||
$dir = __DIR__;
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
if (is_file($dir . '/config/system/settings.php')) { $root = $dir; break; }
|
||||
$dir = dirname($dir);
|
||||
}
|
||||
if ($root === null) { exit("no project root\n"); }
|
||||
|
||||
$settings = require $root . '/config/system/settings.php';
|
||||
$db = $settings['DB']['Connections']['Default'];
|
||||
$pdo = new PDO(
|
||||
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''),
|
||||
$db['user'] ?? '', $db['password'] ?? '',
|
||||
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
|
||||
);
|
||||
|
||||
$rows = $pdo->query(
|
||||
"SELECT uid, pid, title, root, clear, hidden, deleted, include_static_file, tstamp,
|
||||
LENGTH(config) AS config_len, LENGTH(constants) AS const_len
|
||||
FROM sys_template ORDER BY pid, sorting"
|
||||
)->fetchAll();
|
||||
|
||||
echo "=== sys_template ===\n";
|
||||
foreach ($rows as $r) {
|
||||
printf("uid %d | pid %d | %s%s%s%s | geaendert %s\n",
|
||||
$r['uid'], $r['pid'], $r['title'],
|
||||
$r['root'] ? ' | ROOT' : '',
|
||||
$r['hidden'] ? ' | HIDDEN' : '',
|
||||
$r['deleted'] ? ' | DELETED' : '',
|
||||
date('Y-m-d H:i', (int)$r['tstamp'])
|
||||
);
|
||||
printf(" clear=%s config=%d Zeichen constants=%d Zeichen\n", $r['clear'], $r['config_len'], $r['const_len']);
|
||||
$inc = trim((string)$r['include_static_file']);
|
||||
if ($inc !== '') {
|
||||
echo " include_static_file:\n";
|
||||
foreach (explode(',', $inc) as $i => $one) {
|
||||
printf(" %2d. %s\n", $i + 1, trim($one));
|
||||
}
|
||||
} else {
|
||||
echo " include_static_file: (leer)\n";
|
||||
}
|
||||
}
|
||||
|
||||
// Anything in the config field that resets page?
|
||||
foreach ($rows as $r) {
|
||||
if ((int)$r['config_len'] === 0) { continue; }
|
||||
$cfg = $pdo->query("SELECT config FROM sys_template WHERE uid = " . (int)$r['uid'])->fetchColumn();
|
||||
$hits = [];
|
||||
foreach (preg_split('/\R/', (string)$cfg) as $n => $line) {
|
||||
if (preg_match('/^\s*page\s*[<=]/', $line) || str_contains($line, 'lib.headlessPage')) {
|
||||
$hits[] = ($n + 1) . ': ' . trim($line);
|
||||
}
|
||||
}
|
||||
if ($hits !== []) {
|
||||
echo "\n=== page-Zuweisungen im config von uid {$r['uid']} ===\n" . implode("\n", $hits) . "\n";
|
||||
}
|
||||
}
|
||||
80
migrations/check_usecase_markets.php
Normal file
80
migrations/check_usecase_markets.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Read-only diagnosis: why do success stories emit empty `markets`?
|
||||
*
|
||||
* Modeled on migrations/check_bg.php - PDO against the credentials from
|
||||
* config/system/settings.php, no TYPO3 bootstrap, SELECT only.
|
||||
*
|
||||
* Run on the server: php migrations/check_usecase_markets.php
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$settings = require __DIR__ . '/../config/system/settings.php';
|
||||
$db = $settings['DB']['Connections']['Default'];
|
||||
$pdo = new PDO(
|
||||
sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', $db['host'], $db['dbname']),
|
||||
$db['user'],
|
||||
$db['password'],
|
||||
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
|
||||
);
|
||||
|
||||
function one(PDO $pdo, string $sql): array
|
||||
{
|
||||
return $pdo->query($sql)->fetch(PDO::FETCH_ASSOC) ?: [];
|
||||
}
|
||||
function all(PDO $pdo, string $sql): array
|
||||
{
|
||||
return $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
echo "=== 1. MM-Tabellen: Zeilen ueberhaupt da? ===\n";
|
||||
foreach (['tx_vitec_usecase_market_mm', 'tx_vitec_usecase_solution_mm'] as $mm) {
|
||||
$n = one($pdo, "SELECT COUNT(*) c FROM {$mm}");
|
||||
echo sprintf("%-32s %s Zeilen\n", $mm, $n['c'] ?? '?');
|
||||
}
|
||||
|
||||
echo "\n=== 2. Waisen: MM -> Markets, die es nicht (mehr) gibt ===\n";
|
||||
echo "verknuepfte Market-uids und ihr Zustand:\n";
|
||||
$rows = all($pdo, "
|
||||
SELECT mm.uid_foreign AS market_uid,
|
||||
COUNT(*) AS stories,
|
||||
MAX(m.uid IS NULL) AS fehlt_ganz,
|
||||
MAX(COALESCE(m.deleted, -1)) AS deleted,
|
||||
MAX(COALESCE(m.hidden, -1)) AS hidden,
|
||||
MAX(COALESCE(m.title, '?')) AS title
|
||||
FROM tx_vitec_usecase_market_mm mm
|
||||
LEFT JOIN tx_vitec_domain_model_market m ON m.uid = mm.uid_foreign
|
||||
GROUP BY mm.uid_foreign
|
||||
ORDER BY mm.uid_foreign
|
||||
");
|
||||
foreach ($rows as $r) {
|
||||
$state = $r['fehlt_ganz'] ? 'FEHLT KOMPLETT' : (($r['deleted'] ?? 0) ? 'GELOESCHT' : ((($r['hidden'] ?? 0) ? 'versteckt' : 'ok')));
|
||||
echo sprintf(" market uid %-5s %-15s %-40s (%s Stories)\n", $r['market_uid'], $state, mb_substr((string)$r['title'], 0, 40), $r['stories']);
|
||||
}
|
||||
|
||||
echo "\n=== 3. Markets-Tabelle: Bestand ===\n";
|
||||
$m = one($pdo, "SELECT COUNT(*) total, SUM(deleted) del, SUM(hidden) hid, MIN(uid) minuid, MAX(uid) maxuid FROM tx_vitec_domain_model_market");
|
||||
echo sprintf("gesamt %s | geloescht %s | versteckt %s | uid-Bereich %s-%s\n", $m['total'], $m['del'], $m['hid'], $m['minuid'], $m['maxuid']);
|
||||
echo "geloeschte Markets (falls vorhanden):\n";
|
||||
foreach (all($pdo, "SELECT uid, title, FROM_UNIXTIME(tstamp) t FROM tx_vitec_domain_model_market WHERE deleted = 1 ORDER BY uid") as $r) {
|
||||
echo sprintf(" uid %-5s %-45s zuletzt %s\n", $r['uid'], mb_substr((string)$r['title'], 0, 45), $r['t']);
|
||||
}
|
||||
|
||||
echo "\n=== 4. sys_category-Zuordnungen der Stories (JSON-Feld `categories`) ===\n";
|
||||
$c = one($pdo, "SELECT COUNT(*) c FROM sys_category_record_mm WHERE tablenames = 'tx_vitec_domain_model_usecase'");
|
||||
echo "sys_category_record_mm fuer usecases: " . ($c['c'] ?? '?') . " Zeilen\n";
|
||||
|
||||
echo "\n=== 5. Stichprobe: 3 Stories mit ihren MM-Zeilen ===\n";
|
||||
foreach (all($pdo, "
|
||||
SELECT u.uid, u.title,
|
||||
(SELECT COUNT(*) FROM tx_vitec_usecase_market_mm mm WHERE mm.uid_local = u.uid) AS mmrows
|
||||
FROM tx_vitec_domain_model_usecase u
|
||||
WHERE u.deleted = 0
|
||||
ORDER BY u.uid LIMIT 3
|
||||
") as $r) {
|
||||
echo sprintf(" story uid %-4s %-40s -> %s MM-Zeile(n)\n", $r['uid'], mb_substr((string)$r['title'], 0, 40), $r['mmrows']);
|
||||
}
|
||||
|
||||
echo "\nFertig - nichts geschrieben.\n";
|
||||
@@ -40,6 +40,13 @@ foreach ($parsed['components'] as $c) {
|
||||
);
|
||||
}
|
||||
|
||||
echo "\n--- Composed sources (g:) ---\n";
|
||||
foreach (($reader->sources($parsed)['Composed'] ?? []) as $entry) {
|
||||
echo $entry['selector'] . "\n";
|
||||
echo " label: " . $entry['label'] . "\n";
|
||||
echo " html : " . $entry['value'] . "\n\n";
|
||||
}
|
||||
|
||||
echo "\n--- Related Product Cards, raw ---\n";
|
||||
foreach ($parsed['components'] as $c) {
|
||||
if ($c['component'] !== 'Related Product Card') {
|
||||
|
||||
59
migrations/fix_image_reference_fieldname.php
Normal file
59
migrations/fix_image_reference_fieldname.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* One-shot repair (2026-09-04): the first vitec:import-product-images run
|
||||
* wrote its sys_file_reference rows with fieldname 'images' - the JSON key -
|
||||
* but the TCA field (and everything that queries it) is 'productimage'.
|
||||
* Re-files those references and refreshes the products' inline counter.
|
||||
*
|
||||
* Usage: php migrations/fix_image_reference_fieldname.php
|
||||
*/
|
||||
|
||||
$root = null;
|
||||
$dir = __DIR__;
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
if (is_file($dir . '/config/system/settings.php')) {
|
||||
$root = $dir;
|
||||
break;
|
||||
}
|
||||
$dir = dirname($dir);
|
||||
}
|
||||
if ($root === null) {
|
||||
exit("Could not locate project root above " . __DIR__ . "\n");
|
||||
}
|
||||
|
||||
$settings = require $root . '/config/system/settings.php';
|
||||
$db = $settings['DB']['Connections']['Default'];
|
||||
$pdo = new PDO(
|
||||
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''),
|
||||
$db['user'] ?? '',
|
||||
$db['password'] ?? '',
|
||||
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
|
||||
);
|
||||
|
||||
$wrong = (int)$pdo->query(
|
||||
"SELECT COUNT(*) FROM sys_file_reference
|
||||
WHERE deleted = 0 AND tablenames = 'tx_vitec_domain_model_product' AND fieldname = 'images'"
|
||||
)->fetchColumn();
|
||||
echo "Misfiled references (fieldname 'images'): $wrong\n";
|
||||
|
||||
if ($wrong > 0) {
|
||||
$pdo->exec(
|
||||
"UPDATE sys_file_reference SET fieldname = 'productimage'
|
||||
WHERE deleted = 0 AND tablenames = 'tx_vitec_domain_model_product' AND fieldname = 'images'"
|
||||
);
|
||||
echo "Re-filed to 'productimage'.\n";
|
||||
}
|
||||
|
||||
$updated = $pdo->exec(
|
||||
"UPDATE tx_vitec_domain_model_product p
|
||||
SET p.productimage = (
|
||||
SELECT COUNT(*) FROM sys_file_reference r
|
||||
WHERE r.deleted = 0 AND r.uid_foreign = p.uid
|
||||
AND r.tablenames = 'tx_vitec_domain_model_product' AND r.fieldname = 'productimage'
|
||||
)
|
||||
WHERE p.deleted = 0"
|
||||
);
|
||||
echo "Inline counters refreshed on $updated product(s). Flush the cache: vendor/bin/typo3 cache:flush\n";
|
||||
BIN
migrations/products_xlsx/Aligo_Product Page Content.xlsx
Normal file
BIN
migrations/products_xlsx/Aligo_Product Page Content.xlsx
Normal file
Binary file not shown.
BIN
migrations/products_xlsx/Arqa_Product Page Content.xlsx
Normal file
BIN
migrations/products_xlsx/Arqa_Product Page Content.xlsx
Normal file
Binary file not shown.
51
migrations/reset_product_mapping.php
Normal file
51
migrations/reset_product_mapping.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* One-shot: clear the SAVED field mapping of the product-text import
|
||||
* (tx_vitec_import_mapping, model "product_xlsx") so the corrected
|
||||
* DEFAULT_MAPPING seed applies again.
|
||||
*
|
||||
* Deliberately clears ONLY the `mapping` column - the same row also stores
|
||||
* `record_aliases` (the editor's manual related-product matches), and those
|
||||
* must survive.
|
||||
*
|
||||
* Usage: php migrations/reset_product_mapping.php
|
||||
*/
|
||||
|
||||
$root = null;
|
||||
$dir = __DIR__;
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
if (is_file($dir . '/config/system/settings.php')) {
|
||||
$root = $dir;
|
||||
break;
|
||||
}
|
||||
$dir = dirname($dir);
|
||||
}
|
||||
if ($root === null) {
|
||||
exit("Could not locate project root above " . __DIR__ . "\n");
|
||||
}
|
||||
|
||||
$settings = require $root . '/config/system/settings.php';
|
||||
$db = $settings['DB']['Connections']['Default'];
|
||||
$pdo = new PDO(
|
||||
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''),
|
||||
$db['user'] ?? '',
|
||||
$db['password'] ?? '',
|
||||
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
|
||||
);
|
||||
|
||||
$row = $pdo->query("SELECT uid, mapping, record_aliases FROM tx_vitec_import_mapping WHERE model = 'product_xlsx'")->fetch();
|
||||
if (!$row) {
|
||||
exit("No row for model 'product_xlsx' - nothing to clear, defaults apply already.\n");
|
||||
}
|
||||
|
||||
echo "Row uid " . $row['uid'] . "\n";
|
||||
echo " mapping (cleared) : " . ($row['mapping'] ?: '(empty)') . "\n";
|
||||
echo " aliases (KEPT) : " . ($row['record_aliases'] ?: '(empty)') . "\n";
|
||||
|
||||
$pdo->prepare("UPDATE tx_vitec_import_mapping SET mapping = '', tstamp = ? WHERE model = 'product_xlsx'")
|
||||
->execute([time()]);
|
||||
|
||||
echo "Done - the saved field mapping is cleared, the corrected defaults apply on the next preview.\n";
|
||||
49
migrations/reset_subproduct_flags.php
Normal file
49
migrations/reset_subproduct_flags.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* One-shot: clear the subproduct flag on ALL product records.
|
||||
*
|
||||
* The flag was set on every classic product on 2026-09-04 while K-level
|
||||
* entries still existed as records. Same-day model change: families are
|
||||
* content pages, the record table holds only classic products - and the
|
||||
* product list renderer has always EXCLUDED subproduct=1 (its original,
|
||||
* editorial meaning), which emptied every product list on /products.
|
||||
* Before 2026-09-04 no record carried the flag, so 0 across the board
|
||||
* restores the original state.
|
||||
*
|
||||
* Usage: php migrations/reset_subproduct_flags.php
|
||||
*/
|
||||
|
||||
$root = null;
|
||||
$dir = __DIR__;
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
if (is_file($dir . '/config/system/settings.php')) {
|
||||
$root = $dir;
|
||||
break;
|
||||
}
|
||||
$dir = dirname($dir);
|
||||
}
|
||||
if ($root === null) {
|
||||
exit("Could not locate project root above " . __DIR__ . "\n");
|
||||
}
|
||||
|
||||
$settings = require $root . '/config/system/settings.php';
|
||||
$db = $settings['DB']['Connections']['Default'];
|
||||
$pdo = new PDO(
|
||||
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''),
|
||||
$db['user'] ?? '',
|
||||
$db['password'] ?? '',
|
||||
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
|
||||
);
|
||||
|
||||
$count = (int)$pdo->query(
|
||||
"SELECT COUNT(*) FROM tx_vitec_domain_model_product WHERE deleted = 0 AND subproduct = 1"
|
||||
)->fetchColumn();
|
||||
echo "Records with subproduct=1: " . $count . "\n";
|
||||
|
||||
$stmt = $pdo->prepare("UPDATE tx_vitec_domain_model_product SET subproduct = 0, tstamp = ? WHERE deleted = 0 AND subproduct = 1");
|
||||
$stmt->execute([time()]);
|
||||
|
||||
echo "Cleared " . $stmt->rowCount() . " flag(s). Flush the cache: vendor/bin/typo3 cache:flush\n";
|
||||
51
migrations/seo_meta_vitec.json
Normal file
51
migrations/seo_meta_vitec.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"_quelle": "vitec.com Produktseiten, Scrape 2026-09-04. Nur Produkt-Detailseiten; Kategorieseiten-Metas (ChannelLink, 45-series, PRISM, 38-Series) bewusst ausgelassen (zu generisch). 'VITEC - '-Praefix entfernt.",
|
||||
"mgw-diamond-sdi-encoder": {
|
||||
"seotitle": "MGW Diamond - 4K and Multi-Channel SD/HD HEVC Encoder",
|
||||
"seometa": "MGW Diamond is a small, power-efficient quad channel HD or one channel 4K HEVC video encoder ideal for field-based applications. It features a powerful encoding engine with the ability to output up to eight streams simultaneously."
|
||||
},
|
||||
"mgw-ace-encoder": {
|
||||
"seotitle": "MGW Ace Encoder Compact HEVC (H.265) Hardware Encoder",
|
||||
"seometa": "MGW Ace Encoder is the world's first HEVC / H.265 hardware encoder in a professional grade portable streaming appliance. Powered by VITEC HEVC GEN2+ encoder, it delivers industry's best video quality with up to 50% bandwidth savings compared to H.264 and Ultra Low Latency streaming down to 16ms glass-to-glass."
|
||||
},
|
||||
"mgw-ace-decoder": {
|
||||
"seotitle": "MGW Ace Decoder - Professional Portable HEVC & H.264 Decoder",
|
||||
"seometa": "MGW Ace Decoder is a professional grade, high performance IP decoder supporting the bandwidth efficient HEVC/H.265 and H.264/AVC compression standards, with 4:2:2 10-bit decoding from IP or DVB-ASI, genlock support and 4K-ready 12G-SDI and HDMI outputs."
|
||||
},
|
||||
"mgw-pico-portable-encoder": {
|
||||
"seotitle": "MGW Pico Encoder - Ultra-Compact, Low Latency H.264 Encoding & Streaming Appliance",
|
||||
"seometa": "The MGW Pico Encoder is the world's smallest H.264 HD/SD portable encoding appliance. With 3G/HD/SD-SDI and Composite inputs, low power consumption and robust industrial design, the appliance is ideal for any video field-based streaming application."
|
||||
},
|
||||
"mgw-diamond-og-sdi-composite-blade-encoder": {
|
||||
"seotitle": "MGW Diamond OG - 4K and Multi-Channel SD/HD HEVC VITEC OG Encoder Card",
|
||||
"seometa": "MGW Diamond OG is a small, power-efficient quad channel HD or one channel 4K HEVC video encoder card for the openGear ecosystem, with a powerful encoding engine able to output up to eight streams simultaneously."
|
||||
},
|
||||
"mgw-diamond-og-sdi-blade-encoder": {
|
||||
"seotitle": "MGW Diamond+ OG Encoder - Multi-codec, Broadcast-grade 4K/Multichannel HD VITEC OG Encoder Card",
|
||||
"seometa": "MGW Diamond+ OG is a broadcast grade HEVC, H.264 and MPEG-2 IP encoder that is ideal for contribution or point-to-point streaming applications and compatible with the openGear ecosystem for seamless integration."
|
||||
},
|
||||
"mgw-diamond-hx-og": {
|
||||
"seotitle": "MGW Diamond-Hx OG Encoder 4K & Multi-Channel SD/HD HDMI VITEC OG Encoder Card",
|
||||
"seometa": "openGear 4K HEVC video encoder card that is ideal for IPTV distribution or Direct-to-Web applications, featuring a powerful encoding engine with the ability to deliver up to four streams simultaneously."
|
||||
},
|
||||
"mgw-ace-decoder-og-ultra-low-latency-blade-encoder": {
|
||||
"seotitle": "MGW Ace Decoder OG - Professional HEVC & H.264 openGear Decoder Card",
|
||||
"seometa": "MGW Ace Decoder OG is a professional grade, high performance IP decoder card supporting HEVC/H.265 and H.264/AVC, with 4:2:2 10-bit decoding from IP or DVB-ASI, genlock support and 4K-ready outputs for the openGear ecosystem."
|
||||
},
|
||||
"diamond-ip-og-ip-to-ip-encoder": {
|
||||
"seotitle": "Diamond-IP OG Encoder - 4K HEVC encoder with SMPTE ST2110 capture",
|
||||
"seometa": ""
|
||||
},
|
||||
"mges7000-high-density-4kuhdhd-hevc-h264-iptv-encoding-blade": {
|
||||
"seotitle": "MGES 7000 - Market Leading High Density 4K/UHD/HD HEVC & H.264 IPTV Encoding Blade",
|
||||
"seometa": "Featuring the highest density available on the market, VITEC's 4K/UHD/HD HEVC & H.264 IPTV 8-input blade offers real-time hardware encoding with secondary channel, integrated resolution and frame-rate scaling, AES 256/128-bit encryption and low latency mode."
|
||||
},
|
||||
"mges6000-broadcast-quality-hdsd-h264-quad-input-iptv-encoder": {
|
||||
"seotitle": "MGES 6000 - Broadcast-quality HD/SD H.264 Quad-Input IPTV Encoder",
|
||||
"seometa": "The MGES-6000 Blade provides real-time hardware encoding of HD and SD video for all IPTV applications, with best-in-class picture quality, a secondary low-res channel, streaming to up to seven IP destinations per port and optional AES-256/128-bit encryption."
|
||||
},
|
||||
"x-player-end-points-xp23-xp25-xp26": {
|
||||
"seotitle": "VITEC X-Player End-Points",
|
||||
"seometa": "High-performance IP video & digital signage playback devices"
|
||||
}
|
||||
}
|
||||
82
migrations/split_title_descriptors.php
Normal file
82
migrations/split_title_descriptors.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Move the parenthetical descriptor out of product titles into `subtitle`
|
||||
* (decision 2026-09-04): "MGW Diamond-H (HDMI encoder)" becomes
|
||||
* title "MGW Diamond-H" + subtitle "HDMI encoder".
|
||||
*
|
||||
* Rules:
|
||||
* - only titles ENDING in "(...)" are touched; slugs stay as they are
|
||||
* - subtitle empty -> descriptor moves there, title is stripped
|
||||
* - subtitle == descriptor -> title is stripped (subtitle already right)
|
||||
* - subtitle holds other text -> SKIPPED and reported, nothing changes
|
||||
*
|
||||
* Dry run by default: php migrations/split_title_descriptors.php
|
||||
* Write: php migrations/split_title_descriptors.php --apply
|
||||
*/
|
||||
|
||||
$apply = in_array('--apply', $argv, true);
|
||||
|
||||
$root = null;
|
||||
$dir = __DIR__;
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
if (is_file($dir . '/config/system/settings.php')) {
|
||||
$root = $dir;
|
||||
break;
|
||||
}
|
||||
$dir = dirname($dir);
|
||||
}
|
||||
if ($root === null) {
|
||||
exit("Could not locate project root above " . __DIR__ . "\n");
|
||||
}
|
||||
|
||||
$settings = require $root . '/config/system/settings.php';
|
||||
$db = $settings['DB']['Connections']['Default'];
|
||||
$pdo = new PDO(
|
||||
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''),
|
||||
$db['user'] ?? '',
|
||||
$db['password'] ?? '',
|
||||
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
|
||||
);
|
||||
|
||||
$products = $pdo->query(
|
||||
"SELECT uid, title, subtitle FROM tx_vitec_domain_model_product WHERE deleted = 0 ORDER BY title"
|
||||
)->fetchAll();
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"UPDATE tx_vitec_domain_model_product SET title = ?, subtitle = ?, tstamp = ? WHERE uid = ?"
|
||||
);
|
||||
|
||||
$changed = 0;
|
||||
$skipped = 0;
|
||||
foreach ($products as $product) {
|
||||
if (!preg_match('/^(.*\S)\s*\((.+)\)$/u', trim((string)$product['title']), $m)) {
|
||||
continue;
|
||||
}
|
||||
$newTitle = $m[1];
|
||||
$descriptor = trim($m[2]);
|
||||
$subtitle = trim((string)$product['subtitle']);
|
||||
|
||||
if ($subtitle !== '' && $subtitle !== $descriptor) {
|
||||
printf("SKIP %3d | %s -- subtitle belegt: \"%s\" (Deskriptor waere: \"%s\")\n",
|
||||
$product['uid'], $product['title'], $subtitle, $descriptor);
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
printf("%s %3d | \"%s\" -> title \"%s\" | subtitle \"%s\"\n",
|
||||
$apply ? 'WRITE' : 'plan ', $product['uid'], $product['title'], $newTitle, $descriptor);
|
||||
if ($apply) {
|
||||
$stmt->execute([$newTitle, $descriptor, time(), (int)$product['uid']]);
|
||||
}
|
||||
$changed++;
|
||||
}
|
||||
|
||||
printf("\n%d %s, %d skipped.%s\n",
|
||||
$changed,
|
||||
$apply ? 'written' : 'planned',
|
||||
$skipped,
|
||||
$apply ? ' Flush the cache: vendor/bin/typo3 cache:flush' : ' Run again with --apply to write.'
|
||||
);
|
||||
303
packages/evo_megamenu_json/Classes/Service/MenuBuilder.php
Normal file
303
packages/evo_megamenu_json/Classes/Service/MenuBuilder.php
Normal file
@@ -0,0 +1,303 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Evomedien\EvoMegamenuJson\Service;
|
||||
|
||||
use Doctrine\DBAL\ParameterType;
|
||||
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||
use TYPO3\CMS\Core\Database\Query\QueryBuilder;
|
||||
use TYPO3\CMS\Core\Database\Query\Restriction\FrontendRestrictionContainer;
|
||||
use TYPO3\CMS\Core\Domain\Repository\PageRepository;
|
||||
use TYPO3\CMS\Core\Resource\FileRepository;
|
||||
use TYPO3\CMS\Core\Service\FlexFormService;
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
|
||||
|
||||
/**
|
||||
* Turns one "Megamenu" plugin element into the payload the front end reads.
|
||||
*
|
||||
* The element carries its entries inline; every entry carries its columns
|
||||
* (each with items) and, for the teaser layout, its cards. Presentation
|
||||
* settings live in the element's FlexForm and travel with the menu, so the
|
||||
* front end takes its behaviour from the payload instead of hard-coding it.
|
||||
*
|
||||
* Content stages (FlexForm `stage`) decide how much of the editorial detail
|
||||
* is emitted: 1 = structure only, 2 = plus images and teaser lines,
|
||||
* 3 = plus the featured slot. A front end built for stage 1 keeps working
|
||||
* when the stage is raised - later stages only add keys.
|
||||
*/
|
||||
final class MenuBuilder
|
||||
{
|
||||
private const TABLE_CONTENT = 'tt_content';
|
||||
private const TABLE_ENTRY = 'tx_evomegamenujson_entry';
|
||||
private const TABLE_COLUMN = 'tx_evomegamenujson_column';
|
||||
private const TABLE_ITEM = 'tx_evomegamenujson_item';
|
||||
private const TABLE_TEASER = 'tx_evomegamenujson_teaser';
|
||||
|
||||
private const DEFAULT_SETTINGS = [
|
||||
'stage' => 3,
|
||||
'pendingBehaviour' => 'fallback',
|
||||
'showCounts' => true,
|
||||
'showViewAll' => true,
|
||||
'viewAllLabel' => 'View all %s',
|
||||
'openOn' => 'hover',
|
||||
'closeDelay' => 140,
|
||||
'columnsPerRow' => 4,
|
||||
'panelWidth' => 'container',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly ConnectionPool $connectionPool,
|
||||
private readonly FlexFormService $flexFormService,
|
||||
private readonly PageRepository $pageRepository,
|
||||
private readonly FileRepository $fileRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed>|null $contentRow pass the row when it is already at hand
|
||||
* @return array{settings:array<string,mixed>,entries:array<int,array<string,mixed>>}
|
||||
*/
|
||||
public function build(int $contentUid, ContentObjectRenderer $cObj, ?array $contentRow = null): array
|
||||
{
|
||||
$row = $contentRow ?? $this->fetchContentRow($contentUid);
|
||||
if ($row === null) {
|
||||
return ['settings' => self::DEFAULT_SETTINGS, 'entries' => []];
|
||||
}
|
||||
|
||||
$settings = $this->settingsOf($row);
|
||||
$stage = (int)$settings['stage'];
|
||||
|
||||
$entries = [];
|
||||
foreach ($this->children(self::TABLE_ENTRY, (int)$row['uid']) as $entry) {
|
||||
$entries[] = $this->buildEntry($entry, $stage, $cObj);
|
||||
}
|
||||
|
||||
return ['settings' => $settings, 'entries' => $entries];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $entry
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function buildEntry(array $entry, int $stage, ContentObjectRenderer $cObj): array
|
||||
{
|
||||
$layout = (string)($entry['layout'] ?? 'columns') === 'teasers' ? 'teasers' : 'columns';
|
||||
|
||||
$columns = [];
|
||||
foreach ($this->children(self::TABLE_COLUMN, (int)$entry['uid']) as $column) {
|
||||
$items = [];
|
||||
foreach ($this->children(self::TABLE_ITEM, (int)$column['uid']) as $item) {
|
||||
$items[] = $this->buildItem($item, $stage, $cObj);
|
||||
}
|
||||
$columns[] = [
|
||||
'title' => (string)$column['title'],
|
||||
'link' => $this->url((string)$column['link'], $cObj),
|
||||
'items' => $items,
|
||||
];
|
||||
}
|
||||
|
||||
$built = [
|
||||
'title' => (string)$entry['title'],
|
||||
'link' => $this->url((string)$entry['link'], $cObj),
|
||||
'layout' => $layout,
|
||||
'columns' => $columns,
|
||||
];
|
||||
|
||||
if ($layout === 'teasers') {
|
||||
$teasers = [];
|
||||
foreach ($this->children(self::TABLE_TEASER, (int)$entry['uid']) as $teaser) {
|
||||
$card = [
|
||||
'kicker' => (string)$teaser['kicker'],
|
||||
'title' => (string)$teaser['title'],
|
||||
'link' => $this->url((string)$teaser['link'], $cObj),
|
||||
];
|
||||
if ($stage >= 2) {
|
||||
$card['text'] = trim((string)($teaser['teasertext'] ?? ''));
|
||||
$card['image'] = $this->image(self::TABLE_TEASER, 'image', (int)$teaser['uid']);
|
||||
}
|
||||
$teasers[] = $card;
|
||||
}
|
||||
$built['teasers'] = $teasers;
|
||||
}
|
||||
|
||||
if ($stage >= 3) {
|
||||
$built['featured'] = ((int)($entry['featured_enable'] ?? 0) === 1)
|
||||
? [
|
||||
'kicker' => (string)$entry['featured_kicker'],
|
||||
'title' => (string)$entry['featured_title'],
|
||||
'text' => trim((string)($entry['featured_text'] ?? '')),
|
||||
'link' => $this->url((string)$entry['featured_link'], $cObj),
|
||||
'image' => $this->image(self::TABLE_ENTRY, 'featured_image', (int)$entry['uid']),
|
||||
]
|
||||
: null;
|
||||
}
|
||||
|
||||
return $built;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $item
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function buildItem(array $item, int $stage, ContentObjectRenderer $cObj): array
|
||||
{
|
||||
$built = [
|
||||
'title' => (string)$item['title'],
|
||||
'link' => $this->url((string)$item['link'], $cObj),
|
||||
];
|
||||
if ((int)($item['pending'] ?? 0) === 1) {
|
||||
$built['pending'] = true;
|
||||
}
|
||||
if ($stage >= 2) {
|
||||
$teaser = trim((string)($item['teaser'] ?? ''));
|
||||
if ($teaser !== '') {
|
||||
$built['teaser'] = $teaser;
|
||||
}
|
||||
$badge = trim((string)($item['badge'] ?? ''));
|
||||
if ($badge !== '') {
|
||||
$built['badge'] = $badge;
|
||||
}
|
||||
$built['image'] = $this->image(self::TABLE_ITEM, 'image', (int)$item['uid']);
|
||||
}
|
||||
return $built;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ internals
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
private function fetchContentRow(int $uid): ?array
|
||||
{
|
||||
if ($uid <= 0) {
|
||||
return null;
|
||||
}
|
||||
$queryBuilder = $this->queryBuilder(self::TABLE_CONTENT);
|
||||
$row = $queryBuilder->select('*')->from(self::TABLE_CONTENT)
|
||||
->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($uid, ParameterType::INTEGER)))
|
||||
->executeQuery()->fetchAssociative();
|
||||
if ($row === false) {
|
||||
return null;
|
||||
}
|
||||
return $this->overlay(self::TABLE_CONTENT, $row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Children of one parent record, in backend sorting order, with the
|
||||
* frontend restrictions (hidden, time, delete) applied and translations
|
||||
* overlaid.
|
||||
*
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
private function children(string $table, int $parentUid): array
|
||||
{
|
||||
$queryBuilder = $this->queryBuilder($table);
|
||||
$rows = $queryBuilder->select('*')->from($table)
|
||||
->where(
|
||||
$queryBuilder->expr()->eq('parentid', $queryBuilder->createNamedParameter($parentUid, ParameterType::INTEGER)),
|
||||
$queryBuilder->expr()->in('sys_language_uid', [-1, 0])
|
||||
)
|
||||
->orderBy('sorting', 'ASC')
|
||||
->executeQuery()->fetchAllAssociative();
|
||||
|
||||
$overlaid = [];
|
||||
foreach ($rows as $row) {
|
||||
$translated = $this->overlay($table, $row);
|
||||
if ($translated !== null) {
|
||||
$overlaid[] = $translated;
|
||||
}
|
||||
}
|
||||
return $overlaid;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $row
|
||||
* @return array<string,mixed>|null null when the translation hides the record
|
||||
*/
|
||||
private function overlay(string $table, array $row): ?array
|
||||
{
|
||||
try {
|
||||
$overlaid = $this->pageRepository->getLanguageOverlay($table, $row);
|
||||
return is_array($overlaid) ? $overlaid : null;
|
||||
} catch (\Throwable) {
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
|
||||
private function queryBuilder(string $table): QueryBuilder
|
||||
{
|
||||
$queryBuilder = $this->connectionPool->getQueryBuilderForTable($table);
|
||||
$queryBuilder->setRestrictions(GeneralUtility::makeInstance(FrontendRestrictionContainer::class));
|
||||
return $queryBuilder;
|
||||
}
|
||||
|
||||
/**
|
||||
* FlexForm settings merged over the defaults, typed the way the front end
|
||||
* expects them.
|
||||
*
|
||||
* @param array<string,mixed> $row
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function settingsOf(array $row): array
|
||||
{
|
||||
$flex = $this->flexFormService->convertFlexFormContentToArray((string)($row['pi_flexform'] ?? ''));
|
||||
$stored = is_array($flex['settings'] ?? null) ? $flex['settings'] : [];
|
||||
$settings = array_merge(self::DEFAULT_SETTINGS, array_filter($stored, static fn($v): bool => $v !== '' && $v !== null));
|
||||
|
||||
$settings['stage'] = max(1, min(3, (int)$settings['stage']));
|
||||
$settings['closeDelay'] = max(0, (int)$settings['closeDelay']);
|
||||
$settings['columnsPerRow'] = max(2, min(6, (int)$settings['columnsPerRow']));
|
||||
$settings['showCounts'] = (bool)$settings['showCounts'];
|
||||
$settings['showViewAll'] = (bool)$settings['showViewAll'];
|
||||
$settings['openOn'] = $settings['openOn'] === 'click' ? 'click' : 'hover';
|
||||
$settings['panelWidth'] = $settings['panelWidth'] === 'full' ? 'full' : 'container';
|
||||
$settings['pendingBehaviour'] = in_array($settings['pendingBehaviour'], ['fallback', 'mute', 'hide'], true)
|
||||
? $settings['pendingBehaviour'] : 'fallback';
|
||||
$settings['viewAllLabel'] = (string)$settings['viewAllLabel'];
|
||||
|
||||
return $settings;
|
||||
}
|
||||
|
||||
/** Resolved URL for a link field; '' when empty or unresolvable. */
|
||||
private function url(string $link, ContentObjectRenderer $cObj): string
|
||||
{
|
||||
$link = trim($link);
|
||||
if ($link === '') {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
return (string)$cObj->typoLink_URL(['parameter' => $link, 'forceAbsoluteUrl' => false]);
|
||||
} catch (\Throwable) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* First file reference of a field as a plain object - url plus the
|
||||
* metadata a menu needs. Null when nothing is attached.
|
||||
*
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
private function image(string $table, string $field, int $uid): ?array
|
||||
{
|
||||
try {
|
||||
$references = $this->fileRepository->findByRelation($table, $field, $uid);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
$reference = $references[0] ?? null;
|
||||
if ($reference === null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return [
|
||||
'url' => $reference->getPublicUrl(),
|
||||
'alternative' => (string)$reference->getProperty('alternative'),
|
||||
'title' => (string)$reference->getProperty('title'),
|
||||
'width' => (int)$reference->getProperty('width'),
|
||||
'height' => (int)$reference->getProperty('height'),
|
||||
];
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Evomedien\EvoMegamenuJson\UserFunc;
|
||||
|
||||
use Doctrine\DBAL\ParameterType;
|
||||
use Evomedien\EvoMegamenuJson\Service\MenuBuilder;
|
||||
use TYPO3\CMS\Core\Attribute\AsAllowedCallable;
|
||||
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||
use TYPO3\CMS\Core\Database\Query\Restriction\FrontendRestrictionContainer;
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
|
||||
|
||||
/**
|
||||
* Emits one megamenu as JSON. Serves both wiring points:
|
||||
*
|
||||
* - as a field of the plugin's own content element (no configuration -
|
||||
* the element renders itself),
|
||||
* - as a page-level field for the site header, configured with either
|
||||
* `contentUid` (a specific plugin element) or `storagePid` (the first
|
||||
* megamenu element found on that folder).
|
||||
*
|
||||
* TYPO3 v14 hands the ContentObjectRenderer in through the setter rather
|
||||
* than the constructor; without it $this->cObj stays null and the element
|
||||
* path cannot see which record it is rendering.
|
||||
*/
|
||||
final class MegamenuJsonRenderer
|
||||
{
|
||||
private const CTYPE = 'evomegamenujson_megamenu';
|
||||
|
||||
protected ?ContentObjectRenderer $cObj = null;
|
||||
|
||||
public function setContentObjectRenderer(ContentObjectRenderer $cObj): void
|
||||
{
|
||||
$this->cObj = $cObj;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $conf TypoScript: contentUid, storagePid
|
||||
*/
|
||||
#[AsAllowedCallable]
|
||||
public function render(string $content, array $conf): string
|
||||
{
|
||||
try {
|
||||
$cObj = $this->cObj ?? GeneralUtility::makeInstance(ContentObjectRenderer::class);
|
||||
$row = null;
|
||||
$uid = (int)($conf['contentUid'] ?? 0);
|
||||
|
||||
if ($uid <= 0) {
|
||||
$data = is_array($cObj->data ?? null) ? $cObj->data : [];
|
||||
if ((string)($data['CType'] ?? '') === self::CTYPE) {
|
||||
// rendering the element itself
|
||||
$row = $data;
|
||||
$uid = (int)($data['uid'] ?? 0);
|
||||
}
|
||||
}
|
||||
if ($uid <= 0 && (int)($conf['storagePid'] ?? 0) > 0) {
|
||||
$uid = $this->firstElementOn((int)$conf['storagePid']);
|
||||
}
|
||||
if ($uid <= 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$menu = GeneralUtility::makeInstance(MenuBuilder::class)->build($uid, $cObj, $row);
|
||||
|
||||
return (string)json_encode($menu, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
} catch (\Throwable) {
|
||||
// A broken menu must never take the page payload down with it.
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** uid of the first megamenu element on a storage folder, 0 when none. */
|
||||
private function firstElementOn(int $pid): int
|
||||
{
|
||||
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('tt_content');
|
||||
$queryBuilder->setRestrictions(GeneralUtility::makeInstance(FrontendRestrictionContainer::class));
|
||||
$uid = $queryBuilder->select('uid')->from('tt_content')
|
||||
->where(
|
||||
$queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter($pid, ParameterType::INTEGER)),
|
||||
$queryBuilder->expr()->eq('CType', $queryBuilder->createNamedParameter(self::CTYPE))
|
||||
)
|
||||
->orderBy('sorting', 'ASC')
|
||||
->setMaxResults(1)
|
||||
->executeQuery()->fetchOne();
|
||||
|
||||
return is_numeric($uid) ? (int)$uid : 0;
|
||||
}
|
||||
}
|
||||
169
packages/evo_megamenu_json/Configuration/FlexForms/Megamenu.xml
Normal file
169
packages/evo_megamenu_json/Configuration/FlexForms/Megamenu.xml
Normal file
@@ -0,0 +1,169 @@
|
||||
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
<!--
|
||||
Presentation settings for one megamenu. Everything here is emitted as
|
||||
`settings` in the JSON so the front end reads the behaviour from the
|
||||
payload instead of hard-coding it.
|
||||
-->
|
||||
<T3DataStructure>
|
||||
<sheets>
|
||||
|
||||
<sContent>
|
||||
<ROOT>
|
||||
<sheetTitle>LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.sheet.content</sheetTitle>
|
||||
<el>
|
||||
<settings.stage>
|
||||
<label>LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.stage</label>
|
||||
<description>LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.stage.description</description>
|
||||
<config>
|
||||
<type>select</type>
|
||||
<renderType>selectSingle</renderType>
|
||||
<default>3</default>
|
||||
<items>
|
||||
<numIndex index="0">
|
||||
<label>LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.stage.1</label>
|
||||
<value>1</value>
|
||||
</numIndex>
|
||||
<numIndex index="1">
|
||||
<label>LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.stage.2</label>
|
||||
<value>2</value>
|
||||
</numIndex>
|
||||
<numIndex index="2">
|
||||
<label>LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.stage.3</label>
|
||||
<value>3</value>
|
||||
</numIndex>
|
||||
</items>
|
||||
</config>
|
||||
</settings.stage>
|
||||
|
||||
<settings.pendingBehaviour>
|
||||
<label>LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.pendingBehaviour</label>
|
||||
<description>LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.pendingBehaviour.description</description>
|
||||
<config>
|
||||
<type>select</type>
|
||||
<renderType>selectSingle</renderType>
|
||||
<default>fallback</default>
|
||||
<items>
|
||||
<numIndex index="0">
|
||||
<label>LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.pendingBehaviour.fallback</label>
|
||||
<value>fallback</value>
|
||||
</numIndex>
|
||||
<numIndex index="1">
|
||||
<label>LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.pendingBehaviour.mute</label>
|
||||
<value>mute</value>
|
||||
</numIndex>
|
||||
<numIndex index="2">
|
||||
<label>LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.pendingBehaviour.hide</label>
|
||||
<value>hide</value>
|
||||
</numIndex>
|
||||
</items>
|
||||
</config>
|
||||
</settings.pendingBehaviour>
|
||||
|
||||
<settings.showCounts>
|
||||
<label>LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.showCounts</label>
|
||||
<description>LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.showCounts.description</description>
|
||||
<config>
|
||||
<type>check</type>
|
||||
<renderType>checkboxToggle</renderType>
|
||||
<default>1</default>
|
||||
</config>
|
||||
</settings.showCounts>
|
||||
|
||||
<settings.showViewAll>
|
||||
<label>LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.showViewAll</label>
|
||||
<config>
|
||||
<type>check</type>
|
||||
<renderType>checkboxToggle</renderType>
|
||||
<default>1</default>
|
||||
</config>
|
||||
</settings.showViewAll>
|
||||
|
||||
<settings.viewAllLabel>
|
||||
<label>LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.viewAllLabel</label>
|
||||
<description>LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.viewAllLabel.description</description>
|
||||
<config>
|
||||
<type>input</type>
|
||||
<size>30</size>
|
||||
<default>View all %s</default>
|
||||
</config>
|
||||
</settings.viewAllLabel>
|
||||
</el>
|
||||
</ROOT>
|
||||
</sContent>
|
||||
|
||||
<sBehaviour>
|
||||
<ROOT>
|
||||
<sheetTitle>LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.sheet.behaviour</sheetTitle>
|
||||
<el>
|
||||
<settings.openOn>
|
||||
<label>LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.openOn</label>
|
||||
<description>LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.openOn.description</description>
|
||||
<config>
|
||||
<type>select</type>
|
||||
<renderType>selectSingle</renderType>
|
||||
<default>hover</default>
|
||||
<items>
|
||||
<numIndex index="0">
|
||||
<label>LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.openOn.hover</label>
|
||||
<value>hover</value>
|
||||
</numIndex>
|
||||
<numIndex index="1">
|
||||
<label>LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.openOn.click</label>
|
||||
<value>click</value>
|
||||
</numIndex>
|
||||
</items>
|
||||
</config>
|
||||
</settings.openOn>
|
||||
|
||||
<settings.closeDelay>
|
||||
<label>LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.closeDelay</label>
|
||||
<description>LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.closeDelay.description</description>
|
||||
<config>
|
||||
<type>number</type>
|
||||
<size>8</size>
|
||||
<default>140</default>
|
||||
<range>
|
||||
<lower>0</lower>
|
||||
<upper>1000</upper>
|
||||
</range>
|
||||
</config>
|
||||
</settings.closeDelay>
|
||||
|
||||
<settings.columnsPerRow>
|
||||
<label>LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.columnsPerRow</label>
|
||||
<description>LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.columnsPerRow.description</description>
|
||||
<config>
|
||||
<type>number</type>
|
||||
<size>8</size>
|
||||
<default>4</default>
|
||||
<range>
|
||||
<lower>2</lower>
|
||||
<upper>6</upper>
|
||||
</range>
|
||||
</config>
|
||||
</settings.columnsPerRow>
|
||||
|
||||
<settings.panelWidth>
|
||||
<label>LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.panelWidth</label>
|
||||
<config>
|
||||
<type>select</type>
|
||||
<renderType>selectSingle</renderType>
|
||||
<default>container</default>
|
||||
<items>
|
||||
<numIndex index="0">
|
||||
<label>LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.panelWidth.container</label>
|
||||
<value>container</value>
|
||||
</numIndex>
|
||||
<numIndex index="1">
|
||||
<label>LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:flexform.panelWidth.full</label>
|
||||
<value>full</value>
|
||||
</numIndex>
|
||||
</items>
|
||||
</config>
|
||||
</settings.panelWidth>
|
||||
</el>
|
||||
</ROOT>
|
||||
</sBehaviour>
|
||||
|
||||
</sheets>
|
||||
</T3DataStructure>
|
||||
12
packages/evo_megamenu_json/Configuration/Icons.php
Normal file
12
packages/evo_megamenu_json/Configuration/Icons.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use TYPO3\CMS\Core\Imaging\IconProvider\SvgIconProvider;
|
||||
|
||||
return [
|
||||
'evomegamenujson-plugin' => [
|
||||
'provider' => SvgIconProvider::class,
|
||||
'source' => 'EXT:evo_megamenu_json/Resources/Public/Icons/plugin.svg',
|
||||
],
|
||||
];
|
||||
13
packages/evo_megamenu_json/Configuration/Services.yaml
Normal file
13
packages/evo_megamenu_json/Configuration/Services.yaml
Normal file
@@ -0,0 +1,13 @@
|
||||
services:
|
||||
_defaults:
|
||||
autowire: true
|
||||
autoconfigure: true
|
||||
public: false
|
||||
|
||||
Evomedien\EvoMegamenuJson\:
|
||||
resource: '../Classes/*'
|
||||
|
||||
# Resolved through GeneralUtility::makeInstance() from the USER function,
|
||||
# so it has to be reachable from the container.
|
||||
Evomedien\EvoMegamenuJson\Service\MenuBuilder:
|
||||
public: true
|
||||
@@ -0,0 +1,9 @@
|
||||
name: evomedien/megamenu
|
||||
label: EVO Megamenu (JSON)
|
||||
|
||||
# headless has to be loaded before this set: it resets the whole page object
|
||||
# (`page < lib.headlessPage`), which would drop the page-level field added
|
||||
# here. Declaring the dependency keeps that order without the site
|
||||
# configuration having to care about the listing position.
|
||||
dependencies:
|
||||
- friendsoftypo3/headless
|
||||
@@ -0,0 +1,19 @@
|
||||
categories:
|
||||
megamenu:
|
||||
label: 'Megamenu'
|
||||
description: 'Which megamenu element is published at page level'
|
||||
|
||||
settings:
|
||||
megamenu.contentUid:
|
||||
label: 'Megamenu element (uid)'
|
||||
description: 'Uid of the "Megamenu" content element to publish on every page as `megaMenu`. 0 = do not publish at page level.'
|
||||
category: megamenu
|
||||
type: int
|
||||
default: 0
|
||||
|
||||
megamenu.storagePid:
|
||||
label: 'Megamenu folder (pid)'
|
||||
description: 'Alternative to the uid above: the first megamenu element found on this folder is published. Ignored when an element uid is set.'
|
||||
category: megamenu
|
||||
type: int
|
||||
default: 0
|
||||
@@ -0,0 +1,41 @@
|
||||
# =============================================================================
|
||||
# EVO Megamenu JSON
|
||||
#
|
||||
# Two wiring points, one renderer:
|
||||
# 1. the plugin element renders itself, so the menu can be previewed on the
|
||||
# page it sits on (content.megamenu),
|
||||
# 2. the site header needs the menu on every page - set `megamenu.contentUid`
|
||||
# (or `megamenu.storagePid`) and it is published at page level as
|
||||
# `megaMenu`.
|
||||
#
|
||||
# Two things are deliberate here:
|
||||
#
|
||||
# * No TypoScript condition around the page-level field. A condition that
|
||||
# cannot be evaluated swallows every include that follows it and silently
|
||||
# disables the TypoScript of all later sets. The renderer returns an empty
|
||||
# value when nothing is configured - same result, no risk.
|
||||
#
|
||||
# * The JSON key is written out literally. Since TYPO3 v12 the TypoScript
|
||||
# parser substitutes constants only on the VALUE side, so a `{$...}` in
|
||||
# the object path produces no field at all (found the hard way,
|
||||
# 2026-09-11). A project that needs another key copies these five lines
|
||||
# into its own set.
|
||||
#
|
||||
# The USER function returns a JSON string; the headless JSON content object
|
||||
# decodes it, so the menu arrives as a real object, not an escaped string.
|
||||
# =============================================================================
|
||||
|
||||
tt_content.evomegamenujson_megamenu =< lib.contentElement
|
||||
tt_content.evomegamenujson_megamenu {
|
||||
fields {
|
||||
megamenu = USER
|
||||
megamenu.userFunc = Evomedien\EvoMegamenuJson\UserFunc\MegamenuJsonRenderer->render
|
||||
}
|
||||
}
|
||||
|
||||
page.10.fields.megaMenu = USER
|
||||
page.10.fields.megaMenu {
|
||||
userFunc = Evomedien\EvoMegamenuJson\UserFunc\MegamenuJsonRenderer->render
|
||||
contentUid = {$megamenu.contentUid}
|
||||
storagePid = {$megamenu.storagePid}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
defined('TYPO3') or die();
|
||||
|
||||
use TYPO3\CMS\Extbase\Utility\ExtensionUtility;
|
||||
|
||||
/**
|
||||
* Registers the "Megamenu" plugin (CType evomegamenujson_megamenu).
|
||||
*
|
||||
* The element carries the whole menu: its entries live inline below it, and
|
||||
* the FlexForm holds the presentation settings the front end reads back out
|
||||
* of the JSON (stage, hover behaviour, column count, labels).
|
||||
*
|
||||
* Place ONE element per menu - typically on a sysfolder - and point the site
|
||||
* setting `megamenu.contentUid` at it to expose the menu at page level.
|
||||
*/
|
||||
(static function (): void {
|
||||
ExtensionUtility::registerPlugin(
|
||||
'EvoMegamenuJson',
|
||||
'Megamenu',
|
||||
'LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:plugin.megamenu',
|
||||
'evomegamenujson-plugin',
|
||||
'menu',
|
||||
'LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:plugin.megamenu.description',
|
||||
'FILE:EXT:evo_megamenu_json/Configuration/FlexForms/Megamenu.xml'
|
||||
);
|
||||
|
||||
$ll = 'LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:';
|
||||
|
||||
$GLOBALS['TCA']['tt_content']['columns']['tx_evomegamenujson_entries'] = [
|
||||
'label' => $ll . 'tt_content.entries',
|
||||
'description' => $ll . 'tt_content.entries.description',
|
||||
'config' => [
|
||||
'type' => 'inline',
|
||||
'foreign_table' => 'tx_evomegamenujson_entry',
|
||||
'foreign_field' => 'parentid',
|
||||
'foreign_table_field' => 'parenttable',
|
||||
'foreign_sortby' => 'sorting',
|
||||
'maxitems' => 12,
|
||||
'appearance' => [
|
||||
'collapseAll' => true,
|
||||
'expandSingle' => true,
|
||||
'useSortable' => true,
|
||||
'newRecordLinkAddTitle' => true,
|
||||
'levelLinksPosition' => 'top',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$GLOBALS['TCA']['tt_content']['types']['evomegamenujson_megamenu']['showitem'] = '
|
||||
--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:general,
|
||||
--palette--;;general,
|
||||
header,
|
||||
--div--;' . $ll . 'tt_content.tab.entries,
|
||||
tx_evomegamenujson_entries,
|
||||
--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:plugin,
|
||||
pi_flexform,
|
||||
--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:access,
|
||||
--palette--;;hidden,
|
||||
--palette--;;access,
|
||||
';
|
||||
})();
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* A column inside an entry's panel: a headline that is itself a link,
|
||||
* plus the list of items below it.
|
||||
*/
|
||||
|
||||
$ll = 'LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:tx_evomegamenujson_column.';
|
||||
|
||||
return [
|
||||
'ctrl' => [
|
||||
'title' => 'LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:tx_evomegamenujson_column',
|
||||
'label' => 'title',
|
||||
'tstamp' => 'tstamp',
|
||||
'crdate' => 'crdate',
|
||||
'delete' => 'deleted',
|
||||
'sortby' => 'sorting',
|
||||
'hideTable' => true,
|
||||
'versioningWS' => true,
|
||||
'languageField' => 'sys_language_uid',
|
||||
'transOrigPointerField' => 'l10n_parent',
|
||||
'transOrigDiffSourceField' => 'l10n_diffsource',
|
||||
'enablecolumns' => ['disabled' => 'hidden'],
|
||||
'iconfile' => 'EXT:evo_megamenu_json/Resources/Public/Icons/column.svg',
|
||||
'searchFields' => 'title',
|
||||
],
|
||||
'types' => [
|
||||
'1' => ['showitem' => 'hidden, title, link, menu_items, --div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:language, sys_language_uid, l10n_parent'],
|
||||
],
|
||||
'columns' => [
|
||||
'sys_language_uid' => [
|
||||
'exclude' => true,
|
||||
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.language',
|
||||
'config' => ['type' => 'language'],
|
||||
],
|
||||
'l10n_parent' => [
|
||||
'displayCond' => 'FIELD:sys_language_uid:>:0',
|
||||
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.l18n_parent',
|
||||
'config' => [
|
||||
'type' => 'select',
|
||||
'renderType' => 'selectSingle',
|
||||
'default' => 0,
|
||||
'items' => [['label' => '', 'value' => 0]],
|
||||
'foreign_table' => 'tx_evomegamenujson_column',
|
||||
'foreign_table_where' => 'AND {#tx_evomegamenujson_column}.{#pid}=###CURRENT_PID### AND {#tx_evomegamenujson_column}.{#sys_language_uid} IN (-1,0)',
|
||||
],
|
||||
],
|
||||
'l10n_diffsource' => ['config' => ['type' => 'passthrough']],
|
||||
'hidden' => [
|
||||
'exclude' => true,
|
||||
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.hidden',
|
||||
'config' => ['type' => 'check', 'renderType' => 'checkboxToggle', 'default' => 0],
|
||||
],
|
||||
'parentid' => ['config' => ['type' => 'passthrough']],
|
||||
|
||||
'title' => [
|
||||
'label' => $ll . 'title',
|
||||
'config' => ['type' => 'input', 'size' => 40, 'eval' => 'trim', 'required' => true],
|
||||
],
|
||||
'link' => [
|
||||
'label' => $ll . 'link',
|
||||
'description' => $ll . 'link.description',
|
||||
'config' => ['type' => 'link', 'allowedTypes' => ['page', 'url', 'record'], 'size' => 40],
|
||||
],
|
||||
'menu_items' => [
|
||||
'label' => $ll . 'menu_items',
|
||||
'config' => [
|
||||
'type' => 'inline',
|
||||
'foreign_table' => 'tx_evomegamenujson_item',
|
||||
'foreign_field' => 'parentid',
|
||||
'foreign_sortby' => 'sorting',
|
||||
'maxitems' => 30,
|
||||
'appearance' => [
|
||||
'collapseAll' => true,
|
||||
'expandSingle' => true,
|
||||
'useSortable' => true,
|
||||
'newRecordLinkAddTitle' => true,
|
||||
'levelLinksPosition' => 'top',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* One item in the main navigation bar - the thing a visitor hovers.
|
||||
*
|
||||
* `link` is a real target: the bar item stays clickable and leads to the
|
||||
* overview page, the panel only opens on hover. `layout` decides which of
|
||||
* the two panel shapes renders - a column grid, or a small link column
|
||||
* next to teaser cards.
|
||||
*/
|
||||
|
||||
$ll = 'LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:tx_evomegamenujson_entry.';
|
||||
|
||||
return [
|
||||
'ctrl' => [
|
||||
'title' => 'LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:tx_evomegamenujson_entry',
|
||||
'label' => 'title',
|
||||
'label_alt' => 'layout',
|
||||
'label_alt_force' => true,
|
||||
'tstamp' => 'tstamp',
|
||||
'crdate' => 'crdate',
|
||||
'delete' => 'deleted',
|
||||
'sortby' => 'sorting',
|
||||
'hideTable' => true,
|
||||
'versioningWS' => true,
|
||||
'languageField' => 'sys_language_uid',
|
||||
'transOrigPointerField' => 'l10n_parent',
|
||||
'transOrigDiffSourceField' => 'l10n_diffsource',
|
||||
'enablecolumns' => [
|
||||
'disabled' => 'hidden',
|
||||
],
|
||||
'iconfile' => 'EXT:evo_megamenu_json/Resources/Public/Icons/entry.svg',
|
||||
'searchFields' => 'title',
|
||||
],
|
||||
'types' => [
|
||||
'1' => [
|
||||
'showitem' => '
|
||||
--div--;' . $ll . 'tab.general,
|
||||
hidden, title, link, layout,
|
||||
--div--;' . $ll . 'tab.columns,
|
||||
menu_columns,
|
||||
--div--;' . $ll . 'tab.teasers,
|
||||
menu_teasers,
|
||||
--div--;' . $ll . 'tab.featured,
|
||||
featured_enable, featured_kicker, featured_title, featured_text, featured_link, featured_image,
|
||||
--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:language,
|
||||
sys_language_uid, l10n_parent
|
||||
',
|
||||
],
|
||||
],
|
||||
'columns' => [
|
||||
'sys_language_uid' => [
|
||||
'exclude' => true,
|
||||
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.language',
|
||||
'config' => ['type' => 'language'],
|
||||
],
|
||||
'l10n_parent' => [
|
||||
'displayCond' => 'FIELD:sys_language_uid:>:0',
|
||||
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.l18n_parent',
|
||||
'config' => [
|
||||
'type' => 'select',
|
||||
'renderType' => 'selectSingle',
|
||||
'default' => 0,
|
||||
'items' => [['label' => '', 'value' => 0]],
|
||||
'foreign_table' => 'tx_evomegamenujson_entry',
|
||||
'foreign_table_where' => 'AND {#tx_evomegamenujson_entry}.{#pid}=###CURRENT_PID### AND {#tx_evomegamenujson_entry}.{#sys_language_uid} IN (-1,0)',
|
||||
],
|
||||
],
|
||||
'l10n_diffsource' => ['config' => ['type' => 'passthrough']],
|
||||
'hidden' => [
|
||||
'exclude' => true,
|
||||
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.hidden',
|
||||
'config' => ['type' => 'check', 'renderType' => 'checkboxToggle', 'default' => 0],
|
||||
],
|
||||
'parentid' => ['config' => ['type' => 'passthrough']],
|
||||
'parenttable' => ['config' => ['type' => 'passthrough']],
|
||||
|
||||
'title' => [
|
||||
'label' => $ll . 'title',
|
||||
'description' => $ll . 'title.description',
|
||||
'config' => ['type' => 'input', 'size' => 40, 'eval' => 'trim', 'required' => true],
|
||||
],
|
||||
'link' => [
|
||||
'label' => $ll . 'link',
|
||||
'description' => $ll . 'link.description',
|
||||
'config' => [
|
||||
'type' => 'link',
|
||||
'allowedTypes' => ['page', 'url', 'record'],
|
||||
'size' => 40,
|
||||
],
|
||||
],
|
||||
'layout' => [
|
||||
'label' => $ll . 'layout',
|
||||
'description' => $ll . 'layout.description',
|
||||
'config' => [
|
||||
'type' => 'select',
|
||||
'renderType' => 'selectSingle',
|
||||
'default' => 'columns',
|
||||
'items' => [
|
||||
['label' => $ll . 'layout.columns', 'value' => 'columns'],
|
||||
['label' => $ll . 'layout.teasers', 'value' => 'teasers'],
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'menu_columns' => [
|
||||
'label' => $ll . 'menu_columns',
|
||||
'description' => $ll . 'menu_columns.description',
|
||||
'config' => [
|
||||
'type' => 'inline',
|
||||
'foreign_table' => 'tx_evomegamenujson_column',
|
||||
'foreign_field' => 'parentid',
|
||||
'foreign_sortby' => 'sorting',
|
||||
'maxitems' => 12,
|
||||
'appearance' => [
|
||||
'collapseAll' => true,
|
||||
'expandSingle' => true,
|
||||
'useSortable' => true,
|
||||
'showSynchronizationLink' => false,
|
||||
'showAllLocalizationLink' => false,
|
||||
'showPossibleLocalizationRecords' => false,
|
||||
'newRecordLinkAddTitle' => true,
|
||||
'levelLinksPosition' => 'top',
|
||||
],
|
||||
],
|
||||
],
|
||||
'menu_teasers' => [
|
||||
'displayCond' => 'FIELD:layout:=:teasers',
|
||||
'label' => $ll . 'menu_teasers',
|
||||
'description' => $ll . 'menu_teasers.description',
|
||||
'config' => [
|
||||
'type' => 'inline',
|
||||
'foreign_table' => 'tx_evomegamenujson_teaser',
|
||||
'foreign_field' => 'parentid',
|
||||
'foreign_sortby' => 'sorting',
|
||||
'maxitems' => 8,
|
||||
'appearance' => [
|
||||
'collapseAll' => true,
|
||||
'expandSingle' => true,
|
||||
'useSortable' => true,
|
||||
'newRecordLinkAddTitle' => true,
|
||||
'levelLinksPosition' => 'top',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'featured_enable' => [
|
||||
'label' => $ll . 'featured_enable',
|
||||
'description' => $ll . 'featured_enable.description',
|
||||
'config' => ['type' => 'check', 'renderType' => 'checkboxToggle', 'default' => 0],
|
||||
],
|
||||
'featured_kicker' => [
|
||||
'displayCond' => 'FIELD:featured_enable:=:1',
|
||||
'label' => $ll . 'featured_kicker',
|
||||
'config' => ['type' => 'input', 'size' => 30, 'eval' => 'trim', 'max' => 60],
|
||||
],
|
||||
'featured_title' => [
|
||||
'displayCond' => 'FIELD:featured_enable:=:1',
|
||||
'label' => $ll . 'featured_title',
|
||||
'config' => ['type' => 'input', 'size' => 40, 'eval' => 'trim'],
|
||||
],
|
||||
'featured_text' => [
|
||||
'displayCond' => 'FIELD:featured_enable:=:1',
|
||||
'label' => $ll . 'featured_text',
|
||||
'config' => ['type' => 'text', 'cols' => 40, 'rows' => 3],
|
||||
],
|
||||
'featured_link' => [
|
||||
'displayCond' => 'FIELD:featured_enable:=:1',
|
||||
'label' => $ll . 'featured_link',
|
||||
'config' => ['type' => 'link', 'allowedTypes' => ['page', 'url', 'record', 'file'], 'size' => 40],
|
||||
],
|
||||
'featured_image' => [
|
||||
'displayCond' => 'FIELD:featured_enable:=:1',
|
||||
'label' => $ll . 'featured_image',
|
||||
'config' => [
|
||||
'type' => 'file',
|
||||
'maxitems' => 1,
|
||||
'allowed' => 'common-image-types',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* A single link line inside a column. `teaser` and `image` only reach the
|
||||
* JSON from content stage 2 on, `pending` marks a target page that does not
|
||||
* exist yet so the front end can grey the line out instead of linking into
|
||||
* a 404.
|
||||
*/
|
||||
|
||||
$ll = 'LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:tx_evomegamenujson_item.';
|
||||
|
||||
return [
|
||||
'ctrl' => [
|
||||
'title' => 'LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:tx_evomegamenujson_item',
|
||||
'label' => 'title',
|
||||
'label_alt' => 'teaser',
|
||||
'tstamp' => 'tstamp',
|
||||
'crdate' => 'crdate',
|
||||
'delete' => 'deleted',
|
||||
'sortby' => 'sorting',
|
||||
'hideTable' => true,
|
||||
'versioningWS' => true,
|
||||
'languageField' => 'sys_language_uid',
|
||||
'transOrigPointerField' => 'l10n_parent',
|
||||
'transOrigDiffSourceField' => 'l10n_diffsource',
|
||||
'enablecolumns' => ['disabled' => 'hidden'],
|
||||
'iconfile' => 'EXT:evo_megamenu_json/Resources/Public/Icons/item.svg',
|
||||
'searchFields' => 'title,teaser',
|
||||
],
|
||||
'types' => [
|
||||
'1' => ['showitem' => 'hidden, title, link, pending, teaser, badge, image, --div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:language, sys_language_uid, l10n_parent'],
|
||||
],
|
||||
'columns' => [
|
||||
'sys_language_uid' => [
|
||||
'exclude' => true,
|
||||
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.language',
|
||||
'config' => ['type' => 'language'],
|
||||
],
|
||||
'l10n_parent' => [
|
||||
'displayCond' => 'FIELD:sys_language_uid:>:0',
|
||||
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.l18n_parent',
|
||||
'config' => [
|
||||
'type' => 'select',
|
||||
'renderType' => 'selectSingle',
|
||||
'default' => 0,
|
||||
'items' => [['label' => '', 'value' => 0]],
|
||||
'foreign_table' => 'tx_evomegamenujson_item',
|
||||
'foreign_table_where' => 'AND {#tx_evomegamenujson_item}.{#pid}=###CURRENT_PID### AND {#tx_evomegamenujson_item}.{#sys_language_uid} IN (-1,0)',
|
||||
],
|
||||
],
|
||||
'l10n_diffsource' => ['config' => ['type' => 'passthrough']],
|
||||
'hidden' => [
|
||||
'exclude' => true,
|
||||
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.hidden',
|
||||
'config' => ['type' => 'check', 'renderType' => 'checkboxToggle', 'default' => 0],
|
||||
],
|
||||
'parentid' => ['config' => ['type' => 'passthrough']],
|
||||
|
||||
'title' => [
|
||||
'label' => $ll . 'title',
|
||||
'config' => ['type' => 'input', 'size' => 40, 'eval' => 'trim', 'required' => true],
|
||||
],
|
||||
'link' => [
|
||||
'label' => $ll . 'link',
|
||||
'config' => ['type' => 'link', 'allowedTypes' => ['page', 'url', 'record', 'file'], 'size' => 40],
|
||||
],
|
||||
'pending' => [
|
||||
'label' => $ll . 'pending',
|
||||
'description' => $ll . 'pending.description',
|
||||
'config' => ['type' => 'check', 'renderType' => 'checkboxToggle', 'default' => 0],
|
||||
],
|
||||
'teaser' => [
|
||||
'label' => $ll . 'teaser',
|
||||
'description' => $ll . 'teaser.description',
|
||||
'config' => ['type' => 'input', 'size' => 40, 'eval' => 'trim', 'max' => 255],
|
||||
],
|
||||
'badge' => [
|
||||
'label' => $ll . 'badge',
|
||||
'description' => $ll . 'badge.description',
|
||||
'config' => ['type' => 'input', 'size' => 15, 'eval' => 'trim', 'max' => 60],
|
||||
],
|
||||
'image' => [
|
||||
'label' => $ll . 'image',
|
||||
'description' => $ll . 'image.description',
|
||||
'config' => [
|
||||
'type' => 'file',
|
||||
'maxitems' => 1,
|
||||
'allowed' => 'common-image-types',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* A teaser card - the editorial half of the "teasers" layout, used where a
|
||||
* menu should show what is new rather than a full product tree.
|
||||
*/
|
||||
|
||||
$ll = 'LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:tx_evomegamenujson_teaser.';
|
||||
|
||||
return [
|
||||
'ctrl' => [
|
||||
'title' => 'LLL:EXT:evo_megamenu_json/Resources/Private/Language/locallang_db.xlf:tx_evomegamenujson_teaser',
|
||||
'label' => 'title',
|
||||
'label_alt' => 'kicker',
|
||||
'tstamp' => 'tstamp',
|
||||
'crdate' => 'crdate',
|
||||
'delete' => 'deleted',
|
||||
'sortby' => 'sorting',
|
||||
'hideTable' => true,
|
||||
'versioningWS' => true,
|
||||
'languageField' => 'sys_language_uid',
|
||||
'transOrigPointerField' => 'l10n_parent',
|
||||
'transOrigDiffSourceField' => 'l10n_diffsource',
|
||||
'enablecolumns' => ['disabled' => 'hidden'],
|
||||
'iconfile' => 'EXT:evo_megamenu_json/Resources/Public/Icons/teaser.svg',
|
||||
'searchFields' => 'title,teasertext',
|
||||
],
|
||||
'types' => [
|
||||
'1' => ['showitem' => 'hidden, kicker, title, teasertext, link, image, --div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:language, sys_language_uid, l10n_parent'],
|
||||
],
|
||||
'columns' => [
|
||||
'sys_language_uid' => [
|
||||
'exclude' => true,
|
||||
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.language',
|
||||
'config' => ['type' => 'language'],
|
||||
],
|
||||
'l10n_parent' => [
|
||||
'displayCond' => 'FIELD:sys_language_uid:>:0',
|
||||
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.l18n_parent',
|
||||
'config' => [
|
||||
'type' => 'select',
|
||||
'renderType' => 'selectSingle',
|
||||
'default' => 0,
|
||||
'items' => [['label' => '', 'value' => 0]],
|
||||
'foreign_table' => 'tx_evomegamenujson_teaser',
|
||||
'foreign_table_where' => 'AND {#tx_evomegamenujson_teaser}.{#pid}=###CURRENT_PID### AND {#tx_evomegamenujson_teaser}.{#sys_language_uid} IN (-1,0)',
|
||||
],
|
||||
],
|
||||
'l10n_diffsource' => ['config' => ['type' => 'passthrough']],
|
||||
'hidden' => [
|
||||
'exclude' => true,
|
||||
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.hidden',
|
||||
'config' => ['type' => 'check', 'renderType' => 'checkboxToggle', 'default' => 0],
|
||||
],
|
||||
'parentid' => ['config' => ['type' => 'passthrough']],
|
||||
|
||||
'kicker' => [
|
||||
'label' => $ll . 'kicker',
|
||||
'description' => $ll . 'kicker.description',
|
||||
'config' => ['type' => 'input', 'size' => 25, 'eval' => 'trim', 'max' => 60],
|
||||
],
|
||||
'title' => [
|
||||
'label' => $ll . 'title',
|
||||
'config' => ['type' => 'input', 'size' => 40, 'eval' => 'trim', 'required' => true],
|
||||
],
|
||||
'teasertext' => [
|
||||
'label' => $ll . 'teasertext',
|
||||
'config' => ['type' => 'text', 'cols' => 40, 'rows' => 3],
|
||||
],
|
||||
'link' => [
|
||||
'label' => $ll . 'link',
|
||||
'config' => ['type' => 'link', 'allowedTypes' => ['page', 'url', 'record', 'file'], 'size' => 40],
|
||||
],
|
||||
'image' => [
|
||||
'label' => $ll . 'image',
|
||||
'config' => [
|
||||
'type' => 'file',
|
||||
'maxitems' => 1,
|
||||
'allowed' => 'common-image-types',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
101
packages/evo_megamenu_json/README.md
Normal file
101
packages/evo_megamenu_json/README.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# EVO Megamenu JSON
|
||||
|
||||
An editable megamenu for headless TYPO3. One content element holds the whole
|
||||
menu; the extension publishes it as JSON, the front end renders it.
|
||||
|
||||
Project-neutral by design — nothing in here knows which site it runs on.
|
||||
|
||||
## What the editor gets
|
||||
|
||||
A plugin called **Megamenu**. Inside it:
|
||||
|
||||
| Level | Holds |
|
||||
|---|---|
|
||||
| **Entry** | one item in the main bar: label, target page, panel layout |
|
||||
| **Column** | a heading (optionally a link) and its items |
|
||||
| **Item** | label, link, teaser line, badge, thumbnail, “page not ready” flag |
|
||||
| **Teaser card** | kicker, headline, text, link, image — for editorial panels |
|
||||
| **Featured** | one highlighted block per entry, edited on the entry itself |
|
||||
|
||||
Two panel layouts per entry: `columns` for a structure menu (products,
|
||||
solutions, markets) and `teasers` for a small link column next to cards
|
||||
(news, stories).
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
composer require evomedien/evo-megamenu-json
|
||||
vendor/bin/typo3 extension:setup
|
||||
vendor/bin/typo3 database:updateschema "*.add,*.change"
|
||||
```
|
||||
|
||||
In the site configuration, add the set **EVO Megamenu (JSON)** and set:
|
||||
|
||||
| Setting | Meaning |
|
||||
|---|---|
|
||||
|
||||
| `megamenu.contentUid` | uid of the Megamenu element to publish on every page |
|
||||
| `megamenu.storagePid` | alternative: folder whose first Megamenu element is used |
|
||||
|
||||
Leave both uid and pid at 0 and the menu is only rendered where the element
|
||||
sits — useful while building it.
|
||||
|
||||
## Payload
|
||||
|
||||
```json
|
||||
"megaMenu": {
|
||||
"settings": {
|
||||
"stage": 3, "openOn": "hover", "closeDelay": 140, "columnsPerRow": 4,
|
||||
"panelWidth": "container", "pendingBehaviour": "fallback",
|
||||
"showCounts": true, "showViewAll": true, "viewAllLabel": "View all %s"
|
||||
},
|
||||
"entries": [
|
||||
{
|
||||
"title": "Products",
|
||||
"link": "/products",
|
||||
"layout": "columns",
|
||||
"columns": [
|
||||
{
|
||||
"title": "IP Video Streaming",
|
||||
"link": "/products/ip-video-streaming",
|
||||
"items": [
|
||||
{ "title": "Appliances", "link": "/products/ip-video-streaming/appliances",
|
||||
"pending": true, "teaser": "MGW Diamond, Ace, Pico",
|
||||
"image": { "url": "/fileadmin/...", "alternative": "", "title": "", "width": 600, "height": 400 } }
|
||||
]
|
||||
}
|
||||
],
|
||||
"featured": { "kicker": "Datasheet", "title": "MGW Diamond-H", "text": "…", "link": "/product/…", "image": { } }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`settings` travels with the menu on purpose: the front end reads hover
|
||||
behaviour, close delay, column count and labels from the payload instead of
|
||||
hard-coding them, so an editor can change them without a deploy.
|
||||
|
||||
### Content stages
|
||||
|
||||
`stage` decides how much detail is emitted — 1 structure only, 2 adds images,
|
||||
teaser lines and cards, 3 adds the featured block. Later stages only **add**
|
||||
keys, so a front end written for stage 1 keeps working when the stage is
|
||||
raised.
|
||||
|
||||
### `pending`
|
||||
|
||||
An item whose target page does not exist yet carries `"pending": true`.
|
||||
`settings.pendingBehaviour` says what to do with it: `fallback` (link to the
|
||||
column heading instead), `mute` (show, not clickable) or `hide`. This keeps
|
||||
a menu shippable while its deeper pages are still being built.
|
||||
|
||||
## Notes
|
||||
|
||||
* Every link is a resolved URL — page, record, external or file, run through
|
||||
typolink at render time.
|
||||
* Translations are overlaid per record, so a menu can be localised entry by
|
||||
entry.
|
||||
* The renderer never throws: a broken menu returns an empty value rather than
|
||||
taking the page payload down.
|
||||
* Without `friendsoftypo3/headless` the element has no JSON envelope to render
|
||||
into — the extension is built for headless installations.
|
||||
@@ -0,0 +1,96 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xliff version="1.0">
|
||||
<file source-language="en" datatype="plaintext" original="messages" date="2026-09-11T00:00:00Z" product-name="evo_megamenu_json">
|
||||
<header/>
|
||||
<body>
|
||||
|
||||
<trans-unit id="plugin.megamenu"><source>Megamenu</source></trans-unit>
|
||||
<trans-unit id="plugin.megamenu.description"><source>One editable megamenu: entries in the main bar, each opening a panel of columns or teaser cards. Published as JSON.</source></trans-unit>
|
||||
|
||||
<trans-unit id="tt_content.entries"><source>Menu entries</source></trans-unit>
|
||||
<trans-unit id="tt_content.entries.description"><source>The items in the main navigation bar, left to right.</source></trans-unit>
|
||||
<trans-unit id="tt_content.tab.entries"><source>Entries</source></trans-unit>
|
||||
|
||||
<trans-unit id="tx_evomegamenujson_entry"><source>Menu entry</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_entry.tab.general"><source>General</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_entry.tab.columns"><source>Columns</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_entry.tab.teasers"><source>Teaser cards</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_entry.tab.featured"><source>Featured</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_entry.title"><source>Label</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_entry.title.description"><source>Shown in the main bar.</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_entry.link"><source>Target page</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_entry.link.description"><source>The bar item stays a real link - the panel opens on hover, a click goes to this page.</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_entry.layout"><source>Panel layout</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_entry.layout.description"><source>Columns for a structure menu, teaser cards for editorial sections such as news or stories.</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_entry.layout.columns"><source>Columns</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_entry.layout.teasers"><source>Link column plus teaser cards</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_entry.menu_columns"><source>Columns</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_entry.menu_columns.description"><source>One column per group. In the teaser layout only the first column is rendered, next to the cards.</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_entry.menu_teasers"><source>Teaser cards</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_entry.menu_teasers.description"><source>Shown from content stage 2 on.</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_entry.featured_enable"><source>Show featured block</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_entry.featured_enable.description"><source>A highlighted block at the right edge of the panel. Reaches the JSON from content stage 3 on.</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_entry.featured_kicker"><source>Kicker</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_entry.featured_title"><source>Headline</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_entry.featured_text"><source>Text</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_entry.featured_link"><source>Link</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_entry.featured_image"><source>Image</source></trans-unit>
|
||||
|
||||
<trans-unit id="tx_evomegamenujson_column"><source>Column</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_column.title"><source>Column heading</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_column.link"><source>Heading link</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_column.link.description"><source>Optional - makes the column heading clickable, usually the group overview page.</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_column.menu_items"><source>Entries</source></trans-unit>
|
||||
|
||||
<trans-unit id="tx_evomegamenujson_item"><source>Menu item</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_item.title"><source>Label</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_item.link"><source>Link</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_item.pending"><source>Target page not ready</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_item.pending.description"><source>Marks the entry while its page is still being built. What happens then is set on the plugin: fall back to the column link, mute it, or hide it.</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_item.teaser"><source>Teaser line</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_item.teaser.description"><source>One short line below the label. Reaches the JSON from content stage 2 on.</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_item.badge"><source>Badge</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_item.badge.description"><source>Short marker such as "New" or "Coming soon".</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_item.image"><source>Thumbnail</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_item.image.description"><source>Reaches the JSON from content stage 2 on.</source></trans-unit>
|
||||
|
||||
<trans-unit id="tx_evomegamenujson_teaser"><source>Teaser card</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_teaser.kicker"><source>Kicker</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_teaser.kicker.description"><source>Small label above the headline, for example the section or market.</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_teaser.title"><source>Headline</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_teaser.teasertext"><source>Text</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_teaser.link"><source>Link</source></trans-unit>
|
||||
<trans-unit id="tx_evomegamenujson_teaser.image"><source>Image</source></trans-unit>
|
||||
|
||||
<trans-unit id="flexform.sheet.content"><source>Content</source></trans-unit>
|
||||
<trans-unit id="flexform.sheet.behaviour"><source>Behaviour</source></trans-unit>
|
||||
<trans-unit id="flexform.stage"><source>Content stage</source></trans-unit>
|
||||
<trans-unit id="flexform.stage.description"><source>How much detail the JSON carries. Later stages only add keys, so a front end built for stage 1 keeps working.</source></trans-unit>
|
||||
<trans-unit id="flexform.stage.1"><source>1 - structure only (labels and links)</source></trans-unit>
|
||||
<trans-unit id="flexform.stage.2"><source>2 - plus images, teaser lines and cards</source></trans-unit>
|
||||
<trans-unit id="flexform.stage.3"><source>3 - plus the featured block</source></trans-unit>
|
||||
<trans-unit id="flexform.pendingBehaviour"><source>Entries whose page is not ready</source></trans-unit>
|
||||
<trans-unit id="flexform.pendingBehaviour.description"><source>What the front end does with entries marked "target page not ready".</source></trans-unit>
|
||||
<trans-unit id="flexform.pendingBehaviour.fallback"><source>Link to the column heading instead</source></trans-unit>
|
||||
<trans-unit id="flexform.pendingBehaviour.mute"><source>Show, but not clickable</source></trans-unit>
|
||||
<trans-unit id="flexform.pendingBehaviour.hide"><source>Hide</source></trans-unit>
|
||||
<trans-unit id="flexform.showCounts"><source>Show entry count</source></trans-unit>
|
||||
<trans-unit id="flexform.showCounts.description"><source>The line at the foot of the panel, for example "39 entries in 8 columns".</source></trans-unit>
|
||||
<trans-unit id="flexform.showViewAll"><source>Show "view all" link</source></trans-unit>
|
||||
<trans-unit id="flexform.viewAllLabel"><source>Label for "view all"</source></trans-unit>
|
||||
<trans-unit id="flexform.viewAllLabel.description"><source>%s is replaced by the entry label.</source></trans-unit>
|
||||
<trans-unit id="flexform.openOn"><source>Panel opens on</source></trans-unit>
|
||||
<trans-unit id="flexform.openOn.description"><source>Hover keeps the bar item clickable as a link; click turns it into a toggle.</source></trans-unit>
|
||||
<trans-unit id="flexform.openOn.hover"><source>Hover (item stays a link)</source></trans-unit>
|
||||
<trans-unit id="flexform.openOn.click"><source>Click</source></trans-unit>
|
||||
<trans-unit id="flexform.closeDelay"><source>Close delay (ms)</source></trans-unit>
|
||||
<trans-unit id="flexform.closeDelay.description"><source>Grace period when the pointer leaves the menu, so moving diagonally into a column does not close it.</source></trans-unit>
|
||||
<trans-unit id="flexform.columnsPerRow"><source>Columns per row</source></trans-unit>
|
||||
<trans-unit id="flexform.columnsPerRow.description"><source>How many columns the panel places side by side before wrapping.</source></trans-unit>
|
||||
<trans-unit id="flexform.panelWidth"><source>Panel width</source></trans-unit>
|
||||
<trans-unit id="flexform.panelWidth.container"><source>Content width</source></trans-unit>
|
||||
<trans-unit id="flexform.panelWidth.full"><source>Full window width</source></trans-unit>
|
||||
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill="#1D4ED8" d="M2 2h5v1.8H2z"/><path fill="#94A3B8" d="M2 5h5v1.4H2zM2 7.6h5V9H2zM2 10.2h5v1.4H2z"/><path fill="#CBD5E1" d="M9 2h5v9.6H9z"/></svg>
|
||||
|
After Width: | Height: | Size: 239 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill="#1D4ED8" d="M1 2.5h14V5H1z"/><path fill="#94A3B8" d="M1 6.5h14v7H1z"/></svg>
|
||||
|
After Width: | Height: | Size: 172 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><circle cx="3.2" cy="8" r="1.6" fill="#1D4ED8"/><path fill="#94A3B8" d="M6 6.4h8v1.5H6zM6 9.1h5.5v1.3H6z"/></svg>
|
||||
|
After Width: | Height: | Size: 197 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill="#1D4ED8" d="M1 2h14v2.2H1z"/><path fill="#64748B" d="M1 5.6h4.2v8.6H1zM6 5.6h4.2v8.6H6zM10.8 5.6H15v8.6h-4.2z"/></svg>
|
||||
|
After Width: | Height: | Size: 214 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill="#1D4ED8" d="M1.5 2.5h13v6h-13z"/><path fill="#94A3B8" d="M1.5 10h13v1.4h-13zM1.5 12.3h8.5v1.3H1.5z"/></svg>
|
||||
|
After Width: | Height: | Size: 203 B |
24
packages/evo_megamenu_json/composer.json
Normal file
24
packages/evo_megamenu_json/composer.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "evomedien/evo-megamenu-json",
|
||||
"type": "typo3-cms-extension",
|
||||
"description": "Editable megamenu for headless TYPO3 - one plugin fills columns, teaser cards and a featured slot, delivered as JSON",
|
||||
"version": "1.0.0",
|
||||
"authors": [],
|
||||
"license": "GPL-2.0-or-later",
|
||||
"require": {
|
||||
"typo3/cms-core": "^13.4 || ^14.0"
|
||||
},
|
||||
"suggest": {
|
||||
"friendsoftypo3/headless": "Renders the plugin inside the headless JSON envelope and exposes the menu at page level"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Evomedien\\EvoMegamenuJson\\": "Classes"
|
||||
}
|
||||
},
|
||||
"extra": {
|
||||
"typo3/cms": {
|
||||
"extension-key": "evo_megamenu_json"
|
||||
}
|
||||
}
|
||||
}
|
||||
21
packages/evo_megamenu_json/ext_emconf.php
Normal file
21
packages/evo_megamenu_json/ext_emconf.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
$EM_CONF[$_EXTKEY] = [
|
||||
'title' => 'EVO Megamenu JSON',
|
||||
'description' => 'Editable megamenu for headless TYPO3: one plugin fills columns, teaser cards and a featured slot; the menu is delivered as JSON.',
|
||||
'category' => 'plugin',
|
||||
'author' => 'Evomedien',
|
||||
'author_email' => '',
|
||||
'state' => 'stable',
|
||||
'clearCacheOnLoad' => 1,
|
||||
'version' => '1.0.0',
|
||||
'constraints' => [
|
||||
'depends' => [
|
||||
'typo3' => '13.4.0-14.9.99',
|
||||
],
|
||||
'conflicts' => [],
|
||||
'suggests' => [
|
||||
'headless' => '',
|
||||
],
|
||||
],
|
||||
];
|
||||
69
packages/evo_megamenu_json/ext_tables.sql
Normal file
69
packages/evo_megamenu_json/ext_tables.sql
Normal file
@@ -0,0 +1,69 @@
|
||||
#
|
||||
# Megamenu entry - one item in the main navigation bar.
|
||||
# Child of the "Megamenu" plugin element (tt_content).
|
||||
#
|
||||
CREATE TABLE tx_evomegamenujson_entry (
|
||||
parentid int(11) DEFAULT '0' NOT NULL,
|
||||
parenttable varchar(255) DEFAULT '' NOT NULL,
|
||||
title varchar(255) DEFAULT '' NOT NULL,
|
||||
link varchar(1024) DEFAULT '' NOT NULL,
|
||||
layout varchar(30) DEFAULT 'columns' 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,
|
||||
featured_kicker varchar(255) DEFAULT '' NOT NULL,
|
||||
featured_title varchar(255) DEFAULT '' NOT NULL,
|
||||
featured_text text,
|
||||
featured_link varchar(1024) DEFAULT '' NOT NULL,
|
||||
featured_image int(11) unsigned DEFAULT '0' NOT NULL,
|
||||
|
||||
KEY parent (parentid)
|
||||
);
|
||||
|
||||
#
|
||||
# Column inside an entry - a headline plus its list of items.
|
||||
#
|
||||
CREATE TABLE tx_evomegamenujson_column (
|
||||
parentid int(11) DEFAULT '0' NOT NULL,
|
||||
title varchar(255) DEFAULT '' NOT NULL,
|
||||
link varchar(1024) DEFAULT '' NOT NULL,
|
||||
menu_items int(11) DEFAULT '0' NOT NULL,
|
||||
|
||||
KEY parent (parentid)
|
||||
);
|
||||
|
||||
#
|
||||
# Item inside a column - the actual link line.
|
||||
#
|
||||
CREATE TABLE tx_evomegamenujson_item (
|
||||
parentid int(11) DEFAULT '0' NOT NULL,
|
||||
title varchar(255) DEFAULT '' NOT NULL,
|
||||
link varchar(1024) DEFAULT '' NOT NULL,
|
||||
teaser varchar(255) DEFAULT '' NOT NULL,
|
||||
badge varchar(60) DEFAULT '' NOT NULL,
|
||||
pending tinyint(1) unsigned DEFAULT '0' NOT NULL,
|
||||
image int(11) unsigned DEFAULT '0' NOT NULL,
|
||||
|
||||
KEY parent (parentid)
|
||||
);
|
||||
|
||||
#
|
||||
# Teaser card inside an entry - used by the "teasers" layout.
|
||||
#
|
||||
CREATE TABLE tx_evomegamenujson_teaser (
|
||||
parentid int(11) DEFAULT '0' NOT NULL,
|
||||
kicker varchar(255) DEFAULT '' NOT NULL,
|
||||
title varchar(255) DEFAULT '' NOT NULL,
|
||||
teasertext text,
|
||||
link varchar(1024) DEFAULT '' NOT NULL,
|
||||
image int(11) unsigned DEFAULT '0' NOT NULL,
|
||||
|
||||
KEY parent (parentid)
|
||||
);
|
||||
|
||||
#
|
||||
# Plugin element holds its entries inline.
|
||||
#
|
||||
CREATE TABLE tt_content (
|
||||
tx_evomegamenujson_entries int(11) DEFAULT '0' NOT NULL
|
||||
);
|
||||
300
packages/vitec/Classes/Command/AlignSolutionsCommand.php
Normal file
300
packages/vitec/Classes/Command/AlignSolutionsCommand.php
Normal file
@@ -0,0 +1,300 @@
|
||||
<?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;
|
||||
|
||||
/**
|
||||
* DECLARATIVE alignment of the solution records with the "Generic Solutions"
|
||||
* tab of the merged sitemap - the binding target per decision 2026-09-04
|
||||
* (Olli, after the interim state briefly held 121 records): the solution
|
||||
* table contains EXACTLY these 39 solutions in 8 thematic groups. Market-
|
||||
* specific solutions are NOT records; the market connection is editorial
|
||||
* (solution lists on market pages).
|
||||
*
|
||||
* What a run does (idempotent, --dry-run):
|
||||
* 1. ensures the 8 group categories under the root category "Solution"
|
||||
* (group typo "Engagament" corrected to "Engagement")
|
||||
* 2. renames two legacy records onto their sheet names instead of
|
||||
* deleting them:
|
||||
* "Real-time Data Monitoring (CCTV, sensors)" -> "(CCTV, IoT, sensors)"
|
||||
* "Passenger Information Displays (Digital Signage)" -> "Passenger / Public Information Displays"
|
||||
* (the latter keeps the only editorial teaser/description in the table)
|
||||
* 3. creates missing target solutions (title + slug)
|
||||
* 4. sets each target's categories to exactly its group (market categories
|
||||
* are stripped from solutions - they stay in place for other models)
|
||||
* 5. regenerates empty/stale slugs from the (possibly new) title
|
||||
* 6. soft-DELETES every solution record whose title is not a target
|
||||
* (DataHandler delete - restorable via recycler)
|
||||
*
|
||||
* vendor/bin/typo3 vitec:align-solutions --dry-run
|
||||
* vendor/bin/typo3 vitec:align-solutions
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'vitec:align-solutions',
|
||||
description: 'Align solution records to EXACTLY the 39 sitemap Generic Solutions in 8 groups (prunes the rest)'
|
||||
)]
|
||||
final class AlignSolutionsCommand extends Command
|
||||
{
|
||||
private const TABLE = 'tx_vitec_domain_model_solution';
|
||||
|
||||
/** Binding target: group => solutions, verbatim from the sheet (typo fixed). */
|
||||
private const TARGET = [
|
||||
'Control Room & Command Centre Solutions' => [
|
||||
'Control Room Platforms (SOC / NOC / Command Centres)',
|
||||
'Command & Control Visualisation',
|
||||
'Operations Management Environments',
|
||||
'Crisis & Incident Management Centres',
|
||||
],
|
||||
'Real-time Monitoring & Situational Awareness' => [
|
||||
'Multi-source Video Monitoring',
|
||||
'Real-time Data Monitoring (CCTV, IoT, sensors)',
|
||||
'Situational Awareness Platforms',
|
||||
'Surveillance & Operational Visibility',
|
||||
'Incident Detection & Response',
|
||||
],
|
||||
'Video Distribution & Streaming' => [
|
||||
'IPTV Distribution',
|
||||
'AV-over-IP Distribution',
|
||||
'Secure Video Streaming (low latency)',
|
||||
'Multi-site Video Delivery',
|
||||
'Internal & External Broadcast Distribution',
|
||||
],
|
||||
'Video Capture, Encoding & Processing' => [
|
||||
'Video Encoding / Decoding',
|
||||
'Live Video Capture',
|
||||
'Signal Processing & Conversion',
|
||||
'Transcoding & Optimisation',
|
||||
'Contribution & Remote Production',
|
||||
],
|
||||
'Video Wall & Data Visualisation' => [
|
||||
'Video Wall Platforms',
|
||||
'Multi-display Visualisation',
|
||||
'Real-time Dashboards',
|
||||
'Data Aggregation & Display',
|
||||
'Operational Intelligence Displays',
|
||||
],
|
||||
'Digital Signage & Content Delivery' => [
|
||||
'Digital Signage Networks',
|
||||
'Passenger / Public Information Displays',
|
||||
'Wayfinding & Messaging',
|
||||
'Advertising & Sponsorship Displays',
|
||||
'Targeted Content Delivery',
|
||||
],
|
||||
'Enterprise Communications & Engagement' => [
|
||||
'Internal Communications (IPTV)',
|
||||
'Staff Messaging Systems',
|
||||
'Corporate Broadcasting',
|
||||
'Guest / Visitor Engagement',
|
||||
'Multi-location Communication Networks',
|
||||
],
|
||||
'Security & Surveillance Solutions' => [
|
||||
'Surveillance & CCTV Integration',
|
||||
'Security Operations Centres (SOC)',
|
||||
'Perimeter Monitoring',
|
||||
'Incident Response Systems',
|
||||
'Secure Monitoring Environments',
|
||||
],
|
||||
];
|
||||
|
||||
/** Legacy record title => target title (rename instead of delete+create). */
|
||||
private const RENAMES = [
|
||||
'Real-time Data Monitoring (CCTV, sensors)' => 'Real-time Data Monitoring (CCTV, IoT, sensors)',
|
||||
'Passenger Information Displays (Digital Signage)' => 'Passenger / Public Information Displays',
|
||||
];
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report the plan - write nothing');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
Bootstrap::initializeBackendAuthentication();
|
||||
$dryRun = (bool)$input->getOption('dry-run');
|
||||
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable(self::TABLE);
|
||||
|
||||
$solutionRoot = (int)$connection->fetchOne(
|
||||
"SELECT uid FROM sys_category WHERE deleted = 0 AND parent = 0 AND title = 'Solution'"
|
||||
);
|
||||
if ($solutionRoot <= 0) {
|
||||
$output->writeln('<error>Root category "Solution" not found.</error>');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$categoryPid = (int)$connection->fetchOne('SELECT pid FROM sys_category WHERE uid = ' . $solutionRoot);
|
||||
|
||||
// ---- 1. group categories ------------------------------------------
|
||||
$groupCats = [];
|
||||
$groupDatamap = [];
|
||||
$index = 0;
|
||||
$newGroupIds = [];
|
||||
foreach (array_keys(self::TARGET) as $group) {
|
||||
$uid = (int)$connection->fetchOne(
|
||||
'SELECT uid FROM sys_category WHERE deleted = 0 AND parent = ? AND title = ?',
|
||||
[$solutionRoot, $group]
|
||||
);
|
||||
if ($uid > 0) {
|
||||
$groupCats[$group] = $uid;
|
||||
} else {
|
||||
$newId = 'NEW' . ++$index;
|
||||
$newGroupIds[$newId] = $group;
|
||||
$groupDatamap['sys_category'][$newId] = ['pid' => $categoryPid, 'parent' => $solutionRoot, 'title' => $group];
|
||||
$output->writeln(($dryRun ? 'plan ' : 'create') . ' Gruppe: ' . $group);
|
||||
}
|
||||
}
|
||||
if (!$dryRun && $groupDatamap !== []) {
|
||||
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||
$dataHandler->start($groupDatamap, []);
|
||||
$dataHandler->process_datamap();
|
||||
foreach ($newGroupIds as $newId => $group) {
|
||||
$groupCats[$group] = (int)($dataHandler->substNEWwithIDs[$newId] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
// target title => group
|
||||
$groupByTitle = [];
|
||||
foreach (self::TARGET as $group => $titles) {
|
||||
foreach ($titles as $title) {
|
||||
$groupByTitle[$title] = $group;
|
||||
}
|
||||
}
|
||||
|
||||
$solutions = [];
|
||||
foreach ($connection->fetchAllAssociative(
|
||||
'SELECT uid, title, slug FROM ' . self::TABLE . ' WHERE deleted = 0'
|
||||
) as $row) {
|
||||
$solutions[(string)$row['title']] = ['uid' => (int)$row['uid'], 'slug' => (string)$row['slug']];
|
||||
}
|
||||
$pids = $connection->fetchFirstColumn('SELECT pid FROM ' . self::TABLE . ' WHERE deleted = 0');
|
||||
$pidCounts = array_count_values(array_map('intval', $pids));
|
||||
arsort($pidCounts);
|
||||
$storagePid = (int)(array_key_first($pidCounts) ?? 0);
|
||||
|
||||
$datamap = [];
|
||||
$usedSlugs = [];
|
||||
|
||||
// ---- 2. renames ----------------------------------------------------
|
||||
foreach (self::RENAMES as $oldTitle => $targetTitle) {
|
||||
if (!isset($solutions[$oldTitle])) {
|
||||
continue;
|
||||
}
|
||||
if (isset($solutions[$targetTitle])) {
|
||||
$output->writeln('<comment>Rename uebersprungen, Ziel existiert schon: ' . $targetTitle . '</comment>');
|
||||
continue;
|
||||
}
|
||||
$record = $solutions[$oldTitle];
|
||||
$datamap[self::TABLE][$record['uid']]['title'] = $targetTitle;
|
||||
$datamap[self::TABLE][$record['uid']]['slug'] = $this->slugFor($targetTitle, $usedSlugs);
|
||||
$solutions[$targetTitle] = $record;
|
||||
unset($solutions[$oldTitle]);
|
||||
$output->writeln(sprintf('%s uid %d: "%s" -> "%s"', $dryRun ? 'plan ' : 'rename', $record['uid'], $oldTitle, $targetTitle));
|
||||
}
|
||||
|
||||
// ---- 3.-5. targets: create / categories / slugs -------------------
|
||||
$mm = [];
|
||||
foreach ($connection->fetchAllAssociative(
|
||||
"SELECT uid_local, uid_foreign FROM sys_category_record_mm
|
||||
WHERE tablenames = ? AND fieldname = 'categories'",
|
||||
[self::TABLE]
|
||||
) as $row) {
|
||||
$mm[(int)$row['uid_foreign']][] = (int)$row['uid_local'];
|
||||
}
|
||||
|
||||
$created = 0;
|
||||
$newIndex = 0;
|
||||
foreach ($groupByTitle as $title => $group) {
|
||||
$groupUid = (int)($groupCats[$group] ?? 0);
|
||||
if (isset($solutions[$title])) {
|
||||
$record = $solutions[$title];
|
||||
$have = $mm[$record['uid']] ?? [];
|
||||
// categories := exactly the group (strips market categories)
|
||||
if ($groupUid > 0 && ($have !== [$groupUid])) {
|
||||
$datamap[self::TABLE][$record['uid']]['categories'] = (string)$groupUid;
|
||||
}
|
||||
if ($groupUid === 0 && $dryRun) {
|
||||
$output->writeln('plan Kategorie (neu anzulegende Gruppe) fuer: ' . $title);
|
||||
}
|
||||
$slug = (string)($datamap[self::TABLE][$record['uid']]['slug'] ?? $record['slug']);
|
||||
if ($slug === '') {
|
||||
$datamap[self::TABLE][$record['uid']]['slug'] = $this->slugFor($title, $usedSlugs);
|
||||
}
|
||||
} else {
|
||||
$newId = 'NEW_S' . ++$newIndex;
|
||||
$datamap[self::TABLE][$newId] = [
|
||||
'pid' => $storagePid,
|
||||
'title' => $title,
|
||||
'slug' => $this->slugFor($title, $usedSlugs),
|
||||
'categories' => $groupUid > 0 ? (string)$groupUid : '',
|
||||
];
|
||||
$created++;
|
||||
$output->writeln(($dryRun ? 'plan ' : 'create') . ' Solution: ' . $title . ' [' . $group . ']');
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 6. prune ------------------------------------------------------
|
||||
$cmdmap = [];
|
||||
$deleted = 0;
|
||||
foreach ($solutions as $title => $record) {
|
||||
if (isset($groupByTitle[$title])) {
|
||||
continue;
|
||||
}
|
||||
$cmdmap[self::TABLE][$record['uid']]['delete'] = 1;
|
||||
$deleted++;
|
||||
$output->writeln(sprintf('%s uid %d: %s', $dryRun ? 'plan-DELETE' : 'DELETE ', $record['uid'], $title));
|
||||
}
|
||||
|
||||
$output->writeln(sprintf("\nZiel: %d Solutions in %d Gruppen | anlegen: %d, loeschen: %d, aktualisieren: %d",
|
||||
count($groupByTitle), count(self::TARGET), $created, $deleted,
|
||||
count(array_filter(array_keys($datamap[self::TABLE] ?? []), 'is_int'))));
|
||||
|
||||
if ($dryRun) {
|
||||
$output->writeln('DRY RUN - nichts geschrieben.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
foreach ([[$datamap, []], [[], $cmdmap]] as [$dm, $cm]) {
|
||||
if ($dm === [] && $cm === []) {
|
||||
continue;
|
||||
}
|
||||
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||
$dataHandler->start($dm, $cm);
|
||||
if ($dm !== []) {
|
||||
$dataHandler->process_datamap();
|
||||
}
|
||||
if ($cm !== []) {
|
||||
$dataHandler->process_cmdmap();
|
||||
}
|
||||
if ($dataHandler->errorLog !== []) {
|
||||
foreach ($dataHandler->errorLog as $error) {
|
||||
$output->writeln('<error>' . $error . '</error>');
|
||||
}
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
$output->writeln('Fertig. Cache leeren: vendor/bin/typo3 cache:flush');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/** @param string[] $used by-reference collection of slugs handed out in this run */
|
||||
private function slugFor(string $title, array &$used): string
|
||||
{
|
||||
$slug = trim((string)preg_replace('/[^a-z0-9]+/', '-', str_replace('+', ' plus ', mb_strtolower($title))), '-');
|
||||
$candidate = $slug;
|
||||
$suffix = 1;
|
||||
while (in_array($candidate, $used, true)) {
|
||||
$candidate = $slug . '-' . ++$suffix;
|
||||
}
|
||||
$used[] = $candidate;
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
84
packages/vitec/Classes/Command/DebugSetsCommand.php
Normal file
84
packages/vitec/Classes/Command/DebugSetsCommand.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?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\Site\SiteFinder;
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
|
||||
/**
|
||||
* Diagnostic: prints the site sets in the order TYPO3 actually resolves them.
|
||||
*
|
||||
* Set order decides who wins: friendsoftypo3/headless resets the whole `page`
|
||||
* object (`page < lib.headlessPage`), so every set that adds page-level
|
||||
* fields has to load AFTER it. When headless ends up last, those fields
|
||||
* vanish without any error - which is exactly the failure this command was
|
||||
* written for (2026-09-11).
|
||||
*
|
||||
* vendor/bin/typo3 vitec:debug-sets
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'vitec:debug-sets',
|
||||
description: 'Print the resolved site set order (diagnoses page-level TypoScript being reset)'
|
||||
)]
|
||||
final class DebugSetsCommand extends Command
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('site', null, InputOption::VALUE_REQUIRED, 'Site identifier', 'vitec');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$identifier = (string)$input->getOption('site');
|
||||
$site = GeneralUtility::makeInstance(SiteFinder::class)->getSiteByIdentifier($identifier);
|
||||
|
||||
if (!method_exists($site, 'getSets')) {
|
||||
$output->writeln('<error>This TYPO3 version does not expose Site::getSets().</error>');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$sets = $site->getSets();
|
||||
$output->writeln(sprintf('Site "%s": %d sets, in load order', $identifier, count($sets)));
|
||||
|
||||
$position = 0;
|
||||
$headlessAt = null;
|
||||
$vitecAt = null;
|
||||
foreach ($sets as $set) {
|
||||
$position++;
|
||||
$name = is_object($set) && property_exists($set, 'name') ? (string)$set->name : (string)$set;
|
||||
if ($name === 'friendsoftypo3/headless') {
|
||||
$headlessAt = $position;
|
||||
}
|
||||
if ($name === 'evomedien/vitecset') {
|
||||
$vitecAt = $position;
|
||||
}
|
||||
$mark = in_array($name, ['friendsoftypo3/headless', 'evomedien/vitecset'], true) ? ' <-- ' : ' ';
|
||||
$output->writeln(sprintf('%2d.%s%s', $position, $mark, $name));
|
||||
}
|
||||
|
||||
$output->writeln('');
|
||||
if ($headlessAt === null || $vitecAt === null) {
|
||||
$output->writeln('<comment>headless or vitecset is not in the resolved list at all.</comment>');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
if ($headlessAt > $vitecAt) {
|
||||
$output->writeln(sprintf(
|
||||
'<error>PROBLEM: headless loads at %d, AFTER vitecset at %d - it resets `page` and drops the page-level fields.</error>',
|
||||
$headlessAt, $vitecAt
|
||||
));
|
||||
} else {
|
||||
$output->writeln(sprintf(
|
||||
'<info>Order is fine: headless at %d, vitecset at %d.</info>',
|
||||
$headlessAt, $vitecAt
|
||||
));
|
||||
}
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
142
packages/vitec/Classes/Command/ImportProductImagesCommand.php
Normal file
142
packages/vitec/Classes/Command/ImportProductImagesCommand.php
Normal file
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Evomedien\Vitec\Command;
|
||||
|
||||
use Doctrine\DBAL\ParameterType;
|
||||
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\Resource\StorageRepository;
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
|
||||
/**
|
||||
* Attach the product photos harvested from vitec.com / datapath.co.uk
|
||||
* (2026-09-04) to the product records.
|
||||
*
|
||||
* Files live in fileadmin/user_upload/products/<product-slug>/ - one folder
|
||||
* per product, downloaded straight from the live sites. For every folder
|
||||
* whose slug matches a product record WITHOUT any image (fill-up only,
|
||||
* existing images are never touched), the files are indexed in FAL and
|
||||
* appended to the `images` field through DataHandler (sys_file_reference),
|
||||
* in alphabetical file order (the live carousels' _01.._NN order).
|
||||
*
|
||||
* Idempotent: products that got their images on a previous run are skipped
|
||||
* on the next one.
|
||||
*
|
||||
* vendor/bin/typo3 vitec:import-product-images --dry-run
|
||||
* vendor/bin/typo3 vitec:import-product-images
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'vitec:import-product-images',
|
||||
description: 'Attach staged product photos (fileadmin/user_upload/products/<slug>/) to image-less products'
|
||||
)]
|
||||
final class ImportProductImagesCommand extends Command
|
||||
{
|
||||
private const TABLE = 'tx_vitec_domain_model_product';
|
||||
private const STAGE = 'user_upload/products';
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report the plan - write nothing');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
Bootstrap::initializeBackendAuthentication();
|
||||
$dryRun = (bool)$input->getOption('dry-run');
|
||||
|
||||
$storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage();
|
||||
if ($storage === null || !$storage->hasFolder(self::STAGE)) {
|
||||
$output->writeln('<error>Staging folder ' . self::STAGE . ' not found in the default storage.</error>');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$stageFolder = $storage->getFolder(self::STAGE);
|
||||
|
||||
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable(self::TABLE);
|
||||
|
||||
$datamap = [];
|
||||
$newIndex = 0;
|
||||
$planned = 0;
|
||||
$skippedExisting = 0;
|
||||
|
||||
foreach ($stageFolder->getSubfolders() as $folder) {
|
||||
$slug = $folder->getName();
|
||||
$product = $connection->fetchAssociative(
|
||||
'SELECT uid, pid, title FROM ' . self::TABLE . ' WHERE deleted = 0 AND slug = ?',
|
||||
[$slug]
|
||||
);
|
||||
if ($product === false) {
|
||||
$output->writeln(sprintf('<comment>skip %-45s - no product with this slug</comment>', $slug));
|
||||
continue;
|
||||
}
|
||||
$uid = (int)$product['uid'];
|
||||
|
||||
// NB: the TCA field is `productimage` - `images` is only the
|
||||
// JSON key the serializers emit (lesson from the first run).
|
||||
$existing = (int)$connection->fetchOne(
|
||||
"SELECT COUNT(*) FROM sys_file_reference
|
||||
WHERE deleted = 0 AND tablenames = ? AND fieldname = 'productimage' AND uid_foreign = ?",
|
||||
[self::TABLE, $uid]
|
||||
);
|
||||
if ($existing > 0) {
|
||||
$output->writeln(sprintf('keep %-45s - already has %d image(s)', $slug, $existing));
|
||||
$skippedExisting++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$files = $folder->getFiles();
|
||||
usort($files, static fn($a, $b): int => strcasecmp($a->getName(), $b->getName()));
|
||||
if ($files === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$referenceIds = [];
|
||||
foreach ($files as $file) {
|
||||
// getFiles() delivers indexed File objects (sys_file created on
|
||||
// the fly for new files), so uid is available right away.
|
||||
$newId = 'NEW' . ++$newIndex;
|
||||
$referenceIds[] = $newId;
|
||||
$datamap['sys_file_reference'][$newId] = [
|
||||
'table_local' => 'sys_file',
|
||||
'uid_local' => $file->getUid(),
|
||||
'uid_foreign' => $uid,
|
||||
'tablenames' => self::TABLE,
|
||||
'fieldname' => 'productimage',
|
||||
'pid' => (int)$product['pid'],
|
||||
];
|
||||
}
|
||||
$datamap[self::TABLE][$uid]['productimage'] = implode(',', $referenceIds);
|
||||
$planned++;
|
||||
$output->writeln(sprintf('%s %-45s -> uid %d "%s": %d image(s)',
|
||||
$dryRun ? 'plan ' : 'WRITE', $slug, $uid, $product['title'], count($referenceIds)));
|
||||
}
|
||||
|
||||
$output->writeln(sprintf("\n%d product(s) to fill, %d already had images.", $planned, $skippedExisting));
|
||||
if ($planned === 0) {
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
if ($dryRun) {
|
||||
$output->writeln('DRY RUN - nothing written.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||
$dataHandler->start($datamap, []);
|
||||
$dataHandler->process_datamap();
|
||||
if ($dataHandler->errorLog !== []) {
|
||||
foreach ($dataHandler->errorLog as $error) {
|
||||
$output->writeln('<error>' . $error . '</error>');
|
||||
}
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$output->writeln('Done. Flush the cache: vendor/bin/typo3 cache:flush');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
172
packages/vitec/Classes/Command/ImportProductWorkbookCommand.php
Normal file
172
packages/vitec/Classes/Command/ImportProductWorkbookCommand.php
Normal file
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Evomedien\Vitec\Command;
|
||||
|
||||
use Doctrine\DBAL\ParameterType;
|
||||
use Evomedien\Vitec\Controller\Backend\ProductTextImportController;
|
||||
use Evomedien\Vitec\Import\MappingRepository;
|
||||
use Evomedien\Vitec\Import\ProductXlsxReader;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Non-interactive counterpart of the backend module's product-text import:
|
||||
* apply one agency "Product Page Content" workbook to one product record
|
||||
* from the CLI. Same reader, same mapping (the saved one from
|
||||
* tx_vitec_import_mapping, falling back to the module's DEFAULT_MAPPING),
|
||||
* same DataHandler write path.
|
||||
*
|
||||
* Writes only mapped fields whose composed value differs from the record;
|
||||
* unmapped fields and the related-product cards are untouched (review those
|
||||
* in the module, which also shows this run's workbook - the parse is stored
|
||||
* in tx_vitec_product_workbook like an upload).
|
||||
*
|
||||
* vendor/bin/typo3 vitec:import-product-workbook \
|
||||
* "migrations/products_xlsx/Arqa_Product Page Content.xlsx" arqa --dry-run
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'vitec:import-product-workbook',
|
||||
description: 'Apply a product-content workbook (XLSX) to a product record, using the module mapping'
|
||||
)]
|
||||
final class ImportProductWorkbookCommand extends Command
|
||||
{
|
||||
private const TABLE = 'tx_vitec_domain_model_product';
|
||||
|
||||
public function __construct(
|
||||
private readonly ProductXlsxReader $reader,
|
||||
private readonly MappingRepository $mappingRepository,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addArgument('file', InputArgument::REQUIRED, 'Path to the workbook (XLSX)');
|
||||
$this->addArgument('product', InputArgument::REQUIRED, 'Slug or uid of the target product');
|
||||
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report only - nothing written');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
Bootstrap::initializeBackendAuthentication();
|
||||
$dryRun = (bool)$input->getOption('dry-run');
|
||||
|
||||
$file = (string)$input->getArgument('file');
|
||||
if (!is_file($file)) {
|
||||
$output->writeln('<error>File not found: ' . $file . '</error>');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$product = $this->loadProduct((string)$input->getArgument('product'));
|
||||
if ($product === null) {
|
||||
$output->writeln('<error>No product record for "' . $input->getArgument('product') . '".</error>');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$uid = (int)$product['uid'];
|
||||
$output->writeln(sprintf('Target: uid %d "%s" (/%s)%s', $uid, $product['title'], $product['slug'], $dryRun ? ' - DRY RUN' : ''));
|
||||
|
||||
$parsed = $this->reader->parse($file);
|
||||
$pageType = (string)($parsed['pageType'] ?? 'unknown');
|
||||
if ($pageType !== 'product') {
|
||||
$output->writeln('<error>Not a product workbook (pageType "' . $pageType . '") - nothing imported.</error>');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// Workbook URL vs record slug - warn only, same as the module.
|
||||
$url = trim((string)($parsed['meta']['url'] ?? ''), '/');
|
||||
$slug = trim((string)$product['slug'], '/');
|
||||
if ($url !== '' && $slug !== '' && !str_ends_with($url, $slug)) {
|
||||
$output->writeln(sprintf('<comment>URL mismatch: /%s (workbook) vs /%s (record) - check the target!</comment>', $url, $slug));
|
||||
}
|
||||
|
||||
$mapping = array_filter($this->mappingRepository->load('product_xlsx')['mapping'] ?? []);
|
||||
if ($mapping === []) {
|
||||
$mapping = ProductTextImportController::DEFAULT_MAPPING;
|
||||
}
|
||||
|
||||
$data = [];
|
||||
foreach ($mapping as $field => $selector) {
|
||||
$value = $this->reader->valueFor($parsed, $selector);
|
||||
if ($value === null) {
|
||||
$output->writeln(sprintf('%-20s %-34s -> not in this file', $field, $selector));
|
||||
continue;
|
||||
}
|
||||
$old = (string)($product[$field] ?? '');
|
||||
if (trim($old) === trim($value)) {
|
||||
$output->writeln(sprintf('%-20s %-34s -> unchanged', $field, $selector));
|
||||
continue;
|
||||
}
|
||||
$data[$field] = $value;
|
||||
$output->writeln(sprintf('%-20s %-34s -> WRITE (%d chars%s)', $field, $selector, mb_strlen($value), $old === '' ? ', was empty' : ''));
|
||||
}
|
||||
|
||||
if ($data === []) {
|
||||
$output->writeln('All mapped fields are up to date - nothing to write.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
if ($dryRun) {
|
||||
$output->writeln(sprintf('DRY RUN - %d field(s) would be written.', count($data)));
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||
$dataHandler->start([self::TABLE => [(string)$uid => $data]], []);
|
||||
$dataHandler->process_datamap();
|
||||
if ($dataHandler->errorLog !== []) {
|
||||
foreach ($dataHandler->errorLog as $error) {
|
||||
$output->writeln('<error>' . $error . '</error>');
|
||||
}
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$this->storeWorkbook($uid, $parsed, basename($file));
|
||||
$output->writeln(sprintf('%d field(s) written (%s). Workbook stored for the module. Flush the cache: vendor/bin/typo3 cache:flush', count($data), implode(', ', array_keys($data))));
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
private function loadProduct(string $slugOrUid): ?array
|
||||
{
|
||||
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::TABLE);
|
||||
$qb->select('*')->from(self::TABLE)->where($qb->expr()->eq('deleted', 0));
|
||||
if (ctype_digit($slugOrUid)) {
|
||||
$qb->andWhere($qb->expr()->eq('uid', $qb->createNamedParameter((int)$slugOrUid, ParameterType::INTEGER)));
|
||||
} else {
|
||||
$qb->andWhere($qb->expr()->eq('slug', $qb->createNamedParameter($slugOrUid, ParameterType::STRING)));
|
||||
}
|
||||
$row = $qb->executeQuery()->fetchAssociative();
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same storage the module upload uses, so "Edit Product" reopens on this
|
||||
* workbook (related-card review without re-upload).
|
||||
*
|
||||
* @param array<string,mixed> $parsed
|
||||
*/
|
||||
private function storeWorkbook(int $productUid, array $parsed, string $filename): void
|
||||
{
|
||||
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tx_vitec_product_workbook');
|
||||
$values = [
|
||||
'filename' => mb_substr($filename, 0, 255),
|
||||
'payload' => (string)json_encode($parsed, JSON_UNESCAPED_UNICODE),
|
||||
'be_user' => 0,
|
||||
'tstamp' => time(),
|
||||
];
|
||||
if ($connection->count('product_uid', 'tx_vitec_product_workbook', ['product_uid' => $productUid]) > 0) {
|
||||
$connection->update('tx_vitec_product_workbook', $values, ['product_uid' => $productUid]);
|
||||
} else {
|
||||
$connection->insert('tx_vitec_product_workbook', $values + ['product_uid' => $productUid]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,25 @@ final class ImportSuccessStoriesCommand extends Command
|
||||
'accomodation' => 'Accommodation', 'accommodation' => 'Accommodation',
|
||||
];
|
||||
|
||||
/**
|
||||
* --markets-only: old industry names -> the nine main markets of the
|
||||
* current (SEO-CSV) taxonomy, decision 2026-08-26. The repair mode never
|
||||
* creates market records; a target title that does not exist is reported
|
||||
* as unresolved instead. Titles verified against the live records.
|
||||
*/
|
||||
private const INDUSTRY_TO_MARKET = [
|
||||
'Sports' => 'Sports, Venues & Entertainment',
|
||||
'Venues' => 'Sports, Venues & Entertainment',
|
||||
'Government' => 'Defense, Government & Public Sector',
|
||||
'Military' => 'Defense, Government & Public Sector',
|
||||
'Corporate' => 'Corporate & Enterprise',
|
||||
'Broadcast' => 'Media, Broadcast & Telecom',
|
||||
'Education' => 'Healthcare & Education',
|
||||
'Healthcare' => 'Healthcare & Education',
|
||||
'Hospitality & Leisure' => 'Retail, Hospitality & Leisure',
|
||||
'Accommodation' => 'Retail, Hospitality & Leisure',
|
||||
];
|
||||
|
||||
/** CTypes that never become content elements. */
|
||||
private const SKIP_CTYPES = [
|
||||
'shortcut', 'div', 'fluxbs5templates_buttonlink', 'mask_web__jumpmenu',
|
||||
@@ -88,6 +107,7 @@ final class ImportSuccessStoriesCommand extends Command
|
||||
$this->addOption('only', null, InputOption::VALUE_REQUIRED, 'Import only the story whose slug tail matches');
|
||||
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Analyse and report only, write nothing');
|
||||
$this->addOption('force', null, InputOption::VALUE_NONE, 'Delete + recreate stories that already exist (matched by slug)');
|
||||
$this->addOption('markets-only', null, InputOption::VALUE_NONE, 'Repair mode: only rewrite the markets relation of EXISTING records, touch nothing else');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
@@ -131,6 +151,10 @@ final class ImportSuccessStoriesCommand extends Command
|
||||
$dryRun ? 'DRY-RUN' : 'LIVE'
|
||||
));
|
||||
|
||||
if ((bool)$input->getOption('markets-only')) {
|
||||
return $this->relinkMarkets($stories, $only, $dryRun, $output);
|
||||
}
|
||||
|
||||
if (!$dryRun) {
|
||||
$this->ensureMarkets($output, $storagePid);
|
||||
}
|
||||
@@ -158,6 +182,110 @@ final class ImportSuccessStoriesCommand extends Command
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* --markets-only repair mode. Re-derives each story's industries from the
|
||||
* export exactly like the full import, translates them onto the current
|
||||
* taxonomy (INDUSTRY_TO_MARKET) and rewrites ONLY the `markets` MM
|
||||
* relation of the existing usecase record - no other field is touched,
|
||||
* no record is created or deleted anywhere (unlike the full import this
|
||||
* mode never creates market records either). Stories whose target market
|
||||
* does not exist keep their current relations and are reported.
|
||||
*
|
||||
* @param array<int,array<string,mixed>> $stories
|
||||
*/
|
||||
private function relinkMarkets(array $stories, string $only, bool $dryRun, OutputInterface $output): int
|
||||
{
|
||||
$marketConn = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tx_vitec_domain_model_market');
|
||||
foreach ($marketConn->select(['uid', 'title'], 'tx_vitec_domain_model_market', ['deleted' => 0])->fetchAllAssociative() as $marketRow) {
|
||||
$this->marketUidByTitle[$this->normTitle((string)$marketRow['title'])] = (int)$marketRow['uid'];
|
||||
}
|
||||
|
||||
$conn = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable(self::TABLE);
|
||||
$datamap = [];
|
||||
$missing = 0;
|
||||
$unresolved = 0;
|
||||
|
||||
foreach ($stories as $page) {
|
||||
$slugTail = basename((string)$page['slug']);
|
||||
if ($only !== '' && $slugTail !== $only) {
|
||||
continue;
|
||||
}
|
||||
$slug = '/' . $slugTail;
|
||||
$card = $this->cardByPageUid[(int)$page['uid']] ?? null;
|
||||
$useCase = $this->useCaseByKey[$this->normTitle((string)$page['title'])] ?? null;
|
||||
$industries = $this->industriesForStory($page, $card, $useCase);
|
||||
|
||||
$existing = $conn->select(['uid'], self::TABLE, ['slug' => $slug, 'deleted' => 0])->fetchAssociative()
|
||||
?: $conn->select(['uid'], self::TABLE, ['slug' => $slugTail, 'deleted' => 0])->fetchAssociative();
|
||||
|
||||
if (!$existing) {
|
||||
$missing++;
|
||||
$output->writeln(sprintf(' <comment>%-52s</comment> no record for slug %s', $slugTail, $slug));
|
||||
continue;
|
||||
}
|
||||
|
||||
$targets = array_values(array_unique(array_map(
|
||||
static fn(string $industry): string => self::INDUSTRY_TO_MARKET[$industry] ?? $industry,
|
||||
$industries
|
||||
)));
|
||||
$uids = array_values(array_unique(array_filter(array_map(
|
||||
fn(string $title): int => $this->marketUidByTitle[$this->normTitle($title)] ?? 0,
|
||||
$targets
|
||||
))));
|
||||
|
||||
if ($uids === []) {
|
||||
$unresolved++;
|
||||
$output->writeln(sprintf(
|
||||
' <comment>%-52s</comment> uid %-5d [%s] => [%s] -> unresolved, left untouched',
|
||||
$slugTail,
|
||||
(int)$existing['uid'],
|
||||
implode(',', $industries),
|
||||
implode(',', $targets)
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
$output->writeln(sprintf(
|
||||
' %-54s uid %-5d [%s] => [%s] -> markets %s',
|
||||
$slugTail,
|
||||
(int)$existing['uid'],
|
||||
implode(',', $industries),
|
||||
implode(',', $targets),
|
||||
implode(',', $uids)
|
||||
));
|
||||
$datamap[self::TABLE][(int)$existing['uid']] = ['markets' => implode(',', $uids)];
|
||||
}
|
||||
|
||||
$relinked = count($datamap[self::TABLE] ?? []);
|
||||
|
||||
if ($dryRun) {
|
||||
$output->writeln(sprintf(
|
||||
'<info>DRY-RUN: %d stories would be relinked, %d without record, %d unresolved.</info>',
|
||||
$relinked,
|
||||
$missing,
|
||||
$unresolved
|
||||
));
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
if ($datamap !== []) {
|
||||
$dh = GeneralUtility::makeInstance(DataHandler::class);
|
||||
$dh->start($datamap, []);
|
||||
$dh->process_datamap();
|
||||
foreach ($dh->errorLog as $error) {
|
||||
$output->writeln("<error>$error</error>");
|
||||
}
|
||||
}
|
||||
$output->writeln(sprintf(
|
||||
'<info>%d stories relinked, %d without record, %d unresolved.</info>',
|
||||
$relinked,
|
||||
$missing,
|
||||
$unresolved
|
||||
));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
// =========================================================== index building
|
||||
|
||||
private function buildIndexes(): void
|
||||
@@ -268,7 +396,7 @@ final class ImportSuccessStoriesCommand extends Command
|
||||
}
|
||||
|
||||
/** Ensure one market record per canonical industry; fill title->uid map. */
|
||||
private function ensureMarkets(OutputInterface $output, int $fallbackPid): void
|
||||
private function ensureMarkets(OutputInterface $output, int $fallbackPid, bool $create = true): void
|
||||
{
|
||||
$conn = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tx_vitec_domain_model_market');
|
||||
$rows = $conn->select(['uid', 'title', 'pid'], 'tx_vitec_domain_model_market', ['deleted' => 0])->fetchAllAssociative();
|
||||
@@ -285,6 +413,12 @@ final class ImportSuccessStoriesCommand extends Command
|
||||
}
|
||||
}
|
||||
if ($datamap !== []) {
|
||||
if (!$create) {
|
||||
foreach ($datamap['tx_vitec_domain_model_market'] as $fields) {
|
||||
$output->writeln(" <comment>would create market: {$fields['title']}</comment>");
|
||||
}
|
||||
return;
|
||||
}
|
||||
$dh = GeneralUtility::makeInstance(DataHandler::class);
|
||||
$dh->start($datamap, []);
|
||||
$dh->process_datamap();
|
||||
|
||||
274
packages/vitec/Classes/Command/LinkProductDownloadsCommand.php
Normal file
274
packages/vitec/Classes/Command/LinkProductDownloadsCommand.php
Normal file
@@ -0,0 +1,274 @@
|
||||
<?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;
|
||||
|
||||
/**
|
||||
* Link the imported download records (vitec:import-downloads, 2026-08-06) to
|
||||
* their products via `downloads` (tx_vitec_product_download_mm) by matching
|
||||
* the download title against the product titles.
|
||||
*
|
||||
* Deliberately conservative: a download is linked ONLY when the match is
|
||||
* unique. Matching per download title, in this order:
|
||||
*
|
||||
* 1. title minus a trailing document-type phrase ("... Datasheet",
|
||||
* "... Brochure", "... User Guide", ...) == product title
|
||||
* 2. additionally with a leading brand word (VITEC / Datapath) removed
|
||||
* 3. additionally with a trailing role word (Transmitter / Receiver /
|
||||
* Encoder / Decoder / Card) removed - "Aligo TX100 Transmitter
|
||||
* Datasheet" -> "TX100"
|
||||
*
|
||||
* All comparisons on a normalized form (lowercase, alphanumerics only).
|
||||
* Existing links are never touched, nothing is removed; a download that
|
||||
* matches nothing (or more than one product) is reported for manual review.
|
||||
* Idempotent: already-linked pairs are skipped.
|
||||
*
|
||||
* vendor/bin/typo3 vitec:link-product-downloads --dry-run
|
||||
* vendor/bin/typo3 vitec:link-product-downloads
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'vitec:link-product-downloads',
|
||||
description: 'Link download records to products by title match (unique matches only, add-only)'
|
||||
)]
|
||||
final class LinkProductDownloadsCommand extends Command
|
||||
{
|
||||
private const PRODUCT_TABLE = 'tx_vitec_domain_model_product';
|
||||
private const DOWNLOAD_TABLE = 'tx_vitec_domain_model_download';
|
||||
private const MM_TABLE = 'tx_vitec_product_download_mm';
|
||||
|
||||
/** Trailing document-type phrases, longest first. */
|
||||
private const DOC_SUFFIXES = [
|
||||
'quick start guide', 'quick reference guide', 'application note',
|
||||
'success story', 'user guide', 'white paper', 'case study',
|
||||
'whats new', "what's new", 'datasheet', 'data sheet', 'brochure',
|
||||
'firmware', 'software', 'manual', 'flyer', 'guide',
|
||||
];
|
||||
|
||||
/** Trailing role words a download title may carry beyond the product name. */
|
||||
private const ROLE_SUFFIXES = [
|
||||
'capture card', 'graphics card', 'encoder card', 'decoder card',
|
||||
'transmitter', 'receiver', 'transcoder', 'end-point', 'end point',
|
||||
'encoder', 'decoder', 'blade', 'card',
|
||||
];
|
||||
|
||||
/**
|
||||
* Known live-site names that differ from OUR (authoritative) dev names -
|
||||
* normalized document name => dev product title. Dev names never change
|
||||
* (decision 2026-09-04), so the bridge lives here.
|
||||
*/
|
||||
private const ALIASES = [
|
||||
'visionscsdi4' => 'VisionSC-SD14',
|
||||
'visioniosdi4' => 'VisionIO-SD14',
|
||||
'visionsdi2' => 'VisionSD12',
|
||||
'prismsff' => 'SFF PRISM',
|
||||
'sffprism' => 'SFF PRISM',
|
||||
'avediaep6' => 'Avedia End-Points',
|
||||
'eztvep6' => 'EZ TV End-Points',
|
||||
'xp2526' => 'X-Player End-Points',
|
||||
'xp2650' => 'X-Player End-Points',
|
||||
];
|
||||
|
||||
/**
|
||||
* Series documents: regex on the lowercased document name (doc-type
|
||||
* suffix already stripped) => dev product title. May match SEVERAL
|
||||
* records of that title (the Avedia Modular Chassis exists twice by
|
||||
* design - encoder branch and RF branch - and gets its datasheets on
|
||||
* both).
|
||||
*/
|
||||
private const SERIES_RULES = [
|
||||
'/^rf gateway g45\d+/' => '45-series RF gateways',
|
||||
'/^encoder e38\d+/' => '38-Series Encoders',
|
||||
'/^chassis c1(101|103|210)$/' => 'Avedia Modular Chassis',
|
||||
];
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report the plan - write nothing');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
Bootstrap::initializeBackendAuthentication();
|
||||
$dryRun = (bool)$input->getOption('dry-run');
|
||||
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable(self::PRODUCT_TABLE);
|
||||
|
||||
$products = $connection->fetchAllAssociative(
|
||||
'SELECT uid, title FROM ' . self::PRODUCT_TABLE . ' WHERE deleted = 0'
|
||||
);
|
||||
$productByNorm = [];
|
||||
foreach ($products as $product) {
|
||||
$productByNorm[$this->normalize((string)$product['title'])][] = (int)$product['uid'];
|
||||
}
|
||||
$titleByUid = array_column($products, 'title', 'uid');
|
||||
|
||||
$downloads = $connection->fetchAllAssociative(
|
||||
'SELECT uid, title FROM ' . self::DOWNLOAD_TABLE . ' WHERE deleted = 0'
|
||||
);
|
||||
|
||||
$existing = [];
|
||||
foreach ($connection->fetchAllAssociative('SELECT uid_local, uid_foreign FROM ' . self::MM_TABLE) as $row) {
|
||||
$existing[(int)$row['uid_local']][] = (int)$row['uid_foreign'];
|
||||
}
|
||||
|
||||
$additions = []; // product uid => download uids to add
|
||||
$unmatched = [];
|
||||
$ambiguous = [];
|
||||
|
||||
foreach ($downloads as $download) {
|
||||
$downloadUid = (int)$download['uid'];
|
||||
$name = $this->stripDocSuffix((string)$download['title']);
|
||||
if ($name === null) {
|
||||
// No document-type suffix - not a per-product document
|
||||
// (company brochures etc.); leave those to manual linking.
|
||||
$unmatched[] = $download['title'] . ' (kein Dokumenttyp-Suffix)';
|
||||
continue;
|
||||
}
|
||||
|
||||
// Aliases and series rules may map to several records on purpose;
|
||||
// generic title matches must stay unique.
|
||||
$multiAllowed = false;
|
||||
$candidates = [];
|
||||
$aliasTitle = self::ALIASES[$this->normalize($name)] ?? null;
|
||||
if ($aliasTitle === null) {
|
||||
foreach (self::SERIES_RULES as $pattern => $title) {
|
||||
if (preg_match($pattern, mb_strtolower($name))) {
|
||||
$aliasTitle = $title;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($aliasTitle !== null) {
|
||||
$candidates = $productByNorm[$this->normalize($aliasTitle)] ?? [];
|
||||
$multiAllowed = true;
|
||||
}
|
||||
if ($candidates === []) {
|
||||
$candidates = $this->matchProduct($name, $productByNorm);
|
||||
}
|
||||
|
||||
if ($candidates === []) {
|
||||
$unmatched[] = $download['title'];
|
||||
continue;
|
||||
}
|
||||
if (count($candidates) > 1 && !$multiAllowed) {
|
||||
$ambiguous[] = $download['title'];
|
||||
continue;
|
||||
}
|
||||
foreach ($candidates as $productUid) {
|
||||
if (in_array($downloadUid, $existing[$productUid] ?? [], true)) {
|
||||
continue; // already linked
|
||||
}
|
||||
$additions[$productUid][] = $downloadUid;
|
||||
$output->writeln(sprintf('%s "%s" (dl %d) -> %d %s',
|
||||
$dryRun ? 'plan ' : 'link ', $download['title'], $downloadUid, $productUid, $titleByUid[$productUid]));
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($unmatched as $title) {
|
||||
$output->writeln('<comment>kein Treffer: ' . $title . '</comment>');
|
||||
}
|
||||
foreach ($ambiguous as $title) {
|
||||
$output->writeln('<comment>MEHRDEUTIG: ' . $title . '</comment>');
|
||||
}
|
||||
|
||||
$pairCount = array_sum(array_map('count', $additions));
|
||||
$output->writeln(sprintf("\n%d neue Verknuepfung(en) auf %d Produkt(e); %d ohne Treffer, %d mehrdeutig.",
|
||||
$pairCount, count($additions), count($unmatched), count($ambiguous)));
|
||||
|
||||
if ($additions === []) {
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
if ($dryRun) {
|
||||
$output->writeln('DRY RUN - nichts geschrieben.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$datamap = [];
|
||||
foreach ($additions as $productUid => $downloadUids) {
|
||||
$merged = array_merge($existing[$productUid] ?? [], $downloadUids);
|
||||
$datamap[self::PRODUCT_TABLE][$productUid]['downloads'] = implode(',', array_unique($merged));
|
||||
}
|
||||
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||
$dataHandler->start($datamap, []);
|
||||
$dataHandler->process_datamap();
|
||||
if ($dataHandler->errorLog !== []) {
|
||||
foreach ($dataHandler->errorLog as $error) {
|
||||
$output->writeln('<error>' . $error . '</error>');
|
||||
}
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$output->writeln('Fertig. Cache leeren: vendor/bin/typo3 cache:flush');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/** Title without its trailing document-type phrase; null when none found. */
|
||||
private function stripDocSuffix(string $title): ?string
|
||||
{
|
||||
$norm = mb_strtolower(trim($title));
|
||||
foreach (self::DOC_SUFFIXES as $suffix) {
|
||||
if (str_ends_with($norm, $suffix)) {
|
||||
return trim(mb_substr(trim($title), 0, mb_strlen(trim($title)) - mb_strlen($suffix)));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unique product uids matching the document name, applying the passes
|
||||
* described in the class comment.
|
||||
*
|
||||
* @param array<string,int[]> $productByNorm
|
||||
* @return int[]
|
||||
*/
|
||||
private function matchProduct(string $name, array $productByNorm): array
|
||||
{
|
||||
$variants = [$name];
|
||||
// leading brand word
|
||||
$stripped = preg_replace('/^(vitec|datapath)\s+/i', '', $name);
|
||||
if ($stripped !== $name) {
|
||||
$variants[] = $stripped;
|
||||
}
|
||||
// trailing role word (on both variants)
|
||||
foreach ($variants as $variant) {
|
||||
foreach (self::ROLE_SUFFIXES as $role) {
|
||||
if (preg_match('/\s+' . preg_quote($role, '/') . '$/i', $variant)) {
|
||||
$variants[] = trim(preg_replace('/\s+' . preg_quote($role, '/') . '$/i', '', $variant));
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($variants as $variant) {
|
||||
$norm = $this->normalize($variant);
|
||||
// aliases also apply to the stripped variants ("VisionSC SDI4
|
||||
// Capture Card" -> "VisionSC SDI4" -> alias -> VisionSC-SD14)
|
||||
$aliasTitle = self::ALIASES[$norm] ?? null;
|
||||
if ($aliasTitle !== null) {
|
||||
$norm = $this->normalize($aliasTitle);
|
||||
}
|
||||
$hit = $productByNorm[$norm] ?? [];
|
||||
if ($hit !== []) {
|
||||
return $hit;
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* lowercase, alphanumerics only - "TX1/F" and "tx1 f" compare equal.
|
||||
* "+" survives as "plus", otherwise "MGW Diamond+ OG" and
|
||||
* "MGW Diamond OG" would collide.
|
||||
*/
|
||||
private function normalize(string $value): string
|
||||
{
|
||||
return (string)preg_replace('/[^a-z0-9]+/', '', str_replace('+', 'plus', mb_strtolower($value)));
|
||||
}
|
||||
}
|
||||
176
packages/vitec/Classes/Command/MapSolutionPagesCommand.php
Normal file
176
packages/vitec/Classes/Command/MapSolutionPagesCommand.php
Normal file
@@ -0,0 +1,176 @@
|
||||
<?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;
|
||||
|
||||
/**
|
||||
* Map backend pages to solution records: writes the page uid into the
|
||||
* solution's `detail_page` field (2026-09-04, request before the weekend).
|
||||
*
|
||||
* Matching per solution, conservative:
|
||||
* 1. page title == solution title (normalized: lowercase, alphanumerics,
|
||||
* "+" -> "plus")
|
||||
* 2. last page-slug segment == solution slug
|
||||
* Only UNIQUE matches are written; `detail_page` is fill-only (a record that
|
||||
* already points at a page is never changed - a differing find is reported).
|
||||
* Hidden pages match too (pages may be unpublished while content is built);
|
||||
* deleted and sys-folder/link pages never.
|
||||
*
|
||||
* vendor/bin/typo3 vitec:map-solution-pages --dry-run
|
||||
* vendor/bin/typo3 vitec:map-solution-pages
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'vitec:map-solution-pages',
|
||||
description: 'Write matching page uids into the solutions\' detail_page field (unique matches, fill-only)'
|
||||
)]
|
||||
final class MapSolutionPagesCommand extends Command
|
||||
{
|
||||
private const TABLE = 'tx_vitec_domain_model_solution';
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report the plan - write nothing');
|
||||
$this->addOption('prune-duplicate-pages', null, InputOption::VALUE_NONE, 'Soft-delete duplicate pages (slug suffix -N, zero content elements) that lost the disambiguation');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
Bootstrap::initializeBackendAuthentication();
|
||||
$dryRun = (bool)$input->getOption('dry-run');
|
||||
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable(self::TABLE);
|
||||
|
||||
$solutions = $connection->fetchAllAssociative(
|
||||
'SELECT uid, title, slug, detail_page FROM ' . self::TABLE . ' WHERE deleted = 0 ORDER BY title'
|
||||
);
|
||||
|
||||
// standard pages only (doktype 1); hidden allowed, deleted not
|
||||
$pages = $connection->fetchAllAssociative(
|
||||
'SELECT uid, pid, title, slug, hidden FROM pages WHERE deleted = 0 AND doktype = 1'
|
||||
);
|
||||
$pagesByNormTitle = [];
|
||||
$pagesBySlugTail = [];
|
||||
foreach ($pages as $page) {
|
||||
$pagesByNormTitle[$this->normalize((string)$page['title'])][] = $page;
|
||||
$tail = strtolower(trim((string)strrchr('/' . trim((string)$page['slug'], '/'), '/'), '/'));
|
||||
if ($tail !== '') {
|
||||
$pagesBySlugTail[$tail][] = $page;
|
||||
}
|
||||
}
|
||||
|
||||
$datamap = [];
|
||||
$planned = 0;
|
||||
foreach ($solutions as $solution) {
|
||||
$uid = (int)$solution['uid'];
|
||||
$candidates = $pagesByNormTitle[$this->normalize((string)$solution['title'])] ?? [];
|
||||
if ($candidates === [] && (string)$solution['slug'] !== '') {
|
||||
$candidates = $pagesBySlugTail[strtolower((string)$solution['slug'])] ?? [];
|
||||
}
|
||||
if ($candidates === []) {
|
||||
$output->writeln(sprintf('<comment>keine Seite: %s</comment>', $solution['title']));
|
||||
continue;
|
||||
}
|
||||
$duplicatePages = [];
|
||||
if (count($candidates) > 1) {
|
||||
// Page-tree duplicates carry TYPO3's slug dedupe suffix "-N"
|
||||
// (found 2026-09-04: two solution branches were created twice,
|
||||
// empty). Prefer the clean slug; the suffixed ones can be
|
||||
// pruned via --prune-duplicate-pages.
|
||||
$clean = array_values(array_filter($candidates, static fn(array $p): bool => !preg_match('/-\d+$/', (string)$p['slug'])));
|
||||
if (count($clean) === 1) {
|
||||
$duplicatePages = array_values(array_filter($candidates, static fn(array $p): bool => (int)$p['uid'] !== (int)$clean[0]['uid']));
|
||||
$candidates = $clean;
|
||||
}
|
||||
}
|
||||
if (count($candidates) > 1) {
|
||||
$output->writeln(sprintf('<comment>MEHRDEUTIG (%d Seiten): %s - uids %s</comment>',
|
||||
count($candidates), $solution['title'], implode(',', array_column($candidates, 'uid'))));
|
||||
continue;
|
||||
}
|
||||
foreach ($duplicatePages as $duplicate) {
|
||||
$this->duplicates[(int)$duplicate['uid']] = (string)$duplicate['slug'];
|
||||
}
|
||||
$page = $candidates[0];
|
||||
$pageUid = (int)$page['uid'];
|
||||
$current = (int)$solution['detail_page'];
|
||||
if ($current === $pageUid) {
|
||||
$output->writeln(sprintf('ok %s -> Seite %d (bereits gesetzt)', $solution['title'], $pageUid));
|
||||
continue;
|
||||
}
|
||||
if ($current > 0) {
|
||||
$output->writeln(sprintf('<comment>KONFLIKT: %s hat detail_page=%d, Match waere Seite %d "%s" - nicht angefasst</comment>',
|
||||
$solution['title'], $current, $pageUid, $page['title']));
|
||||
continue;
|
||||
}
|
||||
$datamap[self::TABLE][$uid]['detail_page'] = $pageUid;
|
||||
$planned++;
|
||||
$output->writeln(sprintf('%s %s -> Seite %d "%s" (/%s)%s',
|
||||
$dryRun ? 'plan ' : 'WRITE ', $solution['title'], $pageUid, $page['title'],
|
||||
trim((string)$page['slug'], '/'), $page['hidden'] ? ' [Seite versteckt]' : ''));
|
||||
}
|
||||
|
||||
$output->writeln(sprintf("\n%d von %d Solutions zu mappen.", $planned, count($solutions)));
|
||||
|
||||
$prune = (bool)$input->getOption('prune-duplicate-pages');
|
||||
$cmdmap = [];
|
||||
foreach ($this->duplicates as $pageUid => $slug) {
|
||||
$contentCount = (int)$connection->fetchOne(
|
||||
'SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND pid = ?', [$pageUid]
|
||||
);
|
||||
if ($contentCount > 0) {
|
||||
$output->writeln(sprintf('<comment>Duplikat-Seite %d (/%s) hat %d Inhaltselemente - NICHT geloescht</comment>', $pageUid, trim($slug, '/'), $contentCount));
|
||||
continue;
|
||||
}
|
||||
if ($prune) {
|
||||
$cmdmap['pages'][$pageUid]['delete'] = 1;
|
||||
}
|
||||
$output->writeln(sprintf('%s Duplikat-Seite %d (/%s, leer)', $prune ? ($dryRun ? 'plan-DELETE' : 'DELETE ') : 'Duplikat: ', $pageUid, trim($slug, '/')));
|
||||
}
|
||||
if (!$prune && $this->duplicates !== []) {
|
||||
$output->writeln('(Duplikat-Seiten bleiben stehen - mit --prune-duplicate-pages loeschen.)');
|
||||
}
|
||||
|
||||
if ($datamap === [] && $cmdmap === []) {
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
if ($dryRun) {
|
||||
$output->writeln('DRY RUN - nichts geschrieben.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||
$dataHandler->start($datamap, $cmdmap);
|
||||
if ($datamap !== []) {
|
||||
$dataHandler->process_datamap();
|
||||
}
|
||||
if ($cmdmap !== []) {
|
||||
$dataHandler->process_cmdmap();
|
||||
}
|
||||
if ($dataHandler->errorLog !== []) {
|
||||
foreach ($dataHandler->errorLog as $error) {
|
||||
$output->writeln('<error>' . $error . '</error>');
|
||||
}
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$output->writeln('Fertig. Cache leeren: vendor/bin/typo3 cache:flush');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/** @var array<int,string> duplicate page uid => slug, collected during matching */
|
||||
private array $duplicates = [];
|
||||
|
||||
private function normalize(string $value): string
|
||||
{
|
||||
return (string)preg_replace('/[^a-z0-9]+/', '', str_replace('+', 'plus', mb_strtolower($value)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Evomedien\Vitec\Command;
|
||||
|
||||
use Doctrine\DBAL\ParameterType;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Aligns the product records with the agency's "Merged site map" V2
|
||||
* (Products tab, columns J/K/L - the binding structure per decision
|
||||
* 2026-09-04). The sys_category tree already matches V2; this command fixes
|
||||
* the records:
|
||||
*
|
||||
* Model: K-level entries ("Products") are landing records - one product
|
||||
* record per level-2 category, same title as the category, subproduct=0,
|
||||
* its sub-products teasered via `relatedprodukt`. L-level entries
|
||||
* ("Sub-Products") are classic detail-page products with subproduct=1.
|
||||
* A record is recognized as K-record by title == title of its category.
|
||||
*
|
||||
* Steps (in order, each idempotent):
|
||||
* 1. uid 36 "Aligo": slug aligo-workstation -> aligo (frees the slug for
|
||||
* the new Aligo Workstation record), category Operator Workstations ->
|
||||
* Aligo (decision 3a: uid 36 stays the platform landing, the imported
|
||||
* workbook content stays put)
|
||||
* 2. renames to the exact V2 strings (4 records) + Mileston -> Milestone
|
||||
* (typo, also in V2 itself; incl. slug)
|
||||
* 3. QTX100: drop the stray VSN category (V2: Aligo only)
|
||||
* 4. create the L-record "Aligo Workstation" (Operator Workstations)
|
||||
* 5. create the missing K-records (27 categories without a landing record)
|
||||
* 6. subproduct flag: 1 for every L-record, 0 for K-records
|
||||
* 7. relatedprodukt of K-records: when EMPTY, fill with the products of
|
||||
* the K category (curated lists - e.g. Aligo's imported aliases - are
|
||||
* never touched)
|
||||
*
|
||||
* Status cases (APEX not launched, 39-Series coming soon, MGES EOL) stay
|
||||
* visible per decision 2026-09-04.
|
||||
*
|
||||
* vendor/bin/typo3 vitec:migrate-product-structure --dry-run
|
||||
* vendor/bin/typo3 vitec:migrate-product-structure
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'vitec:migrate-product-structure',
|
||||
description: 'Align product records with the Merged-site-map V2 structure (K landing records, L subproduct flags)'
|
||||
)]
|
||||
final class MigrateProductStructureCommand extends Command
|
||||
{
|
||||
private const TABLE = 'tx_vitec_domain_model_product';
|
||||
|
||||
/** Exact V2 titles for records whose current title deviates. */
|
||||
private const RENAMES = [
|
||||
6 => 'Avedia End-Points (EP6, 95-Series)',
|
||||
11 => 'MGW Diamond-H (HDMI encoder)',
|
||||
14 => 'MGW Ace decoder (ultra-low latency decoder)',
|
||||
21 => 'MGW Diamond-Hx OG (HDMI/DVI blade encoder)',
|
||||
55 => 'Milestone',
|
||||
];
|
||||
|
||||
/**
|
||||
* K-level entries: category title => slug for the landing record.
|
||||
* Slugs are explicit because several natural ones are taken by L-records
|
||||
* (aetria, channellink, activesqx, visionav ...).
|
||||
*/
|
||||
private const K_RECORDS = [
|
||||
'Avedia Platform' => 'avedia-platform',
|
||||
'EZ TV Platform' => 'ez-tv-platform',
|
||||
'APEX Platform' => 'apex-platform',
|
||||
'Appliances' => 'appliances',
|
||||
'Avedia Modular System' => 'avedia-modular-system',
|
||||
'VITEC OG Modular System' => 'vitec-og-modular-system',
|
||||
'MGW Blade System' => 'mgw-blade-system',
|
||||
'Avedia Modular System (RF gateways)' => 'avedia-modular-system-rf-gateways',
|
||||
'ChannelLink (IP to IP gateways)' => 'channellink-ip-to-ip-gateways',
|
||||
'PRISM Transcoder' => 'prism-transcoder',
|
||||
'Aetria' => 'aetria-platform',
|
||||
'Operator Workstations' => 'operator-workstations',
|
||||
'Aligo' => 'aligo',
|
||||
'Arqa' => 'arqa',
|
||||
'VSN' => 'vsn',
|
||||
'Video Wall Management Software' => 'video-wall-management-software',
|
||||
'X-Series (Multi-display Processors)' => 'x-series-multi-display-processors',
|
||||
'VMS Plugins & Integrations' => 'vms-plugins-integrations',
|
||||
'Image Graphics Cards (multi-output GPU cards)' => 'image-graphics-cards',
|
||||
'IQS4 (4K splitter)' => 'iqs4-4k-splitter',
|
||||
'VisionSC (scalable capture & processing)' => 'visionsc-scalable-capture-processing',
|
||||
'VisionIO (real-time capture + overlay)' => 'visionio-real-time-capture-overlay',
|
||||
'VisionAV (video + audio capture)' => 'visionav-video-audio-capture',
|
||||
'Vision (DVI & SDI capture cards)' => 'vision-dvi-sdi-capture-cards',
|
||||
'VisionLC (low-profile capture cards)' => 'visionlc-low-profile-capture-cards',
|
||||
'ActiveSQX (IP encode/decode)' => 'activesqx-ip-encode-decode',
|
||||
'Express Backplanes' => 'express-backplanes',
|
||||
'Accessories & Cables' => 'accessories-cables',
|
||||
];
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report the plan - write nothing');
|
||||
$this->addOption('pid', null, InputOption::VALUE_REQUIRED, 'Storage pid for new records (default: pid of the existing products)');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
Bootstrap::initializeBackendAuthentication();
|
||||
$dryRun = (bool)$input->getOption('dry-run');
|
||||
|
||||
$categories = $this->productCategories(); // title => uid (level-2 under "Product")
|
||||
$products = $this->products(); // uid => row
|
||||
$productCats = $this->productCategoryMap(); // product uid => [category uids]
|
||||
|
||||
$pid = (int)($input->getOption('pid') ?? 0);
|
||||
if ($pid <= 0) {
|
||||
$pid = $this->detectPid();
|
||||
}
|
||||
$output->writeln(sprintf('%d products, %d level-2 categories, storage pid %d%s',
|
||||
count($products), count($categories), $pid, $dryRun ? ' - DRY RUN' : ''));
|
||||
|
||||
$datamap = [];
|
||||
$newIndex = 0;
|
||||
|
||||
// ---- 1. Aligo (36): slug + category move ---------------------------
|
||||
$aligo = $products[36] ?? null;
|
||||
if ($aligo !== null) {
|
||||
if ($aligo['slug'] === 'aligo-workstation') {
|
||||
$datamap[36]['slug'] = 'aligo';
|
||||
$output->writeln('uid 36 Aligo: slug aligo-workstation -> aligo');
|
||||
}
|
||||
$cats = $productCats[36] ?? [];
|
||||
if (in_array((int)($categories['Operator Workstations'] ?? -1), $cats, true)) {
|
||||
$newCats = array_diff($cats, [(int)$categories['Operator Workstations']]);
|
||||
$newCats[] = (int)$categories['Aligo'];
|
||||
$datamap[36]['categories'] = implode(',', array_unique($newCats));
|
||||
$output->writeln('uid 36 Aligo: category Operator Workstations -> Aligo');
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 2. renames ----------------------------------------------------
|
||||
foreach (self::RENAMES as $uid => $title) {
|
||||
if (isset($products[$uid]) && $products[$uid]['title'] !== $title) {
|
||||
$datamap[$uid]['title'] = $title;
|
||||
$output->writeln(sprintf('uid %d: "%s" -> "%s"', $uid, $products[$uid]['title'], $title));
|
||||
}
|
||||
}
|
||||
if (isset($products[55]) && $products[55]['slug'] === 'mileston') {
|
||||
$datamap[55]['slug'] = 'milestone';
|
||||
$output->writeln('uid 55: slug mileston -> milestone');
|
||||
}
|
||||
|
||||
// ---- 3. QTX100: drop VSN -------------------------------------------
|
||||
$vsnCat = (int)($categories['VSN'] ?? -1);
|
||||
if (isset($productCats[37]) && in_array($vsnCat, $productCats[37], true)) {
|
||||
$datamap[37]['categories'] = implode(',', array_diff($productCats[37], [$vsnCat]));
|
||||
$output->writeln('uid 37 QTX100: category VSN removed (V2: Aligo only)');
|
||||
}
|
||||
|
||||
// ---- 4. + 5. creates ------------------------------------------------
|
||||
// Existing K-record = a product whose title equals the title of one of
|
||||
// its categories.
|
||||
$existsAsK = function (string $catTitle) use ($products, $productCats, $categories): bool {
|
||||
$catUid = (int)($categories[$catTitle] ?? -1);
|
||||
foreach ($products as $uid => $p) {
|
||||
if ($p['title'] === $catTitle && in_array($catUid, $productCats[$uid] ?? [], true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
$slugs = array_column($products, 'slug');
|
||||
|
||||
$hasAligoWorkstation = false;
|
||||
foreach ($products as $p) {
|
||||
if ($p['title'] === 'Aligo Workstation') {
|
||||
$hasAligoWorkstation = true;
|
||||
}
|
||||
}
|
||||
if (!$hasAligoWorkstation) {
|
||||
$newId = 'NEW' . ++$newIndex;
|
||||
$datamap[$newId] = [
|
||||
'pid' => $pid,
|
||||
'title' => 'Aligo Workstation',
|
||||
'slug' => 'aligo-workstation',
|
||||
'categories' => (string)($categories['Operator Workstations'] ?? ''),
|
||||
'subproduct' => 1,
|
||||
];
|
||||
$output->writeln('create L-record: Aligo Workstation (Operator Workstations)');
|
||||
}
|
||||
|
||||
foreach (self::K_RECORDS as $catTitle => $slug) {
|
||||
if (!isset($categories[$catTitle])) {
|
||||
$output->writeln(sprintf('<error>category "%s" not found - skipped</error>', $catTitle));
|
||||
continue;
|
||||
}
|
||||
// uid 36 IS the Aligo K-record; its move into the Aligo category
|
||||
// happens in this very datamap, so $existsAsK cannot see it yet.
|
||||
if ($catTitle === 'Aligo' && isset($products[36])) {
|
||||
continue;
|
||||
}
|
||||
if ($existsAsK($catTitle)) {
|
||||
continue;
|
||||
}
|
||||
if (in_array($slug, $slugs, true) && !($slug === 'aligo' && isset($datamap[36]['slug']))) {
|
||||
$output->writeln(sprintf('<error>slug "%s" already taken - "%s" skipped, resolve manually</error>', $slug, $catTitle));
|
||||
continue;
|
||||
}
|
||||
$newId = 'NEW' . ++$newIndex;
|
||||
$datamap[$newId] = [
|
||||
'pid' => $pid,
|
||||
'title' => $catTitle,
|
||||
'slug' => $slug,
|
||||
'categories' => (string)$categories[$catTitle],
|
||||
'subproduct' => 0,
|
||||
];
|
||||
$output->writeln(sprintf('create K-record: %s (slug %s)', $catTitle, $slug));
|
||||
}
|
||||
|
||||
// ---- 6. subproduct flags on existing records -----------------------
|
||||
$catTitleByUid = array_flip($categories);
|
||||
$flagged = 0;
|
||||
foreach ($products as $uid => $p) {
|
||||
$isK = false;
|
||||
foreach ($productCats[$uid] ?? [] as $catUid) {
|
||||
if (($catTitleByUid[$catUid] ?? null) === $p['title']) {
|
||||
$isK = true;
|
||||
}
|
||||
}
|
||||
// uid 36 becomes K through this run's category move
|
||||
if ($uid === 36 && isset($datamap[36]['categories'])) {
|
||||
$isK = true;
|
||||
}
|
||||
$target = $isK ? 0 : 1;
|
||||
if ((int)$p['subproduct'] !== $target) {
|
||||
$datamap[$uid]['subproduct'] = $target;
|
||||
$flagged++;
|
||||
}
|
||||
}
|
||||
$output->writeln(sprintf('subproduct flags to update: %d records', $flagged));
|
||||
|
||||
if ($datamap === []) {
|
||||
$output->writeln('Nothing to do - structure already matches V2.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
if ($dryRun) {
|
||||
$output->writeln(sprintf('DRY RUN - %d datamap entries, nothing written.', count($datamap)));
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||
$dataHandler->start([self::TABLE => $datamap], []);
|
||||
$dataHandler->process_datamap();
|
||||
if ($dataHandler->errorLog !== []) {
|
||||
foreach ($dataHandler->errorLog as $error) {
|
||||
$output->writeln('<error>' . $error . '</error>');
|
||||
}
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$output->writeln(sprintf('%d records written/created.', count($datamap)));
|
||||
|
||||
// ---- 7. relatedprodukt on K-records (only when empty) --------------
|
||||
// Re-read: creates need their real uids, categories their fresh state.
|
||||
$products = $this->products();
|
||||
$productCats = $this->productCategoryMap();
|
||||
$related = [];
|
||||
foreach ($products as $uid => $p) {
|
||||
$catUid = (int)($categories[$p['title']] ?? -1);
|
||||
if ($catUid < 0 || !in_array($catUid, $productCats[$uid] ?? [], true)) {
|
||||
continue; // not a K-record
|
||||
}
|
||||
if ($this->relatedCount($uid) > 0) {
|
||||
continue; // curated - never touch
|
||||
}
|
||||
$subs = [];
|
||||
foreach ($productCats as $otherUid => $cats) {
|
||||
if ($otherUid !== $uid && in_array($catUid, $cats, true)) {
|
||||
$subs[] = $otherUid;
|
||||
}
|
||||
}
|
||||
sort($subs);
|
||||
if ($subs !== []) {
|
||||
$related[$uid] = ['relatedprodukt' => implode(',', $subs)];
|
||||
$output->writeln(sprintf('K-record %d %s: relatedprodukt = %s', $uid, $p['title'], implode(',', $subs)));
|
||||
}
|
||||
}
|
||||
if ($related !== []) {
|
||||
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||
$dataHandler->start([self::TABLE => $related], []);
|
||||
$dataHandler->process_datamap();
|
||||
if ($dataHandler->errorLog !== []) {
|
||||
foreach ($dataHandler->errorLog as $error) {
|
||||
$output->writeln('<error>' . $error . '</error>');
|
||||
}
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
$output->writeln('Done. Flush the frontend cache to publish: vendor/bin/typo3 cache:flush');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/** @return array<string,int> level-2 category title => uid (parents under root "Product") */
|
||||
private function productCategories(): array
|
||||
{
|
||||
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_category');
|
||||
$rootUid = (int)$qb->select('uid')->from('sys_category')
|
||||
->where(
|
||||
$qb->expr()->eq('deleted', 0),
|
||||
$qb->expr()->eq('parent', 0),
|
||||
$qb->expr()->eq('title', $qb->createNamedParameter('Product', ParameterType::STRING))
|
||||
)->executeQuery()->fetchOne();
|
||||
|
||||
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_category');
|
||||
$level1 = $qb->select('uid')->from('sys_category')
|
||||
->where($qb->expr()->eq('deleted', 0), $qb->expr()->eq('parent', $rootUid))
|
||||
->executeQuery()->fetchFirstColumn();
|
||||
if ($level1 === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_category');
|
||||
$rows = $qb->select('uid', 'title')->from('sys_category')
|
||||
->where($qb->expr()->eq('deleted', 0), $qb->expr()->in('parent', array_map('intval', $level1)))
|
||||
->executeQuery()->fetchAllAssociative();
|
||||
$map = [];
|
||||
foreach ($rows as $row) {
|
||||
$map[(string)$row['title']] = (int)$row['uid'];
|
||||
}
|
||||
return $map;
|
||||
}
|
||||
|
||||
/** @return array<int,array<string,mixed>> */
|
||||
private function products(): array
|
||||
{
|
||||
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::TABLE);
|
||||
$rows = $qb->select('uid', 'pid', 'title', 'slug', 'subproduct')->from(self::TABLE)
|
||||
->where($qb->expr()->eq('deleted', 0))
|
||||
->executeQuery()->fetchAllAssociative();
|
||||
$map = [];
|
||||
foreach ($rows as $row) {
|
||||
$map[(int)$row['uid']] = $row;
|
||||
}
|
||||
return $map;
|
||||
}
|
||||
|
||||
/** @return array<int,int[]> product uid => category uids */
|
||||
private function productCategoryMap(): array
|
||||
{
|
||||
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_category_record_mm');
|
||||
$rows = $qb->select('uid_local', 'uid_foreign')->from('sys_category_record_mm')
|
||||
->where(
|
||||
$qb->expr()->eq('tablenames', $qb->createNamedParameter(self::TABLE, ParameterType::STRING)),
|
||||
$qb->expr()->eq('fieldname', $qb->createNamedParameter('categories', ParameterType::STRING))
|
||||
)->executeQuery()->fetchAllAssociative();
|
||||
$map = [];
|
||||
foreach ($rows as $row) {
|
||||
$map[(int)$row['uid_foreign']][] = (int)$row['uid_local'];
|
||||
}
|
||||
return $map;
|
||||
}
|
||||
|
||||
private function relatedCount(int $productUid): int
|
||||
{
|
||||
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('tx_vitec_product_related_mm');
|
||||
return (int)$qb->count('*')->from('tx_vitec_product_related_mm')
|
||||
->where($qb->expr()->eq('uid_local', $qb->createNamedParameter($productUid, ParameterType::INTEGER)))
|
||||
->executeQuery()->fetchOne();
|
||||
}
|
||||
|
||||
private function detectPid(): int
|
||||
{
|
||||
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::TABLE);
|
||||
$pids = $qb->select('pid')->from(self::TABLE)
|
||||
->where($qb->expr()->eq('deleted', 0))
|
||||
->executeQuery()->fetchFirstColumn();
|
||||
if ($pids === []) {
|
||||
return 0;
|
||||
}
|
||||
$counts = array_count_values(array_map('intval', $pids));
|
||||
arsort($counts);
|
||||
return (int)array_key_first($counts);
|
||||
}
|
||||
}
|
||||
153
packages/vitec/Classes/Command/RemoveFamilyRecordsCommand.php
Normal file
153
packages/vitec/Classes/Command/RemoveFamilyRecordsCommand.php
Normal file
@@ -0,0 +1,153 @@
|
||||
<?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;
|
||||
|
||||
/**
|
||||
* Removes the K-level "family landing" product records again.
|
||||
*
|
||||
* Background (2026-09-04): the V2 structure migration created one landing
|
||||
* record per level-2 product category. The model decision changed the same
|
||||
* day - product families are ordinary, manually maintained CONTENT PAGES
|
||||
* with cards, not records - so those empty landing records would only
|
||||
* pollute lists and the search index.
|
||||
*
|
||||
* A record qualifies for deletion ONLY when ALL of these hold:
|
||||
* - its title equals the title of one of its level-2 categories
|
||||
* (the K-record signature),
|
||||
* - subproduct = 0,
|
||||
* - teaser, description and capabilities are all empty
|
||||
* (a content-bearing record is never deleted - it is reported instead),
|
||||
* - it is not Aligo (36) or Arqa (93): they carry imported workbook
|
||||
* content and stay until their family pages are built.
|
||||
*
|
||||
* Deletion goes through DataHandler (soft delete, history, relation
|
||||
* handling). Idempotent: a second run finds nothing.
|
||||
*
|
||||
* vendor/bin/typo3 vitec:remove-family-records --dry-run
|
||||
* vendor/bin/typo3 vitec:remove-family-records
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'vitec:remove-family-records',
|
||||
description: 'Delete the empty K-level family landing records (families are content pages now)'
|
||||
)]
|
||||
final class RemoveFamilyRecordsCommand extends Command
|
||||
{
|
||||
private const TABLE = 'tx_vitec_domain_model_product';
|
||||
|
||||
/** Carry imported workbook content - kept until their family pages exist. */
|
||||
private const KEEP = [36, 93];
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report the candidates - delete nothing');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
Bootstrap::initializeBackendAuthentication();
|
||||
$dryRun = (bool)$input->getOption('dry-run');
|
||||
|
||||
// Level-2 category titles under root "Product" (parent chain root->J->K).
|
||||
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('sys_category');
|
||||
$rootUid = (int)$connection->fetchOne(
|
||||
"SELECT uid FROM sys_category WHERE deleted = 0 AND parent = 0 AND title = 'Product'"
|
||||
);
|
||||
$level1 = $connection->fetchFirstColumn(
|
||||
'SELECT uid FROM sys_category WHERE deleted = 0 AND parent = ?',
|
||||
[$rootUid]
|
||||
);
|
||||
if ($level1 === []) {
|
||||
$output->writeln('<error>No level-1 product categories found.</error>');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$placeholders = implode(',', array_fill(0, count($level1), '?'));
|
||||
$catTitleByUid = [];
|
||||
foreach ($connection->fetchAllAssociative(
|
||||
"SELECT uid, title FROM sys_category WHERE deleted = 0 AND parent IN ($placeholders)",
|
||||
array_map('intval', $level1)
|
||||
) as $row) {
|
||||
$catTitleByUid[(int)$row['uid']] = (string)$row['title'];
|
||||
}
|
||||
|
||||
// Products with their category assignments and the content probe.
|
||||
$productConnection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable(self::TABLE);
|
||||
$products = $productConnection->fetchAllAssociative(
|
||||
'SELECT uid, title, slug, subproduct, teaser, description, capabilities FROM ' . self::TABLE . ' WHERE deleted = 0'
|
||||
);
|
||||
$mm = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('sys_category_record_mm')
|
||||
->fetchAllAssociative(
|
||||
"SELECT uid_local, uid_foreign FROM sys_category_record_mm
|
||||
WHERE tablenames = ? AND fieldname = ?",
|
||||
[self::TABLE, 'categories']
|
||||
);
|
||||
$catsByProduct = [];
|
||||
foreach ($mm as $row) {
|
||||
$catsByProduct[(int)$row['uid_foreign']][] = (int)$row['uid_local'];
|
||||
}
|
||||
|
||||
$delete = [];
|
||||
foreach ($products as $product) {
|
||||
$uid = (int)$product['uid'];
|
||||
$isK = false;
|
||||
foreach ($catsByProduct[$uid] ?? [] as $catUid) {
|
||||
if (($catTitleByUid[$catUid] ?? null) === (string)$product['title']) {
|
||||
$isK = true;
|
||||
}
|
||||
}
|
||||
if (!$isK || (int)$product['subproduct'] === 1) {
|
||||
continue;
|
||||
}
|
||||
if (in_array($uid, self::KEEP, true)) {
|
||||
$output->writeln(sprintf('keep %d %s - carries imported workbook content (delete manually once its family page exists)', $uid, $product['title']));
|
||||
continue;
|
||||
}
|
||||
$hasContent = trim((string)$product['teaser']) !== ''
|
||||
|| trim((string)$product['description']) !== ''
|
||||
|| trim((string)$product['capabilities']) !== '';
|
||||
if ($hasContent) {
|
||||
$output->writeln(sprintf('<comment>skip %d %s - has content, review manually</comment>', $uid, $product['title']));
|
||||
continue;
|
||||
}
|
||||
$delete[] = $uid;
|
||||
$output->writeln(sprintf('delete %d %s (/%s)', $uid, $product['title'], $product['slug']));
|
||||
}
|
||||
|
||||
if ($delete === []) {
|
||||
$output->writeln('No empty family landing records found - nothing to delete.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
if ($dryRun) {
|
||||
$output->writeln(sprintf('DRY RUN - %d record(s) would be deleted.', count($delete)));
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$cmdmap = [];
|
||||
foreach ($delete as $uid) {
|
||||
$cmdmap[self::TABLE][$uid]['delete'] = 1;
|
||||
}
|
||||
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||
$dataHandler->start([], $cmdmap);
|
||||
$dataHandler->process_cmdmap();
|
||||
if ($dataHandler->errorLog !== []) {
|
||||
foreach ($dataHandler->errorLog as $error) {
|
||||
$output->writeln('<error>' . $error . '</error>');
|
||||
}
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$output->writeln(sprintf('%d record(s) deleted. Flush the cache: vendor/bin/typo3 cache:flush', count($delete)));
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
523
packages/vitec/Classes/Command/SeedMegamenuCommand.php
Normal file
523
packages/vitec/Classes/Command/SeedMegamenuCommand.php
Normal file
@@ -0,0 +1,523 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Evomedien\Vitec\Command;
|
||||
|
||||
use Doctrine\DBAL\ParameterType;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Fills one "Megamenu" element (EXT:evo_megamenu_json) with the site's real
|
||||
* structure, so the editors start from a complete menu instead of a blank
|
||||
* element - roughly 5 entries, 25 columns and 90 items that would otherwise
|
||||
* be clicked together by hand.
|
||||
*
|
||||
* This is a PROJECT-side seed, deliberately not part of the neutral
|
||||
* extension: it knows where VITEC keeps its structure.
|
||||
*
|
||||
* Products columns = level-1 product categories, items = level-2
|
||||
* (the family pages do not exist yet, so every item
|
||||
* is marked `pending` and the front end falls back to
|
||||
* the column link)
|
||||
* Solutions columns = the 8 group categories, items = the 39 solution
|
||||
* records, linked through their `detail_page`
|
||||
* Markets columns = level-1 market categories, items = level-2; linked
|
||||
* through the market record's `detail_page` where one
|
||||
* exists, `pending` otherwise
|
||||
* Insights one column of section links (pages still to be built)
|
||||
* Stories teaser layout, four usecase records with their card image
|
||||
*
|
||||
* Column links are resolved against the page tree by title, so a renamed
|
||||
* page is reported rather than silently linked to nothing. Run it once;
|
||||
* everything after that is editorial work in the backend.
|
||||
*
|
||||
* vendor/bin/typo3 vitec:seed-megamenu --pid=42 --dry-run
|
||||
* vendor/bin/typo3 vitec:seed-megamenu --pid=42
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'vitec:seed-megamenu',
|
||||
description: 'Create a Megamenu element filled with the live product, solution and market structure'
|
||||
)]
|
||||
final class SeedMegamenuCommand extends Command
|
||||
{
|
||||
private const CTYPE = 'evomegamenujson_megamenu';
|
||||
private const T_ENTRY = 'tx_evomegamenujson_entry';
|
||||
private const T_COLUMN = 'tx_evomegamenujson_column';
|
||||
private const T_ITEM = 'tx_evomegamenujson_item';
|
||||
private const T_TEASER = 'tx_evomegamenujson_teaser';
|
||||
|
||||
private int $newId = 0;
|
||||
/** @var array<string,array<string,mixed>> */
|
||||
private array $datamap = [];
|
||||
/** @var string[] */
|
||||
private array $warnings = [];
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('pid', null, InputOption::VALUE_REQUIRED, 'Storage page/folder for the element and its records');
|
||||
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report the plan - write nothing');
|
||||
$this->addOption('force', null, InputOption::VALUE_NONE, 'Seed even though a megamenu element already exists');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
Bootstrap::initializeBackendAuthentication();
|
||||
$dryRun = (bool)$input->getOption('dry-run');
|
||||
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tt_content');
|
||||
|
||||
$existing = $connection->fetchAssociative(
|
||||
"SELECT uid, pid FROM tt_content WHERE deleted = 0 AND CType = ? ORDER BY uid LIMIT 1",
|
||||
[self::CTYPE]
|
||||
);
|
||||
if ($existing !== false && !$input->getOption('force')) {
|
||||
$output->writeln(sprintf(
|
||||
'<comment>A megamenu element already exists (uid %d on pid %d). Edit it in the backend, or re-run with --force to add a second one.</comment>',
|
||||
$existing['uid'], $existing['pid']
|
||||
));
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$pid = (int)($input->getOption('pid') ?? 0);
|
||||
if ($pid <= 0) {
|
||||
$pid = (int)($existing['pid'] ?? 0);
|
||||
}
|
||||
if ($pid <= 0) {
|
||||
$output->writeln('<error>No storage page given - pass --pid=<uid of a sysfolder or page>.</error>');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$contentId = $this->id();
|
||||
$entryIds = [];
|
||||
|
||||
foreach ([
|
||||
$this->buildProducts($pid, $output),
|
||||
$this->buildSolutions($pid, $output),
|
||||
$this->buildMarkets($pid, $output),
|
||||
$this->buildInsights($pid, $output),
|
||||
$this->buildStories($pid, $output),
|
||||
] as $entryId) {
|
||||
if ($entryId !== null) {
|
||||
$entryIds[] = $entryId;
|
||||
}
|
||||
}
|
||||
|
||||
if ($entryIds === []) {
|
||||
$output->writeln('<error>Nothing could be built - is the structure in place?</error>');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$this->datamap['tt_content'][$contentId] = [
|
||||
'pid' => $pid,
|
||||
'CType' => self::CTYPE,
|
||||
'header' => 'Main navigation',
|
||||
'tx_evomegamenujson_entries' => implode(',', $entryIds),
|
||||
];
|
||||
|
||||
foreach ($this->warnings as $warning) {
|
||||
$output->writeln('<comment>' . $warning . '</comment>');
|
||||
}
|
||||
$output->writeln(sprintf("\n%d entries, %d columns, %d items, %d teaser cards on pid %d.",
|
||||
count($entryIds),
|
||||
count($this->datamap[self::T_COLUMN] ?? []),
|
||||
count($this->datamap[self::T_ITEM] ?? []),
|
||||
count($this->datamap[self::T_TEASER] ?? []),
|
||||
$pid
|
||||
));
|
||||
|
||||
if ($dryRun) {
|
||||
$output->writeln('DRY RUN - nothing written.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||
$dataHandler->start($this->datamap, []);
|
||||
$dataHandler->process_datamap();
|
||||
if ($dataHandler->errorLog !== []) {
|
||||
foreach ($dataHandler->errorLog as $error) {
|
||||
$output->writeln('<error>' . $error . '</error>');
|
||||
}
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$uid = (int)($dataHandler->substNEWwithIDs[$contentId] ?? 0);
|
||||
$output->writeln(sprintf('Megamenu element created: uid %d.', $uid));
|
||||
$output->writeln(sprintf('Point the site setting megamenu.contentUid at %d, then: vendor/bin/typo3 cache:flush', $uid));
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- entries
|
||||
|
||||
private function buildProducts(int $pid, OutputInterface $output): ?string
|
||||
{
|
||||
$root = $this->pageBySlug('/products');
|
||||
$categories = $this->categoryTree('Product');
|
||||
if ($categories === []) {
|
||||
$this->warnings[] = 'Products skipped: no category tree below "Product".';
|
||||
return null;
|
||||
}
|
||||
$pages = $root ? $this->childPagesByTitle((int)$root['uid']) : [];
|
||||
|
||||
$columnIds = [];
|
||||
foreach ($categories as $group) {
|
||||
$page = $pages[$this->norm($group['title'])] ?? null;
|
||||
if ($page === null) {
|
||||
$this->warnings[] = 'Products: no page found for category "' . $group['title'] . '".';
|
||||
}
|
||||
$itemIds = [];
|
||||
foreach ($group['children'] as $family) {
|
||||
// The family pages are still to be built (decision 2026-09-04:
|
||||
// product families are content pages) - mark them pending so
|
||||
// the front end falls back to the category link.
|
||||
$itemIds[] = $this->item($pid, $family['title'], '', true);
|
||||
}
|
||||
$columnIds[] = $this->column($pid, $group['title'], $page ? $this->pageLink((int)$page['uid']) : '', $itemIds);
|
||||
}
|
||||
$output->writeln(sprintf('Products: %d columns, %d items (all pending - family pages missing)',
|
||||
count($columnIds), count($this->datamap[self::T_ITEM] ?? [])));
|
||||
|
||||
return $this->entry($pid, 'Products', $root ? $this->pageLink((int)$root['uid']) : '', 'columns', $columnIds);
|
||||
}
|
||||
|
||||
private function buildSolutions(int $pid, OutputInterface $output): ?string
|
||||
{
|
||||
$root = $this->pageBySlug('/solutions');
|
||||
$categories = $this->categoryTree('Solution');
|
||||
if ($categories === []) {
|
||||
$this->warnings[] = 'Solutions skipped: no category tree below "Solution".';
|
||||
return null;
|
||||
}
|
||||
$pages = $root ? $this->childPagesByTitle((int)$root['uid']) : [];
|
||||
$byCategory = $this->recordsByCategory('tx_vitec_domain_model_solution');
|
||||
|
||||
$columnIds = [];
|
||||
$itemCount = 0;
|
||||
foreach ($categories as $group) {
|
||||
$records = $byCategory[(int)$group['uid']] ?? [];
|
||||
if ($records === []) {
|
||||
continue;
|
||||
}
|
||||
$page = $pages[$this->norm($group['title'])] ?? null;
|
||||
$itemIds = [];
|
||||
foreach ($records as $record) {
|
||||
$detail = (int)$record['detail_page'];
|
||||
$itemIds[] = $this->item($pid, (string)$record['title'],
|
||||
$detail > 0 ? $this->pageLink($detail) : '', $detail === 0);
|
||||
$itemCount++;
|
||||
}
|
||||
$columnIds[] = $this->column($pid, $group['title'], $page ? $this->pageLink((int)$page['uid']) : '', $itemIds);
|
||||
}
|
||||
$output->writeln(sprintf('Solutions: %d columns, %d items', count($columnIds), $itemCount));
|
||||
|
||||
return $this->entry($pid, 'Solutions', $root ? $this->pageLink((int)$root['uid']) : '', 'columns', $columnIds);
|
||||
}
|
||||
|
||||
private function buildMarkets(int $pid, OutputInterface $output): ?string
|
||||
{
|
||||
$root = $this->pageBySlug('/markets');
|
||||
$categories = $this->categoryTree('Market');
|
||||
if ($categories === []) {
|
||||
$this->warnings[] = 'Markets skipped: no category tree below "Market".';
|
||||
return null;
|
||||
}
|
||||
$pages = $root ? $this->childPagesByTitle((int)$root['uid']) : [];
|
||||
$detailByTitle = $this->detailPageByTitle('tx_vitec_domain_model_market');
|
||||
|
||||
$columnIds = [];
|
||||
$pending = 0;
|
||||
$itemCount = 0;
|
||||
foreach ($categories as $group) {
|
||||
$page = $pages[$this->norm($group['title'])] ?? null;
|
||||
$itemIds = [];
|
||||
foreach ($group['children'] as $market) {
|
||||
$detail = (int)($detailByTitle[$this->norm($market['title'])] ?? 0);
|
||||
$itemIds[] = $this->item($pid, $market['title'],
|
||||
$detail > 0 ? $this->pageLink($detail) : '', $detail === 0);
|
||||
$pending += $detail === 0 ? 1 : 0;
|
||||
$itemCount++;
|
||||
}
|
||||
$columnIds[] = $this->column($pid, $group['title'], $page ? $this->pageLink((int)$page['uid']) : '', $itemIds);
|
||||
}
|
||||
$output->writeln(sprintf('Markets: %d columns, %d items (%d pending - no detail page)',
|
||||
count($columnIds), $itemCount, $pending));
|
||||
|
||||
return $this->entry($pid, 'Markets', $root ? $this->pageLink((int)$root['uid']) : '', 'columns', $columnIds);
|
||||
}
|
||||
|
||||
private function buildInsights(int $pid, OutputInterface $output): ?string
|
||||
{
|
||||
$root = $this->pageBySlug('/insights');
|
||||
if ($root === null) {
|
||||
$this->warnings[] = 'Insights skipped: no page /insights.';
|
||||
return null;
|
||||
}
|
||||
$pages = $this->childPagesByTitle((int)$root['uid']);
|
||||
$sections = ['News & Articles', 'Events & Webinars', 'Blog'];
|
||||
|
||||
$itemIds = [];
|
||||
foreach ($sections as $section) {
|
||||
$page = $pages[$this->norm($section)] ?? null;
|
||||
$itemIds[] = $this->item($pid, $section, $page ? $this->pageLink((int)$page['uid']) : '', $page === null);
|
||||
}
|
||||
$columnId = $this->column($pid, 'Sections', $this->pageLink((int)$root['uid']), $itemIds);
|
||||
$output->writeln('Insights: 1 column, ' . count($itemIds) . ' section links (teaser cards stay editorial)');
|
||||
|
||||
return $this->entry($pid, 'Insights', $this->pageLink((int)$root['uid']), 'columns', [$columnId]);
|
||||
}
|
||||
|
||||
private function buildStories(int $pid, OutputInterface $output): ?string
|
||||
{
|
||||
$root = $this->pageBySlug('/success-stories');
|
||||
if ($root === null) {
|
||||
$this->warnings[] = 'Success Stories skipped: no page /success-stories.';
|
||||
return null;
|
||||
}
|
||||
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tx_vitec_domain_model_usecase');
|
||||
$stories = $connection->fetchAllAssociative(
|
||||
"SELECT uid, title, teaser, slug, detail_page FROM tx_vitec_domain_model_usecase
|
||||
WHERE deleted = 0 AND hidden = 0 AND slug <> ''
|
||||
ORDER BY tstamp DESC LIMIT 4"
|
||||
);
|
||||
|
||||
// Stories have no `detail_page` of their own - one detail page serves
|
||||
// them all and SuccessStoryPathRewrite builds /success-stories/<slug>.
|
||||
// The seed writes that path, the same URL the list plugin emits.
|
||||
$basePath = '/' . trim((string)$root['slug'], '/');
|
||||
|
||||
$teaserIds = [];
|
||||
$withoutLink = 0;
|
||||
foreach ($stories as $story) {
|
||||
$detail = (int)$story['detail_page'];
|
||||
$slug = trim((string)$story['slug'], '/');
|
||||
$link = $detail > 0
|
||||
? $this->pageLink($detail)
|
||||
: ($slug !== '' ? $basePath . '/' . $slug : '');
|
||||
$withoutLink += $link === '' ? 1 : 0;
|
||||
$teaserIds[] = $this->teaser(
|
||||
$pid,
|
||||
'Success Story',
|
||||
(string)$story['title'],
|
||||
$this->shorten((string)($story['teaser'] ?? '')),
|
||||
$link,
|
||||
$this->fileOf('tx_vitec_domain_model_usecase', 'card_image', (int)$story['uid'])
|
||||
);
|
||||
}
|
||||
if ($withoutLink > 0) {
|
||||
$this->warnings[] = 'Success Stories: ' . $withoutLink . ' card(s) without link - story has neither detail page nor slug.';
|
||||
}
|
||||
$output->writeln('Success Stories: teaser layout, ' . count($teaserIds) . ' cards');
|
||||
|
||||
return $this->entry($pid, 'Success Stories', $this->pageLink((int)$root['uid']), 'teasers', [], $teaserIds);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------- record makers
|
||||
|
||||
/** @param string[] $columnIds @param string[] $teaserIds */
|
||||
private function entry(int $pid, string $title, string $link, string $layout, array $columnIds, array $teaserIds = []): string
|
||||
{
|
||||
$id = $this->id();
|
||||
$record = [
|
||||
'pid' => $pid,
|
||||
'title' => $title,
|
||||
'link' => $link,
|
||||
'layout' => $layout,
|
||||
];
|
||||
if ($columnIds !== []) {
|
||||
$record['menu_columns'] = implode(',', $columnIds);
|
||||
}
|
||||
if ($teaserIds !== []) {
|
||||
$record['menu_teasers'] = implode(',', $teaserIds);
|
||||
}
|
||||
$this->datamap[self::T_ENTRY][$id] = $record;
|
||||
return $id;
|
||||
}
|
||||
|
||||
/** @param string[] $itemIds */
|
||||
private function column(int $pid, string $title, string $link, array $itemIds): string
|
||||
{
|
||||
$id = $this->id();
|
||||
$this->datamap[self::T_COLUMN][$id] = [
|
||||
'pid' => $pid,
|
||||
'title' => $title,
|
||||
'link' => $link,
|
||||
'menu_items' => implode(',', $itemIds),
|
||||
];
|
||||
return $id;
|
||||
}
|
||||
|
||||
private function item(int $pid, string $title, string $link, bool $pending): string
|
||||
{
|
||||
$id = $this->id();
|
||||
$this->datamap[self::T_ITEM][$id] = [
|
||||
'pid' => $pid,
|
||||
'title' => $title,
|
||||
'link' => $link,
|
||||
'pending' => $pending ? 1 : 0,
|
||||
];
|
||||
return $id;
|
||||
}
|
||||
|
||||
private function teaser(int $pid, string $kicker, string $title, string $text, string $link, int $fileUid): string
|
||||
{
|
||||
$id = $this->id();
|
||||
$record = [
|
||||
'pid' => $pid,
|
||||
'kicker' => $kicker,
|
||||
'title' => $title,
|
||||
'teasertext' => $text,
|
||||
'link' => $link,
|
||||
];
|
||||
if ($fileUid > 0) {
|
||||
$referenceId = $this->id();
|
||||
$this->datamap['sys_file_reference'][$referenceId] = [
|
||||
'pid' => $pid,
|
||||
'table_local' => 'sys_file',
|
||||
'uid_local' => $fileUid,
|
||||
'tablenames' => self::T_TEASER,
|
||||
'fieldname' => 'image',
|
||||
'uid_foreign' => $id,
|
||||
];
|
||||
$record['image'] = $referenceId;
|
||||
}
|
||||
$this->datamap[self::T_TEASER][$id] = $record;
|
||||
return $id;
|
||||
}
|
||||
|
||||
private function id(): string
|
||||
{
|
||||
return 'NEW' . str_pad((string)++$this->newId, 4, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ lookups
|
||||
|
||||
/** @return array<int,array{uid:int,title:string,children:array<int,array{uid:int,title:string}>}> */
|
||||
private function categoryTree(string $rootTitle): array
|
||||
{
|
||||
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('sys_category');
|
||||
$rootUid = (int)$connection->fetchOne(
|
||||
'SELECT uid FROM sys_category WHERE deleted = 0 AND parent = 0 AND title = ?', [$rootTitle]
|
||||
);
|
||||
if ($rootUid <= 0) {
|
||||
return [];
|
||||
}
|
||||
$tree = [];
|
||||
foreach ($connection->fetchAllAssociative(
|
||||
'SELECT uid, title FROM sys_category WHERE deleted = 0 AND hidden = 0 AND parent = ? ORDER BY sorting', [$rootUid]
|
||||
) as $group) {
|
||||
$children = [];
|
||||
foreach ($connection->fetchAllAssociative(
|
||||
'SELECT uid, title FROM sys_category WHERE deleted = 0 AND hidden = 0 AND parent = ? ORDER BY sorting', [(int)$group['uid']]
|
||||
) as $child) {
|
||||
$children[] = ['uid' => (int)$child['uid'], 'title' => (string)$child['title']];
|
||||
}
|
||||
$tree[] = ['uid' => (int)$group['uid'], 'title' => (string)$group['title'], 'children' => $children];
|
||||
}
|
||||
return $tree;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records of a table grouped by the category they hang on, sorted by
|
||||
* their position in the category (uid order = creation order).
|
||||
*
|
||||
* @return array<int,array<int,array<string,mixed>>>
|
||||
*/
|
||||
private function recordsByCategory(string $table): array
|
||||
{
|
||||
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($table);
|
||||
$rows = $connection->fetchAllAssociative(
|
||||
"SELECT r.uid, r.title, r.detail_page, mm.uid_local AS category
|
||||
FROM $table r
|
||||
JOIN sys_category_record_mm mm ON mm.uid_foreign = r.uid AND mm.tablenames = ? AND mm.fieldname = 'categories'
|
||||
WHERE r.deleted = 0 AND r.hidden = 0
|
||||
ORDER BY r.uid",
|
||||
[$table]
|
||||
);
|
||||
$grouped = [];
|
||||
foreach ($rows as $row) {
|
||||
$grouped[(int)$row['category']][] = $row;
|
||||
}
|
||||
return $grouped;
|
||||
}
|
||||
|
||||
/** @return array<string,int> normalized record title => detail_page uid */
|
||||
private function detailPageByTitle(string $table): array
|
||||
{
|
||||
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($table);
|
||||
$map = [];
|
||||
foreach ($connection->fetchAllAssociative(
|
||||
"SELECT title, detail_page FROM $table WHERE deleted = 0 AND hidden = 0"
|
||||
) as $row) {
|
||||
$detail = (int)$row['detail_page'];
|
||||
if ($detail > 0) {
|
||||
$map[$this->norm((string)$row['title'])] = $detail;
|
||||
}
|
||||
}
|
||||
return $map;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
private function pageBySlug(string $slug): ?array
|
||||
{
|
||||
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages');
|
||||
$row = $connection->fetchAssociative(
|
||||
'SELECT uid, title, slug FROM pages WHERE deleted = 0 AND slug = ? AND sys_language_uid IN (0,-1) ORDER BY uid LIMIT 1',
|
||||
[$slug]
|
||||
);
|
||||
if ($row === false) {
|
||||
$this->warnings[] = 'No page with slug ' . $slug . '.';
|
||||
return null;
|
||||
}
|
||||
return $row;
|
||||
}
|
||||
|
||||
/** @return array<string,array<string,mixed>> normalized title => page row */
|
||||
private function childPagesByTitle(int $parentUid): array
|
||||
{
|
||||
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages');
|
||||
$map = [];
|
||||
foreach ($connection->fetchAllAssociative(
|
||||
'SELECT uid, title, slug FROM pages WHERE deleted = 0 AND pid = ? AND doktype = 1 AND sys_language_uid IN (0,-1) ORDER BY sorting',
|
||||
[$parentUid]
|
||||
) as $row) {
|
||||
$map[$this->norm((string)$row['title'])] = $row;
|
||||
}
|
||||
return $map;
|
||||
}
|
||||
|
||||
/** uid of the first file behind a FAL field, 0 when none. */
|
||||
private function fileOf(string $table, string $field, int $uid): int
|
||||
{
|
||||
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('sys_file_reference');
|
||||
$fileUid = $connection->fetchOne(
|
||||
"SELECT uid_local FROM sys_file_reference
|
||||
WHERE deleted = 0 AND tablenames = ? AND fieldname = ? AND uid_foreign = ?
|
||||
ORDER BY sorting_foreign LIMIT 1",
|
||||
[$table, $field, $uid]
|
||||
);
|
||||
return is_numeric($fileUid) ? (int)$fileUid : 0;
|
||||
}
|
||||
|
||||
private function pageLink(int $pageUid): string
|
||||
{
|
||||
return 't3://page?uid=' . $pageUid;
|
||||
}
|
||||
|
||||
private function shorten(string $text, int $max = 120): string
|
||||
{
|
||||
$flat = trim((string)preg_replace('/\s+/u', ' ', strip_tags($text)));
|
||||
return mb_strlen($flat) > $max ? mb_substr($flat, 0, $max - 1) . '…' : $flat;
|
||||
}
|
||||
|
||||
private function norm(string $value): string
|
||||
{
|
||||
return (string)preg_replace('/[^a-z0-9]+/', '', mb_strtolower($value));
|
||||
}
|
||||
}
|
||||
@@ -173,6 +173,47 @@ final class ImportController
|
||||
return new RedirectResponse((string)$this->uriBuilder->buildUriFromRoute('web_vitecimport.seo'), 303);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist hand-made record links from the SEO structure check. Shares the
|
||||
* per-model alias store with the import tabs (MappingRepository), so a
|
||||
* link made in either place serves both - and vitec:create-markets stops
|
||||
* proposing linked rows as new records. Key convention is the import
|
||||
* tab's: lowercased, trimmed CSV name. uid 0 removes a link.
|
||||
*/
|
||||
public function seoAliasesAction(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
$body = (array)$request->getParsedBody();
|
||||
$modelKey = (string)($body['model'] ?? '');
|
||||
if ($this->registry->has($modelKey)) {
|
||||
$aliases = $this->mappingRepository->load($modelKey)['aliases'] ?? [];
|
||||
$names = is_array($body['aliaskey'] ?? null) ? $body['aliaskey'] : [];
|
||||
$selected = is_array($body['alias'] ?? null) ? $body['alias'] : [];
|
||||
$changed = 0;
|
||||
foreach ($selected as $index => $selectedUid) {
|
||||
$key = mb_strtolower(trim((string)($names[$index] ?? '')));
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
$uid = (int)$selectedUid;
|
||||
$current = (int)($aliases[$key] ?? 0);
|
||||
if ($uid > 0 && $uid !== $current) {
|
||||
$aliases[$key] = $uid;
|
||||
$changed++;
|
||||
} elseif ($uid === 0 && $current > 0) {
|
||||
unset($aliases[$key]);
|
||||
$changed++;
|
||||
}
|
||||
}
|
||||
$this->mappingRepository->saveAliases($modelKey, $aliases);
|
||||
$this->flash(
|
||||
sprintf('%d record link(s) changed for %s.', $changed, $modelKey),
|
||||
'Record links saved',
|
||||
true
|
||||
);
|
||||
}
|
||||
return new RedirectResponse((string)$this->uriBuilder->buildUriFromRoute('web_vitecimport.seo'), 303);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ rendering
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,6 +15,7 @@ use TYPO3\CMS\Backend\Routing\UriBuilder;
|
||||
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
|
||||
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||
use TYPO3\CMS\Core\DataHandling\DataHandler;
|
||||
use TYPO3\CMS\Core\Http\JsonResponse;
|
||||
use TYPO3\CMS\Core\Http\RedirectResponse;
|
||||
use TYPO3\CMS\Core\Messaging\FlashMessage;
|
||||
use TYPO3\CMS\Core\Messaging\FlashMessageService;
|
||||
@@ -50,28 +51,52 @@ final class ProductTextImportController
|
||||
/**
|
||||
* Never offered as import targets. `slug` changes URLs - that is redirect
|
||||
* territory, not a text import; the workbook URL is checked against the
|
||||
* record's slug informationally instead.
|
||||
* record's slug informationally instead. The flags and the shortcut pid are
|
||||
* editorial switches that will never come out of a workbook (decision
|
||||
* 2026-08-28) - a text import writing "1" into hideonwebsite would silently
|
||||
* unpublish a product. Only this XLSX flow filters them; the CSV flow keeps
|
||||
* the full field list.
|
||||
*/
|
||||
private const EXCLUDED_TARGETS = ['slug'];
|
||||
private const EXCLUDED_TARGETS = [
|
||||
'slug',
|
||||
'hideonapp',
|
||||
'hideonwebsite',
|
||||
'hideonproducts',
|
||||
'hideondatasheets',
|
||||
'shortcutpid',
|
||||
'shortcut',
|
||||
'legacy',
|
||||
'supportproduct',
|
||||
'subproduct',
|
||||
];
|
||||
|
||||
/**
|
||||
* Pre-seed for the very first mapping (no row in tx_vitec_import_mapping
|
||||
* yet). Editors change everything in the dropdowns; this only saves the
|
||||
* first manual pass. Keys are product columns, values source selectors as
|
||||
* ProductXlsxReader defines them.
|
||||
*
|
||||
* Verified against the finished MGW-Diamond-H page (2026-08-28): the hero
|
||||
* one-liner lives in `teaser` (subtitle is unused there), the ~45-70-word
|
||||
* introduction in `description`, the ~80-120-word overview in
|
||||
* `description2`, and the three Key Capability Groups land as ONE HTML
|
||||
* block in `capabilities` - hence the composed `g:` selector. Why-Choose
|
||||
* cards, resources, pre-footer and hero CTA are page content elements or
|
||||
* template copy on the finished page, not record fields, and therefore
|
||||
* have no defaults here. An earlier version of this seed mapped onto
|
||||
* `subtitle` and onto columns that exist only in the DB but not in the
|
||||
* TCA (`cta`, `key1-3`, `apptext1-3`) - both corrected.
|
||||
*/
|
||||
private const DEFAULT_MAPPING = [
|
||||
'subtitle' => 'c:Hero — H1#1:body',
|
||||
'teaser' => 'c:Product Introduction#1:body',
|
||||
'description' => 'c:Product Overview — H2#1:body',
|
||||
'cta' => 'c:Pre-footer CTA — H2#1:cta',
|
||||
public const DEFAULT_MAPPING = [
|
||||
'seotitle' => 'x:seotitle',
|
||||
// :copy = body with cta fallback - Aligo carries the hero one-liner
|
||||
// in "Body Copy", Arqa in "CTA / Card Copy" (column drift).
|
||||
'teaser' => 'c:Hero — H1#1:copy',
|
||||
'description' => 'c:Product Introduction#1:body',
|
||||
'description2' => 'c:Product Overview — H2#1:body',
|
||||
'capabilities' => 'g:Key Capability Group',
|
||||
'textrelatedproducts' => 'p:Related Products — H2#1',
|
||||
'keywords' => 'm:primaryKeyword',
|
||||
'key1' => 'c:Why Choose Card#1:title',
|
||||
'apptext1' => 'c:Why Choose Card#1:body',
|
||||
'key2' => 'c:Why Choose Card#2:title',
|
||||
'apptext2' => 'c:Why Choose Card#2:body',
|
||||
'key3' => 'c:Why Choose Card#3:title',
|
||||
'apptext3' => 'c:Why Choose Card#3:body',
|
||||
];
|
||||
|
||||
private const SORTABLE = ['title', 'slug', 'tstamp'];
|
||||
@@ -252,6 +277,16 @@ final class ProductTextImportController
|
||||
|
||||
$map = array_map('strval', (array)($body['map'] ?? []));
|
||||
$apply = array_map('strval', (array)($body['apply'] ?? []));
|
||||
// A per-row "Save" button writes exactly its own row; the checkbox
|
||||
// state of every other row is deliberately ignored then. Only one
|
||||
// submit button ever posts its value, so the two can't both be set.
|
||||
$applySingle = trim((string)($body['applySingle'] ?? ''));
|
||||
$applyRelatedSingle = trim((string)($body['applyRelatedSingle'] ?? ''));
|
||||
if ($applySingle !== '') {
|
||||
$apply = [$applySingle];
|
||||
} elseif ($applyRelatedSingle !== '') {
|
||||
$apply = [];
|
||||
}
|
||||
$targets = $this->targetFields();
|
||||
|
||||
$data = [];
|
||||
@@ -271,6 +306,11 @@ final class ProductTextImportController
|
||||
// cannot be related to itself.
|
||||
$relatedChoices = array_map('intval', (array)($body['related'] ?? []));
|
||||
$applyRelated = array_map('intval', (array)($body['applyRelated'] ?? []));
|
||||
if ($applySingle !== '') {
|
||||
$applyRelated = [];
|
||||
} elseif ($applyRelatedSingle !== '') {
|
||||
$applyRelated = [(int)$applyRelatedSingle];
|
||||
}
|
||||
$relatedCards = $this->relatedCards($parsed);
|
||||
$relatedTodo = [];
|
||||
foreach ($applyRelated as $index) {
|
||||
@@ -352,6 +392,60 @@ final class ProductTextImportController
|
||||
return $this->renderTexts($request, $this->loadProduct($uid), $parsed, $map);
|
||||
}
|
||||
|
||||
// ------------------------------------------------- inline field editing
|
||||
|
||||
/**
|
||||
* AJAX (vitec_product_field_get): the FULL raw value of one product field
|
||||
* for the click-to-edit cell - the cell itself only shows a truncated
|
||||
* preview. RTE fields return their stored HTML; editing is deliberately
|
||||
* source-level (decision 2026-08-28: no inline WYSIWYG).
|
||||
*/
|
||||
public function fieldGet(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
$params = $request->getQueryParams();
|
||||
$uid = (int)($params['uid'] ?? 0);
|
||||
$field = (string)($params['field'] ?? '');
|
||||
$product = $this->loadProduct($uid);
|
||||
$targets = $this->targetFields();
|
||||
if ($product === null || !isset($targets[$field])) {
|
||||
return new JsonResponse(['success' => false, 'message' => 'Unknown product or field.']);
|
||||
}
|
||||
return new JsonResponse([
|
||||
'success' => true,
|
||||
'value' => (string)($product[$field] ?? ''),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX (vitec_product_field_save): write one product field. Same target
|
||||
* whitelist and the same DataHandler path as the form apply - RTE
|
||||
* transformations and record history included. Responds with the freshly
|
||||
* re-read value so the cell preview shows what was actually stored.
|
||||
*/
|
||||
public function fieldSave(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
$body = (array)$request->getParsedBody();
|
||||
$uid = (int)($body['uid'] ?? 0);
|
||||
$field = (string)($body['field'] ?? '');
|
||||
$value = (string)($body['value'] ?? '');
|
||||
$targets = $this->targetFields();
|
||||
if ($this->loadProduct($uid) === null || !isset($targets[$field])) {
|
||||
return new JsonResponse(['success' => false, 'message' => 'Unknown product or field.']);
|
||||
}
|
||||
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||
$dataHandler->start([self::TABLE => [(string)$uid => [$field => $value]]], []);
|
||||
$dataHandler->process_datamap();
|
||||
if ($dataHandler->errorLog !== []) {
|
||||
return new JsonResponse(['success' => false, 'message' => implode(' | ', $dataHandler->errorLog)]);
|
||||
}
|
||||
$fresh = (string)($this->loadProduct($uid)[$field] ?? '');
|
||||
return new JsonResponse([
|
||||
'success' => true,
|
||||
'value' => $fresh,
|
||||
'preview' => $this->preview($fresh),
|
||||
]);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ internals
|
||||
|
||||
/**
|
||||
@@ -439,6 +533,9 @@ final class ProductTextImportController
|
||||
|
||||
$view = $this->moduleTemplateFactory->create($request);
|
||||
$this->pageRenderer->addCssFile('EXT:vitec/Resources/Public/Css/backend-import.css');
|
||||
// Click-to-edit for the "Current value" column; a module because the
|
||||
// backend CSP blocks inline handlers.
|
||||
$this->pageRenderer->loadJavaScriptModule('@evomedien/vitec/product-inline-edit.js');
|
||||
$view->assignMultiple([
|
||||
'product' => $product,
|
||||
'parsed' => $parsed,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Evomedien\Vitec\DataProcessing;
|
||||
|
||||
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
|
||||
use TYPO3\CMS\Frontend\ContentObject\DataProcessorInterface;
|
||||
|
||||
/**
|
||||
* header_layout 100 = "Hidden": blank the header fields in the content-blocks
|
||||
* JSON (`data`, produced by nb-content-blocks-json in step 10 of
|
||||
* lib.contentBlock). The layout value itself stays in the payload so the
|
||||
* front end can still tell why the header is empty. Counterpart of the
|
||||
* stdWrap.if rule on lib.contentElementWithHeader / the container elements
|
||||
* (see Sets/Vitecset/setup.typoscript, decision 2026-09-04).
|
||||
*/
|
||||
final class HiddenHeaderProcessor implements DataProcessorInterface
|
||||
{
|
||||
public function process(
|
||||
ContentObjectRenderer $cObj,
|
||||
array $contentObjectConfiguration,
|
||||
array $processorConfiguration,
|
||||
array $processedData
|
||||
): array {
|
||||
$data = $processedData['data'] ?? null;
|
||||
if (is_array($data) && (int)($data['header_layout'] ?? 0) === 100) {
|
||||
foreach (['header', 'subheader', 'header_link'] as $key) {
|
||||
if (isset($data[$key]) && $data[$key] !== '') {
|
||||
$data[$key] = '';
|
||||
}
|
||||
}
|
||||
$processedData['data'] = $data;
|
||||
}
|
||||
return $processedData;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Evomedien\Vitec\EventListener;
|
||||
|
||||
use ApacheSolrForTypo3\Solr\Event\Indexing\BeforeDocumentIsProcessedForIndexingEvent;
|
||||
use Evomedien\Vitec\Service\DownloadFileResolver;
|
||||
use TYPO3\CMS\Core\Attribute\AsEventListener;
|
||||
use TYPO3\CMS\Core\Core\Environment;
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
|
||||
/**
|
||||
* Adds the extracted file text (PDF/Office) to the Solr `content` field of
|
||||
* download documents. Solr 10 dropped the built-in ExtractingRequestHandler,
|
||||
* so extraction goes through the Apache Tika server on the Solr VPS
|
||||
* (Caddy route /tika/*, credentials via TIKA_* env in additional.php).
|
||||
*
|
||||
* Extracted text is cached in var/tika-cache keyed on path+size+mtime -
|
||||
* a full re-index does not re-upload 177 unchanged PDFs, replacing a file
|
||||
* invalidates its entry naturally. Fail-soft throughout (Clause 9.5): no
|
||||
* Tika, no file, or an extraction error leave the document as it was -
|
||||
* title/teaser/keywords still get it indexed.
|
||||
*/
|
||||
#[AsEventListener(identifier: 'vitec/tika-download-content')]
|
||||
final class TikaDownloadContentIndexer
|
||||
{
|
||||
/** Files above this size are not sent to Tika (bytes). */
|
||||
private const MAX_FILE_SIZE = 31457280;
|
||||
|
||||
/** Extracted text is truncated to this many characters before indexing. */
|
||||
private const MAX_TEXT_LENGTH = 100000;
|
||||
|
||||
private const EXTRACTABLE_MIME_TYPES = [
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-powerpoint',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
];
|
||||
|
||||
public function __invoke(BeforeDocumentIsProcessedForIndexingEvent $event): void
|
||||
{
|
||||
try {
|
||||
$item = $event->getIndexQueueItem();
|
||||
if ($item->getType() !== 'tx_vitec_domain_model_download') {
|
||||
return;
|
||||
}
|
||||
|
||||
$file = DownloadFileResolver::resolve($item->getRecordUid());
|
||||
if (
|
||||
$file === null
|
||||
|| $file['size'] <= 0
|
||||
|| $file['size'] > self::MAX_FILE_SIZE
|
||||
|| !in_array($file['mimeType'], self::EXTRACTABLE_MIME_TYPES, true)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
$text = $this->extract($file['path']);
|
||||
if ($text === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$document = $event->getDocument();
|
||||
$existing = trim((string)($document['content'] ?? ''));
|
||||
$document->setField('content', trim($existing . ' ' . $text));
|
||||
} catch (\Throwable) {
|
||||
// fail soft - the download stays indexed with its metadata
|
||||
}
|
||||
}
|
||||
|
||||
private function extract(string $path): string
|
||||
{
|
||||
$cacheFile = $this->cacheFileFor($path);
|
||||
if ($cacheFile !== '' && is_file($cacheFile)) {
|
||||
return (string)file_get_contents($cacheFile);
|
||||
}
|
||||
|
||||
$baseUrl = (string)getenv('TIKA_URL');
|
||||
if ($baseUrl === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$context = stream_context_create(['http' => [
|
||||
'method' => 'PUT',
|
||||
'header' => [
|
||||
'Authorization: Basic ' . base64_encode(getenv('TIKA_USERNAME') . ':' . getenv('TIKA_PASSWORD')),
|
||||
'Accept: text/plain',
|
||||
'Content-Type: application/octet-stream',
|
||||
],
|
||||
'content' => (string)file_get_contents($path),
|
||||
'timeout' => 30,
|
||||
'ignore_errors' => true,
|
||||
]]);
|
||||
$raw = file_get_contents(rtrim($baseUrl, '/') . '/tika', false, $context);
|
||||
$status = (string)($http_response_header[0] ?? '');
|
||||
if ($raw === false || !str_contains($status, '200')) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$text = mb_substr(trim((string)preg_replace('/\s+/u', ' ', $raw)), 0, self::MAX_TEXT_LENGTH);
|
||||
|
||||
if ($cacheFile !== '' && $text !== '') {
|
||||
GeneralUtility::mkdir_deep(dirname($cacheFile));
|
||||
GeneralUtility::writeFile($cacheFile, $text);
|
||||
}
|
||||
|
||||
return $text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache key covers path, size and mtime - replacing a file re-extracts.
|
||||
*/
|
||||
private function cacheFileFor(string $path): string
|
||||
{
|
||||
$size = @filesize($path);
|
||||
$mtime = @filemtime($path);
|
||||
if ($size === false || $mtime === false) {
|
||||
return '';
|
||||
}
|
||||
return Environment::getVarPath() . '/tika-cache/'
|
||||
. sha1($path . '|' . $size . '|' . $mtime) . '.txt';
|
||||
}
|
||||
}
|
||||
@@ -144,7 +144,7 @@ final class ProductXlsxReader
|
||||
*/
|
||||
public function sources(array $parsed): array
|
||||
{
|
||||
$groups = ['Meta' => [], 'Components' => [], 'Page SEO Check' => []];
|
||||
$groups = ['Meta' => [], 'Components' => [], 'Composed' => [], 'Page SEO Check' => []];
|
||||
|
||||
foreach ($parsed['meta'] ?? [] as $key => $value) {
|
||||
$groups['Meta'][] = $this->entry('m:' . $key, 'Meta · ' . $key, (string)$value);
|
||||
@@ -169,6 +169,63 @@ final class ProductXlsxReader
|
||||
$value
|
||||
);
|
||||
}
|
||||
|
||||
// Drift-proof copy selector: the agency fills text sometimes into
|
||||
// "Body Copy" (D), sometimes into "CTA / Card Copy" (E) - Aligo's
|
||||
// hero one-liner sits in D, Arqa's in E. `copy` reads body and
|
||||
// falls back to cta, so one stored mapping fits every delivery.
|
||||
$copy = trim((string)($component['body'] ?? ''));
|
||||
if ($copy === '') {
|
||||
$copy = trim((string)($component['cta'] ?? ''));
|
||||
}
|
||||
if ($copy !== '') {
|
||||
$groups['Components'][] = $this->entry(
|
||||
'c:' . $name . '#' . $component['occurrence'] . ':copy',
|
||||
$name . $suffix . ' · Copy (Body, sonst CTA)',
|
||||
$copy
|
||||
);
|
||||
}
|
||||
|
||||
// Section-intro rows ("... — H2") additionally as ONE ready RTE
|
||||
// value: heading plus paragraph in the exact markup the finished
|
||||
// MGW-Diamond product stores in `textrelatedproducts` - imported
|
||||
// and hand-written intros then render identically.
|
||||
if (str_ends_with($name, '— H2')) {
|
||||
$pair = $this->composePair($component);
|
||||
if ($pair !== null) {
|
||||
$groups['Composed'][] = $this->entry(
|
||||
'p:' . $name . '#' . $component['occurrence'],
|
||||
$name . $suffix . ' · Title+Body as HTML',
|
||||
$pair
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Composed sources: a component occurring several times can be pulled
|
||||
// as ONE value - every occurrence as <h3> + bullets/paragraph, prefixed
|
||||
// with the section heading. Built for the Key Capability Groups, whose
|
||||
// three rows land in the single `capabilities` field; that is the shape
|
||||
// the finished product pages already use.
|
||||
$counts = [];
|
||||
foreach ($parsed['components'] ?? [] as $component) {
|
||||
$counts[(string)$component['component']] = ((int)($component['occurrence'] ?? 1));
|
||||
}
|
||||
foreach ($counts as $name => $count) {
|
||||
if ($count < 2 || in_array($name, self::HIDDEN_COMPONENTS, true)) {
|
||||
continue;
|
||||
}
|
||||
$html = $this->composeGroup($parsed, $name);
|
||||
if ($html !== null) {
|
||||
$groups['Composed'][] = $this->entry('g:' . $name, $name . ' · all ' . $count . ' as HTML', $html);
|
||||
}
|
||||
}
|
||||
|
||||
// Cross-component composite for the SEO title: hero title, em dash,
|
||||
// introduction heading - "Aligo — High-performance AV over IP and KVM".
|
||||
$seoTitle = $this->composeSeoTitle($parsed);
|
||||
if ($seoTitle !== null) {
|
||||
$groups['Composed'][] = $this->entry('x:seotitle', 'Hero title — Intro heading (SEO title)', $seoTitle);
|
||||
}
|
||||
|
||||
foreach ($parsed['seo'] ?? [] as $header => $value) {
|
||||
@@ -191,14 +248,36 @@ final class ProductXlsxReader
|
||||
$value = $parsed['meta'][substr($selector, 2)] ?? null;
|
||||
return is_string($value) && $value !== '' ? $value : null;
|
||||
}
|
||||
if (str_starts_with($selector, 'g:')) {
|
||||
return $this->composeGroup($parsed, substr($selector, 2));
|
||||
}
|
||||
if (str_starts_with($selector, 'p:') && preg_match('/^p:(.+)#(\d+)$/', $selector, $m)) {
|
||||
foreach ($parsed['components'] ?? [] as $component) {
|
||||
if ((string)$component['component'] === $m[1] && (int)$component['occurrence'] === (int)$m[2]) {
|
||||
return $this->composePair($component);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if ($selector === 'x:seotitle') {
|
||||
return $this->composeSeoTitle($parsed);
|
||||
}
|
||||
if (str_starts_with($selector, 's:')) {
|
||||
$value = $parsed['seo'][substr($selector, 2)] ?? null;
|
||||
return is_string($value) && $value !== '' ? $value : null;
|
||||
}
|
||||
if (str_starts_with($selector, 'c:') && preg_match('/^c:(.+)#(\d+):(title|body|cta)$/', $selector, $m)) {
|
||||
if (str_starts_with($selector, 'c:') && preg_match('/^c:(.+)#(\d+):(title|body|cta|copy)$/', $selector, $m)) {
|
||||
foreach ($parsed['components'] ?? [] as $component) {
|
||||
if ((string)$component['component'] === $m[1] && (int)$component['occurrence'] === (int)$m[2]) {
|
||||
$value = (string)($component[$m[3]] ?? '');
|
||||
if ($m[3] === 'copy') {
|
||||
$value = trim((string)($component['body'] ?? ''));
|
||||
if ($value === '') {
|
||||
$value = trim((string)($component['cta'] ?? ''));
|
||||
}
|
||||
$value = trim((string)preg_replace('/\|\s*CTA:.*$/su', '', $value));
|
||||
} else {
|
||||
$value = (string)($component[$m[3]] ?? '');
|
||||
}
|
||||
return $value !== '' ? $value : null;
|
||||
}
|
||||
}
|
||||
@@ -206,6 +285,121 @@ final class ProductXlsxReader
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* SEO title composite: the hero title (the product name), an em dash, and
|
||||
* the introduction heading. Plain text - `seotitle` is a plain input, no
|
||||
* escaping wanted here.
|
||||
*
|
||||
* @param array<string,mixed> $parsed
|
||||
*/
|
||||
private function composeSeoTitle(array $parsed): ?string
|
||||
{
|
||||
$hero = null;
|
||||
$intro = null;
|
||||
foreach ($parsed['components'] ?? [] as $component) {
|
||||
$name = (string)($component['component'] ?? '');
|
||||
if ($name === 'Hero — H1' && (int)($component['occurrence'] ?? 1) === 1) {
|
||||
$hero = trim((string)($component['title'] ?? ''));
|
||||
} elseif ($name === 'Product Introduction' && (int)($component['occurrence'] ?? 1) === 1) {
|
||||
$intro = trim((string)($component['title'] ?? ''));
|
||||
}
|
||||
}
|
||||
if ($hero === null || $hero === '' || $intro === null || $intro === '') {
|
||||
return null;
|
||||
}
|
||||
return $hero . ' — ' . $intro;
|
||||
}
|
||||
|
||||
/**
|
||||
* One component row as a heading/paragraph pair. The markup - centred h3
|
||||
* with an inner span, centred p - is copied verbatim from what the
|
||||
* finished MGW-Diamond product carries in `textrelatedproducts`, so the
|
||||
* front end renders imported intros exactly like the hand-made one.
|
||||
*
|
||||
* @param array<string,mixed> $component
|
||||
*/
|
||||
private function composePair(array $component): ?string
|
||||
{
|
||||
$title = trim((string)($component['title'] ?? ''));
|
||||
$body = trim((string)($component['body'] ?? ''));
|
||||
if ($body === '') {
|
||||
$body = trim((string)($component['cta'] ?? ''));
|
||||
}
|
||||
$body = trim((string)preg_replace('/\|\s*CTA:.*$/su', '', $body));
|
||||
if ($title === '' || $body === '') {
|
||||
return null;
|
||||
}
|
||||
return '<h3 class="text-center"><span>' . htmlspecialchars($title, ENT_QUOTES) . '</span></h3>' . "\n"
|
||||
. '<p class="text-center">' . htmlspecialchars($body, ENT_QUOTES) . '</p>';
|
||||
}
|
||||
|
||||
/**
|
||||
* All occurrences of one component, composed into a single HTML block in
|
||||
* the exact markup the hand-made MGW-Diamond `capabilities` field uses
|
||||
* (the front end styles only that shape): the immediately preceding
|
||||
* "... — H2" row (found by ORDER, not by name - "Key Capability Group" vs
|
||||
* "Key Capabilities — H2" makes name matching fragile) becomes
|
||||
* <h3 class="text-center"><span>, each occurrence's title a
|
||||
* <p><strong> group heading, and a body whose text uses "•" bullets
|
||||
* becomes a <ul>. Plain text otherwise.
|
||||
*
|
||||
* @param array<string,mixed> $parsed
|
||||
*/
|
||||
private function composeGroup(array $parsed, string $name): ?string
|
||||
{
|
||||
$components = $parsed['components'] ?? [];
|
||||
$members = array_values(array_filter(
|
||||
$components,
|
||||
static fn(array $c): bool => (string)($c['component'] ?? '') === $name
|
||||
));
|
||||
if ($members === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$html = '';
|
||||
$firstOrder = (int)($members[0]['order'] ?? 0);
|
||||
foreach ($components as $component) {
|
||||
if ((int)($component['order'] ?? 0) === $firstOrder - 1
|
||||
&& str_ends_with((string)($component['component'] ?? ''), '— H2')
|
||||
&& trim((string)($component['title'] ?? '')) !== ''
|
||||
) {
|
||||
$html .= '<h3 class="text-center"><span>'
|
||||
. htmlspecialchars(trim((string)$component['title']), ENT_QUOTES)
|
||||
. '</span></h3>' . "\n";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($members as $member) {
|
||||
$title = trim((string)($member['title'] ?? ''));
|
||||
$body = trim((string)($member['body'] ?? ''));
|
||||
if ($body === '') {
|
||||
$body = trim((string)($member['cta'] ?? ''));
|
||||
}
|
||||
// Card copy carries a "| CTA: <label>" suffix; the label is
|
||||
// display-only everywhere else and would be junk inside a <p>.
|
||||
$body = trim((string)preg_replace('/\|\s*CTA:.*$/su', '', $body));
|
||||
if ($title !== '') {
|
||||
$html .= '<p><strong>' . htmlspecialchars($title, ENT_QUOTES) . '</strong></p>';
|
||||
}
|
||||
if ($body === '') {
|
||||
continue;
|
||||
}
|
||||
if (str_contains($body, '•')) {
|
||||
$items = array_filter(array_map('trim', explode('•', $body)));
|
||||
$html .= '<ul>';
|
||||
foreach ($items as $item) {
|
||||
$html .= '<li>' . htmlspecialchars($item, ENT_QUOTES) . '</li>';
|
||||
}
|
||||
$html .= '</ul>';
|
||||
} else {
|
||||
$html .= '<p>' . htmlspecialchars($body, ENT_QUOTES) . '</p>';
|
||||
}
|
||||
}
|
||||
|
||||
return $html !== '' ? $html : null;
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $components */
|
||||
private function detectPageType(array $components): string
|
||||
{
|
||||
|
||||
@@ -18,7 +18,10 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
* products -> level-3.x.y+ rows against tx_vitec_domain_model_product
|
||||
* (3.x rows are categories, not products - skipped)
|
||||
* Record matching: slug == last URL segment, falling back to a
|
||||
* normalized title comparison (parentheses stripped, & -> and).
|
||||
* normalized title comparison (parentheses stripped, & -> and), and
|
||||
* finally the hand-made record links (record_aliases per model in
|
||||
* tx_vitec_import_mapping - the same store the import tabs use, so
|
||||
* one link serves both views and vitec:create-markets).
|
||||
* - diff against the previous delivery, keyed by URL
|
||||
*
|
||||
* Works on the normalized row schema produced by normalizeRows(). Read-only:
|
||||
@@ -141,7 +144,8 @@ final class SeoResearchService
|
||||
$depth = substr_count($r['ref'], '.') + 1;
|
||||
return $r['section'] === $section && $depth >= $cfg['depth'][0] && $depth <= $cfg['depth'][1];
|
||||
}));
|
||||
$out[$cfg['key']] = $this->recordsCheck($candidates, $cfg['table']);
|
||||
$aliases = GeneralUtility::makeInstance(MappingRepository::class)->load($cfg['key'])['aliases'] ?? [];
|
||||
$out[$cfg['key']] = $this->recordsCheck($candidates, $cfg['table'], $aliases);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
@@ -174,9 +178,10 @@ final class SeoResearchService
|
||||
|
||||
/**
|
||||
* @param array<int,array<string,string>> $candidates
|
||||
* @return array{total:int,matched:int,missing:array<int,array<string,string>>,extra:array<int,array<string,string>>}
|
||||
* @param array<string,int> $aliases lowercased CSV name -> record uid
|
||||
* @return array{total:int,matched:int,both:array<int,array<string,string>>,missing:array<int,array<string,string>>,extra:array<int,array<string,string>>,linkable:array<int,array<string,string>>}
|
||||
*/
|
||||
private function recordsCheck(array $candidates, string $table): array
|
||||
private function recordsCheck(array $candidates, string $table, array $aliases): array
|
||||
{
|
||||
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
|
||||
$records = $qb->select('uid', 'title', 'slug')->from($table)
|
||||
@@ -185,28 +190,43 @@ final class SeoResearchService
|
||||
|
||||
$bySlug = [];
|
||||
$byTitle = [];
|
||||
$byUid = [];
|
||||
foreach ($records as $rec) {
|
||||
$slug = mb_strtolower(trim((string)($rec['slug'] ?? '')));
|
||||
if ($slug !== '') {
|
||||
$bySlug[$slug] = $rec;
|
||||
}
|
||||
$byTitle[$this->normalizeTitle((string)$rec['title'])] = $rec;
|
||||
$byUid[(int)$rec['uid']] = $rec;
|
||||
}
|
||||
|
||||
$missing = [];
|
||||
$both = [];
|
||||
$matchedUids = [];
|
||||
$directUids = [];
|
||||
foreach ($candidates as $r) {
|
||||
$segment = mb_strtolower(trim((string)basename(rtrim($r['url'], '/'))));
|
||||
$rec = $bySlug[$segment] ?? $byTitle[$this->normalizeTitle($r['name'])] ?? null;
|
||||
$via = '';
|
||||
if ($rec === null) {
|
||||
$aliasUid = (int)($aliases[mb_strtolower(trim($r['name']))] ?? 0);
|
||||
if ($aliasUid > 0 && isset($byUid[$aliasUid])) {
|
||||
$rec = $byUid[$aliasUid];
|
||||
$via = 'link';
|
||||
}
|
||||
}
|
||||
if ($rec !== null) {
|
||||
$matchedUids[(int)$rec['uid']] = true;
|
||||
if ($via === '') {
|
||||
$directUids[(int)$rec['uid']] = true;
|
||||
}
|
||||
$both[] = [
|
||||
'ref' => $r['ref'],
|
||||
'name' => $r['name'],
|
||||
'url' => $r['url'],
|
||||
'uid' => (string)$rec['uid'],
|
||||
'title' => (string)$rec['title'],
|
||||
'via' => $via,
|
||||
];
|
||||
} else {
|
||||
$missing[] = $r;
|
||||
@@ -214,11 +234,19 @@ final class SeoResearchService
|
||||
}
|
||||
|
||||
$extra = [];
|
||||
$linkable = [];
|
||||
foreach ($records as $rec) {
|
||||
if (!isset($matchedUids[(int)$rec['uid']])) {
|
||||
$extra[] = ['uid' => (string)$rec['uid'], 'title' => (string)$rec['title']];
|
||||
}
|
||||
// Select options for the hand-made links: everything the direct
|
||||
// match (slug/title) did not claim - alias-linked records stay in
|
||||
// here so a linked row can be re-pointed or unlinked.
|
||||
if (!isset($directUids[(int)$rec['uid']])) {
|
||||
$linkable[] = ['uid' => (string)$rec['uid'], 'title' => (string)$rec['title']];
|
||||
}
|
||||
}
|
||||
usort($linkable, static fn(array $a, array $b): int => strcasecmp($a['title'], $b['title']));
|
||||
|
||||
return [
|
||||
'total' => count($candidates),
|
||||
@@ -226,6 +254,7 @@ final class SeoResearchService
|
||||
'both' => $both,
|
||||
'missing' => $missing,
|
||||
'extra' => $extra,
|
||||
'linkable' => $linkable,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Evomedien\Vitec\PageTitle;
|
||||
|
||||
use TYPO3\CMS\Core\PageTitle\AbstractPageTitleProvider;
|
||||
use TYPO3\CMS\Core\SingletonInterface;
|
||||
|
||||
/**
|
||||
* Page title for the success-story detail page - without this every story
|
||||
* answers with the generic page title "Story".
|
||||
*
|
||||
* Singleton on purpose, same reasoning as ProductPageTitleProvider: the
|
||||
* UsecaseShowJsonRenderer that sets the title and the
|
||||
* PageTitleProviderManager that later reads it both obtain the provider via
|
||||
* GeneralUtility::makeInstance(); without the singleton those are two
|
||||
* different instances and the title is never seen by the manager.
|
||||
*/
|
||||
final class UsecasePageTitleProvider extends AbstractPageTitleProvider implements SingletonInterface
|
||||
{
|
||||
private string $seotitle = '';
|
||||
|
||||
public function setSeoTitle(string $seotitle): void
|
||||
{
|
||||
$this->seotitle = $seotitle;
|
||||
}
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return $this->seotitle;
|
||||
}
|
||||
}
|
||||
35
packages/vitec/Classes/Seo/VitecMetaHandler.php
Normal file
35
packages/vitec/Classes/Seo/VitecMetaHandler.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Evomedien\Vitec\Seo;
|
||||
|
||||
use FriendsOfTYPO3\Headless\Seo\MetaHandler;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
/**
|
||||
* Mirrors the real page <title> into `meta.title` of the page JSON.
|
||||
*
|
||||
* Why: `meta.title` is rendered from TypoScript (lib.meta) BEFORE the page
|
||||
* content, so on product/usecase detail pages it still shows the generic
|
||||
* page title ("Product") - the record's seotitle only reaches the
|
||||
* PageTitleProvider while the content renders. Headless fixes `seo.title`
|
||||
* afterwards through this handler; we extend it so `meta.title` gets the
|
||||
* same final value. Registered via the MetaHandlerInterface alias in
|
||||
* Services.yaml, which covers both the cacheable listener and the
|
||||
* USER_INT middleware path.
|
||||
*/
|
||||
class VitecMetaHandler extends MetaHandler
|
||||
{
|
||||
public function process(ServerRequestInterface $request, array $content): array
|
||||
{
|
||||
$content = parent::process($request, $content);
|
||||
|
||||
$title = trim((string)($content['seo']['title'] ?? ''));
|
||||
if ($title !== '' && is_array($content['meta'] ?? null)) {
|
||||
$content['meta']['title'] = $title;
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
}
|
||||
150
packages/vitec/Classes/UserFunc/ProductFinderJsonRenderer.php
Normal file
150
packages/vitec/Classes/UserFunc/ProductFinderJsonRenderer.php
Normal file
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Evomedien\Vitec\UserFunc;
|
||||
|
||||
use Doctrine\DBAL\ParameterType;
|
||||
use TYPO3\CMS\Core\Attribute\AsAllowedCallable;
|
||||
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||
use TYPO3\CMS\Core\Database\Query\Restriction\FrontendRestrictionContainer;
|
||||
use TYPO3\CMS\Core\Service\FlexFormService;
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
|
||||
|
||||
/**
|
||||
* Data source for the product filter: the product category hierarchy, and
|
||||
* nothing else.
|
||||
*
|
||||
* The editor places ONE `vitec_productfinder` element above the product
|
||||
* lists on the products page. It renders no product cards - the React front
|
||||
* end builds the filter selects from this payload and filters the products
|
||||
* the `vitec_productlist` elements already delivered, client side, via URL
|
||||
* parameters (?cat=58&subcat=66). No change to the list plugin was needed.
|
||||
*
|
||||
* Emitted shape:
|
||||
*
|
||||
* content.productfinder.categories[] = { uid, title, subcategories[] }
|
||||
* subcategories[] = { uid, title }
|
||||
*
|
||||
* Level 1 below the configured root becomes the main categories, their
|
||||
* direct children the subcategories - the same tree the product records
|
||||
* hang on, so a filter value maps straight onto `product.categories[].uid`.
|
||||
*
|
||||
* TYPO3 v14 hands the ContentObjectRenderer in through the setter; without
|
||||
* it $this->cObj stays null and the element cannot read its own FlexForm.
|
||||
*/
|
||||
final class ProductFinderJsonRenderer
|
||||
{
|
||||
private const CATEGORY_TABLE = 'sys_category';
|
||||
|
||||
/** Fallback when no root is configured: the category tree the products use. */
|
||||
private const DEFAULT_ROOT_TITLE = 'Product';
|
||||
|
||||
protected ?ContentObjectRenderer $cObj = null;
|
||||
|
||||
public function setContentObjectRenderer(ContentObjectRenderer $cObj): void
|
||||
{
|
||||
$this->cObj = $cObj;
|
||||
}
|
||||
|
||||
#[AsAllowedCallable]
|
||||
public function render(string $content, array $conf): string
|
||||
{
|
||||
try {
|
||||
$settings = $this->settings();
|
||||
|
||||
$root = (int)($settings['categoryRoot'] ?? 0);
|
||||
if ($root <= 0) {
|
||||
$root = $this->rootByTitle(self::DEFAULT_ROOT_TITLE);
|
||||
}
|
||||
if ($root <= 0) {
|
||||
return (string)json_encode(['categories' => []]);
|
||||
}
|
||||
|
||||
$order = (string)($settings['sorting'] ?? 'sorting');
|
||||
$categories = [];
|
||||
foreach ($this->childrenOf($root, $order) as $main) {
|
||||
$categories[] = [
|
||||
'uid' => (int)$main['uid'],
|
||||
'title' => (string)$main['title'],
|
||||
'subcategories' => array_map(
|
||||
static fn(array $sub): array => [
|
||||
'uid' => (int)$sub['uid'],
|
||||
'title' => (string)$sub['title'],
|
||||
],
|
||||
$this->childrenOf((int)$main['uid'], $order)
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
return (string)json_encode(['categories' => $categories], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
} catch (\Throwable) {
|
||||
// A broken filter must never take the page payload down with it.
|
||||
return (string)json_encode(['categories' => []]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* FlexForm settings of the element being rendered.
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function settings(): array
|
||||
{
|
||||
$flexform = (string)($this->cObj->data['pi_flexform'] ?? '');
|
||||
if ($flexform === '') {
|
||||
return [];
|
||||
}
|
||||
$parsed = GeneralUtility::makeInstance(FlexFormService::class)
|
||||
->convertFlexFormContentToArray($flexform);
|
||||
|
||||
return is_array($parsed['settings'] ?? null) ? $parsed['settings'] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct children of a category, honouring the frontend restrictions
|
||||
* (hidden, start/end time, deleted).
|
||||
*
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
private function childrenOf(int $parent, string $order): array
|
||||
{
|
||||
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
|
||||
->getQueryBuilderForTable(self::CATEGORY_TABLE);
|
||||
$queryBuilder->setRestrictions(GeneralUtility::makeInstance(FrontendRestrictionContainer::class));
|
||||
|
||||
$query = $queryBuilder->select('uid', 'title', 'sorting')
|
||||
->from(self::CATEGORY_TABLE)
|
||||
->where(
|
||||
$queryBuilder->expr()->eq('parent', $queryBuilder->createNamedParameter($parent, ParameterType::INTEGER)),
|
||||
$queryBuilder->expr()->in('sys_language_uid', [-1, 0])
|
||||
);
|
||||
|
||||
match ($order) {
|
||||
'title' => $query->orderBy('title', 'ASC'),
|
||||
'uid' => $query->orderBy('uid', 'ASC'),
|
||||
default => $query->orderBy('sorting', 'ASC')->addOrderBy('title', 'ASC'),
|
||||
};
|
||||
|
||||
return $query->executeQuery()->fetchAllAssociative();
|
||||
}
|
||||
|
||||
/** Uid of a root-level category by title, 0 when it does not exist. */
|
||||
private function rootByTitle(string $title): int
|
||||
{
|
||||
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
|
||||
->getQueryBuilderForTable(self::CATEGORY_TABLE);
|
||||
$queryBuilder->setRestrictions(GeneralUtility::makeInstance(FrontendRestrictionContainer::class));
|
||||
|
||||
$uid = $queryBuilder->select('uid')->from(self::CATEGORY_TABLE)
|
||||
->where(
|
||||
$queryBuilder->expr()->eq('parent', 0),
|
||||
$queryBuilder->expr()->eq('title', $queryBuilder->createNamedParameter($title))
|
||||
)
|
||||
->setMaxResults(1)
|
||||
->executeQuery()->fetchOne();
|
||||
|
||||
return is_numeric($uid) ? (int)$uid : 0;
|
||||
}
|
||||
}
|
||||
@@ -111,6 +111,12 @@ class ProductListJsonRenderer
|
||||
$categoryUids = array_filter(
|
||||
array_map('intval', explode(',', (string)($settings['categories'] ?? '')))
|
||||
);
|
||||
// A selected category matches its WHOLE subtree (decision
|
||||
// 2026-09-04): editors pick e.g. the "Platforms and End-Points"
|
||||
// parent, the products hang on its child categories.
|
||||
if ($categoryUids !== []) {
|
||||
$categoryUids = $this->expandWithDescendants($categoryUids);
|
||||
}
|
||||
$debugMode = (bool)($settings['debug'] ?? false);
|
||||
$allProducts = (bool)($settings['allproducts'] ?? false);
|
||||
|
||||
@@ -147,6 +153,41 @@ class ProductListJsonRenderer
|
||||
|
||||
$products = $productQuery->executeQuery()->fetchAllAssociative();
|
||||
|
||||
// Order by category in TREE order (decision 2026-09-04): products
|
||||
// of the first selected/child category first, then the next, so a
|
||||
// list over a parent category groups its families like the
|
||||
// sitemap. $categoryUids comes from expandWithDescendants in
|
||||
// depth-first tree order; within one category the uid order is
|
||||
// kept - the records were created in sitemap-V2 row order.
|
||||
if (!empty($categoryUids) && !$allProducts && $products !== []) {
|
||||
$rankByCategory = array_flip($categoryUids);
|
||||
$mmQueryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class)
|
||||
->getQueryBuilderForTable('sys_category_record_mm');
|
||||
$assignments = $mmQueryBuilder->select('uid_local', 'uid_foreign')
|
||||
->from('sys_category_record_mm')
|
||||
->where(
|
||||
$mmQueryBuilder->expr()->eq('tablenames', $mmQueryBuilder->createNamedParameter('tx_vitec_domain_model_product', ParameterType::STRING)),
|
||||
$mmQueryBuilder->expr()->eq('fieldname', $mmQueryBuilder->createNamedParameter('categories', ParameterType::STRING)),
|
||||
$mmQueryBuilder->expr()->in('uid_foreign', $mmQueryBuilder->createNamedParameter(
|
||||
array_map(static fn(array $p): int => (int)$p['uid'], $products),
|
||||
Connection::PARAM_INT_ARRAY
|
||||
))
|
||||
)->executeQuery()->fetchAllAssociative();
|
||||
$rankByProduct = [];
|
||||
foreach ($assignments as $assignment) {
|
||||
$productUid = (int)$assignment['uid_foreign'];
|
||||
$rank = $rankByCategory[(int)$assignment['uid_local']] ?? null;
|
||||
if ($rank !== null && $rank < ($rankByProduct[$productUid] ?? PHP_INT_MAX)) {
|
||||
$rankByProduct[$productUid] = $rank;
|
||||
}
|
||||
}
|
||||
usort($products, static function (array $a, array $b) use ($rankByProduct): int {
|
||||
$rankA = $rankByProduct[(int)$a['uid']] ?? PHP_INT_MAX;
|
||||
$rankB = $rankByProduct[(int)$b['uid']] ?? PHP_INT_MAX;
|
||||
return $rankA <=> $rankB ?: (int)$a['uid'] <=> (int)$b['uid'];
|
||||
});
|
||||
}
|
||||
|
||||
$productsData = [];
|
||||
foreach ($products as $product) {
|
||||
$productsData[] = $this->serializeProduct($product);
|
||||
@@ -163,6 +204,14 @@ class ProductListJsonRenderer
|
||||
'layout' => (string)($settings['layout'] ?? '0'),
|
||||
'showToolbar' => (bool)($settings['showtoolbar'] ?? false),
|
||||
'products' => $productsData,
|
||||
// Success stories the editor attached to these categories
|
||||
// (sys_category, Options tab). They ride along with the list
|
||||
// so the front end can hide them with the same category
|
||||
// filter it applies to the products.
|
||||
'successStories' => $this->successStoriesFor(
|
||||
$allProducts ? [] : $categoryUids,
|
||||
(int)($settings['storysinglepid'] ?? 0)
|
||||
),
|
||||
];
|
||||
|
||||
if ($debugMode) {
|
||||
@@ -181,6 +230,180 @@ class ProductListJsonRenderer
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Success stories attached to the given categories, in the order the
|
||||
* categories were resolved and, inside one category, in the order the
|
||||
* editor arranged them. A story linked from several of the categories
|
||||
* appears once and carries all of them in `forCategories`, which is what
|
||||
* the front end filters on: the product finder writes ?cat= / ?subcat=,
|
||||
* and a card stays visible while one of its categories is selected.
|
||||
*
|
||||
* Cards use the shared story shape (UsecaseSerializer), so the front end
|
||||
* can render them with the same component as every other story list.
|
||||
* `detailUrl` follows the same rule as the story list: the story's own
|
||||
* detail page wins, otherwise the Single PID configured on this element
|
||||
* plus the slug - and null when neither is available.
|
||||
*
|
||||
* Returned in the list envelope of 7.3 - `layout` plus the array - so the
|
||||
* front end treats these cards like every other story list. The layout is
|
||||
* an editorial setting on the category, next to the selection itself.
|
||||
*
|
||||
* @param int[] $categoryUids
|
||||
* @return array{layout:string,stories:array<int,array<string,mixed>>}
|
||||
*/
|
||||
private function successStoriesFor(array $categoryUids, int $singlePid): array
|
||||
{
|
||||
if ($categoryUids === []) {
|
||||
return ['layout' => 'grid', 'stories' => []];
|
||||
}
|
||||
|
||||
$queryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class)
|
||||
->getQueryBuilderForTable('sys_category');
|
||||
$rows = $queryBuilder->select('uid', 'tx_vitec_success_stories', 'tx_vitec_success_stories_layout')->from('sys_category')
|
||||
->where(
|
||||
$queryBuilder->expr()->in('uid', $queryBuilder->createNamedParameter($categoryUids, Connection::PARAM_INT_ARRAY)),
|
||||
$queryBuilder->expr()->eq('deleted', 0),
|
||||
$queryBuilder->expr()->eq('hidden', 0)
|
||||
)->executeQuery()->fetchAllAssociative();
|
||||
|
||||
$listByCategory = [];
|
||||
$layoutByCategory = [];
|
||||
foreach ($rows as $row) {
|
||||
$listByCategory[(int)$row['uid']] = (string)($row['tx_vitec_success_stories'] ?? '');
|
||||
$layoutByCategory[(int)$row['uid']] = (string)($row['tx_vitec_success_stories_layout'] ?? '') ?: 'grid';
|
||||
}
|
||||
|
||||
$order = [];
|
||||
$forCategories = [];
|
||||
$layout = 'grid';
|
||||
foreach ($categoryUids as $categoryUid) {
|
||||
foreach (explode(',', $listByCategory[(int)$categoryUid] ?? '') as $raw) {
|
||||
$storyUid = (int)trim($raw);
|
||||
if ($storyUid <= 0) {
|
||||
continue;
|
||||
}
|
||||
if ($order === []) {
|
||||
// The first category that actually contributes stories sets
|
||||
// the layout - that is the top-most one the element is
|
||||
// configured with, which is where an editor expects the
|
||||
// setting to live.
|
||||
$layout = $layoutByCategory[(int)$categoryUid] ?? 'grid';
|
||||
}
|
||||
if (!in_array($storyUid, $order, true)) {
|
||||
$order[] = $storyUid;
|
||||
}
|
||||
$forCategories[$storyUid][] = (int)$categoryUid;
|
||||
}
|
||||
}
|
||||
if ($order === []) {
|
||||
return ['layout' => 'grid', 'stories' => []];
|
||||
}
|
||||
|
||||
$storyQueryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class)
|
||||
->getQueryBuilderForTable('tx_vitec_domain_model_usecase');
|
||||
$storyRows = $storyQueryBuilder->select('*')->from('tx_vitec_domain_model_usecase')
|
||||
->where(
|
||||
$storyQueryBuilder->expr()->in('uid', $storyQueryBuilder->createNamedParameter($order, Connection::PARAM_INT_ARRAY)),
|
||||
$storyQueryBuilder->expr()->eq('deleted', 0),
|
||||
$storyQueryBuilder->expr()->eq('hidden', 0)
|
||||
)->executeQuery()->fetchAllAssociative();
|
||||
|
||||
$byUid = [];
|
||||
foreach ($storyRows as $storyRow) {
|
||||
$byUid[(int)$storyRow['uid']] = $storyRow;
|
||||
}
|
||||
|
||||
// Same base as the story list: the detail page's PARENT path, because
|
||||
// SuccessStoryPathRewrite maps /success-stories/<slug> onto the detail
|
||||
// subpage at request time. Falls back to the site setting
|
||||
// `vitec.storyDetailPid`, so the editor does not have to configure the
|
||||
// page on every single product list.
|
||||
if ($singlePid <= 0) {
|
||||
$singlePid = $this->storyDetailPidFromSite();
|
||||
}
|
||||
$detailBase = '';
|
||||
if ($singlePid > 0) {
|
||||
$detailPath = rtrim(\Evomedien\Vitec\Service\LinkResolver::pageUrl($singlePid) ?? '', '/');
|
||||
$parent = str_contains($detailPath, '/') ? substr($detailPath, 0, (int)strrpos($detailPath, '/')) : '';
|
||||
$detailBase = $parent !== '' ? $parent : $detailPath;
|
||||
}
|
||||
|
||||
$serializer = GeneralUtility::makeInstance(\Evomedien\Vitec\Service\UsecaseSerializer::class);
|
||||
$stories = [];
|
||||
foreach ($order as $storyUid) {
|
||||
if (!isset($byUid[$storyUid])) {
|
||||
continue; // hidden or deleted meanwhile - simply drops out
|
||||
}
|
||||
$item = $serializer->serializeListItem($byUid[$storyUid]);
|
||||
if (($item['detailUrl'] ?? null) === null && $detailBase !== '' && ($item['slug'] ?? '') !== '') {
|
||||
$item['detailUrl'] = $detailBase . '/' . ltrim((string)$item['slug'], '/');
|
||||
}
|
||||
// NOT the story's own topic categories (those stay in `categories`)
|
||||
// but the product categories it was attached to.
|
||||
$item['forCategories'] = array_values(array_unique($forCategories[$storyUid]));
|
||||
$stories[] = $item;
|
||||
}
|
||||
|
||||
return ['layout' => $layout, 'stories' => $stories];
|
||||
}
|
||||
|
||||
/**
|
||||
* Site-wide fallback for the success story detail page, 0 when unset.
|
||||
* Read from the site settings rather than hard-coded, so the page can be
|
||||
* moved without touching code.
|
||||
*/
|
||||
private function storyDetailPidFromSite(): int
|
||||
{
|
||||
try {
|
||||
$site = ($GLOBALS['TYPO3_REQUEST'] ?? null)?->getAttribute('site');
|
||||
if ($site === null || !method_exists($site, 'getSettings')) {
|
||||
return 0;
|
||||
}
|
||||
return (int)$site->getSettings()->get('vitec.storyDetailPid', 0);
|
||||
} catch (\Throwable) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The given category uids plus every descendant category uid, DEPTH first
|
||||
* over sys_category.parent with siblings in sys_category.sorting order -
|
||||
* the result is in backend-tree order and doubles as the sort rank for
|
||||
* the list. One query for the whole table - the tree is small (< 100
|
||||
* rows).
|
||||
*
|
||||
* @param int[] $categoryUids
|
||||
* @return int[]
|
||||
*/
|
||||
private function expandWithDescendants(array $categoryUids): array
|
||||
{
|
||||
$queryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class)
|
||||
->getQueryBuilderForTable('sys_category');
|
||||
$rows = $queryBuilder->select('uid', 'parent')->from('sys_category')
|
||||
->where($queryBuilder->expr()->eq('deleted', 0))
|
||||
->orderBy('parent')->addOrderBy('sorting')
|
||||
->executeQuery()->fetchAllAssociative();
|
||||
$childrenByParent = [];
|
||||
foreach ($rows as $row) {
|
||||
$childrenByParent[(int)$row['parent']][] = (int)$row['uid'];
|
||||
}
|
||||
$result = [];
|
||||
$visit = function (int $categoryUid) use (&$visit, &$result, $childrenByParent): void {
|
||||
$result[] = $categoryUid;
|
||||
foreach ($childrenByParent[$categoryUid] ?? [] as $childUid) {
|
||||
if (!in_array($childUid, $result, true)) {
|
||||
$visit($childUid);
|
||||
}
|
||||
}
|
||||
};
|
||||
foreach (array_map('intval', $categoryUids) as $categoryUid) {
|
||||
if (!in_array($categoryUid, $result, true)) {
|
||||
$visit($categoryUid);
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a single product DB row to the full headless JSON structure.
|
||||
*
|
||||
|
||||
@@ -73,6 +73,7 @@ class SearchJsonRenderer
|
||||
'pages' => 'page',
|
||||
'tx_vitec_domain_model_product' => 'product',
|
||||
'tx_vitec_domain_model_market' => 'market',
|
||||
'tx_vitec_domain_model_solution' => 'solution',
|
||||
'tx_vitec_domain_model_usecase' => 'story',
|
||||
'tx_news_domain_model_news' => 'news',
|
||||
'tx_vitec_domain_model_download' => 'download',
|
||||
@@ -152,6 +153,15 @@ class SearchJsonRenderer
|
||||
$activeType = '';
|
||||
}
|
||||
|
||||
// Secondary facet filters: value comes verbatim from facets.<name>[].value
|
||||
$facetFilters = [];
|
||||
foreach (['market', 'category'] as $facetParam) {
|
||||
$facetValue = trim((string)($params[$facetParam] ?? ''));
|
||||
if ($facetValue !== '') {
|
||||
$facetFilters[$facetParam] = $facetValue;
|
||||
}
|
||||
}
|
||||
|
||||
if ($query === '') {
|
||||
return (string)json_encode($this->emptyResult());
|
||||
}
|
||||
@@ -177,8 +187,15 @@ class SearchJsonRenderer
|
||||
$search,
|
||||
);
|
||||
$arguments = ['q' => $query, 'page' => $page];
|
||||
$filterArguments = [];
|
||||
if ($activeType !== '') {
|
||||
$arguments['filter'] = ['type:' . $typeFilterField];
|
||||
$filterArguments[] = 'type:' . $typeFilterField;
|
||||
}
|
||||
foreach ($facetFilters as $facetName => $facetValue) {
|
||||
$filterArguments[] = $facetName . ':' . $facetValue;
|
||||
}
|
||||
if ($filterArguments !== []) {
|
||||
$arguments['filter'] = $filterArguments;
|
||||
}
|
||||
$searchRequest = GeneralUtility::makeInstance(SearchRequestBuilder::class, $typoScriptConfiguration)
|
||||
->buildForSearch($arguments, $pageId, $languageId);
|
||||
@@ -255,6 +272,7 @@ class SearchJsonRenderer
|
||||
'numFound' => $numFound,
|
||||
'totalPages' => $resultsPerPage > 0 ? (int)ceil($numFound / $resultsPerPage) : 0,
|
||||
'filter' => $activeType !== '' ? $activeType : null,
|
||||
'activeFacets' => (object)$facetFilters,
|
||||
'facets' => (object)$facets,
|
||||
'results' => $results,
|
||||
'suggestions' => array_values(array_unique($suggestions)),
|
||||
@@ -276,6 +294,7 @@ class SearchJsonRenderer
|
||||
'numFound' => 0,
|
||||
'totalPages' => 0,
|
||||
'filter' => null,
|
||||
'activeFacets' => new stdClass(),
|
||||
'facets' => new \stdClass(),
|
||||
'results' => [],
|
||||
'suggestions' => [],
|
||||
|
||||
@@ -134,6 +134,15 @@ class UsecaseShowJsonRenderer
|
||||
: '';
|
||||
}
|
||||
|
||||
$pageTitle = trim((string)($usecase['seo_title'] ?? ''));
|
||||
if ($pageTitle === '') {
|
||||
$pageTitle = trim((string)($usecase['title'] ?? ''));
|
||||
}
|
||||
if ($pageTitle !== '') {
|
||||
GeneralUtility::makeInstance(\Evomedien\Vitec\PageTitle\UsecasePageTitleProvider::class)
|
||||
->setSeoTitle($pageTitle);
|
||||
}
|
||||
|
||||
$serializer = GeneralUtility::makeInstance(UsecaseSerializer::class);
|
||||
|
||||
$backPid = (int)($settings['backPid'] ?? 0);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Evomedien\Vitec\Controller\Backend\ProductTextImportController;
|
||||
use Evomedien\Vitec\Controller\Backend\StructuredDataController;
|
||||
|
||||
/**
|
||||
@@ -16,4 +17,14 @@ return [
|
||||
'path' => '/vitec/structureddata/generate',
|
||||
'target' => StructuredDataController::class . '::generate',
|
||||
],
|
||||
// Click-to-edit in the product text import's "Current value" column:
|
||||
// read the full raw value / write one field through DataHandler.
|
||||
'vitec_product_field_get' => [
|
||||
'path' => '/vitec/product/field/get',
|
||||
'target' => ProductTextImportController::class . '::fieldGet',
|
||||
],
|
||||
'vitec_product_field_save' => [
|
||||
'path' => '/vitec/product/field/save',
|
||||
'target' => ProductTextImportController::class . '::fieldSave',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -35,6 +35,10 @@ return [
|
||||
'target' => ImportController::class . '::seoUploadAction',
|
||||
'methods' => ['POST'],
|
||||
],
|
||||
'seo_aliases' => [
|
||||
'target' => ImportController::class . '::seoAliasesAction',
|
||||
'methods' => ['POST'],
|
||||
],
|
||||
'products' => [
|
||||
'target' => ProductTextImportController::class . '::productsAction',
|
||||
],
|
||||
|
||||
58
packages/vitec/Configuration/FlexForms/Productfinder.xml
Normal file
58
packages/vitec/Configuration/FlexForms/Productfinder.xml
Normal file
@@ -0,0 +1,58 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Product Finder - delivers the category hierarchy for the filter selects.
|
||||
One element per products page, placed above the product lists.
|
||||
-->
|
||||
<T3DataStructure>
|
||||
<sheets>
|
||||
<sDEF>
|
||||
<ROOT>
|
||||
<sheetTitle>Product Finder</sheetTitle>
|
||||
<type>array</type>
|
||||
<el>
|
||||
<settings.categoryRoot>
|
||||
<label>Category root</label>
|
||||
<description>The category whose children become the filter's main categories. Leave empty to use the product category tree ("Product").</description>
|
||||
<config>
|
||||
<type>select</type>
|
||||
<renderType>selectSingle</renderType>
|
||||
<foreign_table>sys_category</foreign_table>
|
||||
<foreign_table_where>AND sys_category.parent = 0 AND sys_category.sys_language_uid IN (-1,0) ORDER BY sys_category.title ASC</foreign_table_where>
|
||||
<items type="array">
|
||||
<numIndex index="0" type="array">
|
||||
<numIndex index="0">Product category tree (default)</numIndex>
|
||||
<numIndex index="1">0</numIndex>
|
||||
</numIndex>
|
||||
</items>
|
||||
<default>0</default>
|
||||
</config>
|
||||
</settings.categoryRoot>
|
||||
|
||||
<settings.sorting>
|
||||
<label>Sort categories by</label>
|
||||
<description>Applies to the main categories and their subcategories alike.</description>
|
||||
<config>
|
||||
<type>select</type>
|
||||
<renderType>selectSingle</renderType>
|
||||
<items type="array">
|
||||
<numIndex index="0" type="array">
|
||||
<numIndex index="0">Backend order (as arranged in the category tree)</numIndex>
|
||||
<numIndex index="1">sorting</numIndex>
|
||||
</numIndex>
|
||||
<numIndex index="1" type="array">
|
||||
<numIndex index="0">Alphabetically by title</numIndex>
|
||||
<numIndex index="1">title</numIndex>
|
||||
</numIndex>
|
||||
<numIndex index="2" type="array">
|
||||
<numIndex index="0">By uid</numIndex>
|
||||
<numIndex index="1">uid</numIndex>
|
||||
</numIndex>
|
||||
</items>
|
||||
<default>sorting</default>
|
||||
</config>
|
||||
</settings.sorting>
|
||||
</el>
|
||||
</ROOT>
|
||||
</sDEF>
|
||||
</sheets>
|
||||
</T3DataStructure>
|
||||
@@ -100,6 +100,16 @@
|
||||
<default>0</default>
|
||||
</config>
|
||||
</settings.showtoolbar>
|
||||
<settings.storysinglepid>
|
||||
<label>Success stories: detail page</label>
|
||||
<description>Needed only so the success-story cards below this list get a link. Pick the same story detail page the Success Stories list uses. Leave empty and the cards render without a link.</description>
|
||||
<config>
|
||||
<type>group</type>
|
||||
<allowed>pages</allowed>
|
||||
<size>1</size>
|
||||
<maxitems>1</maxitems>
|
||||
</config>
|
||||
</settings.storysinglepid>
|
||||
</el>
|
||||
</ROOT>
|
||||
</sDEF>
|
||||
|
||||
@@ -16,3 +16,9 @@ services:
|
||||
TYPO3\CMS\Backend\View\BackendLayoutView:
|
||||
alias: Evomedien\Vitec\View\VitecBackendLayoutView
|
||||
public: true
|
||||
|
||||
# Headless resolves the meta handler via this interface (cacheable listener
|
||||
# AND the USER_INT middleware). Pointing the alias at our subclass mirrors
|
||||
# the final page title (seo.title) into meta.title as well.
|
||||
FriendsOfTYPO3\Headless\Seo\MetaHandlerInterface:
|
||||
alias: Evomedien\Vitec\Seo\VitecMetaHandler
|
||||
|
||||
@@ -29,6 +29,13 @@ settings:
|
||||
type: bool
|
||||
default: false
|
||||
|
||||
vitec.storyDetailPid:
|
||||
label: 'Success story detail page'
|
||||
description: 'The page that renders a single success story. Used to build story links where no page is configured on the element itself - the story URL is that page''s parent path plus the story slug, which is what SuccessStoryPathRewrite expects. 0 = no fallback.'
|
||||
category: VITEC
|
||||
type: int
|
||||
default: 0
|
||||
|
||||
menu.footer.pageUids:
|
||||
label: 'Footer menu — page UIDs'
|
||||
description: 'Comma-separated list of page UIDs to display in the footer menu (in order).'
|
||||
|
||||
@@ -7,11 +7,44 @@ config {
|
||||
before = record
|
||||
before = seo
|
||||
}
|
||||
vitecUsecase {
|
||||
provider = Evomedien\Vitec\PageTitle\UsecasePageTitleProvider
|
||||
before = record
|
||||
before = seo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@import 'EXT:headless/Configuration/TypoScript/ContentElement/*.typoscript'
|
||||
|
||||
# =============================================================================
|
||||
# header_layout 100 = "Hidden" (decision 2026-09-04): such headers stay OUT of
|
||||
# the JSON - header, subheader and headerLink render empty. headerLayout (100)
|
||||
# stays in the payload so the front end could still tell why. Placed BEFORE
|
||||
# the `< lib.contentElementWithHeader` copies below, which snapshot this lib.
|
||||
# The content-blocks `data` path gets the same rule via HiddenHeaderProcessor.
|
||||
# =============================================================================
|
||||
|
||||
lib.contentElementWithHeader.fields.content.fields {
|
||||
header.stdWrap.if {
|
||||
value = 100
|
||||
equals.field = header_layout
|
||||
negate = 1
|
||||
}
|
||||
subheader.stdWrap.if {
|
||||
value = 100
|
||||
equals.field = header_layout
|
||||
negate = 1
|
||||
}
|
||||
headerLink.stdWrap.if {
|
||||
value = 100
|
||||
equals.field = header_layout
|
||||
negate = 1
|
||||
}
|
||||
}
|
||||
|
||||
lib.contentBlock.fields.data.dataProcessing.20 = Evomedien\Vitec\DataProcessing\HiddenHeaderProcessor
|
||||
|
||||
# =============================================================================
|
||||
# Headless list-plugin JSON renderers (TYPO3 v14: each plugin is its own CType)
|
||||
#
|
||||
@@ -34,6 +67,21 @@ tt_content {
|
||||
}
|
||||
}
|
||||
|
||||
# Product Finder - only the category hierarchy for the filter selects.
|
||||
# Placed once above the product lists; the front end filters the products
|
||||
# those lists already delivered, client side, via ?cat= and ?subcat=.
|
||||
vitec_productfinder < lib.contentElementWithHeader
|
||||
vitec_productfinder {
|
||||
fields {
|
||||
content {
|
||||
fields {
|
||||
productfinder = USER
|
||||
productfinder.userFunc = Evomedien\Vitec\UserFunc\ProductFinderJsonRenderer->render
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# EXT:solr search results as JSON - the search endpoint for the React frontend
|
||||
solr_pi_results < lib.contentElementWithHeader
|
||||
solr_pi_results {
|
||||
@@ -356,7 +404,14 @@ plugin.tx_solr.index.queue {
|
||||
|
||||
products = 1
|
||||
products {
|
||||
# sys_category titles - feeds the category facet
|
||||
fields.category_stringM = SOLR_RELATION
|
||||
fields.category_stringM {
|
||||
localField = categories
|
||||
multiValue = 1
|
||||
}
|
||||
type = tx_vitec_domain_model_product
|
||||
additionalWhereClause = hideonwebsite = 0
|
||||
fields {
|
||||
title = title
|
||||
content = SOLR_CONTENT
|
||||
@@ -433,7 +488,15 @@ plugin.tx_solr.index.queue {
|
||||
|
||||
usecases = 1
|
||||
usecases {
|
||||
# market titles from the MM relation - feeds the market facet
|
||||
fields.market_stringM = SOLR_RELATION
|
||||
fields.market_stringM {
|
||||
localField = markets
|
||||
multiValue = 1
|
||||
}
|
||||
type = tx_vitec_domain_model_usecase
|
||||
# no_index is the editorial opt-out per story (concept 2026-08-20)
|
||||
additionalWhereClause = hideonwebsite = 0 AND no_index = 0
|
||||
fields {
|
||||
title = title
|
||||
content = SOLR_CONTENT
|
||||
@@ -462,6 +525,39 @@ plugin.tx_solr.index.queue {
|
||||
}
|
||||
}
|
||||
|
||||
# Solutions became indexable on 2026-09-11: the sitemap alignment gave all
|
||||
# 39 records a detail_page (and slug). Same pattern as markets.
|
||||
solutions = 1
|
||||
solutions {
|
||||
type = tx_vitec_domain_model_solution
|
||||
additionalWhereClause = detail_page > 0
|
||||
fields {
|
||||
title = title
|
||||
content = SOLR_CONTENT
|
||||
content {
|
||||
cObject = COA
|
||||
cObject {
|
||||
10 = TEXT
|
||||
10.field = subtitle
|
||||
10.noTrimWrap = || |
|
||||
20 = TEXT
|
||||
20.field = teaser
|
||||
20.noTrimWrap = || |
|
||||
30 = TEXT
|
||||
30.field = description
|
||||
30.noTrimWrap = || |
|
||||
}
|
||||
}
|
||||
url = TEXT
|
||||
url {
|
||||
typolink {
|
||||
parameter.field = detail_page
|
||||
returnLast = url
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
news = 1
|
||||
news {
|
||||
type = tx_news_domain_model_news
|
||||
@@ -547,6 +643,16 @@ plugin.tx_solr.search.faceting {
|
||||
field = type
|
||||
keepAllOptionsOnSelection = 1
|
||||
}
|
||||
market {
|
||||
label = Market
|
||||
field = market_stringM
|
||||
keepAllOptionsOnSelection = 1
|
||||
}
|
||||
category {
|
||||
label = Category
|
||||
field = category_stringM
|
||||
keepAllOptionsOnSelection = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,47 @@ $customSysCategoryColumns = [
|
||||
'eval' => 'trim',
|
||||
],
|
||||
],
|
||||
// Success stories shown below the product list of this category. Lives on
|
||||
// the category rather than on the plugin so one editorial decision serves
|
||||
// every list that renders the category (decision 2026-09-11).
|
||||
'tx_vitec_success_stories' => [
|
||||
'exclude' => true,
|
||||
'label' => 'LLL:EXT:vitec/Resources/Private/Language/locallang_db.xlf:sys_category.tx_vitec_success_stories',
|
||||
'description' => 'LLL:EXT:vitec/Resources/Private/Language/locallang_db.xlf:sys_category.tx_vitec_success_stories.description',
|
||||
'config' => [
|
||||
'type' => 'select',
|
||||
'renderType' => 'selectMultipleSideBySide',
|
||||
'foreign_table' => 'tx_vitec_domain_model_usecase',
|
||||
// No ORDER BY in foreign_table_where: TYPO3 appends its own
|
||||
// conditions after this fragment, which turns an ORDER BY here
|
||||
// into a syntax error. Sorting belongs in `sortItems`.
|
||||
'foreign_table_where' => 'AND {#tx_vitec_domain_model_usecase}.{#sys_language_uid} IN (0,-1)',
|
||||
'sortItems' => ['label' => 'asc'],
|
||||
'size' => 8,
|
||||
'minitems' => 0,
|
||||
'maxitems' => 20,
|
||||
'enableMultiSelectFilterTextfield' => true,
|
||||
],
|
||||
],
|
||||
// Same vocabulary as the story and market lists (grid|list|carousel|50-50),
|
||||
// so the front end renders these cards with the component it already has.
|
||||
'tx_vitec_success_stories_layout' => [
|
||||
'exclude' => true,
|
||||
'displayCond' => 'FIELD:tx_vitec_success_stories:REQ:true',
|
||||
'label' => 'LLL:EXT:vitec/Resources/Private/Language/locallang_db.xlf:sys_category.tx_vitec_success_stories_layout',
|
||||
'description' => 'LLL:EXT:vitec/Resources/Private/Language/locallang_db.xlf:sys_category.tx_vitec_success_stories_layout.description',
|
||||
'config' => [
|
||||
'type' => 'select',
|
||||
'renderType' => 'selectSingle',
|
||||
'default' => 'grid',
|
||||
'items' => [
|
||||
['label' => 'Grid', 'value' => 'grid'],
|
||||
['label' => 'List', 'value' => 'list'],
|
||||
['label' => 'Carousel', 'value' => 'carousel'],
|
||||
['label' => '50 / 50', 'value' => '50-50'],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
// Add the fields to the TCA
|
||||
@@ -43,4 +84,15 @@ $customSysCategoryColumns = [
|
||||
'class, filetype, type',
|
||||
'',
|
||||
'after:description'
|
||||
);
|
||||
|
||||
// Success stories go into the existing "Options" tab. That tab comes from
|
||||
// EXT:news (it holds `images`), so anchoring after that field puts our field
|
||||
// INSIDE it - adding our own --div--;...options would produce a second tab of
|
||||
// the same name. EXT:news loads before EXT:vitec, so the anchor exists.
|
||||
\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addToAllTCAtypes(
|
||||
'sys_category',
|
||||
'tx_vitec_success_stories, tx_vitec_success_stories_layout',
|
||||
'',
|
||||
'after:images'
|
||||
);
|
||||
@@ -25,6 +25,7 @@ use TYPO3\CMS\Extbase\Utility\ExtensionUtility;
|
||||
};
|
||||
|
||||
$registerPluginWithFlexForm('Productlist', 'Show Products by Category', 'FILE:EXT:vitec/Configuration/FlexForms/Productlist.xml', 'vitec-plugin-productlist');
|
||||
$registerPluginWithFlexForm('Productfinder', 'Product Finder (filter data)', 'FILE:EXT:vitec/Configuration/FlexForms/Productfinder.xml', 'vitec-plugin-productlist');
|
||||
$registerPluginWithFlexForm('Simplecard', 'Simple Card', 'FILE:EXT:vitec/Configuration/FlexForms/Simplecard.xml', 'vitec-plugin-simplecard');
|
||||
$registerPluginWithFlexForm('Productshow', 'Show Single Product', 'FILE:EXT:vitec/Configuration/FlexForms/Productshow.xml', 'vitec-plugin-productshow');
|
||||
$registerPluginWithFlexForm('Usecaseshow', 'Single Success Story', 'FILE:EXT:vitec/Configuration/FlexForms/Usecase.xml', 'vitec-plugin-usecaseshow');
|
||||
|
||||
@@ -72,10 +72,22 @@ tt_content.vitec_cols_50_50 {
|
||||
}
|
||||
}
|
||||
|
||||
# header_layout 100 = "Hidden": header, subheader and headerLink stay
|
||||
# out of the JSON (same rule as lib.contentElementWithHeader).
|
||||
header = TEXT
|
||||
header.field = header
|
||||
header.stdWrap.if {
|
||||
value = 100
|
||||
equals.field = header_layout
|
||||
negate = 1
|
||||
}
|
||||
subheader = TEXT
|
||||
subheader.field = subheader
|
||||
subheader.stdWrap.if {
|
||||
value = 100
|
||||
equals.field = header_layout
|
||||
negate = 1
|
||||
}
|
||||
headerLayout = INT
|
||||
headerLayout.field = header_layout
|
||||
headerPosition = TEXT
|
||||
@@ -87,6 +99,11 @@ tt_content.vitec_cols_50_50 {
|
||||
parameter.field = header_link
|
||||
returnLast = result
|
||||
}
|
||||
stdWrap.if {
|
||||
value = 100
|
||||
equals.field = header_layout
|
||||
negate = 1
|
||||
}
|
||||
}
|
||||
tx_vitec_bg_variant = TEXT
|
||||
tx_vitec_bg_variant.field = tx_vitec_bg_variant
|
||||
|
||||
@@ -16,6 +16,14 @@ mod {
|
||||
CType = vitec_productlist
|
||||
}
|
||||
}
|
||||
productfinder {
|
||||
iconIdentifier = vitec-plugin-productlist
|
||||
title = Product Finder (filter data)
|
||||
description = Delivers the product category hierarchy for the filter selects. Place once above the product lists - it renders no products itself.
|
||||
tt_content_defValues {
|
||||
CType = vitec_productfinder
|
||||
}
|
||||
}
|
||||
productshow {
|
||||
iconIdentifier = vitec-plugin-productshow
|
||||
title = Show single VITEC Product
|
||||
|
||||
@@ -79,6 +79,11 @@ fields:
|
||||
minitems: 0
|
||||
maxitems: 1
|
||||
allowed: mp4,webm,ogv,mov,m4v
|
||||
- identifier: video_poster
|
||||
type: File
|
||||
minitems: 0
|
||||
maxitems: 1
|
||||
allowed: common-image-types
|
||||
- identifier: link
|
||||
type: Link
|
||||
allowedTypes:
|
||||
|
||||
@@ -34,6 +34,9 @@
|
||||
<trans-unit id="items.video.label">
|
||||
<source>Video</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="items.video_poster.label">
|
||||
<source>Video Poster Image</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="items.link.label">
|
||||
<source>Link</source>
|
||||
</trans-unit>
|
||||
|
||||
@@ -78,6 +78,12 @@ fields:
|
||||
maxitems: 1
|
||||
allowed: mp4,webm,ogv,mov,m4v
|
||||
|
||||
- identifier: featured_video_poster
|
||||
type: File
|
||||
minitems: 0
|
||||
maxitems: 1
|
||||
allowed: common-image-types
|
||||
|
||||
- identifier: featured_video_autoplay
|
||||
type: Checkbox
|
||||
default: 0
|
||||
|
||||
@@ -61,6 +61,12 @@
|
||||
<trans-unit id="featured_video_muted.label">
|
||||
<source>Mute video</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="featured_video_poster.label">
|
||||
<source>Video Poster Image</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="featured_video_poster.description">
|
||||
<source>Preview image shown before the video plays.</source>
|
||||
</trans-unit>
|
||||
|
||||
<!-- Overlay -->
|
||||
<trans-unit id="overlay_color.label">
|
||||
|
||||
@@ -135,6 +135,12 @@ fields:
|
||||
maxitems: 1
|
||||
allowed: mp4,webm,ogv,mov,m4v
|
||||
|
||||
- identifier: hero_video_poster
|
||||
type: File
|
||||
minitems: 0
|
||||
maxitems: 1
|
||||
allowed: common-image-types
|
||||
|
||||
- identifier: hero_video_alignment
|
||||
type: Select
|
||||
renderType: selectSingle
|
||||
@@ -170,6 +176,12 @@ fields:
|
||||
maxitems: 1
|
||||
allowed: mp4,webm,ogv,mov,m4v
|
||||
|
||||
- identifier: hero_bgvideo_poster
|
||||
type: File
|
||||
minitems: 0
|
||||
maxitems: 1
|
||||
allowed: common-image-types
|
||||
|
||||
- identifier: hero_overlay_color
|
||||
type: Color
|
||||
label: Overlay Color
|
||||
|
||||
@@ -128,6 +128,18 @@
|
||||
<trans-unit id="hero_bgvideo.description">
|
||||
<source>Fullscreen background video behind the hero content.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="hero_video_poster.label">
|
||||
<source>Video Poster Image</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="hero_video_poster.description">
|
||||
<source>Preview image shown before the video plays.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="hero_bgvideo_poster.label">
|
||||
<source>Background Video Poster Image</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="hero_bgvideo_poster.description">
|
||||
<source>Preview image shown before the background video plays.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="hero_overlay_color.label">
|
||||
<source>Overlay Color</source>
|
||||
</trans-unit>
|
||||
|
||||
@@ -77,6 +77,12 @@ fields:
|
||||
maxitems: 1
|
||||
allowed: mp4,webm,ogv,mov,m4v
|
||||
|
||||
- identifier: intro_video_poster
|
||||
type: File
|
||||
minitems: 0
|
||||
maxitems: 1
|
||||
allowed: common-image-types
|
||||
|
||||
- identifier: intro_video_alignment
|
||||
type: Select
|
||||
renderType: selectSingle
|
||||
|
||||
@@ -100,6 +100,8 @@
|
||||
<trans-unit id="intro_video_autoplay.label"><source>Autoplay</source></trans-unit>
|
||||
<trans-unit id="intro_video_loop.label"><source>Loop</source></trans-unit>
|
||||
<trans-unit id="intro_video_muted.label"><source>Muted</source></trans-unit>
|
||||
<trans-unit id="intro_video_poster.label"><source>Video Poster Image</source></trans-unit>
|
||||
<trans-unit id="intro_video_poster.description"><source>Preview image shown before the video plays.</source></trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
| | |
|
||||
|---|---|
|
||||
| **Document identifier** | EVO‑VITEC‑HL‑001 |
|
||||
| **Version** | 1.12 |
|
||||
| **Version** | 1.17 |
|
||||
| **Status** | Released |
|
||||
| **Date** | 2026‑08‑24 |
|
||||
| **Date** | 2026‑09‑11 |
|
||||
| **Applies to** | `evomedien/vitec` on TYPO3 v14.3 (headless) |
|
||||
| **Owner** | evomedien — VITEC relaunch |
|
||||
|
||||
@@ -26,6 +26,10 @@
|
||||
| 1.10 | 2026‑08‑18 | **Interface change (additive).** `tx_vitec_domain_model_product` gained five fields: `heroimage` (multiple FAL images, detail payload only), the richtext fields `description2`, `capabilities` and `textrelatedproducts`, and `portfolio` (TCA `link`) — emitted as a **resolved URL** through the new `LinkResolver::typolinkUrl()`. The product payloads are specified for the first time (7.15). Links in `contentelement` / `contentelementcta` that point at a **container** now resolve its children (`items`, page‑level shape) and `background` (7.4). Two record link handlers (`download`, `product`) added to the link browser, resolved server‑side per the new Clause 9.12; new middleware `vitec/download-file` streams `/download/file/<uid>` as a forced download (5.3), file lookup consolidated into `DownloadFileResolver` — first step towards the B‑4 target (10.2). Backend‑only: `relatedprodukt` moved from the Misc tab to General. Editorial: the document footer had been stuck at v1.7 since v1.8. |
|
||||
| 1.11 | 2026‑08‑21 | **New interface: site search.** Apache Solr 10 (dedicated VPS behind an HTTPS reverse proxy) with `apache-solr-for-typo3/solr` 14.0.0-RC1. The EXT:solr results plugin `solr_pi_results` on the search page is rendered headless by `SearchJsonRenderer` (payload key `search`) - request/response contract in the new Clause 7.16. Indexed corpus: pages plus product, market (`detail_page` only), use-case, news and download records; result `type` vocabulary `page\|product\|market\|story\|news\|download`. Downloads gained the canonical route `/download/<slug>` (uid route kept for the record links) and `private_download` is now enforced by `DownloadFileResolver` (5.3, 9.12). The VITEC Set now declares the solr set as a dependency - overriding a foreign set’s TypoScript requires loading after it (5.2). Editorial: the header table had been stuck at v1.9 since v1.10. |
|
||||
| 1.12 | 2026‑08‑24 | **Interface change (additive).** The search endpoint (7.16) gained a type filter and facet counts: request parameter `filter` (a value from the `type` vocabulary; the natural name `type` is unavailable - it is TYPO3’s reserved page-type parameter), response keys `filter` (active filter or null) and `facets.type` (per-type document counts with `active` flags; counts stay complete while a filter is active, except when the filtered result is empty). Editorial baseline of 76 managed synonyms imported into `core_en` (codecs, acquired-brand names such as `exterity => avedia`, UK/US spellings, common misspellings) - synonyms apply at query time, no re-index. New autocomplete endpoint: the EXT:solr suggest plugin as lean JSON page type 7384, deliberately USER_INT (7.16). |
|
||||
| 1.14 | 2026‑09‑11 | **Interface change (additive).** Secondary search facets: stories index their market titles (`market_stringM`, MM relation), products their sys_category titles (`category_stringM`); the search endpoint accepts `market` and `category` GET parameters (values verbatim from `facets.<name>[].value`; an unknown value simply yields zero results) and echoes them as `activeFacets`. Visibility fix: the usecases queue now honours `no_index`/`hideonwebsite`, products defensively `hideonwebsite` - and the excluded story’s document was removed explicitly, because re-indexing never deletes. |
|
||||
| 1.15 | 2026‑09‑11 | **Corpus change (additive, index side).** Download documents now carry the extracted file text: Solr 10 has no ExtractingRequestHandler, so a dedicated Apache Tika container on the Solr VPS (Caddy route `/tika/*`, own basic-auth credential) extracts PDF/Office contents during indexing (`TikaDownloadContentIndexer` on `BeforeDocumentIsProcessedForIndexingEvent`, fail-soft per 9.5, cached in `var/tika-cache` keyed on path+size+mtime). Datasheet specifications are now searchable ("625i", "genlock"). No interface change. |
|
||||
| 1.16 | 2026‑09‑11 | **New plugin** `vitec_productfinder` (`ProductFinderJsonRenderer`, payload key `productfinder`): the product category hierarchy on its own, as the data source for the filter selects above the product lists (7.15.3, 8). No change to `vitec_productlist` — filtering happens client side over the products already delivered, keyed on the category uids this payload carries. |
|
||||
| 1.17 | 2026‑09‑11 | **Interface change (additive).** The product list payload gained `successStories` (7.15.2.1): stories an editor attaches to a `sys_category` (tab *Options*, with its own layout), emitted in the list envelope beside `products`. Each card carries `forCategories` — the product categories it came from — so the front end can hide it with the same `?cat=`/`?subcat=` filter it applies to the products. Story links reuse the 7.5 rule, with the detail page taken from the element or the new site setting `vitec.storyDetailPid`. |
|
||||
|
||||
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
|
||||
@@ -904,6 +908,91 @@ rarely shows. The `layout` vocabulary remains `0`–`3` (see the v1.9 note).
|
||||
|
||||
---
|
||||
|
||||
#### 7.15.2.1 Success stories below a product list
|
||||
|
||||
The list payload carries a second array beside `products`: the success stories
|
||||
an editor attached to the categories this element renders. The link lives on
|
||||
`sys_category` (tab *Options*), not on the plugin, so one decision serves every
|
||||
list that shows the category.
|
||||
|
||||
```json
|
||||
"successStories": {
|
||||
"layout": "grid",
|
||||
"stories": [
|
||||
{
|
||||
"uid": 12,
|
||||
"title": "Abu Dhabi Islamic Bank",
|
||||
"detailUrl": "/success-stories/abu-dhabi-islamic-bank",
|
||||
"cardImage": { },
|
||||
"categories": [ ],
|
||||
"forCategories": [58]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Envelope per 7.3 (`layout` plus the array); `layout` uses the story-list
|
||||
vocabulary `grid|list|carousel|50-50` and comes from the first category that
|
||||
contributes stories. Cards are the shared story shape of 7.5, so the same
|
||||
front-end component renders them.
|
||||
|
||||
`forCategories` is the addition: the **product** categories the story was
|
||||
attached to — not to be confused with `categories`, which stays the story's own
|
||||
topic categories. The front end filters on it, using the same `?cat=` /
|
||||
`?subcat=` parameters the product finder (7.15.3) writes: a card stays visible
|
||||
while one of its categories is selected. Stories are collected over the
|
||||
configured categories **and their descendants**, deduplicated, each carrying
|
||||
every category it came from. An element configured to show all products has no
|
||||
category context and returns the empty envelope.
|
||||
|
||||
`detailUrl` follows 7.5: the story's own `detail_page` wins; otherwise the URL
|
||||
is built from the detail page's parent path plus the story slug — the form
|
||||
`SuccessStoryPathRewrite` maps back. That page is taken from the element's
|
||||
FlexForm, falling back to the site setting `vitec.storyDetailPid`, and
|
||||
`detailUrl` is `null` when neither is set.
|
||||
|
||||
---
|
||||
|
||||
#### 7.15.3 Product Finder payload (`vitec_productfinder`, key `productfinder`)
|
||||
|
||||
The filter above the product lists is fed by its own element. It carries the
|
||||
product **category hierarchy only** — no products, no counts:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "vitec_productfinder",
|
||||
"content": {
|
||||
"productfinder": {
|
||||
"categories": [
|
||||
{
|
||||
"uid": 58,
|
||||
"title": "Platforms and End-Points",
|
||||
"subcategories": [
|
||||
{ "uid": 66, "title": "Avedia Platform" },
|
||||
{ "uid": 67, "title": "EZ TV Platform" },
|
||||
{ "uid": 68, "title": "APEX Platform" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`categories[]` are the children of the configured root category, `subcategories[]`
|
||||
their direct children — the same two levels the product records hang on, so a
|
||||
selected value maps straight onto `product.categories[].uid` in the list payload
|
||||
(7.15.2). The editor places **one** element above the lists; FlexForm offers the
|
||||
category root (empty = the `Product` tree) and the order of both levels (backend
|
||||
order, title, uid).
|
||||
|
||||
Filtering itself is a front-end concern: the list plugin is unchanged, and the
|
||||
front end narrows the products it already received using the URL parameters
|
||||
`?cat=` and `?subcat=`. Categories without products are emitted like any other —
|
||||
the front end decides whether to offer or suppress an empty result.
|
||||
|
||||
---
|
||||
|
||||
### 7.16 Search payload (`solr_pi_results`)
|
||||
|
||||
The site search runs on Apache Solr through EXT:solr. The EXT:solr results plugin
|
||||
@@ -919,6 +1008,8 @@ only the rendering differs from the stock Fluid plugin.
|
||||
| `q` | Search terms. Absent or empty: the response keeps its full shape with `numFound: 0`, so the front end never needs a second code path. |
|
||||
| `page` | 1-based result page, optional. |
|
||||
| `filter` | Restrict results to one type from the `type` vocabulary below (e.g. `filter=product`). Unknown values are ignored. Named `filter` because `type` is TYPO3’s reserved page-type parameter. |
|
||||
| `market` | Restrict to one market (stories carry the facet). Value verbatim from `facets.market[].value`; unknown values yield zero results. |
|
||||
| `category` | Restrict to one product category. Value verbatim from `facets.category[].value`. |
|
||||
|
||||
The EXT:solr namespace (`tx_solr[q]`, `tx_solr[page]`) is accepted as a fallback.
|
||||
All three parameters are excluded from cHash validation; the search page is never
|
||||
@@ -936,7 +1027,11 @@ identifier).
|
||||
"numFound": 202,
|
||||
"totalPages": 21,
|
||||
"filter": null,
|
||||
"activeFacets": {},
|
||||
"facets": {
|
||||
"market": [
|
||||
{ "value": "Sports, Venues & Entertainment", "count": 9, "active": false }
|
||||
],
|
||||
"type": [
|
||||
{ "value": "news", "count": 137, "active": false },
|
||||
{ "value": "product", "count": 13, "active": false }
|
||||
@@ -963,14 +1058,19 @@ identifier).
|
||||
tabs do not collapse - except when the filtered result is empty, where only
|
||||
the active option (count 0) is returned; front ends should then offer
|
||||
"remove filter" rather than rely on the other counts.
|
||||
- `filter` echoes the active type filter, `null` when none.
|
||||
- `filter` echoes the active type filter, `null` when none; `activeFacets`
|
||||
echoes the active secondary facet filters as `{name: value}`, `{}` when none.
|
||||
- `facets.market` (story market titles, from the MM relation) and
|
||||
`facets.category` (product sys_category titles) appear whenever matching
|
||||
documents carry the fields; further facets are pure TypoScript.
|
||||
- `suggestions` lists spellcheck alternatives ("did you mean"), `[]` if none.
|
||||
|
||||
**Indexed corpus** (`plugin.tx_solr.index.queue`): pages, plus records with a
|
||||
resolvable public URL - products, markets (only those with `detail_page`),
|
||||
use cases, news (`type = 0`; "page as news" records are excluded because their
|
||||
target pages are already indexed) and downloads (`private_download = 0 AND
|
||||
hideonwebsite = 0`). Solutions are not indexed until they carry slugs or detail
|
||||
hideonwebsite = 0`; their `content` additionally carries the file text extracted
|
||||
through Apache Tika, so datasheet specifications are searchable). Solutions are not indexed until they carry slugs or detail
|
||||
pages. Ranking boosts products (^10) and stories (^2). Every executed search is
|
||||
logged to `tx_solr_statistics` with the last two IP octets masked.
|
||||
|
||||
@@ -1005,6 +1105,7 @@ content is extracted from the page’s `tt_content` rows via
|
||||
| CType | TS pattern | Renderer / Processor | Payload key | Kind |
|
||||
|---|---|---|---|---|
|
||||
| `vitec_productlist` | `< lib.contentElementWithHeader` | ProductListJsonRenderer | `products` | list |
|
||||
| `vitec_productfinder` | `< lib.contentElementWithHeader` | ProductFinderJsonRenderer | `productfinder` | taxonomy |
|
||||
| `vitec_productshow` | `< lib.…WithHeader` | ProductShowJsonRenderer | `product` | detail |
|
||||
| `vitec_usecaselist` | `< lib.…WithHeader` | UsecaseListJsonRenderer → **UsecaseSerializer** | `usecases` | list |
|
||||
| `vitec_usecaseshow` | `< lib.…WithHeader` | UsecaseShowJsonRenderer → **UsecaseSerializer** | `usecase` | detail |
|
||||
@@ -1368,4 +1469,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.12.*
|
||||
*End of document EVO‑VITEC‑HL‑001 v1.15.*
|
||||
|
||||
@@ -27,6 +27,18 @@
|
||||
<trans-unit id="tx_vitec_domain_model_product.slug.description" resname="tx_vitec_domain_model_product.slug.description">
|
||||
<source>Slug</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sys_category.tx_vitec_success_stories" resname="sys_category.tx_vitec_success_stories">
|
||||
<source>Success Stories</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sys_category.tx_vitec_success_stories.description" resname="sys_category.tx_vitec_success_stories.description">
|
||||
<source>Shown as cards below the product list of this category. The order set here is the order they appear in.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sys_category.tx_vitec_success_stories_layout" resname="sys_category.tx_vitec_success_stories_layout">
|
||||
<source>Layout</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sys_category.tx_vitec_success_stories_layout.description" resname="sys_category.tx_vitec_success_stories_layout.description">
|
||||
<source>Display variant for these story cards - same options as the success story list.</source>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -79,7 +79,10 @@
|
||||
</f:for>
|
||||
</select>
|
||||
</td>
|
||||
<td><small>{row.oldPreview}</small></td>
|
||||
<td data-vitec-inline-edit="1" data-uid="{product.uid}" data-field="{row.field}"
|
||||
style="cursor:pointer;" title="Click to edit this field">
|
||||
<small data-vitec-preview="1">{row.oldPreview}</small>
|
||||
</td>
|
||||
<td>
|
||||
<f:if condition="{row.new}">
|
||||
<f:then>
|
||||
@@ -96,6 +99,10 @@
|
||||
<td>
|
||||
<input class="form-check-input" type="checkbox" name="apply[]" value="{row.field}"
|
||||
{f:if(condition: row.changed, then: 'checked="checked"')} />
|
||||
<button type="submit" class="btn btn-sm btn-default" style="margin-left:.5rem;"
|
||||
name="applySingle" value="{row.field}"
|
||||
formaction="{f:be.uri(route: 'web_vitecimport.product_apply')}"
|
||||
title="Write only this field now">Save</button>
|
||||
</td>
|
||||
</tr>
|
||||
</f:for>
|
||||
@@ -152,6 +159,10 @@
|
||||
<td>
|
||||
<input class="form-check-input" type="checkbox" name="applyRelated[]" value="{rel.index}"
|
||||
{f:if(condition: rel.changed, then: 'checked="checked"')} />
|
||||
<button type="submit" class="btn btn-sm btn-default" style="margin-left:.5rem;"
|
||||
name="applyRelatedSingle" value="{rel.index}"
|
||||
formaction="{f:be.uri(route: 'web_vitecimport.product_apply')}"
|
||||
title="Write only this card now">Save</button>
|
||||
</td>
|
||||
</tr>
|
||||
</f:for>
|
||||
|
||||
@@ -91,6 +91,10 @@
|
||||
sub-solutions. Products: level x.y and deeper (the top product rows are
|
||||
categories). Matching: record slug against the last URL segment, falling back
|
||||
to a normalized title comparison. The Page Ref column shows the hierarchy.
|
||||
A row the matcher misses can be <strong>linked by hand</strong> via its select
|
||||
— the link is stored per model (shared with the import tabs) and re-applied
|
||||
on every future delivery. Linked rows show up under "Both" with a badge
|
||||
and can be re-pointed or removed there.
|
||||
</p>
|
||||
|
||||
<div class="row">
|
||||
@@ -98,22 +102,52 @@
|
||||
<f:if condition="{area} != 'pages'">
|
||||
<div class="col-md-4">
|
||||
<h4 style="text-transform:capitalize;">{area}</h4>
|
||||
<form action="{f:be.uri(route: 'web_vitecimport.seo_aliases')}" method="post">
|
||||
<input type="hidden" name="model" value="{area}" />
|
||||
<f:if condition="{check.both}">
|
||||
<p><strong>Both in CSV and TYPO3 ({check.both -> f:count()})</strong></p>
|
||||
<ul>
|
||||
<f:for each="{check.both}" as="pair">
|
||||
<li><code>{pair.ref}</code> {pair.name} <small class="text-muted">→ uid {pair.uid} ({pair.title})</small></li>
|
||||
<f:for each="{check.both}" as="pair" iteration="bIt">
|
||||
<li>
|
||||
<code>{pair.ref}</code> {pair.name}
|
||||
<small class="text-muted">→ uid {pair.uid} ({pair.title})</small>
|
||||
<f:if condition="{pair.via} == 'link'">
|
||||
<span class="vitec-badge vitec-badge-info">linked</span>
|
||||
<input type="hidden" name="aliaskey[b{bIt.index}]" value="{pair.name}" />
|
||||
<select name="alias[b{bIt.index}]" class="form-select form-select-sm" style="margin:2px 0 6px;">
|
||||
<option value="0">— remove link —</option>
|
||||
<f:for each="{check.linkable}" as="rec">
|
||||
<option value="{rec.uid}" {f:if(condition: '{rec.uid} == {pair.uid}', then: 'selected')}>{rec.title} (uid {rec.uid})</option>
|
||||
</f:for>
|
||||
</select>
|
||||
</f:if>
|
||||
</li>
|
||||
</f:for>
|
||||
</ul>
|
||||
</f:if>
|
||||
<f:if condition="{check.missing}">
|
||||
<p><strong>Missing in TYPO3 ({check.missing -> f:count()})</strong></p>
|
||||
<ul>
|
||||
<f:for each="{check.missing}" as="row">
|
||||
<li><code>{row.ref}</code> {row.name}</li>
|
||||
<f:for each="{check.missing}" as="row" iteration="mIt">
|
||||
<li>
|
||||
<code>{row.ref}</code> {row.name}
|
||||
<f:if condition="{check.linkable}">
|
||||
<input type="hidden" name="aliaskey[m{mIt.index}]" value="{row.name}" />
|
||||
<select name="alias[m{mIt.index}]" class="form-select form-select-sm" style="margin:2px 0 6px;">
|
||||
<option value="0">— really missing —</option>
|
||||
<f:for each="{check.linkable}" as="rec">
|
||||
<option value="{rec.uid}">{rec.title} (uid {rec.uid})</option>
|
||||
</f:for>
|
||||
</select>
|
||||
</f:if>
|
||||
</li>
|
||||
</f:for>
|
||||
</ul>
|
||||
</f:if>
|
||||
<f:if condition="{check.missing} || {check.both}">
|
||||
<p><button type="submit" class="btn btn-default btn-sm">Save record links</button></p>
|
||||
</f:if>
|
||||
</form>
|
||||
<f:if condition="{check.extra}">
|
||||
<p><strong>Only in TYPO3 ({check.extra -> f:count()})</strong></p>
|
||||
<ul>
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* VITEC – click-to-edit for the "Current value" column of the product text
|
||||
* import (Edit Product view).
|
||||
* EXT:vitec/Resources/Public/Javascript/product-inline-edit.js
|
||||
*
|
||||
* Delegated click handler on [data-vitec-inline-edit] cells (backend CSP
|
||||
* forbids inline handlers). Clicking a cell fetches the FULL raw field value
|
||||
* (the cell itself only shows a truncated preview), swaps in a textarea with
|
||||
* Save/Cancel, and writes through the vitec_product_field_save AJAX route,
|
||||
* which uses the same whitelist and DataHandler path as the form apply.
|
||||
* RTE fields are edited as raw HTML on purpose - no inline WYSIWYG
|
||||
* (decision 2026-08-28).
|
||||
*/
|
||||
import AjaxRequest from "@typo3/core/ajax/ajax-request.js";
|
||||
import Notification from "@typo3/backend/notification.js";
|
||||
|
||||
const SELECTOR = "[data-vitec-inline-edit]";
|
||||
|
||||
function previewOf(cell) {
|
||||
return cell.querySelector("[data-vitec-preview]");
|
||||
}
|
||||
|
||||
function closeEditor(cell) {
|
||||
const editor = cell.querySelector("[data-vitec-editor]");
|
||||
if (editor) {
|
||||
editor.remove();
|
||||
}
|
||||
const preview = previewOf(cell);
|
||||
if (preview) {
|
||||
preview.hidden = false;
|
||||
}
|
||||
delete cell.dataset.vitecEditing;
|
||||
}
|
||||
|
||||
async function openEditor(cell) {
|
||||
if (cell.dataset.vitecEditing === "1") {
|
||||
return;
|
||||
}
|
||||
cell.dataset.vitecEditing = "1";
|
||||
|
||||
const getUrl = TYPO3.settings.ajaxUrls["vitec_product_field_get"];
|
||||
const saveUrl = TYPO3.settings.ajaxUrls["vitec_product_field_save"];
|
||||
if (!getUrl || !saveUrl) {
|
||||
Notification.error("Inline edit", "AJAX routes are not registered.");
|
||||
delete cell.dataset.vitecEditing;
|
||||
return;
|
||||
}
|
||||
|
||||
let value = "";
|
||||
try {
|
||||
const response = await new AjaxRequest(getUrl)
|
||||
.withQueryArguments({ uid: cell.dataset.uid, field: cell.dataset.field })
|
||||
.get();
|
||||
const data = await response.resolve();
|
||||
if (!data.success) {
|
||||
Notification.error("Inline edit", data.message || "Could not load the field value.");
|
||||
delete cell.dataset.vitecEditing;
|
||||
return;
|
||||
}
|
||||
value = data.value;
|
||||
} catch (e) {
|
||||
Notification.error("Inline edit", "Loading the field value failed.");
|
||||
delete cell.dataset.vitecEditing;
|
||||
return;
|
||||
}
|
||||
|
||||
const preview = previewOf(cell);
|
||||
if (preview) {
|
||||
preview.hidden = true;
|
||||
}
|
||||
|
||||
const wrap = document.createElement("div");
|
||||
wrap.setAttribute("data-vitec-editor", "1");
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.className = "form-control";
|
||||
textarea.rows = Math.min(14, Math.max(3, Math.ceil(value.length / 80)));
|
||||
textarea.style.fontFamily = "monospace";
|
||||
textarea.style.fontSize = "11px";
|
||||
textarea.value = value;
|
||||
wrap.appendChild(textarea);
|
||||
|
||||
const bar = document.createElement("div");
|
||||
bar.style.marginTop = ".25rem";
|
||||
|
||||
// type="button" is essential: the cells live inside the big mapping <form>,
|
||||
// a bare <button> would submit it.
|
||||
const saveBtn = document.createElement("button");
|
||||
saveBtn.type = "button";
|
||||
saveBtn.className = "btn btn-sm btn-primary";
|
||||
saveBtn.textContent = "Save";
|
||||
|
||||
const cancelBtn = document.createElement("button");
|
||||
cancelBtn.type = "button";
|
||||
cancelBtn.className = "btn btn-sm btn-default";
|
||||
cancelBtn.style.marginLeft = ".5rem";
|
||||
cancelBtn.textContent = "Cancel";
|
||||
|
||||
bar.appendChild(saveBtn);
|
||||
bar.appendChild(cancelBtn);
|
||||
wrap.appendChild(bar);
|
||||
cell.appendChild(wrap);
|
||||
textarea.focus();
|
||||
|
||||
cancelBtn.addEventListener("click", () => closeEditor(cell));
|
||||
textarea.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") {
|
||||
closeEditor(cell);
|
||||
}
|
||||
});
|
||||
|
||||
saveBtn.addEventListener("click", async () => {
|
||||
saveBtn.disabled = true;
|
||||
try {
|
||||
const response = await new AjaxRequest(saveUrl).post({
|
||||
uid: cell.dataset.uid,
|
||||
field: cell.dataset.field,
|
||||
value: textarea.value,
|
||||
});
|
||||
const data = await response.resolve();
|
||||
if (!data.success) {
|
||||
Notification.error("Inline edit", data.message || "Saving failed.");
|
||||
saveBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
if (preview) {
|
||||
preview.textContent = data.preview;
|
||||
}
|
||||
closeEditor(cell);
|
||||
Notification.success("Inline edit", cell.dataset.field + " saved.");
|
||||
} catch (e) {
|
||||
Notification.error("Inline edit", "Saving failed.");
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
const cell = event.target.closest(SELECTOR);
|
||||
if (cell === null) {
|
||||
return;
|
||||
}
|
||||
// Clicks on the editor's own controls must not re-open it.
|
||||
if (event.target.closest("[data-vitec-editor]")) {
|
||||
return;
|
||||
}
|
||||
openEditor(cell);
|
||||
});
|
||||
@@ -258,3 +258,5 @@ $GLOBALS['TYPO3_CONF_VARS']['SYS']['formEngine']['nodeRegistry'][1750000000] = [
|
||||
$GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'q';
|
||||
$GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'page';
|
||||
$GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'filter';
|
||||
$GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'market';
|
||||
$GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'category';
|
||||
|
||||
@@ -362,3 +362,12 @@ CREATE TABLE tx_vitec_product_workbook (
|
||||
tstamp int(11) DEFAULT '0' NOT NULL,
|
||||
PRIMARY KEY (product_uid)
|
||||
);
|
||||
|
||||
#
|
||||
# Success stories shown below the product list of a category (2026-09-11).
|
||||
# Comma-separated usecase uids, order = the editor's arrangement.
|
||||
#
|
||||
CREATE TABLE sys_category (
|
||||
tx_vitec_success_stories varchar(1024) DEFAULT '' NOT NULL,
|
||||
tx_vitec_success_stories_layout varchar(30) DEFAULT 'grid' NOT NULL
|
||||
);
|
||||
|
||||
@@ -25,8 +25,8 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<title>VITEC</title>
|
||||
<script type="module" crossorigin src="/_frontend/assets/index-DkP6_MI5.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/_frontend/assets/index-BIG4WK1s.css">
|
||||
<script type="module" crossorigin src="/_frontend/assets/index-BhQWo9uW.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/_frontend/assets/index-CQJ1iyvA.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="page"></div>
|
||||
|
||||
Reference in New Issue
Block a user