Compare commits

...

11 Commits

Author SHA1 Message Date
55f7f82a42 Show success stories below the product lists, linked on the category
sys_category gains a story selection and a layout (Options tab, alongside
the images field EXT:news puts there), so one editorial decision serves
every product list that renders the category. ProductListJsonRenderer emits
them beside the products in the 7.3 list envelope; each card carries
forCategories - the product categories it came from - so the front end hides
it with the same ?cat=/?subcat= filter it applies to the products. Stories
are collected over the category and its descendants and deduplicated.

Story links follow the existing rule (detail page's parent path plus slug,
which SuccessStoryPathRewrite maps back), taking the page from the element's
FlexForm or the new site setting vitec.storyDetailPid.

Spec raised to 1.17 with clause 7.15.2.1; the header had been left at 1.12
although entries up to 1.15 already existed, so today's two entries were
renumbered to 1.16 and 1.17.
2026-09-11 15:28:32 +02:00
c90649fc1e Drop headless-mixed set; widen asset permissions to all of fileadmin
The headless-mixed set was active although the site runs full headless. On
any request whose Accept header equals exactly 'application/json' it
re-imported the headless page configuration, and 'page < lib.headlessPage'
replaced the whole page object - dropping every field later sets had added:
mainNavigation, footerMenu, metaMenu, megaMenu, jsonLd. The header is not
part of the page cache key, so whichever request rendered first decided what
everyone got, which made it look like a concurrency race. Removing the set
restores the full 15-key payload for every Accept variant.

fix-asset-perms.sh only covered _assets, fileadmin/icons and the extension's
Resources/Public, so anything an importer wrote under public/fileadmin kept
the mount's permissions and returned 403 - most visibly the success-story
images in the megamenu. It now covers public/fileadmin as a whole.
2026-09-11 13:24:28 +02:00
8c881d153c Index PDF and Office file contents via Apache Tika
Solr 10 dropped the ExtractingRequestHandler, so a dedicated Tika
container on the Solr VPS (Caddy route /tika/*, own basic-auth
credential; Tika itself has no auth) extracts file text during
indexing: TikaDownloadContentIndexer listens on
BeforeDocumentIsProcessedForIndexingEvent, resolves the file through
the existing DownloadFileResolver and appends the text (mime
whitelist, 30 MB cap, 100k chars, fail-soft) to the document's content
field. Extractions are cached in var/tika-cache keyed on
path+size+mtime - a full re-index of 177 downloads drops from 2:12 min
to 27 s, replacing a file re-extracts naturally. Datasheet
specifications ("625i", "genlock") are now searchable. Spec v1.15.
2026-09-11 13:15:05 +02:00
5bb4e374b4 Add evo_megamenu_json extension and seed the megamenu
- new project-neutral extension evo_megamenu_json (Evomedien): a Megamenu
  plugin holding entries, columns, items, teaser cards and a featured slot
  as nested records; presentation settings travel with the payload so the
  front end reads behaviour instead of hard-coding it; published as
  page.10.fields.megaMenu, driven by the site settings megamenu.contentUid
  and megamenu.storagePid
- vitec:seed-megamenu fills one element from the live structure: 5 entries,
  26 columns, 95 items, 4 story cards; items without a page yet are flagged
  pending so the front end falls back to the column link
- vitec:debug-sets prints the resolved site set order
- read-only diagnostics: check_megamenu_schema.php, check_typoscript_templates.php
- docs: schema updates run via extension:setup (database:updateschema no
  longer exists in v14), and a JSON Accept header activates headless-mixed,
  which strips every set-added page field
2026-09-11 11:44:20 +02:00
e9eaa62d89 Align solutions to the sitemap's 39 Generic Solutions; map detail pages
- vitec:align-solutions: declarative target = the 39 Generic Solutions in 8
  group categories under the Solution root (sheet is binding, group typo
  Engagament->Engagement fixed). Two legacy records renamed onto their sheet
  names instead of deleted (keeps the only editorial teaser/description),
  slugs generated for all, market categories stripped from solutions, the
  ~82 market-specific records soft-deleted (markets link editorially)
- vitec:map-solution-pages: writes matching page uids into detail_page
  (unique matches, fill-only); disambiguates the duplicated page branches
  by slug suffix and prunes the 10 empty duplicate pages
- read-only diagnostics: check_solutions.php, check_dup_solution_pages.php
2026-09-04 15:30:27 +02:00
c91063d6f3 Import product photos from live sites; link downloads and SEO meta to products
- vitec:import-product-images: attach staged photos (fileadmin/user_upload/
  products/<slug>/, 116 files harvested from vitec.com and datapath.co.uk
  carousels) to image-less products via FAL/DataHandler; fill-up only.
  fix_image_reference_fieldname.php repairs the first run's references
  (TCA field is productimage, not the JSON key images)
- vitec:link-product-downloads: match the download records imported on
  2026-08-06 to products by title (doc-type/brand/role suffix stripping,
  alias table for live-site naming deviations, series rules for g45xx/e38xx/
  chassis docs); 55 unique-match links added, add-only, idempotent
- SEO meta: seo_meta_vitec.json + apply_seo_meta.php fill empty seotitle/
  seometa from the live vitec.com product pages (11 products, fill-up only)"
2026-09-04 14:34:38 +02:00
4a49196bcc Move parenthetical descriptors from product titles into subtitle
migrations/split_title_descriptors.php (dry run by default, --apply writes):
only titles ending in a parenthetical are touched, slugs stay unchanged,
non-empty subtitles are never overwritten. Applied to 23 records - titles
now carry the bare V2 product name, the descriptor lives in subtitle."
2026-09-04 13:32:52 +02:00
5aad13e635 Keep hidden headers (header_layout 100) out of the JSON
Applied on all three render paths: stdWrap.if rule on
lib.contentElementWithHeader (before the plugin copies), the same rule in
the container element definitions, and a HiddenHeaderProcessor behind the
nb content-blocks data processor. headerLayout itself stays in the payload
so the front end can tell why the header is empty.
2026-09-04 13:21:50 +02:00
5499ca54af Align product records with sitemap V2; product family model; CLI workbook import
- vitec:migrate-product-structure: exact V2 titles, Milestone typo fix (incl.
  slug), QTX100 stray VSN category removed, Aligo record moved to its own
  category with slug /product/aligo, new Aligo Workstation record takes over
  /product/aligo-workstation
- product families (sitemap column K) are ordinary content pages now:
  vitec:remove-family-records deletes the interim landing records again,
  reset_subproduct_flags.php clears the misused subproduct flag (the list
  renderer has always excluded subproduct=1)
- vitec:import-product-workbook: non-interactive counterpart of the module
  import (same reader, mapping and DataHandler path); Arqa imported, Aligo
  re-applied with the corrected capabilities markup
- ProductXlsxReader: new :copy cell selector (Body Copy with CTA fallback)
  absorbs the agency's column drift; default teaser mapping uses it
- ProductListJsonRenderer: selected categories now match their whole subtree,
  results ordered by category tree position, sheet order within a category
- read-only diagnostics: check_product_structure.php, check_productlist_ces.php
2026-09-04 12:59:17 +02:00
7833e1b668 Product import 2026-08-28 13:57:54 +02:00
7442d10e68 SEO record links; story markets repair; story page titles
Kev's keyword CSV and TYPO3 hold the same solutions under slightly
different names, so the structure check filed them as missing/extra.
Matching is now slug -> normalized title -> record alias, using the
per-model alias store the import tabs got on 2026-08-18
(tx_vitec_import_mapping.record_aliases, key = lowercased CSV name).
One link therefore serves the import union list, the structure check
and vitec:create-markets (same service - linked rows are no longer
proposed as new records).

- every "Missing in TYPO3" row carries a select of all records the
  direct match did not claim; "Save record links" persists per model
- alias-matched rows show under "Both" with a "linked" badge and the
  same select; option 0 removes the link
- SeoResearchService::recordsCheck() additionally returns `linkable`
  (the select options) and flags alias matches with via=link
- new backend POST route seo_aliases

Success stories: --markets-only repair mode
- /success-stories emitted empty `markets` for all 48 stories - a data
  problem (code and TCA untouched since 17.08.); read-only diagnosis
  script migrations/check_usecase_markets.php added
- vitec:import-success-stories --markets-only re-derives each story's
  industries from the export exactly like the full import and rewrites
  ONLY the markets MM relation of the existing record via DataHandler;
  nothing else is touched, no usecase created or deleted, unresolved
  stories keep their current relations
- decision: industries are translated onto the nine main markets of
  the current taxonomy (INDUSTRY_TO_MARKET) instead of recreating the
  seven deleted industry markets; the repair mode never creates
  market records (root cause: those industry markets were deleted
  after the 07.08. taxonomy import, orphaning all story relations)
- ensureMarkets() gained a $create flag for reuse by the full import

Story detail pages: real page title
- every story page answered with the generic page title "Story"; same
  defect and same fix as the product pages on 21.08.: new
  UsecasePageTitleProvider (singleton), registered as vitecUsecase in
  config.pageTitleProviders, fed from UsecaseShowJsonRenderer with
  seo_title falling back to title

Requires on the server:
    vendor/bin/typo3 cache:flush
2026-08-26 19:48:43 +02:00
93 changed files with 6891 additions and 506 deletions

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View 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.');

View 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']));
}
}

View 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");

View 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));

View 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";

View 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";

View 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";
}
}

View 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";

View File

@@ -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') {

View 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";

View 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";

View 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";

View 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"
}
}

View 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.'
);

View 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;
}
}
}

View File

@@ -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;
}
}

View 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>

View 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',
],
];

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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}
}

View File

@@ -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,
';
})();

View File

@@ -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',
],
],
],
],
];

View File

@@ -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',
],
],
],
];

View File

@@ -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',
],
],
],
];

View File

@@ -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',
],
],
],
];

View 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.

View File

@@ -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>

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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"
}
}
}

View 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' => '',
],
],
];

View 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
);

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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]);
}
}
}

View File

@@ -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();

View 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)));
}
}

View 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)));
}
}

View File

@@ -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);
}
}

View 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;
}
}

View 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));
}
}

View File

@@ -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
/**

View File

@@ -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,

View File

@@ -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;
}
}

View File

@@ -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';
}
}

View File

@@ -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
{

View File

@@ -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,
];
}

View File

@@ -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;
}
}

View 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;
}
}

View 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;
}
}

View File

@@ -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.
*

View File

@@ -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' => [],

View File

@@ -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);

View File

@@ -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',
],
];

View File

@@ -35,6 +35,10 @@ return [
'target' => ImportController::class . '::seoUploadAction',
'methods' => ['POST'],
],
'seo_aliases' => [
'target' => ImportController::class . '::seoAliasesAction',
'methods' => ['POST'],
],
'products' => [
'target' => ProductTextImportController::class . '::productsAction',
],

View 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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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).'

View File

@@ -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
}
}
}

View File

@@ -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'
);

View File

@@ -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');

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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>

View File

@@ -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

View File

@@ -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">

View File

@@ -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

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -3,9 +3,9 @@
| | |
|---|---|
| **Document identifier** | EVOVITECHL001 |
| **Version** | 1.12 |
| **Version** | 1.17 |
| **Status** | Released |
| **Date** | 20260824 |
| **Date** | 20260911 |
| **Applies to** | `evomedien/vitec` on TYPO3 v14.3 (headless) |
| **Owner** | evomedien — VITEC relaunch |
@@ -26,6 +26,10 @@
| 1.10 | 20260818 | **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`, pagelevel shape) and `background` (7.4). Two record link handlers (`download`, `product`) added to the link browser, resolved serverside 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 B4 target (10.2). Backendonly: `relatedprodukt` moved from the Misc tab to General. Editorial: the document footer had been stuck at v1.7 since v1.8. |
| 1.11 | 20260821 | **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 sets TypoScript requires loading after it (5.2). Editorial: the header table had been stuck at v1.9 since v1.10. |
| 1.12 | 20260824 | **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 TYPO3s 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 | 20260911 | **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 storys document was removed explicitly, because re-indexing never deletes. |
| 1.15 | 20260911 | **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 | 20260911 | **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 | 20260911 | **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 TYPO3s 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 pages `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 EVOVITECHL001 v1.12.*
*End of document EVOVITECHL001 v1.15.*

View File

@@ -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>

View File

@@ -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>

View File

@@ -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
&mdash; the link is stored per model (shared with the import tabs) and re-applied
on every future delivery. Linked rows show up under &quot;Both&quot; 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">&rarr; 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">&rarr; 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">&mdash; remove link &mdash;</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">&mdash; really missing &mdash;</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>

View File

@@ -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);
});

View File

@@ -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';

View File

@@ -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
);

View File

@@ -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>