Compare commits

..
67 Commits
Author SHA1 Message Date
orascheandClaude Fable 5.1 17b08df283 Content checks: flag placeholder images instead of counting them as done
Files below fileadmin/placeholders/ (or named "...placeholder...") are grey
dummies. PageCheckService and ProductCheckService now report them:

- image cells carry `placeholder` = number of placeholder files (only when > 0);
  `ok` is unchanged (something is attached)
- summary.filled counts only cells without placeholders, new summary.placeholder
  counts the cells that have one
- detail images carry `placeholder: true|false`
- cards: `placeholder` = cards whose icon/image is a placeholder; record: the
  record image

Additive change to the JSON contract; frontend rendering follows (ticket).
Requested by Harry 2026-11-13.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-13 18:47:12 +01:00
orascheandClaude Fable 5.1 20bcbeb731 Page content check for markets and solutions (vitec_pagecheck)
Sibling of the product check (decision Harry 2026-11-13): new content element vitec_pagecheck with a FlexForm scope (markets | solutions) and PageCheckService/PageCheckJsonRenderer. Overview = one row per page of the tree (11 markets + 24 sub-markets, 8 solution groups + 39 sub-solutions) with 18 checks taken from the page elements (hero image and subline, lead, intro text/image/CTA, cards with visuals, testimonials, products, insights text/cover/CTA, logos, CTA banner), the page properties (meta description, OG image, page media) and the market/solution record behind the page; columns a page type does not have come as ok null. ?check=<pageUid> delivers the page detail (elements in page order with texts, links, images, FlexForm settings; SEO, media, record, children). Always USER_INT, check is cHash-excluded already. New command vitec:pagecheck-pages created /markets/market-check (608) and /solutions/solution-check (609) from the product-check page properties; both live on DEV, rendering is front end work.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-13 11:44:41 +01:00
orascheandClaude Fable 5.1 47c8e2902f Market pages: elements for the Figma structure (0124-FE)
New command vitec:markets-figma-elements (dry-run by default, --apply writes) for the 11 main market pages: numbered placeholder image placeholders/pages/<uid>-02.png and learnmore_target /contact on vitec_marketintro, the hidden Trusted-by and Case-studies shells of the 2026-10-23 page import unhidden (logos with the home page settings, case studies as testimonials with the empty selection = all stories, like the sub-market pages), the lead paragraph in the hero slot hidden (decision aehm 12.11., not deleted). Sub-market pages: vitec_marketinsights CTA /success-stories -> /insights/news with the label Explore our insights. Applied on DEV 2026-11-12: 11 images, 11 targets, 11 logo bands, 11 sliders, 11 hidden leads, 24 CTAs, 0 warnings; protocol in migrations/markets_figma_apply_20261112.txt.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-12 19:14:05 +01:00
orascheandClaude Opus 5.5 6a00926b89 vitec:ssr-health --test-mail: send a sample alert through the real alert path
Deliverability check (e.g. mail-tester.com) with the same sender, mailer and plain-text format as a real alert. Optional address; without one the configured alert recipients get it. No health sample is taken.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 15:39:53 +01:00
orascheandClaude Fable 5.1 4f47382679 Products menu: single-product overviews link to the product (spec v1.33)
Decision Harry 2026-11-12 for the third level of the products panel: an overview page with exactly one product is presented as that product. items[].link becomes the product detail page and items[].image the product picture (productimage, else heroimage, else the page media as before); new additive fields items[].linkType (product | overview) and items[].overviewLink (always the overview page). The product uid is resolved from the card link via the product slug; one extra query per build. Applies today to Avedia, EZ TV, X-Player End-Points, ChannelLink IP Gateway and ActiveSQX and switches back automatically once an editor adds a second card. Removing the sub-product line and the shortcut pill/subline is front end work (ticket 0121-BE).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-12 13:11:56 +01:00
orascheandClaude Fable 5.1 8031247acf Page JSON without mainNavigation, i18n and favicons (0110-FE)
The frontend reads the main navigation from the headless initial-data type (?type=834, field navigation) in both SSR and SPA mode, has no language switch and keeps its favicons static, so the three page-level fields were dead weight (about 15 kB per page for mainNavigation alone). Removed from page.10.fields; lib.mainNavigation dropped, FaviconsJsonRenderer stays in the code base unused. Verified on DEV: page JSON of /, /product/aligo and /products/appliances without the three keys, footerMenu/metaMenu unchanged, /_json/?type=834 still delivers the 7 top-level items.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-12 12:54:12 +01:00
orascheandClaude Opus 5.5 e14f94ba63 SSR auto-purge: purge only the pages that show a saved record, megamenu purges everything
SsrPurgeTargetResolver finds the paths of a saved VITEC or news record: its detail URL, the pages that select it (sys_refindex), product lists by category or all products, list plugins of its type and the records embedding it one level deep. URL, visibility and timing changes, the newest news, events, unknown tables and more than 150 paths still purge everything. Megamenu records and the megamenu element now purge everything (before: only its storage page). New read-only command vitec:ssr-purge-targets shows what a save would purge. Reference index rebuilt (117 updates).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 12:16:44 +01:00
orascheandClaude Opus 5.5 06b9bc8293 SSR cockpit: label the response time, leave test reports out of the client error counts (0114-FE)
The big response time number is labelled last check. ClientErrorClassifier tells smoke and curl test reports and bot reports apart; tests are counted separately and left out of the tile, the hourly bars, the list and the dashboard tile, bots keep a label.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 11:42:57 +01:00
orascheandClaude Fable 5.1 4bc38b1760 Product content check as page content (vitec_productcheck)
The product audit of public/product-check/ becomes data for the React frontend: new content element vitec_productcheck (plugin registration, wizard entry, TypoScript USER func) delivers under content.productcheck either the overview (every product with one {ok,count} check per field - hero, photos, og, video, teaser, subtitle, description, description2, highlights, capabilities, applications, features incl. icon count, resources, datasheets, solutions - plus per-field summary) or, with ?check=<uid>, the detail of one product (texts as stored HTML, images with original URL and 480px thumbnail, videos, feature cards with icons, resources, datasheets, solutions). ProductCheckService ports the PDO queries of the standalone page to Doctrine; the renderer is always USER_INT so the audit is never served from the page cache; check is excluded from the cHash. Migration adds the element to page 607 (/products/product-check). The standalone page stays untouched. Verified on DEV: overview 85 products in 0.13 s warm, detail 0.2-0.9 s, thumbnails resolve.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-12 11:34:59 +01:00
orascheandClaude Opus 5.5 b36dceb486 Scheduler: VITEC tasks instead of separate crontab lines
New command vitec:scheduler-setup creates the scheduler group VITEC with the tasks vitec:ssr-health every 5 minutes and vitec:client-error-cleanup daily at 04:23, safe to run again; --disabled creates them switched off, --enable switches them on after the old crontab lines are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 11:21:53 +01:00
orascheandClaude Opus 5.5 90cb2faaf9 SSR alert mail recipients maintainable in the site settings
New site setting vitec.ssr.alertEmails (Site Management, Settings, VITEC; one address per entry). vitec:ssr-health reads the recipients from there, invalid entries are skipped and logged, SSR_ALERT_EMAIL in the env file stays as fallback. The cockpit shows the recipients in the live band. Pre-filled with the address that was in the env file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 11:13:37 +01:00
orascheandClaude Opus 5.5 994f9f2a1b SSR cockpit and dashboard tiles: info button with tooltip
Every tile of the module and the three SSR dashboard widgets has a round question mark button top right. Hovering it or focusing it with the keyboard shows a tooltip that explains what the tile shows. Pure CSS, anchored to the card so it never leaves the tile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 11:05:33 +01:00
orascheandClaude Opus 5.5 62f3f5394e SSR dashboard tiles in the cockpit look
The three SSR widgets render dark VITEC tiles like the cockpit module: status with pulse, cache ring and last purge, statistics with the 24 h availability strip and number tiles, errors with hit rate ring and split bar. Shared markup in CockpitHtml, styles in dashboard-ssr.css loaded per widget.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 10:59:49 +01:00
orascheandClaude Fable 5.1 cda26947f2 Ordner mit genau einer Uebersichtsseite leiten auf diese (RF Gateway, AV over IP & KVM)
Nachtrag zu 1b72e65 (Vorschlag Content-Import-Session): statt /products
zeigen die Redirects der Ordner rf-gateway und av-over-ip-and-kvm samt
ihrer Alt-URL-Redirects auf /products/avedia-modular-system bzw.
/products/aligo. Migration folder_redirect_targets_20261111 (angewendet).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-11 10:59:02 +01:00
orascheandClaude Fable 5.1 1b72e65a1d 301 fuer die /products-Gruppenordner auf /products (Entscheidung Harry 11.11.)
Die 11 Gruppenseiten des Produktbaums wurden am 10.11. im Backend zu
Ordnern (doktype 254) und antworteten seitdem 404 (Seobility-Crawl 3:
8 'technische Probleme'). Harry: Absicht -> Migration
folder_pages_redirects_20261111 (angewendet) legt 301 auf /products an
(mit und ohne Slash) und biegt aeltere Redirects, die auf einen dieser
Ordner zeigten, ebenfalls auf /products um. Inhalte auf den Ordnern
bleiben unangetastet.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-11 10:56:43 +01:00
orascheandClaude Opus 5.5 67ded6e502 SSR module: dark VITEC cockpit with live charts
Live band with 24 h availability strip, gauges for hit rate, response time, cache fill and client errors, traffic chart 24 h / 7 days from the health log counter deltas drawn with the core Chart.js of EXT:dashboard, warm progress ring with live polling, purge activity timeline, client error cards, link to the site settings. ssr-cockpit.js replaces ssr-warm-progress.js, whose deletion went into a9923e1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 10:52:46 +01:00
orascheandClaude Fable 5.1 a9923e19e0 Paginierte/gefilterte Listen: eigener Titel und Description (Page N, Kategorie)
Mit dem selbstreferenzierenden Canonical (07f74e3) zaehlten
/insights/news?page=2 und ?category=... als eigene Seiten mit dem
Titel von Seite 1 (Seobility: 3 doppelte Titel, 2x Duplicate Content).
VitecMetaHandler haengt jetzt ' - Page N' bzw. die Kategorie an Titel
und Description.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-11 10:49:48 +01:00
orascheandClaude Fable 5.1 8cdcd99eb3 Why-VITEC-Band auf den 3 alten Kategorieseiten (0106-FE, Entscheidung Harry)
migrations/why_band_unwrap_20261111.php: loest die vitec_cols-Container
hinter dem Why-VITEC-Intro auf graphics-and-multi-display,
infrastructure-and-accessories und video-capture auf, stellt die 12 Karten
als icon_card top-level hinter das Intro und sortiert die Hauptspalte neu
(Inspect default, --apply). Angewandt, Seiten gepurgt und gewaermt.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-11 10:25:29 +01:00
orascheandClaude Fable 5.1 086a178341 SSR module: rebuilt on TYPO3 module layout, cards and core components
Ticket 0096-FE (aehm): the module now uses the core Module layout (DocHeader with reload and shortcut, module-body spacing), a Status card as description list with one badge style, an Actions card with core buttons and icons, help texts below the buttons and TYPO3 modal confirmations (ssr-module.js) instead of window.confirm, and three protocol cards. Purge protocol rows show a one-line time, the trigger as badge plus user, paths as count + first two + disclosure, result and duration right-aligned, with 30/100/500 row switches. Status shows the last health sample with relative age and the cache limit (new optional SSR_CACHE_LIMIT_MB in var/vitec-ssr-purge.env, SsrPurgeService::getCacheLimitMb). ssr-warm-progress.js now also disables the Warm button with a spinner while a run is active. backend-ssr.css holds only layout glue with core CSS variables (dark mode safe). Verified in the backend: layout, modal, running progress bar, protocol tables.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-11 10:15:42 +01:00
orascheandClaude Fable 5.1 07f74e372b Antworten auf 0100/0102/0104/0108-FE: Canonical paginierter Listen, JSON-LD-Breadcrumb auf den Datensatz, Story-Liste ohne crops, Startseiten-Daten
VitecMetaHandler: /insights/news?page=2 (und ?category=) bekommt einen
selbstreferenzierenden Canonical statt Seite 1 (0100-FE 5.1).
PageJsonLdRenderer: auf Produkt-, News- und Story-Detailseiten ersetzt
der Datensatz (Titel + URL) die Shell-Seite als letztes Breadcrumb-
Element (0108-FE F11). UsecaseListJsonRenderer: cardImage.crops aus den
Listen-Karten entfernt, ~20 kB pro Seite mit Testimonial-Band
(0102-FE 5). Migration fe_answers_fixes_20261111 (angewendet):
Platzhalter 'H1 Header' (/solutions) und 'Featured Product' (Startseite,
Lorem ipsum, CTA auf /product) versteckt; Karte 'Government' auf
/markets/government; Button in #699 'Explore our solutions'; Story CDC
h4 'Product Highlight' -> h3 (0104-FE 4.1).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-11 10:14:49 +01:00
orascheandClaude Fable 5.1 5824981e2b Overview-Import: Why-VITEC-Karten als icon_card (0106-FE)
Der erste Lauf (5bm) legte die neuen Why-VITEC-Baender auf 9 Uebersichts-
seiten als text_only an - das Frontend baut das Icon-Band nur aus icon_card.
Importer legt neue Band-Karten jetzt als icon_card (ohne Icon) an und stellt
vorhandene Band-Karten beim Re-Run um; 36 Karten repariert, Seiten gepurgt.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-11 10:11:32 +01:00
orascheandClaude Fable 5.1 da2ca2690d SSR module: live progress bar for cache warming
The Cache warming row of the VITEC SSR module now shows a Bootstrap progress bar with a running counter instead of asking for a reload. New ajax route vitec_ssr_health (SsrController::healthJson) returns the node /__health JSON; ssr-warm-progress.js polls it every 2 s while a warm run is active and every 15 s otherwise (pauses in background tabs) and also refreshes the Cache: N pages / M MB line. The health fetch moved into SsrController::fetchHealth, shared by indexAction and the ajax route; its error text is English now. Verified live in the backend: bar, counter and cache line advance during a run.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-10 19:40:46 +01:00
orascheandClaude Fable 5.1 f4f18e913a Produkt-Uebersichtsseiten: Agentur-Texte aus den "Enriched"-Workbooks importiert
- neues CLI vitec:import-product-overview-workbooks (Dropbox-Lieferung 09.11.,
  15 Workbooks): Hero-Subline/CTA, Intro + Use Case, Why-VITEC-Band (Text-
  karten bzw. Text auf passende Icon-Karten), Range-Intro, Solution-Karten
  mit Link auf die Gruppenseite, CTA-Banner, SEO-Meta; Produktkarten bleiben
  (Record-Teaser), Vergleichstabelle nur mit --comparison
- vitec:seed-product-features --attach-icons: gestagte Icons an vorhandene
  Karten ohne Icon haengen (PRISM 5/5, SFF PRISM 4/5 angehaengt)
- Diagnose-Skripte check_overview_pages_20261110 / check_banners_20261110
- .gitignore: migrations/product_overviews_xlsx_in, product_images_pending

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-10 19:37:15 +01:00
orascheandClaude Fable 5.1 2aa8865b23 Tote Partner-Links in alten News entlinkt (22 Links, 10 Artikel)
Entscheidung Harry: Link entfernen, Text behalten. Hosts: Queen's
Awards Magazine, Sam Minton, EET Group, Tecnomultimedia, Sentry Video
Walls, Exertis, InfoComm SEA, SurveyMonkey, Exterity, Eureka Network.
Migration unlink_dead_external_20261110 (angewendet).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-10 19:17:11 +01:00
orascheandClaude Fable 5.1 e357f42b81 Seobility-Crawl 2: Karten-Links, Platzhalter-News, Titel-Kontext ueber Ordner, Descriptions 155 Zeichen
Migration crawl2_fixes_20261110 (angewendet): sechs Karten-Buttons
zeigten auf versteckte/geloeschte Produkt-URLs (ArtioSign/ArtioView ->
/products/aetria, Redirects folgen; X-View-Selbstlink entfernt;
Chassis-Karte auf das verbliebene Produkt). Drei weitere News trugen
noch den InfoComm-Importplatzhalter und doppelten echte Artikel
(JODDB, Bluefin, NAB 2026) -> geloescht + 301; EKOM-Artikel bekommt
seinen Text von der alten /en/news-Seite; byte-identische Datapath-
Kopie (#551) -> 301. Drei Tippfehler. Tote vitec.com-Links in 14
Datensaetzen auf interne Ziele (/markets, /insights/sustainability,
Story Northwestern, /product/fx4, 'vitec.com./' und '&nbsp;'-Links).
SEO-Titel fuer sieben Hauptseiten, Descriptions fuer neun
Kategorieseiten mit < 60 Zeichen.

VitecMetaHandler: Titel-Kontext ueberspringt Ordner-/Shortcut-Ebenen
('End-Points | Products | VITEC' statt 'End-Points | VITEC'); kurze
Story-Titel bekommen '| Success Story'; Meta-Description auf 155
Zeichen gekappt, auch redaktionelle (Seobility: 527 'zu lang').

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-10 19:08:34 +01:00
orascheandClaude Fable 5.1 4b6731786d News: Canonical auf Success Story (Feld vitec_canonical_usecase), 13 Paare gesetzt
13 Pressemeldungen tragen denselben Text wie eine Success Story
(Seobility: doppelter Inhalt). Neues Feld am News-Datensatz (Tab
'VITEC SEO', Auswahl der Story); VitecMetaHandler ersetzt damit den
Canonical-Link durch /success-stories/<slug>. Die Meldung bleibt in der
Liste, Suchmaschinen werten die Story als Original. Migration
news_story_canonical_20261110 setzt die 13 Zuordnungen (angewendet).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-10 18:20:03 +01:00
orascheandClaude Fable 5.1 f7b1eacecb Dubletten-Bereinigung nach Seobility: Produkt-Kopie, 10 News, Slugs, /about, Story-Texte
Migration dedupe_20261110 (angewendet): leere Chassis-Kopie geloescht;
bei InfoComm/LG/PRISM/ISE trugen die -1-Kopien den Text, die Basis war
Platzhalter oder leer - Kopien uebernehmen den sauberen Slug; identische
Kopien (Datapath-Uebernahme, Remote-Work-Blog) weg; Records ohne Inhalt
(Global Events, DSEI-2023-Zeiger) weg; WallControl-Award und die beiden
Jahresbotschaften mit sprechenden Slugs; /about (nur Standortliste)
versteckt und auf /insights/who-we-are umgeleitet, Links umgehaengt;
drei Story-Teaser/-Descriptions, die von fremden Stories kopiert waren,
neu gesetzt. Alle alten Adressen leiten per 301, bestehende Redirects
zeigen direkt auf die neuen Ziele (keine Ketten).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-10 18:12:34 +01:00
orascheandClaude Fable 5.1 37c4c116d0 SSR-Modul: Client errors mit Details-Aufklapper in jeder Zeile
Bisher gab es den Aufklapper nur bei Meldungen mit Stack. Jetzt hat jede
Zeile "details" mit Zeit, Release, voller URL, kompletter Meldung,
Stack (oder Hinweis "no stack transmitted"), vollem User-Agent und
anonymisierter IP, damit sich jeder Fehler bewerten laesst (Wunsch Harry).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-10 18:00:58 +01:00
orascheandClaude Fable 5.1 08c02276e5 Titel-Kontext: Import-Muster 'Titel | VITEC' als ungepflegt werten, direkte Elternseite
Die Kategorieseiten tragen seo_title = '<Titel> | VITEC' (Import), was
die Kontext-Vorlage als gepflegten SEO-Titel las. Der Brand-Suffix wird
vor dem Vergleich abgestreift; die Vorlage baut den Titel aus Seitentitel
+ Elternseite neu auf, der Brand-Suffix folgt wie bisher.

Die Rootline ist root-first; der Helfer las bisher Index 1 (oberste
Ebene, 'Products') statt der direkten Elternseite ('AVoIP & KVM').

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-10 17:56:10 +01:00
orascheandClaude Fable 5.1 33bdb8d856 Titel-Kontext auch bei kopiertem SEO-Titel; Description /solutions
Der Import hatte bei fast allen Seiten den Seitentitel 1:1 als seo_title
gespeichert, dadurch griff die Kontext-Vorlage fuer kurze Titel nie. Ein
seo_title, der dem Seitentitel entspricht, gilt jetzt als nicht gepflegt
('Aligo | AV over IP & KVM | VITEC'). Dazu die Meta-Description der
Solutions-Uebersicht (war bis heute der TCA-Platzhaltertext).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-10 17:49:52 +01:00
orascheandClaude Fable 5.1 8a652063c5 Seobility-Report 2026-11-10: Backend-Befunde umgesetzt
Crawl nach dem Footer-Fix: 1.094 Seiten (vorher 9). Backend-Anteil:

- Migration seo_report_fixes_20261110: Platzhalter-SEO-Titel auf /solutions
  geleert; News mit verdoppeltem Titel/Slug und Slugs mit Slash bereinigt
  (+Redirects); 8 Tippfehler; zweite Welle toter Alt-Links (datapath.co.uk-
  Absolutlinks, utm-Varianten, /resource-center, /services, /contact-sales,
  /aetria-use-cases, /video-wall-solutions ...) in 48 News ersetzt + 101
  Redirects; Record-Shells /product, /news/article, /success-stories/story
  leiten auf ihre Uebersichten; Meta-Description /datasheets; WordPress-
  Reste in den 17 SEO-Artikeln entfernt; 53 News-Zeiger-Records (type=1
  auf alte /en/news-Seiten, ohne eigenen Text) bekommen den Seiteninhalt
  (Text + Bilder) in den Record und werden normale Artikel - die alten
  Seiten leiten per 301 auf die Artikel.
- VitecMetaHandler: kurze Seitentitel ohne SEO-Titel erhalten ihren
  Bereich als Kontext ('Aligo | AV over IP & KVM | VITEC').
- ProcessedFileReferenceToArray: Alt-Text-Fallback (Referenz-Titel, dann
  Dateititel) fuer Content-Block-Bilder.
- UsecaseListJsonRenderer: Testimonial-Baender liefern nur noch Stories
  mit Zitat, aktueller Markt zuerst, max. 12 (vorher alle 70 = 226 kB auf
  jeder Markt-/Solution-Unterseite, identische Bloecke auf ~90 Seiten).
- NewsJsonRenderer: paginierte Listen-Payloads 15 Min. im hash-Cache
  (USER_INT rendert sonst jede Variante neu; 50 langsame URLs im Report).
- robots.txt host-abhaengig: dev bekommt robots-dev.txt mit der dev-Sitemap
  (Seobility fand '0 Sitemaps', weil die Sitemap-Zeile auf www zeigte).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-10 17:47:55 +01:00
orascheandClaude Fable 5.1 5121eaa000 Client-Error-Endpoint POST /api/vitec/client-error (0072-FE / 0077-BE)
Neue PSR-15-Middleware ClientErrorMiddleware: nimmt Browser-Fehler des
React-Frontends als JSON {url, message, stack, release, userAgent}
entgegen und antwortet 204 ohne Body. Schutz: 30 Meldungen pro Minute
und IP (Sliding Window ueber TYPO3-RateLimiter, 429 + Retry-After),
8-KB-Body-Limit (413, Content-Length und tatsaechliche Bytes), kein
JSON-Objekt oder leere message = 400, GET = 405. Keine Session, keine
Cookies; der /_json-Praefix funktioniert ebenfalls.

Speicherung in der neuen Tabelle tx_vitec_clienterrorlog mit
anonymisierter IP (IPv4 letztes Oktett 0, IPv6 /48). Sichtbar als
Tabelle "Client errors (last 24 h)" im Modul VITEC SSR (mit Stack als
Aufklapper) und als Zeile "Client errors reported (24 h / 7 d)" in der
Dashboard-Kachel VITEC: SSR statistics.

CLI vitec:client-error-cleanup [--days=30] loescht Eintraege aelter als
30 Tage (taeglich per Cron).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-10 17:25:23 +01:00
orascheandClaude Fable 5.1 ff6f86b829 Footer-Spalten: Linktexte an die Relaunch-Struktur angeglichen
Die Footer-HTML-Bloecke (pid 19, #132-#135) trugen noch die Benennungen
der alten Website (Venues, Military, Accommodation, VITEC OG, PCIe
Encoder, Interactive TV Portal ...), obwohl die Ziele seit
seo_fixes_20261109 stimmen. Migration fix_footer_link_texts_20261110:
Linktext + title-Attribut = Navigationstitel der Zielseite (Produkt-
Records per Titel), Blatt-Eintraege, die auf dieselbe Seite wie ein
frueherer Eintrag oder ein Eltern-Eintrag zeigen, entfernt, die
Uebersichts-Platzhalter Healthcare (/markets) und Telemetry (/products)
gestrichen, leere Listen bereinigt. Redaktionelle Sonderfaelle: Digital
Signage in der Solutions-Spalte -> Loesungsseite, Fx4 Series -> /product/
fx4, IP/RF Gateways & Transcoders -> /products/gateways-and-transcoding,
HelpDesk-Seite als 'HelpDesk Access' (externer Login-Link steht
darueber). Externe Links, t3://-Links und Ueberschriften unveraendert.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-10 16:05:05 +01:00
orascheandClaude Fable 5.1 be4bb44c40 Content-Block-Bilder als verarbeitete WebP mit srcset ausliefern
Lighthouse (Mobile) auf der Startseite: Score 40, LCP 25 s, 42 MB. Ursache:
nb-headless-content-blocks serialisiert Bildfelder (hero_bgimage,
intro_image, Karten, Banner) als publicUrl des ORIGINALS - 3,7 und 13,4 MB
JPEGs gingen unveraendert an den Browser, waehrend News-/Produktbilder
laengst durch den ImageService laufen.

- XCLASS Headless\ProcessedFileReferenceToArray (das Paket instanziiert
  seinen Serializer per makeInstance): publicUrl = WebP max. 1920 px mit
  Redaktions-Crop, dazu originalUrl/originalSize, srcset 480-1920
  (Stufen, die aufs Original zurueckfielen, entfallen), width/height,
  mimeType, size. SVG/GIF unveraendert (+Metadaten), Videos/PDFs bekommen
  mimeType/size. Exception-safe: Fallback auf Stock-Payload.
- .htaccess: fileadmin/_processed_/* mit Cache-Control max-age=1 Jahr,
  immutable (Hash im Dateinamen).

Effekt Startseite (gleiche Messbedingungen): LCP 25,1 -> 3,8 s, Bilder
18,7 -> 1,1 MB, gesamt 42 -> 22,6 MB; der Rest ist das 20-MB-Testvideo im
Hero (redaktionell) und Frontend-Themen (Ticket 0085-BE).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-10 15:53:20 +01:00
orascheandClaude Fable 5 dc9b5fce47 News-Detail: FAQ-Band + Produktkarten, tote Datapath-Links migriert
Die SEO-Artikel trugen (a) eine FAQ-Sektion als nackte <p>-Paare im
Bodytext und (b) ~150 Links auf die alte datapath.com-Struktur
(/datapath-products/..., alte /markets/...-Namen, /video-wall-solutions)
- alles 404 auf der neuen Seite.

- NewsJsonRenderer (Detail-Modus): extractFaqBand() schneidet die
  'Frequently asked questions'-Sektion aus dem Bodytext und liefert sie
  strukturiert als faq {heading, items[{question, answer(HTML)}]} fuers
  Frontend-Accordion (fail-safe: unter 2 sauberen Paaren bleibt alles
  unveraendert); collectMentionedProducts() liefert bis zu 6 Karten
  (title, text, image, link) fuer im Text verlinkte Produkt- und
  Kategorieseiten. Beide Felder additiv; Listen unveraendert.
- Migration fix_datapath_links_20261110: 148 News-Records auf die neuen
  Ziele umgeschrieben (Mapping alte Datapath-IA -> /product/<slug> bzw.
  /products/<slug>/Markets/Solutions, Ziele gegen DB verifiziert) und
  113 sys_redirects 301 angelegt (alte Pfade mit und ohne Slash - greift
  fuer externe Backlinks, sobald datapath.com hierher zeigt).

Live verifiziert: Beispielartikel liefert faq (8 Fragen, Antworten mit
funktionierenden Links), 4 Karten, Bodytext ohne FAQ-Absaetze und ohne
Datapath-Links; normale News unveraendert (faq null); Redirects 301 ok.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-10 12:52:14 +01:00
orascheandClaude Fable 5 5c640d02f7 News: redaktionell eingefuegtes FAQPage-JSON als echtes Schema ausgeben
17 SEO-Artikel tragen ein rohes FAQPage-JSON-Objekt in einem
abschliessenden Absatz ihres Bodytexts - das Frontend renderte es als
sichtbaren Text, Google sah es nie als strukturierte Daten.

Neue Klasse Seo\EmbeddedJsonLd trennt solche schema.org-Bloecke aus dem
RTE-HTML (fail-safe: was nicht sauber dekodiert, bleibt stehen):
- NewsJsonRenderer: Detail-bodytext und Listen-Exzerpt ohne die Bloecke
- PageJsonLdRenderer: die dekodierten Nodes wandern in den @graph der
  Seite (FAQPage landet damit als <script type=application/ld+json> im
  Head, den das Frontend bereits injiziert)
- VitecMetaHandler: Description-Fallback aus dem Bodytext ohne JSON-Muell

Verifiziert an 3 Artikeln: @graph mit NewsArticle + FAQPage (8-9 Fragen),
kein FAQPage-Rest im Textkoerper, kein sichtbarer JSON-Absatz im HTML.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-10 11:36:48 +01:00
orascheandClaude Fable 5 c9b00e6f10 News-Liste: serverseitige Pagination (Kontrakt 0070-FE)
Die Uebersichtsliste (news_newsliststicky/news_pi1 ohne limit und ohne
Auswahl, d.h. genau /insights/news) liefert jetzt pro Request nur die
Karten der angefragten Seite: ?page=<n>&category=<slug>, Antwort additiv
mit pagination {page,pages,itemsPerPage,total}, categories[] mit
Gesamt-Zaehlern und activeCategory. page klemmt auf die letzte Seite,
unbekannte category faellt auf alle zurueck, Sortierung serverseitig
datetime DESC, itemsPerPage aus settings.list.paginate (Default 12).

Caching: page/category stehen in cacheHash.excludedParameters, der
TYPO3-Seitencache unterscheidet die Varianten also nicht - das Element
konvertiert sich deshalb im paginierten Fall per
convertToUserIntObject() zu USER_INT (EXT:headless wrappt den
INT-Marker selbst); ETag/304 je Query-Variante verifiziert, der
SSR-Cache haelt eine Variante pro Pfad+Query.

Karten im paginierten Modus in schlanker Form (Felderliste aus
0070-FE); media[] traegt jetzt ueberall alternative (Alt-Text-Fallback
B5). Teaser-Listen mit limit und Selected-/Search-Listen unveraendert;
ohne pagination-Feld bleibt das Frontend beim bisherigen Verhalten.

JSON der Liste: 700 kB -> ~100 kB pro Seite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-10 11:11:55 +01:00
orascheandClaude Fable 5 be163a88c2 SSR health alerts: mail texts in English
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-10 11:00:56 +01:00
orascheandClaude Fable 5.2 8f8ae039d4 Real JSON null for empty product feature; SSR warm-cache button and CLI
Ticket 0068-FE: ProductFeatureJsonRenderer returned json_encode(null), which headless passes on as the STRING null (it only decodes object/array-looking strings) and crashed the front end. It now returns an empty string and the TypoScript field sets ifEmptyReturnNull = 1, giving a real JSON null. No other renderer used the pattern. Ticket 0066-FE: SsrPurgeService::warm() posts to the node /__warm endpoint (derived from SSR_PURGE_ENDPOINT, same secret), exposed as a Warm cache button with confirmation in the VITEC SSR module, a Cache warming progress row fed by /__health, and the CLI vitec:ssr-warm [paths]. Warm runs are deliberately not written to the purge log. Verified: two-path run 2/2 done, affected page delivers productfeature null.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 10:59:24 +01:00
orascheandClaude Fable 5 cb53ff67ee SSR dashboard: new errors-and-hit-rate tile fed by /__stats samples in vitec:ssr-health (stat_* columns in healthlog)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-09 22:51:13 +01:00
orascheandClaude Fable 5 bc2e031b0c SSR health: alert mails on ok/down transitions + auto-failover to SPA after N consecutive misses (SSR_ALERT_EMAIL / SSR_FAILOVER_AFTER in var/vitec-ssr-purge.env)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-09 22:18:06 +01:00
orascheandClaude Fable 5 b9736b9ac7 SEO-Audit 2026-11-09: Meta-Komposition, Sitemap, Redirects, robots.txt
- VitecMetaHandler: Record-Metadaten fuer News-/Produkt-/Story-Details
  (Titel, Description, og:/twitter:-Tags, og:image), '| VITEC'-Suffix,
  Fallback-Description aus Seitenfeldern, twitter:card large_image,
  Story-Canonical auf die oeffentliche Kurz-URL
- PageJsonLdRenderer: Product- und NewsArticle-Nodes auf Detailseiten
  (News-Details hatten <title>article</title> und kein Schema)
- NewsJsonRenderer: Listen ohne Volltext-Body (JSON 2.6 MB -> ~0.6 MB)
- XML-Sitemap: News-Teil konfiguriert (Ordner 11+442, Detailseite 21),
  Record-Sitemaps fuer Produkte und Success Stories (kurze URLs),
  Route-Enhancer fuer alle Teile, Shell-/Altseiten ausgeschlossen
- Migration seo_fixes_20261109: 161 Self-Canonicals geleert (warfen u.a.
  Home, Markets, Solutions, Support aus der Sitemap), noindex fuer
  technische und alte News-Seiten, 71 Redirects Alt-URLs -> neue Ziele,
  51 tote Footer-Links ersetzt
- public/robots.txt mit Sitemap-Verweis

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-09 22:11:01 +01:00
orascheandClaude Fable 5 be519f9bc1 Context menu purge: drop .js from the callback module specifier
The backend context menu appends .js itself when importing the callback module; with the extension already in the attribute the browser requested ssr-context-menu.js.js and the click died silently. Confirmed working in the backend after the fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-09 21:14:50 +01:00
orascheandClaude Fable 5 5b524bfce9 Page context menu: Purge from SSR cache
SsrPurgeItemProvider adds the item behind Clear cache for real pages (doktype < 199) whenever the purge endpoint is configured. The callback module posts the page uid to the new vitec_ssr_purge_page ajax route, which resolves the page path through the site router, purges it via SsrPurgeService (trigger contextmenu:username, logged as usual) and reports back as a toast notification.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-09 21:10:30 +01:00
orascheandClaude Fable 5 fa63871da7 SSR control center: English UI strings throughout
Widgets, backend module template and flash messages now match the rest of the backend, which is English.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-09 21:03:40 +01:00
orascheandClaude Fable 5 91c2ceb5b1 Dashboard widgets: fix group name (systemInfo, not systemInformation)
The group identifier comes from EXT:dashboard DashboardWidgetGroups.php; with an unknown group the widgets never appeared in the add-widget dialog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-09 20:59:39 +01:00
orascheandClaude Fable 5 63f950f9db SSR control center: auto-purge webhook, dashboard tiles, backend module
Package 1: SsrPurgeService posts to the node /__purge (endpoint+secret in var/vitec-ssr-purge.env, outside git) and logs every attempt to tx_vitec_ssrpurgelog. DataHandler hooks queue per request and flush once: pages with nav-relevant field changes and all tx_news_/tx_vitec_ records purge everything, other page/content edits purge the hosting page path. Gated by the routing flag and the new Site Set switch vitec.ssr.autoPurge. Flipping the SSR switch ON purges everything once. CLI: vitec:ssr-purge, vitec:ssr-health (samples /__health into tx_vitec_ssrhealthlog, 14 days kept, scheduler-ready). Package 2: dashboard widgets vitecSsrStatus (live health, release, switch, last purge) and vitecSsrStats (purge counts, failures, uptime, response time). Package 3: backend module web_vitecssr with manual purge (paths or everything) and both protocols. Tested end to end against the running node (purge single/all 200, health sample logged).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-09 20:46:16 +01:00
orascheandClaude Fable 5 d52d49974f SSR switch-on fix: proxy the DirectoryIndex homepage detour
First switch-on with a running node: Apache mod_dir turns / into /index.html via the DirectoryIndex subrequest before the per-dir root rule can proxy it; the node then asked for /_json/index.html and rendered a cacheable 404 homepage. New flag-gated rule proxies index.html as / to the node. Companion fix outside git: public/_frontend/.htaccess (build output, gitignored) overrides the root rewrites for /_frontend/*, so release assets only existing on the VPS were answered by its SPA fallback with index.html - HTML served as JS; the flag-gated asset proxy now also sits in that file before the fallback, and the frontend build must adopt it in its .htaccess template (ticket 0059-BE).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-09 20:20:07 +01:00
orascheandClaude Fable 5 d153cc2eba README: drop the dev basic auth credentials
The vitecdev password has been rotated today (htpasswd on the server, new value distributed via the password manager and set as VITEC_API_AUTH on the SSR VPS). Credentials do not belong in the repository; the leaked value is invalid as of now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-09 18:32:29 +01:00
orascheandClaude Fable 5 232aaf2ca8 SSR prep: asset proxy behind flag, forwarded proto, header hygiene
Ticket 0054-FE section 5: (1) with the SSR flag on, /_frontend/assets/* is now proxied to the VPS release as well, deliberately without a file-exists guard so stale local SPA hashes cannot shadow the release; the proxy rules tag requests with env SSRPROXY and mod_headers adds X-Forwarded-Proto: https. (3) If-None-Match comparison in JsonCacheHeaders accepts the -gzip suffix that mod_deflate appends, so browser 304s work through compression. (4) X-TYPO3-Cache-Tags*/-Debug-Cache/-Cache-Lifetime/-Parsetime are stripped from responses (onsuccess and always tables - they leak record ids and overflowed the SSR node 16 KB header limit on news pages).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-09 18:20:21 +01:00
orascheandClaude Fable 5 351349ec1f JSON API: Cache-Control no-cache + ETag/304 revalidation
New JsonCacheHeaders middleware (outer, frontend stack): GET/HEAD 200 responses under /_json get a single Cache-Control: no-cache, Expires: 0 and a content md5 ETag; If-None-Match answers as empty 304. Logged-in visitors (fe_typo_user) keep TYPO3 headers. Expires: 0 is deliberate so mod_expires leaves the response alone. .htaccess: the global Header unset ETag now skips /_json (KEEP_ETAG env, carried across the mod_rewrite internal redirect as REDIRECT_KEEP_ETAG). Fulfils tickets 0011-FE point 3 and 0055-BE question 6; asset immutable headers were already live.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-09 17:03:05 +01:00
orascheandClaude Fable 5 dcd713f222 Site settings: fill schema.org organization data, drop scaffold examples
All seo.* settings now carry real values (sources: vitec.com footer + privacy policy, KBIS for the legal name VITEC SA): canonical base, org name/legalName, address Chatillon, phone, sameAs LinkedIn/YouTube, logo at fileadmin/vitec-brand/vitec-logo.png (served 200, verified in the home jsonLd as absolute www.vitec.com URL). Email left empty - no public address exists. Removed the my.vitec.setting / my.seoRelevantSetting scaffold examples from the set definition and the my.example.setting value.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-09 14:03:54 +01:00
orascheandClaude Fable 5 14623ed4fa gitignore: ignore all of config/system/, not just two files
additional.php.save (editor backup) was untracked and unignored - one git add -A away from pushing config internals. settings.php holds credentials, so the whole directory is off limits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-09 13:43:19 +01:00
orascheandClaude Fable 5 2c0da281bd SSR switch: site setting vitec.ssr.enabled gates .htaccess proxy to SSR node
- Site set Vitecset: new bool setting vitec.ssr.enabled (default off,
  backend switch under Site Management > Settings > VITEC Settings)
- SsrFlagService derives var/vitec-ssr-on.flag from the setting;
  SyncSsrFlag middleware (frontend + backend) re-syncs per request
  because TYPO3 v14 fires no event on site settings save;
  vitec:ssr-flag CLI command for status/--sync
- public/.htaccess: flag-gated block proxies page requests [P] to
  https://vitec-ssr.evomedien.de (mod_proxy incl. https target verified
  on this host 2026-11-09), same exclusions as the SPA fallback block;
  completely inert while the flag file is absent
- Emergency off without TYPO3: delete var/vitec-ssr-on.flag
- Tested end to end: flag on -> pages proxied to VPS Caddy (502 while
  the SSR node is not deployed yet), /_json untouched; flag off ->
  SPA shell exactly as before

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-09 13:35:27 +01:00
orascheandClaude Opus 5.5 bd7a145afa Products: categories and sub-categories become clickable pages
The 2026-11-02 menu build created the 3 categories and 9 sub-categories
as sysfolders (labels); every level must be a clickable page.

- build-product-overviews: six sub-categories adopt the 2026-10 category
  pages (Joe's workbooks; content, slug and SEO kept, sitemap name as
  nav_title), the remaining folders become pages with generated content,
  Capture & Graphics gets the main column of Graphics & Multi-Display and
  Video Capture copied in. Pages move/convert by direct UPDATE (the
  redirects slug hook would rewrite the flat child slugs); three 2026-10
  pages leave the menus but stay reachable. Warns about a sys_redirect
  on a target slug.
- ProvideProductCatalog: categories[].link and groups[].link (additive),
  menu title = nav_title when set, only filled groups/categories count.
  The menu search reads both levels as pages too.
- build-why-vitec-bands: band at the top of the main column; on pages
  with an existing "Why VITEC?" section the context's icons go onto the
  cards whose title matches the icon theme (16 icons on 4 pages).
- No browser cache headers on the page JSON: headless enables
  sendCacheHeaders and TYPO3 14 caches pages for a year by default, so
  every /_json/ response carried max-age=~31536000.
- Spec v1.32.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 12:05:04 +01:00
orascheandClaude Opus 5.5 73e4298b75 Insights spotlight: news from folders 11 + 442, source editable in the menu
- Megamenu entries with the catalog or spotlight layout get two editor
  fields, "Data source folders" (source_pages) and "Data source
  categories" (source_categories); the extension stores them, the site
  package's listener reads them from the entry row.
- ProvideInsightsSpotlight reads its news from those folders (empty =
  11 + 442; the newer posts live in 442, the query only read 11) and,
  when set, only from those categories (Insights: Blog Post + Press
  Release). Newest datetime first, three cards.
- Pages carrying the menu are tagged with the news and event tables, so
  saving such a record refreshes the menu without a cache flush.
- VITEC field descriptions via a TCA override; spec v1.31, README.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 10:59:56 +01:00
orascheandClaude Fable 5 ad1f3c179c why-vitec band: move it before the "Product range" intro
- new migration fix_why_band_position.php: on the 7 overview pages the
  band (Why VITEC? intro + icon cards) had landed between the "Product
  range" heading and its product cards; reorders the top-level column
  to hero -> band -> Product range -> cards (ticket 0042-FE section 3)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-04 20:51:42 +01:00
orascheandClaude Fable 5 f525ce3c27 product-check: Features column with icon coverage
- new "Features" column: check with cards-and-icons count per product
  (e.g. "5·4i"), header carries the filled total like the other columns
- detail pane lists every feature card with icon thumbnail (click to
  enlarge), title and text; cards without an icon show a red cross on
  the icon slot - the missing agency icon sets are visible at a glance

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-04 20:17:50 +01:00
orascheandClaude Fable 5 6ff9c3e7a8 seed-product-features: two more icon-matching tiers
- token-containment pass: theme words inside the card title
  (ManagementFoundation -> "Aetria management foundation")
- last-pair rule: a complete set (#icons == #cards) with exactly one
  icon and one card left over pairs them (FlexibleConnection,
  EndToEndEncryption, HighPerformance, 4KSourcetoDisplays, AVoverIP)
- genuine mismatches (aligo-workstation, EZ-TV Platform, AvediaPlatform
  Artio icons) still stay unmatched for the editors

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-04 20:03:50 +01:00
orascheandClaude Fable 5 46b12d4a58 product.features: Why-choose cards with icons (contract 0036-FE)
- new inline table tx_vitec_product_feature (title, cardtext, FAL icon)
  + `features` field on the product record; ProductShowJsonRenderer
  emits product.features {heading, items[{title, text, icon}]} and null
  while a product has no cards, so the front end keeps its
  applications-HTML fallback
- new vitec:seed-product-features: seeds the cards from the
  applications <p><strong> pairs and matches the staged agency icons by
  normalised title (camelCase split, stop words, Levenshtein <= 3 typo
  tolerance); products with existing cards are never touched
- new vitec:build-why-vitec-bands: "Why VITEC?" intro + icon_card band
  on the 7 overview pages with a staged icon set (the contract's
  "centered" variant does not exist - documented in ticket 0039-BE);
  vision/visionav sub-group sets stay unassigned
- product-check page: sortable title column and an Open Product button
- new migration fix_colombia_quote.php: stray quote element #12037
  removed, quote_role set (ticket 0038-FE)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-04 19:55:01 +01:00
orascheandClaude Fable 5 3ccb9b149e Stage the 2026-11 product asset dump: ignore migrations/product_images_in2/
The agency "Products Hero page" delivery (banners, product photos) is
staged per product slug under migrations/product_images_in2/ and
imported with the existing vitec:import-product-images command;
the staging directory stays out of the repo like product_images_in/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-03 19:32:42 +01:00
orascheandClaude Fable 5 d26c31e6d1 Product content check: live audit page under /product-check/
Standalone PHP page (public/product-check/index.php), deliberately NOT a
plugin so the React front end needs no counterpart. Reads fresh from the
DB on every request (settings.php PDO pattern):

- alphabetical table of all products, header row with 14 field columns
  (hero/photos/og/video, the text fields, resources/datasheets/solutions),
  green check with count / red cross per cell, filled-count per column
- every row expands to a detail pane: all texts rendered, all images as
  thumbnails with a click-to-enlarge lightbox, relations resolved
  (inline resources, datasheet/solution MM), flags, detail page link
- refresh button reloads with a cache buster; noindex + no-store;
  refuses to serve on www.vitec.com

The static badge view (migrations/build_product_audit.php ->
/product-audit/) stays as is.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-03 19:13:23 +01:00
orascheandClaude Fable 5 ed3ced42b3 Product content check: live audit page under /product-check/
Standalone PHP page (public/product-check/index.php), deliberately NOT a
plugin so the React front end needs no counterpart. Reads fresh from the
DB on every request (settings.php PDO pattern):

- alphabetical table of all products, header row with 14 field columns
  (hero/photos/og/video, the text fields, resources/datasheets/solutions),
  green check with count / red cross per cell, filled-count per column
- every row expands to a detail pane: all texts rendered, all images as
  thumbnails with a click-to-enlarge lightbox, relations resolved
  (inline resources, datasheet/solution MM), flags, detail page link
- refresh button reloads with a cache buster; noindex + no-store;
  refuses to serve on www.vitec.com

The static badge view (migrations/build_product_audit.php ->
/product-audit/) stays as is.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-03 19:09:23 +01:00
orascheandClaude Fable 5 8e173b1a85 Hero placeholder fieldname: vitec_hero_bgimage (content block prefix)
The vitec/herosection content block has prefixFields - the real column
and sys_file_reference fieldname is vitec_hero_bgimage; the JSON merely
strips the prefix. The first apply wrote the 20 references with the bare
name, so they existed but never rendered (no herosection anywhere had an
image, so there was no working example to catch it against).

- BuildProductOverviews: attach/backfill/skip-check now use
  vitec_hero_bgimage
- new migration fix_overview_hero_refs.php: renames the existing 20
  references in place (no re-attach) and sets the counter column

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-03 18:42:06 +01:00
orascheandClaude Fable 5 77661bf0b6 Datapath stories, grid repairs, menu v2 pure, hero placeholders, fec test
Batch of 2026-10-28/29 plus 2026-11-03:

- NEW vitec:import-datapath-stories: 22 story packages from the agency
  dump (docx + Links/ assets) as usecase records with content, quotes,
  images, customer logos, download cards; --repair-quotes normalizes
  attribution lines; asset backfill for existing stories
- /products grid incident repaired (fix_products_grid, fix_graphics_grid,
  check_grid_page): containers unhidden, 46 duplicate cards soft-deleted,
  section order restored; BuildProductPortfolio and
  ImportProductCategoryPages now GUARD against re-runs on pages that
  already carry vitec_cols_% containers
- &apos; double-encoding fixed: render-pipeline importers write literal
  apostrophes (ENT_NOQUOTES), fix_double_entities literalizes existing rows
- ticket quick wins (ticket_quickwins_20261028), broadcast market page
  cleanup, AttachCardImages market-card handling, SubmarketWorkbook
  META REPAIR, story content/video fixes (fix_story_*)
- ProvideProductCatalog: transitional v1 fields removed (categories
  uid/link, legacyGroupFields, item products teaser) - pure v2 shape
  (ticket 0024-FE); search renderer keeps its teaser
- BuildProductOverviews: heroes get the numbered placeholder background
  (placeholders/pages/<uid>-01.png, wide grey fallback) - backfill on
  existing pages with empty hero_bgimage, attached at creation for new
  pages; existing references always win (ticket 0026-FE); 40 placeholder
  PNGs shipped to fileadmin (not tracked)
- AttachPageMedia: --depth=3 with sysfolders as pass-through parents for
  the overview pages' -08 menu tiles
- NEW migrations/fec_isolation_test.sh: two-user live test proving
  fe_comment ticket isolation (backend verified tight, cross-user
  sighting traced to the FE page-JSON cache, ticket 0027-BE)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-03 18:29:56 +01:00
orascheandClaude Opus 5.5 9211c8a395 Megamenu: rail display for page trees, cache tags, editorial promo boxes
- Page-tree entries get a "Page tree display" field (columns | rail |
  rail-compact), emitted as display + cardSize: Markets and Solutions
  show their main pages as a list and the sub-pages as image cards on
  hover. Page-tree items carry image (page media) and teaser (abstract)
  from stage 2. Additive; front ends ignoring display render columns.
- vitec:attach-page-media gains --depth=2 and falls back to the page's
  hero background before the grey placeholder; the 39 sub-solution
  pages got their hero image as page media.
- MegamenuJsonRenderer tags every page carrying the menu with the menu
  tables and tt_content_<uid>, so saving menu records refreshes all
  pages instead of waiting for a full flush.
- Products catalog: drop catalog.news; both promo boxes ("Product
  highlight", "Product news") are the entry's editorial teaser cards.
  ProvideInsightsSpotlight back to its previous state.
- Spec v1.29 and v1.30 (header version corrected), README.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 17:35:23 +01:00
orascheandClaude Opus 5.5 9fb7f0ca77 Products: 20 overview pages and megamenu catalog v2
Rebuild the product section after the agency sitemap and menu prototype
of 2026-11-02:

- vitec:build-product-overviews builds sysfolder labels (3 categories,
  9 groups) below /products with 20 overview pages /products/<slug>
  (layout 9: hero, intro per group, product cards, contact CTA) and two
  menu shortcuts. Dry-run by default, idempotent, explicit sorting.
- ProvideProductCatalog reads that page tree and emits catalog version 2:
  categories[].groups[].items[] of type page (productCount, products,
  image) or shortcut (target indices), plus viewAll, news and search.
- Transitional v1 fields (groups[].products/link/uid, categories[].uid/
  link, product teaser) keep the deployed front end alive; it read the
  v1 shape and rendered a white page without them. Remove once the
  front end reads items.
- ProvideInsightsSpotlight::latestNews() is public with a count.
- Product type-ahead (type 7385) adds matching overview pages and a
  per-product overview with its menu path.
- Spec v1.28.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 16:06:44 +01:00
124 changed files with 16688 additions and 251 deletions

No files matched your search

+9 -2
View File
@@ -9,8 +9,10 @@
.env
.env.*
!.env.example
/config/system/settings.php
/config/system/additional.php
# The whole directory, not just the two known files: editor backups like
# additional.php.save would otherwise slip into a careless `git add -A`
# (settings.php holds credentials).
/config/system/
auth.json
# --------------------------------------------------
@@ -135,8 +137,13 @@ migrations/submarkets_xlsx_in/*
migrations/subsolutions_xlsx_in/*
migrations/solutiongroups_xlsx_in/*
migrations/product_images_in/
migrations/product_images_in2/
migrations/markethero_xlsx_in/
migrations/product_categories_xlsx_in/
# generated internal report (php migrations/build_product_audit.php)
public/product-audit/
migrations/datapath_stories_in/
migrations/product_overviews_xlsx_in/
migrations/product_images_pending/
-4
View File
@@ -168,7 +168,3 @@ bash fix-asset-perms.sh # fix asset permission
[GPL‑2.0‑or‑later](LICENSE) — © evomedien.
<div align="center"><sub>VITEC · TYPO3 headless backend + React front end</sub></div>
vitecdev
17ZJfy75sJoU68J6
+17
View File
@@ -41,6 +41,23 @@ languages:
solr_core_read: core_en
rootPageId: 1
routeEnhancers:
# Pretty, resolvable URLs for ALL XML-sitemap parts. The typo3/seo-sitemap
# set maps only "pages"; links to the other parts were generated as
# ?sitemap=<name> query URLs that the renderer cannot resolve (it reads
# tx_seo[sitemap]) - the index then returned itself. SEO audit 2026-11-09.
Sitemap:
type: Simple
routePath: 'sitemap-type/{sitemap}'
aspects:
sitemap:
type: StaticValueMapper
map:
pages: pages
news: news
products: products
stories: stories
_arguments:
sitemap: 'tx_seo/sitemap'
ProductPlugin:
type: Extbase
limitToPages:
+19 -5
View File
@@ -1,13 +1,27 @@
vitec.debugMode: true
my.example.setting: Test
menu.footer.pageUids: '1,2,3'
menu.meta.pageUids: '5'
megamenu.contentUid: 10366
vitec.storyDetailPid: 26
felogin.pid: 520
# felogin v14 und EXT:headless belegen beide templateRootPaths.10 - je nach
# Set-Reihenfolge gewinnt das HTML-Template. Das Setting speist genau diesen
# Index, darum hier fest auf die Headless-JSON-Templates zeigen:
felogin.pid: '520'
felogin.view.templateRootPath: 'EXT:headless/Resources/Private/FeLogin/Templates/'
felogin.view.partialRootPath: 'EXT:headless/Resources/Private/FeLogin/Partials/'
felogin.view.layoutRootPath: 'EXT:headless/Resources/Private/FeLogin/Layouts/'
seo.site.canonicalBase: 'https://www.vitec.com'
seo.site.name: VITEC
seo.organization.name: VITEC
seo.organization.legalName: 'VITEC SA'
seo.organization.logoUrl: /fileadmin/vitec-brand/vitec-logo.png
seo.organization.sameAs: 'https://www.linkedin.com/company/vitec-multimedia/, https://www.youtube.com/user/vitecmm'
seo.organization.phone: '+33 1 46 73 06 06'
seo.organization.street: '99 rue Pierre Sémard'
seo.organization.postalCode: '92320'
seo.organization.locality: Châtillon
seo.organization.country: FR
vitec.ssr.enabled: true
# Shell-Seiten (Produkt-/News-/Story-Detail-Container) nicht in die XML-Sitemap
# aufnehmen; 106 = alter /en/news-Ast (noindex-Duplikate). SEO-Audit 2026-11-09.
seo.sitemap.pages.excludePagesRecursive: '10,21,26,106'
vitec.ssr.alertEmails:
- info@evomedien.de
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
// Puts the vitec_productcheck content element on the page /products/product-check
// (uid 607) once. Idempotent: a second run reports the existing element.
// Run on dedi: php migrations/add_product_check_element_20261112.php
error_reporting(E_ALL); ini_set('display_errors', '1');
$pageUid = 607;
$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"); }
$db = (require $root . '/config/system/settings.php')['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]);
$page = $pdo->query("SELECT uid, title, slug FROM pages WHERE uid = $pageUid AND deleted = 0")->fetch();
if ($page === false) { exit("page $pageUid not found\n"); }
echo "page #{$page['uid']} {$page['title']} ({$page['slug']})\n";
$existing = $pdo->query("SELECT uid, hidden FROM tt_content WHERE pid = $pageUid AND CType = 'vitec_productcheck' AND deleted = 0")->fetch();
if ($existing !== false) { echo "element already there: #{$existing['uid']} (hidden={$existing['hidden']})\n"; exit(0); }
$now = time();
$stmt = $pdo->prepare('INSERT INTO tt_content (pid, tstamp, crdate, sorting, CType, colPos, header, header_layout, sys_language_uid, hidden, deleted)
VALUES (:pid, :now, :now, 256, :ctype, 0, :header, 0, 0, 0, 0)');
$stmt->execute([':pid' => $pageUid, ':now' => $now, ':ctype' => 'vitec_productcheck', ':header' => 'Product Content Check']);
echo "inserted tt_content #" . $pdo->lastInsertId() . " (vitec_productcheck, colPos 0)\n";
+21
View File
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
// READ-ONLY: hero references + sys_file rows of the three products that got a banner on 2026-11-10
$settings = require __DIR__ . '/../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]);
foreach ([25, 24, 22] as $uid) {
$p = $pdo->query("SELECT uid, slug FROM tx_vitec_domain_model_product WHERE uid = $uid")->fetch();
echo "\n=== #{$p['uid']} {$p['slug']}\n";
foreach ($pdo->query("SELECT r.uid ref, r.uid_local, r.hidden, f.identifier, f.tstamp FROM sys_file_reference r JOIN sys_file f ON f.uid = r.uid_local WHERE r.deleted = 0 AND r.tablenames = 'tx_vitec_domain_model_product' AND r.fieldname = 'heroimage' AND r.uid_foreign = $uid ORDER BY r.sorting_foreign") as $r) {
echo " heroimage ref#{$r['ref']} file#{$r['uid_local']} {$r['identifier']} (file tstamp " . date('Y-m-d H:i', (int)$r['tstamp']) . ")" . ($r['hidden'] ? ' HIDDEN' : '') . "\n";
}
foreach ($pdo->query("SELECT uid, identifier, tstamp FROM sys_file WHERE identifier LIKE '/product_import/{$p['slug']}/%'") as $f) {
echo " sys_file#{$f['uid']} {$f['identifier']} (" . date('Y-m-d H:i', (int)$f['tstamp']) . ")\n";
}
}
echo "\n=== feature icons prism/sff ===\n";
foreach ($pdo->query("SELECT f.product, f.title, (SELECT COUNT(*) FROM sys_file_reference r WHERE r.deleted = 0 AND r.tablenames = 'tx_vitec_product_feature' AND r.fieldname = 'icon' AND r.uid_foreign = f.uid) icons FROM tx_vitec_product_feature f WHERE f.deleted = 0 AND f.product IN (29, 30) ORDER BY f.product, f.sorting") as $r) {
echo " #{$r['product']} \"{$r['title']}\" icons={$r['icons']}\n";
}
echo "DONE\n";
+46
View File
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
/**
* Diagnose wie check_grid_447.php, aber mit Seiten-Parameter: Karten,
* Grid-Container und Intros einer Seite mit colPos / tx_container_parent /
* sorting / hidden / deleted.
*
* READ-ONLY. Usage: php migrations/check_grid_page.php 569
*/
error_reporting(E_ALL);
ini_set('display_errors', '1');
$pid = (int)($argv[1] ?? 0);
if ($pid <= 0) {
exit("Usage: php migrations/check_grid_page.php <pid>\n");
}
$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]
);
echo "=== vitec_card / vitec_cols_* / vitec_introparagraph auf pid $pid ===\n";
$rows = $pdo->query(
"SELECT uid, CType, LEFT(header,32) h, colPos, tx_container_parent par, sorting, hidden, deleted
FROM tt_content WHERE pid = $pid AND (CType IN ('vitec_card','vitec_introparagraph') OR CType LIKE 'vitec_cols_%')
ORDER BY deleted, colPos, sorting, uid"
)->fetchAll();
foreach ($rows as $r) {
printf(" #%-6d %-22s col=%-4d par=%-6d sort=%-6d hid=%d del=%d %s\n",
$r['uid'], $r['CType'], $r['colPos'], $r['par'], $r['sorting'], $r['hidden'], $r['deleted'], $r['h']);
}
echo "\ncounts: " . count($rows) . " rows\n";
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
/**
* Diagnose (2026-11-10): state of the 20 product overview pages before the
* "Enriched" workbook import, plus product slugs / solution pages needed for
* banner, icon and solution-card mapping. READ-ONLY, plain PDO.
*
* php migrations/check_overview_pages_20261110.php > migrations/check_overview_out.txt
*/
error_reporting(E_ALL);
ini_set('display_errors', '1');
$settings = require __DIR__ . '/../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]
);
$slugs = ['avedia', 'ez-tv', 'x-player-end-points', 'x-view-integrated-displays', 'aetria', 'operator-workstations',
'appliances', 'modular-systems', 'rugged-appliances', 'avedia-modular-system', 'channellink-ip-gateway',
'prism-transcoder', 'aligo', 'vsn-series-video-wall-controllers', 'video-wall-management-software',
'vms-plugins-and-integrations', 'x-series-multi-display-processors', 'image-graphics-cards',
'vision-video-capture-cards', 'activesqx'];
$ce = $pdo->prepare('SELECT uid, colPos, CType, header, header_layout, vitec_layout_variant, vitec_primary_cta, vitec_primary_cta_label, tx_container_parent, sorting, hidden, LENGTH(bodytext) blen, LEFT(bodytext, 90) bshort FROM tt_content WHERE deleted = 0 AND pid = ? AND sys_language_uid = 0 ORDER BY colPos, sorting');
$kids = $pdo->prepare("SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND tx_container_parent = ? AND CType = 'vitec_card'");
foreach ($slugs as $slug) {
$p = $pdo->prepare('SELECT uid, title, nav_title, layout, seo_title, description, keywords, abstract FROM pages WHERE deleted = 0 AND slug = ? AND sys_language_uid = 0');
$p->execute(['/products/' . $slug]);
$page = $p->fetch();
echo "\n=== /products/$slug ";
if (!$page) { echo "NOT FOUND\n"; continue; }
printf("#%d \"%s\" nav=\"%s\" layout=%s\n seo_title=%s | desc=%s | kw=%s\n", $page['uid'], $page['title'], $page['nav_title'], $page['layout'], $page['seo_title'], mb_substr((string)$page['description'], 0, 80), $page['keywords']);
$ce->execute([$page['uid']]);
foreach ($ce->fetchAll() as $r) {
$n = '';
if (str_starts_with($r['CType'], 'vitec_cols')) { $kids->execute([$r['uid']]); $n = ' cards=' . $kids->fetchColumn(); }
if ((int)$r['tx_container_parent'] > 0 && $r['CType'] === 'vitec_card') { continue; }
printf(" col%-3s s%-6d #%-6d %-22s %-12s %-10s \"%s\" len=%d%s %s%s\n", $r['colPos'], $r['sorting'], $r['uid'], $r['CType'], $r['vitec_layout_variant'], $r['vitec_primary_cta_label'], $r['header'], $r['blen'], $n, (int)$r['tx_container_parent'] > 0 ? ' IN#' . $r['tx_container_parent'] : '', $r['hidden'] ? ' HIDDEN' : '');
if ($r['blen'] > 0 && !str_starts_with($r['CType'], 'vitec_cols')) { echo " > " . str_replace("\n", ' ', (string)$r['bshort']) . "\n"; }
}
}
echo "\n=== products (slug | title) matching banner/icon deliveries ===\n";
foreach ($pdo->query("SELECT uid, slug, title, hidden FROM tx_vitec_domain_model_product WHERE deleted = 0 AND (slug LIKE '%mges%' OR slug LIKE '%ace%' OR slug LIKE '%chassis%' OR slug LIKE '%prism%' OR slug LIKE '%diamond%' OR slug LIKE '%avedia%') ORDER BY slug") as $r) {
printf(" #%-4d %-45s %s%s\n", $r['uid'], $r['slug'], $r['title'], $r['hidden'] ? ' (hidden)' : '');
}
echo "\n=== pages under /solutions (2 levels) ===\n";
$root = $pdo->query("SELECT uid FROM pages WHERE deleted = 0 AND slug = '/solutions'")->fetchColumn();
$q = $pdo->prepare('SELECT uid, title, slug, doktype, hidden FROM pages WHERE deleted = 0 AND pid = ? AND sys_language_uid = 0 ORDER BY sorting');
$q->execute([$root]);
foreach ($q->fetchAll() as $g) {
printf(" #%-4d %-55s %s%s\n", $g['uid'], $g['title'], $g['slug'], $g['hidden'] ? ' (hidden)' : '');
$q2 = $pdo->prepare('SELECT uid, title, slug, hidden FROM pages WHERE deleted = 0 AND pid = ? AND sys_language_uid = 0 ORDER BY sorting');
$q2->execute([$g['uid']]);
foreach ($q2->fetchAll() as $s) {
printf(" #%-4d %-55s %s%s\n", $s['uid'], $s['title'], $s['slug'], $s['hidden'] ? ' (hidden)' : '');
}
}
echo "\n=== products with feature cards (count) for prism/sff/avedia chassis ===\n";
foreach ($pdo->query("SELECT p.uid, p.slug, (SELECT COUNT(*) FROM tx_vitec_product_feature f WHERE f.deleted = 0 AND f.product = p.uid) n FROM tx_vitec_domain_model_product p WHERE p.deleted = 0 AND (p.slug LIKE '%prism%' OR p.slug LIKE '%chassis%')") as $r) {
printf(" #%-4d %-45s features=%d\n", $r['uid'], $r['slug'], $r['n']);
}
echo "\nDONE\n";
@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
/**
* Ticket 0010-FE Punkt 1: /markets/broadcast-and-telecoms ist eine Mischseite
* (alte Pilotseite): vitec_herosection/Container/text/marketlist-Reste plus
* colPos220-Legacy NEBEN dem 27.10.-Import. Ziel: exakt das Schema der zehn
* anderen Market-Seiten (colPos1: markethero + introparagraph; colPos0:
* marketintro, introparagraph, 2 cards, ctabanner).
*
* Whitelist = die acht Import-Elemente vom 27.10.; ALLES andere auf der
* Seite wird soft-geloescht (deleted=1, wiederherstellbar).
*
* Plain PDO (Muster migrations/check_bg.php).
*
* Usage: php migrations/cleanup_broadcast_market_page.php (inspect)
* php migrations/cleanup_broadcast_market_page.php --apply
*/
error_reporting(E_ALL);
ini_set('display_errors', '1');
const PAGE_SLUG = '/markets/broadcast-and-telecoms';
// 11342-11349 = Import 27.10. (inkl. der beiden HIDDEN-Stubs customerlogos/
// usecaselist, die auch die anderen Market-Seiten tragen); 10293 = das alte
// "Explore Broadcast & Telecoms"-Band - der Import hat hier KEINEN eigenen
// Explore-Introparagraph angelegt, das Element bleibt die Ueberschrift
// ueber den Karten (Inspect-Befund 2026-10-28).
const KEEP = [11342, 11343, 11344, 11345, 11346, 11347, 11348, 11349, 10293];
$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]
);
$stmt = $pdo->prepare('SELECT uid FROM pages WHERE deleted = 0 AND slug = ? AND sys_language_uid IN (0,-1)');
$stmt->execute([PAGE_SLUG]);
$pid = (int)$stmt->fetchColumn();
if ($pid === 0) {
exit("Seite " . PAGE_SLUG . " nicht gefunden.\n");
}
echo ($apply ? 'APPLY' : 'INSPECT') . " · Seite #$pid " . PAGE_SLUG . "\n\n";
$stmt = $pdo->prepare(
'SELECT uid, colPos, sorting, CType, header, tx_container_parent, hidden
FROM tt_content WHERE deleted = 0 AND pid = ? ORDER BY colPos, sorting'
);
$stmt->execute([$pid]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
$delete = [];
printf("%-7s %-7s %-8s %-28s %-9s %-7s %s\n", 'uid', 'colPos', 'parent', 'CType', 'hidden', 'aktion', 'header');
foreach ($rows as $row) {
$keep = in_array((int)$row['uid'], KEEP, true);
if (!$keep) {
$delete[] = (int)$row['uid'];
}
printf("%-7d %-7d %-8d %-28s %-9s %-7s %s\n",
$row['uid'], $row['colPos'], $row['tx_container_parent'], $row['CType'],
$row['hidden'] ? 'hidden' : '-', $keep ? 'KEEP' : 'DELETE', mb_substr((string)$row['header'], 0, 45));
}
echo "\n" . count($rows) . " Element(e), davon " . count($delete) . " zu loeschen, " . (count($rows) - count($delete)) . " bleiben.\n";
$missing = array_diff(KEEP, array_map(static fn(array $r): int => (int)$r['uid'], $rows));
if ($missing !== []) {
echo "WARNUNG: Whitelist-Elemente fehlen auf der Seite: " . implode(', ', $missing) . " - NICHT anwenden, erst klaeren.\n";
exit(1);
}
if (!$apply) {
echo "\nInspect only - mit --apply soft-loeschen.\n";
exit(0);
}
if ($delete === []) {
echo "Nichts zu tun.\n";
exit(0);
}
$in = implode(',', array_fill(0, count($delete), '?'));
$stmt = $pdo->prepare("UPDATE tt_content SET deleted = 1, tstamp = UNIX_TIMESTAMP() WHERE uid IN ($in)");
$stmt->execute($delete);
echo "Soft-geloescht: " . $stmt->rowCount() . " Element(e). Danach: vendor/bin/typo3 cache:flush\n";
+296
View File
@@ -0,0 +1,296 @@
<?php
declare(strict_types=1);
/**
* Fixes from the second Seobility crawl of 2026-11-10 (after the duplicate clean-up).
*
* A. Card CTAs pointing at hidden/deleted product URLs (internal 301 hops):
* ArtioSign/ArtioView cards -> /products/aetria (and the two redirects
* follow), X-View self-link cleared, chassis card -> surviving product.
* B. News shells still carrying the InfoComm import placeholder text:
* #6/#7/#5 are duplicates of real articles (#17/#18/#16) -> deleted + 301;
* #4 (EKOM) gets its text from the old /en/news page. #551 is a byte-
* identical copy of #550 under a title that does not match the text -> 301.
* C. Three typos.
* D. Dead vitec.com links in old articles: "https://www.vitec.com/&nbsp;",
* trailing-dot host, /industries, /green-initiative, two dead PDFs ->
* internal targets.
* E. SEO titles for the short top-level pages (/solutions "Solutions | VITEC" etc.).
* F. Meta descriptions for the nine category pages whose generated text was
* under 60 characters ("VITEC Avedia: Avedia Platform.").
*
* Dry run: php migrations/crawl2_fixes_20261110.php
* Write: php migrations/crawl2_fixes_20261110.php --apply (--only=A,B / --skip=F)
*/
error_reporting(E_ALL);
ini_set('display_errors', '1');
$apply = in_array('--apply', $argv, true);
$only = []; $skip = [];
foreach ($argv as $arg) {
if (str_starts_with($arg, '--only=')) { $only = array_map('trim', explode(',', strtoupper(substr($arg, 7)))); }
if (str_starts_with($arg, '--skip=')) { $skip = array_map('trim', explode(',', strtoupper(substr($arg, 7)))); }
}
$runs = static fn(string $s): bool => (!$only || in_array($s, $only, true)) && !in_array($s, $skip, 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("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]
);
echo $apply ? "== APPLY ==\n" : "== DRY RUN (add --apply to write) ==\n";
$now = time();
$purgePaths = [];
$plain = static fn(?string $h): string => trim((string)preg_replace('/\s+/', ' ', html_entity_decode(strip_tags((string)$h), ENT_QUOTES, 'UTF-8')));
// --- helpers -----------------------------------------------------------------
$hasRedirect = $pdo->prepare("SELECT uid, target FROM sys_redirect WHERE deleted = 0 AND source_path = ? LIMIT 1");
$insertRedirect = $pdo->prepare(
"INSERT INTO sys_redirect (pid, createdon, updatedon, source_host, source_path, target, target_statuscode)
VALUES (0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), '*', ?, ?, 301)"
);
$addRedirect = static function (string $source, string $target) use ($pdo, $hasRedirect, $insertRedirect, $apply, &$purgePaths): void {
$purgePaths[$source] = true;
$hasRedirect->execute([$source]);
$existing = $hasRedirect->fetch();
if ($existing) {
if ($existing['target'] === $target) { return; }
echo " redirect $source: Ziel {$existing['target']} -> $target\n";
if ($apply) { $pdo->prepare("UPDATE sys_redirect SET target = ?, updatedon = UNIX_TIMESTAMP() WHERE uid = ?")->execute([$target, (int)$existing['uid']]); }
return;
}
echo " redirect $source -> $target\n";
if ($apply) { $insertRedirect->execute([$source, $target]); }
};
$retarget = static function (string $oldTarget, string $newTarget) use ($pdo, $apply): void {
$st = $pdo->prepare("SELECT uid, source_path FROM sys_redirect WHERE deleted = 0 AND target = ?");
$st->execute([$oldTarget]);
foreach ($st->fetchAll() as $r) {
echo " redirect #{$r['uid']} {$r['source_path']}: Ziel $oldTarget -> $newTarget\n";
if ($apply) { $pdo->prepare("UPDATE sys_redirect SET target = ?, updatedon = UNIX_TIMESTAMP() WHERE uid = ?")->execute([$newTarget, (int)$r['uid']]); }
}
};
$softDelete = static function (string $table, int $uid) use ($pdo, $apply, $now): void {
echo " loeschen $table #$uid\n";
if ($apply) {
$pdo->prepare("UPDATE $table SET deleted = 1, tstamp = ? WHERE uid = ?")->execute([$now, $uid]);
$pdo->prepare("DELETE FROM sys_category_record_mm WHERE tablenames = ? AND uid_foreign = ?")->execute([$table, $uid]);
}
};
$newsRow = static function (int $uid) use ($pdo): ?array {
$st = $pdo->prepare("SELECT uid, title, path_segment, type, LENGTH(bodytext) AS bl FROM tx_news_domain_model_news WHERE deleted = 0 AND uid = ?");
$st->execute([$uid]);
$r = $st->fetch();
return $r ?: null;
};
$updateField = static function (string $table, int $uid, string $field, string $value) use ($pdo, $apply, $now): void {
if ($apply) { $pdo->prepare("UPDATE $table SET $field = ?, tstamp = ? WHERE uid = ?")->execute([$value, $now, $uid]); }
};
// Replace patterns in a text field of every matching row; prints each change.
$replaceIn = static function (string $table, string $field, array $pairs, string $label) use ($pdo, $apply, $now, &$purgePaths): int {
// pair = [needle, replacement, isRegex, likeNeedle (plain text for the SQL prefilter; required for regex pairs)]
$like = implode(' OR ', array_fill(0, count($pairs), "$field LIKE ?"));
$params = array_map(fn($p) => '%' . ($p[3] ?? $p[0]) . '%', $pairs);
$extra = $table === 'tx_news_domain_model_news' ? ', path_segment AS ref' : ($table === 'tt_content' ? ', pid AS ref' : ', uid AS ref');
$st = $pdo->prepare("SELECT uid, $field AS v $extra FROM $table WHERE deleted = 0 AND ($like)");
$st->execute($params);
$n = 0;
foreach ($st->fetchAll() as $r) {
$new = (string)$r['v'];
foreach ($pairs as [$needle, $replacement, $isRegex]) {
$new = $isRegex ? (string)preg_replace($needle, $replacement, $new) : str_replace($needle, $replacement, $new);
}
if ($new === (string)$r['v']) { continue; }
$n++;
echo " $label: $table #{$r['uid']} ({$r['ref']})\n";
if ($table === 'tx_news_domain_model_news') { $purgePaths['/news/article/' . $r['ref']] = true; }
if ($apply) { $pdo->prepare("UPDATE $table SET $field = ?, tstamp = ? WHERE uid = ?")->execute([$new, $now, (int)$r['uid']]); }
}
return $n;
};
// --- A. card CTAs onto hidden/deleted products -----------------------------------
if ($runs('A')) {
echo "\nA. Karten-Buttons auf versteckte/geloeschte Produkt-URLs\n";
$ctas = [
11744 => ['/product/artiosign', '/products/aetria'],
11745 => ['/product/artioview', '/products/aetria'],
11649 => ['/product/artiosign', '/products/aetria'],
11650 => ['/product/artioview', '/products/aetria'],
12179 => ['/product/avedia-modular-chassis-c1101-c1103-c1210-1', '/product/avedia-modular-chassis-c1101-c1103-c1210'],
];
$ce = $pdo->prepare("SELECT uid, pid, header, vitec_primary_cta, vitec_primary_cta_label FROM tt_content WHERE deleted = 0 AND uid = ?");
foreach ($ctas as $uid => [$old, $new]) {
$ce->execute([$uid]); $c = $ce->fetch();
if (!$c) { continue; }
if (trim((string)$c['vitec_primary_cta']) !== $old) { echo " CE #$uid: CTA ist '{$c['vitec_primary_cta']}', erwartet '$old' - uebersprungen\n"; continue; }
echo " CE #$uid (Seite {$c['pid']}, \"{$c['header']}\"): $old -> $new\n";
$updateField('tt_content', $uid, 'vitec_primary_cta', $new);
$pageSlug = $pdo->query("SELECT slug FROM pages WHERE uid = {$c['pid']}")->fetchColumn();
if ($pageSlug) { $purgePaths[$pageSlug] = true; }
}
// X-View card links to the page it sits on: no button.
$ce->execute([12133]); $c = $ce->fetch();
if ($c && trim((string)$c['vitec_primary_cta']) === '/product/x-view-integrated-displays') {
echo " CE #12133 (\"{$c['header']}\"): Selbst-Link entfernt (Button weg)\n";
$updateField('tt_content', 12133, 'vitec_primary_cta', '');
$updateField('tt_content', 12133, 'vitec_primary_cta_label', '');
$purgePaths['/products/x-view-integrated-displays'] = true;
}
// The two product redirects follow the cards.
$addRedirect('/product/artiosign', '/products/aetria');
$addRedirect('/product/artioview', '/products/aetria');
}
// --- B. placeholder news shells -----------------------------------------------------
if ($runs('B')) {
echo "\nB. Platzhalter-News (InfoComm-Importtext)\n";
$placeholderMarker = 'VITEC will demonstrate its latest enterprise video technologies at InfoComm 2026';
$isPlaceholder = static function (int $uid) use ($pdo, $placeholderMarker): bool {
$b = (string)$pdo->query("SELECT bodytext FROM tx_news_domain_model_news WHERE uid = $uid")->fetchColumn();
return strlen($b) === 1308 && str_contains($b, $placeholderMarker);
};
// duplicate shell => real article
foreach ([6 => 17, 7 => 18, 5 => 16] as $shell => $real) {
$s = $newsRow($shell); $r = $newsRow($real);
if (!$s || !$r) { echo " #$shell/#$real fehlt, uebersprungen\n"; continue; }
if (!$isPlaceholder($shell)) { echo " #$shell ist kein Platzhalter mehr, uebersprungen\n"; continue; }
echo " #$shell \"{$s['title']}\" (Platzhalter) -> #$real \"{$r['title']}\" ({$r['bl']} Zeichen)\n";
$softDelete('tx_news_domain_model_news', $shell);
$addRedirect('/news/article/' . $s['path_segment'], '/news/article/' . $r['path_segment']);
$retarget('/news/article/' . $s['path_segment'], '/news/article/' . $r['path_segment']);
$purgePaths['/news/article/' . $r['path_segment']] = true;
}
// EKOM: text lives on the old page
$s = $newsRow(4);
if ($s && $isPlaceholder(4)) {
$page = $pdo->query("SELECT uid, slug FROM pages WHERE deleted = 0 AND slug LIKE '%/news/vitec-partners-with-ekom%' ORDER BY uid LIMIT 1")->fetch();
$html = '';
if ($page) {
foreach ($pdo->query("SELECT CType, bodytext FROM tt_content WHERE deleted = 0 AND hidden = 0 AND pid = {$page['uid']} AND CType IN ('text','textmedia','html') AND bodytext <> '' ORDER BY sorting") as $c) {
$part = (string)$c['bodytext'];
if (str_contains($part, 'vitec.com/&nbsp;')) { $part = str_replace('https://www.vitec.com/&nbsp;', '<a href="/">www.vitec.com</a>', $part); }
$html .= $part . "\n";
}
}
$len = mb_strlen($plain($html));
if ($len >= 500) {
echo " #4 \"{$s['title']}\": Text von Seite #{$page['uid']} ({$page['slug']}) uebernommen, $len Zeichen\n";
$updateField('tx_news_domain_model_news', 4, 'bodytext', trim($html));
$purgePaths['/news/article/' . $s['path_segment']] = true;
} else {
echo " #4: keine alte Seite mit Text gefunden ($len Zeichen) - bleibt\n";
}
}
// byte-identical Datapath pair: keep the one whose title matches the text
$a = $newsRow(551); $b = $newsRow(550);
if ($a && $b) {
$ba = $pdo->query("SELECT bodytext FROM tx_news_domain_model_news WHERE uid = 551")->fetchColumn();
$bb = $pdo->query("SELECT bodytext FROM tx_news_domain_model_news WHERE uid = 550")->fetchColumn();
if ($ba === $bb) {
echo " #551 \"{$a['title']}\" = Textkopie von #550 \"{$b['title']}\" -> weg\n";
$softDelete('tx_news_domain_model_news', 551);
$addRedirect('/news/article/' . $a['path_segment'], '/news/article/' . $b['path_segment']);
$retarget('/news/article/' . $a['path_segment'], '/news/article/' . $b['path_segment']);
}
}
}
// --- C. typos ------------------------------------------------------------------------
if ($runs('C')) {
echo "\nC. Tippfehler\n";
$replaceIn('tx_news_domain_model_news', 'bodytext', [
['/\bsponser\b/', 'sponsor', true, 'sponser'],
['/\bSponser\b/', 'Sponsor', true, 'Sponser'],
['/\breconnaisance\b/', 'reconnaissance', true, 'reconnaisance'],
['/\bReconnaisance\b/', 'Reconnaissance', true, 'Reconnaisance'],
['/\bauxillary\b/', 'auxiliary', true, 'auxillary'],
['/\bAuxillary\b/', 'Auxiliary', true, 'Auxillary'],
], 'Tippfehler');
}
// --- D. dead vitec.com links -----------------------------------------------------------
if ($runs('D')) {
echo "\nD. Tote vitec.com-Links\n";
$pairs = [
['https://www.vitec.com/&nbsp;', '<a href="/">www.vitec.com</a>', false],
['~https?://www\.vitec\.com\.(?=[/"#\s<])/?~', '/', true, 'www.vitec.com.'],
['https://www.vitec.com/green-initiative', '/insights/sustainability', false],
['https://www.vitec.com/industries/military/government', '/markets/defense-and-security', false],
['https://www.vitec.com/industries', '/markets', false],
['~https?://www\.vitec\.com/fileadmin/downloads/Company_Downloads/Use_Cases/Northwestern_University_USE_CASE_WebVersion\.pdf~', '/success-stories/northwestern-university', true, 'Northwestern_University_USE_CASE'],
['~https?://datapathdocuments\.co\.uk/wp-content/uploads/Datapath-Fx4_datasheet_EN\.pdf~', '/product/fx4', true, 'Datapath-Fx4_datasheet'],
];
$n = $replaceIn('tx_news_domain_model_news', 'bodytext', $pairs, 'Link');
$n += $replaceIn('tt_content', 'bodytext', $pairs, 'Link');
echo " $n Datensaetze\n";
}
// --- E. SEO titles for short top-level pages -------------------------------------------
if ($runs('E')) {
echo "\nE. Seitentitel der Hauptseiten\n";
$titles = [
'/solutions' => 'Video Solutions for Control Rooms, Streaming & Signage',
'/products' => 'Products: IPTV, Video Streaming, Video Walls & Capture',
'/support' => 'Support, Training & Product Resources',
'/datasheets' => 'Datasheets: Encoders, IPTV, Video Walls & Capture Cards',
'/contact' => 'Contact VITEC: Sales, Support & Offices Worldwide',
'/contact/locations' => 'VITEC Offices & Locations Worldwide',
'/success-stories' => 'Success Stories from Broadcast, Defense & Enterprise',
];
$st = $pdo->prepare("SELECT uid, title, seo_title FROM pages WHERE deleted = 0 AND slug = ?");
foreach ($titles as $slug => $seo) {
$st->execute([$slug]); $p = $st->fetch();
if (!$p) { continue; }
if ($p['seo_title'] === $seo) { continue; }
printf(" %-20s \"%s\" -> \"%s\"\n", $slug, $p['seo_title'], $seo);
$updateField('pages', (int)$p['uid'], 'seo_title', $seo);
$purgePaths[$slug] = true;
}
}
// --- F. descriptions for the thinnest category pages -------------------------------------
if ($runs('F')) {
echo "\nF. Meta-Descriptions der kuerzesten Kategorieseiten\n";
$desc = [
'/products/ez-tv' => 'EZ TV Platform by VITEC: enterprise IPTV and digital signage for live TV, video distribution and targeted content across screens and devices.',
'/products/activesqx' => 'ActiveSQX2 by VITEC and Datapath: IP stream decoding card that brings network video sources onto video walls. Specifications, features and datasheets.',
'/products/avedia' => 'Avedia Platform by VITEC: one platform for IP video, digital signage and interactive experiences across enterprise networks.',
'/products/rf-gateway' => 'VITEC RF gateways for the Avedia Modular System: receive satellite, cable and terrestrial TV and distribute it as IPTV across the network.',
'/products/prism-transcoder' => 'PRISM and PRISM SFF by VITEC: broadcast-grade video transcoders for converting, scaling and re-streaming live video, including a compact small-form-factor model.',
'/products/channellink-ip-gateway' => 'ChannelLink IP Gateway by VITEC: brings IP video streams into IPTV and digital signage networks, with transcoding for reliable delivery to every screen.',
'/products/aligo' => 'Aligo by VITEC: AV over IP and KVM encoders, receivers and workstations (QTX100, TX100, RX100, TX100 SQX) for control rooms and multi-display environments.',
'/products/vms-plugins-and-integrations' => 'VMS plugins and integrations for VITEC and Datapath video walls: bring Genetec and Milestone video management systems onto operator displays.',
'/products/image-graphics-cards' => 'Datapath Image graphics cards from VITEC (Image 2K, Image 4K, IQS4): multi-output graphics for video walls and multi-display workstations.',
];
$st = $pdo->prepare("SELECT uid, description FROM pages WHERE deleted = 0 AND slug = ?");
foreach ($desc as $slug => $d) {
$st->execute([$slug]); $p = $st->fetch();
if (!$p || $p['description'] === $d) { continue; }
printf(" %-42s (%d -> %d Zeichen)\n alt: %s\n neu: %s\n", $slug, strlen((string)$p['description']), strlen($d), $p['description'], $d);
$updateField('pages', (int)$p['uid'], 'description', $d);
$purgePaths[$slug] = true;
}
}
echo "\nSSR-Pfade zum Purgen (" . count($purgePaths) . "):\n " . implode("\n ", array_keys($purgePaths)) . "\n";
echo $apply ? "\nFERTIG.\n" : "\n(Trockenlauf, nichts geschrieben)\n";
+307
View File
@@ -0,0 +1,307 @@
<?php
declare(strict_types=1);
/**
* Duplicate clean-up after the Seobility crawl of 2026-11-10.
*
* A. Product copy "avedia-modular-chassis-c1101-c1103-c1210-1" (empty) -> deleted, 301 to the original.
* B. News duplicates. Where the "-1/-2" copy carries the real text and the
* base record is an empty shell or an import placeholder, the shell is
* deleted and the copy takes over the clean slug (301 from the "-1" URL).
* Records without any content anywhere (#9/#229 global events, #42 DSEI
* 2023 pointer) are deleted and their URLs sent to the news overview.
* C. Records that are NOT duplicates but carry a "-1/-2" slug from the
* import: slug rebuilt from the title, 301 from the old URL.
* D. /about (page 7): only an office list, superseded by /insights/who-we-are
* and /about/locations -> hidden, 301, two links re-pointed.
* E. Stories with copied teaser / SEO description (#11 UAE University,
* #30 Kansas City Royals, #35 Minnesota United): texts rebuilt from the
* story's own first paragraph.
*
* Dry run: php migrations/dedupe_20261110.php
* Write: php migrations/dedupe_20261110.php --apply
* Sections: --only=A,B or --skip=D,E
*
* Deletions are TYPO3 soft deletes (deleted = 1, recoverable via the recycler).
*/
error_reporting(E_ALL);
ini_set('display_errors', '1');
$apply = in_array('--apply', $argv, true);
$only = []; $skip = [];
foreach ($argv as $arg) {
if (str_starts_with($arg, '--only=')) { $only = array_map('trim', explode(',', strtoupper(substr($arg, 7)))); }
if (str_starts_with($arg, '--skip=')) { $skip = array_map('trim', explode(',', strtoupper(substr($arg, 7)))); }
}
$runs = static fn(string $s): bool => (!$only || in_array($s, $only, true)) && !in_array($s, $skip, 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("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]
);
echo $apply ? "== APPLY ==\n" : "== DRY RUN (add --apply to write) ==\n";
$now = time();
$purgePaths = [];
// --- helpers -----------------------------------------------------------------
$hasRedirect = $pdo->prepare("SELECT uid, target FROM sys_redirect WHERE deleted = 0 AND source_path = ? LIMIT 1");
$insertRedirect = $pdo->prepare(
"INSERT INTO sys_redirect (pid, createdon, updatedon, source_host, source_path, target, target_statuscode)
VALUES (0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), '*', ?, ?, 301)"
);
$updateRedirectBySource = $pdo->prepare("UPDATE sys_redirect SET target = ?, updatedon = UNIX_TIMESTAMP() WHERE uid = ?");
$addRedirect = static function (string $source, string $target) use ($hasRedirect, $insertRedirect, $updateRedirectBySource, $apply, &$purgePaths): void {
$purgePaths[$source] = true;
$hasRedirect->execute([$source]);
$existing = $hasRedirect->fetch();
if ($existing) {
if ($existing['target'] === $target) { return; }
echo " redirect $source: Ziel {$existing['target']} -> $target\n";
if ($apply) { $updateRedirectBySource->execute([$target, (int)$existing['uid']]); }
return;
}
echo " redirect $source -> $target\n";
if ($apply) { $insertRedirect->execute([$source, $target]); }
};
// Redirects whose TARGET is the URL that goes away get the new target (no 301 chains).
$retarget = static function (string $oldTarget, string $newTarget) use ($pdo, $apply): void {
$st = $pdo->prepare("SELECT uid, source_path FROM sys_redirect WHERE deleted = 0 AND target = ?");
$st->execute([$oldTarget]);
foreach ($st->fetchAll() as $r) {
echo " redirect #{$r['uid']} {$r['source_path']}: Ziel $oldTarget -> $newTarget\n";
if ($apply) {
$pdo->prepare("UPDATE sys_redirect SET target = ?, updatedon = UNIX_TIMESTAMP() WHERE uid = ?")->execute([$newTarget, (int)$r['uid']]);
}
}
};
$softDelete = static function (string $table, int $uid) use ($pdo, $apply, $now): void {
echo " loeschen $table #$uid\n";
if ($apply) {
$pdo->prepare("UPDATE $table SET deleted = 1, tstamp = ? WHERE uid = ?")->execute([$now, $uid]);
$pdo->prepare("DELETE FROM sys_category_record_mm WHERE tablenames = ? AND uid_foreign = ?")->execute([$table, $uid]);
}
};
$newsRow = static function (int $uid) use ($pdo): ?array {
$st = $pdo->prepare("SELECT uid, title, path_segment, type, hidden, LENGTH(bodytext) AS bl FROM tx_news_domain_model_news WHERE deleted = 0 AND uid = ?");
$st->execute([$uid]);
$r = $st->fetch();
return $r ?: null;
};
$setSegment = static function (int $uid, string $segment) use ($pdo, $apply, $now): void {
echo " news #$uid slug -> $segment\n";
if ($apply) {
$pdo->prepare("UPDATE tx_news_domain_model_news SET path_segment = ?, tstamp = ? WHERE uid = ?")->execute([$segment, $now, $uid]);
}
};
$slugify = static function (string $t): string {
$t = html_entity_decode($t, ENT_QUOTES, 'UTF-8');
$t = strtolower(trim((string)preg_replace('/[^A-Za-z0-9]+/', '-', iconv('UTF-8', 'ASCII//TRANSLIT', $t) ?: $t), '-'));
return $t;
};
$plain = static fn(?string $h): string => trim((string)preg_replace('/\s+/', ' ', html_entity_decode(strip_tags((string)$h), ENT_QUOTES, 'UTF-8')));
$cut = static function (string $s, int $max): string {
if (mb_strlen($s) <= $max) { return $s; }
$s = mb_substr($s, 0, $max);
$s = preg_replace('/\s+\S*$/u', '', $s) ?? $s;
return rtrim($s, ' ,;:-') . '…';
};
$newsOverview = '/insights/news';
// --- A. product copy -----------------------------------------------------------
if ($runs('A')) {
echo "\nA. Produkt-Kopie\n";
$st = $pdo->query("SELECT uid, slug, LENGTH(description) dl FROM tx_vitec_domain_model_product WHERE deleted = 0 AND slug IN ('avedia-modular-chassis-c1101-c1103-c1210', 'avedia-modular-chassis-c1101-c1103-c1210-1')");
$p = [];
foreach ($st as $r) { $p[$r['slug']] = $r; }
$copy = $p['avedia-modular-chassis-c1101-c1103-c1210-1'] ?? null;
$base = $p['avedia-modular-chassis-c1101-c1103-c1210'] ?? null;
if ($copy && $base && (int)$copy['dl'] === 0) {
echo " Produkt #{$copy['uid']} (leer) -> #{$base['uid']}\n";
$softDelete('tx_vitec_domain_model_product', (int)$copy['uid']);
$addRedirect('/product/' . $copy['slug'], '/product/' . $base['slug']);
$purgePaths['/product/' . $base['slug']] = true;
} else {
echo " nichts zu tun\n";
}
}
// --- B. news duplicates ----------------------------------------------------------
if ($runs('B')) {
echo "\nB. News-Dubletten\n";
// [keep uid, clean segment, [uids to delete], [old segments to redirect]]
$takeovers = [
[12, 'vitec-to-showcase-expanding-enterprise-iptv-and-digital-signage-innovations-at-infocomm-2026', [1]],
[13, 'vitec-and-lg-electronics-to-deliver-large-scale-iptv-and-digital-signage-over-next-generation-private-5g-networks', [2]],
[14, 'vitec-to-launch-prism-sff-its-most-compact-transcoder-at-eurosatory-2026', [3]],
[258, 'ise-2026-a-milestone-for-vitec-and-datapath', [10, 230]],
[21, 'vitec-acquires-datapath-ltd-to-expand-video-capabilities', [263]],
[231, 'the-impact-of-remote-work-on-security', [344]],
];
foreach ($takeovers as [$keepUid, $clean, $deleteUids]) {
$keep = $newsRow($keepUid);
if ($keep === null) { echo " #$keepUid fehlt, uebersprungen\n"; continue; }
echo " behalten #$keepUid ({$keep['bl']} Zeichen) als /news/article/$clean | {$keep['title']}\n";
foreach ($deleteUids as $d) {
$row = $newsRow($d);
if ($row === null) { echo " #$d schon weg\n"; continue; }
echo " weg: #$d /news/article/{$row['path_segment']} (type {$row['type']}, {$row['bl']} Zeichen)\n";
$softDelete('tx_news_domain_model_news', $d);
if ($row['path_segment'] !== $clean) {
$addRedirect('/news/article/' . $row['path_segment'], '/news/article/' . $clean);
$retarget('/news/article/' . $row['path_segment'], '/news/article/' . $clean);
}
}
if ($keep['path_segment'] !== $clean) {
$old = $keep['path_segment'];
$setSegment($keepUid, $clean);
$addRedirect('/news/article/' . $old, '/news/article/' . $clean);
$retarget('/news/article/' . $old, '/news/article/' . $clean);
}
$purgePaths['/news/article/' . $clean] = true;
}
// Records with no content anywhere.
echo " ohne Inhalt (Record + alte Seite leer):\n";
foreach ([9, 229] as $d) {
$row = $newsRow($d);
if ($row === null) { continue; }
echo " weg: #$d /news/article/{$row['path_segment']} | {$row['title']}\n";
$softDelete('tx_news_domain_model_news', $d);
$addRedirect('/news/article/' . $row['path_segment'], $newsOverview);
$retarget('/news/article/' . $row['path_segment'], $newsOverview);
}
$row = $newsRow(42);
if ($row !== null) {
$alt = $pdo->query("SELECT path_segment FROM tx_news_domain_model_news WHERE deleted = 0 AND uid <> 42 AND type = 0 AND path_segment LIKE '%dsei-2023%' LIMIT 1")->fetchColumn();
$target = $alt ? '/news/article/' . $alt : $newsOverview;
echo " weg: #42 /news/article/{$row['path_segment']} (Zeiger auf leere Seite 321) -> $target\n";
$softDelete('tx_news_domain_model_news', 42);
$addRedirect('/news/article/' . $row['path_segment'], $target);
$retarget('/news/article/' . $row['path_segment'], $target);
}
}
// --- C. wrong "-N" slugs on distinct articles --------------------------------------
if ($runs('C')) {
echo "\nC. Eigene Artikel mit Import-Suffix im Slug\n";
foreach ([
435 => 'wallcontrol-10-scoops-installation-product-awards-crown',
334 => '2023-end-of-year-message-from-ceo-bjorn-krylander',
376 => '2022-end-of-year-message-from-ceo-bjorn-krylander',
] as $uid => $segment) {
$row = $newsRow($uid);
if ($row === null || $row['path_segment'] === $segment) { continue; }
echo " #$uid | {$row['title']}\n";
$old = $row['path_segment'];
$setSegment($uid, $segment);
$addRedirect('/news/article/' . $old, '/news/article/' . $segment);
$retarget('/news/article/' . $old, '/news/article/' . $segment);
$purgePaths['/news/article/' . $segment] = true;
}
}
// --- D. /about ------------------------------------------------------------------------
if ($runs('D')) {
echo "\nD. /about (Seite 7) -> /insights/who-we-are\n";
$page = $pdo->query("SELECT uid, hidden, slug FROM pages WHERE uid = 7 AND deleted = 0")->fetch();
if ($page && (int)$page['hidden'] === 0) {
echo " Seite 7 verstecken\n";
if ($apply) { $pdo->exec("UPDATE pages SET hidden = 1, tstamp = $now WHERE uid = 7"); }
}
$addRedirect('/about', '/insights/who-we-are');
$addRedirect('/about/', '/insights/who-we-are');
foreach ($pdo->query("SELECT uid, pid, bodytext, vitec_button_link, header_link FROM tt_content WHERE deleted = 0 AND uid IN (117, 10291)") as $c) {
$changes = [];
foreach (['bodytext', 'vitec_button_link', 'header_link'] as $f) {
$v = (string)($c[$f] ?? '');
$n = (string)preg_replace('/t3:\/\/page\?uid=7(?![0-9])/', 't3://page?uid=550', $v);
$n = str_replace(['"/about"', '"/about/"'], '"/insights/who-we-are"', $n);
if (in_array($v, ['/about', '/about/', '7'], true)) { $n = $v === '7' ? '550' : 't3://page?uid=550'; }
if ($n !== $v) { $changes[$f] = $n; }
elseif ($f !== 'bodytext' && $v !== '') { echo " CE #{$c['uid']} $f = $v\n"; }
}
if ($changes) {
echo " CE #{$c['uid']} (Seite {$c['pid']}): " . implode(', ', array_keys($changes)) . " -> who-we-are\n";
if ($apply) {
$set = implode(', ', array_map(fn($f) => "$f = ?", array_keys($changes)));
$pdo->prepare("UPDATE tt_content SET $set, tstamp = $now WHERE uid = ?")->execute([...array_values($changes), (int)$c['uid']]);
}
} else {
echo " CE #{$c['uid']}: kein /about-Link gefunden (manuell pruefen)\n";
}
}
$purgePaths['/'] = true;
}
// --- E. story teasers / descriptions copied from the wrong story -----------------------
if ($runs('E')) {
echo "\nE. Story-Teaser/-Beschreibungen aus dem eigenen Text\n";
$firstPara = static function (int $usecase) use ($pdo, $plain): string {
$st = $pdo->prepare("SELECT bodytext FROM tt_content WHERE deleted = 0 AND hidden = 0 AND tx_vitec_usecase_content = ? AND bodytext <> '' ORDER BY sorting");
$st->execute([$usecase]);
$fallback = '';
foreach ($st->fetchAll(PDO::FETCH_COLUMN) as $html) {
$html = (string)preg_replace('/<(script|style)\b[^>]*>.*?<\/\1>/is', '', (string)$html);
$parts = preg_match_all('/<p[^>]*>(.*?)<\/p>/is', $html, $m) ? $m[1] : [$html];
foreach ($parts as $p) {
$t = $plain($p);
if (mb_strlen($t) < 80 || str_contains($t, 'document.') || str_contains($t, 'function (')) { continue; }
// Prefer a real sentence over a title-case sub-headline.
if (mb_strlen($t) >= 120 && preg_match('/[.!?]$/u', $t) === 1) { return $t; }
if ($fallback === '') { $fallback = $t; }
}
}
return $fallback;
};
// Texts derived from each story's own (correct) teaser or headline; the
// automatic first-paragraph pick was tried and read badly.
$texts = [
11 => ['seo_description' => 'VITEC IPTV and digital signage power streaming and next-generation learning at United Arab Emirates University. Read the success story.'],
30 => ['teaser' => 'Reliable, high-quality, low-latency streams and digital signage tools: the Kansas City Royals improve fan engagement with VITEC’s IPTV platform.'],
35 => [
'teaser' => 'VITEC’s EZ TV IPTV and digital signage solution delivers game-day coverage and advertising opportunities at Minnesota United’s Allianz Field.',
'seo_description' => 'VITEC’s EZ TV IPTV and digital signage solution delivers game-day coverage and advertising opportunities at Minnesota United’s Allianz Field.',
],
];
foreach ($texts as $uid => $values) {
$st = $pdo->prepare("SELECT uid, title, teaser, seo_description FROM tx_vitec_domain_model_usecase WHERE deleted = 0 AND uid = ?");
$st->execute([$uid]);
$row = $st->fetch();
if (!$row) { continue; }
echo " #$uid {$row['title']} (erster Absatz der Story: " . $cut($firstPara($uid), 80) . ")\n";
foreach ($values as $f => $v) {
echo " $f alt: " . $cut($plain($row[$f]), 90) . "\n $f neu: $v\n";
}
if ($apply) {
$set = implode(', ', array_map(fn($f) => "$f = ?", array_keys($values)));
$pdo->prepare("UPDATE tx_vitec_domain_model_usecase SET $set, tstamp = $now WHERE uid = ?")->execute([...array_values($values), $uid]);
}
}
}
echo "\nSSR-Pfade zum Purgen (" . count($purgePaths) . "):\n " . implode("\n ", array_keys($purgePaths)) . "\n";
echo $apply ? "\nFERTIG. Danach: vendor/bin/typo3 cache:flush && vendor/bin/typo3 referenceindex:update\n" : "\n(Trockenlauf, nichts geschrieben)\n";
+113
View File
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
/**
* Data points from the frontend answers 0100-FE / 0104-FE / 0108-FE (2026-11-10).
*
* 1. /solutions: header element #1 "H1 Header" is an import placeholder -> hidden.
* 2. Home: #757 vitec_featuredcontent "Featured Product" carries lorem ipsum
* and links the empty /product shell -> hidden (placeholder).
* 3. Home: #702 vitec_card "Government" linked /markets/defense-and-security
* -> /markets/government (where the footer sends "Government").
* 4. Home: #699 text, button "Learn more" -> "Explore our solutions".
* 5. Story CDC: <h4>Product Highlight</h4> under an h2 -> <h3>.
*
* Dry run: php migrations/fe_answers_fixes_20261111.php
* Write: php migrations/fe_answers_fixes_20261111.php --apply
*/
error_reporting(E_ALL);
ini_set('display_errors', '1');
$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("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]
);
echo $apply ? "== APPLY ==\n" : "== DRY RUN (add --apply to write) ==\n";
$now = time();
$set = static function (string $table, int $uid, string $field, string $value) use ($pdo, $apply, $now): void {
if ($apply) { $pdo->prepare("UPDATE $table SET $field = ?, tstamp = ? WHERE uid = ?")->execute([$value, $now, $uid]); }
};
$ce = static function (int $uid) use ($pdo): ?array {
$st = $pdo->prepare("SELECT uid, pid, CType, header, hidden, bodytext, vitec_primary_cta, vitec_cta FROM tt_content WHERE deleted = 0 AND uid = ?");
$st->execute([$uid]);
$r = $st->fetch();
return $r ?: null;
};
echo "\n1./2. Platzhalter-Elemente verstecken\n";
foreach ([1 => 'H1 Header', 757 => 'Featured Product'] as $uid => $expect) {
$c = $ce($uid);
if (!$c) { echo " #$uid fehlt\n"; continue; }
if ($c['header'] !== $expect) { echo " #$uid hat Header '{$c['header']}' (erwartet '$expect') - uebersprungen\n"; continue; }
if ((int)$c['hidden'] === 1) { echo " #$uid schon versteckt\n"; continue; }
echo " #$uid {$c['CType']} \"{$c['header']}\" auf Seite {$c['pid']}: hidden = 1\n";
$set('tt_content', $uid, 'hidden', '1');
}
echo "\n3. Karte 'Government' (#702)\n";
$c = $ce(702);
if ($c && $c['vitec_primary_cta'] === 't3://page?uid=37') {
echo " #702 CTA t3://page?uid=37 (/markets/defense-and-security) -> t3://page?uid=526 (/markets/government)\n";
$set('tt_content', 702, 'vitec_primary_cta', 't3://page?uid=526');
} else {
echo " #702: CTA ist '" . ($c['vitec_primary_cta'] ?? '?') . "' - uebersprungen\n";
}
echo "\n4. Text #699: Button-Text\n";
$c = $ce(699);
if ($c && str_contains((string)$c['bodytext'], '>Learn more</a>')) {
echo " #699: 'Learn more' -> 'Explore our solutions'\n";
$set('tt_content', 699, 'bodytext', str_replace('>Learn more</a>', '>Explore our solutions</a>', (string)$c['bodytext']));
} else {
echo " #699: kein 'Learn more'-Button gefunden - uebersprungen\n";
}
echo "\n5. Story CDC: h4 'Product Highlight' -> h3\n";
$pattern = '~<h4(\s[^>]*)?>(\s*Product Highlight\s*)</h4>~i';
$n = 0;
foreach ($pdo->query("SELECT c.uid, u.slug, c.bodytext FROM tt_content c LEFT JOIN tx_vitec_domain_model_usecase u ON u.uid = c.tx_vitec_usecase_content WHERE c.deleted = 0 AND c.bodytext LIKE '%Product Highlight%'")->fetchAll() as $r) {
$new = (string)preg_replace($pattern, '<h3$1>$2</h3>', (string)$r['bodytext'], -1, $count);
if ($count === 0) { continue; }
$n++;
echo " tt_content #{$r['uid']} (Story " . ($r['slug'] ?? '-') . "): $count x h4 -> h3\n";
$set('tt_content', (int)$r['uid'], 'bodytext', $new);
}
// The story text may also live on the record itself.
$cols = $pdo->query("SHOW COLUMNS FROM tx_vitec_domain_model_usecase")->fetchAll(PDO::FETCH_COLUMN);
foreach ($cols as $col) {
if (!in_array($col, ['description', 'teaser', 'subtitle'], true)) { continue; }
foreach ($pdo->query("SELECT uid, slug, $col AS v FROM tx_vitec_domain_model_usecase WHERE deleted = 0 AND $col LIKE '%Product Highlight%'")->fetchAll() as $r) {
$new = (string)preg_replace($pattern, '<h3$1>$2</h3>', (string)$r['v'], -1, $count);
if ($count === 0) { continue; }
$n++;
echo " usecase #{$r['uid']} ({$r['slug']}) Feld $col: $count x h4 -> h3\n";
$set('tx_vitec_domain_model_usecase', (int)$r['uid'], $col, $new);
}
}
// Story sections live in the content-block collection table vitec_items.
foreach ($pdo->query("SELECT uid, text FROM vitec_items WHERE deleted = 0 AND text LIKE '%Product Highlight%'")->fetchAll() as $r) {
$new = (string)preg_replace($pattern, '<h3$1>$2</h3>', (string)$r['text'], -1, $count);
if ($count === 0) { continue; }
$n++;
echo " vitec_items #{$r['uid']}: $count x h4 -> h3\n";
$set('vitec_items', (int)$r['uid'], 'text', $new);
}
if ($n === 0) { echo " nichts gefunden (Text liegt woanders)\n"; }
echo "\nSSR-Pfade: / /solutions /success-stories/centers-for-disease-control-cdc\n";
echo $apply ? "FERTIG.\n" : "(Trockenlauf, nichts geschrieben)\n";
+131
View File
@@ -0,0 +1,131 @@
#!/bin/bash
# fe_comment Isolationstest (2026-11-03): zwei FE-User NACHEINANDER einloggen
# und pruefen, dass /api/fe-comment/state UND das USER_INT-Feld auf
# /_json/ticket-overview jedem User nur die EIGENEN Tickets zeigen.
# Aufruf: bash migrations/fec_isolation_test.sh <user1> <user2>
# (fragt beide Passwoerter unsichtbar ab)
set -e
cd /usr/www/users/vitecevo/live
BASE=https://dev.vitec.com
U1=${1:?Aufruf: fec_isolation_test.sh <user1> <user2>}
U2=${2:?Aufruf: fec_isolation_test.sh <user1> <user2>}
extract_token() {
php -r '
$j = json_decode(file_get_contents($argv[1]), true);
$f = "";
$w = function ($n) use (&$w, &$f) {
if (is_array($n)) {
if (($n["name"] ?? "") === "__RequestToken" && isset($n["value"])) {
$f = (string)$n["value"];
}
foreach ($n as $c) { $w($c); }
} elseif (is_string($n) && $f === "") {
if (preg_match("/name=\"__RequestToken\" value=\"([^\"]+)\"/", $n, $m)) {
$f = $m[1];
}
}
};
$w($j);
echo $f;
' "$1"
}
login() { # $1=user $2=pass $3=jar
rm -f "$3"
curl -sS -c "$3" -o /tmp/fec_form.json "$BASE/_json/fe-user-profile"
TOKEN=$(extract_token /tmp/fec_form.json)
if [ -z "$TOKEN" ]; then echo "KEIN __RequestToken gefunden"; exit 1; fi
curl -sS -b "$3" -c "$3" -o /tmp/fec_login.json \
--data-urlencode "user=$1" \
--data-urlencode "pass=$2" \
--data-urlencode "logintype=login" \
--data-urlencode "pid=520" \
--data-urlencode "__RequestToken=$TOKEN" \
"$BASE/_json/fe-user-profile"
if ! grep -q fe_typo_user "$3"; then
echo "LOGIN FEHLGESCHLAGEN fuer $1 (kein fe_typo_user-Cookie)"; exit 1
fi
}
summarize_state() { # stdin = state json, $1 = label
php -r '
$j = json_decode(stream_get_contents(STDIN), true);
printf("%-28s allowed=%s user=%s summaryUid=%s items=%d\n",
$argv[1],
var_export($j["allowed"] ?? null, true),
$j["user"]["name"] ?? "-",
isset($j["summary"]["uid"]) ? (string)$j["summary"]["uid"] : "-",
is_array($j["summary"]["items"] ?? null) ? count($j["summary"]["items"]) : 0);
foreach (($j["summary"]["items"] ?? []) as $i) {
printf(" item #%d %s\n", $i["uid"], mb_substr($i["url"], 0, 70));
}
' "$1"
}
summarize_overview() { # stdin = page json, $1 = label
php -r '
$j = json_decode(stream_get_contents(STDIN), true);
$fo = null;
foreach (($j["content"]["colPos0"] ?? []) as $el) {
if (($el["type"] ?? "") === "fecomment_overview") {
$fo = $el["content"]["feedbackOverview"] ?? null;
}
}
if ($fo === null) { echo $argv[1] . " KEIN fecomment_overview-Element gefunden\n"; exit; }
printf("%-28s allowed=%s user=%s summaryUid=%s items=%d\n",
$argv[1],
var_export($fo["allowed"] ?? null, true),
$fo["user"]["name"] ?? "-",
isset($fo["summary"]["uid"]) ? (string)$fo["summary"]["uid"] : "-",
is_array($fo["summary"]["items"] ?? null) ? count($fo["summary"]["items"]) : 0);
' "$1"
}
check_user() { # $1=user $2=jar
echo "=== $1 ==="
curl -sS -b "$2" "$BASE/api/fe-comment/state" | summarize_state "state-API"
curl -sS -b "$2" "$BASE/_json/ticket-overview" | summarize_overview "ticket-overview (USER_INT)"
echo
}
read -rsp "Passwort fuer $U1: " P1; echo
read -rsp "Passwort fuer $U2: " P2; echo
login "$U1" "$P1" /tmp/fec_u1.jar
login "$U2" "$P2" /tmp/fec_u2.jar
# Testticket als U1 anlegen, damit es etwas zu sehen (bzw. NICHT zu sehen) gibt
echo "--- lege Testticket als $U1 an ---"
TICKET=$(curl -sS -b /tmp/fec_u1.jar -H 'Content-Type: application/json' \
-d '{"url":"/isolation-test","note":"<p>Isolationstest - bitte ignorieren</p>"}' \
"$BASE/api/fe-comment/feedback" | php -r '
$j = json_decode(stream_get_contents(STDIN), true);
echo (int)($j["uid"] ?? 0);')
echo "Testticket uid=$TICKET"
if [ "$TICKET" = "0" ]; then echo "ANLEGEN FEHLGESCHLAGEN"; exit 1; fi
echo
# beide Sessions leben gleichzeitig - genau wie zwei Browser
check_user "$U1" /tmp/fec_u1.jar
check_user "$U2" /tmp/fec_u2.jar
# Gegenprobe: darf U2 das Ticket von U1 veraendern? (erwartet: Fehler)
echo "--- Gegenprobe: $U2 versucht Ticket #$TICKET von $U1 zu loeschen ---"
curl -sS -b /tmp/fec_u2.jar -X POST "$BASE/api/fe-comment/feedback/$TICKET/delete"; echo
echo "(erwartet: {\"error\":\"feedback not found or not yours\"})"
echo
# aufraeumen: U1 loescht sein eigenes Testticket
echo "--- aufraeumen: $U1 loescht Testticket #$TICKET ---"
curl -sS -b /tmp/fec_u1.jar -X POST "$BASE/api/fe-comment/feedback/$TICKET/delete"; echo
echo
echo "=== anonym (ohne Session) ==="
curl -sS "$BASE/api/fe-comment/state" | summarize_state "state-API"
curl -sS "$BASE/_json/ticket-overview" | summarize_overview "ticket-overview (USER_INT)"
echo
echo "ERWARTUNG: $U1 sieht 1 Item (das Testticket), $U2 sieht 0 Items,"
echo "die Gegenprobe-Loeschung durch $U2 schlaegt fehl, anonym ist allowed=false."
rm -f /tmp/fec_u1.jar /tmp/fec_u2.jar /tmp/fec_form.json /tmp/fec_login.json
+81
View File
@@ -0,0 +1,81 @@
<?php
/**
* fix_colombia_quote.php (2026-11-04, Ticket 0038-FE Punkt 3)
*
* the-national-police-of-colombia: das lose Zitat-Element #12037 traegt nur
* die ROLLE ("Director of the Engineering Department of Cosmos Sistemas
* Integrado"), waehrend der Usecase-Record quote_person gefuellt, quote_role
* aber leer hat. Fix: Element soft-loeschen (+ Inline-Zaehler), quote_role
* am Record setzen.
*
* php migrations/fix_colombia_quote.php # INSPECT (default)
* php migrations/fix_colombia_quote.php --apply # schreibt
*/
declare(strict_types=1);
$apply = in_array('--apply', $argv, true);
$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]
);
$role = 'Director of the Engineering Department of Cosmos Sistemas Integrado';
echo $apply ? "APPLY\n\n" : "INSPECT - nichts wird geschrieben (--apply zum Schreiben)\n\n";
$story = $pdo->query(
"SELECT uid, title, quote_person, quote_role, content_elements FROM tx_vitec_domain_model_usecase
WHERE deleted = 0 AND slug = 'the-national-police-of-colombia'"
)->fetch(PDO::FETCH_ASSOC);
if (!$story) {
echo "ABBRUCH: Story the-national-police-of-colombia nicht gefunden.\n";
exit(1);
}
printf(
"Story #%d \"%s\": quote_person=\"%s\" quote_role=\"%s\" content_elements=%d\n",
$story['uid'], $story['title'], $story['quote_person'], $story['quote_role'], $story['content_elements']
);
$element = $pdo->query(
'SELECT uid, pid, CType, deleted, tx_vitec_usecase_content, bodytext, header FROM tt_content WHERE uid = 12037'
)->fetch(PDO::FETCH_ASSOC);
if (!$element) {
echo "Element #12037 existiert nicht.\n";
exit(1);
}
printf(
"Element #12037: CType=%s deleted=%d usecase=%d text=\"%s\"\n",
$element['CType'], $element['deleted'], $element['tx_vitec_usecase_content'],
mb_substr(trim(strip_tags((string)$element['bodytext'] . ' ' . (string)$element['header'])), 0, 90)
);
if ((int)$element['tx_vitec_usecase_content'] !== (int)$story['uid']) {
echo "ABBRUCH: Element #12037 haengt nicht an dieser Story.\n";
exit(1);
}
if ((int)$element['deleted'] === 1) {
echo "Element ist bereits geloescht.\n";
}
if (!$apply) {
echo "\nWuerde: Element #12037 loeschen, content_elements neu zaehlen, quote_role setzen auf:\n \"$role\"\n";
exit(0);
}
if ((int)$element['deleted'] === 0) {
$pdo->prepare('UPDATE tt_content SET deleted = 1, tstamp = ? WHERE uid = 12037')->execute([time()]);
echo "Element #12037 geloescht.\n";
}
$count = (int)$pdo->query(
'SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND tx_vitec_usecase_content = ' . (int)$story['uid']
)->fetchColumn();
$pdo->prepare('UPDATE tx_vitec_domain_model_usecase SET content_elements = ?, quote_role = ?, tstamp = ? WHERE uid = ?')
->execute([$count, $role, time(), (int)$story['uid']]);
printf("Record aktualisiert: quote_role gesetzt, content_elements = %d.\n", $count);
echo "Danach: vendor/bin/typo3 cache:flush\n";
+188
View File
@@ -0,0 +1,188 @@
<?php
declare(strict_types=1);
/**
* Fix dead old-site links inside news bodytexts (SEO follow-up 2026-11-10).
*
* The imported Datapath articles and the 17 new SEO articles carry hrefs
* from the OLD datapath.com information architecture: /datapath-products/…,
* old /markets/… market names and /video-wall-solutions/…. None of these
* paths exist on the relaunch site (404 on every page view).
*
* 1. Rewrites every known old href inside tx_news_domain_model_news
* bodytext (and teaser, defensively) onto the matching new product
* detail page (/product/<slug>), product category page
* (/products/<slug>), market or solution page.
* 2. Queues a 301 sys_redirect for every old path (with and without
* trailing slash) so external backlinks land correctly once the
* datapath.com domains point at this site.
*
* Dry run: php migrations/fix_datapath_links_20261110.php
* Write: php migrations/fix_datapath_links_20261110.php --apply
*/
error_reporting(E_ALL);
ini_set('display_errors', '1');
$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("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]
);
echo $apply ? "== APPLY ==\n" : "== DRY RUN (add --apply to write) ==\n";
/* Old path (no trailing slash) => new target path. */
$mapping = [
// Aetria
'/datapath-products/aetria-control-room-solutions' => '/products/aetria',
'/datapath-products/aetria-control-room-solutions/aetria-overview' => '/products/aetria',
'/datapath-products/aetria-control-room-solutions/aetria-network-manager' => '/product/aetria-network-manager',
'/datapath-products/aetria-control-room-solutions/aetria-touch' => '/product/aetria-touch',
'/datapath-products/aetria-control-room-solutions/aetria-wallcontrol' => '/product/aetria-wallcontrol',
'/datapath-products/aetria-control-room-solutions/operator-workstations' => '/products/operator-workstations',
// AVoIP / KVM
'/datapath-products/arqa-avoip-with-kvm' => '/product/arqa',
'/datapath-products/video-distribution-kvm' => '/products/av-over-ip-and-kvm',
'/datapath-products/video-distribution-kvm/aligo-4k-avoip-with-kvm' => '/products/aligo',
'/datapath-products/video-distribution-kvm/aligo-qtx100-quad-transmitter' => '/product/qtx100',
'/datapath-products/video-distribution-kvm/aligo-rx100-receiver' => '/product/rx100',
'/datapath-products/video-distribution-kvm/aligo-tx100-transmitter' => '/product/tx100',
// Capture & graphics
'/datapath-products/graphics-products' => '/products/capture-and-graphics',
'/datapath-products/graphics-products/image2k' => '/product/image-2k',
'/datapath-products/graphics-products/image4k' => '/product/image-4k',
'/datapath-products/sqx-ip-capture' => '/products/activesqx',
'/datapath-products/vision-range' => '/products/vision-video-capture-cards',
'/datapath-products/video-capture-cards/capture-card-features' => '/products/vision-video-capture-cards',
'/datapath-products/video-capture-cards/vision-range/visionrgb-e1s' => '/product/visionrgb-e1s',
'/datapath-products/video-capture-cards/vision-range/visionsd41s' => '/product/visionsd4-1s',
'/datapath-products/video-capture-cards/vision-range/visionsdi2' => '/products/vision-video-capture-cards',
'/datapath-products/video-capture-cards/visionav-range' => '/product/visionav',
'/datapath-products/video-capture-cards/visionav-range/visionhd4' => '/product/visionhd4',
'/datapath-products/video-capture-cards/visionsc-range' => '/products/vision-video-capture-cards',
'/datapath-products/video-capture-cards/visionsc-range/visionsc-dp2' => '/product/visionsc-dp2',
'/datapath-products/video-capture-cards/visionsc-range/visionsc-hd4' => '/product/visionsc-hd4',
'/datapath-products/video-capture-cards/visionsc-range/visionsc-uhd2' => '/product/visionsc-uhd2',
// Multi-display / Fx4 / Hx4
'/datapath-products/multi-display-controllers/datapath-fx4' => '/product/fx4',
'/datapath-products/multi-display-controllers/datapath-fx4-sdi' => '/product/fx4-sdi',
'/datapath-products/multi-display-controllers/datapath-hx4' => '/product/hx4',
'/datapath-products/multi-display-controllers/multi-display-overview' => '/products/x-series-multi-display-processors',
'/datapath-products/multi-display-controllers/video-wall-designer-software' => '/products/video-wall-management-software',
// Video walls / VSN / WallControl
'/datapath-products/video-wall-control/industrial-wall-controllers' => '/products/vsn-series-video-wall-controllers',
'/datapath-products/video-wall-control/video-wall-controllers' => '/products/vsn-series-video-wall-controllers',
'/datapath-products/video-wall-control/vsn-11-series' => '/products/vsn-series-video-wall-controllers',
'/datapath-products/video-wall-control/vsn-9-series' => '/products/vsn-series-video-wall-controllers',
'/datapath-products/video-wall-controllers' => '/products/video-wall-controllers-and-management',
'/datapath-products/video-wall-controllers/datapath-fx4' => '/product/fx4',
'/datapath-products/video-wall-controllers/video-wall-software/wallcontrol-10-software' => '/product/wall-control-10',
'/datapath-products/video-wall-controllers/vsn-series' => '/products/vsn-series-video-wall-controllers',
'/datapath-products/video-wall-management-software' => '/products/video-wall-management-software',
'/datapath-products/video-wall-software/wallcontrol-10-software' => '/product/wall-control-10',
// Accessories (no equivalent records - portfolio page)
'/datapath-products/components-accessories/components-accessories-overview/displayport-accessories' => '/products',
'/datapath-products/components-accessories/pcie-backplanes/express11-g3' => '/products',
// Old market / solution names
'/markets/airport-and-aviation' => '/markets/transport/airports',
'/markets/corporate' => '/markets/corporation',
'/markets/emergency-operation-center' => '/markets/public-safety/emergency-operation-centres',
'/markets/events-and-experiences' => '/markets/sports-and-entertainment/events-and-experiences',
'/markets/military-control-room' => '/markets/defense-and-security',
'/markets/network-operation-center' => '/solutions/control-room-command-centre-solutions/control-room-platforms-soc-noc-command-centres',
'/markets/retail-and-hospitality' => '/markets/hospitality',
'/markets/security-and-surveillance' => '/solutions/security-surveillance-solutions',
'/markets/traffic-management-control-centers' => '/markets/government/traffic-and-smart-mobility',
'/markets/utilities' => '/markets/critical-national-infrastructure/energy-and-utilities',
'/video-wall-solutions' => '/solutions/video-wall-data-visualisation',
'/video-wall-solutions/retail-events-and-experiences' => '/markets/sports-and-entertainment/events-and-experiences',
'/video-wall-solutions/video-wall-for-security-suites' => '/solutions/security-surveillance-solutions',
];
/* ------------------------------------------------------------------ */
/* Sanity: every target must exist (page slug, or product slug). */
/* ------------------------------------------------------------------ */
$pageBySlug = $pdo->prepare("SELECT uid FROM pages WHERE deleted = 0 AND hidden = 0 AND slug = ? LIMIT 1");
$productBySlug = $pdo->prepare("SELECT uid FROM tx_vitec_domain_model_product WHERE deleted = 0 AND hidden = 0 AND hideonwebsite = 0 AND slug = ? LIMIT 1");
$missing = 0;
foreach (array_unique(array_values($mapping)) as $target) {
if (str_starts_with($target, '/product/')) {
$productBySlug->execute([substr($target, strlen('/product/'))]);
if ((int)$productBySlug->fetchColumn() > 0) { continue; }
} else {
$pageBySlug->execute([$target]);
if ((int)$pageBySlug->fetchColumn() > 0) { continue; }
}
echo " !! Ziel fehlt: $target\n";
$missing++;
}
if ($missing > 0) {
echo "$missing Ziele nicht gefunden - Mapping pruefen.\n";
if ($apply) { exit("Abbruch.\n"); }
}
/* ------------------------------------------------------------------ */
/* 1. Rewrite hrefs in news bodytext/teaser */
/* ------------------------------------------------------------------ */
echo "1. Links in News-Texten ersetzen:\n";
$findNews = $pdo->prepare("SELECT COUNT(*) FROM tx_news_domain_model_news WHERE deleted = 0 AND (bodytext LIKE ? OR teaser LIKE ?)");
$fixNews = $pdo->prepare(
"UPDATE tx_news_domain_model_news
SET bodytext = REPLACE(bodytext, ?, ?), teaser = REPLACE(teaser, ?, ?), tstamp = UNIX_TIMESTAMP()
WHERE deleted = 0 AND (bodytext LIKE ? OR teaser LIKE ?)"
);
$totalHits = 0;
foreach ($mapping as $old => $new) {
// Both href variants: with and without trailing slash; plus the one
// broken absolute form (http://datapath-products/...).
$variants = ['href="' . $old . '/"', 'href="' . $old . '"', 'href="http:/' . $old . '/"'];
foreach ($variants as $needle) {
$like = '%' . $needle . '%';
$findNews->execute([$like, $like]);
$count = (int)$findNews->fetchColumn();
if ($count === 0) { continue; }
$totalHits += $count;
echo " $needle -> href=\"$new\" ($count Record(s))\n";
if ($apply) {
$fixNews->execute([$needle, 'href="' . $new . '"', $needle, 'href="' . $new . '"', $like, $like]);
}
}
}
echo " Summe Record-Treffer: $totalHits\n";
/* ------------------------------------------------------------------ */
/* 2. 301 redirects for the old paths */
/* ------------------------------------------------------------------ */
echo "2. Redirects anlegen:\n";
$hasRedirect = $pdo->prepare("SELECT COUNT(*) FROM sys_redirect WHERE deleted = 0 AND source_path = ?");
$insertRedirect = $pdo->prepare(
"INSERT INTO sys_redirect (pid, createdon, updatedon, source_host, source_path, target, target_statuscode)
VALUES (0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), '*', ?, ?, 301)"
);
$newRedirects = 0;
foreach ($mapping as $old => $new) {
foreach ([$old, $old . '/'] as $source) {
$hasRedirect->execute([$source]);
if ((int)$hasRedirect->fetchColumn() > 0) { continue; }
$newRedirects++;
if ($apply) {
$insertRedirect->execute([$source, $new]);
}
}
}
echo " $newRedirects neue Redirects (mit + ohne Slash; bestehende unangetastet)\n";
echo $apply ? "FERTIG. Cache flushen.\n" : "DRY RUN beendet.\n";
+81
View File
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
/**
* Ticket 0014-FE Punkt 2: doppelt kodierte HTML-Entities in tt_content
* (z. B. "VITEC&amp;apos;s" im JSON). Ursache: die Produkt-Importer
* escapten mit ENT_QUOTES|ENT_HTML5 (' -> &apos;), die RTE-Persistenz
* escapte das & erneut. Die Importer schreiben seit 2026-10-28 literal
* (ENT_NOQUOTES); dieses Skript repariert den Bestand um EINE
* Escaping-Stufe: &amp;apos; -> &apos; usw.
*
* Plain PDO (Muster migrations/check_bg.php).
*
* Usage: php migrations/fix_double_entities.php (Scan/Report)
* php migrations/fix_double_entities.php --apply
*/
error_reporting(E_ALL);
ini_set('display_errors', '1');
// Stufe 1: doppelt kodierte Entities um eine Stufe reduzieren.
// Stufe 2 (Befund 2026-10-28: Scan fand KEINE Doubles in der DB - die DB
// traegt &apos; EINFACH kodiert, erst die Render-Pipeline escapet das &
// erneut): Apostroph-Entities literal machen, dann kann nichts mehr doppeln.
const PATTERNS = ['&amp;apos;', '&amp;quot;', '&amp;#039;', '&amp;amp;', '&amp;nbsp;', '&apos;', '&#039;'];
const REPLACEMENTS = ['&apos;', '&quot;', '&#039;', '&amp;', '&nbsp;', "'", "'"];
$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]
);
echo ($apply ? 'APPLY' : 'SCAN') . "\n";
$like = implode(' OR ', array_map(static fn(string $p): string => "bodytext LIKE '%" . $p . "%'", PATTERNS));
$rows = $pdo->query(
"SELECT c.uid, c.pid, p.slug, c.CType, c.bodytext FROM tt_content c
LEFT JOIN pages p ON p.uid = c.pid
WHERE c.deleted = 0 AND ($like) ORDER BY p.slug, c.uid"
)->fetchAll();
$fixed = 0;
foreach ($rows as $row) {
$new = str_replace(PATTERNS, REPLACEMENTS, (string)$row['bodytext']);
$hits = [];
foreach (PATTERNS as $pattern) {
$n = substr_count((string)$row['bodytext'], $pattern);
if ($n > 0) {
$hits[] = $pattern . 'x' . $n;
}
}
printf("#%-6d %-28s %-45s %s\n", $row['uid'], $row['CType'], (string)$row['slug'], implode(' ', $hits));
if ($apply) {
$pdo->prepare('UPDATE tt_content SET bodytext = ?, tstamp = UNIX_TIMESTAMP() WHERE uid = ?')
->execute([$new, (int)$row['uid']]);
$fixed++;
}
}
echo "\n" . count($rows) . " Element(e) betroffen" . ($apply ? ", $fixed repariert. Danach: vendor/bin/typo3 cache:flush" : ". Mit --apply reparieren.") . "\n";
if (!$apply && $rows !== []) {
echo "HINWEIS: Ersetzt wird genau EINE Escaping-Stufe (&amp;apos; -> &apos; usw.).\n";
}
@@ -0,0 +1,196 @@
<?php
declare(strict_types=1);
/**
* Footer columns (pid 19, html elements #132-#135, #119): align the old-site
* link texts with the relaunch structure.
*
* The footer HTML still carries the old website's wording ("Venues",
* "Military", "VITEC OG", "PCIe Encoder" …) although every href points at
* the new pages since seo_fixes_20261109. This migration, per internal link:
*
* 1. sets the link text (and title attribute) to the target page's
* navigation title (nav_title, falling back to title);
* 2. drops leaf entries that only duplicate an earlier entry of the same
* column (several old items collapsed onto one new page);
* 3. drops the orphaned catch-all entries that point at the bare /markets
* or /products overview ("Healthcare", "Telemetry" - no equivalent page);
* 4. removes list containers left empty by 2./3.
*
* External links (LinkedIn, YouTube, HelpDesk) and t3:// links stay as they
* are. Headings (<h4>) stay.
*
* Dry run: php migrations/fix_footer_link_texts_20261110.php
* Write: php migrations/fix_footer_link_texts_20261110.php --apply
*/
error_reporting(E_ALL);
ini_set('display_errors', '1');
$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("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]
);
echo $apply ? "== APPLY ==\n" : "== DRY RUN (add --apply to write) ==\n";
$pageTitle = $pdo->prepare("SELECT title, nav_title FROM pages WHERE deleted = 0 AND slug = ? LIMIT 1");
$resolveTitle = static function (string $href) use ($pageTitle): ?string {
$pageTitle->execute([$href]);
$row = $pageTitle->fetch();
if (!$row) {
return null;
}
$t = trim((string)($row['nav_title'] ?? '')) ?: trim((string)($row['title'] ?? ''));
return $t !== '' ? $t : null;
};
$dropOverviewHrefs = ['/markets', '/products'];
// Product detail pages are records, not pages.
$productTitle = $pdo->prepare("SELECT title FROM tx_vitec_domain_model_product WHERE deleted = 0 AND hidden = 0 AND slug = ? LIMIT 1");
$resolveAnyTitle = static function (string $href) use ($resolveTitle, $productTitle): ?string {
if (str_starts_with($href, '/product/')) {
$productTitle->execute([substr($href, strlen('/product/'))]);
$t = trim((string)($productTitle->fetchColumn() ?: ''));
return $t !== '' ? $t : null;
}
return $resolveTitle($href);
};
/*
* Editorial overrides where the mechanical "target page title" is the wrong
* wording or the earlier redirect mapping landed on a too-generic page.
* Keyed by element uid; matched by old link text (decoded) or by href.
*/
$overrides = [
133 => [
// Solutions column: "Digital Signage" is a solution, not the product category
['matchHref' => '/products/iptv-and-digital-signage', 'href' => '/solutions/digital-signage-content-delivery'],
],
134 => [
// Fx4 is a product of its own, not the whole video-wall category
['matchText' => 'Fx4 Series', 'href' => '/product/fx4', 'text' => 'Fx4'],
// the gateways/transcoding parent has its own category page
['matchText' => 'IP/RF Gateways & Transcoders', 'href' => '/products/gateways-and-transcoding'],
],
135 => [
// sits directly below the external "Log in to HelpDesk" link
['matchHref' => '/support/login-to-helpdesk', 'text' => 'HelpDesk Access'],
],
];
$elements = $pdo->query(
"SELECT uid, bodytext FROM tt_content WHERE pid = 19 AND deleted = 0 AND CType = 'html' ORDER BY colPos, sorting"
)->fetchAll();
$update = $pdo->prepare("UPDATE tt_content SET bodytext = ?, tstamp = UNIX_TIMESTAMP() WHERE uid = ?");
foreach ($elements as $element) {
$uid = (int)$element['uid'];
$html = (string)$element['bodytext'];
$log = [];
// --- 1. rename internal links -----------------------------------------
$seen = [];
$elementOverrides = $overrides[$uid] ?? [];
$html = preg_replace_callback(
'#<a\b([^>]*?)href="(/[^"]*)"([^>]*)>([^<]*)</a>#s',
static function (array $m) use ($resolveAnyTitle, &$log, &$seen, $elementOverrides): string {
$href = $m[2];
$oldText = trim(strip_tags($m[4]));
$oldDecoded = html_entity_decode($oldText, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$forcedText = null;
foreach ($elementOverrides as $o) {
$hit = (isset($o['matchText']) && $o['matchText'] === $oldDecoded)
|| (isset($o['matchHref']) && $o['matchHref'] === $href);
if ($hit) {
if (!empty($o['href']) && $o['href'] !== $href) {
$log[] = sprintf(' > [%s] Ziel %s -> %s', $oldText, $href, $o['href']);
$href = $o['href'];
}
$forcedText = $o['text'] ?? null;
break;
}
}
$newTitle = $forcedText ?? $resolveAnyTitle($href);
$seen[$href] = ($seen[$href] ?? 0) + 1;
if ($newTitle === null) {
$log[] = sprintf(' ? [%s] -> %s (keine Seite, unveraendert)', $oldText, $href);
return $m[0];
}
$escaped = htmlspecialchars($newTitle, ENT_QUOTES | ENT_HTML5, 'UTF-8', false);
$attrs = $m[1] . 'href="' . $href . '"' . $m[3];
if (preg_match('/\stitle="[^"]*"/', $attrs)) {
$attrs = preg_replace('/\stitle="[^"]*"/', ' title="' . $escaped . '"', $attrs, 1);
} else {
$attrs .= ' title="' . $escaped . '"';
}
if ($oldDecoded !== $newTitle) {
$log[] = sprintf(' ~ [%s] -> [%s] (%s)', $oldText, $newTitle, $href);
}
return '<a' . $attrs . '>' . $escaped . '</a>';
},
$html
) ?? $html;
// --- 2.+3. drop duplicate leaf items and overview catch-alls -------------
// Parent items (a link followed by a nested <ul>) always stay; a leaf
// pointing at the same page as a parent or an earlier leaf is a duplicate.
$kept = [];
// Link texts are plain ([^<]*), so neither pattern can span from a parent
// link into its nested list.
preg_match_all('#<li>\s*<a\b[^>]*href="(/[^"]*)"[^>]*>[^<]*</a>\s*<ul>#s', $html, $parents);
foreach ($parents[1] as $parentHref) {
$kept[$parentHref] = true;
}
$html = preg_replace_callback(
'#<li>\s*<a\b[^>]*href="(/[^"]*)"[^>]*>([^<]*)</a>\s*</li>\s*#s',
static function (array $m) use (&$kept, &$log, $dropOverviewHrefs): string {
$href = $m[1];
$text = trim(strip_tags($m[2]));
if (in_array($href, $dropOverviewHrefs, true)) {
$log[] = sprintf(' - [%s] -> %s (Uebersichts-Platzhalter entfernt)', $text, $href);
return '';
}
if (isset($kept[$href])) {
$log[] = sprintf(' - [%s] -> %s (Dublette entfernt)', $text, $href);
return '';
}
$kept[$href] = true;
return $m[0];
},
$html
) ?? $html;
// --- 4. empty lists ------------------------------------------------------
$before = '';
while ($before !== $html) {
$before = $html;
$html = preg_replace('#<ul>\s*</ul>#s', '', $html) ?? $html;
}
$changed = $html !== (string)$element['bodytext'];
echo "\n##### tt_content #$uid " . ($changed ? '(AENDERUNGEN)' : '(unveraendert)') . "\n";
foreach ($log as $line) {
echo $line . "\n";
}
if ($changed && $apply) {
$update->execute([$html, $uid]);
echo " gespeichert\n";
}
}
echo $apply ? "\nFERTIG. Cache flushen + SSR purgen (Direkt-SQL loest keinen Auto-Purge aus).\n" : "\nDRY RUN beendet.\n";
+88
View File
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
/**
* Ticket 0014-FE, Folge-Befund: pid 569 (/products/graphics-and-multi-display)
* nach dem IQS4-Kategorie-Re-Import. Container und ihre Kinder sind intakt,
* aber: 8 Duplikat-Karten top-level in colPos0 (11853-11860) und die Grids
* stehen falsch relativ zu ihren Intros (11840 vor 11629, 11841 vor 11632).
*
* Reparatur: Duplikate soft-loeschen, colPos0 in Sektions-Folge neu sortieren.
*
* Usage: php migrations/fix_graphics_grid.php (inspect)
* php migrations/fix_graphics_grid.php --apply
*/
error_reporting(E_ALL);
ini_set('display_errors', '1');
const PID = 569;
const DUPLICATES = [11853, 11854, 11855, 11856, 11857, 11858, 11859, 11860];
// Intro -> Grid(s): Why VITEC -> 11838 (3 Benefit-Karten) + 11839 (1),
// Finder-Intro + Comparison-Text, Products-Intro -> 11840 (IGC + IQS4),
// Solutions-Intro -> 11841 (2 Solution-Karten)
const ORDER = [11620, 11621, 11622, 11838, 11839, 11627, 11628, 11629, 11840, 11632, 11841];
$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]
);
echo ($apply ? 'APPLY' : 'INSPECT') . " · pid " . PID . "\n\n";
$rows = $pdo->query('SELECT uid, CType, colPos, LEFT(header,40) h FROM tt_content WHERE deleted = 0 AND pid = ' . PID)->fetchAll();
$byUid = [];
foreach ($rows as $row) {
$byUid[(int)$row['uid']] = $row;
}
foreach (array_merge(ORDER, DUPLICATES) as $uid) {
if (!isset($byUid[$uid])) {
exit("ABBRUCH: uid $uid fehlt auf der Seite - erst klaeren.\n");
}
}
$tail = [];
foreach ($pdo->query('SELECT uid, CType, LEFT(header,40) h FROM tt_content WHERE deleted = 0 AND pid = ' . PID . ' AND colPos = 0 ORDER BY sorting, uid') as $row) {
$uid = (int)$row['uid'];
if (!in_array($uid, ORDER, true) && !in_array($uid, DUPLICATES, true)) {
$tail[] = $uid;
echo "tail: #$uid {$row['CType']} \"{$row['h']}\"\n";
}
}
echo "\n1) " . count(DUPLICATES) . " Duplikat-Karten soft-loeschen: " . implode(',', DUPLICATES) . "\n";
echo "2) colPos0 neu sortieren:\n";
$sequence = array_merge(ORDER, $tail);
foreach ($sequence as $i => $uid) {
$r = $byUid[$uid];
printf(" %3d. #%-6d %-22s %s\n", $i + 1, $uid, $r['CType'], $r['h']);
}
if (!$apply) {
echo "\nInspect only - mit --apply schreiben.\n";
exit(0);
}
$pdo->exec('UPDATE tt_content SET deleted = 1, tstamp = UNIX_TIMESTAMP() WHERE uid IN (' . implode(',', DUPLICATES) . ')');
$sorting = 0;
$stmt = $pdo->prepare('UPDATE tt_content SET sorting = ?, tstamp = UNIX_TIMESTAMP() WHERE uid = ?');
foreach ($sequence as $uid) {
$sorting += 256;
$stmt->execute([$sorting, $uid]);
}
echo "\nGeschrieben. Danach: vendor/bin/typo3 cache:flush\n";
+111
View File
@@ -0,0 +1,111 @@
<?php
/**
* fix_overview_hero_refs.php (2026-11-03)
*
* Repariert die Hero-Platzhalter-Referenzen der 20 Produkt-Übersichtsseiten:
* vitec:build-product-overviews --apply hatte die sys_file_reference-Zeilen
* mit fieldname 'hero_bgimage' angelegt - der Content-Block vitec/herosection
* hat aber prefixFields, die echte Spalte/der echte Fieldname ist
* 'vitec_hero_bgimage' (das JSON streift den Praefix nur ab). Ergebnis:
* Referenzen existieren, rendern aber nicht.
*
* Dieses Skript biegt die vorhandenen Referenzen um (fieldname-Update) und
* setzt das Zaehlfeld tt_content.vitec_hero_bgimage - KEIN Neuanlegen.
*
* php migrations/fix_overview_hero_refs.php # INSPECT (default)
* php migrations/fix_overview_hero_refs.php --apply # schreibt
*/
declare(strict_types=1);
$apply = in_array('--apply', $argv, true);
$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]
);
echo $apply ? "APPLY\n\n" : "INSPECT - nichts wird geschrieben (--apply zum Schreiben)\n\n";
// Zielspalte muss existieren, sonst stimmt die Diagnose nicht
$col = (int)$pdo->query(
"SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'tt_content' AND COLUMN_NAME = 'vitec_hero_bgimage'"
)->fetchColumn();
echo 'tt_content.vitec_hero_bgimage vorhanden: ' . ($col > 0 ? 'ja' : 'NEIN') . "\n";
if ($col === 0) {
echo "ABBRUCH: Zielspalte fehlt - Feldname im Content Block pruefen.\n";
exit(1);
}
// die falsch benannten Referenzen, nur an vitec_herosection-Elementen
$refs = $pdo->query(
"SELECT r.uid AS ref_uid, r.uid_foreign AS content_uid, r.uid_local AS file_uid,
c.pid, c.CType, f.identifier
FROM sys_file_reference r
JOIN tt_content c ON c.uid = r.uid_foreign
LEFT JOIN sys_file f ON f.uid = r.uid_local
WHERE r.deleted = 0 AND r.tablenames = 'tt_content' AND r.fieldname = 'hero_bgimage'
AND c.deleted = 0 AND c.CType = 'vitec_herosection'
ORDER BY c.pid"
)->fetchAll(PDO::FETCH_ASSOC);
echo count($refs) . " Referenz(en) mit fieldname 'hero_bgimage' an vitec_herosection:\n";
foreach ($refs as $ref) {
printf(
" ref #%d -> content #%d (pid %d) file %s\n",
$ref['ref_uid'],
$ref['content_uid'],
$ref['pid'],
(string)$ref['identifier']
);
}
// Sicherheitsnetz: Elemente, die BEIDE Varianten tragen (sollte es nicht geben)
$both = $pdo->query(
"SELECT uid_foreign FROM sys_file_reference
WHERE deleted = 0 AND tablenames = 'tt_content' AND fieldname = 'vitec_hero_bgimage'
AND uid_foreign IN (SELECT uid_foreign FROM sys_file_reference
WHERE deleted = 0 AND tablenames = 'tt_content' AND fieldname = 'hero_bgimage')"
)->fetchAll(PDO::FETCH_COLUMN);
if ($both !== []) {
echo "ACHTUNG: Elemente mit korrekter UND falscher Referenz: " . implode(', ', $both) . " - die falschen wuerden geloescht statt umbenannt.\n";
}
if (!$apply) {
echo "\nINSPECT-Ende.\n";
exit(0);
}
$renamed = 0;
$deleted = 0;
foreach ($refs as $ref) {
if (in_array((string)$ref['content_uid'], array_map('strval', $both), true)) {
$pdo->prepare('UPDATE sys_file_reference SET deleted = 1, tstamp = ? WHERE uid = ?')
->execute([time(), (int)$ref['ref_uid']]);
$deleted++;
continue;
}
$pdo->prepare("UPDATE sys_file_reference SET fieldname = 'vitec_hero_bgimage', tstamp = ? WHERE uid = ?")
->execute([time(), (int)$ref['ref_uid']]);
$renamed++;
}
// Zaehlfeld pro betroffenem Element nachziehen
$contentUids = array_values(array_unique(array_map(static fn(array $r): int => (int)$r['content_uid'], $refs)));
foreach ($contentUids as $uid) {
$count = (int)$pdo->query(
"SELECT COUNT(*) FROM sys_file_reference
WHERE deleted = 0 AND tablenames = 'tt_content' AND fieldname = 'vitec_hero_bgimage' AND uid_foreign = " . $uid
)->fetchColumn();
$pdo->prepare('UPDATE tt_content SET vitec_hero_bgimage = ?, tstamp = ? WHERE uid = ?')
->execute([$count, time(), $uid]);
}
printf("\nFertig: %d Referenz(en) umbenannt, %d geloescht, %d Zaehlfeld(er) gesetzt.\n", $renamed, $deleted, count($contentUids));
echo "Danach: vendor/bin/typo3 cache:flush\n";
+117
View File
@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
/**
* Ticket 0014-FE Punkt 1: /products (pid 447) ohne Grid-Container im Payload.
*
* Befund (check_grid_447.php, 2026-10-28): der Portfolio-Re-Run (IQS4) hat
* die 15 vitec_cols_33_33_33-Container VERSTECKT (hidden=1, uid 11794-11808;
* ihre 38 Original-Karten stecken intakt und bebildert darin), 38 neue
* Duplikat-Karten top-level in colPos0 angelegt (11861-11898) und die
* Sortierung neu verteilt - die versteckten Container stehen dadurch an
* falschen Positionen.
*
* Reparatur: Container einblenden, Duplikate soft-loeschen, colPos0 in der
* P5-Reihenfolge (Intro -> Grid(s) -> naechstes Intro, danach Banner/FAQ in
* bisheriger Folge) mit Stride 256 neu sortieren.
*
* Usage: php migrations/fix_products_grid.php (inspect)
* php migrations/fix_products_grid.php --apply
*/
error_reporting(E_ALL);
ini_set('display_errors', '1');
const PID = 447;
const CONTAINERS = [11794, 11795, 11796, 11797, 11798, 11799, 11800, 11801, 11802, 11803, 11804, 11805, 11806, 11807, 11808];
const DUPLICATES = [
11861, 11862, 11863, 11864, 11865, 11866, 11867, 11868, 11869, 11870,
11871, 11872, 11873, 11874, 11875, 11876, 11877, 11878, 11879, 11880,
11881, 11882, 11883, 11884, 11885, 11886, 11887, 11888, 11889, 11890,
11891, 11892, 11893, 11894, 11895, 11896, 11897, 11898,
];
// Zielreihenfolge colPos0: Sektions-Intro, danach seine Grids (Zuordnung
// ueber die Container-Kinder verifiziert, check_grid_447-Ausgabe 2026-10-28)
const ORDER = [
11741, 11808, 11807, // IPTV & Digital Signage (5 Karten -> 2 Grids)
11747, 11806, 11805, 11804, // Video Encoding & Streaming (7 -> 3)
11755, 11803, // Gateways & Transcoding (3 -> 1)
11759, 11802, 11801, // Control Room Platforms (6 -> 2)
11766, 11800, // AV over IP & KVM (2 -> 1)
11769, 11799, 11798, // Video Wall & Display Processing (4 -> 2)
11774, 11797, // Graphics & Multi-Display (2 -> 1)
11777, 11796, 11795, // Video Capture (6 -> 2)
11784, 11794, // Infrastructure & Accessories (3 -> 1)
];
$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]
);
echo ($apply ? 'APPLY' : 'INSPECT') . " · pid " . PID . "\n\n";
// Sanity: alle ORDER-/CONTAINER-uids existieren undeleted auf der Seite
$rows = $pdo->query('SELECT uid, CType, colPos, hidden, LEFT(header,40) h FROM tt_content WHERE deleted = 0 AND pid = ' . PID)->fetchAll();
$byUid = [];
foreach ($rows as $row) {
$byUid[(int)$row['uid']] = $row;
}
foreach (array_merge(ORDER, DUPLICATES) as $uid) {
if (!isset($byUid[$uid])) {
exit("ABBRUCH: uid $uid fehlt auf der Seite (geloescht/verschoben?) - erst klaeren.\n");
}
}
// Rest von colPos0 (Banner, FAQ, ...) hinten anhaengen, bisherige Folge
$tail = [];
foreach ($pdo->query('SELECT uid, CType, LEFT(header,40) h FROM tt_content WHERE deleted = 0 AND pid = ' . PID . ' AND colPos = 0 ORDER BY sorting, uid') as $row) {
$uid = (int)$row['uid'];
if (!in_array($uid, ORDER, true) && !in_array($uid, DUPLICATES, true)) {
$tail[] = $uid;
echo "tail: #$uid {$row['CType']} \"{$row['h']}\"\n";
}
}
echo "\n1) " . count(CONTAINERS) . " Container einblenden (hidden=0): " . implode(',', CONTAINERS) . "\n";
echo "2) " . count(DUPLICATES) . " Duplikat-Karten soft-loeschen: " . implode(',', DUPLICATES) . "\n";
echo "3) colPos0 neu sortieren: " . count(ORDER) . " Elemente in P5-Folge + " . count($tail) . " Tail-Element(e)\n";
$sequence = array_merge(ORDER, $tail);
foreach ($sequence as $i => $uid) {
$r = $byUid[$uid];
printf(" %3d. #%-6d %-22s %s\n", $i + 1, $uid, $r['CType'], $r['h']);
}
if (!$apply) {
echo "\nInspect only - mit --apply schreiben.\n";
exit(0);
}
$pdo->exec('UPDATE tt_content SET hidden = 0, tstamp = UNIX_TIMESTAMP() WHERE uid IN (' . implode(',', CONTAINERS) . ')');
$pdo->exec('UPDATE tt_content SET deleted = 1, tstamp = UNIX_TIMESTAMP() WHERE uid IN (' . implode(',', DUPLICATES) . ')');
$sorting = 0;
$stmt = $pdo->prepare('UPDATE tt_content SET sorting = ?, tstamp = UNIX_TIMESTAMP() WHERE uid = ?');
foreach ($sequence as $uid) {
$sorting += 256;
$stmt->execute([$sorting, $uid]);
}
echo "\nGeschrieben. Danach: vendor/bin/typo3 cache:flush\n";
+141
View File
@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
/**
* Ticket 0017-FE Punkt 1 + 2 (Altbestand vitec.com-Import, gegen das
* Original auf www.vitec.com verifiziert, 2026-10-29):
*
* 1) CDC (centers-for-disease-control-cdc): die Background-vitec_columns
* (#355) traegt links den Text des Crowne Plaza Muscat OCEC und rechts
* dessen Logo. Das Original hat dort CDC-Copy. -> linkes Text-Item auf
* die Original-Absaetze setzen, rechtes Logo-Item soft-loeschen
* (CDC-Bild kann redaktionell nachgelegt werden).
*
* 2) "Key Feature"-textmedia mit identischem Hotel-/Signage-Text in 5
* Stories. Abgleich mit dem Original:
* - parramatta-eels #218: Text ist DORT ECHT -> behalten
* - belgrade #253, isr #266, meydan #206: Original hat KEIN
* Key Feature -> Element soft-loeschen
* - proteo #234: Original hat MIL-spec-Bullets -> bodytext ersetzen
*
* Usage: php migrations/fix_story_content_errors.php (inspect)
* php migrations/fix_story_content_errors.php --apply
*/
error_reporting(E_ALL);
ini_set('display_errors', '1');
const CDC_COLUMNS_UID = 355;
const DELETE_ELEMENTS = [253, 266, 206];
const PROTEO_UID = 234;
const KEEP_CHECK = 218;
const CDC_BACKGROUND = '<p>When it comes mitigating major threats to public health, it is imperative for organizations like the Centers for Disease Control (CDC) to keep their finger on the pulse of global developments that require a coordinated national or global response.</p>'
. '<p>In addition to being ardent consumers of news and information, doctors, scientists, and other healthcare experts at the CDC produce a tremendous amount of original content for both internal and external consumption. This makes the agency one of the most video-centric departments in the federal government.</p>';
const PROTEO_KEYFEATURE = '<ul>'
. '<li>MIL-spec ruggedization HEVC &amp; H.264 Encoder</li>'
. '<li>Low latency HD/SD Streaming</li>'
. '<li>Low bandwidth HEVC Streaming</li>'
. '<li>Overlay for video input identification and timestamping</li>'
. '<li>KLV / STANAG metadata processing for ISR</li>'
. '</ul>';
$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]
);
echo ($apply ? 'APPLY' : 'INSPECT') . "\n";
// --- 1) CDC-Spalten-Items ----------------------------------------------------
// Collection-Tabellen generisch ueber foreign_table_parent_uid finden
// (die Content-Blocks-Felder heissen hier unprefixed: column/text/item_type)
$itemTables = $pdo->query(
"SELECT DISTINCT TABLE_NAME FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND COLUMN_NAME = 'foreign_table_parent_uid'"
)->fetchAll(PDO::FETCH_COLUMN);
echo "\n--- 1) CDC #" . CDC_COLUMNS_UID . " · Kandidaten-Tabellen: " . implode(', ', $itemTables) . "\n";
$fixedLeft = false;
$deletedRight = false;
foreach ($itemTables as $table) {
$cols = $pdo->query("SELECT COLUMN_NAME FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = " . $pdo->quote($table))->fetchAll(PDO::FETCH_COLUMN);
$textCol = in_array('vitec_text', $cols, true) ? 'vitec_text' : (in_array('text', $cols, true) ? 'text' : null);
$colCol = in_array('vitec_column', $cols, true) ? 'vitec_column' : (in_array('column', $cols, true) ? 'column' : null);
if ($textCol === null || $colCol === null) {
continue;
}
$items = $pdo->query("SELECT * FROM `$table` WHERE deleted = 0 AND foreign_table_parent_uid = " . CDC_COLUMNS_UID . " ORDER BY sorting")->fetchAll();
if ($items === []) {
continue;
}
echo " Tabelle: $table (textCol=$textCol, colCol=$colCol)\n";
foreach ($items as $item) {
$text = (string)$item[$textCol];
printf(" item #%d col=%s type=%s text=\"%s\"\n",
$item['uid'], $item[$colCol] ?? '?',
$item['vitec_item_type'] ?? ($item['item_type'] ?? '?'),
mb_substr(trim(strip_tags($text)), 0, 70));
$isHotelText = str_contains($text, 'Crowne Plaza');
$column = (string)($item[$colCol] ?? 'left');
if ($isHotelText && $textCol !== null) {
echo " -> linkes Text-Item: Hotel-Text wird durch CDC-Background ersetzt\n";
if ($apply) {
$pdo->prepare("UPDATE `$table` SET `$textCol` = ?, tstamp = UNIX_TIMESTAMP() WHERE uid = ?")
->execute([CDC_BACKGROUND, (int)$item['uid']]);
$fixedLeft = true;
}
} elseif ($column === 'right') {
echo " -> rechtes Item (Hotel-Logo): wird soft-geloescht\n";
if ($apply) {
$pdo->prepare("UPDATE `$table` SET deleted = 1, tstamp = UNIX_TIMESTAMP() WHERE uid = ?")
->execute([(int)$item['uid']]);
$deletedRight = true;
}
}
}
break; // erste Tabelle mit Items reicht
}
if ($apply) {
echo $fixedLeft ? " Text ersetzt.\n" : " WARNUNG: kein Hotel-Text-Item gefunden!\n";
echo $deletedRight ? " Logo-Item geloescht.\n" : " Hinweis: kein rechtes Item geloescht.\n";
}
// --- 2) Key-Feature-Elemente -------------------------------------------------
echo "\n--- 2) Key-Feature-Elemente ---\n";
$in = implode(',', array_merge(DELETE_ELEMENTS, [PROTEO_UID, KEEP_CHECK]));
foreach ($pdo->query("SELECT uid, CType, LEFT(header,30) h, LEFT(bodytext,70) b, deleted FROM tt_content WHERE uid IN ($in)") as $row) {
$uid = (int)$row['uid'];
$action = in_array($uid, DELETE_ELEMENTS, true) ? 'LOESCHEN (Original hat kein Key Feature)'
: ($uid === PROTEO_UID ? 'bodytext -> MIL-spec-Bullets (Original)' : 'BEHALTEN (Text im Original echt)');
printf(" #%-4d %-12s \"%s\" \"%s\"%s -> %s\n", $uid, $row['CType'], $row['h'],
trim(strip_tags((string)$row['b'])), $row['deleted'] ? ' [DELETED]' : '', $action);
}
if ($apply) {
$pdo->exec('UPDATE tt_content SET deleted = 1, tstamp = UNIX_TIMESTAMP() WHERE uid IN (' . implode(',', DELETE_ELEMENTS) . ')');
$pdo->prepare('UPDATE tt_content SET bodytext = ?, tstamp = UNIX_TIMESTAMP() WHERE uid = ?')
->execute([PROTEO_KEYFEATURE, PROTEO_UID]);
echo " 3 geloescht, Proteo ersetzt.\n";
}
echo $apply ? "\nFertig. Danach: vendor/bin/typo3 cache:flush\n" : "\nInspect only - mit --apply schreiben.\n";
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
/**
* Ticket 0017-FE Punkt 4: drei Alt-Stories tragen Platzhalter in
* quote/quote_person/quote_role (africa-hall: "Here Fu Manchu lalala.",
* bayer-uk: "This is a testimonial", meydan-racecourse: der Feldhilfetext).
* Auf Testimonial-Karten wuerden die erscheinen -> Felder leeren.
*
* Usage: php migrations/fix_story_placeholder_quotes.php (inspect)
* php migrations/fix_story_placeholder_quotes.php --apply
*/
error_reporting(E_ALL);
ini_set('display_errors', '1');
const SLUGS = ['africa-hall', 'bayer-uk', 'meydan-racecourse'];
$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]
);
echo ($apply ? 'APPLY' : 'INSPECT') . "\n";
$in = implode(',', array_fill(0, count(SLUGS), '?'));
$stmt = $pdo->prepare("SELECT uid, slug, LEFT(quote,60) q, quote_person, quote_role
FROM tx_vitec_domain_model_usecase WHERE deleted = 0 AND slug IN ($in)");
$stmt->execute(SLUGS);
$rows = $stmt->fetchAll();
foreach ($rows as $row) {
printf("#%-4d %-20s quote=\"%s\" person=\"%s\" role=\"%s\" -> leeren\n",
$row['uid'], $row['slug'], $row['q'], $row['quote_person'], $row['quote_role']);
if ($apply) {
$pdo->prepare("UPDATE tx_vitec_domain_model_usecase
SET quote = '', quote_person = '', quote_role = '', tstamp = UNIX_TIMESTAMP() WHERE uid = ?")
->execute([(int)$row['uid']]);
}
}
echo count($rows) . " Record(s)" . ($apply ? " geleert. Danach cache:flush." : ". Mit --apply leeren.") . "\n";
+155
View File
@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
/**
* Ticket 0017-FE Punkt 5b: Videos als echte vitec_video-Elemente.
*
* - betfred-hq: "The Result" endet mit "<p>Case Study Video</p>" + Link
* auf youtube nw52kbYpF3A -> Absaetze strippen, vitec_video danach.
* - kanha-shanti-vanam: "The Result" traegt "<p>Short Videos</p><ul>"
* mit zwei YouTube-Links (WD0dHmqN4s0, C8FzHR5aUAI) -> strippen,
* zwei vitec_video danach.
* - atlanta-hawks: html-Element #326 traegt den escapten iframe
* (-ds3p9jb5F0) -> Element loeschen, vitec_video an seiner Position.
*
* Einfuegung direkt per SQL (pid/colPos 999/tx_vitec_usecase_content/
* sorting zwischen den Nachbarn); der Inline-Zaehler content_elements am
* Usecase-Record wird mitgezogen.
*
* Usage: php migrations/fix_story_videos.php (inspect)
* php migrations/fix_story_videos.php --apply
*/
error_reporting(E_ALL);
ini_set('display_errors', '1');
$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]
);
echo ($apply ? 'APPLY' : 'INSPECT') . "\n";
$col = $pdo->query("SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'tt_content' AND COLUMN_NAME = 'vitec_youtube_code'")->fetchColumn();
if ((int)$col !== 1) {
exit("ABBRUCH: Spalte tt_content.vitec_youtube_code nicht gefunden.\n");
}
function storyUid(PDO $pdo, string $slug): int
{
$stmt = $pdo->prepare('SELECT uid FROM tx_vitec_domain_model_usecase WHERE deleted = 0 AND slug = ?');
$stmt->execute([$slug]);
return (int)$stmt->fetchColumn();
}
/** sorting-Wert direkt nach $anchorUid; bei zu kleinem Abstand werden die Kinder vorher mit Stride 256 neu nummeriert. */
function sortingAfter(PDO $pdo, int $storyUid, int $anchorUid, bool $apply): int
{
$children = $pdo->query("SELECT uid, sorting FROM tt_content
WHERE deleted = 0 AND tx_vitec_usecase_content = $storyUid ORDER BY sorting, uid")->fetchAll();
$index = array_search($anchorUid, array_map(static fn(array $r): int => (int)$r['uid'], $children), true);
if ($index === false) {
throw new RuntimeException("Anker #$anchorUid nicht unter den Kindern von Story #$storyUid");
}
$anchorSort = (int)$children[$index]['sorting'];
$nextSort = isset($children[$index + 1]) ? (int)$children[$index + 1]['sorting'] : $anchorSort + 512;
if ($nextSort - $anchorSort >= 4) {
return $anchorSort + intdiv($nextSort - $anchorSort, 2);
}
// zu eng: komplett neu nummerieren und erneut rechnen
if ($apply) {
$sorting = 0;
$stmt = $pdo->prepare('UPDATE tt_content SET sorting = ? WHERE uid = ?');
foreach ($children as $child) {
$sorting += 256;
$stmt->execute([$sorting, (int)$child['uid']]);
}
}
return ($index + 1) * 256 + 128;
}
function insertVideo(PDO $pdo, int $storyUid, int $pid, int $sorting, string $header, string $code, bool $apply): void
{
printf(" + vitec_video \"%s\" code=%s sorting=%d\n", $header, $code, $sorting);
if (!$apply) {
return;
}
$pdo->prepare("INSERT INTO tt_content
(pid, CType, colPos, tx_vitec_usecase_content, sorting, header, vitec_youtube_code, crdate, tstamp)
VALUES (?, 'vitec_video', 999, ?, ?, ?, ?, UNIX_TIMESTAMP(), UNIX_TIMESTAMP())")
->execute([$pid, $storyUid, $sorting, $header, $code]);
$pdo->exec("UPDATE tx_vitec_domain_model_usecase
SET content_elements = (SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND tx_vitec_usecase_content = $storyUid),
tstamp = UNIX_TIMESTAMP()
WHERE uid = $storyUid");
}
function stripFromBody(PDO $pdo, int $elementUid, string $pattern, bool $apply): bool
{
$body = (string)$pdo->query("SELECT bodytext FROM tt_content WHERE uid = $elementUid")->fetchColumn();
$clean = (string)preg_replace($pattern, '', $body);
if ($clean === $body) {
echo " ! Strip-Muster traf NICHT in #$elementUid - nichts geaendert\n";
return false;
}
echo " ~ #$elementUid: Video-Absaetze aus bodytext entfernt\n";
if ($apply) {
$pdo->prepare('UPDATE tt_content SET bodytext = ?, tstamp = UNIX_TIMESTAMP() WHERE uid = ?')
->execute([trim($clean), $elementUid]);
}
return true;
}
// --- Betfred -----------------------------------------------------------------
echo "\n--- betfred-hq ---\n";
$uid = storyUid($pdo, 'betfred-hq');
$pid = (int)$pdo->query("SELECT pid FROM tx_vitec_domain_model_usecase WHERE uid = $uid")->fetchColumn();
if (stripFromBody($pdo, 11910, '#<p>\s*Case Study Video\s*</p>\s*<p>[^<]*nw52kbYpF3A[^<]*</p>#u', $apply)) {
insertVideo($pdo, $uid, $pid, sortingAfter($pdo, $uid, 11910, $apply), 'Case Study Video', 'nw52kbYpF3A', $apply);
}
// --- Kanha Shanti Vanam --------------------------------------------------------
echo "\n--- kanha-shanti-vanam ---\n";
$uid = storyUid($pdo, 'kanha-shanti-vanam-meditation-center');
$pid = (int)$pdo->query("SELECT pid FROM tx_vitec_domain_model_usecase WHERE uid = $uid")->fetchColumn();
if (stripFromBody($pdo, 11964, '#<p>\s*Short Videos\s*</p>\s*<ul>\s*<li>[^<]*WD0dHmqN4s0[^<]*</li>\s*<li>[^<]*C8FzHR5aUAI[^<]*</li>\s*</ul>#u', $apply)) {
$sorting = sortingAfter($pdo, $uid, 11964, $apply);
insertVideo($pdo, $uid, $pid, $sorting, 'Short Videos', 'WD0dHmqN4s0', $apply);
insertVideo($pdo, $uid, $pid, $sorting + 1, '', 'C8FzHR5aUAI', $apply);
}
// --- Atlanta Hawks --------------------------------------------------------------
echo "\n--- atlanta-hawks ---\n";
$uid = storyUid($pdo, 'atlanta-hawks');
$old = $pdo->query('SELECT uid, pid, sorting, bodytext FROM tt_content WHERE uid = 326 AND deleted = 0')->fetch();
if ($old && str_contains((string)$old['bodytext'], '-ds3p9jb5F0')) {
echo " ~ html #326 (escapter iframe) wird geloescht, vitec_video an seiner Position\n";
if ($apply) {
$pdo->exec('UPDATE tt_content SET deleted = 1, tstamp = UNIX_TIMESTAMP() WHERE uid = 326');
}
insertVideo($pdo, $uid, (int)$old['pid'], (int)$old['sorting'], '', '-ds3p9jb5F0', $apply);
} else {
echo " ! html #326 nicht gefunden oder ohne erwarteten iframe - uebersprungen\n";
}
echo $apply ? "\nFertig. Danach: vendor/bin/typo3 cache:flush\n" : "\nInspect only - mit --apply schreiben.\n";
+134
View File
@@ -0,0 +1,134 @@
<?php
/**
* fix_why_band_position.php (2026-11-04)
*
* Verschiebt das "Why VITEC?"-Band (introparagraph + folgende icon_card-
* Elemente aus vitec:build-why-vitec-bands) auf den 7 Produkt-Uebersichts-
* seiten VOR das "Product range"-Intro. Der Band-Lauf hatte es dahinter
* einsortiert und damit die Ueberschrift von ihren Produktkarten getrennt
* (Arne, 0042-FE Abschnitt 3).
*
* Soll: Hero -> Why-VITEC-Band -> "Product range" -> Produktkarten.
*
* php migrations/fix_why_band_position.php # INSPECT (default)
* php migrations/fix_why_band_position.php --apply # schreibt
*/
declare(strict_types=1);
$apply = in_array('--apply', $argv, true);
$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]
);
echo $apply ? "APPLY\n\n" : "INSPECT - nichts wird geschrieben (--apply zum Schreiben)\n\n";
$slugs = [
'/products/activesqx',
'/products/aetria',
'/products/aligo',
'/products/avedia-modular-system',
'/products/image-graphics-cards',
'/products/operator-workstations',
'/products/prism-transcoder',
];
$moved = 0;
foreach ($slugs as $slug) {
$page = $pdo->prepare('SELECT uid, title FROM pages WHERE deleted = 0 AND slug = ?');
$page->execute([$slug]);
$page = $page->fetch(PDO::FETCH_ASSOC);
if ($page === false) {
echo "$slug: Seite NICHT gefunden - uebersprungen\n\n";
continue;
}
$pid = (int)$page['uid'];
$stmt = $pdo->prepare(
'SELECT uid, CType, header, sorting, hidden FROM tt_content
WHERE pid = ? AND deleted = 0 AND colPos = 0
AND (tx_container_parent IS NULL OR tx_container_parent = 0)
ORDER BY sorting, uid'
);
$stmt->execute([$pid]);
$elements = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Band finden: "Why VITEC?"-Intro + direkt folgende vitec_card-Elemente
$bandStart = null;
foreach ($elements as $i => $el) {
if ($el['CType'] === 'vitec_introparagraph' && trim((string)$el['header']) === 'Why VITEC?') {
$bandStart = $i;
break;
}
}
if ($bandStart === null) {
echo "$slug (pid $pid): kein 'Why VITEC?'-Intro - uebersprungen\n\n";
continue;
}
$bandEnd = $bandStart;
while (isset($elements[$bandEnd + 1]) && $elements[$bandEnd + 1]['CType'] === 'vitec_card') {
$bandEnd++;
}
// Ziel: das erste andere Intro ("Product range")
$target = null;
foreach ($elements as $i => $el) {
if ($i >= $bandStart && $i <= $bandEnd) {
continue;
}
if ($el['CType'] === 'vitec_introparagraph') {
$target = $i;
break;
}
}
if ($target === null) {
echo "$slug (pid $pid): kein zweites Intro gefunden - uebersprungen\n\n";
continue;
}
echo "$slug (pid $pid) - Band: Elemente " . ($bandEnd - $bandStart + 1)
. " (#" . $elements[$bandStart]['uid'] . "..#" . $elements[$bandEnd]['uid'] . ")"
. ", Ziel-Intro: #" . $elements[$target]['uid'] . " \"" . $elements[$target]['header'] . "\"\n";
if ($target > $bandEnd) {
echo " Band steht bereits VOR dem Ziel-Intro - nichts zu tun\n\n";
continue;
}
// neue Reihenfolge: Band raus, vor das Ziel-Intro wieder rein
$band = array_slice($elements, $bandStart, $bandEnd - $bandStart + 1);
$rest = array_merge(array_slice($elements, 0, $bandStart), array_slice($elements, $bandEnd + 1));
$newOrder = [];
foreach ($rest as $el) {
if ((int)$el['uid'] === (int)$elements[$target]['uid']) {
foreach ($band as $b) {
$newOrder[] = $b;
}
}
$newOrder[] = $el;
}
echo " Neue Reihenfolge:\n";
$sorting = 0;
foreach ($newOrder as $el) {
$sorting += 256;
printf(" %5d #%-6d %-24s %s\n", $sorting, $el['uid'], $el['CType'], (string)$el['header']);
if ($apply) {
$pdo->prepare('UPDATE tt_content SET sorting = ? WHERE uid = ?')->execute([$sorting, (int)$el['uid']]);
}
}
$moved++;
echo "\n";
}
echo ($apply ? "FERTIG" : "WUERDE") . ": $moved Seite(n) umsortiert.\n";
if ($apply && $moved > 0) {
echo "Danach: vendor/bin/typo3 cache:flush\n";
}
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
/**
* The product group pages (two middle levels of the /products tree) were
* turned into folders by the editor on 2026-11-10 (decision confirmed by
* Harry 2026-11-11: intentional). Their former URLs answered 404 since
* then (Seobility crawl 3: 8 "technische Probleme"). Every folder under
* /products gets a 301 to /products; the content elements on the folders
* stay untouched.
*
* Dry run: php migrations/folder_pages_redirects_20261111.php
* Write: php migrations/folder_pages_redirects_20261111.php --apply
*/
error_reporting(E_ALL);
ini_set('display_errors', '1');
$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("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]
);
echo $apply ? "== APPLY ==\n" : "== DRY RUN (add --apply to write) ==\n";
$hasRedirect = $pdo->prepare("SELECT uid, target FROM sys_redirect WHERE deleted = 0 AND source_path = ? LIMIT 1");
$insertRedirect = $pdo->prepare(
"INSERT INTO sys_redirect (pid, createdon, updatedon, source_host, source_path, target, target_statuscode)
VALUES (0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), '*', ?, ?, 301)"
);
$target = '/products';
$paths = [];
$n = 0;
foreach ($pdo->query("SELECT uid, slug, title, doktype FROM pages WHERE deleted = 0 AND hidden = 0 AND doktype = 254 AND slug LIKE '/products/%' ORDER BY slug") as $p) {
$ce = (int)$pdo->query("SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND hidden = 0 AND pid = {$p['uid']}")->fetchColumn();
foreach ([$p['slug'], $p['slug'] . '/'] as $source) {
$hasRedirect->execute([$source]);
$existing = $hasRedirect->fetch();
if ($existing) {
printf(" #%-4d %-55s (%2d CEs) %s: Redirect existiert -> %s\n", $p['uid'], $source, $ce, $p['title'], $existing['target']);
continue;
}
printf(" #%-4d %-55s (%2d CEs) %s -> %s\n", $p['uid'], $source, $ce, $p['title'], $target);
$n++;
if ($apply) { $insertRedirect->execute([$source, $target]); }
}
$paths[] = $p['slug'];
// Older redirects that point AT this folder (t3://page?uid=… or its slug)
// would now end in a 404 chain: send them to /products as well.
$st = $pdo->prepare("SELECT uid, source_path, target FROM sys_redirect WHERE deleted = 0 AND (target = ? OR target = ? OR target LIKE ?)");
$st->execute(['t3://page?uid=' . $p['uid'], $p['slug'], 't3://page?uid=' . $p['uid'] . '&%']);
foreach ($st->fetchAll() as $r) {
printf(" redirect #%d %s: Ziel %s -> %s\n", $r['uid'], $r['source_path'], $r['target'], $target);
if ($apply) { $pdo->prepare("UPDATE sys_redirect SET target = ?, updatedon = UNIX_TIMESTAMP() WHERE uid = ?")->execute([$target, (int)$r['uid']]); }
}
}
echo "\n$n Redirect(s) " . ($apply ? 'angelegt' : 'wuerden angelegt') . ".\n";
echo "SSR-Pfade:\n " . implode("\n ", $paths) . "\n";
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
/**
* Follow-up to folder_pages_redirects_20261111: a product folder that holds
* exactly one visible overview page sends its visitors to that page instead
* of /products (RF Gateway -> Avedia Modular System, AV over IP & KVM -> Aligo).
* Applies to the folder's own redirects and to older redirects that were
* pointed at /products in the previous step because they targeted the folder.
*
* Dry run: php migrations/folder_redirect_targets_20261111.php
* Write: php migrations/folder_redirect_targets_20261111.php --apply
*/
error_reporting(E_ALL);
ini_set('display_errors', '1');
$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("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]
);
echo $apply ? "== APPLY ==\n" : "== DRY RUN (add --apply to write) ==\n";
// Older redirects (sys_redirect uid) that targeted the folder before the previous step.
$legacyByFolder = [566 => [34, 124, 125], 590 => [63, 66]];
$n = 0;
foreach ($pdo->query("SELECT uid, slug, title FROM pages WHERE deleted = 0 AND hidden = 0 AND doktype = 254 AND slug LIKE '/products/%' ORDER BY slug") as $folder) {
$children = $pdo->query("SELECT uid, slug, title FROM pages WHERE deleted = 0 AND hidden = 0 AND doktype = 1 AND pid = {$folder['uid']}")->fetchAll();
if (count($children) !== 1) { continue; }
$target = $children[0]['slug'];
printf(" Ordner #%d %s -> einzige Seite #%d %s\n", $folder['uid'], $folder['slug'], $children[0]['uid'], $target);
$uids = $legacyByFolder[(int)$folder['uid']] ?? [];
$st = $pdo->prepare("SELECT uid, source_path, target FROM sys_redirect WHERE deleted = 0 AND target = '/products' AND (source_path = ? OR source_path = ?" . ($uids ? ' OR uid IN (' . implode(',', array_map('intval', $uids)) . ')' : '') . ")");
$st->execute([$folder['slug'], $folder['slug'] . '/']);
foreach ($st->fetchAll() as $r) {
printf(" redirect #%-4d %-55s /products -> %s\n", $r['uid'], $r['source_path'], $target);
$n++;
if ($apply) { $pdo->prepare("UPDATE sys_redirect SET target = ?, updatedon = UNIX_TIMESTAMP() WHERE uid = ?")->execute([$target, (int)$r['uid']]); }
}
}
echo "\n$n Redirect(s) " . ($apply ? 'umgebogen' : 'wuerden umgebogen') . ".\n";
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
/**
* News articles that carry the same text as a success story get the story
* as canonical (field tx_news_domain_model_news.vitec_canonical_usecase,
* evaluated by VitecMetaHandler). The article stays in the news list.
*
* Pairs from the Seobility duplicate-content report of 2026-11-10.
*
* Dry run: php migrations/news_story_canonical_20261110.php
* Write: php migrations/news_story_canonical_20261110.php --apply
*/
error_reporting(E_ALL);
ini_set('display_errors', '1');
$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("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]
);
echo $apply ? "== APPLY ==\n" : "== DRY RUN (add --apply to write) ==\n";
// news uid => usecase (story) uid
$pairs = [
361 => 56, // Denver Sportsbook Bar & Grill
121 => 24, // European Space Agency
164 => 26, // Feature Story News
336 => 62, // Kyndryl Cyber Operations Center
388 => 74, // Leading Video Capture for the Medical Market
239 => 66, // RRS Sir David Attenborough
158 => 46, // Sacramento Kings
310 => 68, // SENTRY Video Walls – 2024 Olympic Games
290 => 72, // UK Mission Critical Control Room
389 => 59, // Intercity Istanbul Park
436 => 64, // NHS tribute onto Parliament
395 => 54, // Bahrain International Circuit
327 => 60, // ITV News Headquarters
];
$news = $pdo->prepare("SELECT uid, title, path_segment, vitec_canonical_usecase FROM tx_news_domain_model_news WHERE deleted = 0 AND uid = ?");
$story = $pdo->prepare("SELECT uid, title, slug, hidden FROM tx_vitec_domain_model_usecase WHERE deleted = 0 AND uid = ?");
$update = $pdo->prepare("UPDATE tx_news_domain_model_news SET vitec_canonical_usecase = ?, tstamp = UNIX_TIMESTAMP() WHERE uid = ?");
$set = 0;
$paths = [];
foreach ($pairs as $newsUid => $storyUid) {
$news->execute([$newsUid]); $n = $news->fetch();
$story->execute([$storyUid]); $s = $story->fetch();
if (!$n || !$s) { echo " #$newsUid -> #$storyUid: Datensatz fehlt, uebersprungen\n"; continue; }
if ((int)$s['hidden'] === 1) { echo " #$newsUid -> #$storyUid: Story versteckt, uebersprungen\n"; continue; }
$state = (int)$n['vitec_canonical_usecase'] === $storyUid ? 'schon gesetzt' : 'setzen';
printf(" news #%-4d %-70s -> story #%-3d /success-stories/%s [%s]\n", $newsUid, mb_substr($n['title'], 0, 70), $storyUid, $s['slug'], $state);
$paths[] = '/news/article/' . $n['path_segment'];
if ($state === 'setzen') {
$set++;
if ($apply) { $update->execute([$storyUid, $newsUid]); }
}
}
echo "\n$set Zuordnung(en) " . ($apply ? 'geschrieben' : 'wuerden geschrieben') . ".\n";
echo "SSR-Pfade:\n " . implode("\n ", $paths) . "\n";
@@ -0,0 +1,19 @@
#!/bin/sh
# Placeholder flag for the content checks (2026-11-13): permissions, syntax check,
# cache flush and SSR purge of the three check pages; "commit" as first argument
# commits the two services afterwards.
set -e
cd /usr/www/users/vitecevo/live
SRV=packages/vitec/Classes/Service
chmod 644 $SRV/PageCheckService.php $SRV/ProductCheckService.php migrations/pagecheck_placeholder_20261113.sh
php -l $SRV/PageCheckService.php
php -l $SRV/ProductCheckService.php
if [ "$1" = "commit" ]; then
git add $SRV/PageCheckService.php $SRV/ProductCheckService.php migrations/pagecheck_placeholder_20261113.sh
git commit -q -F migrations/pagecheck_placeholder_20261113.msg
git log --oneline -1
exit 0
fi
vendor/bin/typo3 cache:flush
vendor/bin/typo3 vitec:ssr-purge /markets/market-check /solutions/solution-check /products/product-check
vendor/bin/typo3 vitec:ssr-warm /markets/market-check /solutions/solution-check /products/product-check
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
# vitec_pagecheck (2026-11-13): permissions, syntax check, cache flush, page setup (dry-run unless --apply).
set -e
cd /usr/www/users/vitecevo/live
EXT=packages/vitec
CFG=$EXT/Configuration
chmod 644 $EXT/Classes/Command/CreateCheckPagesCommand.php $EXT/Classes/Service/PageCheckService.php $EXT/Classes/UserFunc/PageCheckJsonRenderer.php \
$CFG/FlexForms/Pagecheck.xml $CFG/Services.yaml $CFG/Sets/Vitecset/setup.typoscript $CFG/TCA/Overrides/tt_content.php $CFG/page.tsconfig migrations/pagecheck_setup_20261113.sh
php -l $EXT/Classes/Service/PageCheckService.php
php -l $EXT/Classes/UserFunc/PageCheckJsonRenderer.php
php -l $EXT/Classes/Command/CreateCheckPagesCommand.php
php -l $CFG/TCA/Overrides/tt_content.php
vendor/bin/typo3 cache:flush
vendor/bin/typo3 vitec:pagecheck-pages "$@"
+224
View File
@@ -0,0 +1,224 @@
<?php
declare(strict_types=1);
/**
* SEO fixes (audit 2026-11-09):
*
* 1. Clear SELF-referencing canonical_link values. The page imports wrote
* "t3://page?uid=<self>" / "<self>" into 161 pages; TYPO3 emits a
* self-canonical anyway, and the pages sitemap EXCLUDES every page with
* a canonical_link (additionalWhere) - which is why markets, solutions,
* support, contact, insights and even the home page were missing from
* sitemap.xml.
* 2. no_index = 1 for technical pages (search, megamenu, footer shells...)
* and the imported old news pages under /en/news + /news (duplicates of
* the EXT:news articles).
* 3. 301 redirects for old-site URLs onto their new equivalents - the
* footer link targets plus every old news page that matches an EXT:news
* article by slug segment.
* 4. Rewrites the old-site hrefs inside tt_content bodytext (footer html
* columns) onto the new targets, so no page carries dead internal links.
*
* Dry run: php migrations/seo_fixes_20261109.php
* Write: php migrations/seo_fixes_20261109.php --apply
*/
error_reporting(E_ALL);
ini_set('display_errors', '1');
$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("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]
);
echo $apply ? "== APPLY ==\n" : "== DRY RUN (add --apply to write) ==\n";
/* ------------------------------------------------------------------ */
/* 1. Self-canonicals */
/* ------------------------------------------------------------------ */
// Comparison in PHP: CONCAT() in SQL trips over mixed column collations.
$selfCanonical = [];
foreach ($pdo->query("SELECT uid, canonical_link FROM pages WHERE deleted = 0 AND canonical_link <> ''") as $row) {
$uid = (int)$row['uid'];
$link = trim((string)$row['canonical_link']);
if ($link === 't3://page?uid=' . $uid || $link === (string)$uid) {
$selfCanonical[] = $uid;
}
}
echo "1. Self-Canonicals leeren: " . count($selfCanonical) . " Seiten\n";
if ($apply && $selfCanonical !== []) {
$pdo->exec(
"UPDATE pages SET canonical_link = '', tstamp = UNIX_TIMESTAMP()
WHERE uid IN (" . implode(',', $selfCanonical) . ")"
);
}
/* ------------------------------------------------------------------ */
/* 2. no_index for technical + old news pages */
/* ------------------------------------------------------------------ */
$technicalUids = [12, 16, 19, 106, 107, 448, 450, 516, 517, 519, 521, 523, 607];
$in = implode(',', $technicalUids);
$cnt = (int)$pdo->query("SELECT COUNT(*) FROM pages WHERE deleted = 0 AND no_index = 0 AND (uid IN ($in) OR pid = 106)")->fetchColumn();
echo "2. no_index=1 setzen: $cnt Seiten (technische Seiten + alte News-Seiten unter #106)\n";
if ($apply && $cnt > 0) {
$pdo->exec("UPDATE pages SET no_index = 1, tstamp = UNIX_TIMESTAMP() WHERE deleted = 0 AND no_index = 0 AND (uid IN ($in) OR pid = 106)");
}
/* ------------------------------------------------------------------ */
/* 3a. Old-site URL mapping (footer targets) */
/* ------------------------------------------------------------------ */
$mapping = [
'/about/careers' => '/insights/careers',
'/about/company' => '/insights/who-we-are',
'/about/events' => '/insights/events',
'/about/locations' => '/contact/locations',
'/about/news' => '/insights/news',
'/about/sustainability' => '/insights/sustainability',
'/about/who-we-are' => '/insights/who-we-are',
'/about/why-vitec' => '/insights/who-we-are',
'/downloads' => '/datasheets',
'/markets/accommodation' => '/markets/hospitality',
'/markets/broadcast' => '/markets/broadcast-and-telecoms',
'/markets/corporate' => '/markets/corporation',
'/markets/healthcare' => '/markets',
'/markets/military' => '/markets/defense-and-security',
'/markets/sports' => '/markets/sports-and-entertainment',
'/markets/venues' => '/markets/sports-and-entertainment',
'/products/encoders-decoders' => '/products/video-encoding-and-streaming',
'/products/encoders-decoders/appliances' => '/products/appliances',
'/products/encoders-decoders/modular-systems' => '/products/modular-systems',
'/products/encoders-decoders/modular-systems/avedia-encoders' => '/products/avedia-modular-system',
'/products/encoders-decoders/modular-systems/iptv-blade-system' => '/products/avedia-modular-system',
'/products/encoders-decoders/modular-systems/vitec-og' => '/products/modular-systems',
'/products/encoders-decoders/pcie-encoder' => '/products/activesqx',
'/products/encoders-decoders/rugged' => '/products/rugged-appliances',
'/products/ip/rf-gateways-transcoders' => '/products/rf-gateway',
'/products/ip/rf-gateways-transcoders/ip-gateways' => '/products/channellink-ip-gateway',
'/products/ip/rf-gateways-transcoders/prism-transcoder' => '/products/prism-transcoder',
'/products/ip/rf-gateways-transcoders/rf-gateways' => '/products/rf-gateway',
'/products/iptv-digital-signage-platform' => '/products/iptv-and-digital-signage',
'/products/iptv-digital-signage-platform/all-in-one-platform' => '/products/avedia',
'/products/iptv-digital-signage-platform/digital-signage' => '/products/iptv-and-digital-signage',
'/products/iptv-digital-signage-platform/end-points' => '/products/end-points',
'/products/iptv-digital-signage-platform/integrated-displays' => '/products/x-view-integrated-displays',
'/products/iptv-digital-signage-platform/interactive-tv-portal' => '/products/ez-tv',
'/products/iptv-digital-signage-platform/video-wall-controllers' => '/products/video-wall-controllers-and-management',
'/products/iptv-digital-signage-platform/video-wall-controllers/aligo' => '/products/aligo',
'/products/iptv-digital-signage-platform/video-wall-controllers/fx4-series' => '/products/video-wall-controllers-and-management',
'/products/iptv-digital-signage-platform/video-wall-controllers/vsn-series' => '/products/vsn-series-video-wall-controllers',
'/products/iptv-digital-signage-platform/x-player' => '/products/x-player-end-points',
'/products/services' => '/support',
'/products/services/deployment-services' => '/support/commissioning',
'/products/services/digital-signage-and-design-services' => '/support',
'/products/services/training' => '/support/training',
'/products/telemetry' => '/products',
'/solutions/contribution-remote-production' => '/solutions/video-capture-encoding-processing/contribution-remote-production',
'/solutions/iptv-distribution' => '/solutions/video-distribution-streaming/iptv-distribution',
'/solutions/situational-awareness-and-isr' => '/solutions/real-time-monitoring-situational-awareness',
'/solutions/video-streaming' => '/solutions/video-distribution-streaming',
'/support/material-compliance' => '/support/materials-compliance',
'/support/request-helpdesk-access' => '/support/login-to-helpdesk',
'/en/news' => '/insights/news',
];
// Resolve every target slug to a page uid (sanity: fail loudly on typos).
$targetUids = [];
$stmt = $pdo->prepare("SELECT uid FROM pages WHERE deleted = 0 AND hidden = 0 AND slug = ? LIMIT 1");
$missing = 0;
foreach (array_unique(array_values($mapping)) as $target) {
$stmt->execute([$target]);
$uid = (int)$stmt->fetchColumn();
if ($uid <= 0) {
echo " !! Zielseite fehlt: $target\n";
$missing++;
continue;
}
$targetUids[$target] = $uid;
}
if ($missing > 0 && $apply) {
exit("Abbruch: $missing Zielseiten nicht gefunden - Mapping pruefen.\n");
}
echo "3a. Redirects Alt-URLs -> neue Seiten:\n";
$hasRedirect = $pdo->prepare("SELECT COUNT(*) FROM sys_redirect WHERE deleted = 0 AND source_path = ?");
$insertRedirect = $pdo->prepare(
"INSERT INTO sys_redirect (pid, createdon, updatedon, source_host, source_path, target, target_statuscode)
VALUES (0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), '*', ?, ?, 301)"
);
$newRedirects = 0;
foreach ($mapping as $old => $new) {
if (!isset($targetUids[$new])) { continue; }
$hasRedirect->execute([$old]);
if ((int)$hasRedirect->fetchColumn() > 0) { continue; }
$newRedirects++;
if ($apply) {
$insertRedirect->execute([$old, 't3://page?uid=' . $targetUids[$new]]);
}
}
echo " $newRedirects neue Redirects (bestehende unangetastet)\n";
/* ------------------------------------------------------------------ */
/* 3b. Old imported news pages -> EXT:news articles (slug match) */
/* ------------------------------------------------------------------ */
echo "3b. Alte News-Seiten -> /news/article/<slug>:\n";
$oldNewsPages = $pdo->query(
"SELECT uid, slug FROM pages WHERE deleted = 0 AND (pid = 106 OR uid = 107) AND slug <> ''"
)->fetchAll();
$newsBySegment = $pdo->prepare(
"SELECT uid FROM tx_news_domain_model_news WHERE deleted = 0 AND hidden = 0 AND path_segment = ? LIMIT 1"
);
$matched = 0; $unmatched = [];
foreach ($oldNewsPages as $page) {
$segment = basename((string)$page['slug']);
$newsBySegment->execute([$segment]);
$newsUid = (int)$newsBySegment->fetchColumn();
if ($newsUid <= 0) {
$unmatched[] = $page['slug'];
continue;
}
$hasRedirect->execute([$page['slug']]);
if ((int)$hasRedirect->fetchColumn() > 0) { continue; }
$matched++;
if ($apply) {
$insertRedirect->execute([(string)$page['slug'], '/news/article/' . $segment]);
}
}
echo " $matched Redirects auf Artikel, " . count($unmatched) . " ohne Artikel-Match (bleiben als noindex-Seiten erreichbar)\n";
foreach ($unmatched as $slug) {
echo " kein Match: $slug\n";
}
/* ------------------------------------------------------------------ */
/* 4. Dead hrefs inside tt_content bodytext */
/* ------------------------------------------------------------------ */
echo "4. Alt-Links in Inhalten ersetzen:\n";
$findContent = $pdo->prepare("SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND bodytext LIKE ?");
$fixContent = $pdo->prepare("UPDATE tt_content SET bodytext = REPLACE(bodytext, ?, ?), tstamp = UNIX_TIMESTAMP() WHERE deleted = 0 AND bodytext LIKE ?");
$replaced = 0;
foreach ($mapping as $old => $new) {
$needle = 'href="' . $old . '"';
$findContent->execute(['%' . $needle . '%']);
$count = (int)$findContent->fetchColumn();
if ($count === 0) { continue; }
echo " $needle -> href=\"$new\" ($count Element(e))\n";
$replaced += $count;
if ($apply) {
$fixContent->execute([$needle, 'href="' . $new . '"', '%' . $needle . '%']);
}
}
echo " Summe: $replaced Element-Treffer\n";
echo $apply ? "FERTIG. Cache flushen (Redirects + Seiten sind gecacht).\n" : "DRY RUN beendet.\n";
+414
View File
@@ -0,0 +1,414 @@
<?php
declare(strict_types=1);
/**
* Fixes from the Seobility crawl of 2026-11-10 (1,094 pages).
*
* 1. Placeholder SEO title "Title for search engines [seo_title]" on /solutions.
* 2. News records whose title/path_segment were saved twice in a row.
* 3. News path_segments that contain slashes (imported from old URL paths).
* 4. Typos reported by the crawler (content tables).
* 5. Second wave of dead old-site links: /resource-center, /services,
* /contact-sales, /aetria-use-cases, /video-wall-solutions, absolute
* datapath.co.uk / datapath-us.com links and tracking-parameter variants.
* 6. "http://www.vitec.com./" (trailing dot) links.
* 7. Redirects: record shells (/product, /news/article, /success-stories/
* story) onto their overviews, plus a few orphaned product URLs.
* 8. Meta description for /datasheets.
* 9. WordPress remnants inside the 17 SEO articles (lazy-load placeholder
* images, leftover script fragment, wp-/data- attributes, bare text).
* 10. News records of type "internal page" pointing at the old /en/news
* pages: their body lives only on those pages (which now redirect onto
* the empty article URLs). The page content is copied into the record,
* images become fal_media, the record becomes a regular article, and
* every old page gets its 301.
*
* Dry run: php migrations/seo_report_fixes_20261110.php
* Write: php migrations/seo_report_fixes_20261110.php --apply
*/
error_reporting(E_ALL);
ini_set('display_errors', '1');
$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("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]
);
echo $apply ? "== APPLY ==\n" : "== DRY RUN (add --apply to write) ==\n";
$now = time();
$hasRedirect = $pdo->prepare("SELECT COUNT(*) FROM sys_redirect WHERE deleted = 0 AND source_path = ?");
$insertRedirect = $pdo->prepare(
"INSERT INTO sys_redirect (pid, createdon, updatedon, source_host, source_path, target, target_statuscode)
VALUES (0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), '*', ?, ?, 301)"
);
$redirectsAdded = 0;
$addRedirect = static function (string $source, string $target) use ($hasRedirect, $insertRedirect, $apply, &$redirectsAdded): void {
$hasRedirect->execute([$source]);
if ((int)$hasRedirect->fetchColumn() > 0) {
return;
}
$redirectsAdded++;
if ($apply) {
$insertRedirect->execute([$source, $target]);
}
};
$pageBySlug = $pdo->prepare("SELECT uid FROM pages WHERE deleted = 0 AND hidden = 0 AND slug = ? LIMIT 1");
$productBySlug = $pdo->prepare("SELECT uid FROM tx_vitec_domain_model_product WHERE deleted = 0 AND hidden = 0 AND slug = ? LIMIT 1");
$newsBySegment = $pdo->prepare("SELECT uid FROM tx_news_domain_model_news WHERE deleted = 0 AND path_segment = ? LIMIT 1");
$targetExists = static function (string $target) use ($pageBySlug, $productBySlug, $newsBySegment): bool {
if (str_starts_with($target, '/product/')) {
$productBySlug->execute([substr($target, 9)]);
return (int)$productBySlug->fetchColumn() > 0;
}
if (str_starts_with($target, '/news/article/')) {
$newsBySegment->execute([substr($target, 14)]);
return (int)$newsBySegment->fetchColumn() > 0;
}
$pageBySlug->execute([$target]);
return (int)$pageBySlug->fetchColumn() > 0;
};
/* ------------------------------------------------------------------ */
echo "\n1. Platzhalter-SEO-Titel/-Beschreibungen\n";
$rows = $pdo->query("SELECT uid, slug, seo_title FROM pages WHERE deleted = 0 AND (seo_title LIKE 'Title for search engines%' OR description LIKE 'Description for search engines%')")->fetchAll();
foreach ($rows as $r) {
echo " #{$r['uid']} {$r['slug']}: seo_title/description geleert\n";
if ($apply) {
$pdo->prepare("UPDATE pages SET seo_title = IF(seo_title LIKE 'Title for search engines%', '', seo_title), description = IF(description LIKE 'Description for search engines%', '', description), tstamp = ? WHERE uid = ?")->execute([$now, $r['uid']]);
}
}
/* ------------------------------------------------------------------ */
echo "\n2. News mit verdoppeltem Titel / path_segment\n";
$halve = static function (string $s): ?string {
$len = mb_strlen($s);
if ($len < 20 || $len % 2 !== 0) { return null; }
$h = mb_substr($s, 0, intdiv($len, 2));
return $h === mb_substr($s, intdiv($len, 2)) ? $h : null;
};
foreach ($pdo->query("SELECT uid, title, path_segment FROM tx_news_domain_model_news WHERE deleted = 0 AND CHAR_LENGTH(title) > 40") as $r) {
$newTitle = $halve((string)$r['title']);
if ($newTitle === null) { continue; }
$seg = (string)$r['path_segment'];
$newSeg = $seg;
// doubled slug: "a-b-ca-b-c" -> "a-b-c"
$segLen = strlen($seg);
if ($segLen % 2 === 0 && substr($seg, 0, intdiv($segLen, 2)) === substr($seg, intdiv($segLen, 2))) {
$newSeg = substr($seg, 0, intdiv($segLen, 2));
}
echo " #{$r['uid']}: Titel -> [$newTitle]" . ($newSeg !== $seg ? ", Slug -> $newSeg (+Redirect)" : '') . "\n";
if ($apply) {
$pdo->prepare("UPDATE tx_news_domain_model_news SET title = ?, path_segment = ?, tstamp = ? WHERE uid = ?")->execute([$newTitle, $newSeg, $now, $r['uid']]);
}
if ($newSeg !== $seg) {
$addRedirect('/news/article/' . $seg, '/news/article/' . $newSeg);
}
}
/* ------------------------------------------------------------------ */
echo "\n3. News path_segment mit Slash\n";
foreach ($pdo->query("SELECT uid, path_segment FROM tx_news_domain_model_news WHERE deleted = 0 AND path_segment LIKE '%/%'") as $r) {
$old = (string)$r['path_segment'];
$new = basename($old);
$newsBySegment->execute([$new]);
$taken = (int)$newsBySegment->fetchColumn();
if ($taken > 0 && $taken !== (int)$r['uid']) {
$new .= '-' . $r['uid'];
}
echo " #{$r['uid']}: $old -> $new (+Redirect)\n";
if ($apply) {
$pdo->prepare("UPDATE tx_news_domain_model_news SET path_segment = ?, tstamp = ? WHERE uid = ?")->execute([$new, $now, $r['uid']]);
}
$addRedirect('/news/article/' . $old, '/news/article/' . $new);
}
/* ------------------------------------------------------------------ */
echo "\n4. Tippfehler\n";
$typos = [
'continously' => 'continuously', 'continous' => 'continuous', 'indpendent' => 'independent',
'aircrafts' => 'aircraft', 'sponser' => 'sponsor', 'auxillary' => 'auxiliary',
'processer' => 'processor', 'accomodation' => 'accommodation',
'Accomodation' => 'Accommodation', 'Continous' => 'Continuous',
];
$typoTargets = [
'tt_content' => ['bodytext', 'header'],
'tx_news_domain_model_news' => ['bodytext', 'teaser', 'title'],
'tx_vitec_domain_model_usecase' => ['description', 'teaser'],
];
foreach ($typos as $wrong => $right) {
foreach ($typoTargets as $table => $fields) {
foreach ($fields as $field) {
$n = (int)$pdo->query("SELECT COUNT(*) FROM $table WHERE deleted = 0 AND $field LIKE " . $pdo->quote('%' . $wrong . '%'))->fetchColumn();
if ($n === 0) { continue; }
echo " $wrong -> $right: $table.$field ($n)\n";
if ($apply) {
$pdo->exec("UPDATE $table SET $field = REPLACE($field, " . $pdo->quote($wrong) . ", " . $pdo->quote($right) . "), tstamp = $now WHERE deleted = 0 AND $field LIKE " . $pdo->quote('%' . $wrong . '%'));
}
}
}
}
/* ------------------------------------------------------------------ */
echo "\n5. Tote Alt-Links (zweite Welle, inkl. datapath.co.uk / utm-Varianten)\n";
$awardSeg = 'aetria-award-wins-set-the-tone-for-datapaths-best-ever-ise-week';
$newsBySegment->execute([$awardSeg]);
$awardTarget = (int)$newsBySegment->fetchColumn() > 0 ? '/news/article/' . $awardSeg : '/insights/news';
$mapping2 = [
'/resource-center/legacy-products' => '/products/capture-and-graphics',
'/resource-center/legacy-products/imagedp4' => '/products/image-graphics-cards',
'/resource-center/legacy-products/datapath-x4' => '/products/video-wall-controllers-and-management',
'/resource-center/legacy-products/datapath-dl8' => '/products/video-wall-controllers-and-management',
'/legacy-products/imagedp4-plus' => '/products/image-graphics-cards',
'/resource-center/get-support' => '/support',
'/services/premium-service-package' => '/support/support-plans',
'/services/training' => '/support/training',
'/contact-sales' => '/contact/contact-form',
'/aetria-use-cases/oil-and-gas-control-room' => '/markets/critical-national-infrastructure/energy-and-utilities',
'/aetria-use-cases/energy-trading-and-dispatch' => '/markets/critical-national-infrastructure/energy-and-utilities',
'/aetria-use-cases/cctv-for-university-campus' => '/markets/education',
'/video-wall-solutions/commercial-video-walls' => '/solutions/video-wall-data-visualisation',
'/video-wall-solutions/control-rooms' => '/solutions/control-room-command-centre-solutions',
'/video-wall-solutions/projection-mapping' => '/solutions/video-wall-data-visualisation',
'/video-wall-solutions/video-wall-for-security-suites' => '/solutions/security-surveillance-solutions',
'/video-wall-solutions' => '/solutions/video-wall-data-visualisation',
'/insights/success-stories' => '/success-stories',
'/datapath-products' => '/products',
'/datapath-products/aetria-control-room-solutions' => '/products/aetria',
'/datapath-products/aetria-control-room-solutions/aetria-overview' => '/products/aetria',
'/datapath-products/aetria-control-room-solutions/aetria-command-center' => '/product/aetria-command-center',
'/datapath-products/aetria-control-room-solutions/aetria-workstation' => '/product/aetria-workstation',
'/datapath-products/aetria-control-room-solutions/aetria-wallcontrol' => '/product/aetria-wallcontrol',
'/datapath-products/video-wall-controllers/vsn-v3' => '/product/vsn-v3',
'/datapath-products/video-wall-controllers/vsn' => '/products/vsn-series-video-wall-controllers',
'/datapath-products/video-wall-controllers/vsn-series' => '/products/vsn-series-video-wall-controllers',
'/datapath-products/video-wall-controllers/vsnmicro-600' => '/product/vsnmicro-600',
'/datapath-products/video-wall-controllers/datapath-fx4' => '/product/fx4',
'/datapath-products/video-distribution-kvm/aligo-4k-avoip-with-kvm' => '/products/aligo',
'/datapath-products/video-distribution-kvm' => '/products/av-over-ip-and-kvm',
'/datapath-products/video-capture-cards/visionsc-range/visionsc-uhd2' => '/product/visionsc-uhd2',
'/datapath-products/video-capture-cards/visionsc-range/visionsc-hd4' => '/product/visionsc-hd4',
'/datapath-products/video-capture-cards/visionsc-range/visionsc-s2' => '/product/visionsc-s2',
'/datapath-products/video-wall-management-software/wallcontrol-10-software' => '/product/wall-control-10',
'/datapath-products/graphics-products/image2k' => '/product/image-2k',
'/datapath-products/components-accessories/rack-mounting-kits' => '/products',
'/datapath-products/sqx-ip-capture/active-sqx' => '/product/activesqx',
'/datapath-products/video-wall-control' => '/products/video-wall-controllers-and-management',
'/' . $awardSeg => $awardTarget,
];
$missing = 0;
foreach (array_unique(array_values($mapping2)) as $t) {
if (!$targetExists($t)) { echo " !! Ziel fehlt: $t\n"; $missing++; }
}
if ($missing > 0 && $apply) { exit("Abbruch: Ziele fehlen.\n"); }
$hostPrefix = '(?:https?://(?:www\.)?datapath(?:-us)?\.(?:co\.uk|com))?';
$linkTables = ['tx_news_domain_model_news' => 'bodytext', 'tt_content' => 'bodytext'];
$linkHits = 0;
foreach ($linkTables as $table => $field) {
$select = $pdo->query("SELECT uid, $field AS body FROM $table WHERE deleted = 0 AND ($field LIKE '%datapath%' OR $field LIKE '%/resource-center/%' OR $field LIKE '%/services/%' OR $field LIKE '%/contact-sales%' OR $field LIKE '%/aetria-use-cases/%' OR $field LIKE '%/video-wall-solutions%' OR $field LIKE '%/insights/success-stories%' OR $field LIKE '%/legacy-products/%' OR $field LIKE '%aetria-award-wins%')");
$update = $pdo->prepare("UPDATE $table SET $field = ?, tstamp = ? WHERE uid = ?");
foreach ($select as $r) {
$body = (string)$r['body'];
$new = $body;
foreach ($mapping2 as $old => $target) {
$pattern = '#href="' . $hostPrefix . preg_quote($old, '#') . '/?(?:\?[^"]*)?"#';
$new = preg_replace($pattern, 'href="' . $target . '"', $new) ?? $new;
}
if ($new !== $body) {
$linkHits++;
echo " $table #{$r['uid']}: Links ersetzt\n";
if ($apply) { $update->execute([$new, $now, $r['uid']]); }
}
}
}
echo " Records mit ersetzten Links: $linkHits\n";
foreach ($mapping2 as $old => $target) {
$addRedirect($old, $target);
$addRedirect($old . '/', $target);
}
/* ------------------------------------------------------------------ */
echo "\n6. http://www.vitec.com./ (Punkt-Tippfehler)\n";
$n = (int)$pdo->query("SELECT COUNT(*) FROM tx_news_domain_model_news WHERE deleted = 0 AND bodytext LIKE '%www.vitec.com./%'")->fetchColumn();
echo " $n Record(s)\n";
if ($apply && $n > 0) {
$pdo->exec("UPDATE tx_news_domain_model_news SET bodytext = REPLACE(REPLACE(bodytext, 'http://www.vitec.com./', 'https://www.vitec.com/'), 'https://www.vitec.com./', 'https://www.vitec.com/'), tstamp = $now WHERE deleted = 0 AND bodytext LIKE '%www.vitec.com./%'");
}
/* ------------------------------------------------------------------ */
echo "\n7. Redirects fuer Record-Shells und verwaiste Produkt-URLs\n";
foreach ([
'/product' => '/products',
'/news/article' => '/insights/news',
'/success-stories/story' => '/success-stories',
'/product/artiosign' => '/products/video-wall-management-software',
'/product/artioview' => '/products/video-wall-management-software',
'/product/x-view-integrated-displays' => '/products/x-view-integrated-displays',
] as $old => $target) {
$addRedirect($old, $target);
$addRedirect($old . '/', $target);
}
/* ------------------------------------------------------------------ */
echo "\n8. Meta-Description /datasheets\n";
$st = $pdo->prepare("UPDATE pages SET description = ?, tstamp = ? WHERE uid = 17 AND (description IS NULL OR description = '')");
$desc = 'Download VITEC and Datapath product datasheets: encoders and decoders, IPTV and digital signage platforms, video wall controllers, capture cards and more - searchable by product and category.';
if ($apply) { $st->execute([$desc, $now]); echo " gesetzt (" . $st->rowCount() . ")\n"; } else { echo " wird gesetzt, falls leer\n"; }
/* ------------------------------------------------------------------ */
echo "\n9. WordPress-Reste in den SEO-Artikeln\n";
$seoUids = [232,234,235,236,237,238,240,241,242,243,244,245,246,247,248,249,252];
$sel = $pdo->query("SELECT uid, path_segment, bodytext FROM tx_news_domain_model_news WHERE deleted = 0 AND uid IN (" . implode(',', $seoUids) . ")");
$upd = $pdo->prepare("UPDATE tx_news_domain_model_news SET bodytext = ?, tstamp = ? WHERE uid = ?");
foreach ($sel as $r) {
$b = (string)$r['bodytext'];
$o = $b;
$log = [];
$b = preg_replace('#<img[^>]*src="data:image/[^"]*"[^>]*>\s*#i', '', $b, -1, $c1) ?? $b; if ($c1) { $log[] = "$c1 Platzhalter-Bilder"; }
$b = preg_replace('#<p>[^<]*getWR360PopupSkin[^<]*</p>\s*#i', '', $b, -1, $c2) ?? $b; if ($c2) { $log[] = "Script-Rest"; }
$b = preg_replace('#\s+class="wp-[^"]*"#i', '', $b, -1, $c3) ?? $b; if ($c3) { $log[] = "$c3 wp-Klassen"; }
$b = preg_replace('#\s+data-[a-z0-9-]+="[^"]*"#i', '', $b, -1, $c4) ?? $b; if ($c4) { $log[] = "$c4 data-Attribute"; }
// bare description text between a product <h3> and the following link
$b = preg_replace('#(</h3>)\s*([^<\s][^<]{20,}?)\s*(?=<a |<h[2-4]|<p>|<ul>)#s', '$1<p>$2</p>', $b, -1, $c5) ?? $b; if ($c5) { $log[] = "$c5 nackte Textabsaetze gekapselt"; }
$b = preg_replace('#<a([^>]*)>\s*View products?\s*</a>#i', '<a$1>View products</a>', $b, -1, $c6) ?? $b; if ($c6) { $log[] = "$c6 View-products-Links normalisiert"; }
if ($b !== $o) {
echo " #{$r['uid']} {$r['path_segment']}: " . implode(', ', $log) . "\n";
if ($apply) { $upd->execute([$b, $now, $r['uid']]); }
}
}
/* ------------------------------------------------------------------ */
echo "\n10. News-Zeiger-Records (type=1) -> Inhalt der alten Seite uebernehmen\n";
$allowedTags = '<p><h2><h3><h4><ul><ol><li><strong><em><b><i><a><br><blockquote>';
$stripAttrs = static function (string $html): string {
// keep href/target on links, drop everything else (style, class, ids)
$html = preg_replace_callback('#<a\b([^>]*)>#i', static function (array $m): string {
preg_match('/href="([^"]*)"/i', $m[1], $h);
preg_match('/target="([^"]*)"/i', $m[1], $t);
return '<a' . (isset($h[1]) ? ' href="' . $h[1] . '"' : '') . (isset($t[1]) ? ' target="' . $t[1] . '"' : '') . '>';
}, $html) ?? $html;
return preg_replace('#<(p|h[2-4]|ul|ol|li|strong|em|b|i|blockquote|br)\b[^>]*>#i', '<$1>', $html) ?? $html;
};
$collectContent = null;
$collectContent = static function (int $pid, int $parent, array &$texts, array &$imageRefs) use ($pdo, &$collectContent, $allowedTags, $stripAttrs): void {
$st = $pdo->prepare("SELECT uid, CType, bodytext, header FROM tt_content WHERE pid = ? AND tx_container_parent = ? AND deleted = 0 AND hidden = 0 ORDER BY colPos, sorting");
$st->execute([$pid, $parent]);
foreach ($st->fetchAll() as $el) {
$ctype = (string)$el['CType'];
if (str_starts_with($ctype, 'vitec_container') || str_starts_with($ctype, 'vitec_cols')) {
$collectContent($pid, (int)$el['uid'], $texts, $imageRefs);
continue;
}
if (in_array($ctype, ['image', 'textpic', 'textmedia'], true)) {
$refs = $pdo->prepare("SELECT uid FROM sys_file_reference WHERE tablenames = 'tt_content' AND uid_foreign = ? AND fieldname IN ('image', 'assets', 'media') AND deleted = 0 AND hidden = 0 ORDER BY sorting_foreign");
$refs->execute([(int)$el['uid']]);
foreach ($refs->fetchAll() as $ref) { $imageRefs[] = (int)$ref['uid']; }
}
if (in_array($ctype, ['text', 'textpic', 'textmedia'], true)) {
$t = trim((string)$el['bodytext']);
if ($t !== '') { $texts[] = $stripAttrs(strip_tags($t, $allowedTags)); }
continue;
}
if ($ctype === 'html') {
$raw = (string)$el['bodytext'];
if (preg_match('#<blockquote[^>]*>(.*?)</blockquote>#is', $raw, $q)) {
$cite = '';
if (preg_match('#</blockquote>\s*<span[^>]*>(.*?)</span>#is', $raw, $s)) { $cite = trim(strip_tags($s[1])); }
$texts[] = '<blockquote><p>' . trim(strip_tags($q[1])) . '</p>' . ($cite !== '' ? '<p><em>' . $cite . '</em></p>' : '') . '</blockquote>';
continue;
}
if (stripos($raw, '<style') !== false || stripos($raw, '<script') !== false || mb_strlen(trim(strip_tags($raw))) < 200) {
continue; // layout snippets (buttons, styles)
}
$texts[] = $stripAttrs(strip_tags($raw, $allowedTags));
}
}
};
$pointerRows = $pdo->query("SELECT n.uid, n.pid, n.title, n.path_segment, n.internalurl, n.bodytext, n.fal_media FROM tx_news_domain_model_news n WHERE n.deleted = 0 AND n.type = '1'")->fetchAll();
$pageSlugs = $pdo->prepare("SELECT uid, slug, pid FROM pages WHERE uid = ? AND deleted = 0");
$maxRefSorting = $pdo->prepare("SELECT COALESCE(MAX(sorting_foreign), 0) FROM sys_file_reference WHERE tablenames = 'tx_news_domain_model_news' AND fieldname = 'fal_media' AND uid_foreign = ?");
$copyRef = $pdo->prepare(
"INSERT INTO sys_file_reference (pid, tstamp, crdate, uid_local, uid_foreign, tablenames, fieldname, sorting_foreign, title, alternative, description, crop, link)
SELECT ?, ?, ?, uid_local, ?, 'tx_news_domain_model_news', 'fal_media', ?, title, alternative, description, crop, link FROM sys_file_reference WHERE uid = ?"
);
$updNews = $pdo->prepare("UPDATE tx_news_domain_model_news SET bodytext = ?, type = '0', internalurl = '', fal_media = ?, tstamp = ? WHERE uid = ?");
$updType = $pdo->prepare("UPDATE tx_news_domain_model_news SET type = '0', internalurl = '', tstamp = ? WHERE uid = ?");
$migrated = 0; $typeOnly = 0; $skipped = 0;
foreach ($pointerRows as $n) {
$pid = 0;
if (preg_match('/uid=(\d+)/', (string)$n['internalurl'], $m)) { $pid = (int)$m[1]; } elseif (ctype_digit((string)$n['internalurl'])) { $pid = (int)$n['internalurl']; }
$pageSlugs->execute([$pid]);
$page = $pageSlugs->fetch();
if (!$page || !in_array((int)$page['pid'], [106], true) && (int)$page['uid'] !== 107) {
echo " #{$n['uid']}: Ziel-Seite $pid nicht im alten News-Ast - uebersprungen\n";
$skipped++;
continue;
}
$oldSlug = (string)$page['slug'];
$newUrl = '/news/article/' . $n['path_segment'];
if (trim((string)$n['bodytext']) !== '') {
echo " #{$n['uid']}: hat eigenen Text (" . strlen((string)$n['bodytext']) . " Z.) -> nur type=0" . "\n";
$typeOnly++;
if ($apply) { $updType->execute([$now, $n['uid']]); }
$addRedirect($oldSlug, $newUrl);
continue;
}
$texts = []; $imageRefs = [];
$collectContent($pid, 0, $texts, $imageRefs);
$body = trim(implode("\n", $texts));
$plainLen = mb_strlen(trim(strip_tags($body)));
if ($plainLen < 500) {
echo " #{$n['uid']}: Seite $pid liefert nur $plainLen Zeichen Text - uebersprungen (bleibt Zeiger)\n";
$skipped++;
continue;
}
$imageRefs = array_slice(array_unique($imageRefs), 0, 3);
// Imported placeholder slugs ("default-<hash>") take the old page's slug.
$segment = (string)$n['path_segment'];
if (str_starts_with($segment, 'default-')) {
$candidate = basename($oldSlug);
$newsBySegment->execute([$candidate]);
if ((int)$newsBySegment->fetchColumn() === 0) {
$segment = $candidate;
$newUrl = '/news/article/' . $segment;
echo " #{$n['uid']}: Slug default-... -> $segment\n";
}
}
echo " #{$n['uid']} $segment: $plainLen Zeichen aus " . count($texts) . " Elementen, " . count($imageRefs) . " Bild(er) <- $oldSlug\n";
$migrated++;
if ($apply) {
if ($segment !== (string)$n['path_segment']) {
$pdo->prepare("UPDATE tx_news_domain_model_news SET path_segment = ? WHERE uid = ?")->execute([$segment, $n['uid']]);
}
$sorting = 0;
$maxRefSorting->execute([$n['uid']]);
$sorting = (int)$maxRefSorting->fetchColumn();
$existing = (int)$n['fal_media'];
foreach ($imageRefs as $refUid) {
$sorting += 256;
$copyRef->execute([(int)$n['pid'], $now, $now, (int)$n['uid'], $sorting, $refUid]);
$existing++;
}
$updNews->execute([$body, $existing, $now, $n['uid']]);
}
$addRedirect($oldSlug, $newUrl);
}
echo " uebernommen: $migrated, nur type=0: $typeOnly, uebersprungen: $skipped\n";
echo "\nRedirects neu: $redirectsAdded\n";
echo $apply ? "FERTIG. cache:flush + SSR purge + warm.\n" : "DRY RUN beendet.\n";
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
// Meta description for the /solutions overview (page 2) - was the TCA
// placeholder text until seo_report_fixes_20261110 cleared it. Fill-up only.
$root=null;$dir=__DIR__;for($i=0;$i<5;$i++){if(is_file($dir.'/config/system/settings.php')){$root=$dir;break;}$dir=dirname($dir);}
$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]);
$desc='Explore VITEC video solutions for control rooms and command centres, real-time monitoring, video distribution and streaming, capture and encoding, video walls, digital signage, enterprise communications and security.';
$st=$pdo->prepare("UPDATE pages SET description=?, tstamp=UNIX_TIMESTAMP() WHERE uid=2 AND (description IS NULL OR description='')");
$st->execute([$desc]);
echo "Seite 2 (/solutions): ".$st->rowCount()." Zeile(n) aktualisiert\n";
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
// Setzt die fehlende Meta-Description der Success-Stories-Uebersicht (Seite 4).
// Fill-up only: eine vorhandene Description wird nicht ueberschrieben.
$root=null;$dir=__DIR__;for($i=0;$i<5;$i++){if(is_file($dir.'/config/system/settings.php')){$root=$dir;break;}$dir=dirname($dir);}
$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]);
$desc='Discover how organizations worldwide use VITEC and Datapath video solutions: customer success stories from broadcast, defense, enterprise, sports and public safety.';
$st=$pdo->prepare("UPDATE pages SET description=?, tstamp=UNIX_TIMESTAMP() WHERE uid=4 AND (description IS NULL OR description='')");
$st->execute([$desc]);
echo "Seite 4: ".$st->rowCount()." Zeile(n) aktualisiert\n";
+147
View File
@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
/**
* Zwei Quick Wins aus Arnes Tickets (2026-10-28):
*
* A) 0012-FE Punkt 2: die Karte "Request access" auf /support (uid 11489)
* soll auf /support/login-to-helpdesk#form-helpdesk zeigen (Anker auf
* das HelpDesk-Formular; Schema form-<formKey>).
*
* B) 0009-FE Punkt 7: vitec_ctabanner auf Market-/Solution-Seiten haben
* Labels ("Contact our team" / "Request a demo"), aber keine Links.
* Leere primary_cta mit Contact-Label -> /contact, leere secondary_cta
* mit Demo-Label -> /contact/request-a-demo (als t3://page?uid=...).
* Gefuellte Links werden NIE angefasst.
*
* Plain PDO (Muster migrations/check_bg.php).
*
* Usage: php migrations/ticket_quickwins_20261028.php (inspect)
* php migrations/ticket_quickwins_20261028.php --apply
*/
error_reporting(E_ALL);
ini_set('display_errors', '1');
const REQUEST_ACCESS_UID = 11489;
const ANCHOR = 'form-helpdesk';
$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]
);
echo ($apply ? 'APPLY' : 'INSPECT') . "\n";
function pageUidBySlug(PDO $pdo, string $slug): int
{
$stmt = $pdo->prepare('SELECT uid FROM pages WHERE deleted = 0 AND slug = ? AND sys_language_uid IN (0,-1)');
$stmt->execute([$slug]);
return (int)$stmt->fetchColumn();
}
// --- A) Request-access-Karte -> Anker ---------------------------------------
echo "\n--- A) Request access -> #" . ANCHOR . " ---\n";
$stmt = $pdo->prepare('SELECT uid, header, vitec_primary_cta FROM tt_content WHERE deleted = 0 AND uid = ?');
$stmt->execute([REQUEST_ACCESS_UID]);
$card = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$card) {
echo "Karte #" . REQUEST_ACCESS_UID . " nicht gefunden - uebersprungen.\n";
} elseif (stripos((string)$card['header'], 'request access') === false) {
echo "Karte #" . REQUEST_ACCESS_UID . " heisst \"" . $card['header'] . "\" (erwartet: Request access) - NICHT angefasst.\n";
} else {
$cta = (string)$card['vitec_primary_cta'];
if (str_contains($cta, '#' . ANCHOR)) {
echo "Karte #" . REQUEST_ACCESS_UID . ": Anker bereits gesetzt ($cta) - nichts zu tun.\n";
} elseif ($cta === '') {
echo "Karte #" . REQUEST_ACCESS_UID . ": primary_cta ist LEER - erst klaeren, nicht angefasst.\n";
} else {
$new = $cta . '#' . ANCHOR;
echo "Karte #" . REQUEST_ACCESS_UID . " (\"" . $card['header'] . "\"): \"$cta\" -> \"$new\"\n";
if ($apply) {
$pdo->prepare('UPDATE tt_content SET vitec_primary_cta = ?, tstamp = UNIX_TIMESTAMP() WHERE uid = ?')
->execute([$new, REQUEST_ACCESS_UID]);
echo " geschrieben.\n";
}
}
}
// --- B) CTA-Banner-Defaults auf Market-/Solution-Seiten ----------------------
echo "\n--- B) ctabanner-Links auf /markets + /solutions ---\n";
$contactUid = pageUidBySlug($pdo, '/contact');
$demoUid = pageUidBySlug($pdo, '/contact/request-a-demo');
$solutionsUid = pageUidBySlug($pdo, '/solutions');
if ($contactUid === 0 || $demoUid === 0 || $solutionsUid === 0) {
exit("Zielseiten nicht aufloesbar (/contact=#$contactUid, request-a-demo=#$demoUid, /solutions=#$solutionsUid) - Abbruch.\n");
}
echo "Ziele: /contact=#$contactUid, request-a-demo=#$demoUid, /solutions=#$solutionsUid\n";
$rows = $pdo->query(
"SELECT c.uid, p.slug, c.vitec_primary_cta, c.vitec_primary_cta_label,
c.vitec_secondary_cta, c.vitec_secondary_cta_label
FROM tt_content c JOIN pages p ON p.uid = c.pid AND p.deleted = 0
WHERE c.deleted = 0 AND c.CType = 'vitec_ctabanner'
AND (p.slug = '/markets' OR p.slug LIKE '/markets/%' OR p.slug = '/solutions' OR p.slug LIKE '/solutions/%')
ORDER BY p.slug"
)->fetchAll(PDO::FETCH_ASSOC);
$setPrimary = 0;
$setSecondary = 0;
foreach ($rows as $row) {
$actions = [];
$pLabel = (string)$row['vitec_primary_cta_label'];
$sLabel = (string)$row['vitec_secondary_cta_label'];
// Sub-Solution-Variante: "Contact our team" / "Request a demo";
// Market-Variante (Fund 28.10.): "Talk to our team" / "Explore solutions"
if ((string)$row['vitec_primary_cta'] === '' && (stripos($pLabel, 'contact') !== false || stripos($pLabel, 'talk') !== false)) {
$actions['vitec_primary_cta'] = 't3://page?uid=' . $contactUid;
$setPrimary++;
}
if ((string)$row['vitec_secondary_cta'] === '') {
if (stripos($sLabel, 'demo') !== false) {
$actions['vitec_secondary_cta'] = 't3://page?uid=' . $demoUid;
$setSecondary++;
} elseif (stripos($sLabel, 'solution') !== false) {
$actions['vitec_secondary_cta'] = 't3://page?uid=' . $solutionsUid;
$setSecondary++;
}
}
if ($actions === []) {
continue;
}
echo "#" . $row['uid'] . " " . $row['slug'] . ": " . implode(', ', array_map(
static fn(string $field, string $value): string => $field . ' = ' . $value,
array_keys($actions), $actions
)) . "\n";
if ($apply) {
$set = implode(', ', array_map(static fn(string $f): string => "$f = ?", array_keys($actions)));
$pdo->prepare("UPDATE tt_content SET $set, tstamp = UNIX_TIMESTAMP() WHERE uid = ?")
->execute([...array_values($actions), (int)$row['uid']]);
}
}
echo "\n" . count($rows) . " Banner geprueft · $setPrimary primary + $setSecondary secondary " . ($apply ? 'gesetzt' : 'zu setzen') . ".\n";
if (!$apply) {
echo "Inspect only - mit --apply schreiben.\n";
} else {
echo "Danach: vendor/bin/typo3 cache:flush\n";
}
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
/**
* Old Datapath/VITEC articles link to partner sites that no longer exist
* (Seobility "Probleme mit externen Links", decision Harry 2026-11-10:
* remove the link, keep the text). The anchor is replaced by its text.
*
* Dry run: php migrations/unlink_dead_external_20261110.php
* Write: php migrations/unlink_dead_external_20261110.php --apply
*/
error_reporting(E_ALL);
ini_set('display_errors', '1');
$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("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]
);
echo $apply ? "== APPLY ==\n" : "== DRY RUN (add --apply to write) ==\n";
$now = time();
$hosts = [
'queensawardsmagazine.com',
'samminton.com',
'eetgroup.com',
'broadcast-asia.com',
'tecnomultimedia.com',
'sentryvideowalls.com',
'exertis.co.uk',
'infocomm-sea.com',
'surveymonkey.co.uk',
'exterity.com',
'eurekanetwork.org',
];
$hostAlt = implode('|', array_map(fn($h) => preg_quote($h, '~'), $hosts));
// <a ... href="...host..." ...>text</a> -> text
$pattern = '~<a\b[^>]*\bhref="[^"]*(?:' . $hostAlt . ')[^"]*"[^>]*>(.*?)</a>~is';
$purge = [];
$total = 0;
foreach (['tx_news_domain_model_news' => 'path_segment', 'tt_content' => 'pid'] as $table => $ref) {
$like = implode(' OR ', array_map(fn($h) => "bodytext LIKE '%$h%'", $hosts));
foreach ($pdo->query("SELECT uid, $ref AS ref, bodytext FROM $table WHERE deleted = 0 AND ($like)") as $r) {
$new = (string)preg_replace_callback($pattern, static function (array $m): string {
return $m[1];
}, (string)$r['bodytext'], -1, $count);
if ($count === 0 || $new === (string)$r['bodytext']) {
echo " $table #{$r['uid']}: Host im Text, aber kein <a>-Tag erkannt (manuell pruefen)\n";
continue;
}
preg_match_all('~https?://[^"\s<]*(?:' . $hostAlt . ')[^"\s<]*~i', (string)$r['bodytext'], $urls);
printf(" %s #%-4d (%s): %d Link(s) entfernt: %s\n", $table, $r['uid'], $r['ref'], $count, implode(', ', array_unique($urls[0])));
$total += $count;
if ($table === 'tx_news_domain_model_news') { $purge[] = '/news/article/' . $r['ref']; }
if ($apply) {
$pdo->prepare("UPDATE $table SET bodytext = ?, tstamp = ? WHERE uid = ?")->execute([$new, $now, (int)$r['uid']]);
}
}
}
echo "\n$total Link(s) in " . count($purge) . " News" . ($apply ? ' entfernt' : ' wuerden entfernt') . ".\n";
echo "SSR-Pfade:\n " . implode("\n ", $purge) . "\n";
+161
View File
@@ -0,0 +1,161 @@
<?php
/**
* why_band_unwrap_20261111.php (Ticket 0106-FE, Abschnitt "alte Seiten")
*
* Die drei 2026-10-Kategorieseiten ohne Menueplatz tragen ihre "Why VITEC?"-
* Karten noch als `vertical`-Karten IN vitec_cols_33_33_33-Containern
* (wrap-cards-grid vom 27.10.). Das Frontend baut das Icon-Band (0039-BE)
* aber nur aus `vitec_card` mit layout_variant icon_card DIREKT hinter dem
* Intro. Entscheidung Harry 2026-11-11: diese Seiten bekommen das Band auch.
*
* Umbau je Seite: Intro "Why VITEC?" finden, die direkt folgenden Container
* (nur solche, die ausschliesslich vitec_card enthalten) aufloesen - Karten
* nach colPos 0 / tx_container_parent 0 / icon_card, leere Container soft-
* loeschen, colPos 0 mit Stride 256 neu sortieren (Band direkt hinter dem
* Intro, Rest in bisheriger Folge). Direktes SQL -> Auto-Purge greift NICHT:
* danach cache:flush + vitec:ssr-purge/-warm mit den Pfaden.
*
* php migrations/why_band_unwrap_20261111.php # INSPECT (default)
* php migrations/why_band_unwrap_20261111.php --apply # schreibt
*/
declare(strict_types=1);
$apply = in_array('--apply', $argv, true);
$settings = require __DIR__ . '/../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]
);
echo $apply ? "APPLY\n\n" : "INSPECT - nichts wird geschrieben (--apply zum Schreiben)\n\n";
$slugs = [
'/products/graphics-and-multi-display',
'/products/infrastructure-and-accessories',
'/products/video-capture',
];
$norm = static fn(string $s): string => (string)preg_replace('/[^a-z0-9]+/', '', strtolower($s));
$done = 0;
foreach ($slugs as $slug) {
$st = $pdo->prepare('SELECT uid, title FROM pages WHERE deleted = 0 AND slug = ? AND sys_language_uid = 0');
$st->execute([$slug]);
$page = $st->fetch();
if (!$page) {
echo "$slug: Seite NICHT gefunden - uebersprungen\n\n";
continue;
}
$pid = (int)$page['uid'];
$st = $pdo->prepare(
'SELECT uid, CType, header, sorting, vitec_layout_variant FROM tt_content
WHERE pid = ? AND deleted = 0 AND colPos = 0 AND sys_language_uid = 0
AND (tx_container_parent IS NULL OR tx_container_parent = 0)
ORDER BY sorting, uid'
);
$st->execute([$pid]);
$top = $st->fetchAll();
echo "== $slug (pid $pid) \"{$page['title']}\" - " . count($top) . " Top-Level-Elemente in colPos 0\n";
// bereits umgebaut? (Band = Intro + direkt folgende Karten top-level)
$whyIdx = null;
foreach ($top as $i => $el) {
if ($el['CType'] === 'vitec_introparagraph' && str_starts_with($norm((string)$el['header']), 'whyvitec')) {
$whyIdx = $i;
break;
}
}
if ($whyIdx === null) {
echo " kein Why-VITEC-Intro gefunden - uebersprungen\n\n";
continue;
}
$intro = $top[$whyIdx];
if (isset($top[$whyIdx + 1]) && $top[$whyIdx + 1]['CType'] === 'vitec_card') {
echo " Band liegt bereits top-level hinter Intro #{$intro['uid']} - nichts zu tun\n\n";
continue;
}
// direkt folgende Container einsammeln, solange sie nur Karten enthalten
$containers = [];
$cards = [];
for ($i = $whyIdx + 1; $i < count($top); $i++) {
$el = $top[$i];
if (!str_starts_with((string)$el['CType'], 'vitec_cols')) {
break;
}
$kids = $pdo->query(
'SELECT uid, CType, header, colPos, sorting, vitec_layout_variant FROM tt_content
WHERE deleted = 0 AND tx_container_parent = ' . (int)$el['uid'] . ' ORDER BY colPos, sorting, uid'
)->fetchAll();
$onlyCards = $kids !== [] && count(array_filter($kids, static fn(array $k): bool => $k['CType'] !== 'vitec_card')) === 0;
if (!$onlyCards) {
break;
}
$containers[] = $el;
foreach ($kids as $k) {
$cards[] = $k;
}
}
if ($cards === []) {
echo " Intro #{$intro['uid']} \"{$intro['header']}\" ohne folgende Karten-Container - uebersprungen\n\n";
continue;
}
printf(" Intro #%d \"%s\"\n", $intro['uid'], $intro['header']);
printf(" %d Container (#%s) mit %d Karte(n):\n", count($containers), implode(', #', array_column($containers, 'uid')), count($cards));
foreach ($cards as $c) {
printf(" #%-6d %-14s \"%s\"\n", $c['uid'], (string)$c['vitec_layout_variant'], (string)$c['header']);
}
// neue Reihenfolge: bis Intro, dann Karten, dann Rest ohne die Container
$containerUids = array_map('intval', array_column($containers, 'uid'));
$newOrder = [];
foreach ($top as $i => $el) {
if (in_array((int)$el['uid'], $containerUids, true)) {
continue;
}
$newOrder[] = ['uid' => (int)$el['uid'], 'label' => $el['CType'] . ' ' . $el['header']];
if ($i === $whyIdx) {
foreach ($cards as $c) {
$newOrder[] = ['uid' => (int)$c['uid'], 'label' => 'vitec_card (icon_card) ' . $c['header']];
}
}
}
echo " Neue Reihenfolge colPos 0:\n";
$sorting = 0;
foreach ($newOrder as $el) {
$sorting += 256;
printf(" %5d #%-6d %s\n", $sorting, $el['uid'], $el['label']);
}
if ($apply) {
$pdo->beginTransaction();
$upCard = $pdo->prepare("UPDATE tt_content SET colPos = 0, tx_container_parent = 0, vitec_layout_variant = 'icon_card', tstamp = UNIX_TIMESTAMP() WHERE uid = ?");
foreach ($cards as $c) {
$upCard->execute([(int)$c['uid']]);
}
$pdo->exec('UPDATE tt_content SET deleted = 1, tstamp = UNIX_TIMESTAMP() WHERE uid IN (' . implode(',', $containerUids) . ')');
$upSort = $pdo->prepare('UPDATE tt_content SET sorting = ?, tstamp = UNIX_TIMESTAMP() WHERE uid = ?');
$sorting = 0;
foreach ($newOrder as $el) {
$sorting += 256;
$upSort->execute([$sorting, $el['uid']]);
}
$pdo->commit();
echo " GESCHRIEBEN\n";
}
$done++;
echo "\n";
}
echo ($apply ? 'FERTIG' : 'WUERDE') . ": $done Seite(n) umgebaut.\n";
if ($apply && $done > 0) {
echo "Danach: vendor/bin/typo3 cache:flush && vendor/bin/typo3 vitec:ssr-purge " . implode(' ', $slugs) . " && vendor/bin/typo3 vitec:ssr-warm " . implode(' ', $slugs) . "\n";
}
@@ -136,6 +136,19 @@ final class MenuBuilder
'columns' => $columns,
];
// Page-tree display (2026-11-02): same columns, two presentations -
// `columns` side by side, or `rail`: the columns as a list on the
// left, the hovered column's items as image cards on the right
// (`cardSize` regular|compact). Additive: a front end that does not
// know `display` keeps rendering columns.
if ($layout === 'pagetree') {
$display = (string)($entry['tree_display'] ?? 'columns');
$built['display'] = str_starts_with($display, 'rail') ? 'rail' : 'columns';
if ($built['display'] === 'rail') {
$built['cardSize'] = $display === 'rail-compact' ? 'compact' : 'regular';
}
}
// Catalog panels: the teaser cards serve as the featured tiles (left
// column), the catalog data itself comes from a listener of
// BuildCatalogEvent - this extension does not know the data source.
@@ -229,10 +242,20 @@ final class MenuBuilder
foreach ($this->pageRepository->getMenu($rootUid, '*', 'sorting', self::NAV_WHERE) as $child) {
$items = [];
foreach ($this->pageRepository->getMenu((int)$child['uid'], '*', 'sorting', self::NAV_WHERE) as $grandchild) {
$items[] = [
$item = [
'title' => (string)(($grandchild['nav_title'] ?? '') !== '' ? $grandchild['nav_title'] : $grandchild['title']),
'link' => $this->url('t3://page?uid=' . (int)$grandchild['uid'], $cObj),
];
// card data for the `rail` display (2026-11-02), same rules
// as the spotlight nav: abstract as teaser, page media image
if ($stage >= 2) {
$teaser = trim((string)($grandchild['abstract'] ?? ''));
if ($teaser !== '') {
$item['teaser'] = $teaser;
}
$item['image'] = $this->image('pages', 'media', (int)$grandchild['uid']);
}
$items[] = $item;
}
$column = [
'title' => (string)(($child['nav_title'] ?? '') !== '' ? $child['nav_title'] : $child['title']),
@@ -7,6 +7,8 @@ namespace Evomedien\EvoMegamenuJson\UserFunc;
use Doctrine\DBAL\ParameterType;
use Evomedien\EvoMegamenuJson\Service\MenuBuilder;
use TYPO3\CMS\Core\Attribute\AsAllowedCallable;
use TYPO3\CMS\Core\Cache\CacheDataCollectorInterface;
use TYPO3\CMS\Core\Cache\CacheTag;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\Restriction\FrontendRestrictionContainer;
use TYPO3\CMS\Core\Utility\GeneralUtility;
@@ -24,10 +26,24 @@ use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
* 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.
*
* Cache (2026-11-02): the menu is part of every cached page, but its records
* live on a storage folder - saving them cleared only that folder, and every
* page cached before kept the old menu until a full flush. The page is
* therefore tagged with the menu tables; the DataHandler flushes the tags
* `<table>` and `<table>_<uid>` of every record it writes, so any save in the
* menu refreshes all pages carrying it. Data the panels read from the page
* tree (pagetree, catalog, spotlight) is not covered and still needs a flush.
*/
final class MegamenuJsonRenderer
{
private const CTYPE = 'evomegamenujson_megamenu';
private const RECORD_TABLES = [
'tx_evomegamenujson_entry',
'tx_evomegamenujson_column',
'tx_evomegamenujson_item',
'tx_evomegamenujson_teaser',
];
protected ?ContentObjectRenderer $cObj = null;
@@ -63,6 +79,7 @@ final class MegamenuJsonRenderer
}
$menu = GeneralUtility::makeInstance(MenuBuilder::class)->build($uid, $cObj, $row);
$this->tagPageCache($cObj, $uid);
return (string)json_encode($menu, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
} catch (\Throwable) {
@@ -71,6 +88,21 @@ final class MegamenuJsonRenderer
}
}
/** Tags the page cache with the menu element and tables (see class comment). */
private function tagPageCache(ContentObjectRenderer $cObj, int $contentUid): void
{
try {
$collector = $cObj->getRequest()->getAttribute('frontend.cache.collector');
if (!$collector instanceof CacheDataCollectorInterface) {
return;
}
$tags = array_map(static fn(string $table): CacheTag => new CacheTag($table), self::RECORD_TABLES);
$collector->addCacheTags(new CacheTag('tt_content_' . $contentUid), ...$tags);
} catch (\Throwable) {
// without tags the menu still renders, it just ages until a flush
}
}
/** uid of the first megamenu element on a storage folder, 0 when none. */
private function firstElementOn(int $pid): int
{
@@ -43,7 +43,7 @@ return [
'1' => [
'showitem' => '
--div--;' . $ll . 'tab.general,
hidden, title, link, layout,
hidden, title, link, layout, tree_display, source_pages, source_categories,
--div--;' . $ll . 'tab.columns,
menu_columns,
--div--;' . $ll . 'tab.teasers,
@@ -115,6 +115,43 @@ return [
],
],
],
'tree_display' => [
// page-tree panels only: same data, two presentations
'displayCond' => 'FIELD:layout:=:pagetree',
'label' => $ll . 'tree_display',
'description' => $ll . 'tree_display.description',
'config' => [
'type' => 'select',
'renderType' => 'selectSingle',
'default' => 'columns',
'items' => [
['label' => $ll . 'tree_display.columns', 'value' => 'columns'],
['label' => $ll . 'tree_display.rail', 'value' => 'rail'],
['label' => $ll . 'tree_display.rail-compact', 'value' => 'rail-compact'],
],
],
],
// data-driven layouts: where the site package's listener reads its
// records from - the extension only passes the entry row on
'source_pages' => [
'displayCond' => 'FIELD:layout:IN:catalog,spotlight',
'label' => $ll . 'source_pages',
'description' => $ll . 'source_pages.description',
'config' => [
'type' => 'group',
'allowed' => 'pages',
'size' => 3,
'maxitems' => 20,
],
],
'source_categories' => [
'displayCond' => 'FIELD:layout:IN:catalog,spotlight',
'label' => $ll . 'source_categories',
'description' => $ll . 'source_categories.description',
'config' => [
'type' => 'category',
],
],
'menu_columns' => [
'label' => $ll . 'menu_columns',
+27 -4
View File
@@ -117,6 +117,13 @@ final class ProvideCatalog
`getEntry()` hands the listener the raw entry row (to tell several catalog
entries apart), `getStage()` the content stage.
**Data source fields** (catalog and spotlight entries): *Data source
folders* (`source_pages`, comma list of page uids) and *Data source
categories* (`source_categories`, sys_category MM with
`tablenames = tx_evomegamenujson_entry`, `fieldname = source_categories`).
The extension only stores them; the listener reads them from the entry row
and decides what they mean (empty = its own default).
### Layout `pagetree`
A structure panel that can never age against the site: the columns mirror
@@ -125,7 +132,15 @@ page, its children as the items. Hidden or nav-hidden pages leave the menu
with the next cache flush. Curated columns are ignored; teasers and the
featured slot stay editorial. From stage 2 a child page with media gets its
first image as `image` on the column - attach images in the page
properties and the panel turns visual by itself.
properties and the panel turns visual by itself. From stage 2 the items
carry `image` (first page media or `null`) and, when set, the page
`abstract` as `teaser`.
The entry field *Page tree display* picks the presentation and travels as
`display`: `columns` (default, columns side by side) or `rail` (the columns
as a list on the left, the hovered column's items as image cards on the
right) plus `cardSize` (`regular` | `compact`) for `rail`. A front end that
ignores `display` keeps rendering columns.
### Layout `spotlight`
@@ -148,9 +163,13 @@ The VITEC site package ships `ProvideInsightsSpotlight`, which supplies
}
```
- `news`: the three latest news records (storage pid 11, type 0 - the Solr
convention), `link` = `/news/article/<path_segment>` per the NewsDetail
route enhancer, image from `fal_media` as processed 640px webp.
- `news`: the three latest news records (type 0 - the Solr convention)
from the entry's *Data source folders* (empty = 11 + 442) and, when set,
in one of its *Data source categories*; `link` =
`/news/article/<path_segment>` per the NewsDetail route enhancer, image
from `fal_media` as processed 640px webp. Pages carrying the menu are
tagged with the news and event tables, so saving such a record refreshes
the menu.
- `events`: the next two events (`hideonwebsite` = 0, upcoming by
eventstart/eventend), `link` = the record's `eventurl` or the events
page, image processed the same way.
@@ -163,5 +182,9 @@ The VITEC site package ships `ProvideInsightsSpotlight`, which supplies
entry.
* The renderer never throws: a broken menu returns an empty value rather than
taking the page payload down.
* Caching: every page carrying the menu is tagged with the menu tables and
`tt_content_<uid>` of the menu element, so saving any menu record refreshes
all of them. Panels built from the page tree (`pagetree`, `catalog`,
`spotlight`) pick up page changes with the next cache flush.
* Without `friendsoftypo3/headless` the element has no JSON envelope to render
into — the extension is built for headless installations.
@@ -27,6 +27,15 @@
<trans-unit id="tx_evomegamenujson_entry.layout.catalog"><source>Catalog (data-driven: categories and items from the site package, teaser cards as featured tiles)</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.layout.pagetree"><source>Page tree (data-driven: one column per child of the linked page, its children as items; curated columns are ignored)</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.layout.spotlight"><source>Spotlight (data-driven: left-rail nav from the linked page's children plus live visual cards - latest news, next events - supplied by the site package; curated columns are ignored)</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.source_pages"><source>Data source folders</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.source_pages.description"><source>For data-driven panels: the folders the site package reads its records from (e.g. news folders). Empty = the site package's default.</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.source_categories"><source>Data source categories</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.source_categories.description"><source>For data-driven panels: only records in one of these categories. Empty = no category restriction.</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.tree_display"><source>Page tree display</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.tree_display.description"><source>Columns: one column per child page with its children as links. List + cards: the child pages as a list on the left, the children of the hovered one as image cards on the right (images from the page media).</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.tree_display.columns"><source>Columns side by side</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.tree_display.rail"><source>List on the left, cards on hover</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.tree_display.rail-compact"><source>List on the left, small cards on hover (for many sub-pages)</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>
@@ -8,6 +8,9 @@ CREATE TABLE tx_evomegamenujson_entry (
title varchar(255) DEFAULT '' NOT NULL,
link varchar(1024) DEFAULT '' NOT NULL,
layout varchar(30) DEFAULT 'columns' NOT NULL,
tree_display varchar(30) DEFAULT 'columns' NOT NULL,
source_pages text,
source_categories int(11) unsigned DEFAULT '0' NOT NULL,
menu_columns int(11) DEFAULT '0' NOT NULL,
menu_teasers int(11) DEFAULT '0' NOT NULL,
featured_enable tinyint(1) unsigned DEFAULT '0' NOT NULL,
@@ -32,6 +32,12 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
* a missing numbered file falls back to the generic grey placeholder
* and is reported for generation
*
* Market pages (0010-FE): an "Explore <Market>" card whose primary_cta
* points to a /markets/... page gets that TARGET page's first media file
* (mirrors the marketlist cards); these cards keep their text_only
* variant (the FE styles them as market cards). Flipcards are skipped
* entirely - they carry icons, never photos.
*
* A card that already has a vitec_image reference is NEVER touched, but it
* still advances the numbering, so re-runs stay stable. Editors swap the
* placeholder for the real asset on the element.
@@ -45,7 +51,7 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
)]
final class AttachCardImagesCommand extends Command
{
private const ROOTS = ['/products', '/insights', '/support', '/contact'];
private const ROOTS = ['/products', '/insights', '/support', '/contact', '/markets'];
private const FIRST_SLOT = 10;
protected function configure(): void
@@ -77,7 +83,7 @@ final class AttachCardImagesCommand extends Command
$datamap = [];
$newIndex = 0;
$stats = ['photo' => 0, 'placeholder' => 0, 'grey' => 0, 'kept' => 0, 'variant' => 0, 'missing' => []];
$stats = ['photo' => 0, 'pagemedia' => 0, 'placeholder' => 0, 'grey' => 0, 'kept' => 0, 'variant' => 0, 'upgraded' => 0, 'missing' => []];
foreach ($pageUids as $pageUid => $slug) {
$cards = $content->fetchAllAssociative(
"SELECT uid, header, vitec_primary_cta, vitec_layout_variant FROM tt_content
@@ -92,17 +98,43 @@ final class AttachCardImagesCommand extends Command
$slot = self::FIRST_SLOT - 1;
foreach ($cards as $card) {
$cardUid = (int)$card['uid'];
if ((string)$card['vitec_layout_variant'] === 'flipcard') {
continue; // flipcards carry icons (vitec_icon), never photos
}
$cta = (string)$card['vitec_primary_cta'];
$isProduct = (bool)preg_match('#^/product/([a-z0-9+-]+)$#', $cta, $matches);
if (!$isProduct) {
$slot++;
}
$existing = (int)$refs->fetchOne(
"SELECT COUNT(*) FROM sys_file_reference WHERE deleted = 0 AND tablenames = 'tt_content'
AND fieldname = 'vitec_image' AND uid_foreign = ?",
$existingRef = $refs->fetchAssociative(
"SELECT r.uid, r.uid_local, f.identifier FROM sys_file_reference r
JOIN sys_file f ON f.uid = r.uid_local
WHERE r.deleted = 0 AND r.tablenames = 'tt_content'
AND r.fieldname = 'vitec_image' AND r.uid_foreign = ?
ORDER BY r.sorting_foreign LIMIT 1",
[$cardUid]
);
if ($existing > 0) {
if ($existingRef) {
// upgrade pass (2026-10-28): a product card that got the
// generic grey because its record had no image swaps to
// the real photo once the delivery arrived
if ($isProduct && str_ends_with((string)$existingRef['identifier'], 'placeholder-grey.png')) {
$photo = $this->productImageIdentifier($pool, $matches[1]);
if ($photo !== null && $storage->hasFile($photo)) {
$output->writeln(sprintf(' ^ #%d "%s": grau -> %s', $cardUid, (string)$card['header'], basename($photo)));
$stats['upgraded']++;
if ($apply) {
$datamap['sys_file_reference'][(string)(int)$existingRef['uid']] = [
'uid_local' => $storage->getFile($photo)->getUid(),
];
}
if ((string)$card['vitec_layout_variant'] === 'text_only') {
$datamap['tt_content'][(string)$cardUid]['vitec_layout_variant'] = 'vertical';
$stats['variant']++;
}
continue;
}
}
$stats['kept']++;
// a card that carries an image must not stay on the
// explicit no-image variant (found 2026-10-27: the FE
@@ -115,6 +147,7 @@ final class AttachCardImagesCommand extends Command
}
$identifier = null;
$note = '';
$isMarketCard = false;
if ($isProduct) {
$identifier = $this->productImageIdentifier($pool, $matches[1]);
if ($identifier !== null) {
@@ -125,6 +158,13 @@ final class AttachCardImagesCommand extends Command
$stats['grey']++;
$note = 'record ' . $matches[1] . ' has NO image - generic grey';
}
} elseif (($pageMedia = $this->marketPageMediaIdentifier($pool, $cta)) !== null) {
// 0010-FE: mirror the marketlist cards, which show the
// target sub-market page's media
$identifier = $pageMedia;
$isMarketCard = true;
$stats['pagemedia']++;
$note = 'target page media';
} else {
$identifier = 'placeholders/pages/' . $pageUid . '-' . $slot . '.png';
if ($storage->hasFile($identifier)) {
@@ -145,7 +185,9 @@ final class AttachCardImagesCommand extends Command
if (!$apply) {
continue;
}
if ((string)$card['vitec_layout_variant'] === 'text_only') {
// market cards keep text_only - the FE keys its market-card
// styling on that variant (0010-FE) and renders the image anyway
if (!$isMarketCard && (string)$card['vitec_layout_variant'] === 'text_only') {
$datamap['tt_content'][(string)$cardUid]['vitec_layout_variant'] = 'vertical';
$stats['variant']++;
}
@@ -161,8 +203,8 @@ final class AttachCardImagesCommand extends Command
}
}
$output->writeln(sprintf("\n%d product photo(s), %d numbered placeholder(s), %d generic grey, %d card(s) already had an image, %d variant flip(s) text_only->vertical%s",
$stats['photo'], $stats['placeholder'], $stats['grey'], $stats['kept'], $stats['variant'],
$output->writeln(sprintf("\n%d product photo(s), %d target page media, %d numbered placeholder(s), %d generic grey, %d card(s) already had an image, %d grey->photo upgrade(s), %d variant flip(s) text_only->vertical%s",
$stats['photo'], $stats['pagemedia'], $stats['placeholder'], $stats['grey'], $stats['kept'], $stats['upgraded'], $stats['variant'],
$stats['missing'] === [] ? '' : ' · MISSING numbered files: ' . implode(', ', $stats['missing'])));
if (!$apply) {
@@ -184,6 +226,32 @@ final class AttachCardImagesCommand extends Command
return Command::SUCCESS;
}
/**
* First media file of the /markets/... page the card links to (plain
* slug or t3://page?uid=N); null when the target is no market page or
* has no media.
*/
private function marketPageMediaIdentifier(ConnectionPool $pool, string $cta): ?string
{
$pages = $pool->getConnectionForTable('pages');
$page = null;
if (preg_match('#^t3://page\?uid=(\d+)#', $cta, $m)) {
$page = $pages->fetchAssociative('SELECT uid, slug FROM pages WHERE deleted = 0 AND uid = ?', [(int)$m[1]]);
} elseif (preg_match('#^/markets/[a-z0-9+/-]+$#', $cta)) {
$page = $pages->fetchAssociative('SELECT uid, slug FROM pages WHERE deleted = 0 AND slug = ? AND sys_language_uid IN (0,-1)', [$cta]);
}
if (!$page || !str_starts_with((string)$page['slug'], '/markets/')) {
return null;
}
$identifier = $pool->getConnectionForTable('sys_file_reference')->fetchOne(
"SELECT f.identifier FROM sys_file_reference r JOIN sys_file f ON f.uid = r.uid_local
WHERE r.deleted = 0 AND r.hidden = 0 AND r.tablenames = 'pages'
AND r.fieldname = 'media' AND r.uid_foreign = ?
ORDER BY r.sorting_foreign LIMIT 1",
[(int)$page['uid']]
);
return $identifier ? ltrim((string)$identifier, '/') : null;
}
/** First product photo, falling back to the hero image; null when the record has neither. */
private function productImageIdentifier(ConnectionPool $pool, string $slug): ?string
{
@@ -24,12 +24,20 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
* Image per page: the numbered placeholder
* `fileadmin/placeholders/pages/<pid>-08.png` when it exists (slot 08 =
* page media / menu tile; the number in the picture doubles as the image
* wish list towards the agency), otherwise the generic wide grey
* placeholder. A page that already has a media reference is NEVER touched -
* editors swap the placeholder for the real image in the page properties.
* wish list towards the agency), else the page's own hero background
* (`vitec_bgimage`, same file referenced - 2026-11-02, for the sub-market /
* sub-solution cards of the megamenu `rail` display), otherwise the generic
* wide grey placeholder. A page that already has a media reference is NEVER
* touched - editors swap the image in the page properties.
*
* --depth=2 targets the grandchildren (e.g. the sub-markets below
* /markets/<market>) instead of the children; --depth=3 reaches through two
* levels (sysfolders pass as intermediate parents, so the product overview
* pages below /products > category folder > group folder are covered).
*
* vendor/bin/typo3 vitec:attach-page-media --root=/support
* vendor/bin/typo3 vitec:attach-page-media --root=/support --apply
* vendor/bin/typo3 vitec:attach-page-media --root=/markets --depth=2 --apply
* vendor/bin/typo3 vitec:attach-page-media --root=/products --depth=3 --apply
*/
#[AsCommand(
name: 'vitec:attach-page-media',
@@ -40,6 +48,7 @@ final class AttachPageMediaCommand extends Command
protected function configure(): void
{
$this->addOption('root', null, InputOption::VALUE_REQUIRED, 'Slug of the root page, e.g. /support');
$this->addOption('depth', null, InputOption::VALUE_REQUIRED, '1 = children of the root, 2 = their children, 3 = one level further', '1');
$this->addOption('apply', null, InputOption::VALUE_NONE, 'Write changes (default: dry run)');
}
@@ -56,11 +65,18 @@ final class AttachPageMediaCommand extends Command
$output->writeln('<error>Root page ' . $rootSlug . ' not found.</error>');
return Command::FAILURE;
}
$children = $pages->fetchAllAssociative(
'SELECT uid, title FROM pages WHERE deleted = 0 AND hidden = 0 AND doktype = 1 AND pid = ? AND sys_language_uid IN (0,-1) ORDER BY sorting',
[(int)$root['uid']]
);
$output->writeln(($apply ? 'APPLY' : 'DRY-RUN') . sprintf(' · %s #%d · %d child page(s)', $rootSlug, (int)$root['uid'], count($children)));
$depth = max(1, min(3, (int)$input->getOption('depth')));
$parents = [(int)$root['uid']];
for ($level = 1; $level <= $depth; $level++) {
// intermediate levels pass through sysfolders (254); only real
// pages (doktype 1) are targets on the final level
$doktypes = $level < $depth ? '1, 254' : '1';
$children = $pages->fetchAllAssociative(
'SELECT uid, pid, title, doktype FROM pages WHERE deleted = 0 AND hidden = 0 AND doktype IN (' . $doktypes . ') AND pid IN (' . implode(',', $parents ?: [0]) . ') AND sys_language_uid IN (0,-1) ORDER BY pid, sorting'
);
$parents = array_map('intval', array_column($children, 'uid'));
}
$output->writeln(($apply ? 'APPLY' : 'DRY-RUN') . sprintf(' · %s #%d · depth %d · %d page(s)', $rootSlug, (int)$root['uid'], $depth, count($children)));
$storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage();
$refs = $pool->getConnectionForTable('sys_file_reference');
@@ -76,10 +92,19 @@ final class AttachPageMediaCommand extends Command
$output->writeln(sprintf(' = #%d "%s": has media (%d) - untouched', $uid, (string)$child['title'], $existing));
continue;
}
$fileUid = 0;
$identifier = 'placeholders/pages/' . $uid . '-08.png';
if (!$storage->hasFile($identifier)) {
$identifier = 'placeholders/placeholder-grey-wide.png';
if (!$storage->hasFile($identifier)) {
// the page's own hero background, first hero on the page
$fileUid = (int)$refs->fetchOne(
"SELECT r.uid_local FROM sys_file_reference r JOIN tt_content c ON c.uid = r.uid_foreign
WHERE r.deleted = 0 AND r.tablenames = 'tt_content' AND r.fieldname = 'vitec_bgimage'
AND c.deleted = 0 AND c.hidden = 0 AND c.pid = ?
ORDER BY c.colPos, c.sorting, r.sorting_foreign LIMIT 1",
[$uid]
);
$identifier = $fileUid > 0 ? 'hero image (sys_file #' . $fileUid . ')' : 'placeholders/placeholder-grey-wide.png';
if ($fileUid === 0 && !$storage->hasFile($identifier)) {
$output->writeln(sprintf(' <comment>! #%d "%s": no placeholder file - skipped</comment>', $uid, (string)$child['title']));
continue;
}
@@ -88,11 +113,13 @@ final class AttachPageMediaCommand extends Command
if (!$apply) {
continue;
}
$file = $storage->getFile($identifier);
if ($fileUid === 0) {
$fileUid = $storage->getFile($identifier)->getUid();
}
$datamap['sys_file_reference']['NEWPM' . (++$newIndex)] = [
'pid' => $uid,
'table_local' => 'sys_file',
'uid_local' => $file->getUid(),
'uid_local' => $fileUid,
'tablenames' => 'pages',
'uid_foreign' => $uid,
'fieldname' => 'media',
@@ -888,13 +888,13 @@ final class BuildContentPagesCommand extends Command
private function p(string $text): string
{
$text = trim($text);
return $text === '' ? '' : '<p>' . htmlspecialchars($text, ENT_QUOTES | ENT_HTML5) . '</p>';
return $text === '' ? '' : '<p>' . htmlspecialchars($text, ENT_NOQUOTES | ENT_HTML5) . '</p>';
}
/** @param string[] $items */
private function ul(array $items): string
{
$li = array_map(fn(string $item): string => '<li>' . htmlspecialchars($item, ENT_QUOTES | ENT_HTML5) . '</li>', $items);
$li = array_map(fn(string $item): string => '<li>' . htmlspecialchars($item, ENT_NOQUOTES | ENT_HTML5) . '</li>', $items);
return '<ul>' . implode('', $li) . '</ul>';
}
File diff suppressed because it is too large. Load diff
@@ -79,6 +79,24 @@ final class BuildProductPortfolioCommand extends Command
return Command::FAILURE;
}
$pageUid = (int)$root['uid'];
// Guard (2026-10-28, Ticket 0014-FE): nach vitec:wrap-cards-in-grid
// stecken die Karten IN vitec_cols-Containern (colPos 221-223), wo
// der Slot-Cursor sie nicht sieht - ein Re-Run hat deshalb die 15
// Container versteckt und 38 Duplikat-Karten erzeugt (Repair:
// migrations/fix_products_grid.php). Auf einer gewickelten Seite
// darf dieser Builder nicht mehr laufen.
$containerCount = (int)GeneralUtility::makeInstance(ConnectionPool::class)
->getConnectionForTable('tt_content')->fetchOne(
"SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND pid = ? AND CType LIKE 'vitec_cols_%'",
[$pageUid]
);
if ($containerCount > 0) {
$output->writeln('<error>/products ist grid-gewickelt (' . $containerCount
. ' vitec_cols-Container). Ein Re-Run wuerde Karten duplizieren und die Grids verstecken - Abbruch.'
. ' Fuer Einzelaenderungen die Karten im BE pflegen oder erst die Grids aufloesen.</error>');
return Command::FAILURE;
}
// the page sits on layout 0 (Default, main only) - the portfolio
// needs hero/main/preFooter: 1 "Index Page" (diagnosis 2026-10-27:
// the page's own content was all deleted, the rendered payload came
@@ -391,7 +409,9 @@ final class BuildProductPortfolioCommand extends Command
private function p(string $text): string
{
$text = trim($text);
return $text === '' ? '' : '<p>' . htmlspecialchars($text, ENT_QUOTES | ENT_HTML5) . '</p>';
// ENT_NOQUOTES (2026-10-28, Ticket 0014-FE): ENT_QUOTES machte ' zu &apos;,
// und die RTE-Persistenz escapte das & gleich nochmal (&amp;apos; im JSON)
return $text === '' ? '' : '<p>' . htmlspecialchars($text, ENT_NOQUOTES | ENT_HTML5) . '</p>';
}
private function norm(string $value): string
@@ -0,0 +1,313 @@
<?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\Resource\StorageRepository;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Builds the "Why VITEC?" icon band on the product OVERVIEW pages
* (/products/<slug>, contract 0036-FE variant 3b): one vitec_introparagraph
* headline plus one vitec_card (icon_card variant - the design's "centered"
* card; 0036 named a variant that does not exist) per staged agency icon of
* that page's context under fileadmin/icons/delivered/products/<context>/.
* Card titles are derived from the icon filename theme ("ScalableArchitecture"
* -> "Scalable architecture"); bodytext stays empty until the agency
* delivers copy (0036: a card without text is fine).
*
* The band lands at the TOP of the main column, before the "Product range"
* intro and its cards (0042-FE: placed after the intro it split heading and
* cards; fix_why_band_position.php corrected the first seven pages).
* Idempotent: a page already carrying a "Why VITEC?" introparagraph gets
* no second band; unused icons of its context go onto existing cards whose
* title matches the icon theme (2026-11-05: the adopted category pages'
* workbook cards) - nothing else is touched. The Vision sub-group icon
* sets (vision, visionav) stay unassigned: ONE page, TWO sets - editorial
* decision, reported only.
*
* vendor/bin/typo3 vitec:build-why-vitec-bands
* vendor/bin/typo3 vitec:build-why-vitec-bands --apply
*/
#[AsCommand(
name: 'vitec:build-why-vitec-bands',
description: 'Create the Why-VITEC icon bands on the product overview pages from the staged icons (dry-run by default)'
)]
final class BuildWhyVitecBandsCommand extends Command
{
private const ICON_BASE = 'icons/delivered/products/';
private const HEADER = 'Why VITEC?';
private const COLPOS = 0;
/** staged icon context => overview (or, since 2026-11-05, sub-category) page slug */
private const BANDS = [
'activesqx' => '/products/activesqx',
'aetria' => '/products/aetria',
'aligo' => '/products/aligo',
'avedia-modular-system' => '/products/avedia-modular-system',
'image-graphics-cards' => '/products/image-graphics-cards',
'operator-workstations' => '/products/operator-workstations',
'prism-transcoder' => '/products/prism-transcoder',
// sub-category pages = the adopted 2026-10 category pages (2026-11-05);
// they already carry a "Why VITEC?" section from Joe's workbooks whose
// cards ARE these icon themes -> icons go onto the existing cards
'avoip-and-kvm' => '/products/av-over-ip-and-kvm',
'control-room-platform' => '/products/control-room-platforms',
'ip-gateway-and-transcoder' => '/products/gateways-and-transcoding',
'iptv-distribution-and-signange-platform' => '/products/iptv-and-digital-signage',
];
/** words ignored when an icon theme is matched against a card title */
private const STOPWORDS = ['and', 'for', 'the', 'of', 'to', 'a', 'an', 'in', 'on', 'with'];
/** acronyms kept upper-case when the filename theme becomes a card title */
private const ACRONYMS = ['KVM', 'IPTV', 'AV', 'HD', 'SDI', 'IP', 'SQX', 'RF', 'VSN', 'HDMI', 'DVI'];
private int $newCounter = 0;
protected function configure(): void
{
$this->addOption('apply', null, InputOption::VALUE_NONE, 'Write changes (default: dry run)');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
Bootstrap::initializeBackendAuthentication();
$apply = (bool)$input->getOption('apply');
$pool = GeneralUtility::makeInstance(ConnectionPool::class);
$storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage();
$output->writeln(($apply ? 'APPLY' : 'DRY-RUN') . ' · ' . count(self::BANDS) . ' band(s)');
$built = 0;
foreach (self::BANDS as $context => $slug) {
$page = $pool->getConnectionForTable('pages')->fetchAssociative(
'SELECT uid, title FROM pages WHERE deleted = 0 AND slug = ? AND sys_language_uid IN (0,-1)',
[$slug]
);
if (!$page) {
$output->writeln('<error>' . $slug . ': page not found - skipped</error>');
continue;
}
$pageUid = (int)$page['uid'];
$folder = self::ICON_BASE . $context;
$icons = [];
if ($storage->hasFolder($folder)) {
foreach ($storage->getFilesInFolder($storage->getFolder($folder)) as $file) {
if (mb_strtolower($file->getExtension()) === 'svg' && preg_match('/-([A-Za-z0-9]+)\.svg$/', $file->getName(), $m)) {
$icons[] = ['identifier' => $file->getIdentifier(), 'title' => $this->themeTitle($m[1])];
}
}
}
if ($icons === []) {
$output->writeln('<comment>' . $slug . ': no staged icons under fileadmin/' . $folder . ' - skipped</comment>');
continue;
}
$existing = (int)$pool->getConnectionForTable('tt_content')->fetchOne(
"SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND pid = ? AND CType = 'vitec_introparagraph' AND header = ?",
[$pageUid, self::HEADER]
);
if ($existing > 0) {
// the page has its "Why VITEC?" section already (own band or
// a workbook import): put the icons onto matching cards
// without one; everything else stays untouched
$built += $this->iconsOntoExistingCards($pageUid, $slug, $icons, $apply, $output);
continue;
}
$output->writeln(sprintf("\n<info>%s</info> (#%d): band with %d icon card(s)", $slug, $pageUid, count($icons)));
foreach ($icons as $icon) {
$output->writeln(' "' . $icon['title'] . '" (' . basename($icon['identifier']) . ')');
}
$built++;
if (!$apply) {
continue;
}
// current main-column order (top-level elements only: the band
// must not land inside a b13 container)
$elements = $pool->getConnectionForTable('tt_content')->fetchAllAssociative(
'SELECT uid FROM tt_content WHERE deleted = 0 AND pid = ? AND colPos = ?
AND (tx_container_parent IS NULL OR tx_container_parent = 0)
ORDER BY sorting, uid',
[$pageUid, self::COLPOS]
);
$existingUids = array_map(static fn(array $r): string => (string)(int)$r['uid'], $elements);
$datamap = [];
$newIds = [];
$introId = $this->newId();
$datamap['tt_content'][$introId] = [
'pid' => $pageUid, 'colPos' => self::COLPOS, 'CType' => 'vitec_introparagraph',
'header' => self::HEADER, 'bodytext' => '',
];
$newIds[] = $introId;
foreach ($icons as $icon) {
$refId = $this->newId();
$datamap['sys_file_reference'][$refId] = [
'pid' => $pageUid,
'table_local' => 'sys_file',
'uid_local' => $storage->getFile($icon['identifier'])->getUid(),
];
$cardId = $this->newId();
$datamap['tt_content'][$cardId] = [
'pid' => $pageUid, 'colPos' => self::COLPOS, 'CType' => 'vitec_card',
'header' => $icon['title'], 'bodytext' => '',
'vitec_layout_variant' => 'icon_card',
'vitec_icon' => $refId,
];
$newIds[] = $cardId;
}
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start($datamap, []);
$dataHandler->process_datamap();
if ($dataHandler->errorLog !== []) {
$output->writeln('<error>DataHandler: ' . implode(' | ', $dataHandler->errorLog) . '</error>');
return Command::FAILURE;
}
// final order: the band first, then the existing main column
$order = array_merge(
array_map(static fn(string $id) => (string)($dataHandler->substNEWwithIDs[$id] ?? '0'), $newIds),
$existingUids
);
$sorting = 0;
foreach ($order as $uid) {
if ((int)$uid > 0) {
$sorting += 256;
$pool->getConnectionForTable('tt_content')->update('tt_content', ['sorting' => $sorting], ['uid' => (int)$uid]);
}
}
}
$output->writeln(sprintf(
"\n<info>%d band(s) %s.</info> Vision sub-group icon sets (vision, visionav) stay unassigned - one page, two sets (editorial).",
$built, $apply ? 'written - flush the cache' : 'planned (dry run - nothing written)'
));
return Command::SUCCESS;
}
/**
* Icons onto the existing cards of a page whose title matches an icon
* theme ("Centralized management" ~ CentralManagement: every word of
* the theme is a prefix of a card-title word or vice versa, same word
* count). Only cards without an icon; they become icon_card like the
* generated bands. Returns 1 when anything was (or would be) attached.
*
* @param array<int,array{identifier:string,title:string}> $icons
*/
private function iconsOntoExistingCards(int $pageUid, string $slug, array $icons, bool $apply, OutputInterface $output): int
{
$pool = GeneralUtility::makeInstance(ConnectionPool::class);
$cards = $pool->getConnectionForTable('tt_content')->fetchAllAssociative(
"SELECT c.uid, c.header FROM tt_content c
WHERE c.deleted = 0 AND c.pid = ? AND c.CType = 'vitec_card'
AND NOT EXISTS (SELECT 1 FROM sys_file_reference r WHERE r.deleted = 0 AND r.tablenames = 'tt_content'
AND r.fieldname = 'vitec_icon' AND r.uid_foreign = c.uid)
ORDER BY c.uid",
[$pageUid]
);
$storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage();
// icons already on a card of this page (e.g. the generated bands)
$used = array_map('intval', $pool->getConnectionForTable('sys_file_reference')->fetchFirstColumn(
"SELECT r.uid_local FROM sys_file_reference r JOIN tt_content c ON c.uid = r.uid_foreign
WHERE r.deleted = 0 AND r.tablenames = 'tt_content' AND r.fieldname = 'vitec_icon' AND c.deleted = 0 AND c.pid = ?",
[$pageUid]
));
$datamap = [];
$lines = [];
foreach ($icons as $icon) {
if (in_array($storage->getFile($icon['identifier'])->getUid(), $used, true)) {
continue;
}
foreach ($cards as $k => $card) {
if (!$this->sameTheme($icon['title'], (string)$card['header'])) {
continue;
}
$lines[] = sprintf(' card #%d "%s" <- %s', (int)$card['uid'], $card['header'], basename($icon['identifier']));
$refId = $this->newId();
$datamap['sys_file_reference'][$refId] = [
'pid' => $pageUid,
'table_local' => 'sys_file',
'uid_local' => $storage->getFile($icon['identifier'])->getUid(),
];
$datamap['tt_content'][(int)$card['uid']] = ['vitec_icon' => $refId, 'vitec_layout_variant' => 'icon_card'];
unset($cards[$k]);
continue 2;
}
$lines[] = ' <comment>no card for "' . $icon['title'] . '" (' . basename($icon['identifier']) . ')</comment>';
}
if ($lines === []) {
$output->writeln($slug . ': band complete - untouched');
return 0;
}
$output->writeln(sprintf("\n<info>%s</info> (#%d): \"Why VITEC?\" exists - icons onto its cards", $slug, $pageUid));
foreach ($lines as $line) {
$output->writeln($line);
}
if ($datamap === []) {
return 0;
}
if ($apply) {
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start($datamap, []);
$dataHandler->process_datamap();
if ($dataHandler->errorLog !== []) {
$output->writeln('<error>DataHandler: ' . implode(' | ', $dataHandler->errorLog) . '</error>');
}
}
return 1;
}
private function sameTheme(string $iconTitle, string $cardTitle): bool
{
$words = fn(string $text): array => array_values(array_diff(
preg_split('/[^a-z0-9]+/', mb_strtolower($text), -1, PREG_SPLIT_NO_EMPTY) ?: [],
self::STOPWORDS
));
$a = $words($iconTitle);
$b = $words($cardTitle);
if ($a === [] || count($a) !== count($b)) {
return false;
}
foreach ($a as $i => $word) {
if (!str_starts_with($b[$i], $word) && !str_starts_with($word, $b[$i])) {
return false;
}
}
return true;
}
/** "ScalableArchitecture" -> "Scalable architecture", acronyms kept ("KVMCapability" -> "KVM capability") */
private function themeTitle(string $theme): string
{
$words = preg_split('/(?=[A-Z][a-z])|(?<=[a-z])(?=[A-Z])|(?<=[A-Za-z])(?=\d)/', $theme, -1, PREG_SPLIT_NO_EMPTY) ?: [$theme];
$out = [];
foreach ($words as $i => $word) {
if (in_array(strtoupper($word), self::ACRONYMS, true)) {
$out[] = strtoupper($word);
} elseif (preg_match('/\d/', $word)) {
$out[] = $word;
} else {
$out[] = $i === 0 ? ucfirst(mb_strtolower($word)) : mb_strtolower($word);
}
}
return implode(' ', $out);
}
private function newId(): string
{
return 'NEWWV' . str_pad((string)(++$this->newCounter), 4, '0', STR_PAD_LEFT) . substr(md5(uniqid('', true)), 0, 6);
}
}
@@ -0,0 +1,53 @@
<?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\Database\ConnectionPool;
#[AsCommand(
name: 'vitec:client-error-cleanup',
description: 'Delete client error reports (tx_vitec_clienterrorlog) older than 30 days; run daily via cron',
)]
final class ClientErrorCleanupCommand extends Command
{
private const TABLE = 'tx_vitec_clienterrorlog';
private const DEFAULT_DAYS = 30;
public function __construct(private readonly ConnectionPool $connectionPool)
{
parent::__construct();
}
protected function configure(): void
{
$this->addOption('days', 'd', InputOption::VALUE_REQUIRED, 'Keep entries younger than this many days', (string)self::DEFAULT_DAYS);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$days = (int)$input->getOption('days');
if ($days < 1) {
$output->writeln('<error>--days must be at least 1</error>');
return Command::INVALID;
}
$cutoff = time() - $days * 86400;
$conn = $this->connectionPool->getConnectionForTable(self::TABLE);
$deleted = $conn->executeStatement('DELETE FROM ' . self::TABLE . ' WHERE crdate < ?', [$cutoff]);
$remaining = (int)$conn->executeQuery('SELECT COUNT(*) FROM ' . self::TABLE)->fetchOne();
$output->writeln(sprintf(
'deleted %d client error report(s) older than %d days (before %s), %d remaining',
$deleted,
$days,
date('Y-m-d H:i', $cutoff),
$remaining
));
return Command::SUCCESS;
}
}
@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use TYPO3\CMS\Core\Core\Bootstrap;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Creates the two internal audit pages for the page content check (decision
* Harry 2026-11-13, sibling of /products/product-check):
*
* /markets/market-check vitec_pagecheck, scope markets
* /solutions/solution-check vitec_pagecheck, scope solutions
*
* Page properties are taken from the product check page (/products/
* product-check: doktype, backend layout, no_index, nav_hide, nav_title
* handling) so all three audit pages behave alike. Idempotent: an existing
* slug is reported and skipped.
*
* vendor/bin/typo3 vitec:pagecheck-pages # dry run (default)
* vendor/bin/typo3 vitec:pagecheck-pages --apply # creates
*/
#[AsCommand(
name: 'vitec:pagecheck-pages',
description: 'Create /markets/market-check and /solutions/solution-check with the vitec_pagecheck element (dry-run by default)'
)]
final class CreateCheckPagesCommand extends Command
{
private const TEMPLATE_SLUG = '/products/product-check';
private const PAGES = [
['parentSlug' => '/markets', 'title' => 'Market Check', 'slug' => '/markets/market-check', 'header' => 'Market Content Check', 'scope' => 'markets'],
['parentSlug' => '/solutions', 'title' => 'Solution Check', 'slug' => '/solutions/solution-check', 'header' => 'Solution Content Check', 'scope' => 'solutions'],
];
protected function configure(): void
{
$this->addOption('apply', null, InputOption::VALUE_NONE, 'Create the pages (default: dry run)');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
Bootstrap::initializeBackendAuthentication();
$apply = (bool)$input->getOption('apply');
$db = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages');
$template = $db->fetchAssociative(
'SELECT uid, doktype, backend_layout, backend_layout_next_level, no_index, no_follow, nav_hide, no_search, seo_title, description FROM pages WHERE deleted = 0 AND slug = ?',
[self::TEMPLATE_SLUG]
);
if ($template === false) {
$output->writeln('<error>Template page ' . self::TEMPLATE_SLUG . ' not found.</error>');
return Command::FAILURE;
}
$output->writeln(sprintf(
'Template %s (uid %d): doktype %d, layout "%s", no_index %d, nav_hide %d, no_search %d',
self::TEMPLATE_SLUG, $template['uid'], $template['doktype'], $template['backend_layout'], $template['no_index'], $template['nav_hide'], $template['no_search']
));
$output->writeln($apply ? '<comment>APPLY</comment>' : 'DRY RUN - nothing is written (--apply to create)');
$created = 0;
foreach (self::PAGES as $def) {
$existing = $db->fetchAssociative('SELECT uid FROM pages WHERE deleted = 0 AND slug = ?', [$def['slug']]);
if ($existing !== false) {
$output->writeln(sprintf(' %s exists (uid %d) - skipped', $def['slug'], $existing['uid']));
continue;
}
$parent = $db->fetchAssociative('SELECT uid FROM pages WHERE deleted = 0 AND sys_language_uid = 0 AND slug = ?', [$def['parentSlug']]);
if ($parent === false) {
$output->writeln(sprintf(' <error>parent %s not found - %s skipped</error>', $def['parentSlug'], $def['slug']));
continue;
}
$output->writeln(sprintf(' create %s "%s" under %s (pid %d) + vitec_pagecheck "%s" (scope %s)', $def['slug'], $def['title'], $def['parentSlug'], $parent['uid'], $def['header'], $def['scope']));
$created++;
if (!$apply) {
continue;
}
$pageId = 'NEWpage' . $def['scope'];
$datamap = [
'pages' => [
$pageId => [
'pid' => -(int)$this->lastChild((int)$parent['uid'], (int)$parent['uid']),
'title' => $def['title'],
'slug' => $def['slug'],
'doktype' => (int)$template['doktype'],
'backend_layout' => (string)$template['backend_layout'],
'backend_layout_next_level' => (string)$template['backend_layout_next_level'],
'no_index' => (int)$template['no_index'],
'no_follow' => (int)$template['no_follow'],
'nav_hide' => (int)$template['nav_hide'],
'no_search' => (int)$template['no_search'],
'hidden' => 0,
],
],
'tt_content' => [
'NEWce' . $def['scope'] => [
'pid' => $pageId,
'colPos' => 0,
'sys_language_uid' => 0,
'CType' => 'vitec_pagecheck',
'header' => $def['header'],
'pi_flexform' => ['data' => ['sDEF' => ['lDEF' => ['settings.scope' => ['vDEF' => $def['scope']]]]]],
],
],
];
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start($datamap, []);
$dataHandler->process_datamap();
if ($dataHandler->errorLog !== []) {
$output->writeln(' <error>DataHandler: ' . implode(' | ', $dataHandler->errorLog) . '</error>');
return Command::FAILURE;
}
foreach ($dataHandler->substNEWwithIDs as $new => $uid) {
$output->writeln(sprintf(' written: %s -> #%d', $new, $uid));
}
}
$output->writeln(sprintf('%s: %d page(s).', $apply ? 'DONE' : 'DRY RUN - would create', $created));
if ($apply && $created > 0) {
$output->writeln('Afterwards: vendor/bin/typo3 cache:flush');
}
return Command::SUCCESS;
}
/** uid of the last live child page (to append after it), or the parent itself when there is none */
private function lastChild(int $parentUid, int $fallback): int
{
$uid = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages')->fetchOne(
'SELECT uid FROM pages WHERE deleted = 0 AND sys_language_uid = 0 AND pid = ? ORDER BY sorting DESC LIMIT 1',
[$parentUid]
);
return $uid === false ? $fallback : (int)$uid;
}
}
@@ -30,7 +30,8 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
* the Dropbox folder names, so a vitec:import-product-images re-run attaches
* the delivered banner/carousel assets immediately.
*
* IQS4 stays out on purpose: its delivery folder is empty - question to Joe.
* 2026-10-28: IQS4 and VisionSC-SDI4 added - the fresh Dropbox dump
* delivered their banners, and both datasheets already sit in the pool.
*
* vendor/bin/typo3 vitec:create-missing-products
* vendor/bin/typo3 vitec:create-missing-products --apply
@@ -53,6 +54,11 @@ final class CreateMissingProductsCommand extends Command
'Rugged portable HEVC/H.264 encoder for demanding field deployments.'],
['X-View Integrated Displays', 'x-view-integrated-displays', 'x-player-end-points-xp23-xp25-xp26',
'Commercial displays with an integrated IPTV end-point for TV and signage delivery.'],
// 2026-10-28 (delivery brought banners; datasheets already in the pool)
['IQS4', 'iqs4', 'image-4k',
'Distribute a single 4K source across multiple displays with a compact 4K splitter.'],
['VisionSC-SDI4', 'visionsc-sdi4', 'visionsc-s2',
'Four-channel SDI capture card for professional video and visualization workflows.'],
];
protected function configure(): void
@@ -0,0 +1,753 @@
<?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\Resource\Enum\DuplicationBehavior;
use TYPO3\CMS\Core\Resource\StorageRepository;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Imports the 22 Datapath success stories from the 2026-10-28 Dropbox
* delivery (staged by the session into migrations/datapath_stories_in/
* <slug>/{story.docx, story.pdf, images/*}) into the usecase model.
*
* The docx files share one structure (verified on Edinburgh / Woori):
* 1st paragraph -> title
* 2nd paragraph -> subtitle (one-liner)
* 1st IntenseQuote -> record quote, the two short lines after it
* quote_person / quote_role
* known section names -> text elements (Background, The Challenge,
* The Solution, The Result, ... - plain
* paragraphs, no heading styles in the files)
* ListParagraph runs -> <ul> inside the section body
* further IntenseQuote -> vitec_quotation elements; attribution from
* the preceding "... said:" line or the short
* "Name, Role" line after the quote
*
* Per story: usecase record (pid/detail_page from the existing records),
* hero_bgimage + card_image from the first staged image, remaining images
* (max 4) as image elements, the staged Collateral pdf as a NEW download
* record plus a vitec_downloadcard (layout story_download, header
* Downloads) - the same shape the migrated vitec.com stories have after
* the 2026-10-28 plugin conversion. Files land in
* fileadmin/success_stories_import/datapath/<slug>/.
*
* Idempotent: a story whose normalised title already exists gets an
* asset-only backfill (logo/hero/card/images where missing) instead.
* Markets stay unassigned on purpose (editorial; reported).
*
* vendor/bin/typo3 vitec:import-datapath-stories [--only=woori] [--apply]
*/
#[AsCommand(
name: 'vitec:import-datapath-stories',
description: 'Import the Datapath success-story delivery (docx + images + pdf) into the usecase model (dry-run by default)'
)]
final class ImportDatapathStoriesCommand extends Command
{
private const TABLE = 'tx_vitec_domain_model_usecase';
private const DOWNLOAD_TABLE = 'tx_vitec_domain_model_download';
private const COLPOS = 999;
private const MAX_IMAGE_ELEMENTS = 4;
private const FILES_TARGET = 'success_stories_import/datapath';
private const SECTION_NAMES = [
'background', 'the challenge', 'challenge', 'the solution', 'solution',
'the result', 'result', 'the results', 'results', 'future plans',
'future expansion', 'the future', 'key features', 'benefits',
'the benefits', 'conclusion', 'summary',
];
private int $newCounter = 0;
private bool $repairQuotes = false;
protected function configure(): void
{
$this->addOption('dir', null, InputOption::VALUE_REQUIRED, 'Inbox directory', 'migrations/datapath_stories_in');
$this->addOption('only', null, InputOption::VALUE_REQUIRED, 'Folder-name substring filter', '');
$this->addOption('apply', null, InputOption::VALUE_NONE, 'Write changes (default: dry run)');
$this->addOption('repair-quotes', null, InputOption::VALUE_NONE, 'Repair pass 0017-FE: move lead attribution into quote_person/quote_role, delete the bogus quotation elements, strip "Updated on" remnants');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
Bootstrap::initializeBackendAuthentication();
$apply = (bool)$input->getOption('apply');
$this->repairQuotes = (bool)$input->getOption('repair-quotes');
$only = mb_strtolower((string)$input->getOption('only'));
$dir = rtrim((string)$input->getOption('dir'), '/');
$pool = GeneralUtility::makeInstance(ConnectionPool::class);
$existing = [];
$pidCounts = [];
$detailCounts = [];
foreach ($pool->getConnectionForTable(self::TABLE)->fetchAllAssociative(
'SELECT uid, pid, title, detail_page FROM ' . self::TABLE . ' WHERE deleted = 0'
) as $row) {
$existing[$this->norm((string)$row['title'])] = (int)$row['uid'];
$pidCounts[(int)$row['pid']] = ($pidCounts[(int)$row['pid']] ?? 0) + 1;
if ((int)$row['detail_page'] > 0) {
$detailCounts[(int)$row['detail_page']] = ($detailCounts[(int)$row['detail_page']] ?? 0) + 1;
}
}
arsort($pidCounts);
arsort($detailCounts);
$storagePid = (int)(array_key_first($pidCounts) ?? 0);
$detailPage = (int)(array_key_first($detailCounts) ?? 0);
$downloadPids = array_count_values(array_map('intval', $pool->getConnectionForTable(self::DOWNLOAD_TABLE)
->fetchFirstColumn('SELECT pid FROM ' . self::DOWNLOAD_TABLE . ' WHERE deleted = 0')));
arsort($downloadPids);
$downloadPid = (int)(array_key_first($downloadPids) ?? $storagePid);
$folders = array_filter(glob($dir . '/*', GLOB_ONLYDIR) ?: [], static fn(string $f): bool =>
is_file($f . '/story.docx') && ($only === '' || str_contains(mb_strtolower(basename($f)), $only)));
$output->writeln(sprintf('%s · %d story folder(s) · storage pid %d · detail page %d · download pid %d',
$apply ? 'APPLY' : 'DRY-RUN', count($folders), $storagePid, $detailPage, $downloadPid));
$created = 0;
foreach ($folders as $folder) {
try {
$created += $this->importOne($folder, $existing, $storagePid, $detailPage, $downloadPid, $apply, $output) ? 1 : 0;
} catch (\Throwable $e) {
$output->writeln('<error> ' . basename($folder) . ': ' . get_class($e) . ' - ' . $e->getMessage() . '</error>');
}
}
// --- repair pass: story downloadcards whose FlexForm still carries a
// literal NEW id (first import wrote the placeholder unsubstituted) -
// resolve via "<story title> Success Story" and write the real uid
$content = $pool->getConnectionForTable('tt_content');
$downloadByNorm = [];
foreach ($pool->getConnectionForTable(self::DOWNLOAD_TABLE)->fetchAllAssociative(
'SELECT uid, title FROM ' . self::DOWNLOAD_TABLE . ' WHERE deleted = 0'
) as $download) {
$downloadByNorm[$this->norm((string)$download['title'])] = (int)$download['uid'];
}
$repairMap = [];
foreach ($content->fetchAllAssociative(
"SELECT c.uid, u.title FROM tt_content c
JOIN " . self::TABLE . " u ON u.uid = c.tx_vitec_usecase_content AND u.deleted = 0
WHERE c.deleted = 0 AND c.CType = 'vitec_downloadcard' AND c.pi_flexform LIKE '%NEWDS%'"
) as $card) {
$target = $downloadByNorm[$this->norm((string)$card['title'] . ' success story')] ?? 0;
if ($target > 0) {
$repairMap['tt_content'][(string)(int)$card['uid']]['pi_flexform'] =
['data' => ['sDEF' => ['lDEF' => ['settings.download' => ['vDEF' => (string)$target]]]]];
$output->writeln(' ~ REPAIR card #' . $card['uid'] . ' ("' . $card['title'] . '") -> download #' . $target);
} else {
$output->writeln(' <comment>! REPAIR card #' . $card['uid'] . ' ("' . $card['title'] . '"): kein Download gefunden</comment>');
}
}
if ($apply && $repairMap !== []) {
$repairHandler = GeneralUtility::makeInstance(DataHandler::class);
$repairHandler->start($repairMap, []);
$repairHandler->process_datamap();
}
$output->writeln($apply
? "<info>$created story record(s) created. Flush the frontend cache; assign markets editorially; re-run the sub-solution import for the testimonials.</info>"
: "\nDRY RUN - nothing written. Add --apply to write.");
return Command::SUCCESS;
}
/** @param array<string,int> $existing */
private function importOne(string $folder, array $existing, int $storagePid, int $detailPage, int $downloadPid, bool $apply, OutputInterface $output): bool
{
$slug = basename($folder);
$doc = $this->parseDocx($folder . '/story.docx');
$title = $doc['title'];
if ($title === '') {
$output->writeln('<comment> ' . $slug . ': kein Titel im docx - uebersprungen</comment>');
return false;
}
if (isset($existing[$this->norm($title)])) {
if ($this->repairQuotes) {
$this->repairStoryQuotes($existing[$this->norm($title)], $slug, $doc, $apply, $output);
} else {
$this->backfillAssets($existing[$this->norm($title)], $slug, $folder, $storagePid, $apply, $output);
}
return false;
}
$images = array_values(array_filter(glob($folder . '/images/*') ?: [], 'is_file'));
$pdf = is_file($folder . '/story.pdf') ? $folder . '/story.pdf' : null;
$output->writeln(sprintf('+ "%s" (%d Sektion(en), %d Zitat(e)%s, %d Bild(er), %s)',
$title, count($doc['sections']), count($doc['quotes']),
$doc['lead']['quote'] !== '' ? ' + Lead-Quote' : '',
count($images), $pdf ? 'PDF' : 'KEIN PDF'));
foreach ($doc['report'] as $line) {
$output->writeln(' ' . $line);
}
if (!$apply) {
return false;
}
$storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage();
$targetFolder = $this->ensureFolder($storage, self::FILES_TARGET . '/' . $slug);
$datamap = [];
$elementIds = [];
// --- sections as text elements -------------------------------------------------
foreach ($doc['sections'] as $section) {
if ($section['type'] === 'text') {
$id = $this->newId();
$datamap['tt_content'][$id] = [
'pid' => $storagePid, 'CType' => 'text', 'colPos' => self::COLPOS,
'header' => $section['header'], 'header_layout' => 2,
'bodytext' => $section['body'],
];
$elementIds[] = $id;
} else { // quotation
$id = $this->newId();
$datamap['tt_content'][$id] = [
'pid' => $storagePid, 'CType' => 'vitec_quotation', 'colPos' => self::COLPOS,
'vitec_quote' => $section['quote'],
'vitec_quote_name' => $section['name'],
'vitec_quote_position' => $section['position'],
];
$elementIds[] = $id;
}
}
// --- image elements (beyond hero/card) ------------------------------------------
$heroRef = null;
$cardRef = null;
foreach ($images as $i => $imagePath) {
$file = $storage->addFile($imagePath, $targetFolder, basename($imagePath), DuplicationBehavior::REPLACE, false);
if ($i === 0) {
$heroRef = $this->fileRef($datamap, $file->getUid(), $storagePid);
$cardRef = $this->fileRef($datamap, $file->getUid(), $storagePid);
continue;
}
if ($i > self::MAX_IMAGE_ELEMENTS) {
continue;
}
$id = $this->newId();
$datamap['tt_content'][$id] = [
'pid' => $storagePid, 'CType' => 'image', 'colPos' => self::COLPOS,
'image' => $this->fileRef($datamap, $file->getUid(), $storagePid),
];
$elementIds[] = $id;
}
// --- download record (OWN DataHandler pass: NEW ids are NOT
// substituted inside FlexForm values, found 2026-10-28 - the card
// needs the REAL uid) + downloadcard --------------------------------------------
if ($pdf !== null) {
$pdfName = 'VITEC_' . $slug . '_USE-CASE.pdf';
$pdfFile = $storage->addFile($pdf, $targetFolder, $pdfName, DuplicationBehavior::REPLACE, false);
$downloadId = $this->newId();
$downloadMap = [];
$downloadMap[self::DOWNLOAD_TABLE][$downloadId] = [
'pid' => $downloadPid,
'title' => $title . ' Success Story',
'slug' => $this->slugify($title . ' success story'),
'file' => $this->fileRef($downloadMap, $pdfFile->getUid(), $downloadPid),
];
$downloadHandler = GeneralUtility::makeInstance(DataHandler::class);
$downloadHandler->start($downloadMap, []);
$downloadHandler->process_datamap();
$downloadUid = (int)($downloadHandler->substNEWwithIDs[$downloadId] ?? 0);
if ($downloadUid > 0) {
$cardId = $this->newId();
$datamap['tt_content'][$cardId] = [
'pid' => $storagePid, 'CType' => 'vitec_downloadcard', 'colPos' => self::COLPOS,
'header' => 'Downloads',
'pi_flexform' => ['data' => ['sDEF' => ['lDEF' => [
'settings.download' => ['vDEF' => (string)$downloadUid],
'settings.layout' => ['vDEF' => 'story_download'],
]]]],
];
$elementIds[] = $cardId;
}
}
// --- the record -------------------------------------------------------------------
$recordId = $this->newId();
$record = [
'pid' => $storagePid,
'title' => $title,
'slug' => $this->slugify($title),
'subtitle' => $doc['subtitle'],
'teaser' => $doc['teaser'],
'quote' => $doc['lead']['quote'],
'quote_person' => $doc['lead']['name'],
'quote_role' => $doc['lead']['position'],
'seo_title' => $title,
'seo_description' => $doc['subtitle'],
'content_elements' => implode(',', $elementIds),
];
if ($detailPage > 0) {
$record['detail_page'] = $detailPage;
}
if ($heroRef !== null) {
$record['hero_bgimage'] = $heroRef;
$record['card_image'] = $cardRef;
}
$datamap[self::TABLE][$recordId] = $record;
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start($datamap, []);
$dataHandler->process_datamap();
if ($dataHandler->errorLog !== []) {
throw new \RuntimeException('DataHandler: ' . implode(' | ', array_slice($dataHandler->errorLog, 0, 3)));
}
$uid = (int)($dataHandler->substNEWwithIDs[$recordId] ?? 0);
$output->writeln(' -> Record #' . $uid . ' angelegt');
return true;
}
/**
* Asset backfill for an already imported story: the 2026-10-28 delivery
* keeps the web images AND the customer logo in the Links/ folders,
* which the first staging pass had excluded. Content-idempotent -
* attaches customer_logo / hero_bgimage / card_image only when the
* field has no reference yet and adds image elements only when the
* story has none; existing references are never touched.
*/
private function backfillAssets(int $uid, string $slug, string $folder, int $storagePid, bool $apply, OutputInterface $output): void
{
$pool = GeneralUtility::makeInstance(ConnectionPool::class);
$refs = $pool->getConnectionForTable('sys_file_reference')->fetchFirstColumn(
"SELECT DISTINCT fieldname FROM sys_file_reference
WHERE deleted = 0 AND tablenames = ? AND uid_foreign = ?
AND fieldname IN ('customer_logo', 'hero_bgimage', 'card_image')",
[self::TABLE, $uid]
);
$imageElements = (int)$pool->getConnectionForTable('tt_content')->fetchOne(
"SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND CType = 'image' AND tx_vitec_usecase_content = ?",
[$uid]
);
$images = array_values(array_filter(glob($folder . '/images/*') ?: [], 'is_file'));
$logos = array_values(array_filter(glob($folder . '/logo.*') ?: [], 'is_file'));
$needLogo = $logos !== [] && !in_array('customer_logo', $refs, true);
$needHero = $images !== [] && !in_array('hero_bgimage', $refs, true);
$needCard = $images !== [] && !in_array('card_image', $refs, true);
$newImageCount = ($imageElements === 0 && count($images) > 1)
? min(self::MAX_IMAGE_ELEMENTS, count($images) - 1)
: 0;
if (!$needLogo && !$needHero && !$needCard && $newImageCount === 0) {
$output->writeln(' = #' . $uid . ' ' . $slug . ': Assets vollstaendig - nichts zu tun');
return;
}
$output->writeln(sprintf(' ~ #%d %s: Backfill %s%s%s%s', $uid, $slug,
$needLogo ? '[logo] ' : '', $needHero ? '[hero] ' : '', $needCard ? '[card] ' : '',
$newImageCount > 0 ? '[' . $newImageCount . ' Bild-Element(e)]' : ''));
if (!$apply) {
return;
}
$storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage();
$targetFolder = $this->ensureFolder($storage, self::FILES_TARGET . '/' . $slug);
$datamap = [];
$recordFields = [];
if ($needLogo) {
$logoName = 'logo-' . $slug . '.' . mb_strtolower(pathinfo($logos[0], PATHINFO_EXTENSION));
$logoFile = $storage->addFile($logos[0], $targetFolder, $logoName, DuplicationBehavior::REPLACE, false);
$recordFields['customer_logo'] = $this->fileRefFull($datamap, $logoFile->getUid(), $storagePid, $uid, 'customer_logo');
}
if ($needHero || $needCard) {
$first = $storage->addFile($images[0], $targetFolder, basename($images[0]), DuplicationBehavior::REPLACE, false);
if ($needHero) {
$recordFields['hero_bgimage'] = $this->fileRefFull($datamap, $first->getUid(), $storagePid, $uid, 'hero_bgimage');
}
if ($needCard) {
$recordFields['card_image'] = $this->fileRefFull($datamap, $first->getUid(), $storagePid, $uid, 'card_image');
}
}
if ($newImageCount > 0) {
// append via the parent CSV (existing children in sorting order +
// the new elements) so the inline count stays correct; new images
// land BEFORE a trailing downloadcard
$children = array_map('intval', $pool->getConnectionForTable('tt_content')->fetchFirstColumn(
'SELECT uid FROM tt_content WHERE deleted = 0 AND tx_vitec_usecase_content = ? ORDER BY sorting',
[$uid]
));
$newIds = [];
foreach (array_slice($images, 1, $newImageCount) as $imagePath) {
$file = $storage->addFile($imagePath, $targetFolder, basename($imagePath), DuplicationBehavior::REPLACE, false);
$id = $this->newId();
$datamap['tt_content'][$id] = [
'pid' => $storagePid, 'CType' => 'image', 'colPos' => self::COLPOS,
'image' => $this->fileRef($datamap, $file->getUid(), $storagePid),
];
$newIds[] = $id;
}
$last = $children !== [] ? (int)end($children) : 0;
$lastType = $last > 0 ? (string)$pool->getConnectionForTable('tt_content')->fetchOne(
'SELECT CType FROM tt_content WHERE uid = ?', [$last]) : '';
if ($lastType === 'vitec_downloadcard') {
array_pop($children);
$csv = array_merge($children, $newIds, [$last]);
} else {
$csv = array_merge($children, $newIds);
}
$recordFields['content_elements'] = implode(',', array_map('strval', $csv));
}
$datamap[self::TABLE][(string)$uid] = $recordFields;
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start($datamap, []);
$dataHandler->process_datamap();
if ($dataHandler->errorLog !== []) {
throw new \RuntimeException('DataHandler: ' . implode(' | ', array_slice($dataHandler->errorLog, 0, 3)));
}
$output->writeln(' -> Backfill geschrieben');
}
/** sys_file_reference NEW id for a field of an EXISTING parent record. */
private function fileRefFull(array &$datamap, int $fileUid, int $pid, int $uidForeign, string $fieldname): string
{
$refId = $this->newId();
$datamap['sys_file_reference'][$refId] = [
'pid' => $pid,
'table_local' => 'sys_file',
'uid_local' => $fileUid,
'tablenames' => self::TABLE,
'uid_foreign' => $uidForeign,
'fieldname' => $fieldname,
];
return $refId;
}
/** Short, non-sentence line right after a quote = attribution (name/role). */
private function isAttributionLine(array $paragraph, int $maxLen): bool
{
$text = (string)$paragraph['text'];
if ($text === '' || mb_strlen($text) > $maxLen + 60) {
return false;
}
$first = trim((string)($paragraph['lines'][0] ?? $text));
if ($first === '' || mb_strlen($first) > $maxLen || str_ends_with($first, '.')) {
return false;
}
if (count(preg_split('/\s+/u', $first) ?: []) > 8) {
return false;
}
return !in_array(mb_strtolower(rtrim($text, ':')), self::SECTION_NAMES, true);
}
/**
* Repair pass (0017-FE Punkt 3 + 5a) for an already imported story:
* writes the docx lead attribution into quote_person/quote_role (only
* when empty), deletes the quotation elements that carry just the name
* or role (or the glued combination) as their quote, and strips the
* "NN | YYYY Updated on NN | YYYY" footer from text elements.
*/
private function repairStoryQuotes(int $uid, string $slug, array $doc, bool $apply, OutputInterface $output): void
{
$pool = GeneralUtility::makeInstance(ConnectionPool::class);
$record = $pool->getConnectionForTable(self::TABLE)->fetchAssociative(
'SELECT uid, quote_person, quote_role FROM ' . self::TABLE . ' WHERE uid = ?', [$uid]
);
if (!$record) {
return;
}
$name = (string)$doc['lead']['name'];
$position = (string)$doc['lead']['position'];
// normalise for the record (element matching below uses the RAW
// parsed strings): trailing commas, "Name, Role" in ONE line, and a
// single line that is clearly a role ("FIA Head of ...") -> role only
$person = trim($name, " \t,");
$role = trim($position, " \t,");
if ($role === '' && str_contains($person, ',')) {
[$person, $role] = $this->splitAttribution($person);
}
if ($role === '' && $person !== ''
&& count(preg_split('/\s+/u', $person) ?: []) >= 3
&& preg_match('/\b(Head|Manager|Director|President|CEO|CTO|Chief|Officer|Engineer|Architect|Strategy)\b/i', $person)) {
$role = $person;
$person = '';
}
$datamap = [];
$cmdmap = [];
$actions = [];
if ($person !== '' && (string)$record['quote_person'] === '') {
$datamap[self::TABLE][(string)$uid]['quote_person'] = $person;
$actions[] = 'person="' . $person . '"';
}
if ($role !== '' && (string)$record['quote_role'] === '') {
$datamap[self::TABLE][(string)$uid]['quote_role'] = $role;
$actions[] = 'role="' . $role . '"';
}
// bogus quotation elements: quote == name / role / glued combination
$targets = array_filter([
$this->norm($name),
$this->norm($position),
$this->norm($name . $position),
], static fn(string $v): bool => $v !== '');
if ($targets !== []) {
foreach ($pool->getConnectionForTable('tt_content')->fetchAllAssociative(
"SELECT uid, vitec_quote FROM tt_content
WHERE deleted = 0 AND CType = 'vitec_quotation' AND tx_vitec_usecase_content = ?",
[$uid]
) as $element) {
if (in_array($this->norm((string)$element['vitec_quote']), $targets, true)) {
$cmdmap['tt_content'][(int)$element['uid']]['delete'] = 1;
$actions[] = 'delete quotation #' . $element['uid'] . ' ("' . mb_substr((string)$element['vitec_quote'], 0, 40) . '")';
}
}
}
// "Updated on" remnants inside text elements (incl. glued into <li>)
foreach ($pool->getConnectionForTable('tt_content')->fetchAllAssociative(
"SELECT uid, bodytext FROM tt_content
WHERE deleted = 0 AND CType = 'text' AND tx_vitec_usecase_content = ?
AND bodytext LIKE '%Updated on%'",
[$uid]
) as $element) {
$body = (string)$element['bodytext'];
$clean = (string)preg_replace('/\s*\d{1,2}\s*\|\s*\d{4}\s*Updated on\s*\d{1,2}\s*\|\s*\d{4}\.?/iu', '', $body);
$clean = (string)preg_replace('/<(p|li)>\s*<\/\1>/u', '', $clean);
if ($clean !== $body) {
$datamap['tt_content'][(string)(int)$element['uid']]['bodytext'] = $clean;
$actions[] = 'strip "Updated on" in #' . $element['uid'];
}
}
if ($actions === []) {
$output->writeln(' = #' . $uid . ' ' . $slug . ': nichts zu reparieren');
return;
}
$output->writeln(' ~ #' . $uid . ' ' . $slug . ':');
foreach ($actions as $action) {
$output->writeln(' - ' . $action);
}
if (!$apply) {
return;
}
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start($datamap, $cmdmap);
if ($datamap !== []) {
$dataHandler->process_datamap();
}
if ($cmdmap !== []) {
$dataHandler->process_cmdmap();
}
if ($dataHandler->errorLog !== []) {
throw new \RuntimeException('DataHandler: ' . implode(' | ', array_slice($dataHandler->errorLog, 0, 3)));
}
$output->writeln(' -> repariert');
}
/** sys_file_reference NEW id for use in a parent FAL field. */
private function fileRef(array &$datamap, int $fileUid, int $pid): string
{
$refId = $this->newId();
$datamap['sys_file_reference'][$refId] = [
'pid' => $pid,
'table_local' => 'sys_file',
'uid_local' => $fileUid,
];
return $refId;
}
private function ensureFolder(\TYPO3\CMS\Core\Resource\ResourceStorage $storage, string $path): \TYPO3\CMS\Core\Resource\Folder
{
$folder = $storage->getRootLevelFolder();
foreach (explode('/', trim($path, '/')) as $segment) {
$folder = $folder->hasFolder($segment) ? $folder->getSubfolder($segment) : $folder->createFolder($segment);
}
return $folder;
}
// ---- docx parsing ------------------------------------------------------------------
/**
* @return array{title:string,subtitle:string,teaser:string,
* lead:array{quote:string,name:string,position:string},
* sections:array<int,array<string,string>>,quotes:array<int,int>,report:string[]}
*/
private function parseDocx(string $file): array
{
$zip = new \ZipArchive();
if ($zip->open($file) !== true) {
throw new \RuntimeException('docx laesst sich nicht oeffnen');
}
$xml = (string)$zip->getFromName('word/document.xml');
$zip->close();
$dom = new \DOMDocument();
$dom->loadXML($xml);
$xpath = new \DOMXPath($dom);
$w = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
$xpath->registerNamespace('w', $w);
$paragraphs = [];
foreach ($xpath->query('//w:body/w:p') as $p) {
$text = '';
foreach ($xpath->query('.//w:t|.//w:br', $p) as $node) {
// w:br as line break - attribution lines come as "Name<br>Role"
// in some files and glued without it the names stick together
// ("Shawn HanssonCEO, ...", ticket 0017-FE)
$text .= $node->localName === 'br' ? "\n" : $node->textContent;
}
$text = trim((string)preg_replace('/[ \t]+/u', ' ', $text));
$text = (string)preg_replace('/\s*\n\s*/u', "\n", $text);
// import footer "04 | 2025 Updated on 10 | 2026" (0017-FE 5a)
$text = trim((string)preg_replace('/\s*\d{1,2}\s*\|\s*\d{4}\s*Updated on\s*\d{1,2}\s*\|\s*\d{4}\.?\s*$/iu', '', $text));
if ($text === '') {
continue;
}
$styleNode = $xpath->query('./w:pPr/w:pStyle/@w:val', $p)->item(0);
$paragraphs[] = [
'text' => str_replace("\n", ' ', $text),
'lines' => explode("\n", $text),
'style' => $styleNode ? (string)$styleNode->nodeValue : '',
];
}
$report = [];
$title = $paragraphs[0]['text'] ?? '';
$subtitle = $paragraphs[1]['text'] ?? '';
$lead = ['quote' => '', 'name' => '', 'position' => ''];
$index = 2;
if (isset($paragraphs[$index]) && $paragraphs[$index]['style'] === 'IntenseQuote') {
$lead['quote'] = $this->unquote($paragraphs[$index]['text']);
$index++;
// attribution lines are often ALSO IntenseQuote-styled in these
// files (0017-FE: they became bogus quotation elements) and may
// be glued into ONE paragraph via w:br
if (isset($paragraphs[$index]) && $this->isAttributionLine($paragraphs[$index], 60)) {
$lines = $paragraphs[$index]['lines'];
$lead['name'] = trim((string)$lines[0]);
if (count($lines) > 1) {
$lead['position'] = trim(implode(', ', array_map('trim', array_slice($lines, 1))));
}
$index++;
if ($lead['position'] === '' && isset($paragraphs[$index]) && $this->isAttributionLine($paragraphs[$index], 100)) {
$lead['position'] = $paragraphs[$index]['text'];
$index++;
}
}
} else {
$report[] = 'HINWEIS: kein Lead-Quote an Position 3';
}
// sections
$sections = [];
$quotes = [];
$current = null;
$body = []; // items: ['p', html] | ['ul', [li,...]]
$flushBody = function () use (&$sections, &$current, &$body): void {
if ($current === null && $body === []) {
return;
}
$html = '';
foreach ($body as [$kind, $value]) {
$html .= $kind === 'ul'
? '<ul>' . implode('', array_map(static fn(string $li): string => '<li>' . htmlspecialchars($li, ENT_NOQUOTES | ENT_HTML5) . '</li>', $value)) . '</ul>'
: '<p>' . htmlspecialchars($value, ENT_NOQUOTES | ENT_HTML5) . '</p>';
}
if ($html !== '' || $current !== null) {
$sections[] = ['type' => 'text', 'header' => (string)$current, 'body' => $html];
}
$body = [];
};
for ($i = $index; $i < count($paragraphs); $i++) {
$paragraph = $paragraphs[$i];
$lower = mb_strtolower(rtrim($paragraph['text'], ':'));
if ($paragraph['style'] !== 'IntenseQuote' && in_array($lower, self::SECTION_NAMES, true)) {
$flushBody();
$current = $paragraph['text'];
continue;
}
if ($paragraph['style'] === 'IntenseQuote') {
// attribution BEFORE the quote ("... said:" / "... continues:")
$name = '';
$position = '';
$last = $body !== [] ? $body[count($body) - 1] : null;
if ($last !== null && $last[0] === 'p' && preg_match('/^(.{3,90}?)\s+(said|says|continues|adds|explains)\s*:?\s*$/iu', $last[1], $m)) {
[$name, $position] = $this->splitAttribution($m[1]);
array_pop($body);
} elseif (isset($paragraphs[$i + 1]) && $paragraphs[$i + 1]['style'] !== 'IntenseQuote') {
$next = $paragraphs[$i + 1]['text'];
$nextLower = mb_strtolower(rtrim($next, ':'));
if (mb_strlen($next) <= 90 && str_contains($next, ',') && !in_array($nextLower, self::SECTION_NAMES, true)) {
[$name, $position] = $this->splitAttribution($next);
$i++;
}
}
$flushBody();
$current = null;
$sections[] = ['type' => 'quote', 'quote' => $this->unquote($paragraph['text']), 'name' => $name, 'position' => $position];
$quotes[] = 1;
continue;
}
if ($paragraph['style'] === 'ListParagraph') {
$last = $body !== [] ? count($body) - 1 : -1;
if ($last >= 0 && $body[$last][0] === 'ul') {
$body[$last][1][] = $paragraph['text'];
} else {
$body[] = ['ul', [$paragraph['text']]];
}
continue;
}
if ($current === null && $sections === []) {
$report[] = 'HINWEIS: Absatz vor erster Sektion: "' . mb_substr($paragraph['text'], 0, 50) . '..."';
}
$body[] = ['p', $paragraph['text']];
}
$flushBody();
// teaser from the first text section
$teaser = '';
foreach ($sections as $section) {
if ($section['type'] === 'text' && preg_match('/<p>(.*?)<\/p>/s', $section['body'], $m)) {
$teaser = html_entity_decode($m[1], ENT_NOQUOTES | ENT_HTML5);
if (mb_strlen($teaser) > 300) {
$teaser = mb_substr($teaser, 0, 300);
$teaser = mb_substr($teaser, 0, (int)mb_strrpos($teaser, ' ')) . ' ...';
}
break;
}
}
return ['title' => $title, 'subtitle' => $subtitle, 'teaser' => $teaser,
'lead' => $lead, 'sections' => $sections, 'quotes' => $quotes, 'report' => $report];
}
/** @return array{0:string,1:string} name, position */
private function splitAttribution(string $value): array
{
$parts = explode(',', $value, 2);
return [trim($parts[0]), trim($parts[1] ?? '')];
}
private function unquote(string $value): string
{
// multibyte-safe strip of straight and curly quotes at both ends
return trim((string)preg_replace('/^[\s"\'\x{201C}\x{201D}\x{2018}\x{2019}]+|[\s"\'\x{201C}\x{201D}\x{2018}\x{2019}]+$/u', '', $value));
}
private function slugify(string $value): string
{
$value = str_replace(['+', '&'], ['plus', 'and'], mb_strtolower(trim($value)));
return trim((string)preg_replace('/[^a-z0-9]+/', '-', $value), '-');
}
private function norm(string $value): string
{
return (string)preg_replace('/[^a-z0-9]+/', '', str_replace(['+', '&'], ['plus', 'and'], mb_strtolower($value)));
}
private function newId(): string
{
return 'NEWDS' . (++$this->newCounter);
}
}
@@ -275,7 +275,7 @@ final class ImportMarketsMainPageCommand extends Command
private function p(string $text): string
{
return '<p>' . htmlspecialchars($text, ENT_QUOTES | ENT_HTML5) . '</p>';
return '<p>' . htmlspecialchars($text, ENT_NOQUOTES | ENT_HTML5) . '</p>';
}
private function norm(string $value): string
@@ -173,6 +173,24 @@ final class ImportProductCategoryPagesCommand extends Command
}
$pageUid = ctype_digit($pageId) ? (int)$pageId : 0;
// Guard (2026-10-28, Ticket 0014-FE): nach vitec:wrap-cards-in-grid
// stecken die Karten IN vitec_cols-Containern (colPos 221-223), wo
// der Slot-Cursor sie nicht sieht - der IQS4-Re-Run hat so auf pid
// 569 den Kartensatz dupliziert (Repair: fix_products_grid.php).
// Eine gewickelte Seite wird uebersprungen statt beschaedigt.
if ($pageUid > 0) {
$containerCount = (int)GeneralUtility::makeInstance(ConnectionPool::class)
->getConnectionForTable('tt_content')->fetchOne(
"SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND pid = ? AND CType LIKE 'vitec_cols_%'",
[$pageUid]
);
if ($containerCount > 0) {
$this->report[] = 'SKIP: Seite ist grid-gewickelt (' . $containerCount
. ' Container) - Re-Import wuerde Karten duplizieren. Inhalte im BE pflegen oder Grids erst aufloesen.';
return null;
}
}
// --- hero -------------------------------------------------------------------
if ($hero !== null) {
$id = $this->slotNext($pageUid, self::COLPOS_HERO, 'vitec_herosection', $pageId);
@@ -316,9 +334,9 @@ final class ImportProductCategoryPagesCommand extends Command
}
$body = '<table class="vitec-comparison"><thead><tr><th>Product</th><th>Type</th><th>Description</th></tr></thead><tbody>';
foreach ($rows as $row) {
$body .= '<tr><td>' . htmlspecialchars((string)$row['heading'], ENT_QUOTES | ENT_HTML5) . '</td>'
. '<td>' . htmlspecialchars((string)$row['body'], ENT_QUOTES | ENT_HTML5) . '</td>'
. '<td>' . htmlspecialchars((string)$row['cta'], ENT_QUOTES | ENT_HTML5) . '</td></tr>';
$body .= '<tr><td>' . htmlspecialchars((string)$row['heading'], ENT_NOQUOTES | ENT_HTML5) . '</td>'
. '<td>' . htmlspecialchars((string)$row['body'], ENT_NOQUOTES | ENT_HTML5) . '</td>'
. '<td>' . htmlspecialchars((string)$row['cta'], ENT_NOQUOTES | ENT_HTML5) . '</td></tr>';
}
$body .= '</tbody></table>';
$id = $this->slotNext($pageUid, self::COLPOS_MAIN, 'text', $pageId);
@@ -502,7 +520,9 @@ final class ImportProductCategoryPagesCommand extends Command
private function p(string $text): string
{
$text = trim($text);
return $text === '' ? '' : '<p>' . htmlspecialchars($text, ENT_QUOTES | ENT_HTML5) . '</p>';
// ENT_NOQUOTES (2026-10-28, Ticket 0014-FE): ENT_QUOTES machte ' zu &apos;,
// und die RTE-Persistenz escapte das & gleich nochmal (&amp;apos; im JSON)
return $text === '' ? '' : '<p>' . htmlspecialchars($text, ENT_NOQUOTES | ENT_HTML5) . '</p>';
}
private function norm(string $value): string
@@ -0,0 +1,688 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Command;
use PhpOffice\PhpSpreadsheet\IOFactory;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use TYPO3\CMS\Core\Core\Bootstrap;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Fills the product OVERVIEW pages (/products/<slug>, layout 9, built by
* vitec:build-product-overviews on 2026-11-02) with the agency's editorial
* copy from the "<Overview> - Enriched.xlsx" workbooks (Dropbox delivery
* 2026-11-09, staged in migrations/product_overviews_xlsx_in/).
*
* Workbook shape: sheet "Content" with rows "Section | Content field |
* Website copy / value | Production notes", sections 01 HERO, 02 INTRO,
* 03 WHY VITEC, 04 COMPARISON, 05 PRODUCT RANGE, 06 SOLUTIONS, 07 CTA,
* 08 SEO. The page is found by its title (= the workbook's H1).
*
* The pages already carry generated content (hero, optional "Why VITEC?"
* icon band, "Product range" intro, product card grids, contact banner).
* This importer UPDATES in place and only ADDS what is missing:
*
* 01 HERO -> hero bodytext (subline) + CTA label; title stays
* 02 INTRO -> NEW introparagraph (H2 + introduction) at the top of
* the main column, plus the use-case paragraph
* 03 WHY VITEC -> page with icon band: benefit copy goes onto the card
* whose title matches the benefit heading (theme match
* like vitec:build-why-vitec-bands), unmatched benefits
* are REPORTED only (the band is the agency's icon set);
* page without band: NEW band of icon_card cards without
* icon (0106-FE: the frontend builds the band from
* icon_card only - text_only cards fall apart; a band
* card still carrying text_only is repaired on re-run)
* 04 COMPARISON -> skipped unless --comparison: the workbook holds no
* specifications ("populate from approved datasheets"),
* only the card descriptions again
* 05 PRODUCT RANGE -> the "Product range" intro gets the H2 + intro copy;
* product CARDS stay untouched (they show the record
* teasers from the product workbooks - the overview
* workbook texts are mostly boilerplate)
* 06 SOLUTIONS -> NEW intro + one text_only card per solution, linked
* to the solution group page under /solutions
* 07 CTA -> contact banner header/body/button (link stays)
* 08 SEO -> seo/og/twitter title + description, keywords, abstract
*
* Main-column order after apply: intro, use case, Why VITEC band,
* [comparison], range intro, product cards (unchanged), solutions.
* Idempotent: existing elements are found by header; nothing is duplicated.
*
* vendor/bin/typo3 vitec:import-product-overview-workbooks
* vendor/bin/typo3 vitec:import-product-overview-workbooks --only=aetria --apply
*/
#[AsCommand(
name: 'vitec:import-product-overview-workbooks',
description: 'Fill the product overview pages from the agency "Enriched" workbooks (dry-run by default)'
)]
final class ImportProductOverviewWorkbooksCommand extends Command
{
private const COLPOS_HERO = 1;
private const COLPOS_MAIN = 0;
private const COLPOS_PREFOOTER = 3;
private const WHY_HEADER = 'Why VITEC?';
private const RANGE_PLACEHOLDER = 'Product range';
private const STOPWORDS = ['and', 'for', 'the', 'of', 'to', 'a', 'an', 'in', 'on', 'with'];
/** workbook H1 (post-norm) => page slug where the title match fails */
private const TITLE_TO_SLUG = [
'xplayerendpoints' => '/products/x-player-end-points',
];
/** workbook solution names that differ from the group page titles beyond spelling (post-norm) */
private const SOLUTION_ALIASES = [
'videowallandvisualisationsolutions' => 'videowallanddatavisualisation',
];
/** @var array<string,array<string|int,array<string,mixed>>> */
private array $datamap = [];
/** @var string[] */
private array $report = [];
private int $newCounter = 0;
private bool $apply = false;
private bool $withComparison = false;
private int $touched = 0;
protected function configure(): void
{
$this->addOption('dir', null, InputOption::VALUE_REQUIRED, 'Workbook directory', 'migrations/product_overviews_xlsx_in');
$this->addOption('only', null, InputOption::VALUE_REQUIRED, 'Filename substring filter', '');
$this->addOption('comparison', null, InputOption::VALUE_NONE, 'Also create the comparison table (text element)');
$this->addOption('apply', null, InputOption::VALUE_NONE, 'Write changes (default: dry run)');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
Bootstrap::initializeBackendAuthentication();
$this->apply = (bool)$input->getOption('apply');
$this->withComparison = (bool)$input->getOption('comparison');
$only = mb_strtolower((string)$input->getOption('only'));
$dir = rtrim((string)$input->getOption('dir'), '/');
$files = glob($dir . '/*.xlsx') ?: [];
$files = array_values(array_filter($files, static fn(string $f): bool => !str_starts_with(basename($f), '~$')
&& ($only === '' || str_contains(mb_strtolower(basename($f)), $only))));
sort($files);
$output->writeln(($this->apply ? 'APPLY' : 'DRY-RUN') . ' · ' . count($files) . ' workbook(s) in ' . $dir
. ($this->withComparison ? ' · with comparison table' : ' · comparison table skipped'));
$ok = 0;
foreach ($files as $file) {
try {
if ($this->importOne($file, $output)) {
$ok++;
}
} catch (\Throwable $e) {
$output->writeln('<error> ' . basename($file) . ': ' . $e->getMessage() . '</error>');
}
}
$output->writeln(sprintf(
"\n<info>%d of %d workbook(s) %s, %d element/page write(s).</info>%s",
$ok, count($files), $this->apply ? 'applied' : 'planned', $this->touched,
$this->apply ? ' Flush the cache, then vitec:ssr-purge --all + vitec:ssr-warm.' : ' DRY RUN - nothing written. Add --apply to write.'
));
return Command::SUCCESS;
}
private function importOne(string $file, OutputInterface $output): bool
{
$this->datamap = [];
$this->report = [];
$wb = $this->readWorkbook($file);
$h1 = $wb['hero']['h1'] ?? '';
if ($h1 === '') {
$output->writeln('<comment> ' . basename($file) . ': no "01 · HERO / H1" row - skipped</comment>');
return false;
}
$page = $this->pageByTitle($h1);
if ($page === null) {
$output->writeln('<error> ' . basename($file) . ': no overview page titled "' . $h1 . '" under /products - skipped'
. ($this->report !== [] ? ' (' . implode('; ', $this->report) . ')' : '') . '</error>');
return false;
}
$pageUid = (int)$page['uid'];
$output->writeln(sprintf("\n<info>%s</info> -> %s (#%d)", basename($file), $page['slug'], $pageUid));
$pool = GeneralUtility::makeInstance(ConnectionPool::class);
$conn = $pool->getConnectionForTable('tt_content');
$top = $conn->fetchAllAssociative(
'SELECT uid, colPos, CType, header, bodytext, vitec_layout_variant, vitec_primary_cta_label, vitec_cta_label FROM tt_content
WHERE deleted = 0 AND pid = ? AND sys_language_uid IN (0,-1)
AND (tx_container_parent IS NULL OR tx_container_parent = 0)
ORDER BY colPos, sorting, uid',
[$pageUid]
);
$main = array_values(array_filter($top, static fn(array $r): bool => (int)$r['colPos'] === self::COLPOS_MAIN));
$byUid = [];
foreach ($main as $row) {
$byUid[(string)$row['uid']] = $row;
}
// --- 01 hero ------------------------------------------------------------------
$hero = $this->first($top, self::COLPOS_HERO, 'vitec_herosection');
if ($hero === null) {
$this->report[] = 'hero: NO vitec_herosection in colPos 1 - subline/CTA not placed';
} else {
$this->update((string)$hero['uid'], $hero, [
'bodytext' => $this->p($wb['hero']['subline'] ?? ''),
'vitec_cta_label' => $wb['hero']['cta'] ?? '',
], 'hero #' . $hero['uid']);
}
// --- 02 intro + use case ----------------------------------------------------------
$introH2 = ($wb['intro']['h2'] ?? '') !== '' ? $wb['intro']['h2'] : $h1;
$introId = $this->ensureIntro($main, $byUid, $pageUid, $introH2, $wb['intro']['body'] ?? '', 'intro');
$useId = null;
if (($wb['intro']['usecase_body'] ?? '') !== '') {
$useHeading = ($wb['intro']['usecase_heading'] ?? '') !== '' ? $wb['intro']['usecase_heading'] : 'Applications and workflows';
$useId = $this->ensureIntro($main, $byUid, $pageUid, $useHeading, $wb['intro']['usecase_body'], 'use case');
}
// --- 03 why vitec -----------------------------------------------------------------
$whyIds = [];
$whyIndex = $this->indexOf($main, 'vitec_introparagraph', self::WHY_HEADER);
if ($whyIndex !== null) {
$whyIds[] = (string)$main[$whyIndex]['uid'];
$cards = [];
for ($i = $whyIndex + 1; $i < count($main) && $main[$i]['CType'] === 'vitec_card'; $i++) {
$cards[] = $main[$i];
$whyIds[] = (string)$main[$i]['uid'];
}
$used = [];
foreach ($wb['why']['benefits'] as $benefit) {
$match = null;
foreach ($cards as $card) {
if (isset($used[(string)$card['uid']])) {
continue;
}
if ($this->norm((string)$card['header']) === $this->norm($benefit['heading']) || $this->sameTheme($benefit['heading'], (string)$card['header'])) {
$match = $card;
break;
}
}
if ($match === null) {
$this->report[] = 'why: benefit "' . $benefit['heading'] . '" has no matching icon card - NOT placed (band = agency icon set; editorial decision)';
continue;
}
$used[(string)$match['uid']] = true;
if ((string)$match['vitec_layout_variant'] !== 'icon_card') {
// 0106-FE: band cards must be icon_card (first run wrote text_only)
$this->update((string)$match['uid'], $match, ['vitec_layout_variant' => 'icon_card'], 'why card "' . $match['header'] . '" -> icon_card');
}
if (trim(strip_tags((string)$match['bodytext'])) !== '' && $this->norm(strip_tags((string)$match['bodytext'])) !== $this->norm($benefit['body'])) {
$this->report[] = 'why: card "' . $match['header'] . '" already has own text - left as is';
continue;
}
$this->update((string)$match['uid'], $match, ['bodytext' => $this->p($benefit['body'])], 'why card "' . $match['header'] . '" <- "' . $benefit['heading'] . '"');
}
} elseif ($wb['why']['benefits'] !== []) {
$id = $this->create($pageUid, 'vitec_introparagraph', ['header' => self::WHY_HEADER, 'bodytext' => ''], 'why: NEW band intro');
$whyIds[] = $id;
foreach ($wb['why']['benefits'] as $benefit) {
$whyIds[] = $this->create($pageUid, 'vitec_card', [
'vitec_layout_variant' => 'icon_card',
'header' => $benefit['heading'],
'bodytext' => $this->p($benefit['body']),
'vitec_primary_cta_label' => '',
], 'why: NEW card "' . $benefit['heading'] . '" (icon_card, no icon)');
}
}
// --- 04 comparison (opt-in) ------------------------------------------------------------
$comparisonId = null;
if ($this->withComparison && ($wb['comparison']['rows'] ?? []) !== []) {
$h2 = $this->fixCase($wb['comparison']['h2'] ?? 'Compare ' . $h1, $h1);
$body = $this->p($wb['comparison']['intro'] ?? '')
. '<table class="vitec-comparison"><thead><tr><th>Product</th><th>Description</th></tr></thead><tbody>';
foreach ($wb['comparison']['rows'] as $row) {
$body .= '<tr><td>' . htmlspecialchars($row['name'], ENT_NOQUOTES | ENT_HTML5) . '</td><td>'
. htmlspecialchars($row['text'], ENT_NOQUOTES | ENT_HTML5) . '</td></tr>';
}
$body .= '</tbody></table>';
$idx = $this->indexOf($main, 'text', $h2);
if ($idx !== null) {
$comparisonId = (string)$main[$idx]['uid'];
$this->update($comparisonId, $main[$idx], ['bodytext' => $body], 'comparison #' . $comparisonId);
} else {
$comparisonId = $this->create($pageUid, 'text', ['header' => $h2, 'bodytext' => $body], 'comparison: NEW text element (' . count($wb['comparison']['rows']) . ' rows)');
}
} elseif (($wb['comparison']['rows'] ?? []) !== []) {
$this->report[] = 'comparison: ' . count($wb['comparison']['rows']) . ' row(s) skipped (no specifications in the workbook; --comparison to create)';
}
// --- 05 product range intro ----------------------------------------------------------
$rangeH2 = $this->fixCase(($wb['range']['h2'] ?? '') !== '' ? $wb['range']['h2'] : ('Explore the ' . $h1 . ' range'), $h1);
$rangeIdx = $this->indexOf($main, 'vitec_introparagraph', $rangeH2) ?? $this->indexOf($main, 'vitec_introparagraph', self::RANGE_PLACEHOLDER);
if ($rangeIdx !== null) {
$rangeId = (string)$main[$rangeIdx]['uid'];
$this->update($rangeId, $main[$rangeIdx], ['header' => $rangeH2, 'bodytext' => $this->p($wb['range']['intro'] ?? '')], 'range intro #' . $rangeId);
} else {
$rangeId = $this->create($pageUid, 'vitec_introparagraph', ['header' => $rangeH2, 'bodytext' => $this->p($wb['range']['intro'] ?? '')], 'range: NEW intro (grouped page)');
}
$names = array_map(static fn(array $p): string => $p['name'], $wb['range']['products'] ?? []);
$cardsOnPage = (int)$conn->fetchOne(
"SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND pid = ? AND CType = 'vitec_card' AND colPos >= 200",
[$pageUid]
);
$this->report[] = 'range: ' . count($names) . ' workbook product(s), ' . $cardsOnPage . ' card(s) in grids on the page - cards untouched (record teasers win)';
// --- 06 solutions ------------------------------------------------------------------------
$solIds = [];
if (($wb['solutions']['items'] ?? []) !== []) {
$h2 = $this->fixCase(($wb['solutions']['h2'] ?? '') !== '' ? $wb['solutions']['h2'] : ('Solutions using ' . $h1), $h1);
$idx = $this->indexOf($main, 'vitec_introparagraph', $h2);
if ($idx !== null) {
$this->report[] = 'solutions: intro "' . $h2 . '" exists - block left as is';
} else {
$solIds[] = $this->create($pageUid, 'vitec_introparagraph', ['header' => $h2, 'bodytext' => ''], 'solutions: NEW intro "' . $h2 . '"');
foreach ($wb['solutions']['items'] as $item) {
$fields = [
'vitec_layout_variant' => 'text_only',
'header' => $item['title'],
'bodytext' => $this->p($item['text']),
'vitec_primary_cta_label' => 'Learn more',
];
$target = $this->resolveSolutionPage($item['title']);
if ($target > 0) {
$fields['vitec_primary_cta'] = 't3://page?uid=' . $target;
}
$solIds[] = $this->create($pageUid, 'vitec_card', $fields, 'solutions: NEW card "' . $item['title'] . '" -> ' . ($target > 0 ? 'page #' . $target : 'NO solution page resolved (link empty)'));
}
}
}
// --- 07 cta banner -------------------------------------------------------------------------
$cta = $this->first($top, self::COLPOS_PREFOOTER, 'vitec_ctabanner');
if ($cta === null) {
$this->report[] = 'cta: NO vitec_ctabanner in colPos 3 - not placed';
} elseif (($wb['cta']['h2'] ?? '') !== '') {
$this->update((string)$cta['uid'], $cta, [
'header' => $this->fixCase($wb['cta']['h2'], $h1),
'bodytext' => $this->p($wb['cta']['body'] ?? ''),
'vitec_primary_cta_label' => ($wb['cta']['button'] ?? '') !== '' ? $wb['cta']['button'] : 'Contact us',
], 'cta #' . $cta['uid']);
}
// --- 08 seo / page properties -------------------------------------------------------------
$seoTitle = ($wb['seo']['title'] ?? '') !== '' ? $wb['seo']['title'] : $h1 . ' | VITEC';
$desc = $wb['seo']['description'] ?? '';
$keywords = implode(', ', array_filter([$wb['seo']['primary'] ?? '', $wb['seo']['secondary'] ?? '']));
$props = [
'seo_title' => $seoTitle, 'og_title' => $seoTitle, 'twitter_title' => $seoTitle,
'description' => $desc, 'og_description' => $desc, 'twitter_description' => $desc,
'keywords' => $keywords,
'abstract' => mb_substr(trim(($wb['intro']['body'] ?? '') !== '' ? $wb['intro']['body'] : $desc), 0, 300),
];
$changed = [];
foreach ($props as $field => $value) {
if (trim((string)($page[$field] ?? '')) !== trim($value)) {
$changed[] = $field;
$this->datamap['pages'][(string)$pageUid][$field] = $value;
}
}
if ($changed !== []) {
$this->touched++;
$this->report[] = 'page meta: ' . implode(', ', $changed);
} else {
$this->report[] = 'page meta: unchanged';
}
foreach ($this->report as $line) {
$output->writeln(' ' . $line);
}
if (!$this->apply) {
return true;
}
// --- write -----------------------------------------------------------------------------------
$subst = [];
if ($this->datamap !== []) {
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start($this->datamap, []);
$dataHandler->process_datamap();
if ($dataHandler->errorLog !== []) {
throw new \RuntimeException('DataHandler: ' . implode(' | ', $dataHandler->errorLog));
}
$subst = $dataHandler->substNEWwithIDs;
}
// final main-column order: intro, use case, why band, [comparison], range intro, the rest (cards/grids/group intros), solutions
$order = array_merge([$introId], $useId !== null ? [$useId] : [], $whyIds, $comparisonId !== null ? [$comparisonId] : [], [$rangeId]);
$placed = array_flip($order);
foreach ($main as $row) {
if (!isset($placed[(string)$row['uid']])) {
$order[] = (string)$row['uid'];
}
}
$order = array_merge($order, $solIds);
$sorting = 0;
foreach ($order as $id) {
$uid = str_starts_with($id, 'NEW') ? (int)($subst[$id] ?? 0) : (int)$id;
if ($uid > 0) {
$sorting += 256;
$conn->update('tt_content', ['sorting' => $sorting], ['uid' => $uid]);
}
}
return true;
}
// ---- element helpers ---------------------------------------------------------------------------
/** @param array<int,array<string,mixed>> $rows */
private function first(array $rows, int $colPos, string $ctype): ?array
{
foreach ($rows as $row) {
if ((int)$row['colPos'] === $colPos && $row['CType'] === $ctype) {
return $row;
}
}
return null;
}
/** @param array<int,array<string,mixed>> $main */
private function indexOf(array $main, string $ctype, string $header): ?int
{
$needle = $this->norm($header);
foreach ($main as $i => $row) {
if ($row['CType'] === $ctype && $this->norm((string)$row['header']) === $needle) {
return $i;
}
}
return null;
}
/**
* Existing introparagraph with this header (updated) or a new one.
*
* @param array<int,array<string,mixed>> $main
* @param array<string,array<string,mixed>> $byUid
*/
private function ensureIntro(array $main, array $byUid, int $pageUid, string $header, string $body, string $label): string
{
$idx = $this->indexOf($main, 'vitec_introparagraph', $header);
if ($idx !== null) {
$id = (string)$main[$idx]['uid'];
$this->update($id, $byUid[$id], ['header' => $header, 'bodytext' => $this->p($body)], $label . ' #' . $id);
return $id;
}
return $this->create($pageUid, 'vitec_introparagraph', ['header' => $header, 'bodytext' => $this->p($body)], $label . ': NEW "' . $header . '"');
}
/** @param array<string,mixed> $fields */
private function create(int $pageUid, string $ctype, array $fields, string $label): string
{
$id = $this->newId();
$this->datamap['tt_content'][$id] = ['pid' => $pageUid, 'colPos' => self::COLPOS_MAIN, 'CType' => $ctype] + $fields;
$this->report[] = $label;
$this->touched++;
return $id;
}
/**
* Writes only fields whose current value differs (RTE-tolerant on bodytext).
*
* @param array<string,mixed> $current
* @param array<string,string> $fields
*/
private function update(string $uid, array $current, array $fields, string $label): void
{
$diff = [];
foreach ($fields as $field => $value) {
$old = (string)($current[$field] ?? '');
$same = $field === 'bodytext'
? $this->norm(strip_tags($old)) === $this->norm(strip_tags($value))
: trim($old) === trim($value);
if (!$same) {
$diff[] = $field;
$this->datamap['tt_content'][$uid][$field] = $value;
}
}
if ($diff === []) {
$this->report[] = $label . ': unchanged';
return;
}
$this->touched++;
$this->report[] = $label . ': ' . implode(', ', $diff);
}
// ---- resolution --------------------------------------------------------------------------------
/** @return array<string,mixed>|null */
private function pageByTitle(string $title): ?array
{
$pages = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages');
$needle = $this->norm($title);
$rows = $pages->fetchAllAssociative(
"SELECT uid, slug, title, nav_title, seo_title, og_title, twitter_title, description, og_description, twitter_description, keywords, abstract
FROM pages WHERE deleted = 0 AND doktype = 1 AND sys_language_uid IN (0,-1) AND slug LIKE '/products/%'"
);
if (isset(self::TITLE_TO_SLUG[$needle])) {
foreach ($rows as $row) {
if ($row['slug'] === self::TITLE_TO_SLUG[$needle]) {
return $row;
}
}
}
$hits = array_values(array_filter($rows, fn(array $row): bool => $this->norm((string)$row['title']) === $needle));
if (count($hits) === 1) {
return $hits[0];
}
if (count($hits) > 1) {
// prefer the flat overview slug (one segment below /products)
$flat = array_values(array_filter($hits, static fn(array $r): bool => substr_count((string)$r['slug'], '/') === 2));
if (count($flat) === 1) {
return $flat[0];
}
$this->report[] = 'ambiguous title "' . $title . '": ' . implode(', ', array_map(static fn(array $r): string => $r['slug'], $hits));
return null;
}
$hits = array_values(array_filter($rows, fn(array $row): bool => $this->norm((string)$row['nav_title']) === $needle));
return count($hits) === 1 ? $hits[0] : null;
}
private function resolveSolutionPage(string $title): int
{
$pages = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages');
$root = $pages->fetchOne("SELECT uid FROM pages WHERE deleted = 0 AND slug = '/solutions'");
if (!$root) {
return 0;
}
$needle = $this->norm($title);
$needle = self::SOLUTION_ALIASES[$needle] ?? $needle;
$strip = static fn(string $n): string => (string)preg_replace('/solutions$/', '', $n);
$rows = $pages->fetchAllAssociative(
'SELECT uid, title FROM pages WHERE deleted = 0 AND pid = ? AND sys_language_uid IN (0,-1) AND doktype = 1',
[(int)$root]
);
foreach ($rows as $row) {
if ($this->norm((string)$row['title']) === $needle) {
return (int)$row['uid'];
}
}
foreach ($rows as $row) {
if ($strip($this->norm((string)$row['title'])) === $strip($needle)) {
return (int)$row['uid'];
}
}
return 0;
}
// ---- workbook reading --------------------------------------------------------------------------
/** @return array<string,mixed> */
private function readWorkbook(string $file): array
{
$spreadsheet = IOFactory::load($file);
$sheet = $spreadsheet->getSheetByName('Content') ?? $spreadsheet->getSheet($spreadsheet->getSheetCount() - 1);
$rows = $sheet->toArray(null, false, false, false);
$spreadsheet->disconnectWorksheets();
$wb = ['hero' => [], 'intro' => [], 'why' => ['benefits' => []], 'comparison' => ['rows' => []],
'range' => ['products' => []], 'solutions' => ['items' => []], 'cta' => [], 'seo' => []];
$benefits = [];
$products = [];
$solutions = [];
foreach ($rows as $row) {
$section = trim((string)($row[0] ?? ''));
$field = trim((string)($row[1] ?? ''));
$value = trim((string)($row[2] ?? ''));
if ($section === '' || $field === '' || !preg_match('/^(\d\d)/', $section, $m)) {
continue;
}
$f = mb_strtolower($field);
switch ($m[1]) {
case '01':
if ($f === 'h1') {
$wb['hero']['h1'] = $value;
} elseif (str_contains($f, 'subline')) {
$wb['hero']['subline'] = $value;
} elseif (str_contains($f, 'cta')) {
$wb['hero']['cta'] = $value;
}
break;
case '02':
if ($f === 'h2') {
$wb['intro']['h2'] = $value;
} elseif ($f === 'introduction') {
$wb['intro']['body'] = $value;
} elseif (str_contains($f, 'use-case card heading')) {
$wb['intro']['usecase_heading'] = $value;
} elseif (str_contains($f, 'use-case description')) {
$wb['intro']['usecase_body'] = $value;
}
break;
case '03':
if (preg_match('/^benefit\s*(\d+)\s*[—–-]+\s*(heading|body)$/u', $f, $b)) {
$benefits[(int)$b[1]][$b[2]] = $value;
} elseif (str_contains($f, 'h2')) {
$wb['why']['h2'] = $value;
}
break;
case '04':
if ($f === 'h2') {
$wb['comparison']['h2'] = $value;
} elseif ($f === 'intro') {
$wb['comparison']['intro'] = $value;
} elseif (preg_match('/^product\s*\d+\s*:\s*(.+)$/iu', $field, $c)) {
$wb['comparison']['rows'][] = ['name' => trim($c[1]), 'text' => $value];
}
break;
case '05':
if ($f === 'h2') {
$wb['range']['h2'] = $value;
} elseif ($f === 'intro') {
$wb['range']['intro'] = $value;
} elseif (preg_match('/^product\s*(\d+)\s*[—–-]+\s*(group|name|description|cta|target url)$/u', $f, $p)) {
$products[(int)$p[1]][$p[2]] = $value;
}
break;
case '06':
if ($f === 'h2') {
$wb['solutions']['h2'] = $value;
} elseif (preg_match('/^solution\s*(\d+)\s*[—–-]+\s*(title|description|link)$/u', $f, $s)) {
$solutions[(int)$s[1]][$s[2]] = $value;
}
break;
case '07':
if ($f === 'h2') {
$wb['cta']['h2'] = $value;
} elseif ($f === 'body') {
$wb['cta']['body'] = $value;
} elseif ($f === 'button') {
$wb['cta']['button'] = $value;
} elseif ($f === 'url') {
$wb['cta']['url'] = $value;
}
break;
case '08':
if (str_contains($f, 'seo title')) {
$wb['seo']['title'] = $value;
} elseif (str_contains($f, 'meta description')) {
$wb['seo']['description'] = $value;
} elseif (str_contains($f, 'primary keyword')) {
$wb['seo']['primary'] = $value;
} elseif (str_contains($f, 'secondary keyword')) {
$wb['seo']['secondary'] = $value;
}
break;
}
}
ksort($benefits);
foreach ($benefits as $b) {
if (($b['heading'] ?? '') !== '') {
$wb['why']['benefits'][] = ['heading' => $b['heading'], 'body' => $b['body'] ?? ''];
}
}
ksort($products);
foreach ($products as $p) {
if (($p['name'] ?? '') !== '') {
$wb['range']['products'][] = ['group' => $p['group'] ?? '', 'name' => $p['name'], 'text' => $p['description'] ?? ''];
}
}
ksort($solutions);
foreach ($solutions as $s) {
if (($s['title'] ?? '') !== '') {
$wb['solutions']['items'][] = ['title' => $s['title'], 'text' => $s['description'] ?? '', 'link' => $s['link'] ?? ''];
}
}
return $wb;
}
// ---- text helpers --------------------------------------------------------------------------------
/** "Compare aetria" -> "Compare Aetria": the template lower-cased the product name inside the H2s */
private function fixCase(string $text, string $h1): string
{
$lower = mb_strtolower($h1);
if ($lower === '' || $lower === $h1) {
return $text;
}
return (string)preg_replace('/' . preg_quote($lower, '/') . '/u', $h1, $text);
}
private function sameTheme(string $a, string $b): bool
{
$words = fn(string $text): array => array_values(array_diff(
preg_split('/[^a-z0-9]+/', mb_strtolower($text), -1, PREG_SPLIT_NO_EMPTY) ?: [],
self::STOPWORDS
));
$x = $words($a);
$y = $words($b);
if ($x === [] || count($x) !== count($y)) {
return false;
}
foreach ($x as $i => $word) {
if (!str_starts_with($y[$i], $word) && !str_starts_with($word, $y[$i])) {
return false;
}
}
return true;
}
private function p(string $text): string
{
$text = trim($text);
return $text === '' ? '' : '<p>' . htmlspecialchars($text, ENT_NOQUOTES | ENT_HTML5) . '</p>';
}
private function norm(string $value): string
{
$value = mb_strtolower($value);
$value = str_replace(['visualization', 'center'], ['visualisation', 'centre'], $value);
return (string)preg_replace('/[^a-z0-9]+/', '', str_replace(['+', '&'], ['plus', 'and'], $value));
}
private function newId(): string
{
return 'NEWOV' . str_pad((string)(++$this->newCounter), 4, '0', STR_PAD_LEFT);
}
}
@@ -0,0 +1,510 @@
<?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\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Resource\ResourceStorage;
use TYPO3\CMS\Core\Resource\StorageRepository;
use TYPO3\CMS\Core\Service\FlexFormService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Ticket 0124-FE (2026-11-12): Joe aligned the market pages with Figma, the
* front end renders the new bands (0118-FE) - this command creates the
* content they need. Per main market page (/markets/<slug>, 11 pages):
*
* B1 vitec_marketintro: numbered placeholder image placeholders/pages/<uid>-02.png
* when the element has no image yet; learnmore_target "/contact" when empty.
* B2 vitec_customerlogos ("Trusted by"): the 2026-10-23 page import left a
* HIDDEN shell after the sub-market cards - it is unhidden and gets the
* settings of the home page element (#758: layout, customer selection).
* A page without a shell gets a copy of #758 instead.
* B3 vitec_usecaselist ("Case studies" shell, hidden as well): unhidden as
* layout testimonials. Stories = what an editor already picked on the
* shell, else the union of the market's sub-market testimonial lists,
* else the most common sub-market list. No shell: a new element after
* the logos, i.e. before the CTA banner.
* B5 the lead paragraph in the hero slot (vitec_introparagraph, colPos 1)
* is HIDDEN, not deleted (decision aehm 12.11.: copy raw material for Joe).
*
* Sub-market pages (children of the 11):
* B4 vitec_marketinsights: cta_target /success-stories -> /insights/news,
* label "Explore success stories" -> "Explore our insights" (placeholder).
*
* Idempotent: elements that already exist are left alone, filled fields are
* never overwritten. Everything goes through DataHandler (history, refindex,
* SSR auto-purge), still run cache:flush + vitec:ssr-purge/-warm afterwards -
* the command prints the exact lines.
*
* vendor/bin/typo3 vitec:markets-figma-elements # dry run (default)
* vendor/bin/typo3 vitec:markets-figma-elements --apply # writes
*/
#[AsCommand(
name: 'vitec:markets-figma-elements',
description: 'Create the Figma structure elements on the 11 main market pages (0124-FE, dry-run by default)'
)]
final class MarketsFigmaElementsCommand extends Command
{
private const MARKET_SLUGS = [
'/markets/broadcast-and-telecoms',
'/markets/corporation',
'/markets/critical-national-infrastructure',
'/markets/defense-and-security',
'/markets/education',
'/markets/government',
'/markets/hospitality',
'/markets/industrial',
'/markets/public-safety',
'/markets/sports-and-entertainment',
'/markets/transport',
];
private const LOGO_TEMPLATE_UID = 758;
private const STORY_SINGLE_PID = 26;
private const CONTACT_TARGET = '/contact';
private const LEARNMORE_LABEL = 'Talk to our team';
private const OLD_INSIGHTS_TARGET = '/success-stories';
private const NEW_INSIGHTS_TARGET = '/insights/news';
private const OLD_INSIGHTS_LABEL = 'Explore success stories';
private const NEW_INSIGHTS_LABEL = 'Explore our insights';
private const TESTIMONIALS_HEADER = 'Testimonials from the Field';
private const GENERIC_PLACEHOLDER = 'placeholders/placeholder-grey.png';
private Connection $db;
private ResourceStorage $storage;
private OutputInterface $output;
private bool $apply = false;
/** @var array<string,int> */
private array $counts = [
'images' => 0, 'targets' => 0, 'labels' => 0, 'logos' => 0, 'testimonials' => 0,
'hiddenLeads' => 0, 'insightsTargets' => 0, 'insightsLabels' => 0, 'warnings' => 0,
];
protected function configure(): void
{
$this->addOption('apply', null, InputOption::VALUE_NONE, 'Write the changes (default: dry run)');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
Bootstrap::initializeBackendAuthentication();
$this->output = $output;
$this->apply = (bool)$input->getOption('apply');
$this->db = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tt_content');
$this->storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage();
$output->writeln($this->apply ? '<comment>APPLY - writing via DataHandler</comment>' : 'DRY RUN - nothing is written (--apply to write)');
$output->writeln('');
// --- template element for the logo band ------------------------------------
$template = $this->db->fetchAssociative(
'SELECT uid, pid, CType, header, hidden, deleted FROM tt_content WHERE uid = ?',
[self::LOGO_TEMPLATE_UID]
);
if (!$template || $template['CType'] !== 'vitec_customerlogos' || (int)$template['deleted'] === 1) {
$output->writeln(sprintf('<error>Logo template #%d is not a live vitec_customerlogos element.</error>', self::LOGO_TEMPLATE_UID));
return Command::FAILURE;
}
$templateSettings = $this->flexSettings((string)$this->db->fetchOne('SELECT pi_flexform FROM tt_content WHERE uid = ?', [self::LOGO_TEMPLATE_UID]));
$output->writeln(sprintf(
'Logo template: #%d "%s" (page %d) - layout %s, customers %s, onlySelected %s',
$template['uid'], $template['header'], $template['pid'],
$templateSettings['layout'] ?? '-', ($templateSettings['customers'] ?? '') !== '' ? $templateSettings['customers'] : '(all)', $templateSettings['onlySelected'] ?? '0'
));
// --- the 11 market pages -------------------------------------------------------
$markets = [];
foreach (self::MARKET_SLUGS as $slug) {
$page = $this->db->fetchAssociative(
'SELECT uid, title, slug FROM pages WHERE deleted = 0 AND sys_language_uid = 0 AND slug = ?',
[$slug]
);
if (!$page) {
$this->warn("page $slug NOT FOUND - skipped");
continue;
}
$markets[(int)$page['uid']] = $page;
}
if ($markets === []) {
return Command::FAILURE;
}
$marketUids = array_keys($markets);
$in = implode(',', array_map('intval', $marketUids));
// --- sub-market testimonial lists (story selection for B3) ----------------------
$flexFormService = GeneralUtility::makeInstance(FlexFormService::class);
$storiesByMarket = [];
$listFrequency = [];
$rows = $this->db->fetchAllAssociative(
"SELECT c.uid, c.pid, p.pid AS market, c.pi_flexform FROM tt_content c
JOIN pages p ON p.uid = c.pid
WHERE c.CType = 'vitec_usecaselist' AND c.deleted = 0 AND c.hidden = 0 AND c.sys_language_uid = 0
AND p.deleted = 0 AND p.pid IN ($in)
ORDER BY p.sorting, c.sorting"
);
foreach ($rows as $row) {
$settings = $this->flexSettings((string)$row['pi_flexform']);
$uids = array_values(array_filter(array_map('intval', explode(',', (string)($settings['usecases'] ?? '')))));
if ($output->isVerbose()) {
$output->writeln(sprintf(' list #%d (page %d, market %d): layout=%s usecases=%d', $row['uid'], $row['pid'], $row['market'], $settings['layout'] ?? '-', count($uids)));
}
if (($settings['layout'] ?? '') !== 'testimonials' || $uids === []) {
continue;
}
$market = (int)$row['market'];
foreach ($uids as $uid) {
if (!in_array($uid, $storiesByMarket[$market] ?? [], true)) {
$storiesByMarket[$market][] = $uid;
}
}
$key = implode(',', $uids);
$listFrequency[$key] = ($listFrequency[$key] ?? 0) + 1;
}
arsort($listFrequency);
$defaultStories = $listFrequency === [] ? [] : array_map('intval', explode(',', (string)array_key_first($listFrequency)));
$output->writeln(sprintf(
'Sub-market testimonial lists: %d element(s) on %d market(s); default list (most common, %dx): %s',
count($rows), count($storiesByMarket), $listFrequency === [] ? 0 : reset($listFrequency), implode(',', $defaultStories)
));
$output->writeln('');
$purgePaths = [];
// =====================================================================
// main market pages
// =====================================================================
foreach ($markets as $pid => $page) {
$output->writeln(sprintf('<info>== %s (pid %d) "%s"</info>', $page['slug'], $pid, $page['title']));
$purgePaths[] = (string)$page['slug'];
$elements = $this->db->fetchAllAssociative(
'SELECT uid, CType, colPos, sorting, hidden, header, tx_container_parent,
vitec_learnmore_label, vitec_learnmore_target
FROM tt_content
WHERE pid = ? AND deleted = 0 AND sys_language_uid = 0
ORDER BY colPos, sorting, uid',
[$pid]
);
foreach ($elements as $el) {
$output->writeln(sprintf(
' col %-3d %5d #%-6d %-22s %s%s',
$el['colPos'], $el['sorting'], $el['uid'], $el['CType'],
(int)$el['hidden'] === 1 ? '[hidden] ' : '',
$this->short((string)$el['header'])
));
}
$topLevel = array_values(array_filter($elements, static fn(array $e): bool => (int)$e['tx_container_parent'] === 0));
$live = array_values(array_filter($topLevel, static fn(array $e): bool => (int)$e['hidden'] === 0));
$col0 = array_values(array_filter($live, static fn(array $e): bool => (int)$e['colPos'] === 0));
$col1 = array_values(array_filter($live, static fn(array $e): bool => (int)$e['colPos'] === 1));
$col0All = array_values(array_filter($topLevel, static fn(array $e): bool => (int)$e['colPos'] === 0));
$datamap = ['tt_content' => [], 'sys_file_reference' => []];
// --- B1: intro image + learn-more target --------------------------------
$intro = $this->first($col0, 'vitec_marketintro');
if ($intro === null) {
$this->warn('no live vitec_marketintro in colPos 0 - B1 skipped');
} else {
$introUid = (int)$intro['uid'];
$refs = (int)$this->db->fetchOne(
"SELECT COUNT(*) FROM sys_file_reference WHERE deleted = 0 AND tablenames = 'tt_content' AND fieldname = 'vitec_image' AND uid_foreign = ?",
[$introUid]
);
if ($refs > 0) {
$output->writeln(sprintf(' B1 intro #%d already has %d image(s) - unchanged', $introUid, $refs));
} else {
$identifier = 'placeholders/pages/' . $pid . '-02.png';
if (!$this->storage->hasFile($identifier)) {
$this->warn("$identifier missing - using generic " . self::GENERIC_PLACEHOLDER);
$identifier = self::GENERIC_PLACEHOLDER;
}
if (!$this->storage->hasFile($identifier)) {
$this->warn("$identifier missing as well - no image attached");
} else {
$file = $this->storage->getFile($identifier);
$refId = 'NEWimg' . $pid;
$datamap['sys_file_reference'][$refId] = [
'pid' => $pid,
'table_local' => 'sys_file',
'uid_local' => $file->getUid(),
'tablenames' => 'tt_content',
'fieldname' => 'vitec_image',
'uid_foreign' => $introUid,
];
$datamap['tt_content'][$introUid]['vitec_image'] = $refId;
$output->writeln(sprintf(' B1 intro #%d: attach %s (sys_file %d)', $introUid, $identifier, $file->getUid()));
$this->counts['images']++;
}
}
if (trim((string)$intro['vitec_learnmore_target']) === '') {
$datamap['tt_content'][$introUid]['vitec_learnmore_target'] = self::CONTACT_TARGET;
$output->writeln(sprintf(' B1 intro #%d: learnmore_target "" -> %s', $introUid, self::CONTACT_TARGET));
$this->counts['targets']++;
if (trim((string)$intro['vitec_learnmore_label']) === '') {
$datamap['tt_content'][$introUid]['vitec_learnmore_label'] = self::LEARNMORE_LABEL;
$output->writeln(sprintf(' B1 intro #%d: learnmore_label "" -> %s', $introUid, self::LEARNMORE_LABEL));
$this->counts['labels']++;
}
} else {
$output->writeln(sprintf(' B1 intro #%d: learnmore_target already "%s" - unchanged', $introUid, $intro['vitec_learnmore_target']));
}
}
// --- B5: hide the lead paragraph in the hero slot --------------------------
$leads = array_values(array_filter($col1, static fn(array $e): bool => $e['CType'] === 'vitec_introparagraph'));
if (count($leads) !== 1) {
$this->warn(sprintf('%d live vitec_introparagraph in colPos 1 (expected 1)%s', count($leads), $leads === [] ? ' - B5 skipped' : ' - hiding all of them'));
}
foreach ($leads as $lead) {
$datamap['tt_content'][(int)$lead['uid']]['hidden'] = 1;
$output->writeln(sprintf(' B5 lead #%d "%s": hide', $lead['uid'], $this->short((string)$lead['header'])));
$this->counts['hiddenLeads']++;
}
// --- anchor: last sub-market card after the "Explore" intro paragraph -----
$anchorUid = 0;
$anchorLabel = '';
$exploreIdx = null;
foreach ($col0 as $i => $el) {
if ($el['CType'] === 'vitec_introparagraph') {
$exploreIdx = $i;
break;
}
}
if ($exploreIdx !== null) {
$anchorUid = (int)$col0[$exploreIdx]['uid'];
$anchorLabel = 'intro paragraph #' . $anchorUid;
for ($i = $exploreIdx + 1; $i < count($col0) && $col0[$i]['CType'] === 'vitec_card'; $i++) {
$anchorUid = (int)$col0[$i]['uid'];
$anchorLabel = 'card #' . $anchorUid . ' "' . $this->short((string)$col0[$i]['header']) . '"';
}
} elseif ($intro !== null) {
$anchorUid = (int)$intro['uid'];
$anchorLabel = 'market intro #' . $anchorUid;
}
// the 2026-10-23 page import left HIDDEN shells for both bands - reuse them
$logosShell = $this->first($col0All, 'vitec_customerlogos');
$testiShell = $this->first($col0All, 'vitec_usecaselist');
$hasLogos = $logosShell !== null && (int)$logosShell['hidden'] === 0;
$hasTesti = $testiShell !== null && (int)$testiShell['hidden'] === 0;
$hasCta = $this->first($col0, 'vitec_ctabanner') !== null;
if (!$hasCta) {
$this->warn('no vitec_ctabanner in colPos 0 - the new bands will end the page');
}
// --- B2 (shell): unhide the "Trusted by" shell with the home page settings -----
if ($logosShell !== null && !$hasLogos) {
$logosUid = (int)$logosShell['uid'];
$datamap['tt_content'][$logosUid] = [
'hidden' => 0,
'pi_flexform' => ['data' => ['sDEF' => ['lDEF' => [
'settings.layout' => ['vDEF' => (string)($templateSettings['layout'] ?? 'marquee')],
'settings.customers' => ['vDEF' => (string)($templateSettings['customers'] ?? '')],
'settings.onlySelected' => ['vDEF' => (string)($templateSettings['onlySelected'] ?? '0')],
]]]],
];
$output->writeln(sprintf(' B2 unhide logo shell #%d "%s", settings as #%d', $logosUid, $this->short((string)$logosShell['header']), self::LOGO_TEMPLATE_UID));
$this->counts['logos']++;
}
// --- B3: testimonials -------------------------------------------------------------
// Selection: explicit lists of the market's sub-markets when they have
// any; today all 24 sub-market sliders have an EMPTY selection, which
// the plugin renders as "all stories" - the main pages do the same.
$stories = $storiesByMarket[$pid] ?? [];
$storySource = 'own sub-markets';
if ($stories === []) {
$stories = $defaultStories;
$storySource = 'most common sub-market list';
}
if ($stories === []) {
$storySource = 'empty selection = all stories, like the sub-market pages';
}
if ($hasTesti) {
$output->writeln(' B3 vitec_usecaselist already on the page - unchanged');
} elseif ($testiShell !== null) {
$testiUid = (int)$testiShell['uid'];
$current = $this->flexSettings((string)$this->db->fetchOne('SELECT pi_flexform FROM tt_content WHERE uid = ?', [$testiUid]));
$currentStories = array_values(array_filter(array_map('intval', explode(',', (string)($current['usecases'] ?? '')))));
if ($currentStories !== []) {
$stories = $currentStories;
$storySource = 'already selected on the shell';
}
{
$datamap['tt_content'][$testiUid] = [
'hidden' => 0,
'pi_flexform' => ['data' => ['sDEF' => ['lDEF' => [
'settings.layout' => ['vDEF' => 'testimonials'],
'settings.usecases' => ['vDEF' => implode(',', $stories)],
'settings.singlePid' => ['vDEF' => (string)self::STORY_SINGLE_PID],
'settings.showtoolbar' => ['vDEF' => '0'],
]]]],
];
$output->writeln(sprintf(' B3 unhide shell #%d "%s" as testimonials (%s%s)', $testiUid, $this->short((string)$testiShell['header']), $storySource, $stories === [] ? '' : ': ' . implode(',', $stories)));
$this->counts['testimonials']++;
}
} elseif ($anchorUid === 0) {
$this->warn('no anchor element found - B2/B3 skipped');
} else {
$datamap['tt_content']['NEWtesti' . $pid] = [
'pid' => -$anchorUid,
'colPos' => 0,
'sys_language_uid' => 0,
'CType' => 'vitec_usecaselist',
'header' => self::TESTIMONIALS_HEADER,
'pi_flexform' => [
'data' => ['sDEF' => ['lDEF' => [
'settings.layout' => ['vDEF' => 'testimonials'],
'settings.usecases' => ['vDEF' => implode(',', $stories)],
'settings.singlePid' => ['vDEF' => (string)self::STORY_SINGLE_PID],
'settings.showtoolbar' => ['vDEF' => '0'],
]]],
],
];
$output->writeln(sprintf(' B3 new vitec_usecaselist (testimonials, %s%s) after %s', $storySource, $stories === [] ? '' : ': ' . implode(',', $stories), $anchorLabel));
$this->counts['testimonials']++;
}
// --- write datamap ------------------------------------------------------------
$this->runDataHandler($datamap, []);
// --- B2 (no shell): logo band copied from #758, after the anchor (= before testimonials)
if ($hasLogos) {
$output->writeln(' B2 vitec_customerlogos already visible on the page - unchanged');
} elseif ($logosShell !== null) {
// handled above (shell unhidden in the datamap)
} elseif ($anchorUid > 0) {
$cmdmap = ['tt_content' => [self::LOGO_TEMPLATE_UID => ['copy' => [
'action' => 'paste',
'target' => -$anchorUid,
'update' => ['colPos' => 0],
]]]];
$output->writeln(sprintf(' B2 copy logo band #%d after %s', self::LOGO_TEMPLATE_UID, $anchorLabel));
$this->counts['logos']++;
$this->runDataHandler([], $cmdmap);
}
$output->writeln('');
}
// =====================================================================
// sub-market pages: insights CTA -> news
// =====================================================================
$output->writeln('<info>== sub-market pages: vitec_marketinsights CTA</info>');
$insights = $this->db->fetchAllAssociative(
"SELECT c.uid, c.pid, c.hidden, c.vitec_cta_label, c.vitec_cta_target, p.slug, p.hidden AS page_hidden
FROM tt_content c JOIN pages p ON p.uid = c.pid
WHERE c.CType = 'vitec_marketinsights' AND c.deleted = 0 AND c.sys_language_uid = 0
AND p.deleted = 0 AND p.pid IN ($in)
ORDER BY p.slug"
);
$datamap = ['tt_content' => []];
foreach ($insights as $row) {
$uid = (int)$row['uid'];
$target = trim((string)$row['vitec_cta_target']);
$label = trim((string)$row['vitec_cta_label']);
$changes = [];
if ($target === self::OLD_INSIGHTS_TARGET || $target === '') {
$datamap['tt_content'][$uid]['vitec_cta_target'] = self::NEW_INSIGHTS_TARGET;
$changes[] = sprintf('target "%s" -> %s', $target, self::NEW_INSIGHTS_TARGET);
$this->counts['insightsTargets']++;
if ($label === self::OLD_INSIGHTS_LABEL || $label === '') {
$datamap['tt_content'][$uid]['vitec_cta_label'] = self::NEW_INSIGHTS_LABEL;
$changes[] = sprintf('label "%s" -> %s', $label, self::NEW_INSIGHTS_LABEL);
$this->counts['insightsLabels']++;
} else {
$changes[] = sprintf('label "%s" kept (not the standard text)', $label);
}
$purgePaths[] = (string)$row['slug'];
} elseif ($target === self::NEW_INSIGHTS_TARGET) {
$changes[] = 'already ' . self::NEW_INSIGHTS_TARGET;
} else {
$changes[] = sprintf('target "%s" is individual - unchanged', $target);
}
$output->writeln(sprintf(' #%-6d %-60s %s%s', $uid, $row['slug'], (int)$row['hidden'] === 1 || (int)$row['page_hidden'] === 1 ? '[hidden] ' : '', implode('; ', $changes)));
}
$this->runDataHandler($datamap, []);
$output->writeln('');
// =====================================================================
$output->writeln(sprintf(
'%s: %d intro image(s), %d learnmore target(s) (+%d label), %d logo band(s), %d testimonial slider(s), %d lead paragraph(s) hidden, %d insights target(s) (+%d label); %d warning(s).',
$this->apply ? 'DONE' : 'DRY RUN - would write',
$this->counts['images'], $this->counts['targets'], $this->counts['labels'], $this->counts['logos'], $this->counts['testimonials'],
$this->counts['hiddenLeads'], $this->counts['insightsTargets'], $this->counts['insightsLabels'], $this->counts['warnings']
));
$purgePaths = array_values(array_unique($purgePaths));
$output->writeln('Afterwards: vendor/bin/typo3 cache:flush && vendor/bin/typo3 vitec:ssr-purge ' . implode(' ', $purgePaths) . ' && vendor/bin/typo3 vitec:ssr-warm ' . implode(' ', $purgePaths));
return Command::SUCCESS;
}
/** @param array<string,array<string|int,array<string,mixed>>> $datamap */
private function runDataHandler(array $datamap, array $cmdmap): void
{
$datamap = array_filter($datamap);
if (!$this->apply || ($datamap === [] && $cmdmap === [])) {
return;
}
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start($datamap, $cmdmap);
if ($datamap !== []) {
$dataHandler->process_datamap();
}
if ($cmdmap !== []) {
$dataHandler->process_cmdmap();
}
if ($dataHandler->errorLog !== []) {
$this->warn('DataHandler: ' . implode(' | ', $dataHandler->errorLog));
}
foreach ($dataHandler->substNEWwithIDs as $new => $uid) {
$this->output->writeln(sprintf(' written: %s -> #%d', $new, $uid));
}
foreach ($dataHandler->copyMappingArray_merged['tt_content'] ?? [] as $from => $to) {
$this->output->writeln(sprintf(' copied: #%d -> #%d', $from, $to));
}
}
/** @return array<string,string> flat "settings" sheet of a pi_flexform XML (empty when none) */
private function flexSettings(string $xml): array
{
if (trim($xml) === '') {
return [];
}
$settings = GeneralUtility::makeInstance(FlexFormService::class)->convertFlexFormContentToArray($xml)['settings'] ?? [];
return is_array($settings) ? array_map(static fn($v): string => is_scalar($v) ? (string)$v : '', $settings) : [];
}
/** @param array<int,array<string,mixed>> $elements */
private function first(array $elements, string $cType): ?array
{
foreach ($elements as $el) {
if ($el['CType'] === $cType) {
return $el;
}
}
return null;
}
private function warn(string $message): void
{
$this->counts['warnings']++;
$this->output->writeln(' <comment>WARN ' . $message . '</comment>');
}
private function short(string $text): string
{
$text = trim((string)preg_replace('/\s+/', ' ', strip_tags($text)));
return mb_strlen($text) > 60 ? mb_substr($text, 0, 57) . '...' : $text;
}
}
@@ -0,0 +1,136 @@
<?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\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Scheduler\Domain\Repository\SchedulerTaskRepository;
use TYPO3\CMS\Scheduler\Execution;
use TYPO3\CMS\Scheduler\Service\TaskService;
/**
* Puts the project's recurring jobs into the TYPO3 Scheduler (group
* "VITEC"), so they are visible, startable and switchable in
* System › Scheduler instead of hiding in the crontab. The only cron
* line left on dedi5092 is the core "scheduler:run" every minute.
*
* Idempotent: a task whose command already exists in the scheduler is
* left alone. --disabled creates new tasks switched off (use while the
* old crontab lines still run, so nothing runs twice), --enable switches
* the VITEC tasks on afterwards.
*/
#[AsCommand(
name: 'vitec:scheduler-setup',
description: 'Create the VITEC scheduler tasks (SSR health every 5 min, client error cleanup daily) in the group "VITEC"; safe to run again'
)]
final class SchedulerSetupCommand extends Command
{
private const GROUP = 'VITEC';
/** command => [cron expression, description] */
private const TASKS = [
'vitec:ssr-health' => [
'*/5 * * * *',
'SSR health check: samples /__health and /__stats of the SSR node into the health log (cockpit charts), '
. 'mails the alert recipients from Site settings › VITEC on down/up changes and falls back to the SPA '
. 'after repeated failures. Formerly crontab.',
],
'vitec:client-error-cleanup' => [
'23 4 * * *',
'Deletes browser error reports (client errors from the React frontend) older than 30 days. Formerly crontab.',
],
];
public function __construct(
private readonly TaskService $taskService,
private readonly SchedulerTaskRepository $taskRepository,
private readonly ConnectionPool $connectionPool,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addOption('disabled', null, InputOption::VALUE_NONE, 'Create new tasks switched off');
$this->addOption('enable', null, InputOption::VALUE_NONE, 'Switch on all VITEC tasks created by this command');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
Bootstrap::initializeBackendAuthentication();
$groupUid = $this->groupUid();
$output->writeln('Scheduler group "' . self::GROUP . '": uid ' . $groupUid);
foreach (self::TASKS as $command => [$cron, $description]) {
$existing = $this->findTask($command);
if ($existing !== null) {
if ($input->getOption('enable') && (int)$existing['disable'] === 1) {
$task = $this->taskRepository->findByUid((int)$existing['uid']);
$task->setDisabled(false);
$this->taskRepository->update($task);
$output->writeln(sprintf('<info>%s: enabled (uid %d)</info>', $command, $existing['uid']));
} else {
$output->writeln(sprintf('%s: exists (uid %d, %s)', $command, $existing['uid'],
(int)$existing['disable'] === 1 ? 'disabled' : 'enabled'));
}
continue;
}
if (!$this->taskService->isTaskTypeRegistered($command)) {
$output->writeln('<error>' . $command . ': not available as scheduler task type</error>');
continue;
}
$task = $this->taskService->createNewTask($command);
$task->setDescription($description);
$task->setTaskGroup($groupUid);
$task->setExecution(Execution::createRecurringExecution(time() - 60, 0, 0, false, $cron));
$task->setDisabled((bool)$input->getOption('disabled'));
if ($this->taskRepository->add($task)) {
$output->writeln(sprintf('<info>%s: created (uid %d, %s, %s)</info>', $command, $task->getTaskUid(), $cron,
$task->isDisabled() ? 'disabled' : 'enabled'));
} else {
$output->writeln('<error>' . $command . ': could not be created</error>');
}
}
return Command::SUCCESS;
}
private function groupUid(): int
{
$connection = $this->connectionPool->getConnectionForTable('tx_scheduler_task_group');
$uid = $connection->executeQuery(
'SELECT uid FROM tx_scheduler_task_group WHERE groupName = ? AND deleted = 0 ORDER BY uid LIMIT 1',
[self::GROUP]
)->fetchOne();
if ($uid !== false) {
return (int)$uid;
}
$sorting = (int)$connection->executeQuery('SELECT MAX(sorting) FROM tx_scheduler_task_group')->fetchOne();
$connection->insert('tx_scheduler_task_group', [
'pid' => 0,
'tstamp' => time(),
'crdate' => time(),
'groupName' => self::GROUP,
'color' => '#f47937',
'description' => 'Recurring jobs of the VITEC website (SSR monitoring, cleanups)',
'sorting' => $sorting + 256,
], ['pid' => Connection::PARAM_INT, 'tstamp' => Connection::PARAM_INT, 'crdate' => Connection::PARAM_INT, 'sorting' => Connection::PARAM_INT]);
return (int)$connection->lastInsertId();
}
private function findTask(string $command): ?array
{
$row = $this->connectionPool->getConnectionForTable('tx_scheduler_task')->executeQuery(
'SELECT uid, disable FROM tx_scheduler_task WHERE tasktype = ? AND deleted = 0 ORDER BY uid LIMIT 1',
[$command]
)->fetchAssociative();
return $row === false ? null : $row;
}
}
@@ -0,0 +1,416 @@
<?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\Resource\StorageRepository;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Seeds the "Why choose <product>?" cards (tx_vitec_product_feature,
* contract 0036-FE) from the `applications` RTE HTML plus the staged agency
* icons:
*
* - `applications` pairs `<p><strong>Title</strong></p><p>Text</p>` become
* one feature record each (several text paragraphs are joined); the
* leading <h3> stays where it is - the JSON heading is derived from it.
* - icons come from fileadmin/icons/delivered/products/<context>/ (the
* 2026-11-03 staging); <context> is the product slug unless ICON_DIRS
* maps it. Matching: normalised title vs. the filename theme (the part
* after the last "-"), stop words removed, Levenshtein <= 3 as typo
* tolerance (the delivery contains e.g. "EncodeDecondeOnOneCard").
* - a product that already HAS feature records is never touched - after
* the seed the editors own the cards. EXCEPT with --attach-icons
* (2026-11-10, later icon deliveries such as PRISM/SFF PRISM/Avedia
* chassis): then its cards WITHOUT an icon get the matching staged icon
* (same matcher); titles, texts and cards with an icon stay untouched.
*
* Unmatched icons and unmatched cards are reported so the editors can map
* the leftovers by hand (0036-FE asks for exactly that list).
*
* vendor/bin/typo3 vitec:seed-product-features
* vendor/bin/typo3 vitec:seed-product-features --only=activesqx2 --apply
* vendor/bin/typo3 vitec:seed-product-features --only=prism --attach-icons --apply
*/
#[AsCommand(
name: 'vitec:seed-product-features',
description: 'Seed the Why-choose feature cards from the applications HTML + staged icons (dry-run by default)'
)]
final class SeedProductFeaturesCommand extends Command
{
private const TABLE = 'tx_vitec_product_feature';
private const ICON_BASE = 'icons/delivered/products/';
/** product slug => staging context where the names differ */
private const ICON_DIRS = [
'avedia-platform-iptv-signage-cmsserver' => 'avedia-platform',
'ez-tv-platform-iptv-signage-cmsserver' => 'ez-tv-platform',
'x-player-end-points-xp23-xp25-xp26' => 'x-player-end-points-product',
];
private const STOPWORDS = ['and', 'or', 'the', 'a', 'an', 'of', 'on', 'in', 'to', 'with', 'for', 'your', 'at', 'is'];
private int $newCounter = 0;
protected function configure(): void
{
$this->addOption('only', null, InputOption::VALUE_REQUIRED, 'Only product slugs containing this substring');
$this->addOption('attach-icons', null, InputOption::VALUE_NONE, 'Products with existing cards: attach staged icons to cards without one');
$this->addOption('apply', null, InputOption::VALUE_NONE, 'Write changes (default: dry run)');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
Bootstrap::initializeBackendAuthentication();
$apply = (bool)$input->getOption('apply');
$attachIcons = (bool)$input->getOption('attach-icons');
$only = mb_strtolower((string)($input->getOption('only') ?? ''));
$pool = GeneralUtility::makeInstance(ConnectionPool::class);
$products = $pool->getConnectionForTable('tx_vitec_domain_model_product')->fetchAllAssociative(
'SELECT uid, pid, title, slug, applications FROM tx_vitec_domain_model_product WHERE deleted = 0 ORDER BY LOWER(title)'
);
$output->writeln(($apply ? 'APPLY' : 'DRY-RUN') . ' · ' . count($products) . ' product(s)');
$storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage();
$seeded = 0;
$cards = 0;
$iconsMatched = 0;
$skippedExisting = 0;
$skippedEmpty = 0;
$attached = 0;
foreach ($products as $product) {
$uid = (int)$product['uid'];
$slug = (string)$product['slug'];
if ($only !== '' && !str_contains(mb_strtolower($slug), $only)) {
continue;
}
$existing = (int)$pool->getConnectionForTable(self::TABLE)->fetchOne(
'SELECT COUNT(*) FROM ' . self::TABLE . ' WHERE deleted = 0 AND product = ?',
[$uid]
);
if ($existing > 0 && !$attachIcons) {
$skippedExisting++;
continue;
}
// staged icons of this product, filename theme => identifier
$context = self::ICON_DIRS[$slug] ?? $slug;
$iconByTheme = [];
$folder = self::ICON_BASE . $context;
if ($storage->hasFolder($folder)) {
foreach ($storage->getFilesInFolder($storage->getFolder($folder)) as $file) {
if (mb_strtolower($file->getExtension()) !== 'svg') {
continue;
}
if (preg_match('/-([A-Za-z0-9]+)\.svg$/', $file->getName(), $m)) {
$iconByTheme[$m[1]] = $file->getIdentifier();
}
}
}
if ($existing > 0) {
// --attach-icons: existing cards without an icon get a staged one
if ($iconByTheme === []) {
$skippedExisting++;
continue;
}
$bare = $pool->getConnectionForTable(self::TABLE)->fetchAllAssociative(
'SELECT f.uid, f.title FROM ' . self::TABLE . ' f WHERE f.deleted = 0 AND f.product = ?
AND NOT EXISTS (SELECT 1 FROM sys_file_reference r WHERE r.deleted = 0 AND r.tablenames = ?
AND r.fieldname = ? AND r.uid_foreign = f.uid)
ORDER BY f.sorting',
[$uid, self::TABLE, 'icon']
);
if ($bare === []) {
$skippedExisting++;
continue;
}
$pairs = array_map(static fn(array $r): array => ['uid' => (int)$r['uid'], 'title' => (string)$r['title'], 'text' => '-'], $bare);
$output->writeln(sprintf("\n<info>%s</info> (uid %d) - ATTACH: %d existing card(s) without icon, %d staged icon(s)", $slug, $uid, count($pairs), count($iconByTheme)));
$usedThemes = $this->matchIcons($pairs, $iconByTheme);
foreach ($pairs as $pair) {
$output->writeln(' "' . $pair['title'] . '"' . (isset($pair['icon']) ? ' + icon ' . basename((string)$pair['icon']) : ' <comment>- no icon</comment>'));
}
foreach (array_diff_key($iconByTheme, $usedThemes) as $identifier) {
$output->writeln(' <comment>UNMATCHED icon: ' . basename($identifier) . '</comment>');
}
$iconsMatched += count($usedThemes);
$attached += count($usedThemes);
if (!$apply || $usedThemes === []) {
continue;
}
$datamap = [];
foreach ($pairs as $pair) {
if (!isset($pair['icon'])) {
continue;
}
$refId = $this->newId();
$datamap['sys_file_reference'][$refId] = [
'pid' => (int)$product['pid'],
'table_local' => 'sys_file',
'uid_local' => $storage->getFile((string)$pair['icon'])->getUid(),
];
$datamap[self::TABLE][(string)$pair['uid']] = ['icon' => $refId];
}
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start($datamap, []);
$dataHandler->process_datamap();
if ($dataHandler->errorLog !== []) {
$output->writeln(' <error>DataHandler: ' . implode(' | ', $dataHandler->errorLog) . '</error>');
return Command::FAILURE;
}
continue;
}
$pairs = $this->parseApplications((string)$product['applications']);
if ($pairs === []) {
$skippedEmpty++;
continue;
}
$output->writeln(sprintf("\n<info>%s</info> (uid %d) - %d card(s), %d staged icon(s)", $slug, $uid, count($pairs), count($iconByTheme)));
$usedThemes = $this->matchIcons($pairs, $iconByTheme);
foreach ($pairs as $pair) {
$output->writeln(sprintf(
' "%s"%s%s',
$pair['title'],
$pair['text'] !== '' ? '' : ' <comment>(no text)</comment>',
isset($pair['icon']) ? ' + icon ' . basename((string)$pair['icon']) : ' <comment>- no icon</comment>'
));
}
foreach (array_diff_key($iconByTheme, $usedThemes) as $theme => $identifier) {
$output->writeln(' <comment>UNMATCHED icon: ' . basename($identifier) . '</comment>');
}
$cards += count($pairs);
$iconsMatched += count($usedThemes);
$seeded++;
if (!$apply) {
continue;
}
$datamap = [];
$featureIds = [];
foreach ($pairs as $pair) {
$featureId = $this->newId();
$record = [
'pid' => (int)$product['pid'],
'product' => $uid,
'title' => $pair['title'],
'cardtext' => $pair['text'],
];
if (isset($pair['icon'])) {
$refId = $this->newId();
$datamap['sys_file_reference'][$refId] = [
'pid' => (int)$product['pid'],
'table_local' => 'sys_file',
'uid_local' => $storage->getFile((string)$pair['icon'])->getUid(),
];
$record['icon'] = $refId;
}
$datamap[self::TABLE][$featureId] = $record;
$featureIds[] = $featureId;
}
$datamap['tx_vitec_domain_model_product'][$uid] = ['features' => implode(',', $featureIds)];
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start($datamap, []);
$dataHandler->process_datamap();
if ($dataHandler->errorLog !== []) {
$output->writeln(' <error>DataHandler: ' . implode(' | ', $dataHandler->errorLog) . '</error>');
return Command::FAILURE;
}
// DataHandler puts NEW records at the TOP - write explicit sorting
$sorting = 0;
foreach ($featureIds as $featureId) {
$realUid = (int)($dataHandler->substNEWwithIDs[$featureId] ?? 0);
if ($realUid > 0) {
$sorting += 256;
$pool->getConnectionForTable(self::TABLE)->update(self::TABLE, ['sorting' => $sorting], ['uid' => $realUid]);
}
}
}
$output->writeln(sprintf(
"\n<info>%d product(s) seeded (%d cards, %d icons matched%s), %d with existing cards untouched, %d without applications pairs%s</info>",
$seeded, $cards, $iconsMatched, $attachIcons ? ', ' . $attached . ' attached to existing cards' : '', $skippedExisting, $skippedEmpty,
$apply ? '' : ' (dry run - nothing written)'
));
if ($apply && ($seeded > 0 || $attached > 0)) {
$output->writeln('<info>Flush the cache: vendor/bin/typo3 cache:flush</info>');
}
return Command::SUCCESS;
}
/**
* The `<p><strong>Title</strong></p><p>Text…</p>` pairs of the
* applications HTML, in order. Several text paragraphs after one title
* are joined; the <h3> heading and empty paragraphs are skipped.
*
* @return array<int,array{title:string,text:string,icon?:string}>
*/
private function parseApplications(string $html): array
{
if (trim($html) === '') {
return [];
}
$pairs = [];
$current = null;
if (!preg_match_all('#<p[^>]*>(.*?)</p>#is', $html, $matches)) {
return [];
}
foreach ($matches[1] as $inner) {
$plain = trim((string)preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags($inner), ENT_QUOTES | ENT_HTML5, 'UTF-8')));
if ($plain === '') {
continue;
}
$strongOnly = preg_match('#^\s*<strong[^>]*>(.*?)</strong>\s*$#is', trim($inner), $m)
? trim((string)preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags($m[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8')))
: null;
if ($strongOnly !== null && $strongOnly !== '') {
if ($current !== null) {
$pairs[] = $current;
}
$current = ['title' => $strongOnly, 'text' => ''];
continue;
}
if ($current !== null) {
$current['text'] = trim($current['text'] . ' ' . $plain);
}
}
if ($current !== null) {
$pairs[] = $current;
}
return $pairs;
}
/**
* Matches the staged icons onto the cards (three passes: normalised title
* / Levenshtein, token containment, last pair standing). Sets 'icon' on
* the matched pairs and returns the used themes.
*
* @param array<int,array{title:string,text:string,icon?:string,uid?:int}> $pairs
* @param array<string,string> $iconByTheme theme => identifier
* @return array<string,bool>
*/
private function matchIcons(array &$pairs, array $iconByTheme): array
{
$usedThemes = [];
foreach ($pairs as $i => $pair) {
$theme = $this->matchTheme($pair['title'], array_diff_key($iconByTheme, $usedThemes));
if ($theme !== null) {
$pairs[$i]['icon'] = $iconByTheme[$theme];
$usedThemes[$theme] = true;
}
}
// second pass: token containment, e.g. theme "ManagementFoundation"
// inside the card title "Aetria management foundation"
foreach ($pairs as $i => $pair) {
if (isset($pair['icon'])) {
continue;
}
foreach (array_keys(array_diff_key($iconByTheme, $usedThemes)) as $theme) {
$titleTokens = $this->tokens($pair['title']);
$themeTokens = $this->tokens($theme);
if ($titleTokens !== [] && $themeTokens !== []
&& (array_diff($themeTokens, $titleTokens) === [] || array_diff($titleTokens, $themeTokens) === [])
) {
$pairs[$i]['icon'] = $iconByTheme[$theme];
$usedThemes[$theme] = true;
break;
}
}
}
// last pair standing: a complete set (#icons == #cards) with exactly
// one icon and one card left over - they belong to each other
if (count($iconByTheme) === count($pairs)) {
$freeThemes = array_keys(array_diff_key($iconByTheme, $usedThemes));
$freePairs = array_keys(array_filter($pairs, static fn(array $p): bool => !isset($p['icon'])));
if (count($freeThemes) === 1 && count($freePairs) === 1) {
$pairs[$freePairs[0]]['icon'] = $iconByTheme[$freeThemes[0]];
$usedThemes[$freeThemes[0]] = true;
}
}
return $usedThemes;
}
/**
* The icon theme that matches a card title: exact on the normalised
* strings (stop words removed on both sides), else the closest theme
* within Levenshtein distance 3 (typo tolerance).
*
* @param array<string,string> $iconByTheme theme => identifier
*/
private function matchTheme(string $title, array $iconByTheme): ?string
{
if ($iconByTheme === []) {
return null;
}
$titleNorms = array_unique([$this->norm($title, false), $this->norm($title, true)]);
$best = null;
$bestDistance = PHP_INT_MAX;
foreach (array_keys($iconByTheme) as $theme) {
$themeNorms = array_unique([$this->norm($theme, false), $this->norm($theme, true)]);
foreach ($titleNorms as $a) {
foreach ($themeNorms as $b) {
if ($a === $b) {
return $theme;
}
if (abs(strlen($a) - strlen($b)) <= 3) {
$distance = levenshtein($a, $b);
if ($distance < $bestDistance) {
$bestDistance = $distance;
$best = $theme;
}
}
}
}
}
return $bestDistance <= 3 ? $best : null;
}
/**
* Lowercase word list (camelCase split, stop words removed) for the
* token-containment pass.
*
* @return string[]
*/
private function tokens(string $value): array
{
$words = preg_split('/(?=[A-Z][a-z])|[^A-Za-z0-9]+/', $value, -1, PREG_SPLIT_NO_EMPTY) ?: [];
$words = array_map(static fn(string $w): string => mb_strtolower($w), $words);
return array_values(array_filter($words, static fn(string $w): bool => !in_array($w, self::STOPWORDS, true)));
}
/** lowercase, letters/digits only; optionally with stop words removed (camelCase themes are split first) */
private function norm(string $value, bool $dropStopwords): string
{
$words = preg_split('/(?=[A-Z][a-z])|[^A-Za-z0-9]+/', $value, -1, PREG_SPLIT_NO_EMPTY) ?: [];
if ($dropStopwords) {
$words = array_filter($words, static fn(string $w): bool => !in_array(mb_strtolower($w), self::STOPWORDS, true));
}
return mb_strtolower(implode('', $words));
}
private function newId(): string
{
return 'NEWPF' . str_pad((string)(++$this->newCounter), 4, '0', STR_PAD_LEFT) . substr(md5(uniqid('', true)), 0, 6);
}
}
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Command;
use Evomedien\Vitec\Service\SsrFlagService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Shows (and on --sync repairs) the SSR routing state: the Site Set
* switch "vitec.ssr.enabled" versus the flag file that .htaccess
* evaluates. The SyncSsrFlag middleware does the same sync on every
* request, so this command is mainly for diagnostics and scripting.
*
* vendor/bin/typo3 vitec:ssr-flag # status only
* vendor/bin/typo3 vitec:ssr-flag --sync # align flag with setting
*/
#[AsCommand(name: 'vitec:ssr-flag', description: 'Show/sync the SSR routing flag against the vitec.ssr.enabled site setting')]
final class SsrFlagCommand extends Command
{
public function __construct(private readonly SsrFlagService $ssrFlagService)
{
parent::__construct();
}
protected function configure(): void
{
$this->addOption('sync', null, InputOption::VALUE_NONE, 'Create/remove the flag file so it matches the setting');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$setting = $this->ssrFlagService->isEnabledInSettings();
$flag = $this->ssrFlagService->isFlagSet();
$output->writeln('Setting vitec.ssr.enabled: ' . ($setting ? 'ON' : 'off'));
$output->writeln('Flag ' . $this->ssrFlagService->getFlagPath() . ': ' . ($flag ? 'EXISTS (SSR routing active)' : 'absent (SPA as before)'));
if ($setting === $flag) {
$output->writeln('<info>In sync.</info>');
return Command::SUCCESS;
}
if (!$input->getOption('sync')) {
$output->writeln('<comment>OUT OF SYNC - run with --sync (or just hit any page: the middleware repairs it).</comment>');
return Command::FAILURE;
}
$new = $this->ssrFlagService->sync();
$output->writeln('<info>Synced: flag is now ' . ($new ? 'SET - SSR routing active.' : 'removed - SPA as before.') . '</info>');
return Command::SUCCESS;
}
}
@@ -0,0 +1,283 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Command;
use Evomedien\Vitec\Service\SsrFlagService;
use Evomedien\Vitec\Service\SsrPurgeService;
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\Database\ConnectionPool;
use TYPO3\CMS\Core\Http\RequestFactory;
use TYPO3\CMS\Core\Mail\Mailer;
use TYPO3\CMS\Core\Mail\MailMessage;
use TYPO3\CMS\Core\Utility\GeneralUtility;
#[AsCommand(
name: 'vitec:ssr-health',
description: 'Sample the SSR node /__health into tx_vitec_ssrhealthlog; mails on ok/down transitions, auto-failover to SPA after N consecutive misses',
)]
final class SsrHealthSampleCommand extends Command
{
private const KEEP_DAYS = 14;
public function __construct(
private readonly SsrPurgeService $purgeService,
private readonly SsrFlagService $flagService,
private readonly RequestFactory $requestFactory,
private readonly ConnectionPool $connectionPool,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addOption(
'test-mail',
null,
InputOption::VALUE_OPTIONAL,
'Send a sample alert mail through the real alert path and exit (no health sample). Optional address; default: the configured alert recipients',
false
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$testMail = $input->getOption('test-mail');
if ($testMail !== false) {
return $this->sendTestMail((string)$testMail, $output);
}
$endpoint = $this->purgeService->getHealthEndpoint();
if ($endpoint === '') {
$output->writeln('<error>SSR_HEALTH_ENDPOINT missing in var/vitec-ssr-purge.env</error>');
return Command::INVALID;
}
$row = ['crdate' => time(), 'ok' => 0, 'release_id' => '', 'cached_pages' => 0, 'cache_mb' => 0, 'response_ms' => 0];
$start = microtime(true);
try {
$response = $this->requestFactory->request($endpoint, 'GET', [
'timeout' => 4, 'connect_timeout' => 2, 'http_errors' => false,
]);
$row['response_ms'] = (int)round((microtime(true) - $start) * 1000);
if ($response->getStatusCode() === 200) {
$data = json_decode((string)$response->getBody(), true);
$row['ok'] = !empty($data['ok']) ? 1 : 0;
$row['release_id'] = substr((string)($data['release']['release'] ?? ''), 0, 100);
$row['cached_pages'] = (int)($data['cachedPages'] ?? 0);
$row['cache_mb'] = (int)($data['cacheMB'] ?? 0);
}
} catch (\Throwable $e) {
$row['response_ms'] = (int)round((microtime(true) - $start) * 1000);
}
// Cumulative node counters from /__stats (-1 = no data this sample,
// e.g. node down or release without the endpoint). The errors tile
// computes 24h deltas from these, skipping -1 rows; the counters
// reset whenever the node restarts, which the delta logic clamps.
$stats = [
'stat_hit' => -1, 'stat_miss' => -1, 'stat_stale' => -1,
'stat_bypass' => -1, 'stat_render_errors' => -1, 'stat_backend_errors' => -1,
];
if ($row['ok'] === 1 && ($statsEndpoint = $this->purgeService->getStatsEndpoint()) !== '') {
try {
$statsResponse = $this->requestFactory->request($statsEndpoint, 'GET', [
'timeout' => 4, 'connect_timeout' => 2, 'http_errors' => false,
]);
if ($statsResponse->getStatusCode() === 200) {
$s = json_decode((string)$statsResponse->getBody(), true) ?: [];
$stats = [
'stat_hit' => (int)($s['hit'] ?? -1),
'stat_miss' => (int)($s['miss'] ?? -1),
'stat_stale' => (int)($s['stale'] ?? -1),
'stat_bypass' => (int)($s['bypass'] ?? -1),
'stat_render_errors' => (int)($s['renderErrors'] ?? -1),
'stat_backend_errors' => (int)($s['backendErrors'] ?? -1),
];
}
} catch (\Throwable) {
// Stats are optional - the health sample must never fail on them.
}
}
$row = array_merge($row, $stats);
// Previous samples, newest first, read BEFORE this one is inserted:
// [0] is the state every transition decision compares against.
$failoverAfter = $this->purgeService->getFailoverAfter();
$previous = array_map('intval', $this->connectionPool
->getQueryBuilderForTable('tx_vitec_ssrhealthlog')
->select('ok')->from('tx_vitec_ssrhealthlog')
->orderBy('crdate', 'DESC')->addOrderBy('uid', 'DESC')
->setMaxResults($failoverAfter)
->executeQuery()->fetchFirstColumn());
$conn = $this->connectionPool->getConnectionForTable('tx_vitec_ssrhealthlog');
$conn->insert('tx_vitec_ssrhealthlog', $row);
$conn->executeStatement(
'DELETE FROM tx_vitec_ssrhealthlog WHERE crdate < ?',
[time() - self::KEEP_DAYS * 86400]
);
$output->writeln(sprintf(
'%s release=%s pages=%d cache=%dMB %dms',
$row['ok'] ? '<info>ok</info>' : '<error>DOWN</error>',
$row['release_id'] ?: '-',
$row['cached_pages'],
$row['cache_mb'],
$row['response_ms']
));
if ($row['stat_hit'] >= 0) {
$output->writeln(sprintf(
'stats: hit=%d miss=%d stale=%d bypass=%d renderErrors=%d backendErrors=%d',
$row['stat_hit'],
$row['stat_miss'],
$row['stat_stale'],
$row['stat_bypass'],
$row['stat_render_errors'],
$row['stat_backend_errors']
));
}
$prevOk = $previous === [] ? null : $previous[0];
if ($prevOk !== null && $prevOk !== $row['ok']) {
if ($row['ok'] === 1) {
$this->sendAlert(
'[VITEC SSR] Recovered: SSR server reachable again',
"The 5-minute health check reaches the SSR server again (status ok).\n\n"
. 'Time: ' . date('d.m.Y H:i:s') . "\n"
. 'Release: ' . ($row['release_id'] ?: '-') . "\n"
. 'Response time: ' . $row['response_ms'] . " ms\n\n"
. 'Current state: switch vitec.ssr.enabled = '
. ($this->flagService->isEnabledInSettings() ? 'ON' : 'OFF')
. ', flag file ' . ($this->flagService->isFlagSet() ? 'present (SSR active)' : 'missing (SPA delivery)') . ".\n"
. "If the auto-failover was triggered, the site deliberately stays on SPA.\n"
. "Re-enable: TYPO3 backend > Site Management > Settings > SSR delivery ON,\n"
. "then a full purge (vendor/bin/typo3 vitec:ssr-purge --all).\n",
$output
);
} else {
$this->sendAlert(
'[VITEC SSR] ALERT: SSR server not responding',
"The 5-minute health check could not reach the SSR server\n"
. "(or /__health reported an error).\n\n"
. 'Time: ' . date('d.m.Y H:i:s') . "\n"
. 'Endpoint: ' . $endpoint . "\n"
. 'Response time: ' . $row['response_ms'] . " ms\n\n"
. 'As long as the flag file exists, dev.vitec.com keeps routing to the SSR server. After '
. $failoverAfter . ' failed checks in a row (about ' . ($failoverAfter * 5) . " minutes) the\n"
. "auto-failover switches to SPA delivery automatically - the site stays reachable.\n\n"
. "This mail is sent only on the ok -> down transition, not for every check.\n",
$output
);
}
}
if ($row['ok'] === 0 && $this->flagService->isFlagSet()) {
$consecutive = 1;
foreach ($previous as $ok) {
if ($ok !== 0) {
break;
}
$consecutive++;
}
if ($consecutive >= $failoverAfter) {
try {
$this->flagService->disableSsr();
$output->writeln(sprintf(
'<error>AUTO-FAILOVER after %d consecutive misses: vitec.ssr.enabled=false, caches flushed, flag removed</error>',
$consecutive
));
$this->sendAlert(
'[VITEC SSR] AUTO-FAILOVER: site switched to SPA',
'AUTO-FAILOVER TRIGGERED: ' . $consecutive . " consecutive DOWN checks.\n\n"
. "dev.vitec.com now serves the SPA (rendering in the browser).\n"
. "The site stays available for visitors.\n\n"
. "What happened automatically:\n"
. "- site setting vitec.ssr.enabled set to false (settings.yaml)\n"
. "- caches flushed\n"
. "- flag file var/vitec-ssr-on.flag removed\n\n"
. "Re-enable (deliberately MANUAL only) once the SSR server is back:\n"
. "1. Check on the VPS: systemctl status vitec-ssr and /__health\n"
. "2. TYPO3 backend > Site Management > Settings > SSR delivery back ON\n"
. "3. Full purge: vendor/bin/typo3 vitec:ssr-purge --all\n",
$output
);
} catch (\Throwable $e) {
$output->writeln('<error>auto-failover FAILED: ' . $e->getMessage() . '</error>');
$this->sendAlert(
'[VITEC SSR] ERROR: auto-failover FAILED',
"The auto-failover should have triggered but failed:\n"
. $e->getMessage() . "\n\n"
. "Please check manually: site setting vitec.ssr.enabled, file var/vitec-ssr-on.flag.\n"
. "Emergency off without TYPO3: delete the flag file AND set the setting to false\n"
. "(otherwise the SyncSsrFlag middleware recreates the flag file immediately).\n",
$output
);
}
}
}
return Command::SUCCESS;
}
/**
* Deliverability check (e.g. mail-tester.com): same sender, mailer and
* plain-text format as a real alert, clearly marked as a test.
*/
private function sendTestMail(string $address, OutputInterface $output): int
{
$override = [];
if ($address !== '') {
if (!GeneralUtility::validEmail($address)) {
$output->writeln('<error>invalid address: ' . $address . '</error>');
return Command::INVALID;
}
$override = [$address];
}
$this->sendAlert(
'[VITEC SSR] TEST: alert mail check',
"This is a test of the VITEC SSR alert mail - nothing is wrong.\n\n"
. "Real alerts look like this one and are sent when the 5-minute health\n"
. "check sees the SSR server go down or come back, and when the\n"
. "auto-failover switches the site to SPA delivery.\n\n"
. 'Time: ' . date('d.m.Y H:i:s') . "\n"
. 'Current state: switch vitec.ssr.enabled = '
. ($this->flagService->isEnabledInSettings() ? 'ON' : 'OFF')
. ', flag file ' . ($this->flagService->isFlagSet() ? 'present (SSR active)' : 'missing (SPA delivery)') . ".\n\n"
. "Recipients are maintained in the TYPO3 backend:\n"
. "Site Management > Settings > VITEC > SSR alert mail recipients.\n",
$output,
$override
);
return Command::SUCCESS;
}
/**
* Mails are best effort: a mail problem must never break the sampler
* (the cron would stop logging history). Recipients come from the site
* setting vitec.ssr.alertEmails (Site Management › Settings › VITEC),
* fallback SSR_ALERT_EMAIL in var/vitec-ssr-purge.env - none at all
* means "alerts off", which the output states so the cron log shows why.
*/
private function sendAlert(string $subject, string $body, OutputInterface $output, array $override = []): void
{
$alert = $this->purgeService->getAlertRecipients();
if ($alert['invalid'] !== [] && $override === []) {
$output->writeln('<comment>skipped invalid alert addresses: ' . implode(', ', $alert['invalid']) . '</comment>');
}
$recipients = $override !== [] ? $override : $alert['recipients'];
if ($recipients === []) {
$output->writeln('<comment>transition detected but no alert recipients are configured (Site settings › VITEC or SSR_ALERT_EMAIL) - no mail sent</comment>');
return;
}
try {
$mail = GeneralUtility::makeInstance(MailMessage::class);
$mail->to(...$recipients)->subject($subject)->text($body);
GeneralUtility::makeInstance(Mailer::class)->send($mail);
$output->writeln('alert mail sent: ' . $subject . ' -> ' . implode(', ', $recipients));
} catch (\Throwable $e) {
$output->writeln('<error>alert mail failed: ' . $e->getMessage() . '</error>');
}
}
}
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Command;
use Evomedien\Vitec\Service\SsrPurgeService;
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;
#[AsCommand(
name: 'vitec:ssr-purge',
description: 'Purge paths (or everything with --all) from the SSR node page cache',
)]
final class SsrPurgeCommand extends Command
{
public function __construct(private readonly SsrPurgeService $purgeService)
{
parent::__construct();
}
protected function configure(): void
{
$this->addArgument('paths', InputArgument::IS_ARRAY, 'Paths to purge, e.g. /products/aligo');
$this->addOption('all', null, InputOption::VALUE_NONE, 'Purge the whole cache ("*")');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$paths = $input->getOption('all') ? ['*'] : (array)$input->getArgument('paths');
if ($paths === []) {
$output->writeln('<error>No paths given (use arguments or --all).</error>');
return Command::INVALID;
}
$result = $this->purgeService->purge($paths, 'cli');
$output->writeln(sprintf(
'%s HTTP %d - %s (paths: %s)',
$result['success'] ? '<info>OK</info>' : '<error>FAILED</error>',
$result['httpCode'],
$result['message'],
implode(', ', $result['paths'])
));
return $result['success'] ? Command::SUCCESS : Command::FAILURE;
}
}
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Command;
use Evomedien\Vitec\Service\SsrPurgeTargetResolver;
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\Utility\GeneralUtility;
/**
* Read-only: shows which SSR paths the auto-purge would clear when the
* given record is saved (SsrPurgeTargetResolver). Purges nothing.
*
* vitec:ssr-purge-targets tx_vitec_domain_model_product 12
* vitec:ssr-purge-targets tx_news_domain_model_news 300 --fields=title,bodytext
*/
#[AsCommand(
name: 'vitec:ssr-purge-targets',
description: 'Show which SSR paths the auto-purge would clear for a saved record (read-only, purges nothing)'
)]
final class SsrPurgeTargetsCommand extends Command
{
protected function configure(): void
{
$this->addArgument('table', InputArgument::REQUIRED, 'Table, e.g. tx_vitec_domain_model_product');
$this->addArgument('uid', InputArgument::REQUIRED, 'Record uid');
$this->addOption('fields', null, InputOption::VALUE_REQUIRED, 'Changed fields, comma-separated', 'title');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$fields = GeneralUtility::trimExplode(',', (string)$input->getOption('fields'), true);
$paths = GeneralUtility::makeInstance(SsrPurgeTargetResolver::class)
->resolve((string)$input->getArgument('table'), (int)$input->getArgument('uid'), $fields);
if ($paths === []) {
$output->writeln('nothing to purge (record not rendered anywhere)');
} elseif ($paths === [SsrPurgeTargetResolver::ALL]) {
$output->writeln('<comment>* (everything)</comment>');
} else {
$output->writeln(count($paths) . ' path(s):');
foreach ($paths as $path) {
$output->writeln(' ' . $path);
}
}
return Command::SUCCESS;
}
}
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Command;
use Evomedien\Vitec\Service\SsrPurgeService;
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\Output\OutputInterface;
#[AsCommand(
name: 'vitec:ssr-warm',
description: 'Start a background warm run on the SSR node (all sitemap pages, or the given paths)',
)]
final class SsrWarmCommand extends Command
{
public function __construct(private readonly SsrPurgeService $purgeService)
{
parent::__construct();
}
protected function configure(): void
{
$this->addArgument('paths', InputArgument::IS_ARRAY, 'Paths to warm; none = every page of sitemap.xml');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$result = $this->purgeService->warm((array)$input->getArgument('paths'));
$output->writeln(($result['success'] ? '<info>OK</info> ' : '<error>FAILED</error> ') . $result['message']);
return $result['success'] ? Command::SUCCESS : Command::FAILURE;
}
}
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\ContextMenu;
use TYPO3\CMS\Backend\ContextMenu\ItemProviders\AbstractProvider;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Core\Environment;
/**
* Page tree context menu: "Purge from SSR cache" - evicts exactly this
* page from the SSR node's full-page cache, with toast feedback.
* Shown only for real pages and only while the purge endpoint is
* configured (var/vitec-ssr-purge.env).
*/
final class SsrPurgeItemProvider extends AbstractProvider
{
protected $itemsConfiguration = [
'ssrPurge' => [
'type' => 'item',
'label' => 'Purge from SSR cache',
'iconIdentifier' => 'actions-system-cache-clear-impact-medium',
'callbackAction' => 'ssrPurge',
],
];
public function canHandle(): bool
{
return $this->table === 'pages';
}
/**
* Below the core PageProvider (100): we run after it and splice our
* item into the finished menu, right behind "Clear cache".
*/
public function getPriority(): int
{
return 42;
}
public function addItems(array $items): array
{
if (!is_readable(Environment::getVarPath() . '/vitec-ssr-purge.env')) {
return $items;
}
$page = BackendUtility::getRecord('pages', (int)$this->identifier, 'uid,doktype');
if ($page === null || (int)$page['doktype'] >= 199) {
// Spacers, folders, recycler - nothing cached for those.
return $items;
}
$this->initDisabledItems();
$localItems = $this->prepareItems($this->itemsConfiguration);
if (isset($items['clearCache'])) {
$new = [];
foreach ($items as $key => $item) {
$new[$key] = $item;
if ($key === 'clearCache') {
$new = array_merge($new, $localItems);
}
}
return $new;
}
return array_merge($items, $localItems);
}
protected function getAdditionalAttributes(string $itemName): array
{
// No ".js" here: the context menu JS appends it when importing.
return ['data-callback-module' => '@evomedien/vitec/ssr-context-menu'];
}
}
@@ -0,0 +1,547 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Controller\Backend;
use Evomedien\Vitec\Service\ClientErrorClassifier;
use Evomedien\Vitec\Service\SsrPurgeService;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Template\Components\ButtonBar;
use TYPO3\CMS\Backend\Template\Components\ComponentFactory;
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Http\RedirectResponse;
use TYPO3\CMS\Core\Http\RequestFactory;
use TYPO3\CMS\Core\Messaging\FlashMessage;
use TYPO3\CMS\Core\Site\SiteFinder;
use TYPO3\CMS\Core\Messaging\FlashMessageService;
use TYPO3\CMS\Core\Page\PageRenderer;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Backend module "VITEC SSR": live state of the SSR node, manual purge
* (single paths or everything) and the purge/health protocol.
*/
#[AsController]
final class SsrController
{
public function __construct(
private readonly ModuleTemplateFactory $moduleTemplateFactory,
private readonly SsrPurgeService $purgeService,
private readonly RequestFactory $requestFactory,
private readonly ConnectionPool $connectionPool,
private readonly FlashMessageService $flashMessageService,
private readonly UriBuilder $uriBuilder,
private readonly PageRenderer $pageRenderer,
private readonly ComponentFactory $componentFactory,
) {}
public function indexAction(ServerRequestInterface $request): ResponseInterface
{
$view = $this->moduleTemplateFactory->create($request);
$view->setTitle('VITEC SSR');
[$health, $healthError] = $this->fetchHealth();
$this->pageRenderer->loadJavaScriptModule('@evomedien/vitec/ssr-cockpit.js');
$this->pageRenderer->loadJavaScriptModule('@evomedien/vitec/ssr-module.js');
$this->pageRenderer->addCssFile('EXT:vitec/Resources/Public/Css/backend-ssr.css');
// DocHeader: shortcut button (0096-FE); the reload button comes from the core.
$view->addButtonToButtonBar(
$this->componentFactory->createShortcutButton()
->setRouteIdentifier('web_vitecssr')
->setDisplayName('VITEC SSR'),
ButtonBar::BUTTON_POSITION_RIGHT
);
$limit = (int)($request->getQueryParams()['limit'] ?? 30);
if (!in_array($limit, [30, 100, 500], true)) {
$limit = 30;
}
$healthLog = $this->rows('tx_vitec_ssrhealthlog', 10);
$lastSample = $healthLog[0] ?? null;
$errorReport = $this->clientErrors(50);
$clientErrors = $errorReport['list'];
$cockpit = $this->cockpitData($health, $lastSample);
$alert = $this->purgeService->getAlertRecipients();
$settingsUri = '';
if ($GLOBALS['BE_USER']->isAdmin()) {
try {
$sites = GeneralUtility::makeInstance(SiteFinder::class)->getAllSites();
$site = reset($sites);
if ($site) {
$settingsUri = (string)$this->uriBuilder->buildUriFromRoute(
'site_configuration.editSettings',
['site' => $site->getIdentifier()]
);
}
} catch (\Throwable) {
$settingsUri = '';
}
}
$view->assignMultiple([
'cockpit' => $cockpit,
'cockpitJson' => json_encode($cockpit, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT),
'settingsUri' => $settingsUri,
'failoverAfter' => $this->purgeService->getFailoverAfter(),
'alert' => $alert,
'alertList' => implode(', ', $alert['recipients']),
'alertCount' => count($alert['recipients']),
'alertInvalid' => implode(', ', $alert['invalid']),
'chunkCount24h' => $errorReport['chunk'],
'jsErrorCount24h' => $errorReport['js'],
'testCount24h' => $errorReport['tests'],
'botCount24h' => $errorReport['bots'],
'flagOn' => file_exists(Environment::getVarPath() . '/vitec-ssr-on.flag'),
'configured' => $this->purgeService->isConfigured(),
'health' => $health,
'healthError' => $healthError,
'cacheLimitMb' => $this->purgeService->getCacheLimitMb(),
'lastSample' => $lastSample,
'lastSampleAge' => $lastSample ? BackendUtility::calcAge(time() - (int)$lastSample['crdate']) : '',
'purgeLog' => $this->purgeRows($limit),
'purgeLimit' => $limit,
'moduleUri' => (string)$this->uriBuilder->buildUriFromRoute('web_vitecssr'),
'healthLog' => $healthLog,
'clientErrors' => $clientErrors,
'clientErrorCount24h' => $errorReport['real'],
]);
return $view->renderResponse('Ssr/Index');
}
/**
* Client error reports of the last 24 hours (POST /api/vitec/client-error,
* ClientErrorMiddleware), newest first. Test reports (smoke tests, curl)
* are counted apart and left out of the list (0114-FE); bot reports stay
* in with a label. Rows carry a "path" and a shortened user agent for the
* list; the full values stay in the row for the details toggle.
*
* @return array{list: list<array>, real: int, chunk: int, js: int, tests: int, bots: int}
*/
private function clientErrors(int $limit): array
{
$report = ['list' => [], 'real' => 0, 'chunk' => 0, 'js' => 0, 'tests' => 0, 'bots' => 0];
try {
$qb = $this->connectionPool->getQueryBuilderForTable('tx_vitec_clienterrorlog');
$rows = $qb->select('*')->from('tx_vitec_clienterrorlog')
->where($qb->expr()->gt('crdate', $qb->createNamedParameter(time() - 86400, Connection::PARAM_INT)))
->orderBy('uid', 'DESC')->setMaxResults(2000)
->executeQuery()->fetchAllAssociative();
} catch (\Throwable) {
return $report;
}
foreach ($rows as $row) {
$ua = (string)$row['user_agent'];
if (ClientErrorClassifier::isTest((string)$row['message'], $ua, (string)$row['release'])) {
$report['tests']++;
continue;
}
$row['is_chunk_reload'] = ClientErrorClassifier::isChunkReload((string)$row['message']);
$row['is_bot'] = ClientErrorClassifier::isBot($ua);
$report['real']++;
$report[$row['is_chunk_reload'] ? 'chunk' : 'js']++;
$report['bots'] += $row['is_bot'] ? 1 : 0;
if (count($report['list']) < $limit) {
$path = (string)(parse_url((string)$row['url'], PHP_URL_PATH) ?? '');
$row['path'] = $path !== '' ? $path : (string)$row['url'];
$row['ua_short'] = mb_strlen($ua) > 60 ? mb_substr($ua, 0, 57) . '...' : $ua;
$report['list'][] = $row;
}
}
return $report;
}
/**
* Purge log rows prepared for the template: trigger split into a short
* label plus the user, paths as count + first two + the rest behind a
* disclosure (the raw list was a wall of text, 0096-FE).
*/
private function purgeRows(int $limit): array
{
$rows = $this->rows('tx_vitec_ssrpurgelog', $limit);
$today = date('Y-m-d');
$yesterday = date('Y-m-d', time() - 86400);
$lastDay = '';
foreach ($rows as &$row) {
// Day separators for the activity timeline.
$day = date('Y-m-d', (int)$row['crdate']);
$row['day_label'] = match ($day) {
$today => 'Today',
$yesterday => 'Yesterday',
default => date('D, d.m.', (int)$row['crdate']),
};
$row['day_first'] = $day !== $lastDay;
$lastDay = $day;
$trigger = (string)$row['trigger_source'];
$who = '';
if (str_contains($trigger, ':')) {
[$trigger, $who] = explode(':', $trigger, 2);
}
$row['trigger_label'] = match ($trigger) {
'module' => 'manual',
'contextmenu' => 'context menu',
'editor-save', 'editor-command' => 'auto-purge',
default => $trigger,
};
$row['trigger_who'] = $who;
// Cockpit tone per trigger (pill + timeline dot colour).
$row['trigger_tone'] = match ($trigger) {
'editor-save', 'editor-command' => 'ok',
'module' => 'orange',
'contextmenu' => 'navy',
'switch-on' => 'warn',
default => 'info',
};
$paths = preg_split('/\s*,\s*/', trim((string)$row['paths']), -1, PREG_SPLIT_NO_EMPTY) ?: [];
$row['paths_all'] = $paths === ['*'];
$row['paths_count'] = count($paths);
$row['paths_first'] = array_slice($paths, 0, 2);
$row['paths_rest'] = array_slice($paths, 2);
}
unset($row);
return $rows;
}
/**
* Everything the cockpit draws (ssr-cockpit.js reads it as JSON):
* availability strip, traffic buckets for 24 h / 7 days, response
* sparkline, hit rate, client errors per hour. Source is the health
* log (cron vitec:ssr-health, every 5 min, kept 14 days); the node's
* hit/miss/stale counters are cumulative since its last start, so
* traffic is the difference between two samples (a smaller value
* than before means the node restarted: then the new value counts).
*/
private function cockpitData(?array $health, ?array $lastSample): array
{
$now = time();
$tz = (int)date('Z');
$from = $now - 7 * 86400 - 4 * 3600;
$samples = [];
$purges = [];
$errors = [];
try {
$samples = $this->connectionPool->getConnectionForTable('tx_vitec_ssrhealthlog')->executeQuery(
'SELECT crdate, ok, response_ms, cached_pages, cache_mb, stat_hit, stat_miss, stat_stale, stat_bypass,'
. ' stat_render_errors, stat_backend_errors FROM tx_vitec_ssrhealthlog WHERE crdate > ? ORDER BY crdate, uid',
[$from]
)->fetchAllAssociative();
$purges = $this->connectionPool->getConnectionForTable('tx_vitec_ssrpurgelog')->executeQuery(
'SELECT crdate, paths, success FROM tx_vitec_ssrpurgelog WHERE crdate > ? ORDER BY crdate',
[$from]
)->fetchAllAssociative();
$errors = $this->connectionPool->getConnectionForTable('tx_vitec_clienterrorlog')->executeQuery(
'SELECT crdate, message, user_agent, `release` FROM tx_vitec_clienterrorlog WHERE crdate > ?',
[$now - 86400]
)->fetchAllAssociative();
} catch (\Throwable) {
// Tables missing on a fresh system: the cockpit shows empty charts.
}
// Traffic deltas between consecutive samples with counters.
$keys = ['hit', 'miss', 'stale', 'bypass', 'render_errors', 'backend_errors'];
$prev = null;
foreach ($samples as &$s) {
foreach ($s as $k => $v) {
$s[$k] = (int)$v;
}
$s['d'] = null;
if ($s['stat_hit'] >= 0) {
if ($prev !== null) {
$d = [];
foreach ($keys as $k) {
$c = max(0, $s['stat_' . $k]);
$p = max(0, $prev['stat_' . $k]);
$d[$k] = $c >= $p ? $c - $p : $c;
}
$s['d'] = $d;
}
$prev = $s;
}
}
unset($s);
$buckets = function (int $size, int $count) use ($now, $tz, $samples, $purges): array {
$end = intdiv($now + $tz, $size) * $size - $tz + $size;
$start = $end - $size * $count;
$out = [];
for ($i = 0; $i < $count; $i++) {
$out[] = ['t' => $start + $i * $size, 'hit' => 0, 'stale' => 0, 'miss' => 0, 'bypass' => 0,
'errors' => 0, 'msSum' => 0, 'msN' => 0, 'purges' => 0, 'purgeAll' => 0];
}
foreach ($samples as $s) {
$i = intdiv($s['crdate'] - $start, $size);
if ($i < 0 || $i >= $count) {
continue;
}
if ($s['d'] !== null) {
$out[$i]['hit'] += $s['d']['hit'];
$out[$i]['stale'] += $s['d']['stale'];
$out[$i]['miss'] += $s['d']['miss'];
$out[$i]['bypass'] += $s['d']['bypass'];
$out[$i]['errors'] += $s['d']['render_errors'] + $s['d']['backend_errors'];
}
if ($s['ok'] === 1) {
$out[$i]['msSum'] += $s['response_ms'];
$out[$i]['msN']++;
}
}
foreach ($purges as $p) {
$i = intdiv((int)$p['crdate'] - $start, $size);
if ($i < 0 || $i >= $count) {
continue;
}
$out[$i]['purges']++;
if (trim((string)$p['paths']) === '*') {
$out[$i]['purgeAll']++;
}
}
foreach ($out as &$b) {
$b['ms'] = $b['msN'] > 0 ? (int)round($b['msSum'] / $b['msN']) : null;
unset($b['msSum'], $b['msN']);
}
return $out;
};
// Availability strip: 96 windows of 15 minutes.
$slot = 900;
$stripEnd = intdiv($now, $slot) * $slot + $slot;
$strip = array_fill(0, 96, null);
$ok24 = 0;
$n24 = 0;
$ms24 = [];
$slow = 0;
$down = 0;
foreach ($samples as $s) {
$i = 95 - intdiv($stripEnd - 1 - $s['crdate'], $slot);
if ($i < 0) {
continue;
}
$n24++;
$state = $s['ok'] !== 1 ? 2 : ($s['response_ms'] > 400 ? 1 : 0);
if ($s['ok'] === 1) {
$ok24++;
$ms24[] = $s['response_ms'];
}
$cur = $strip[$i];
if ($cur === null || $state > $cur['s']) {
$strip[$i] = ['s' => $state, 'ms' => $s['response_ms']];
}
}
foreach ($strip as $w) {
if ($w !== null && $w['s'] === 1) {
$slow++;
}
if ($w !== null && $w['s'] === 2) {
$down++;
}
}
sort($ms24);
$pct = static function (array $sorted, float $p): ?int {
if ($sorted === []) {
return null;
}
return $sorted[(int)min(count($sorted) - 1, floor($p * (count($sorted) - 1) + 0.5))];
};
// Hit rate now (last 24 h) and the 24 h before.
$rate = static function (array $samples, int $a, int $b): ?float {
$hit = 0;
$all = 0;
foreach ($samples as $s) {
if ($s['d'] === null || $s['crdate'] <= $a || $s['crdate'] > $b) {
continue;
}
$hit += $s['d']['hit'] + $s['d']['stale'];
$all += $s['d']['hit'] + $s['d']['stale'] + $s['d']['miss'];
}
return $all > 0 ? round($hit / $all * 100, 1) : null;
};
// Response time sparkline: the last 48 good samples (4 hours).
$spark = [];
foreach (array_slice(array_values(array_filter($samples, static fn(array $s): bool => $s['ok'] === 1)), -48) as $s) {
$spark[] = [$s['crdate'], $s['response_ms']];
}
// Client errors per hour, last 24 h.
$errHours = array_fill(0, 24, 0);
$hourEnd = intdiv($now, 3600) * 3600 + 3600;
foreach ($errors as $e) {
if (ClientErrorClassifier::isTest((string)$e['message'], (string)$e['user_agent'], (string)$e['release'])) {
continue;
}
$i = 23 - intdiv($hourEnd - 1 - (int)$e['crdate'], 3600);
if ($i >= 0 && $i < 24) {
$errHours[$i]++;
}
}
$cachedPages = $health['cachedPages'] ?? ($lastSample['cached_pages'] ?? null);
$cacheMb = $health['cacheMB'] ?? ($lastSample['cache_mb'] ?? null);
return [
'now' => $now,
'online' => $health !== null,
'warming' => $health['warming'] ?? null,
'strip' => ['end' => $stripEnd, 'slot' => $slot, 'w' => $strip],
'uptimePct' => $n24 > 0 ? round($ok24 / $n24 * 100, 1) : null,
'slowWindows' => $slow,
'downWindows' => $down,
'msMedian' => $pct($ms24, 0.5),
'msP95' => $pct($ms24, 0.95),
'spark' => $spark,
'hitRate' => $rate($samples, $now - 86400, $now),
'hitRatePrev' => $rate($samples, $now - 2 * 86400, $now - 86400),
'cachedPages' => $cachedPages !== null ? (int)$cachedPages : null,
'cacheMb' => $cacheMb !== null ? (int)$cacheMb : null,
'cacheLimitMb' => $this->purgeService->getCacheLimitMb(),
'errHours' => $errHours,
'range' => [
'24h' => ['size' => 3600, 'b' => $buckets(3600, 24)],
'7d' => ['size' => 14400, 'b' => $buckets(14400, 42)],
],
];
}
public function purgeAction(ServerRequestInterface $request): ResponseInterface
{
$body = (array)$request->getParsedBody();
$mode = (string)($body['mode'] ?? 'paths');
$user = (string)($GLOBALS['BE_USER']->user['username'] ?? '?');
if ($mode === 'all') {
$paths = ['*'];
} else {
$paths = preg_split('/[\s,]+/', (string)($body['paths'] ?? ''), -1, PREG_SPLIT_NO_EMPTY) ?: [];
}
if ($paths === []) {
$this->flash('No paths given.', 'Purge', false);
} else {
$result = $this->purgeService->purge($paths, 'module:' . $user);
$this->flash(
sprintf('HTTP %d — %s', $result['httpCode'], $result['message']),
$result['success'] ? 'Purge successful' : 'Purge failed',
$result['success']
);
}
return new RedirectResponse((string)$this->uriBuilder->buildUriFromRoute('web_vitecssr'), 303);
}
/**
* "Warm cache" button: asks the node to render every page of the
* sitemap in the background (0066-FE). Returns at once; progress is
* the "Cache warming" row of the status table.
*/
public function warmAction(ServerRequestInterface $request): ResponseInterface
{
$result = $this->purgeService->warm();
$this->flash(
$result['message'],
$result['success'] ? 'Cache warming' : 'Cache warming failed',
$result['success']
);
return new RedirectResponse((string)$this->uriBuilder->buildUriFromRoute('web_vitecssr'), 303);
}
/**
* Ajax route vitec_ssr_purge_page: purge exactly one page, called by
* the page tree context menu item (ssr-context-menu.js).
*/
public function purgePage(ServerRequestInterface $request): ResponseInterface
{
$body = (array)($request->getParsedBody() ?? []);
$pageUid = (int)($body['pageUid'] ?? ($request->getQueryParams()['pageUid'] ?? 0));
if ($pageUid <= 0) {
return new JsonResponse(['success' => false, 'message' => 'No page uid given'], 400);
}
try {
$site = GeneralUtility::makeInstance(SiteFinder::class)->getSiteByPageId($pageUid);
$path = $site->getRouter()->generateUri($pageUid)->getPath() ?: '/';
} catch (\Throwable) {
return new JsonResponse(['success' => false, 'message' => 'Page has no routable URL (folder?)']);
}
$user = (string)($GLOBALS['BE_USER']->user['username'] ?? '?');
$result = $this->purgeService->purge([$path], 'contextmenu:' . $user);
return new JsonResponse([
'success' => $result['success'],
'path' => $path,
'message' => 'HTTP ' . $result['httpCode'] . ' - ' . $result['message'],
]);
}
/**
* GET /__health of the SSR node. Returns [health array|null, error string].
*/
private function fetchHealth(): array
{
$endpoint = $this->purgeService->getHealthEndpoint();
if ($endpoint === '') {
return [null, 'var/vitec-ssr-purge.env missing or incomplete.'];
}
$start = microtime(true);
try {
$response = $this->requestFactory->request($endpoint, 'GET', [
'timeout' => 3, 'connect_timeout' => 2, 'http_errors' => false,
]);
if ($response->getStatusCode() !== 200) {
return [null, 'HTTP ' . $response->getStatusCode()];
}
$health = json_decode((string)$response->getBody(), true) ?: null;
if ($health === null) {
return [null, 'invalid JSON from /__health'];
}
$health['responseMs'] = (int)round((microtime(true) - $start) * 1000);
return [$health, ''];
} catch (\Throwable $e) {
return [null, $e->getMessage()];
}
}
/**
* Ajax route vitec_ssr_health: live status for the cockpit (warm
* progress ring, cache fill, online state; ssr-cockpit.js polls it).
*/
public function healthJson(ServerRequestInterface $request): ResponseInterface
{
[$health, $error] = $this->fetchHealth();
return new JsonResponse(['health' => $health, 'error' => $error]);
}
private function rows(string $table, int $limit): array
{
try {
$qb = $this->connectionPool->getQueryBuilderForTable($table);
return $qb->select('*')->from($table)
->orderBy('uid', 'DESC')->setMaxResults($limit)
->executeQuery()->fetchAllAssociative();
} catch (\Throwable) {
return [];
}
}
private function flash(string $message, string $title, bool $ok): void
{
$flash = GeneralUtility::makeInstance(
FlashMessage::class,
$message,
$title,
$ok ? ContextualFeedbackSeverity::OK : ContextualFeedbackSeverity::ERROR,
true
);
$this->flashMessageService->getMessageQueueByIdentifier()->addMessage($flash);
}
}
@@ -4,9 +4,12 @@ declare(strict_types=1);
namespace Evomedien\Vitec\EventListener;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\ParameterType;
use Evomedien\EvoMegamenuJson\Event\BuildSpotlightEvent;
use TYPO3\CMS\Core\Attribute\AsEventListener;
use TYPO3\CMS\Core\Cache\CacheDataCollectorInterface;
use TYPO3\CMS\Core\Cache\CacheTag;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\Restriction\FrontendRestrictionContainer;
use TYPO3\CMS\Core\Resource\FileRepository;
@@ -19,17 +22,23 @@ use TYPO3\CMS\Extbase\Service\ImageService;
* news and the next events as image cards, queried live - editors touch
* news and event records, never the menu.
*
* Conventions mirrored from the site set: news live on pid 11 (type 0 only,
* same filter as the Solr indexing TS), their details render on page 21
* through the NewsDetail route enhancer (/news/article/<path_segment>).
* Conventions mirrored from the site set: news are type 0 only (same filter
* as the Solr indexing TS), their details render on page 21 through the
* NewsDetail route enhancer (/news/article/<path_segment>).
* Events come from the vitec event table; an event links to its own
* eventurl when set, otherwise to the events page.
*
* News sources (2026-11-05, ticket 0046-FE: the newer posts sit in folder
* 442, the query only read 11): the entry's "Data source folders" and
* "Data source categories" decide, editable in the menu record. No folder
* set = 11 + 442 (like the /insights/news plugin), no category = all.
*/
#[AsEventListener(identifier: 'vitec/megamenu-insights-spotlight')]
final class ProvideInsightsSpotlight
{
private const NEWS_TABLE = 'tx_news_domain_model_news';
private const NEWS_STORAGE_PID = 11;
private const DEFAULT_NEWS_PIDS = [11, 442];
private const ENTRY_TABLE = 'tx_evomegamenujson_entry';
private const NEWS_DETAIL_PATH = '/news/article/';
private const EVENT_TABLE = 'tx_vitec_domain_model_event';
private const EVENTS_PAGE = '/insights/events';
@@ -44,25 +53,83 @@ final class ProvideInsightsSpotlight
public function __invoke(BuildSpotlightEvent $event): void
{
$stage = $event->getStage();
$entry = $event->getEntry();
// group field: "11,442" (older notation "pages_11" tolerated)
$pids = array_values(array_filter(array_map(
static fn(string $value): int => (int)preg_replace('/^\D+_/', '', trim($value)),
explode(',', (string)($entry['source_pages'] ?? ''))
)));
$event->setSpotlight([
'news' => $this->latestNews($stage),
'news' => $this->latestNews($stage, $pids ?: self::DEFAULT_NEWS_PIDS, $this->entryCategories((int)($entry['uid'] ?? 0))),
'events' => $this->upcomingEvents($stage),
]);
$this->tagPageCache();
}
/** @return array<int,array<string,mixed>> */
private function latestNews(int $stage): array
/**
* The menu sits in every cached page; tagging the page with the news and
* event tables lets the DataHandler refresh those pages as soon as an
* editor saves a news or event record (it flushes the tag `<table>`).
*/
private function tagPageCache(): void
{
try {
$collector = ($GLOBALS['TYPO3_REQUEST'] ?? null)?->getAttribute('frontend.cache.collector');
if ($collector instanceof CacheDataCollectorInterface) {
$collector->addCacheTags(new CacheTag(self::NEWS_TABLE), new CacheTag(self::EVENT_TABLE));
}
} catch (\Throwable) {
// untagged, the menu only ages until the next flush
}
}
/**
* Categories picked in the entry's "Data source categories".
*
* @return int[]
*/
private function entryCategories(int $entryUid): array
{
if ($entryUid <= 0) {
return [];
}
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_category_record_mm');
return array_map('intval', $queryBuilder->select('uid_local')->from('sys_category_record_mm')
->where(
$queryBuilder->expr()->eq('tablenames', $queryBuilder->createNamedParameter(self::ENTRY_TABLE)),
$queryBuilder->expr()->eq('fieldname', $queryBuilder->createNamedParameter('source_categories')),
$queryBuilder->expr()->eq('uid_foreign', $queryBuilder->createNamedParameter($entryUid, ParameterType::INTEGER))
)
->executeQuery()->fetchFirstColumn());
}
/**
* @param int[] $pids news folders
* @param int[] $categories only news in one of these; empty = all
* @return array<int,array<string,mixed>>
*/
private function latestNews(int $stage, array $pids, array $categories): array
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::NEWS_TABLE);
$queryBuilder->setRestrictions(GeneralUtility::makeInstance(FrontendRestrictionContainer::class));
try {
$rows = $queryBuilder->select('uid', 'title', 'teaser', 'datetime', 'path_segment')
$queryBuilder->select('uid', 'title', 'teaser', 'datetime', 'path_segment')
->from(self::NEWS_TABLE)
->where(
$queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter(self::NEWS_STORAGE_PID, ParameterType::INTEGER)),
$queryBuilder->expr()->in('pid', $queryBuilder->createNamedParameter($pids, ArrayParameterType::INTEGER)),
$queryBuilder->expr()->eq('type', $queryBuilder->createNamedParameter('0'))
)
->orderBy('datetime', 'DESC')
);
if ($categories !== []) {
$mm = $this->connectionPool->getQueryBuilderForTable('sys_category_record_mm');
$mm->select('uid_foreign')->from('sys_category_record_mm')
->where(
$mm->expr()->eq('tablenames', $queryBuilder->createNamedParameter(self::NEWS_TABLE)),
$mm->expr()->eq('fieldname', $queryBuilder->createNamedParameter('categories')),
$mm->expr()->in('uid_local', $queryBuilder->createNamedParameter($categories, ArrayParameterType::INTEGER))
);
$queryBuilder->andWhere($queryBuilder->expr()->in('uid', $mm->getSQL()));
}
$rows = $queryBuilder->orderBy('datetime', 'DESC')
->setMaxResults(self::NEWS_COUNT)
->executeQuery()->fetchAllAssociative();
} catch (\Throwable) {
@@ -5,50 +5,84 @@ declare(strict_types=1);
namespace Evomedien\Vitec\EventListener;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\ParameterType;
use Evomedien\EvoMegamenuJson\Event\BuildCatalogEvent;
use TYPO3\CMS\Core\Attribute\AsEventListener;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Resource\FileRepository;
use TYPO3\CMS\Extbase\Service\ImageService;
/**
* Supplies the product catalog for megamenu entries with the layout
* `catalog` (2026-10-09, modelled on the Ross Video "Products" menu):
* main categories (middle column) -> subcategories as group headings ->
* products with a one-line teaser (right column), plus the endpoint of the
* product type-ahead in the panel's search bar.
* Supplies the products panel of the megamenu (entry layout `catalog`),
* version 2 (2026-11-02, agency "Product Menu Prototype"): three levels,
* the menu ends at the product OVERVIEW pages.
*
* Same tree and visibility rules as the product list and the Product
* Finder: level 1 below the root category "Product" are the main
* categories, their direct children the subcategories; products need
* legacy/supportproduct/hideonwebsite/hideonproducts/subproduct = 0. Within
* a group the uid order is kept (records were created in sitemap order).
* Links follow the Product Finder convention /products?cat=<main>&subcat=<sub>.
* categories[] (A) = category pages directly below /products
* groups[] (B) = sub-category pages below those
* items[] type "page" = overview pages (/products/<slug>)
* type "shortcut" = shortcut pages, resolved to a target
* group or overview page of this tree
*
* Three queries for the whole catalog (categories, assignments, products) -
* the menu travels with every page.
* The page tree built by vitec:build-product-overviews is the single
* source: editors rename, reorder, hide (hidden/nav_hide) or move pages
* there. An overview item lists the products of its page (the vitec_card
* elements linking /product/<slug>, in creation order) - the sub-product
* pages the visitor reaches from the overview page; the menu does not show
* them, the count and the list serve the "N sub-products" line and search.
*
* An overview page with exactly ONE product is shown as that product
* (decision Harry 2026-11-12, e.g. Avedia, EZ TV, X-Player, ChannelLink,
* ActiveSQX): `link` is the product detail page and `image` the product's
* own image (productimage, else heroimage, else the page media). `linkType`
* says which case it is (`product` | `overview`), `overviewLink` always
* keeps the overview page. The page itself stays reachable (breadcrumb,
* search, sitemap).
*
* A and B were sysfolders (menu labels only) until 2026-11-05; Olli: every
* category and sub-category is a clickable page. Both levels now carry
* `link` (additive; '' while a level is still a folder). Folders are still
* read, so the menu survives a half-converted tree; only groups with items
* and categories with such groups count (keeps other pages below /products,
* e.g. the 2026-10 category pages, out of the menu).
*
* Next to the tree: viewAll (/products) and the type-ahead endpoint. The
* boxes of the left column ("Product highlight", "Product news") are the
* entry's editorial teaser cards, not data from here (decision Olli
* 2026-11-02: no automatic news).
*
* Version 1 (2026-10-09) listed sys_category groups with their products;
* the nine old category pages are plain pages below /products and are not
* part of this tree. The transitional v1 fields (categories[].uid/link,
* groups[].uid/link/products, products[].teaser) are gone since 2026-11-03 -
* the front end reads the pure v2 shape (ticket 0024-FE).
*/
#[AsEventListener(identifier: 'vitec/megamenu-product-catalog')]
final class ProvideProductCatalog
{
private const ROOT_TITLE = 'Product';
private const PRODUCT_TABLE = 'tx_vitec_domain_model_product';
private const PRODUCTS_PAGE = '/products';
private const PRODUCTS_SLUG = '/products';
private const SEARCH_ENDPOINT = '/_json/?type=7385';
private const TEASER_LENGTH = 120;
private const DOKTYPE_PAGE = 1;
private const DOKTYPE_SHORTCUT = 4;
private const DOKTYPE_FOLDER = 254;
public function __construct(private readonly ConnectionPool $connectionPool) {}
public function __construct(
private readonly ConnectionPool $connectionPool,
private readonly FileRepository $fileRepository,
) {}
public function __invoke(BuildCatalogEvent $event): void
{
try {
$stage = $event->getStage();
$event->setCatalog([
'version' => 2,
'search' => [
'endpoint' => self::SEARCH_ENDPOINT,
'param' => 'q',
'minChars' => 2,
'placeholder' => 'Search products',
],
'categories' => $this->categories(),
'viewAll' => ['title' => 'View all products', 'link' => self::PRODUCTS_SLUG],
'categories' => $this->categories($stage),
]);
} catch (\Throwable) {
// fail-soft: the menu renders without the catalog rather than not at all
@@ -56,141 +90,246 @@ final class ProvideProductCatalog
}
/** @return array<int,array<string,mixed>> */
private function categories(): array
private function categories(int $stage): array
{
$root = $this->rootUid();
$root = $this->productsRootUid();
if ($root === 0) {
return [];
}
$mains = $this->childCategories([$root]);
$subs = $this->childCategories(array_column($mains, 'uid'));
$subsByParent = [];
foreach ($subs as $sub) {
$subsByParent[(int)$sub['parent']][] = $sub;
}
$categoryRows = $this->children([$root], [self::DOKTYPE_PAGE, self::DOKTYPE_FOLDER]);
$groupRows = $this->children(array_column($categoryRows, 'uid'), [self::DOKTYPE_PAGE, self::DOKTYPE_FOLDER]);
$itemRows = $this->children(array_column($groupRows, 'uid'), [self::DOKTYPE_PAGE, self::DOKTYPE_SHORTCUT]);
$overviewUids = array_map('intval', array_column(
array_filter($itemRows, static fn(array $r): bool => (int)$r['doktype'] === self::DOKTYPE_PAGE), 'uid'
));
$productsByPage = $this->productsOnPages($overviewUids);
$categoryUids = array_merge(array_column($mains, 'uid'), array_column($subs, 'uid'));
$productsByCategory = $this->productsByCategory(array_map('intval', $categoryUids));
// positions for shortcut resolution: folder uid => [c, g], page uid => [c, g, p]
$groupPos = [];
$pagePos = [];
// only the products tree: groups with items, categories with such groups
$filledGroups = array_map('intval', array_column($itemRows, 'pid'));
$groupRows = array_values(array_filter($groupRows, static fn(array $row): bool => in_array((int)$row['uid'], $filledGroups, true)));
$filledCategories = array_map('intval', array_column($groupRows, 'pid'));
$categoryRows = array_values(array_filter($categoryRows, static fn(array $row): bool => in_array((int)$row['uid'], $filledCategories, true)));
$catalog = [];
foreach ($mains as $main) {
$mainUid = (int)$main['uid'];
$categories = [];
foreach ($categoryRows as $c => $categoryRow) {
$groups = [];
// products filed directly on the main category: an untitled first group
if (!empty($productsByCategory[$mainUid])) {
$groups[] = [
'uid' => $mainUid,
'title' => '',
'link' => self::PRODUCTS_PAGE . '?cat=' . $mainUid,
'products' => $productsByCategory[$mainUid],
];
}
foreach ($subsByParent[$mainUid] ?? [] as $sub) {
$subUid = (int)$sub['uid'];
if (empty($productsByCategory[$subUid])) {
continue; // no empty headings in the menu
foreach (array_values(array_filter($groupRows, fn(array $g): bool => (int)$g['pid'] === (int)$categoryRow['uid'])) as $g => $groupRow) {
$groupPos[(int)$groupRow['uid']] = [$c, $g];
$items = [];
foreach (array_values(array_filter($itemRows, fn(array $i): bool => (int)$i['pid'] === (int)$groupRow['uid'])) as $p => $itemRow) {
if ((int)$itemRow['doktype'] === self::DOKTYPE_SHORTCUT) {
$items[] = ['type' => 'shortcut', 'title' => (string)$itemRow['title'], 'shortcut' => (int)$itemRow['shortcut']];
continue;
}
$uid = (int)$itemRow['uid'];
$pagePos[$uid] = [$c, $g, $p];
$products = $productsByPage[$uid] ?? [];
// exactly one product: the tile is that product (link + image), see class comment
$single = count($products) === 1 ? $products[0] : null;
$item = [
'type' => 'page',
'uid' => $uid,
'title' => (string)$itemRow['title'],
'link' => $single !== null ? $single['link'] : (string)$itemRow['slug'],
'linkType' => $single !== null ? 'product' : 'overview',
'overviewLink' => (string)$itemRow['slug'],
'productCount' => count($products),
'products' => array_map(static fn(array $p): array => ['title' => $p['title'], 'link' => $p['link']], $products),
];
if ($stage >= 2) {
$item['image'] = ($single !== null && $single['uid'] > 0 ? $this->productImage($single['uid']) : null)
?? $this->pageImage($uid);
}
$items[] = $item;
}
$groups[] = [
'uid' => $subUid,
'title' => (string)$sub['title'],
'link' => self::PRODUCTS_PAGE . '?cat=' . $mainUid . '&subcat=' . $subUid,
'products' => $productsByCategory[$subUid],
];
$groups[] = ['title' => (string)$groupRow['title'], 'link' => $this->sectionLink($groupRow), 'items' => $items];
}
if ($groups === []) {
continue;
}
$catalog[] = [
'uid' => $mainUid,
'title' => (string)$main['title'],
'link' => self::PRODUCTS_PAGE . '?cat=' . $mainUid,
'groups' => $groups,
];
$categories[] = ['title' => (string)$categoryRow['title'], 'link' => $this->sectionLink($categoryRow), 'groups' => $groups];
}
return $catalog;
// shortcuts: target = a group of this tree (opens that group) or an
// overview page (opens its group, highlights the page); anything else
// is dropped - a shortcut must not lead out of the menu silently
foreach ($categories as $c => $category) {
foreach ($category['groups'] as $g => $group) {
foreach ($group['items'] as $i => $item) {
if ($item['type'] !== 'shortcut') {
continue;
}
$targetUid = $item['shortcut'];
if (isset($groupPos[$targetUid])) {
[$tc, $tg] = $groupPos[$targetUid];
$resolved = ['category' => $tc, 'group' => $tg];
$link = '';
} elseif (isset($pagePos[$targetUid])) {
[$tc, $tg, $tp] = $pagePos[$targetUid];
$resolved = ['category' => $tc, 'group' => $tg, 'page' => $tp];
$link = (string)$categories[$tc]['groups'][$tg]['items'][$tp]['link'];
} else {
unset($categories[$c]['groups'][$g]['items'][$i]);
continue;
}
$categories[$c]['groups'][$g]['items'][$i] = [
'type' => 'shortcut',
'title' => $item['title'],
'link' => $link,
'target' => $resolved,
];
}
$categories[$c]['groups'][$g]['items'] = array_values($categories[$c]['groups'][$g]['items']);
}
}
return $categories;
}
private function rootUid(): int
/** @param array<string,mixed> $row category or sub-category page row; '' while it is a folder */
private function sectionLink(array $row): string
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_category');
return (int)$queryBuilder->select('uid')->from('sys_category')
return (int)$row['doktype'] === self::DOKTYPE_PAGE ? (string)$row['slug'] : '';
}
private function productsRootUid(): int
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('pages');
return (int)$queryBuilder->select('uid')->from('pages')
->where(
$queryBuilder->expr()->eq('parent', 0),
$queryBuilder->expr()->eq('title', $queryBuilder->createNamedParameter(self::ROOT_TITLE))
$queryBuilder->expr()->eq('slug', $queryBuilder->createNamedParameter(self::PRODUCTS_SLUG)),
$queryBuilder->expr()->in('sys_language_uid', [0, -1])
)
->setMaxResults(1)
->executeQuery()->fetchOne();
}
/**
* Visible child pages (frontend restrictions: hidden/deleted/start/end),
* nav_hide respected, in page tree order. `title` is the menu label:
* nav_title when set (the adopted 2026-10 category pages carry the
* sitemap name there, their page title stays the SEO/H1 one).
*
* @param int[] $parents
* @return array<int,array{uid:int,parent:int,title:string}>
* @param int[] $doktypes
* @return array<int,array<string,mixed>>
*/
private function childCategories(array $parents): array
private function children(array $parents, array $doktypes): array
{
if ($parents === []) {
return [];
}
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_category');
return $queryBuilder->select('uid', 'parent', 'title')->from('sys_category')
->where($queryBuilder->expr()->in('parent', $queryBuilder->createNamedParameter($parents, ArrayParameterType::INTEGER)))
->orderBy('parent')->addOrderBy('sorting')
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('pages');
$rows = $queryBuilder->select('uid', 'pid', 'title', 'nav_title', 'slug', 'doktype', 'shortcut')->from('pages')
->where(
$queryBuilder->expr()->in('pid', $queryBuilder->createNamedParameter(array_map('intval', $parents), ArrayParameterType::INTEGER)),
$queryBuilder->expr()->in('doktype', $queryBuilder->createNamedParameter($doktypes, ArrayParameterType::INTEGER)),
$queryBuilder->expr()->eq('nav_hide', 0),
$queryBuilder->expr()->in('sys_language_uid', [0, -1])
)
->orderBy('pid')->addOrderBy('sorting')
->executeQuery()->fetchAllAssociative();
foreach ($rows as &$row) {
if (trim((string)($row['nav_title'] ?? '')) !== '') {
$row['title'] = $row['nav_title'];
}
}
return $rows;
}
/**
* @param int[] $categoryUids
* @return array<int,array<int,array{uid:int,title:string,teaser:string,link:string}>> category uid => products
* The products an overview page presents: its visible cards that link a
* product detail page (container children included), creation order.
*
* @param int[] $pageUids
* @return array<int,array<int,array{title:string,link:string,uid:int}>> page uid => products
*/
private function productsByCategory(array $categoryUids): array
private function productsOnPages(array $pageUids): array
{
if ($categoryUids === []) {
if ($pageUids === []) {
return [];
}
$queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::PRODUCT_TABLE);
$rows = $queryBuilder
->select('p.uid', 'p.title', 'p.teaser', 'p.slug', 'mm.uid_local AS category')
->from(self::PRODUCT_TABLE, 'p')
->join(
'p',
'sys_category_record_mm',
'mm',
'mm.uid_foreign = p.uid AND mm.tablenames = ' . $queryBuilder->createNamedParameter(self::PRODUCT_TABLE)
. ' AND mm.fieldname = ' . $queryBuilder->createNamedParameter('categories')
)
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tt_content');
$rows = $queryBuilder->select('pid', 'header', 'vitec_primary_cta')->from('tt_content')
->where(
$queryBuilder->expr()->in('mm.uid_local', $queryBuilder->createNamedParameter($categoryUids, ArrayParameterType::INTEGER)),
$queryBuilder->expr()->eq('p.legacy', 0),
$queryBuilder->expr()->eq('p.supportproduct', 0),
$queryBuilder->expr()->eq('p.hideonwebsite', 0),
$queryBuilder->expr()->eq('p.hideonproducts', 0),
$queryBuilder->expr()->eq('p.subproduct', 0),
$queryBuilder->expr()->neq('p.slug', $queryBuilder->createNamedParameter('', ParameterType::STRING))
$queryBuilder->expr()->in('pid', $queryBuilder->createNamedParameter($pageUids, ArrayParameterType::INTEGER)),
$queryBuilder->expr()->eq('CType', $queryBuilder->createNamedParameter('vitec_card')),
$queryBuilder->expr()->like('vitec_primary_cta', $queryBuilder->createNamedParameter('/product/%'))
)
->orderBy('p.uid')
->orderBy('uid')
->executeQuery()->fetchAllAssociative();
$byCategory = [];
$uidBySlug = $rows === [] ? [] : $this->productUidsBySlug();
$byPage = [];
foreach ($rows as $row) {
$byCategory[(int)$row['category']][] = [
'uid' => (int)$row['uid'],
'title' => (string)$row['title'],
'teaser' => $this->oneLiner((string)$row['teaser']),
'link' => '/product/' . ltrim((string)$row['slug'], '/'),
$link = (string)$row['vitec_primary_cta'];
$slug = trim((string)parse_url($link, PHP_URL_PATH), '/');
$slug = substr($slug, strlen('product/'));
$byPage[(int)$row['pid']][] = [
'title' => (string)$row['header'],
'link' => $link,
'uid' => $uidBySlug[$slug] ?? 0,
];
}
return $byCategory;
return $byPage;
}
/** Plain one-line teaser: tags and entities resolved, whitespace collapsed, cut at a word. */
private function oneLiner(string $value): string
/** @return array<string,int> product slug (without slashes) => uid, live products only */
private function productUidsBySlug(): array
{
$text = trim((string)preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags($value), ENT_QUOTES | ENT_HTML5, 'UTF-8')));
if (mb_strlen($text) <= self::TEASER_LENGTH) {
return $text;
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_vitec_domain_model_product');
$rows = $queryBuilder->select('uid', 'slug')->from('tx_vitec_domain_model_product')
->executeQuery()->fetchAllAssociative();
$map = [];
foreach ($rows as $row) {
$map[trim((string)$row['slug'], '/')] = (int)$row['uid'];
}
return $map;
}
/** The product's own picture (productimage, else heroimage) as 640px tile - or null. */
private function productImage(int $productUid): ?array
{
try {
foreach (['productimage', 'heroimage'] as $field) {
$references = $this->fileRepository->findByRelation('tx_vitec_domain_model_product', $field, $productUid);
if ($references !== []) {
return $this->tile($references[0]);
}
}
} catch (\Throwable) {
}
return null;
}
/** First page media (page properties > resources), 640px tile, root-relative - or null. */
private function pageImage(int $pageUid): ?array
{
try {
$references = $this->fileRepository->findByRelation('pages', 'media', $pageUid);
if ($references === []) {
return null;
}
return $this->tile($references[0]);
} catch (\Throwable) {
return null;
}
}
/** @return array{url:string,alternative:string,width:int,height:int}|null */
private function tile(\TYPO3\CMS\Core\Resource\FileReference $reference): ?array
{
try {
$references = [$reference];
$imageService = \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(ImageService::class);
$processed = $imageService->applyProcessingInstructions($references[0], ['width' => '640', 'fileExtension' => 'webp']);
$url = $imageService->getImageUri($processed);
return [
'url' => str_starts_with($url, '/') || preg_match('#^https?://#', $url) ? $url : '/' . $url,
'alternative' => (string)($references[0]->getAlternative() ?? ''),
'width' => (int)$processed->getProperty('width'),
'height' => (int)$processed->getProperty('height'),
];
} catch (\Throwable) {
return null;
}
$cut = mb_substr($text, 0, self::TEASER_LENGTH);
$space = mb_strrpos($cut, ' ');
return rtrim($space !== false ? mb_substr($cut, 0, $space) : $cut, ' ,;:-') . ' …';
}
}
@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Headless;
use Netzbewegung\NbHeadlessContentBlocks\DataProcessing\ToArray\FileReferenceToArray;
use TYPO3\CMS\Core\Imaging\ImageManipulation\CropVariantCollection;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Service\ImageService;
/**
* XCLASS of the headless-content-blocks file serializer: delivers
* processed, responsive images instead of the original upload.
*
* Why (Lighthouse 2026-11-10, home page score 40 / LCP 25 s / 42 MB): the
* stock serializer emits `publicUrl` = the ORIGINAL file for every
* content-block image field (hero_bgimage, intro_image, card images …).
* Editors upload 4-13 MB JPEGs, so that is what every visitor downloads -
* while news/product images already go through the ImageService.
*
* Output per image (additive to the stock keys id/alt/title):
* - publicUrl: now the processed default variant (WebP, max. 1920 px wide,
* editor crop applied) - the frontend needs no change to benefit.
* - originalUrl: the untouched upload, should anyone need it.
* - srcset: WebP steps 480-1920 px (steps that would fall back to the
* original are skipped), as {url,width,descriptor} like elsewhere.
* - width/height: pixel size of the default variant (aspect ratio for
* CLS-free layout) - mimeType alongside.
* Non-images (video, pdf …), SVG and GIF keep the stock behaviour and only
* gain mimeType/size (GIF processing would drop animation; SVG scales).
*
* Exception-safe: any processing failure falls back to the stock output.
*/
class ProcessedFileReferenceToArray extends FileReferenceToArray
{
private const DEFAULT_WIDTH = 1920;
private const SRCSET_WIDTHS = [480, 768, 1024, 1440, 1920];
private const SKIP_EXTENSIONS = ['svg', 'gif'];
public function toArray(): array
{
$data = parent::toArray();
try {
$file = $this->fileReference;
$mimeType = (string)$file->getMimeType();
$extension = strtolower((string)$file->getExtension());
// Alt-text fallback (Seobility: 215 pages with empty alt): the
// reference title, then the file's title property. Editors can
// still override per reference.
if (trim((string)($data['alt'] ?? '')) === '') {
$fallbackAlt = trim((string)($file->getTitle() ?? ''));
if ($fallbackAlt === '') {
$fallbackAlt = trim((string)($file->getOriginalFile()->getProperty('title') ?? ''));
}
if ($fallbackAlt !== '') {
$data['alt'] = $fallbackAlt;
}
}
$data['mimeType'] = $mimeType;
$data['size'] = (int)$file->getSize();
if (!str_starts_with($mimeType, 'image/') || in_array($extension, self::SKIP_EXTENSIONS, true)) {
if (str_starts_with($mimeType, 'image/')) {
$data['width'] = (int)$file->getProperty('width');
$data['height'] = (int)$file->getProperty('height');
}
return $data;
}
$crop = null;
if ($file->hasProperty('crop') && $file->getProperty('crop')) {
$cropArea = CropVariantCollection::create((string)$file->getProperty('crop'))->getCropArea('default');
if (!$cropArea->isEmpty()) {
$crop = $cropArea->makeAbsoluteBasedOnFile($file);
}
}
$imageService = GeneralUtility::makeInstance(ImageService::class);
$instructions = static fn (int $width): array => array_filter([
'width' => $width,
'crop' => $crop,
'fileExtension' => 'webp',
], static fn ($v) => $v !== null);
$default = $imageService->applyProcessingInstructions($file, $instructions(self::DEFAULT_WIDTH));
$srcset = [];
foreach (self::SRCSET_WIDTHS as $width) {
$variant = $imageService->applyProcessingInstructions($file, $instructions($width));
if ($variant->usesOriginalFile()) {
continue;
}
$srcset[] = [
'url' => $imageService->getImageUri($variant, true),
'width' => (int)$variant->getProperty('width'),
'descriptor' => (int)$variant->getProperty('width') . 'w',
];
}
$data['originalUrl'] = (string)($data['publicUrl'] ?? '');
$data['originalSize'] = (int)$file->getSize();
$data['publicUrl'] = $imageService->getImageUri($default, true);
$data['width'] = (int)$default->getProperty('width');
$data['height'] = (int)$default->getProperty('height');
$data['size'] = (int)$default->getSize();
$data['srcset'] = $srcset;
$data['mimeType'] = (string)($default->getMimeType() ?: 'image/webp');
} catch (\Throwable $e) {
// keep the stock payload
}
return $data;
}
}
@@ -0,0 +1,222 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Hooks;
use TYPO3\CMS\Core\Core\Environment;
use Evomedien\Vitec\Service\SsrPurgeService;
use Evomedien\Vitec\Service\SsrPurgeTargetResolver;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Site\SiteFinder;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Database\ConnectionPool;
/**
* Auto-purge: tells the SSR node which pages to drop from its full-page
* cache after editors change content (ticket 0054-FE section 3).
*
* Mapping rules (v1, deliberately conservative):
* - pages: navigation-relevant field changed (title, nav_title, slug,
* hidden, nav_hide, doktype) -> '*' (menus are embedded in EVERY page),
* otherwise just that page's path.
* - tt_content: the hosting page's path; the megamenu element -> '*'.
* - tx_news_* and tx_vitec_*: targeted since 2026-11-12 - the pages that
* show the record (SsrPurgeTargetResolver: detail URL, explicit
* selections, list plugins, embedding records); '*' for URL/visibility
* changes, the newest news, events and anything unknown.
* - megamenu records (tx_evomegamenujson_*): '*' - the menu is on every page.
* - delete/move/undelete commands: pages, records and megamenu -> '*',
* content elements -> hosting page.
*
* Changes are queued per request and flushed ONCE in afterAllOperations,
* so bulk operations (importer!) end up as a single purge call.
* Runs only while the SSR routing flag is ON and the Site Set switch
* vitec.ssr.autoPurge is enabled.
*/
final class DataHandlerSsrPurge
{
private const MEGAMENU_PREFIX = 'tx_evomegamenujson_';
private const NAV_FIELDS =['title', 'nav_title', 'slug', 'hidden', 'nav_hide', 'doktype', 'fe_group', 'starttime', 'endtime'];
/** @var array<string,true> */
private static array $queue = [];
public function processDatamap_afterDatabaseOperations(
string $status,
string $table,
$id,
array $fieldArray,
DataHandler $dataHandler
): void {
if (!$this->isActive()) {
return;
}
if ($table === 'pages') {
if (array_intersect(array_keys($fieldArray), self::NAV_FIELDS) !== []) {
self::$queue['*'] = true;
return;
}
$pageId = $this->realId($id, $dataHandler);
if ($pageId > 0) {
$this->queuePagePath($pageId);
}
return;
}
if ($table === 'tt_content') {
$recordId = $this->realId($id, $dataHandler);
if ($this->isMegamenuElement($recordId)) {
self::$queue['*'] = true;
return;
}
$pid = $fieldArray['pid'] ?? null;
if (!is_numeric($pid) || (int)$pid <= 0) {
$pid = $this->pidOfRecord($table, $recordId);
}
if ((int)$pid > 0) {
$this->queuePagePath((int)$pid);
}
return;
}
if (str_starts_with($table, self::MEGAMENU_PREFIX)) {
self::$queue['*'] = true;
return;
}
if (str_starts_with($table, 'tx_news_') || str_starts_with($table, 'tx_vitec_')) {
$paths = GeneralUtility::makeInstance(SsrPurgeTargetResolver::class)
->resolve($table, $this->realId($id, $dataHandler), $status === 'new' ? ['pid'] : array_keys($fieldArray));
foreach ($paths as $path) {
self::$queue[$path] = true;
}
}
}
public function processDatamap_afterAllOperations(DataHandler $dataHandler): void
{
$this->flush('editor-save');
}
public function processCmdmap_postProcess(
string $command,
string $table,
$id,
$value,
DataHandler $dataHandler
): void {
if (!$this->isActive()) {
return;
}
if ($table === 'pages') {
// Delete/move/copy of a page changes menus everywhere.
self::$queue['*'] = true;
return;
}
if ($table === 'tt_content') {
if ($this->isMegamenuElement((int)$id)) {
self::$queue['*'] = true;
return;
}
$pid = $this->pidOfRecord($table, (int)$id);
if ($pid > 0) {
$this->queuePagePath($pid);
}
return;
}
if (str_starts_with($table, 'tx_news_') || str_starts_with($table, 'tx_vitec_')
|| str_starts_with($table, self::MEGAMENU_PREFIX)) {
// Delete, move, copy, undelete: lists change everywhere.
self::$queue['*'] = true;
}
}
/**
* The megamenu element (CType evomegamenujson_megamenu) renders on
* every page through the site setting megamenu.contentUid.
*/
private function isMegamenuElement(int $uid): bool
{
if ($uid <= 0) {
return false;
}
try {
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('tt_content');
$qb->getRestrictions()->removeAll();
$cType = $qb->select('CType')->from('tt_content')
->where($qb->expr()->eq('uid', $qb->createNamedParameter($uid, \Doctrine\DBAL\ParameterType::INTEGER)))
->executeQuery()->fetchOne();
return $cType === 'evomegamenujson_megamenu';
} catch (\Throwable) {
return false;
}
}
public function processCmdmap_afterFinish(DataHandler $dataHandler): void
{
$this->flush('editor-command');
}
private function flush(string $trigger): void
{
if (self::$queue === []) {
return;
}
$paths = isset(self::$queue['*']) ? ['*'] : array_map('strval', array_keys(self::$queue));
self::$queue = [];
GeneralUtility::makeInstance(SsrPurgeService::class)->purgeQuietly($paths, $trigger);
}
private function isActive(): bool
{
if (!file_exists(Environment::getVarPath() . '/vitec-ssr-on.flag')) {
return false;
}
try {
foreach (GeneralUtility::makeInstance(SiteFinder::class)->getAllSites() as $site) {
if ($site->getSettings()->get('vitec.ssr.autoPurge', true)) {
return true;
}
}
} catch (\Throwable) {
return false;
}
return false;
}
private function queuePagePath(int $pageId): void
{
try {
$site = GeneralUtility::makeInstance(SiteFinder::class)->getSiteByPageId($pageId);
$uri = $site->getRouter()->generateUri($pageId);
$path = $uri->getPath() ?: '/';
self::$queue[$path] = true;
} catch (\Throwable) {
// Page outside a site (folders etc.) - nothing cached for it.
}
}
private function realId($id, DataHandler $dataHandler): int
{
if (is_numeric($id)) {
return (int)$id;
}
return (int)($dataHandler->substNEWwithIDs[$id] ?? 0);
}
private function pidOfRecord(string $table, int $uid): int
{
if ($uid <= 0) {
return 0;
}
try {
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
$qb->getRestrictions()->removeAll();
$pid = $qb->select('pid')->from($table)
->where($qb->expr()->eq('uid', $qb->createNamedParameter($uid, \Doctrine\DBAL\ParameterType::INTEGER)))
->executeQuery()->fetchOne();
return (int)$pid;
} catch (\Throwable) {
return 0;
}
}
}
@@ -85,15 +85,57 @@ final class SubmarketWorkbookImporter
throw new \RuntimeException('Meta sheet missing - not a generated sub-market workbook?');
}
// Meta sheets can be stale template copies (2026-10-05: all 24 shared
// record 32; 2026-10-28: "Broadcast & Media.xlsx" still shipped the
// complete Corporate meta block). The FILENAME names the agency's
// actual intent - when it disagrees with the sheet, trust it, drop
// the copied record link and re-resolve the record by title.
$fileTitle = (string)preg_replace('/\.xlsx$/i', '', basename($file));
if ($this->norm($fileTitle) !== $this->norm($submarket)) {
$this->report[] = 'META REPAIR: sheet says "' . $submarket . '" (group "' . $group . '", record ' . $recordUid
. ') - stale template copy, using filename "' . $fileTitle . '"';
$submarket = $fileTitle;
$group = '';
$recordUid = 0;
foreach (GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tx_vitec_domain_model_market')
->fetchAllAssociative('SELECT uid, title FROM tx_vitec_domain_model_market WHERE deleted = 0') as $marketRow) {
if ($this->norm((string)$marketRow['title']) === $this->norm($submarket)) {
$recordUid = $recordUid === 0 ? (int)$marketRow['uid'] : -1; // -1 = ambiguous
}
}
$recordUid = max(0, $recordUid);
if ($recordUid > 0) {
$this->report[] = 'market record re-resolved by title: #' . $recordUid;
}
}
// --- resolve pages ---------------------------------------------------
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages');
$marketsRoot = $connection->fetchAssociative("SELECT uid FROM pages WHERE deleted = 0 AND slug = '/markets'");
if (!$marketsRoot) {
throw new \RuntimeException('Page /markets not found.');
}
$mainPage = $this->childByTitle((int)$marketsRoot['uid'], $group);
$mainPage = $group !== '' ? $this->childByTitle((int)$marketsRoot['uid'], $group) : null;
if ($mainPage === null) {
throw new \RuntimeException('Main market page "' . $group . '" not found below /markets.');
// repaired meta (or unknown group): locate the ONE main market
// that already has a sub-page with this title
$matches = [];
foreach ($connection->fetchAllAssociative(
'SELECT uid, title FROM pages WHERE deleted = 0 AND pid = ? AND sys_language_uid IN (0,-1)',
[(int)$marketsRoot['uid']]
) as $mainRow) {
if ($this->childByTitle((int)$mainRow['uid'], $submarket) !== null) {
$matches[] = $mainRow;
}
}
if (count($matches) === 1) {
$mainPage = $this->childByTitle((int)$marketsRoot['uid'], (string)$matches[0]['title']);
$this->report[] = 'group resolved via page tree: "' . $matches[0]['title'] . '"';
}
}
if ($mainPage === null) {
throw new \RuntimeException('Main market page "' . $group . '" not found below /markets'
. ($group === '' ? ' (and no main market has a sub-page titled "' . $submarket . '")' : '') . '.');
}
$subPage = $this->childByTitle((int)$mainPage['uid'], $submarket);
@@ -0,0 +1,184 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Http\NormalizedParams;
use TYPO3\CMS\Core\Http\Response;
use TYPO3\CMS\Core\RateLimiter\RateLimiterFactory;
/**
* Client error endpoint: POST /api/vitec/client-error
*
* The React frontend reports browser-side failures here (window.onerror,
* unhandledrejection, chunk reloads after a deploy, element error
* boundaries) - contract 0072-FE section 4, accepted in 0077-BE:
*
* {url, message, stack, release, userAgent} -> 204 No Content
*
* Guards, all answered with a 4xx and no body:
* 405 method other than POST
* 413 body larger than 8 KB (Content-Length or actual bytes)
* 400 not a JSON object, or "message" missing/empty
* 429 more than 30 reports per minute from one IP (sliding window
* via TYPO3's rate limiter, cache "ratelimiter")
*
* Entries land in tx_vitec_clienterrorlog with the IP anonymized (IPv4
* last octet zeroed, IPv6 cut to /48); the full IP is only the key of
* the rate limiter and is never stored. No session, no cookies - a
* navigator.sendBeacon() call must get through unchanged. Rows older
* than 30 days are removed by vitec:client-error-cleanup.
*/
final class ClientErrorMiddleware implements MiddlewareInterface
{
private const PATH = '/api/vitec/client-error';
private const MAX_BODY_BYTES = 8192;
private const LIMIT_PER_MINUTE = 30;
private const TABLE = 'tx_vitec_clienterrorlog';
public function __construct(
private readonly ConnectionPool $connectionPool,
private readonly RateLimiterFactory $rateLimiterFactory,
) {}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$path = rtrim($request->getUri()->getPath(), '/');
if ($path !== self::PATH) {
return $handler->handle($request);
}
$method = strtoupper($request->getMethod());
if ($method === 'OPTIONS') {
return $this->empty(204)->withHeader('Allow', 'POST, OPTIONS');
}
if ($method !== 'POST') {
return $this->empty(405)->withHeader('Allow', 'POST, OPTIONS');
}
$declared = (int)$request->getHeaderLine('Content-Length');
if ($declared > self::MAX_BODY_BYTES) {
return $this->empty(413);
}
// Rate limit first, so a flood of oversized or broken bodies is
// cheap to refuse as well.
$ip = '';
try {
$normalized = $request->getAttribute('normalizedParams') ?? NormalizedParams::createFromRequest($request);
$ip = (string)$normalized->getRemoteAddress();
$limiter = $this->rateLimiterFactory->createLimiter([
'id' => 'vitec-client-error',
'policy' => 'sliding_window',
'limit' => self::LIMIT_PER_MINUTE,
'interval' => '1 minute',
], $ip);
$limit = $limiter->consume(1);
if (!$limit->isAccepted()) {
$retry = max(1, $limit->getRetryAfter()->getTimestamp() - time());
return $this->empty(429)->withHeader('Retry-After', (string)$retry);
}
} catch (\Throwable) {
// A broken limiter must not take the endpoint down; the
// body limit and the 30-day cleanup still bound the damage.
}
$body = $request->getBody();
if ($body->isSeekable()) {
$body->rewind();
}
$raw = $body->read(self::MAX_BODY_BYTES + 1);
if (strlen($raw) > self::MAX_BODY_BYTES) {
return $this->empty(413);
}
try {
$data = json_decode($raw, true, 32, JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return $this->empty(400);
}
if (!\is_array($data) || array_is_list($data)) {
return $this->empty(400);
}
$message = trim($this->str($data['message'] ?? ''));
if ($message === '') {
return $this->empty(400);
}
$row = [
'pid' => 0,
'crdate' => time(),
'url' => $this->cut($this->str($data['url'] ?? ''), 1024),
'message' => $this->cut($message, 2000),
'stack' => $this->cut($this->str($data['stack'] ?? ''), 6000),
'release' => $this->cut($this->str($data['release'] ?? ''), 100),
'user_agent' => $this->cut($this->str($data['userAgent'] ?? $request->getHeaderLine('User-Agent')), 500),
'ip' => self::anonymizeIp($ip),
];
try {
$this->connectionPool->getConnectionForTable(self::TABLE)->insert(self::TABLE, $row);
} catch (\Throwable) {
// Table missing (extension:setup not run yet) or DB hiccup:
// the client gets its 204 anyway - a reporting endpoint must
// never turn into a second error in the browser console.
}
return $this->empty(204);
}
/**
* IPv4: last octet zeroed (192.0.2.0). IPv6: first three groups
* kept (2001:db8:1::). Anything else: empty.
*/
public static function anonymizeIp(string $ip): string
{
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) {
$parts = explode('.', $ip);
$parts[3] = '0';
return implode('.', $parts);
}
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) {
$packed = inet_pton($ip);
if ($packed === false) {
return '';
}
$hex = bin2hex($packed);
$groups = [];
for ($i = 0; $i < 3; $i++) {
$groups[] = ltrim(substr($hex, $i * 4, 4), '0') ?: '0';
}
return implode(':', $groups) . '::';
}
return '';
}
private function str(mixed $value): string
{
if (\is_string($value)) {
return $value;
}
if (\is_int($value) || \is_float($value) || \is_bool($value)) {
return (string)$value;
}
return '';
}
private function cut(string $value, int $max): string
{
$value = str_replace("\0", '', $value);
return mb_strlen($value) > $max ? mb_substr($value, 0, $max) : $value;
}
private function empty(int $status): ResponseInterface
{
return (new Response())
->withStatus($status)
->withHeader('Cache-Control', 'no-store');
}
}
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use TYPO3\CMS\Core\Http\Response;
/**
* Normalizes client caching for the headless JSON API (/_json):
* a single "Cache-Control: no-cache" plus a content ETag, answering
* If-None-Match with an empty 304 so browsers revalidate instead of
* re-downloading (tickets 0011-FE / 0055-BE).
*
* "Expires: 0" is set on purpose: mod_expires leaves responses alone
* that already carry an Expires header, otherwise it would re-add
* max-age/Expires on top of ours (public/.htaccess ExpiresByType).
*
* Logged-in visitors (fe_typo_user cookie) keep TYPO3's own headers:
* their responses may be personalized and must not be eligible for
* shared revalidation.
*/
final class JsonCacheHeaders implements MiddlewareInterface
{
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$response = $handler->handle($request);
$path = $request->getUri()->getPath();
if (
!\in_array($request->getMethod(), ['GET', 'HEAD'], true)
|| $response->getStatusCode() !== 200
|| ($path !== '/_json' && !str_starts_with($path, '/_json/'))
|| isset($request->getCookieParams()['fe_typo_user'])
) {
return $response;
}
$etag = '"' . md5((string)$response->getBody()) . '"';
// mod_deflate suffixes the ETag it compressed ("<md5>-gzip");
// browsers send that form back, so accept it too (0054-FE 5.3).
$ifNoneMatch = str_replace('-gzip"', '"', trim($request->getHeaderLine('If-None-Match')));
if ($ifNoneMatch !== '' && str_contains($ifNoneMatch, $etag)) {
return (new Response())
->withStatus(304)
->withHeader('ETag', $etag)
->withHeader('Cache-Control', 'no-cache')
->withHeader('Expires', '0');
}
return $response
->withHeader('Cache-Control', 'no-cache')
->withoutHeader('Pragma')
->withHeader('Expires', '0')
->withHeader('ETag', $etag);
}
}
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Middleware;
use Evomedien\Vitec\Service\SsrFlagService;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
/**
* Self-healing sync of the SSR routing flag (see SsrFlagService).
*
* Registered for BOTH stacks: backend, so flipping the Site Set switch
* takes effect with the very next backend request (the module reload
* right after saving), and frontend, so /_json traffic repairs the flag
* even if nobody is logged into the backend. When flag and setting
* agree - the normal case - this costs a single file_exists() per
* request.
*/
final class SyncSsrFlag implements MiddlewareInterface
{
public function __construct(private readonly SsrFlagService $ssrFlagService)
{
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$this->ssrFlagService->syncQuietly();
return $handler->handle($request);
}
}
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Seo;
/**
* Splits editor-pasted schema.org JSON blocks out of RTE HTML.
*
* The SEO-maintained news articles carry a raw FAQPage JSON object in a
* trailing <p>…</p> of their bodytext (found 2026-11-10: 17 records). Left
* in place, the frontend renders the JSON as visible text while Google
* never sees it as structured data. This helper removes such paragraphs
* from the HTML and returns the decoded nodes, ready to join the page's
* JSON-LD @graph (their own @context is dropped - the graph carries one).
*
* Fail-safe: a paragraph that merely looks like JSON but does not decode
* to a schema.org node stays in the HTML untouched.
*/
final class EmbeddedJsonLd
{
/**
* @return array{html: string, nodes: list<array<string,mixed>>}
*/
public static function extract(string $html): array
{
if ($html === '' || !str_contains($html, 'schema.org')) {
return ['html' => $html, 'nodes' => []];
}
$nodes = [];
$clean = preg_replace_callback(
'#<p[^>]*>\s*(\{.+?\})\s*</p>#s',
static function (array $match) use (&$nodes): string {
$candidate = $match[1];
if (!str_contains($candidate, 'schema.org')) {
return $match[0];
}
$decoded = json_decode($candidate, true);
if (!is_array($decoded)) {
// RTE processing may have entity-encoded the quotes.
$decoded = json_decode(html_entity_decode($candidate, ENT_QUOTES | ENT_HTML5), true);
}
if (!is_array($decoded) || empty($decoded['@type'])) {
return $match[0];
}
unset($decoded['@context']);
$nodes[] = $decoded;
return '';
},
$html
) ?? $html;
return ['html' => trim($clean), 'nodes' => $nodes];
}
}
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Seo;
use TYPO3\CMS\Seo\XmlSitemap\RecordsXmlSitemapDataProvider;
/**
* Success-story sitemap entries with their PUBLIC URLs.
*
* The route enhancer generates /success-stories/story/<slug>, but the
* public canonical URL is /success-stories/<slug> (SuccessStoryPathRewrite
* middleware, old-site SEO URLs). The sitemap must list the canonical
* form, so the generated loc is rewritten accordingly.
*/
final class StoriesXmlSitemapDataProvider extends RecordsXmlSitemapDataProvider
{
protected function defineUrl(array $data): array
{
$data = parent::defineUrl($data);
if (isset($data['loc']) && is_string($data['loc'])) {
$data['loc'] = str_replace('/success-stories/story/', '/success-stories/', $data['loc']);
}
return $data;
}
}
+457 -9
View File
@@ -4,20 +4,34 @@ declare(strict_types=1);
namespace Evomedien\Vitec\Seo;
use Doctrine\DBAL\ParameterType;
use FriendsOfTYPO3\Headless\Seo\MetaHandler;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Routing\PageArguments;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Mirrors the real page <title> into `meta.title` of the page JSON.
* Completes the page JSON's `seo` block for the headless frontend.
*
* 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.
* On top of the stock headless MetaHandler this adds (SEO audit 2026-11-09):
*
* 1. Record-aware metadata: news, product and success-story detail pages
* take title, meta description and og:image from the routed record -
* the shell page itself carries none of that (news details rendered
* as <title>article</title> before this).
* 2. A uniform "| VITEC" title suffix wherever the brand is missing.
* 3. Fallback meta description from the page record (description/abstract).
* 4. og:title / og:description / og:image plus the twitter:* counterparts
* whenever they are missing; twitter:card upgrades to
* summary_large_image once an image exists.
* 5. Canonical rewrite for success stories: the public URL is
* /success-stories/<slug>, not the internal /success-stories/story/<slug>
* (see SuccessStoryPathRewrite middleware).
* 6. Mirrors the final page <title> into `meta.title` (pre-existing: the
* TypoScript lib.meta renders before the record providers run).
*
* Exception-safe: any failure keeps the stock result untouched.
*/
class VitecMetaHandler extends MetaHandler
{
@@ -25,6 +39,12 @@ class VitecMetaHandler extends MetaHandler
{
$content = parent::process($request, $content);
try {
$content = $this->enrich($request, $content);
} catch (\Throwable $e) {
// keep stock output on any failure
}
$title = trim((string)($content['seo']['title'] ?? ''));
if ($title !== '' && is_array($content['meta'] ?? null)) {
$content['meta']['title'] = $title;
@@ -32,4 +52,432 @@ class VitecMetaHandler extends MetaHandler
return $content;
}
/**
* @param array<string,mixed> $content
* @return array<string,mixed>
*/
private function enrich(ServerRequestInterface $request, array $content): array
{
$base = $this->resolveBase($request);
$args = $this->routeArguments($request);
$pageInfo = $request->getAttribute('frontend.page.information');
$page = $pageInfo !== null ? $pageInfo->getPageRecord() : [];
$title = trim((string)($content['seo']['title'] ?? ''));
$description = '';
$image = '';
$canonicalPath = '';
// --- record context -------------------------------------------------
$newsUid = (int)($args['tx_news_pi1']['news'] ?? 0);
$usecaseUid = (int)($args['tx_vitec_usecaseshow']['usecase'] ?? 0);
$productArg = (string)($args['tx_vitec_productshow']['product'] ?? '');
if ($newsUid > 0) {
$news = $this->fetchRow('tx_news_domain_model_news', ['uid' => $newsUid]);
if ($news !== null) {
$recordTitle = trim((string)($news['alternative_title'] ?? ''));
if ($recordTitle === '') {
$recordTitle = trim((string)($news['title'] ?? ''));
}
if ($recordTitle !== '') {
$title = $recordTitle;
}
$description = $this->plainText((string)($news['teaser'] ?? ''));
if ($description === '') {
$description = $this->plainText(
EmbeddedJsonLd::extract((string)($news['bodytext'] ?? ''))['html']
);
}
$image = $this->firstImage('tx_news_domain_model_news', 'fal_media', $newsUid);
// Same text as a success story: the story is the original.
$storyUid = (int)($news['vitec_canonical_usecase'] ?? 0);
if ($storyUid > 0) {
$story = $this->fetchRow('tx_vitec_domain_model_usecase', ['uid' => $storyUid]);
$storySlug = trim((string)($story['slug'] ?? ''));
if ($storySlug !== '') {
$canonicalPath = '/success-stories/' . $storySlug;
}
}
}
} elseif ($productArg !== '') {
$product = is_numeric($productArg)
? $this->fetchRow('tx_vitec_domain_model_product', ['uid' => (int)$productArg])
: $this->fetchRow('tx_vitec_domain_model_product', ['slug' => $productArg]);
if ($product !== null) {
$description = trim((string)($product['seometa'] ?? ''));
if ($description === '') {
$description = $this->plainText((string)($product['teaser'] ?? ''));
}
$productUid = (int)$product['uid'];
$image = $this->firstImage('tx_vitec_domain_model_product', 'ogimage', $productUid);
if ($image === '') {
$image = $this->firstImage('tx_vitec_domain_model_product', 'productimage', $productUid);
}
}
} elseif ($usecaseUid > 0) {
$usecase = $this->fetchRow('tx_vitec_domain_model_usecase', ['uid' => $usecaseUid]);
if ($usecase !== null) {
$recordTitle = trim((string)($usecase['seo_title'] ?? ''));
if ($recordTitle !== '') {
$title = $recordTitle;
}
// "Betfred HQ | VITEC" is too short to say what the page is.
$storyCore = trim((string)preg_replace('/\s*[|\x{2013}\-]\s*vitec\s*$/iu', '', $title));
if ($storyCore !== '' && mb_strlen($storyCore) < 30 && mb_stripos($storyCore, 'success story') === false) {
$title = $storyCore . ' | Success Story';
}
$description = trim((string)($usecase['seo_description'] ?? ''));
if ($description === '') {
$description = trim((string)($usecase['og_description'] ?? ''));
}
if ($description === '') {
$description = $this->plainText((string)($usecase['teaser'] ?? ''));
}
$image = $this->firstImage('tx_vitec_domain_model_usecase', 'og_image', $usecaseUid);
if ($image === '') {
$image = $this->firstImage('tx_vitec_domain_model_usecase', 'card_image', $usecaseUid);
}
if ($image === '') {
$image = $this->firstImage('tx_vitec_domain_model_usecase', 'hero_bgimage', $usecaseUid);
}
}
}
// --- fallbacks from the page record ----------------------------------
if ($description === '') {
$description = trim((string)($page['description'] ?? ''));
if ($description === '') {
$description = trim((string)($page['abstract'] ?? ''));
}
}
if ($image === '' && !empty($page['uid'])) {
$image = $this->firstImage('pages', 'media', (int)$page['uid']);
}
// --- short page titles get their section as context -------------------
// "Aligo | VITEC" (Seobility: 456 titles "zu kurz") becomes
// "Aligo | AV over IP & KVM | VITEC"; only for plain pages without an
// editor-set SEO title, never for record detail pages.
// An SEO title that merely repeats the page title (the import copied
// title -> seo_title) counts as "not maintained".
$seoTitle = trim((string)($page['seo_title'] ?? ''));
$pageTitle = trim((string)($page['title'] ?? ''));
// "Aligo | VITEC" as seo_title is the import pattern, not editor work.
$seoCore = trim((string)preg_replace('/\s*[|\x{2013}\-]\s*vitec\s*$/iu', '', $seoTitle));
$seoTitleMaintained = $seoCore !== '' && mb_strtolower($seoCore) !== mb_strtolower($pageTitle);
if ($newsUid === 0 && $productArg === '' && $usecaseUid === 0
&& $pageTitle !== '' && mb_strlen($pageTitle) < 30
&& !$seoTitleMaintained
) {
$parentTitle = $this->parentTitle($pageInfo);
if ($parentTitle !== '' && mb_stripos($pageTitle, $parentTitle) === false) {
$title = $pageTitle . ' | ' . $parentTitle;
}
}
// --- uniform brand suffix ---------------------------------------------
if ($title !== '' && mb_stripos($title, 'vitec') === false) {
$title .= ' | VITEC';
}
if ($title !== '') {
$content['seo']['title'] = $title;
}
// --- compose meta list ------------------------------------------------
$meta = is_array($content['seo']['meta'] ?? null) ? $content['seo']['meta'] : [];
// ~155 characters is what search engines show (Seobility: 527 "zu lang").
$description = $this->crop($description, 155);
$absImage = $image !== '' ? $this->absolute($image, $base) : '';
if ($description !== '' && !$this->hasMeta($meta, 'name', 'description')) {
$meta[] = ['name' => 'description', 'content' => $description];
}
// Editorial descriptions already in the list get the same length cap.
foreach ($meta as $index => $entry) {
if (($entry['name'] ?? '') === 'description' && is_string($entry['content'] ?? null)) {
$meta[$index]['content'] = $this->crop(trim($entry['content']), 155);
}
}
$finalDescription = $this->metaContent($meta, 'name', 'description');
if ($finalDescription === '') {
$finalDescription = $description;
}
if ($title !== '' && !$this->hasMeta($meta, 'property', 'og:title')) {
$meta[] = ['property' => 'og:title', 'content' => $title];
}
if ($finalDescription !== '' && !$this->hasMeta($meta, 'property', 'og:description')) {
$meta[] = ['property' => 'og:description', 'content' => $finalDescription];
}
if ($absImage !== '' && !$this->hasMeta($meta, 'property', 'og:image')) {
$meta[] = ['property' => 'og:image', 'content' => $absImage];
}
if ($title !== '' && !$this->hasMeta($meta, 'name', 'twitter:title')) {
$meta[] = ['name' => 'twitter:title', 'content' => $title];
}
if ($finalDescription !== '' && !$this->hasMeta($meta, 'name', 'twitter:description')) {
$meta[] = ['name' => 'twitter:description', 'content' => $finalDescription];
}
$finalImage = $this->metaContent($meta, 'property', 'og:image');
if ($finalImage !== '' && !$this->hasMeta($meta, 'name', 'twitter:image')) {
$meta[] = ['name' => 'twitter:image', 'content' => $finalImage];
}
if ($finalImage !== '') {
$hasCard = false;
foreach ($meta as $index => $entry) {
if (($entry['name'] ?? '') === 'twitter:card') {
$meta[$index]['content'] = 'summary_large_image';
$hasCard = true;
}
}
if (!$hasCard) {
$meta[] = ['name' => 'twitter:card', 'content' => 'summary_large_image'];
}
}
$content['seo']['meta'] = $meta;
// --- success-story canonical: public short URL ------------------------
if (is_array($content['seo']['link'] ?? null)) {
foreach ($content['seo']['link'] as $index => $link) {
if (isset($link['href']) && is_string($link['href'])) {
$content['seo']['link'][$index]['href'] =
str_replace('/success-stories/story/', '/success-stories/', $link['href']);
}
}
}
// --- news article whose canonical is a success story --------------------
if ($canonicalPath !== '') {
$href = $this->absolute($canonicalPath, $base);
$links = is_array($content['seo']['link'] ?? null) ? $content['seo']['link'] : [];
$replaced = false;
foreach ($links as $index => $link) {
if (($link['rel'] ?? '') === 'canonical') {
$links[$index]['href'] = $href;
$replaced = true;
}
}
if (!$replaced) {
$links[] = ['href' => $href, 'rel' => 'canonical'];
}
$content['seo']['link'] = $links;
}
// --- paginated / filtered list pages: self-referencing canonical --------
// /insights/news?page=2 pointed at page 1, which marks every further
// page as a duplicate and weakens the links to its articles (0100-FE).
if ($canonicalPath === '' && $newsUid === 0 && $productArg === '' && $usecaseUid === 0) {
$query = $request->getQueryParams();
$keep = [];
foreach (['category', 'page'] as $key) {
$value = $query[$key] ?? null;
if (!is_scalar($value) || trim((string)$value) === '') {
continue;
}
if ($key === 'page' && (int)$value <= 1) {
continue;
}
$keep[$key] = trim((string)$value);
}
if ($keep !== [] && is_array($content['seo']['link'] ?? null)) {
foreach ($content['seo']['link'] as $index => $link) {
if (($link['rel'] ?? '') === 'canonical' && is_string($link['href'] ?? null)) {
$content['seo']['link'][$index]['href'] =
strtok($link['href'], '?') . '?' . http_build_query($keep);
}
}
}
// Self-canonical pages need their own title/description, otherwise
// they count as duplicates of page 1 ("VITEC News and Articles" x3).
if ($keep !== []) {
$suffix = [];
if (isset($keep['category'])) {
$suffix[] = ucfirst(str_replace('-', ' ', $keep['category']));
}
if (isset($keep['page'])) {
$suffix[] = 'Page ' . (int)$keep['page'];
}
$suffixText = ' – ' . implode(' – ', $suffix);
$currentTitle = trim((string)($content['seo']['title'] ?? ''));
if ($currentTitle !== '') {
$content['seo']['title'] = preg_match('/^(.*?)(\s*\|\s*VITEC)$/i', $currentTitle, $m) === 1
? $m[1] . $suffixText . $m[2]
: $currentTitle . $suffixText;
}
foreach ($content['seo']['meta'] as $index => $entry) {
if (in_array($entry['name'] ?? '', ['description', 'twitter:description'], true)
|| ($entry['property'] ?? '') === 'og:description'
) {
$content['seo']['meta'][$index]['content'] = rtrim((string)$entry['content'], '. ') . $suffixText . '.';
}
}
}
}
return $content;
}
/**
* Navigation title of the parent page, or '' when the parent is the site
* root, a technical container (user_nav, folders) or missing.
*/
private function parentTitle($pageInfo): string
{
if ($pageInfo === null) {
return '';
}
// Rootline keys are the depth (0 = root), but the array is krsort()ed
// (current page first). Walk up from the parent, skipping structural
// levels (folders, shortcuts, spacers, technical names) until a page
// with a presentable name turns up; the site root never counts.
$rootLine = $pageInfo->getRootLine();
ksort($rootLine);
$byDepth = array_values($rootLine);
for ($i = count($byDepth) - 2; $i >= 1; $i--) {
$candidate = $byDepth[$i];
if (!is_array($candidate)) {
continue;
}
if (in_array((int)($candidate['doktype'] ?? 1), [3, 4, 199, 254, 255], true)) {
continue;
}
$name = trim((string)($candidate['nav_title'] ?? '')) ?: trim((string)($candidate['title'] ?? ''));
if ($name === '' || preg_match('/^user[_ -]?nav$/i', $name) === 1 || str_contains($name, '_')) {
continue;
}
return $name;
}
return '';
}
/**
* Route arguments of the current request (record detail context).
*
* @return array<string,mixed>
*/
private function routeArguments(ServerRequestInterface $request): array
{
$routing = $request->getAttribute('routing');
$args = $routing instanceof PageArguments ? $routing->getArguments() : [];
// Query-parameter fallback (uncached plugin context).
return array_replace_recursive($request->getQueryParams(), $args);
}
/**
* @param array<string,int|string> $where equality constraints
* @return array<string,mixed>|null
*/
private function fetchRow(string $table, array $where): ?array
{
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
$constraints = [
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0),
];
foreach ($where as $field => $value) {
$constraints[] = is_int($value)
? $qb->expr()->eq($field, $qb->createNamedParameter($value, ParameterType::INTEGER))
: $qb->expr()->eq($field, $qb->createNamedParameter((string)$value, ParameterType::STRING));
}
$row = $qb->select('*')->from($table)->where(...$constraints)
->setMaxResults(1)->executeQuery()->fetchAssociative();
return is_array($row) ? $row : null;
}
/**
* Root-relative URL of the first FAL image attached to a record field.
*/
private function firstImage(string $table, string $field, int $uid): string
{
if ($uid <= 0) {
return '';
}
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_file_reference');
$row = $qb
->select('f.identifier')
->from('sys_file_reference', 'fr')
->join('fr', 'sys_file', 'f', 'fr.uid_local = f.uid')
->where(
$qb->expr()->eq('fr.tablenames', $qb->createNamedParameter($table, ParameterType::STRING)),
$qb->expr()->eq('fr.fieldname', $qb->createNamedParameter($field, ParameterType::STRING)),
$qb->expr()->eq('fr.uid_foreign', $qb->createNamedParameter($uid, ParameterType::INTEGER)),
$qb->expr()->eq('fr.deleted', 0),
$qb->expr()->eq('fr.hidden', 0),
$qb->expr()->eq('f.missing', 0)
)
->orderBy('fr.sorting_foreign', 'ASC')
->setMaxResults(1)
->executeQuery()
->fetchAssociative();
$identifier = (string)($row['identifier'] ?? '');
return $identifier !== '' ? '/fileadmin' . $identifier : '';
}
/**
* @param list<array<string,string>> $meta
*/
private function hasMeta(array $meta, string $attribute, string $key): bool
{
return $this->metaContent($meta, $attribute, $key) !== '';
}
/**
* @param list<array<string,string>> $meta
*/
private function metaContent(array $meta, string $attribute, string $key): string
{
foreach ($meta as $entry) {
if (strtolower((string)($entry[$attribute] ?? '')) === $key) {
return trim((string)($entry['content'] ?? ''));
}
}
return '';
}
private function resolveBase(ServerRequestInterface $request): string
{
$normalizedParams = $request->getAttribute('normalizedParams');
if ($normalizedParams !== null) {
return rtrim($normalizedParams->getSiteUrl(), '/');
}
return '';
}
private function absolute(string $url, string $base): string
{
if ($url === '' || preg_match('#^https?://#i', $url) === 1) {
return $url;
}
return $base . '/' . ltrim($url, '/');
}
/**
* Collapse RTE HTML to a single plain-text line.
*/
private function plainText(string $value): string
{
$value = strip_tags($value);
$value = preg_replace('/\s+/u', ' ', $value) ?? $value;
return trim($value);
}
/**
* Crop to $max characters at a word boundary (ellipsis appended).
*/
private function crop(string $value, int $max): string
{
if ($value === '' || mb_strlen($value) <= $max) {
return $value;
}
$cut = mb_substr($value, 0, $max);
$lastSpace = mb_strrpos($cut, ' ');
if ($lastSpace !== false && $lastSpace > (int)($max * 0.6)) {
$cut = mb_substr($cut, 0, $lastSpace);
}
return rtrim($cut, " \t.,;:") . '...';
}
}
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Service;
/**
* Tells test reports and bot reports in tx_vitec_clienterrorlog apart
* from real visitor errors (ticket 0114-FE: the cockpit tile must not
* count smoke tests). One rule set for the module and the dashboard.
*
* Test: sent with curl, a user agent that says "smoke" or "client-error
* test", a message starting with "test", or the dummy release "t" of the
* endpoint smoke test. Bot: a crawler user agent (Googlebot & Co.); bot
* reports stay real errors, they only get a label.
*/
final class ClientErrorClassifier
{
public static function isTest(string $message, string $userAgent, string $release = ''): bool
{
if ($release === 't') {
return true;
}
if (preg_match('#curl/|smoke|client-error test#i', $userAgent) === 1) {
return true;
}
return preg_match('#^\s*test\b#i', $message) === 1;
}
public static function isBot(string $userAgent): bool
{
return preg_match('#bot\b|crawler|spider|slurp|lighthouse|headlesschrome#i', $userAgent) === 1;
}
public static function isChunkReload(string $message): bool
{
return str_contains(strtolower($message), 'chunk reload');
}
}
@@ -0,0 +1,676 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Service;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Service\FlexFormService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Service\ImageService;
/**
* Page content audit for the market and solution trees (sibling of
* ProductCheckService, decision Harry 2026-11-13): which market / sub-market /
* solution-group / sub-solution PAGE carries which content, and the full
* content of one page for the detail pane.
*
* Unlike products these are pages built from content elements, so the audit
* looks at the elements in page order (hero, intro, cards, testimonials,
* products, insights, logos, CTA banner), at the page properties (meta
* description, OG image, page media) and at the market / solution record that
* points to the page via detail_page.
*
* Scopes: 'markets' = children of /markets (11 markets, 24 sub-markets)
* 'solutions' = children of /solutions (8 groups, 39 sub-solutions)
*
* Columns carry an `applies` list - a column a page type does not have (e.g.
* logos on a sub-market, insights on a main market) is delivered as
* {ok: null, count: 0} and counted neither as filled nor as applicable.
*
* Placeholders (Harry 2026-11-13): files below fileadmin/placeholders/ (or
* named "...placeholder...") are grey dummies that must not pass as finished.
* Image cells carry `placeholder` = number of such files when there is at
* least one; summary.filled counts only cells without placeholders, and
* summary.placeholder counts the cells that have one. Detail images carry
* `placeholder: true|false`. `ok` itself is unchanged (something is attached).
*
* Read fresh on every call, nothing cached (USER_INT in the renderer).
*/
final class PageCheckService
{
/** SQL condition on sys_file f that marks a placeholder file. */
public const PLACEHOLDER_SQL = "(f.identifier LIKE '/placeholders/%' OR LOWER(f.name) LIKE '%placeholder%')";
public static function isPlaceholder(string $identifier, string $name): bool
{
return str_starts_with($identifier, '/placeholders/') || str_contains(strtolower($name), 'placeholder');
}
public const SCOPES = [
'markets' => [
'label' => 'Markets',
'rootSlug' => '/markets',
'types' => ['market', 'submarket'],
'recordTable' => 'tx_vitec_domain_model_market',
],
'solutions' => [
'label' => 'Solutions',
'rootSlug' => '/solutions',
'types' => ['solution', 'subsolution'],
'recordTable' => 'tx_vitec_domain_model_solution',
],
];
private const ALL = ['market', 'submarket', 'solution', 'subsolution'];
/**
* Columns in display order. kind as in the product check (images / video /
* text / features / list), applies = page types that have the column.
*/
private const FIELDS = [
['key' => 'hero', 'label' => 'Hero image', 'kind' => 'images', 'applies' => self::ALL],
['key' => 'subline', 'label' => 'Hero subline', 'kind' => 'text', 'applies' => self::ALL],
['key' => 'lead', 'label' => 'Lead', 'kind' => 'text', 'applies' => ['solution', 'subsolution']],
['key' => 'intro', 'label' => 'Intro text', 'kind' => 'text', 'applies' => self::ALL],
['key' => 'introImage', 'label' => 'Intro image', 'kind' => 'images', 'applies' => self::ALL],
['key' => 'introCta', 'label' => 'Intro CTA', 'kind' => 'text', 'applies' => self::ALL],
['key' => 'cards', 'label' => 'Cards', 'kind' => 'features', 'applies' => self::ALL],
['key' => 'stories', 'label' => 'Testimonials', 'kind' => 'list', 'applies' => self::ALL],
['key' => 'products', 'label' => 'Products', 'kind' => 'list', 'applies' => ['submarket', 'subsolution']],
['key' => 'insights', 'label' => 'Insights text', 'kind' => 'text', 'applies' => ['submarket', 'solution', 'subsolution']],
['key' => 'insightsCover', 'label' => 'Insights cover', 'kind' => 'images', 'applies' => ['submarket', 'solution', 'subsolution']],
['key' => 'insightsCta', 'label' => 'Insights CTA', 'kind' => 'text', 'applies' => ['submarket', 'solution', 'subsolution']],
['key' => 'logos', 'label' => 'Logos', 'kind' => 'list', 'applies' => ['market']],
['key' => 'cta', 'label' => 'CTA banner', 'kind' => 'text', 'applies' => ['market', 'solution']],
['key' => 'seo', 'label' => 'Meta description', 'kind' => 'text', 'applies' => self::ALL],
['key' => 'og', 'label' => 'OG image', 'kind' => 'images', 'applies' => self::ALL],
['key' => 'media', 'label' => 'Page media', 'kind' => 'images', 'applies' => self::ALL],
['key' => 'record', 'label' => 'Record', 'kind' => 'list', 'applies' => self::ALL],
];
private const HERO_COLPOS = [1, 110];
private const CONTAINER_PREFIXES = ['vitec_cols_', 'vitec_container', 'vitec_cards_carousel'];
/** Text fields per element type for the detail pane: column => label. */
private const ELEMENT_TEXTS = [
'vitec_markethero' => ['vitec_subline' => 'Subline', 'vitec_cta_label' => 'CTA label', 'vitec_video_title' => 'Video title'],
'vitec_herosection' => ['header' => 'Header', 'subheader' => 'Subheader', 'bodytext' => 'Text', 'vitec_cta_label' => 'CTA label'],
'vitec_introparagraph' => ['header' => 'Header', 'subheader' => 'Subheader', 'bodytext' => 'Text', 'vitec_button_text' => 'Button'],
'vitec_marketintro' => ['vitec_intro_headline' => 'Headline', 'vitec_intro_text' => 'Text', 'vitec_learnmore_label' => 'Button'],
'vitec_card' => ['vitec_eyebrow' => 'Eyebrow', 'header' => 'Header', 'subheader' => 'Subheader', 'bodytext' => 'Text', 'vitec_back_headline' => 'Back headline', 'vitec_back_text' => 'Back text', 'vitec_primary_cta_label' => 'Button'],
'vitec_usecaselist' => ['header' => 'Header'],
'vitec_customerlogos' => ['header' => 'Header'],
'vitec_marketinsights' => ['vitec_text1' => 'Text 1', 'vitec_text2' => 'Text 2', 'vitec_cta_label' => 'CTA label', 'vitec_video_person' => 'Video person', 'vitec_video_role' => 'Video role', 'vitec_video_title' => 'Video title'],
'vitec_ctabanner' => ['header' => 'Header', 'bodytext' => 'Text', 'vitec_primary_cta_label' => 'Primary button', 'vitec_secondary_cta_label' => 'Secondary button'],
'vitec_productfeature' => ['header' => 'Header'],
'vitec_productlist' => ['header' => 'Header'],
];
/** Link fields per element type: column => label. */
private const ELEMENT_LINKS = [
'vitec_markethero' => ['vitec_cta_link' => 'CTA link', 'vitec_video_url' => 'Video URL'],
'vitec_herosection' => ['vitec_cta' => 'CTA link'],
'vitec_introparagraph' => ['vitec_button_link' => 'Button link'],
'vitec_marketintro' => ['vitec_learnmore_target' => 'Button target'],
'vitec_card' => ['vitec_primary_cta' => 'Button link', 'vitec_card_link' => 'Card link'],
'vitec_marketinsights' => ['vitec_cta_target' => 'CTA target', 'vitec_video_url' => 'Video URL'],
'vitec_ctabanner' => ['vitec_primary_cta' => 'Primary link', 'vitec_secondary_cta' => 'Secondary link'],
];
private const ELEMENT_LABELS = [
'vitec_markethero' => 'Hero',
'vitec_herosection' => 'Hero section',
'vitec_introparagraph' => 'Intro paragraph',
'vitec_marketintro' => 'Intro (text | image)',
'vitec_card' => 'Card',
'vitec_usecaselist' => 'Testimonials',
'vitec_customerlogos' => 'Customer logos',
'vitec_marketinsights' => 'Insights',
'vitec_ctabanner' => 'CTA banner',
'vitec_productfeature' => 'Featured product',
'vitec_productlist' => 'Product list',
];
public function __construct(
private readonly ConnectionPool $connectionPool,
) {}
/** @return array<string, mixed> */
public function overview(string $scope): array
{
$cfg = self::SCOPES[$scope] ?? throw new \InvalidArgumentException('Unknown scope ' . $scope);
$pages = $this->pages($cfg);
$pageUids = array_map(static fn(array $p): int => (int)$p['uid'], $pages);
$elementsByPage = $this->elementsByPage($pageUids);
$allElementUids = [];
foreach ($elementsByPage as $els) {
foreach ($els as $el) {
$allElementUids[] = (int)$el['uid'];
}
}
$elementFiles = $this->referenceCounts('tt_content', $allElementUids);
$elementPlaceholders = $this->referenceCounts('tt_content', $allElementUids, true);
$pageFiles = $this->referenceCounts('pages', $pageUids);
$pagePlaceholders = $this->referenceCounts('pages', $pageUids, true);
$records = $this->records($cfg['recordTable'], $pageUids);
$recordUids = array_map(static fn(array $r): int => (int)$r['uid'], $records);
$recordFiles = $this->referenceCounts($cfg['recordTable'], $recordUids);
$recordPlaceholders = $this->referenceCounts($cfg['recordTable'], $recordUids, true);
// Empty selection = what the testimonial band shows: quoted stories,
// at most twelve (UsecaseListJsonRenderer::testimonialRows).
$allStories = min(12, $this->countAll('tx_vitec_domain_model_usecase', "deleted = 0 AND hidden = 0 AND hideonwebsite = 0 AND TRIM(COALESCE(quote, '')) <> ''"));
$allCustomers = $this->countAll('tx_vitec_domain_model_customer', 'deleted = 0 AND hidden = 0');
$keys = array_column(self::FIELDS, 'key');
$filled = array_fill_keys($keys, 0);
$placeholder = array_fill_keys($keys, 0);
$applicable = array_fill_keys($keys, 0);
$byType = [];
$rows = [];
foreach ($pages as $p) {
$uid = (int)$p['uid'];
$type = (string)$p['_type'];
$byType[$type] = ($byType[$type] ?? 0) + 1;
$els = $elementsByPage[$uid] ?? [];
$visible = array_values(array_filter($els, static fn(array $e): bool => (int)$e['hidden'] === 0));
$hiddenCount = count($els) - count($visible);
$record = $records[$uid] ?? null;
$recordUid = $record ? (int)$record['uid'] : 0;
$raw = $this->pageChecks(
$type,
$visible,
['n' => $elementFiles, 'ph' => $elementPlaceholders],
['n' => $pageFiles[$uid] ?? [], 'ph' => $pagePlaceholders[$uid] ?? []],
$p,
$record,
['n' => $recordFiles[$recordUid] ?? [], 'ph' => $recordPlaceholders[$recordUid] ?? []],
$allStories,
$allCustomers
);
$checks = [];
foreach (self::FIELDS as $field) {
$key = $field['key'];
if (!in_array($type, $field['applies'], true)) {
$checks[$key] = ['ok' => null, 'count' => 0];
continue;
}
$checks[$key] = $raw[$key];
$applicable[$key]++;
if (($raw[$key]['placeholder'] ?? 0) > 0) {
$placeholder[$key]++;
} elseif ($raw[$key]['ok'] === true) {
$filled[$key]++;
}
}
$rows[] = $this->base($p, $type, $record, $hiddenCount) + ['checks' => $checks];
}
return [
'scope' => $scope,
'scopeLabel' => $cfg['label'],
'generatedAt' => date(\DATE_ATOM),
'fields' => self::FIELDS,
'summary' => ['pages' => count($rows), 'byType' => $byType, 'filled' => $filled, 'placeholder' => $placeholder, 'applicable' => $applicable],
'pages' => $rows,
];
}
/** @return array<string, mixed>|null null when the page is not part of any scope */
public function detail(int $uid): ?array
{
foreach (self::SCOPES as $scope => $cfg) {
foreach ($this->pages($cfg) as $p) {
if ((int)$p['uid'] !== $uid) {
continue;
}
$type = (string)$p['_type'];
$records = $this->records($cfg['recordTable'], [$uid]);
$record = $records[$uid] ?? null;
$els = $this->elementsByPage([$uid])[$uid] ?? [];
$hiddenCount = count(array_filter($els, static fn(array $e): bool => (int)$e['hidden'] === 1));
$flexFormService = GeneralUtility::makeInstance(FlexFormService::class);
$elements = [];
foreach ($els as $el) {
$cType = (string)$el['CType'];
$texts = [];
foreach (self::ELEMENT_TEXTS[$cType] ?? ['header' => 'Header', 'bodytext' => 'Text'] as $column => $label) {
$value = (string)($el[$column] ?? '');
$texts[] = ['key' => $column, 'label' => $label, 'ok' => $this->filled($value), 'html' => $value];
}
$links = [];
foreach (self::ELEMENT_LINKS[$cType] ?? [] as $column => $label) {
$value = trim((string)($el[$column] ?? ''));
if ($value !== '') {
$links[] = ['key' => $column, 'label' => $label, 'target' => $value];
}
}
$settings = [];
if (trim((string)($el['pi_flexform'] ?? '')) !== '') {
$settings = $flexFormService->convertFlexFormContentToArray((string)$el['pi_flexform'])['settings'] ?? [];
$settings = array_filter($settings, static fn($v): bool => is_scalar($v) && (string)$v !== '' && (string)$v !== '0');
$settings = array_map(static fn($v): string => (string)$v, $settings);
}
$elements[] = [
'uid' => (int)$el['uid'],
'type' => $cType,
'label' => self::ELEMENT_LABELS[$cType] ?? $cType,
'colPos' => (int)$el['colPos'],
'slot' => in_array((int)$el['colPos'], self::HERO_COLPOS, true) ? 'hero' : 'main',
'hidden' => (int)$el['hidden'] === 1,
'header' => (string)($el['header'] ?? ''),
'layoutVariant' => (string)($el['vitec_layout_variant'] ?? ''),
'texts' => $texts,
'links' => $links,
'images' => $this->files('tt_content', (int)$el['uid']),
'settings' => $settings,
];
}
$children = [];
if ((int)$p['_level'] === 1) {
foreach ($this->pages($cfg) as $c) {
if ((int)$c['pid'] === $uid) {
$children[] = ['uid' => (int)$c['uid'], 'title' => (string)$c['title'], 'url' => (string)$c['slug'], 'hidden' => (int)$c['hidden'] === 1];
}
}
}
return $this->base($p, $type, $record, $hiddenCount) + [
'seo' => [
'title' => (string)$p['title'],
'seoTitle' => (string)($p['seo_title'] ?? ''),
'description' => (string)($p['description'] ?? ''),
'og' => $this->files('pages', $uid, ['og_image']),
],
'media' => $this->files('pages', $uid, ['media']),
'record' => $record === null ? null : [
'uid' => (int)$record['uid'],
'title' => (string)$record['title'],
'subtitle' => (string)($record['subtitle'] ?? ''),
'teaser' => (string)($record['teaser'] ?? ''),
'description' => (string)($record['description'] ?? ''),
'image' => $this->files($cfg['recordTable'], (int)$record['uid'], ['image']),
],
'elements' => $elements,
'children' => $children,
];
}
}
return null;
}
// ---- checks -------------------------------------------------------------
/**
* @param array<int, array<string, mixed>> $visible visible elements in page order
* @param array{n: array<int, array<string, int>>, ph: array<int, array<string, int>>} $elementFiles n/ph: [elementUid][fieldname] = count of all / of placeholder files
* @param array{n: array<string, int>, ph: array<string, int>} $pageFiles [fieldname] = count
* @param array<string, mixed> $page
* @param array<string, mixed>|null $record
* @param array{n: array<string, int>, ph: array<string, int>} $recordFiles
* @return array<string, array{ok: bool, count: int, icons?: int, placeholder?: int}>
*/
private function pageChecks(string $type, array $visible, array $elementFiles, array $pageFiles, array $page, ?array $record, array $recordFiles, int $allStories, int $allCustomers): array
{
$hero = $this->firstOf($visible, 'vitec_markethero') ?? $this->firstOf($visible, 'vitec_herosection');
$heroFiles = $hero ? ($elementFiles['n'][(int)$hero['uid']] ?? []) : [];
$heroPh = $hero ? ($elementFiles['ph'][(int)$hero['uid']] ?? []) : [];
$heroFields = ['vitec_bgimage', 'vitec_hero_bgimage', 'vitec_hero_image'];
$lead = null;
foreach ($visible as $el) {
if ($el['CType'] === 'vitec_introparagraph' && in_array((int)$el['colPos'], self::HERO_COLPOS, true)) {
$lead = $el;
break;
}
}
$intro = $this->firstOf($visible, 'vitec_marketintro');
$introFiles = $intro ? ($elementFiles['n'][(int)$intro['uid']] ?? []) : [];
$introPh = $intro ? ($elementFiles['ph'][(int)$intro['uid']] ?? []) : [];
$cards = array_values(array_filter($visible, static fn(array $e): bool => $e['CType'] === 'vitec_card'));
$cardVisuals = 0;
$cardPlaceholders = 0;
foreach ($cards as $c) {
$f = $elementFiles['n'][(int)$c['uid']] ?? [];
$ph = $elementFiles['ph'][(int)$c['uid']] ?? [];
if (($f['vitec_icon'] ?? 0) > 0 || ($f['vitec_image'] ?? 0) > 0) {
$cardVisuals++;
}
if (($ph['vitec_icon'] ?? 0) > 0 || ($ph['vitec_image'] ?? 0) > 0) {
$cardPlaceholders++;
}
}
$stories = $this->firstOf($visible, 'vitec_usecaselist');
$storyCount = 0;
if ($stories) {
$sel = $this->flexSettings((string)$stories['pi_flexform']);
$selected = GeneralUtility::intExplode(',', (string)($sel['usecases'] ?? ''), true);
$storyCount = $selected === [] ? $allStories : count($selected);
}
$productCount = 0;
$hasProducts = false;
foreach ($visible as $el) {
if ($el['CType'] === 'vitec_productfeature') {
$hasProducts = true;
$productCount += (int)(($this->flexSettings((string)$el['pi_flexform'])['product'] ?? 0) > 0);
} elseif ($el['CType'] === 'vitec_productlist') {
$hasProducts = true;
$sel = $this->flexSettings((string)$el['pi_flexform']);
$productCount += count(GeneralUtility::intExplode(',', (string)($sel['selectedproducts'] ?? ''), true));
}
}
$insights = $this->firstOf($visible, 'vitec_marketinsights');
$insightsFiles = $insights ? ($elementFiles['n'][(int)$insights['uid']] ?? []) : [];
$insightsPh = $insights ? ($elementFiles['ph'][(int)$insights['uid']] ?? []) : [];
$logos = $this->firstOf($visible, 'vitec_customerlogos');
$logoCount = 0;
if ($logos) {
$sel = $this->flexSettings((string)$logos['pi_flexform']);
$selected = GeneralUtility::intExplode(',', (string)($sel['customers'] ?? ''), true);
$logoCount = $selected === [] || ($sel['onlySelected'] ?? '0') !== '1' ? $allCustomers : count($selected);
}
$cta = $this->firstOf($visible, 'vitec_ctabanner');
$recordFilled = 0;
if ($record !== null) {
$recordFilled += (($recordFiles['n']['image'] ?? 0) > 0) ? 1 : 0;
$recordFilled += $this->filled($record['teaser'] ?? '') ? 1 : 0;
$recordFilled += $this->filled($record['description'] ?? '') ? 1 : 0;
}
$sum = static fn(array $counts, array $fields): int => array_sum(array_map(static fn(string $f): int => (int)($counts[$f] ?? 0), $fields));
return [
'hero' => $this->check($sum($heroFiles, $heroFields), $sum($heroPh, $heroFields)),
'subline' => $this->check($hero && ($this->filled($hero['vitec_subline'] ?? '') || $this->filled($hero['subheader'] ?? '')) ? 1 : 0),
'lead' => $this->check($lead && $this->filled($lead['bodytext'] ?? '') ? 1 : 0),
'intro' => $this->check($intro && $this->filled($intro['vitec_intro_text'] ?? '') ? 1 : 0),
'introImage' => $this->check($introFiles['vitec_image'] ?? 0, $introPh['vitec_image'] ?? 0),
'introCta' => $this->check($intro && $this->filled($intro['vitec_learnmore_label'] ?? '') && $this->filled($intro['vitec_learnmore_target'] ?? '') ? 1 : 0),
'cards' => $this->check(count($cards), $cardPlaceholders) + ['icons' => $cardVisuals],
'stories' => ['ok' => $stories !== null, 'count' => $storyCount],
'products' => ['ok' => $hasProducts, 'count' => $productCount],
'insights' => $this->check($insights && $this->filled($insights['vitec_text1'] ?? '') ? 1 : 0),
'insightsCover' => $this->check($insightsFiles['vitec_video_cover'] ?? 0, $insightsPh['vitec_video_cover'] ?? 0),
'insightsCta' => $this->check($insights && $this->filled($insights['vitec_cta_label'] ?? '') && $this->filled($insights['vitec_cta_target'] ?? '') ? 1 : 0),
'logos' => ['ok' => $logos !== null, 'count' => $logoCount],
'cta' => $this->check($cta && $this->filled($cta['vitec_primary_cta_label'] ?? '') ? 1 : 0),
'seo' => $this->check($this->filled($page['description'] ?? '') ? 1 : 0),
'og' => $this->check($pageFiles['n']['og_image'] ?? 0, $pageFiles['ph']['og_image'] ?? 0),
'media' => $this->check($pageFiles['n']['media'] ?? 0, $pageFiles['ph']['media'] ?? 0),
'record' => ['ok' => $record !== null && ($recordFiles['n']['image'] ?? 0) > 0 && $this->filled($record['teaser'] ?? ''), 'count' => $recordFilled]
+ ((($recordFiles['ph']['image'] ?? 0) > 0) ? ['placeholder' => (int)$recordFiles['ph']['image']] : []),
];
}
// ---- building blocks ----------------------------------------------------
/**
* @param array<string, mixed> $p
* @param array<string, mixed>|null $record
*/
private function base(array $p, string $type, ?array $record, int $hiddenElements): array
{
$flags = [];
if ((int)$p['hidden'] === 1) {
$flags[] = 'hidden';
}
if ((int)$p['nav_hide'] === 1) {
$flags[] = 'navHide';
}
if ((int)$p['no_index'] === 1) {
$flags[] = 'noindex';
}
if ($record === null) {
$flags[] = 'noRecord';
}
if ($hiddenElements > 0) {
$flags[] = 'hiddenElements:' . $hiddenElements;
}
return [
'uid' => (int)$p['uid'],
'title' => (string)$p['title'],
'slug' => trim((string)$p['slug'], '/'),
'url' => (string)$p['slug'],
'type' => $type,
'level' => (int)$p['_level'],
'parent' => (int)$p['pid'],
'flags' => $flags,
];
}
/**
* @param int $placeholder number of placeholder files among $count; emitted only when > 0
* @return array{ok: bool, count: int, placeholder?: int}
*/
private function check(int $count, int $placeholder = 0): array
{
return ['ok' => $count > 0, 'count' => $count] + ($placeholder > 0 ? ['placeholder' => $placeholder] : []);
}
private function filled(mixed $value): bool
{
return trim(strip_tags((string)$value)) !== '';
}
/** @param array<int, array<string, mixed>> $elements */
private function firstOf(array $elements, string $cType): ?array
{
foreach ($elements as $el) {
if ($el['CType'] === $cType) {
return $el;
}
}
return null;
}
/** @return array<string, string> */
private function flexSettings(string $xml): array
{
if (trim($xml) === '') {
return [];
}
$settings = GeneralUtility::makeInstance(FlexFormService::class)->convertFlexFormContentToArray($xml)['settings'] ?? [];
return is_array($settings) ? array_map(static fn($v): string => is_scalar($v) ? (string)$v : '', $settings) : [];
}
/**
* The scope's pages: level 1 = children of the root page, level 2 = their
* children. Standard pages only, in tree order; a page carrying the check
* element itself is left out.
*
* @param array{rootSlug: string, types: array<int, string>} $cfg
* @return array<int, array<string, mixed>> rows with _level and _type
*/
private function pages(array $cfg): array
{
$conn = $this->connection('pages');
$root = $conn->fetchAssociative('SELECT uid FROM pages WHERE deleted = 0 AND sys_language_uid = 0 AND slug = ?', [$cfg['rootSlug']]);
if ($root === false) {
return [];
}
$select = 'SELECT uid, pid, title, slug, hidden, nav_hide, no_index, description, seo_title, sorting FROM pages
WHERE deleted = 0 AND sys_language_uid = 0 AND doktype = 1 AND pid IN (%s) ORDER BY pid, sorting';
$level1 = $conn->fetchAllAssociative(sprintf($select, (int)$root['uid']));
$checkPages = $this->pagesWithCheckElement();
$out = [];
foreach ($level1 as $p1) {
if (isset($checkPages[(int)$p1['uid']])) {
continue;
}
$out[] = $p1 + ['_level' => 1, '_type' => $cfg['types'][0]];
$level2 = $conn->fetchAllAssociative(sprintf($select, (int)$p1['uid']));
foreach ($level2 as $p2) {
if (isset($checkPages[(int)$p2['uid']])) {
continue;
}
$out[] = $p2 + ['_level' => 2, '_type' => $cfg['types'][1]];
}
}
return $out;
}
/** @return array<int, true> page uid => true for pages that hold a check element */
private function pagesWithCheckElement(): array
{
$out = [];
foreach ($this->connection('tt_content')->fetchFirstColumn(
"SELECT DISTINCT pid FROM tt_content WHERE deleted = 0 AND CType IN ('vitec_pagecheck', 'vitec_productcheck')"
) as $pid) {
$out[(int)$pid] = true;
}
return $out;
}
/**
* All non-deleted default-language elements of the pages, page order,
* containers themselves left out (their children count as page content).
*
* @param int[] $pageUids
* @return array<int, array<int, array<string, mixed>>> [pageUid] = rows
*/
private function elementsByPage(array $pageUids): array
{
if ($pageUids === []) {
return [];
}
$rows = $this->connection('tt_content')->fetchAllAssociative(
'SELECT * FROM tt_content WHERE deleted = 0 AND sys_language_uid = 0 AND pid IN ('
. implode(',', array_map('intval', $pageUids)) . ') ORDER BY pid, colPos, sorting, uid'
);
// page order: hero slot first, then the main column(s) by colPos and sorting
usort($rows, static function (array $a, array $b): int {
$heroA = in_array((int)$a['colPos'], self::HERO_COLPOS, true) ? 0 : 1;
$heroB = in_array((int)$b['colPos'], self::HERO_COLPOS, true) ? 0 : 1;
return [(int)$a['pid'], $heroA, (int)$a['colPos'], (int)$a['sorting'], (int)$a['uid']]
<=> [(int)$b['pid'], $heroB, (int)$b['colPos'], (int)$b['sorting'], (int)$b['uid']];
});
$out = [];
foreach ($rows as $row) {
$cType = (string)$row['CType'];
foreach (self::CONTAINER_PREFIXES as $prefix) {
if (str_starts_with($cType, $prefix)) {
continue 2;
}
}
$out[(int)$row['pid']][] = $row;
}
return $out;
}
/**
* Visible file references per record and field.
*
* @param int[] $uids
* @return array<int, array<string, int>> [uid][fieldname] = count
*/
private function referenceCounts(string $table, array $uids, bool $placeholdersOnly = false): array
{
if ($uids === []) {
return [];
}
$in = implode(',', array_map('intval', $uids));
$sql = $placeholdersOnly
? 'SELECT r.uid_foreign, r.fieldname, COUNT(*) AS n FROM sys_file_reference r JOIN sys_file f ON f.uid = r.uid_local
WHERE r.deleted = 0 AND r.hidden = 0 AND r.tablenames = ? AND r.uid_foreign IN (' . $in . ') AND ' . self::PLACEHOLDER_SQL . '
GROUP BY r.uid_foreign, r.fieldname'
: 'SELECT uid_foreign, fieldname, COUNT(*) AS n FROM sys_file_reference
WHERE deleted = 0 AND hidden = 0 AND tablenames = ? AND uid_foreign IN (' . $in . ')
GROUP BY uid_foreign, fieldname';
$rows = $this->connection('sys_file_reference')->fetchAllAssociative($sql, [$table]);
$out = [];
foreach ($rows as $r) {
$out[(int)$r['uid_foreign']][(string)$r['fieldname']] = (int)$r['n'];
}
return $out;
}
/**
* Files of one record with original URL and a ~480 px thumbnail.
*
* @param string[]|null $fieldnames null = all fields
* @return array<int, array{field: string, url: string, thumb: string, name: string, mime: string, placeholder: bool}>
*/
private function files(string $table, int $uid, ?array $fieldnames = null): array
{
$sql = "SELECT r.fieldname, r.uid_local, f.identifier, f.name, f.mime_type
FROM sys_file_reference r JOIN sys_file f ON f.uid = r.uid_local
WHERE r.deleted = 0 AND r.hidden = 0 AND r.tablenames = ? AND r.uid_foreign = ?";
$params = [$table, $uid];
if ($fieldnames !== null) {
$sql .= ' AND r.fieldname IN (' . implode(',', array_fill(0, count($fieldnames), '?')) . ')';
$params = array_merge($params, $fieldnames);
}
$sql .= ' ORDER BY r.fieldname, r.sorting_foreign';
$out = [];
foreach ($this->connection('sys_file_reference')->fetchAllAssociative($sql, $params) as $r) {
$url = '/fileadmin' . $r['identifier'];
$out[] = [
'field' => (string)preg_replace('/^vitec_/', '', (string)$r['fieldname']),
'url' => $url,
'thumb' => $this->thumbnail((int)$r['uid_local'], (string)$r['mime_type'], $url),
'name' => (string)$r['name'],
'mime' => (string)$r['mime_type'],
'placeholder' => self::isPlaceholder((string)$r['identifier'], (string)$r['name']),
];
}
return $out;
}
private function thumbnail(int $fileUid, string $mime, string $fallback): string
{
if (!str_starts_with($mime, 'image/') || $mime === 'image/svg+xml') {
return $fallback;
}
try {
$file = GeneralUtility::makeInstance(ResourceFactory::class)->getFileObject($fileUid);
$imageService = GeneralUtility::makeInstance(ImageService::class);
$processed = $imageService->applyProcessingInstructions($file, ['maxWidth' => 480, 'maxHeight' => 480]);
return $imageService->getImageUri($processed);
} catch (\Throwable) {
return $fallback;
}
}
/**
* Records pointing to the pages via detail_page (first live record per page).
*
* @param int[] $pageUids
* @return array<int, array<string, mixed>> [pageUid] = record row
*/
private function records(string $table, array $pageUids): array
{
if ($pageUids === []) {
return [];
}
$rows = $this->connection($table)->fetchAllAssociative(
'SELECT uid, title, subtitle, teaser, description, detail_page FROM ' . $table . '
WHERE deleted = 0 AND detail_page IN (' . implode(',', array_map('intval', $pageUids)) . ') ORDER BY hidden, uid'
);
$out = [];
foreach ($rows as $r) {
$out[(int)$r['detail_page']] ??= $r;
}
return $out;
}
private function countAll(string $table, string $where): int
{
return (int)$this->connection($table)->fetchOne("SELECT COUNT(*) FROM $table WHERE $where");
}
private function connection(string $table): Connection
{
return $this->connectionPool->getConnectionForTable($table);
}
}
@@ -0,0 +1,414 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Service;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Service\ImageService;
/**
* Product content audit as data: which product record has which content,
* and the full content of one product for the detail pane.
*
* This is the data model behind the standalone page public/product-check/
* (PHP + PDO, 2026-11-03), ported to Doctrine so the React frontend can
* render the same audit as a page (/products/product-check, content
* element vitec_productcheck, see ProductCheckJsonRenderer). Both read the
* database fresh on every call; nothing here is cached on purpose.
*
* overview(): every product (alphabetical) with one check per field
* {ok, count[, icons]} plus the per-field summary.
* detail(): one product with all texts (RTE HTML as stored), images
* (original URL + ~480 px thumbnail), videos, feature cards
* with icons, resources, datasheets, solutions.
*
* Placeholders (Harry 2026-11-13, same rule as PageCheckService): files below
* fileadmin/placeholders/ or named "...placeholder..." do not pass as
* finished. Image cells carry `placeholder` = number of such files (only when
* > 0), summary.filled counts only cells without placeholders and
* summary.placeholder the cells that have one; detail images carry
* `placeholder: true|false`.
*/
final class ProductCheckService
{
private const PRODUCT_TABLE = 'tx_vitec_domain_model_product';
/**
* Columns of the audit table, in display order. kind drives the cell
* format in the frontend: images/video/features/list show a count,
* text shows a plain check.
*/
private const FIELDS = [
['key' => 'hero', 'label' => 'Hero', 'kind' => 'images'],
['key' => 'photos', 'label' => 'Photos', 'kind' => 'images'],
['key' => 'og', 'label' => 'OG image', 'kind' => 'images'],
['key' => 'video', 'label' => 'Video', 'kind' => 'video'],
['key' => 'teaser', 'label' => 'Teaser', 'kind' => 'text'],
['key' => 'subtitle', 'label' => 'Subtitle', 'kind' => 'text'],
['key' => 'description', 'label' => 'Description', 'kind' => 'text'],
['key' => 'description2', 'label' => 'Description 2', 'kind' => 'text'],
['key' => 'highlights', 'label' => 'Highlights', 'kind' => 'text'],
['key' => 'capabilities', 'label' => 'Capabilities', 'kind' => 'text'],
['key' => 'applications', 'label' => 'Applications', 'kind' => 'text'],
['key' => 'features', 'label' => 'Features', 'kind' => 'features'],
['key' => 'resources', 'label' => 'Resources', 'kind' => 'list'],
['key' => 'datasheets', 'label' => 'Datasheets', 'kind' => 'list'],
['key' => 'solutions', 'label' => 'Solutions', 'kind' => 'list'],
];
private const FLAGS = [
'hidden' => 'hidden',
'legacy' => 'legacy',
'supportproduct' => 'support',
'subproduct' => 'sub',
'hideonwebsite' => 'hideWeb',
'hideonproducts' => 'hideList',
'showdatapath' => 'datapath',
];
private const TEXT_FIELDS = [
'teaser' => 'Teaser',
'subtitle' => 'Subtitle',
'description' => 'Description',
'description2' => 'Description 2',
'highlights' => 'Highlights',
'capabilities' => 'Capabilities',
'applications' => 'Applications',
];
public function __construct(
private readonly ConnectionPool $connectionPool,
) {}
/**
* @return array{generatedAt: string, fields: array<int, array{key: string, label: string, kind: string}>, summary: array{products: int, filled: array<string, int>}, products: array<int, array<string, mixed>>}
*/
public function overview(): array
{
$products = $this->products();
$uids = array_map(static fn(array $p): int => (int)$p['uid'], $products);
$files = $this->fileCounts($uids);
$placeholders = $this->fileCounts($uids, true);
$features = $this->featureCounts($uids);
$resources = $this->countBy('tx_vitec_product_resource', 'product', 'deleted = 0 AND hidden = 0');
$datasheets = $this->mmCounts('tx_vitec_product_datasheet_mm', 'tx_vitec_domain_model_download');
$solutions = $this->mmCounts('tx_vitec_product_solution_mm', 'tx_vitec_domain_model_solution');
$filled = array_fill_keys(array_column(self::FIELDS, 'key'), 0);
$placeholder = array_fill_keys(array_column(self::FIELDS, 'key'), 0);
$rows = [];
foreach ($products as $p) {
$uid = (int)$p['uid'];
$f = $files[$uid] ?? [];
$ph = $placeholders[$uid] ?? [];
$checks = [
'hero' => $this->check($f['heroimage'] ?? 0, $ph['heroimage'] ?? 0),
'photos' => $this->check($f['productimage'] ?? 0, $ph['productimage'] ?? 0),
'og' => $this->check($f['ogimage'] ?? 0, $ph['ogimage'] ?? 0),
'video' => $this->check($f['videofile'] ?? 0),
'teaser' => $this->check($this->filled($p['teaser']) ? 1 : 0),
'subtitle' => $this->check($this->filled($p['subtitle']) ? 1 : 0),
'description' => $this->check($this->filled($p['description']) ? 1 : 0),
'description2' => $this->check($this->filled($p['description2']) ? 1 : 0),
'highlights' => $this->check($this->filled($p['highlights']) ? 1 : 0),
'capabilities' => $this->check($this->filled($p['capabilities']) ? 1 : 0),
'applications' => $this->check($this->filled($p['applications']) ? 1 : 0),
'features' => $this->check($features[$uid]['cards'] ?? 0) + ['icons' => $features[$uid]['icons'] ?? 0],
'resources' => $this->check($resources[$uid] ?? 0),
'datasheets' => $this->check($datasheets[$uid] ?? 0),
'solutions' => $this->check($solutions[$uid] ?? 0),
];
foreach ($checks as $key => $c) {
if (($c['placeholder'] ?? 0) > 0) {
$placeholder[$key]++;
} elseif ($c['ok']) {
$filled[$key]++;
}
}
$rows[] = $this->base($p) + ['checks' => $checks];
}
return [
'generatedAt' => date(\DATE_ATOM),
'fields' => self::FIELDS,
'summary' => ['products' => count($rows), 'filled' => $filled, 'placeholder' => $placeholder],
'products' => $rows,
];
}
/**
* @return array<string, mixed>|null null when the product does not exist
*/
public function detail(int $uid): ?array
{
$p = $this->connection()->executeQuery(
'SELECT uid, title, slug, teaser, subtitle, description, description2, highlights,
capabilities, applications, resources_layout,
hidden, legacy, supportproduct, subproduct, hideonwebsite, hideonproducts, showdatapath
FROM ' . self::PRODUCT_TABLE . ' WHERE deleted = 0 AND uid = ?',
[$uid]
)->fetchAssociative();
if ($p === false) {
return null;
}
$files = $this->files($uid);
$texts = [];
foreach (self::TEXT_FIELDS as $field => $label) {
$texts[$field] = [
'label' => $label,
'ok' => $this->filled($p[$field]),
'html' => (string)$p[$field],
];
}
return $this->base($p) + [
'images' => [
'hero' => $files['heroimage'] ?? [],
'photos' => $files['productimage'] ?? [],
'og' => $files['ogimage'] ?? [],
],
'videos' => array_map(static fn(array $v): array => ['url' => $v['url'], 'name' => $v['name']], $files['videofile'] ?? []),
'texts' => $texts,
'features' => $this->features($uid),
'resources' => $this->resources($uid),
'datasheets' => $this->mmTitles('tx_vitec_product_datasheet_mm', 'tx_vitec_domain_model_download', $uid),
'solutions' => $this->mmTitles('tx_vitec_product_solution_mm', 'tx_vitec_domain_model_solution', $uid),
];
}
// ---- building blocks ----------------------------------------------------
/** @param array<string, mixed> $p */
private function base(array $p): array
{
$slug = trim((string)$p['slug'], '/');
$flags = [];
foreach (self::FLAGS as $column => $label) {
if ((int)($p[$column] ?? 0) === 1) {
$flags[] = $label;
}
}
return [
'uid' => (int)$p['uid'],
'title' => (string)$p['title'],
'slug' => $slug,
'url' => '/product/' . $slug,
'flags' => $flags,
'resourcesLayout' => (string)($p['resources_layout'] ?? ''),
];
}
/** @return array{ok: bool, count: int} */
private function check(int $count, int $placeholder = 0): array
{
return ['ok' => $count > 0, 'count' => $count] + ($placeholder > 0 ? ['placeholder' => $placeholder] : []);
}
private function filled(mixed $value): bool
{
return trim(strip_tags((string)$value)) !== '';
}
/** @return array<int, array<string, mixed>> */
private function products(): array
{
return $this->connection()->executeQuery(
'SELECT uid, title, slug, teaser, subtitle, description, description2, highlights,
capabilities, applications, resources_layout,
hidden, legacy, supportproduct, subproduct, hideonwebsite, hideonproducts, showdatapath
FROM ' . self::PRODUCT_TABLE . ' WHERE deleted = 0 ORDER BY LOWER(title), uid'
)->fetchAllAssociative();
}
/**
* Visible FAL references per product and field.
*
* @param int[] $uids
* @return array<int, array<string, int>> [productUid][fieldname] = count
*/
private function fileCounts(array $uids, bool $placeholdersOnly = false): array
{
if ($uids === []) {
return [];
}
$in = implode(',', array_map('intval', $uids));
$sql = $placeholdersOnly
? "SELECT r.uid_foreign, r.fieldname, COUNT(*) AS n FROM sys_file_reference r JOIN sys_file f ON f.uid = r.uid_local
WHERE r.deleted = 0 AND r.hidden = 0 AND r.tablenames = 'tx_vitec_domain_model_product'
AND r.fieldname IN ('heroimage','productimage','ogimage','videofile')
AND r.uid_foreign IN (" . $in . ') AND ' . PageCheckService::PLACEHOLDER_SQL . '
GROUP BY r.uid_foreign, r.fieldname'
: "SELECT uid_foreign, fieldname, COUNT(*) AS n FROM sys_file_reference
WHERE deleted = 0 AND hidden = 0 AND tablenames = 'tx_vitec_domain_model_product'
AND fieldname IN ('heroimage','productimage','ogimage','videofile')
AND uid_foreign IN (" . $in . ')
GROUP BY uid_foreign, fieldname';
$rows = $this->connection()->executeQuery($sql)->fetchAllAssociative();
$out = [];
foreach ($rows as $r) {
$out[(int)$r['uid_foreign']][(string)$r['fieldname']] = (int)$r['n'];
}
return $out;
}
/**
* Files of one product with original URL and a ~480 px thumbnail
* (SVG and non-images keep the original as thumbnail).
*
* @return array<string, array<int, array{url: string, thumb: string, name: string, mime: string, placeholder: bool}>>
*/
private function files(int $uid): array
{
$rows = $this->connection()->executeQuery(
"SELECT r.fieldname, r.uid_local, f.identifier, f.name, f.mime_type
FROM sys_file_reference r JOIN sys_file f ON f.uid = r.uid_local
WHERE r.deleted = 0 AND r.hidden = 0 AND r.tablenames = 'tx_vitec_domain_model_product'
AND r.fieldname IN ('heroimage','productimage','ogimage','videofile') AND r.uid_foreign = ?
ORDER BY r.fieldname, r.sorting_foreign",
[$uid]
)->fetchAllAssociative();
$out = [];
foreach ($rows as $r) {
$url = '/fileadmin' . $r['identifier'];
$out[(string)$r['fieldname']][] = [
'url' => $url,
'thumb' => $this->thumbnail((int)$r['uid_local'], (string)$r['mime_type'], $url),
'name' => (string)$r['name'],
'mime' => (string)$r['mime_type'],
'placeholder' => PageCheckService::isPlaceholder((string)$r['identifier'], (string)$r['name']),
];
}
return $out;
}
private function thumbnail(int $fileUid, string $mime, string $fallback): string
{
if (!str_starts_with($mime, 'image/') || $mime === 'image/svg+xml') {
return $fallback;
}
try {
$file = GeneralUtility::makeInstance(ResourceFactory::class)->getFileObject($fileUid);
$imageService = GeneralUtility::makeInstance(ImageService::class);
$processed = $imageService->applyProcessingInstructions($file, ['maxWidth' => 480, 'maxHeight' => 480]);
return $imageService->getImageUri($processed);
} catch (\Throwable) {
return $fallback;
}
}
/**
* @param int[] $uids
* @return array<int, array{cards: int, icons: int}>
*/
private function featureCounts(array $uids): array
{
if ($uids === []) {
return [];
}
$in = implode(',', array_map('intval', $uids));
$out = [];
foreach ($this->connection()->executeQuery(
"SELECT product, COUNT(*) AS n FROM tx_vitec_product_feature
WHERE deleted = 0 AND hidden = 0 AND product IN ($in) GROUP BY product"
)->fetchAllAssociative() as $r) {
$out[(int)$r['product']] = ['cards' => (int)$r['n'], 'icons' => 0];
}
foreach ($this->connection()->executeQuery(
"SELECT fe.product, COUNT(DISTINCT fe.uid) AS n
FROM tx_vitec_product_feature fe
JOIN sys_file_reference r ON r.uid_foreign = fe.uid AND r.tablenames = 'tx_vitec_product_feature'
AND r.fieldname = 'icon' AND r.deleted = 0 AND r.hidden = 0
WHERE fe.deleted = 0 AND fe.hidden = 0 AND fe.product IN ($in) GROUP BY fe.product"
)->fetchAllAssociative() as $r) {
$out[(int)$r['product']]['icons'] = (int)$r['n'];
}
return $out;
}
/** @return array<int, array{title: string, text: string, icon: array{url: string, name: string}|null}> */
private function features(int $uid): array
{
$cards = $this->connection()->executeQuery(
'SELECT uid, title, cardtext FROM tx_vitec_product_feature
WHERE deleted = 0 AND hidden = 0 AND product = ? ORDER BY sorting, uid',
[$uid]
)->fetchAllAssociative();
if ($cards === []) {
return [];
}
$icons = [];
foreach ($this->connection()->executeQuery(
"SELECT r.uid_foreign, f.identifier, f.name FROM sys_file_reference r JOIN sys_file f ON f.uid = r.uid_local
WHERE r.deleted = 0 AND r.hidden = 0 AND r.tablenames = 'tx_vitec_product_feature' AND r.fieldname = 'icon'
AND r.uid_foreign IN (" . implode(',', array_map(static fn(array $c): int => (int)$c['uid'], $cards)) . ')
ORDER BY r.uid_foreign, r.sorting_foreign'
)->fetchAllAssociative() as $r) {
$icons[(int)$r['uid_foreign']] ??= ['url' => '/fileadmin' . $r['identifier'], 'name' => (string)$r['name']];
}
return array_map(static fn(array $c): array => [
'title' => (string)$c['title'],
'text' => (string)$c['cardtext'],
'icon' => $icons[(int)$c['uid']] ?? null,
], $cards);
}
/** @return array<int, array{title: string, type: string, link: string, text: string}> */
private function resources(int $uid): array
{
return array_map(static fn(array $r): array => [
'title' => (string)$r['title'],
'type' => (string)$r['restype'],
'link' => (string)$r['link'],
'text' => (string)$r['cardtext'],
], $this->connection()->executeQuery(
'SELECT title, restype, link, cardtext FROM tx_vitec_product_resource
WHERE deleted = 0 AND hidden = 0 AND product = ? ORDER BY sorting, uid',
[$uid]
)->fetchAllAssociative());
}
/** @return array<int, int> [productUid] = count */
private function countBy(string $table, string $column, string $where): array
{
$out = [];
foreach ($this->connection()->executeQuery(
"SELECT $column AS k, COUNT(*) AS n FROM $table WHERE $where GROUP BY $column"
)->fetchAllAssociative() as $r) {
$out[(int)$r['k']] = (int)$r['n'];
}
return $out;
}
/** @return array<int, int> [productUid] = count of non-deleted foreign records */
private function mmCounts(string $mmTable, string $foreignTable): array
{
$out = [];
foreach ($this->connection()->executeQuery(
"SELECT mm.uid_local AS k, COUNT(*) AS n FROM $mmTable mm
JOIN $foreignTable t ON t.uid = mm.uid_foreign AND t.deleted = 0
GROUP BY mm.uid_local"
)->fetchAllAssociative() as $r) {
$out[(int)$r['k']] = (int)$r['n'];
}
return $out;
}
/** @return array<int, array{uid: int, title: string}> */
private function mmTitles(string $mmTable, string $foreignTable, int $uid): array
{
return array_map(static fn(array $r): array => ['uid' => (int)$r['uid'], 'title' => (string)$r['title']], $this->connection()->executeQuery(
"SELECT t.uid, t.title FROM $mmTable mm JOIN $foreignTable t ON t.uid = mm.uid_foreign AND t.deleted = 0
WHERE mm.uid_local = ? ORDER BY mm.sorting",
[$uid]
)->fetchAllAssociative());
}
private function connection(): \TYPO3\CMS\Core\Database\Connection
{
return $this->connectionPool->getConnectionForTable(self::PRODUCT_TABLE);
}
}
@@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Service;
use TYPO3\CMS\Core\Cache\CacheManager;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Exception\SiteNotFoundException;
use TYPO3\CMS\Core\Site\SiteFinder;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Keeps the SSR routing flag file in sync with the site setting
* "vitec.ssr.enabled" (Site Set switch, editable in the backend under
* Site Management > Settings).
*
* The flag file is what public/.htaccess checks per request (RewriteCond
* ... -f): if it exists, page requests are proxied to the SSR node on the
* Solr VPS; if not, the SPA fallback serves _frontend/index.html as
* before. TYPO3 itself never routes differently - the flag only steers
* Apache, which is why a plain file is used: zero runtime cost in the
* hot path and trivially inspectable on the server.
*
* Saving site settings in the TYPO3 v14 backend fires NO event (core
* SiteWriter::writeSettings dispatches nothing, checked 2026-11-09), so
* the sync runs self-healingly from the SyncSsrFlag middleware on every
* frontend and backend request - a mismatch costs one file operation
* once, agreement costs a file_exists(). The vitec:ssr-flag CLI command
* offers the same sync plus a status view.
*/
final class SsrFlagService
{
public function __construct(private readonly SiteFinder $siteFinder)
{
}
public function getFlagPath(): string
{
return Environment::getVarPath() . '/vitec-ssr-on.flag';
}
/**
* True as soon as ANY site enables the setting (there is only the
* "vitec" site; iterating keeps us independent of the identifier).
*/
public function isEnabledInSettings(): bool
{
try {
foreach ($this->siteFinder->getAllSites() as $site) {
if ((bool)$site->getSettings()->get('vitec.ssr.enabled', false)) {
return true;
}
}
} catch (SiteNotFoundException) {
// No sites configured - treat as disabled.
}
return false;
}
public function isFlagSet(): bool
{
return is_file($this->getFlagPath());
}
/**
* Creates or removes the flag file so it matches the setting.
*
* @return bool|null The new state if something changed, null if the
* flag already matched the setting.
*/
public function sync(): ?bool
{
$desired = $this->isEnabledInSettings();
if ($desired === $this->isFlagSet()) {
return null;
}
if ($desired) {
file_put_contents(
$this->getFlagPath(),
"SSR routing is ON (site setting vitec.ssr.enabled).\n"
. "Managed by SsrFlagService - do not create/delete manually,\n"
. "flip the switch in Site Management > Settings instead.\n"
. 'Last sync: ' . date('c') . "\n"
);
// Fresh ON: whatever the node cached while the switch was OFF
// is stale by definition - drop its whole page cache once.
\TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(\Evomedien\Vitec\Service\SsrPurgeService::class)
->purgeQuietly(['*'], 'switch-on');
} else {
@unlink($this->getFlagPath());
}
return $desired;
}
/**
* Auto-failover (vitec:ssr-health): turns SSR delivery hard OFF.
*
* Removing only the flag file would not stick - the self-healing
* SyncSsrFlag middleware recreates it from the still-true site setting
* on the very next request. So the setting itself is flipped to false
* in config/sites/<identifier>/settings.yaml (the file the backend
* settings editor writes, flat dotted keys), caches are flushed so the
* compiled site settings forget the old value, and only then the flag
* is removed. Re-enabling is deliberately manual (backend switch or
* settings.yaml + cache flush).
*/
public function disableSsr(): void
{
$changed = false;
foreach ($this->siteFinder->getAllSites() as $site) {
$file = Environment::getConfigPath() . '/sites/' . $site->getIdentifier() . '/settings.yaml';
if (!is_file($file)) {
continue;
}
$yaml = (string)file_get_contents($file);
$new = preg_replace('/^(vitec\.ssr\.enabled\s*:).*$/m', '$1 false', $yaml, 1, $count);
if ($new !== null && $count > 0 && $new !== $yaml) {
if (file_put_contents($file, $new) === false) {
throw new \RuntimeException('Could not write ' . $file, 1762800001);
}
$changed = true;
}
}
if (!$changed && $this->isEnabledInSettings()) {
throw new \RuntimeException(
'vitec.ssr.enabled not found in any config/sites/*/settings.yaml - cannot disable it, '
. 'and the SyncSsrFlag middleware would recreate the flag from the setting',
1762800002
);
}
if ($changed) {
GeneralUtility::makeInstance(CacheManager::class)->flushCaches();
}
@unlink($this->getFlagPath());
}
/**
* Sync variant for the request hot path: never lets an IO hiccup
* (read-only FS, concurrent unlink ...) break request handling.
*/
public function syncQuietly(): void
{
try {
$this->sync();
} catch (\Throwable) {
// Intentionally silent - next request retries.
}
}
}
@@ -0,0 +1,330 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Service;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Http\RequestFactory;
use TYPO3\CMS\Core\Site\SiteFinder;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Talks to the SSR node's POST /__purge endpoint (contract 0054-FE) and
* keeps a log of every attempt in tx_vitec_ssrpurgelog.
*
* Endpoint + secret live OUTSIDE the repository in var/vitec-ssr-purge.env
* (KEY=VALUE lines: SSR_PURGE_ENDPOINT, SSR_PURGE_SECRET, SSR_HEALTH_ENDPOINT,
* SSR_ALERT_EMAIL, SSR_FAILOVER_AFTER)
* - same pattern as the routing flag file, nothing secret in git.
*/
final class SsrPurgeService
{
private ?array $config = null;
public function __construct(
private readonly RequestFactory $requestFactory,
private readonly ConnectionPool $connectionPool,
private readonly SiteFinder $siteFinder,
) {}
public static function getConfigPath(): string
{
return Environment::getVarPath() . '/vitec-ssr-purge.env';
}
public function isConfigured(): bool
{
$c = $this->getConfig();
return ($c['SSR_PURGE_ENDPOINT'] ?? '') !== '' && ($c['SSR_PURGE_SECRET'] ?? '') !== '';
}
public function getHealthEndpoint(): string
{
return $this->getConfig()['SSR_HEALTH_ENDPOINT'] ?? '';
}
/**
* The /__stats sibling of the health endpoint (cumulative hit/miss/
* error counters, in the node since release 20261109-211544). Derived
* from SSR_HEALTH_ENDPOINT; SSR_STATS_ENDPOINT overrides explicitly.
*/
public function getStatsEndpoint(): string
{
$explicit = $this->getConfig()['SSR_STATS_ENDPOINT'] ?? '';
if ($explicit !== '') {
return $explicit;
}
$health = $this->getHealthEndpoint();
return $health === '' ? '' : str_replace('/__health', '/__stats', $health);
}
/**
* Comma-separated recipient list for vitec:ssr-health transition and
* failover alerts (SSR_ALERT_EMAIL); empty string = alerting off.
*/
public function getAlertEmail(): string
{
return trim($this->getConfig()['SSR_ALERT_EMAIL'] ?? '');
}
/**
* Who gets the vitec:ssr-health alert mails. Maintained by editors in
* Site Management › Settings › VITEC (vitec.ssr.alertEmails, one
* address per entry); the first site with at least one valid address
* wins. Without such a list, SSR_ALERT_EMAIL from the env file is the
* fallback. Invalid entries are skipped and reported back.
*
* @return array{recipients: list<string>, source: string, invalid: list<string>}
*/
public function getAlertRecipients(): array
{
$invalid = [];
try {
foreach ($this->siteFinder->getAllSites() as $site) {
$list = $site->getSettings()->get('vitec.ssr.alertEmails', []);
if (is_string($list)) {
$list = GeneralUtility::trimExplode(',', $list, true);
}
$valid = [];
foreach ((array)$list as $address) {
$address = trim((string)$address);
if ($address === '') {
continue;
}
if (GeneralUtility::validEmail($address)) {
$valid[] = $address;
} else {
$invalid[] = $address;
}
}
if ($valid !== []) {
return ['recipients' => array_values(array_unique($valid)), 'source' => 'site settings', 'invalid' => $invalid];
}
}
} catch (\Throwable) {
// No site configuration readable (e.g. broken YAML): use the env fallback.
}
$env = array_values(array_filter(
GeneralUtility::trimExplode(',', $this->getAlertEmail(), true),
static fn(string $a): bool => GeneralUtility::validEmail($a)
));
return ['recipients' => $env, 'source' => $env !== [] ? 'var/vitec-ssr-purge.env' : '', 'invalid' => $invalid];
}
/**
* Consecutive DOWN samples after which vitec:ssr-health fails over to
* the SPA (SSR_FAILOVER_AFTER, default 2 = ~10 minutes at the 5-minute
* cron cadence). Never below 1.
*/
public function getFailoverAfter(): int
{
return max(1, (int)($this->getConfig()['SSR_FAILOVER_AFTER'] ?? 2));
}
/**
* SSR_CACHE_MB of the node (/etc/vitec-ssr.env on the VPS) as the
* module knows it — the node does not report its limit in /__health.
* Optional SSR_CACHE_LIMIT_MB in var/vitec-ssr-purge.env; 0 = unknown.
*/
public function getCacheLimitMb(): int
{
return max(0, (int)($this->getConfig()['SSR_CACHE_LIMIT_MB'] ?? 0));
}
/**
* POST /__warm on the node (contract 0066-FE), derived from
* SSR_PURGE_ENDPOINT; SSR_WARM_ENDPOINT overrides explicitly.
*/
public function getWarmEndpoint(): string
{
$explicit = $this->getConfig()['SSR_WARM_ENDPOINT'] ?? '';
if ($explicit !== '') {
return $explicit;
}
$purge = $this->getConfig()['SSR_PURGE_ENDPOINT'] ?? '';
return $purge === '' ? '' : str_replace('/__purge', '/__warm', $purge);
}
/**
* Starts a background warm run on the node: no paths (or '*') = every
* page of sitemap.xml, otherwise exactly the given paths. The node
* answers at once and renders in the background; progress shows up as
* "warming" in /__health and /__stats. Not written to the purge log -
* a warm run evicts nothing, and the log feeds the purge statistics.
*
* @param string[] $paths
* @return array{success:bool, httpCode:int, message:string, data:array}
*/
public function warm(array $paths = []): array
{
if (!$this->isConfigured()) {
return ['success' => false, 'httpCode' => 0, 'message' => 'not configured (var/vitec-ssr-purge.env missing)', 'data' => []];
}
$paths = $this->normalize($paths);
$payload = ($paths === [] || $paths === ['*']) ? ['source' => 'sitemap'] : ['paths' => $paths];
try {
$response = $this->requestFactory->request($this->getWarmEndpoint(), 'POST', [
'headers' => [
'X-Purge-Secret' => $this->getConfig()['SSR_PURGE_SECRET'],
'Content-Type' => 'application/json',
],
'body' => json_encode($payload, JSON_THROW_ON_ERROR),
// The node reads the sitemap (index + sub-sitemaps) before
// it answers; the pages themselves are not awaited.
'timeout' => 30,
'connect_timeout' => 3,
'http_errors' => false,
]);
$code = $response->getStatusCode();
$body = (string)$response->getBody();
$data = json_decode($body, true);
$data = is_array($data) ? $data : [];
if ($code === 200 && !empty($data['started'])) {
$message = sprintf(
'Warm run started: %d pages queued (source: %s, %d at a time)',
(int)($data['queued'] ?? 0),
(string)($data['source'] ?? '?'),
(int)($data['concurrency'] ?? 0)
);
return ['success' => true, 'httpCode' => $code, 'message' => $message, 'data' => $data];
}
if ($code === 200 && !empty($data['running'])) {
$w = (array)($data['warming'] ?? []);
$message = sprintf(
'A warm run is already active: %d of %d pages done',
(int)($w['done'] ?? 0) + (int)($w['skipped'] ?? 0) + (int)($w['failed'] ?? 0),
(int)($w['queued'] ?? 0)
);
return ['success' => true, 'httpCode' => $code, 'message' => $message, 'data' => $data];
}
$message = 'HTTP ' . $code . ' - ' . (string)($data['error'] ?? substr($body, 0, 200));
return ['success' => false, 'httpCode' => $code, 'message' => $message, 'data' => $data];
} catch (\Throwable $e) {
return ['success' => false, 'httpCode' => 0, 'message' => substr($e->getMessage(), 0, 200), 'data' => []];
}
}
/**
* Normalizes, dedupes and sends the paths; logs the outcome.
* Never throws - editors' saves must not fail on a purge problem.
*
* @param string[] $paths page paths, or ['*'] for everything
* @return array{success:bool, httpCode:int, message:string, paths:string[]}
*/
public function purge(array $paths, string $trigger): array
{
$paths = $this->normalize($paths);
if ($paths === []) {
return ['success' => true, 'httpCode' => 0, 'message' => 'nothing to purge', 'paths' => []];
}
if (!$this->isConfigured()) {
$result = ['success' => false, 'httpCode' => 0, 'message' => 'not configured (var/vitec-ssr-purge.env missing)', 'paths' => $paths];
$this->log($trigger, $result, 0);
return $result;
}
$c = $this->getConfig();
$start = microtime(true);
try {
$response = $this->requestFactory->request($c['SSR_PURGE_ENDPOINT'], 'POST', [
'headers' => [
'X-Purge-Secret' => $c['SSR_PURGE_SECRET'],
'Content-Type' => 'application/json',
],
'body' => json_encode(['paths' => $paths], JSON_THROW_ON_ERROR),
'timeout' => 4,
'connect_timeout' => 2,
'http_errors' => false,
]);
$code = $response->getStatusCode();
$body = substr((string)$response->getBody(), 0, 200);
$result = [
'success' => $code === 200,
'httpCode' => $code,
'message' => $body,
'paths' => $paths,
];
} catch (\Throwable $e) {
$result = ['success' => false, 'httpCode' => 0, 'message' => substr($e->getMessage(), 0, 200), 'paths' => $paths];
}
$this->log($trigger, $result, (int)round((microtime(true) - $start) * 1000));
return $result;
}
public function purgeQuietly(array $paths, string $trigger): void
{
try {
$this->purge($paths, $trigger);
} catch (\Throwable) {
// Logged inside purge(); never disturb the caller.
}
}
/**
* Path rules from 0055-BE Q3 / 0054-FE section 3: path only, query
* stripped, no trailing slash (root stays '/'), '*' wins over all.
*
* @param string[] $paths
* @return string[]
*/
public function normalize(array $paths): array
{
$out = [];
foreach ($paths as $p) {
$p = trim((string)$p);
if ($p === '') {
continue;
}
if ($p === '*') {
return ['*'];
}
$p = parse_url($p, PHP_URL_PATH) ?: '/';
if ($p !== '/' ) {
$p = '/' . trim($p, '/');
}
$out[$p] = true;
}
return array_keys($out);
}
private function getConfig(): array
{
if ($this->config !== null) {
return $this->config;
}
$this->config = [];
$file = self::getConfigPath();
if (is_readable($file)) {
foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
if (str_starts_with(ltrim($line), '#') || !str_contains($line, '=')) {
continue;
}
[$k, $v] = explode('=', $line, 2);
$this->config[trim($k)] = trim($v);
}
}
return $this->config;
}
private function log(string $trigger, array $result, int $durationMs): void
{
try {
$this->connectionPool->getConnectionForTable('tx_vitec_ssrpurgelog')->insert(
'tx_vitec_ssrpurgelog',
[
'crdate' => time(),
'trigger_source' => substr($trigger, 0, 100),
'paths' => implode(', ', $result['paths']),
'success' => $result['success'] ? 1 : 0,
'http_code' => $result['httpCode'],
'message' => substr($result['message'], 0, 255),
'duration_ms' => $durationMs,
]
);
} catch (\Throwable) {
// Table missing (before schema update) must not break anything.
}
}
}
@@ -0,0 +1,296 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Service;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Site\SiteFinder;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Targeted auto-purge (2026-11-12, Harry): which SSR paths show a given
* VITEC or news record, so saving one product no longer empties the
* whole SSR cache.
*
* A record shows up on
* 1. its own detail URL (route enhancers in config/sites/vitec/config.yaml),
* 2. pages whose content elements select it explicitly (sys_refindex),
* 3. pages with list plugins that pick records of its type on their own,
* 4. the pages of records that embed it (related products, product
* downloads, product solutions) - one level deep.
* Everything still goes ('*') when the change can touch links or menus on
* every page: URL, visibility or timing fields, the newest news and the
* events (the megamenu spotlight shows them live), unknown tables, or
* more than MAX_PATHS paths. When in doubt: '*'.
*/
final class SsrPurgeTargetResolver
{
public const ALL = '*';
private const MAX_PATHS = 150;
/** Changing one of these can change URLs, menus or visibility everywhere. */
private const GLOBAL_FIELDS = ['slug', 'path_segment', 'hidden', 'deleted', 'starttime', 'endtime',
'fe_group', 'pid', 'sys_language_uid', 'type', 'datetime', 'is_group'];
/** Backend-only tables: nothing of them is rendered. */
private const INTERNAL_TABLES = ['tx_vitec_form_submission', 'tx_vitec_import_mapping', 'tx_vitec_seo_research',
'tx_vitec_product_workbook', 'tx_vitec_ssrpurgelog', 'tx_vitec_ssrhealthlog', 'tx_vitec_clienterrorlog'];
/** Inline children: child table => [parent field, parent table]. */
private const CHILD_TABLES = [
'tx_vitec_product_related_text' => ['product_uid', 'tx_vitec_domain_model_product'],
'tx_vitec_product_resource' => ['product', 'tx_vitec_domain_model_product'],
'tx_vitec_product_feature' => ['product', 'tx_vitec_domain_model_product'],
'tx_news_domain_model_link' => ['parent', 'tx_news_domain_model_news'],
];
/** Shown in the megamenu or rarely changed and used broadly. */
private const ALWAYS_ALL_TABLES = ['tx_vitec_domain_model_event', 'tx_vitec_domain_model_searchcard',
'tx_news_domain_model_tag'];
/** List plugins that show records of a table without a fixed selection. */
private const LIST_CTYPES = [
// Product lists (vitec_productlist) filter by category: productListPages().
'tx_vitec_domain_model_product' => ['vitec_productcheck'],
'tx_vitec_domain_model_usecase' => ['vitec_usecaselist', 'vitec_usecaseshow'],
'tx_vitec_domain_model_solution' => ['vitec_solutionlist'],
'tx_vitec_domain_model_market' => ['vitec_marketlist', 'vitec_marketshow', 'vitec_usecaselist'],
'tx_vitec_domain_model_download' => ['vitec_downloadcardcollection'],
'tx_vitec_domain_model_location' => ['vitec_locationlist'],
'tx_vitec_domain_model_customer' => ['vitec_customerlogos'],
'tx_news_domain_model_news' => ['news_pi1', 'news_newsliststicky', 'news_newsdetail'],
];
/** Records that embed a record of the key table: [embedding table, field]. */
private const EMBEDDED_IN = [
'tx_vitec_domain_model_product' => [['tx_vitec_domain_model_product', 'relatedprodukt'],
['tx_vitec_product_related_text', 'related_uid']],
'tx_vitec_domain_model_download' => [['tx_vitec_domain_model_product', 'downloads']],
'tx_vitec_domain_model_solution' => [['tx_vitec_domain_model_product', 'solutions']],
'tx_vitec_domain_model_usecase' => [['tx_news_domain_model_news', 'vitec_canonical_usecase']],
];
/** Detail routes: table => [plugin namespace, controller, action, argument, detail page uids]. */
private const DETAIL_ROUTES = [
'tx_vitec_domain_model_product' => ['tx_vitec_productshow', 'Product', 'show', 'product', [10]],
'tx_vitec_domain_model_usecase' => ['tx_vitec_usecaseshow', 'Usecase', 'show', 'usecase', [4, 25, 26]],
'tx_news_domain_model_news' => ['tx_news_pi1', 'News', 'detail', 'news', [21]],
];
/** The megamenu spotlight shows the newest news (ProvideInsightsSpotlight, 3 + margin). */
private const SPOTLIGHT_NEWS = 5;
private const SPOTLIGHT_NEWS_PIDS = [11, 442];
/**
* @param string[] $changedFields field names of the save ([] = unknown)
* @return list<string> paths to purge, ['*'] for everything, [] for nothing visible
*/
public function resolve(string $table, int $uid, array $changedFields): array
{
try {
return $this->doResolve($table, $uid, $changedFields);
} catch (\Throwable) {
return [self::ALL];
}
}
private function doResolve(string $table, int $uid, array $changedFields): array
{
if (in_array($table, self::INTERNAL_TABLES, true)) {
return [];
}
if ($uid <= 0) {
return [self::ALL];
}
if (isset(self::CHILD_TABLES[$table])) {
[$field, $parentTable] = self::CHILD_TABLES[$table];
$parentUid = (int)$this->connection($table)->executeQuery(
'SELECT ' . $field . ' FROM ' . $table . ' WHERE uid = ?', [$uid]
)->fetchOne();
return $parentUid > 0 ? $this->doResolve($parentTable, $parentUid, []) : [self::ALL];
}
if (in_array($table, self::ALWAYS_ALL_TABLES, true) || !isset(self::LIST_CTYPES[$table])) {
return [self::ALL];
}
if (array_intersect($changedFields, self::GLOBAL_FIELDS) !== []) {
return [self::ALL];
}
if ($table === 'tx_news_domain_model_news' && $this->isSpotlightNews($uid)) {
return [self::ALL];
}
$paths = $this->pathsOf($table, $uid);
foreach (self::EMBEDDED_IN[$table] ?? [] as [$embeddingTable, $field]) {
foreach ($this->embeddingRecords($embeddingTable, $field, $table, $uid) as [$hostTable, $hostUid]) {
$paths = array_merge($paths, $this->pathsOf($hostTable, $hostUid));
}
}
foreach ($this->pagesWithCTypes(self::LIST_CTYPES[$table]) as $pid) {
$paths[] = $this->pagePath($pid);
}
if ($table === 'tx_vitec_domain_model_product') {
foreach ($this->productListPages($uid) as $pid) {
$paths[] = $this->pagePath($pid);
}
}
$paths = array_values(array_unique(array_filter($paths)));
return count($paths) > self::MAX_PATHS ? [self::ALL] : $paths;
}
/** Detail URL(s) plus the pages that select the record explicitly. */
private function pathsOf(string $table, int $uid): array
{
$paths = $this->detailPaths($table, $uid);
$pids = $this->connection('sys_refindex')->executeQuery(
'SELECT DISTINCT c.pid FROM sys_refindex r JOIN tt_content c ON c.uid = r.recuid
WHERE r.tablename = ? AND r.ref_table = ? AND r.ref_uid = ? AND c.deleted = 0',
['tt_content', $table, $uid]
)->fetchFirstColumn();
foreach ($pids as $pid) {
$paths[] = $this->pagePath((int)$pid);
}
if ($table === 'tx_vitec_domain_model_solution') {
$detailPage = (int)$this->connection($table)->executeQuery(
'SELECT detail_page FROM ' . $table . ' WHERE uid = ?', [$uid]
)->fetchOne();
if ($detailPage > 0) {
$paths[] = $this->pagePath($detailPage);
}
}
return $paths;
}
/** @return list<array{0: string, 1: int}> records embedding the given one */
private function embeddingRecords(string $embeddingTable, string $field, string $table, int $uid): array
{
if ($embeddingTable === 'tx_vitec_product_related_text') {
$rows = $this->connection($embeddingTable)->executeQuery(
'SELECT DISTINCT product_uid FROM tx_vitec_product_related_text WHERE related_uid = ?', [$uid]
)->fetchFirstColumn();
return array_map(static fn($u): array => ['tx_vitec_domain_model_product', (int)$u], $rows);
}
$rows = $this->connection('sys_refindex')->executeQuery(
'SELECT DISTINCT recuid FROM sys_refindex WHERE tablename = ? AND field = ? AND ref_table = ? AND ref_uid = ?',
[$embeddingTable, $field, $table, $uid]
)->fetchFirstColumn();
return array_map(static fn($u): array => [$embeddingTable, (int)$u], $rows);
}
private function detailPaths(string $table, int $uid): array
{
if (!isset(self::DETAIL_ROUTES[$table])) {
return [];
}
[$namespace, $controller, $action, $argument, $pageUids] = self::DETAIL_ROUTES[$table];
$paths = [];
foreach ($pageUids as $pageUid) {
try {
$site = GeneralUtility::makeInstance(SiteFinder::class)->getSiteByPageId($pageUid);
$paths[] = $site->getRouter()->generateUri($pageUid, [
$namespace => ['action' => $action, 'controller' => $controller, $argument => $uid],
])->getPath();
} catch (\Throwable) {
// Record not routable (hidden, no slug): its pages are covered by the lists.
}
}
return $paths;
}
private function pagesWithCTypes(array $cTypes): array
{
if ($cTypes === []) {
return [];
}
return array_map('intval', $this->connection('tt_content')->executeQuery(
'SELECT DISTINCT pid FROM tt_content WHERE deleted = 0 AND CType IN (?)',
[$cTypes],
[Connection::PARAM_STR_ARRAY]
)->fetchFirstColumn());
}
/**
* Pages with a product list that can show the product. Mirrors
* ProductListJsonRenderer: selected products (settings.selectedproducts)
* show exactly those - covered by the refindex; otherwise a category
* filter matches the selected categories and their whole subtree;
* "all products" or no category shows every product.
*/
private function productListPages(int $productUid): array
{
// The product's categories plus all their ancestors.
$categories = array_map('intval', $this->connection('sys_category_record_mm')->executeQuery(
"SELECT uid_local FROM sys_category_record_mm
WHERE tablenames = 'tx_vitec_domain_model_product' AND fieldname = 'categories' AND uid_foreign = ?",
[$productUid]
)->fetchFirstColumn());
$seen = [];
$queue = $categories;
while ($queue !== []) {
$cat = array_shift($queue);
if ($cat <= 0 || isset($seen[$cat])) {
continue;
}
$seen[$cat] = true;
$queue[] = (int)$this->connection('sys_category')->executeQuery(
'SELECT parent FROM sys_category WHERE uid = ?', [$cat]
)->fetchOne();
}
$pages = [];
$lists = $this->connection('tt_content')->executeQuery(
"SELECT uid, pid, pi_flexform FROM tt_content WHERE deleted = 0 AND CType = 'vitec_productlist'"
)->fetchAllAssociative();
foreach ($lists as $list) {
$refs = $this->connection('sys_refindex')->executeQuery(
"SELECT ref_table, ref_uid FROM sys_refindex
WHERE tablename = 'tt_content' AND recuid = ? AND field = 'pi_flexform'
AND ref_table IN ('tx_vitec_domain_model_product', 'sys_category')",
[(int)$list['uid']]
)->fetchAllAssociative();
$selected = array_filter($refs, static fn(array $r): bool => $r['ref_table'] === 'tx_vitec_domain_model_product');
if ($selected !== []) {
continue; // fixed selection: in the refindex pages already when it contains the product
}
$listCategories = array_map('intval', array_column(
array_filter($refs, static fn(array $r): bool => $r['ref_table'] === 'sys_category'),
'ref_uid'
));
$allProducts = preg_match('#settings\.allproducts">\s*<value index="vDEF">1<#', (string)$list['pi_flexform']) === 1;
if ($allProducts || $listCategories === [] || array_intersect($listCategories, array_keys($seen)) !== []) {
$pages[] = (int)$list['pid'];
}
}
return array_values(array_unique($pages));
}
private function isSpotlightNews(int $uid): bool
{
$newest = $this->connection('tx_news_domain_model_news')->executeQuery(
'SELECT uid FROM tx_news_domain_model_news WHERE deleted = 0 AND hidden = 0 AND type = 0 AND pid IN (?)
ORDER BY datetime DESC LIMIT ' . self::SPOTLIGHT_NEWS,
[self::SPOTLIGHT_NEWS_PIDS],
[Connection::PARAM_INT_ARRAY]
)->fetchFirstColumn();
return in_array($uid, array_map('intval', $newest), true);
}
private function pagePath(int $pageId): string
{
if ($pageId <= 0) {
return '';
}
try {
$site = GeneralUtility::makeInstance(SiteFinder::class)->getSiteByPageId($pageId);
return $site->getRouter()->generateUri($pageId)->getPath() ?: '/';
} catch (\Throwable) {
return ''; // Folder or page outside a site: nothing cached for it.
}
}
private function connection(string $table): Connection
{
return GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($table);
}
}
@@ -155,8 +155,40 @@ final class NewsJsonRenderer
return json_encode($response) ?: '';
}
// Server-side pagination for the big overview list (contract
// 0070-FE/0075-BE): only a plain/sticky list WITHOUT a limit and
// WITHOUT a hand-picked selection paginates. That is exactly the
// /insights/news element (#130); every teaser list carries a
// limit (1-3) and keeps today's full behaviour, as do selected/
// search lists - the frontend falls back to client-side paging
// wherever the `pagination` key is absent.
$paginated = in_array($type, ['news_pi1', 'news_newsliststicky'], true)
&& (int)($settings['limit'] ?? 0) === 0
&& trim((string)($settings['selectedList'] ?? '')) === '';
if ($paginated
&& $this->cObj !== null
&& $this->cObj->getUserObjectType() === ContentObjectRenderer::OBJECTTYPE_USER
) {
// `page`/`category` sit in cacheHash.excludedParameters, so the
// TYPO3 page cache does NOT distinguish the query variants -
// a cacheable render would bake page 1 in for every URL. As
// USER_INT the element renders per request; EXT:headless wraps
// the INT marker itself (JsonContentObject detects
// '<!--INT_SCRIPT.'), and the SSR full-page cache keeps one
// variant per path+query, so per-request cost stays off the
// visitors' hot path.
$this->cObj->convertToUserIntObject();
return '';
}
$newsRows = $this->fetchNewsList($contentElement, $settings);
$items = array_map(fn (array $newsRow): array => $this->serializeNews($newsRow, $settings), $newsRows);
if ($paginated) {
return $this->renderPaginatedList($contentElement, $settings, $newsRows);
}
$items = array_map(fn (array $newsRow): array => $this->serializeNews($newsRow, $settings, 'list'), $newsRows);
$response = [
'mode' => 'list',
@@ -323,12 +355,28 @@ final class NewsJsonRenderer
* @param array<string,mixed> $settings
* @return array<string,mixed>
*/
private function serializeNews(array $news, array $settings = []): array
private function serializeNews(array $news, array $settings = [], string $mode = 'detail'): array
{
$uid = (int)($news['uid'] ?? 0);
$pathSegment = (string)($news['path_segment'] ?? '');
$urls = $this->buildNewsUrls($news, $settings);
// Detail view only (contract 0083-BE): the SEO articles carry a
// "Frequently asked questions" section as bare <p> pairs plus inline
// product links. The Q&A pairs move into a structured `faq` band
// (accordion in the frontend), linked products/categories become
// `mentionedProducts` cards; the body keeps everything else.
$detailBody = null;
$faqBand = null;
$mentionedProducts = [];
if ($mode !== 'list') {
$detailBody = \Evomedien\Vitec\Seo\EmbeddedJsonLd::extract(RteResolver::html($news['bodytext'] ?? ''))['html'];
$faqSplit = $this->extractFaqBand($detailBody);
$detailBody = $faqSplit['html'];
$faqBand = $faqSplit['faq'];
$mentionedProducts = $this->collectMentionedProducts($detailBody);
}
return [
'uid' => $uid,
'title' => (string)($news['title'] ?? ''),
@@ -338,7 +386,17 @@ final class NewsJsonRenderer
'detailUrl' => $urls['detailUrl'],
'canonicalUrl' => $urls['canonicalUrl'],
'teaser' => (string)($news['teaser'] ?? ''),
'bodytext' => RteResolver::html($news['bodytext'] ?? ''),
// List payloads do not carry the full article body: with ~600
// records that inflated /_json/insights/news to 2.6 MB (and the
// SSR HTML to 2.8 MB). Cards render from `teaser`; only records
// WITHOUT a teaser get a plain-text excerpt in `bodytext` so no
// card goes blank. The detail mode is unchanged.
// Editor-pasted schema.org blocks (FAQPage JSON in a trailing
// paragraph) are split out of the body - they reach Google via
// the page-level jsonLd @graph instead (PageJsonLdRenderer).
'bodytext' => $mode === 'list'
? (trim((string)($news['teaser'] ?? '')) !== '' ? '' : $this->listExcerpt((string)($news['bodytext'] ?? '')))
: (string)$detailBody,
'datetime' => (int)($news['datetime'] ?? 0),
'archive' => (int)($news['archive'] ?? 0),
'istopnews' => (bool)($news['istopnews'] ?? false),
@@ -350,9 +408,375 @@ final class NewsJsonRenderer
'relatedFiles' => $this->resolveFiles('tx_news_domain_model_news', 'fal_related_files', $uid),
'related' => $this->resolveRelatedNews($uid),
'relatedLinks' => $this->resolveRelatedLinks($uid),
'faq' => $faqBand,
'mentionedProducts' => $mentionedProducts,
];
}
/**
* Paginated list payload (contract 0070-FE/0075-BE): only the requested
* page's cards plus pagination meta, filter chips with full-list counts
* and the active category. Request: ?page=<n>&category=<slug> on the page
* URL; page is clamped into [1..pages] (page > pages serves the last
* page), an unknown category slug falls back to "all".
*
* @param array<string,mixed> $contentElement
* @param array<string,mixed> $settings
* @param array<int,array<string,mixed>> $rows full filtered list (no limit)
*/
private function renderPaginatedList(array $contentElement, array $settings, array $rows): string
{
$queryParams = $GLOBALS['TYPO3_REQUEST']?->getQueryParams() ?? [];
$requestedPage = max(1, (int)($queryParams['page'] ?? 1));
$requestedCategory = strtolower(trim((string)($queryParams['category'] ?? '')));
// USER_INT renders on every request; the 12-card page payload (image
// processing included) took 1-6 s on cold SSR misses (Seobility: 50
// slow URLs, all ?page=N). Keep each page/category variant for 15 min
// in the hash cache - cache:flush clears it, news edits are rare.
$cache = null;
$cacheId = '';
try {
$cache = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Cache\CacheManager::class)->getCache('hash');
$cacheId = 'vitec-newslist-' . (int)($contentElement['uid'] ?? 0)
. '-' . (int)($contentElement['sys_language_uid'] ?? 0)
. '-' . $requestedPage . '-' . md5($requestedCategory);
$cached = $cache->get($cacheId);
if (is_string($cached) && $cached !== '') {
return $cached;
}
} catch (\Throwable $e) {
$cache = null;
}
// All categories of all entries in ONE query - feeds both the filter
// chips (counts over the FULL list, independent of the active filter)
// and the per-card category labels.
$categoriesByNews = $this->categoriesForNewsUids(
array_map(static fn (array $row): int => (int)($row['uid'] ?? 0), $rows)
);
$categoryIndex = [];
foreach ($rows as $row) {
foreach ($categoriesByNews[(int)($row['uid'] ?? 0)] ?? [] as $category) {
$slug = $this->slugify((string)$category['title']);
if ($slug === '') {
continue;
}
if (!isset($categoryIndex[$slug])) {
$categoryIndex[$slug] = [
'uid' => (int)$category['uid'],
'title' => (string)$category['title'],
'slug' => $slug,
'count' => 0,
];
}
$categoryIndex[$slug]['count']++;
}
}
ksort($categoryIndex);
$activeCategory = isset($categoryIndex[$requestedCategory]) ? $requestedCategory : '';
if ($activeCategory !== '') {
$rows = array_values(array_filter($rows, function (array $row) use ($categoriesByNews, $activeCategory): bool {
foreach ($categoriesByNews[(int)($row['uid'] ?? 0)] ?? [] as $category) {
if ($this->slugify((string)$category['title']) === $activeCategory) {
return true;
}
}
return false;
}));
}
// Newest first, server-side (the flexform historically says
// "tstamp asc"; the frontend re-sorted - now it no longer has to).
usort($rows, static fn (array $a, array $b): int => ((int)($b['datetime'] ?? 0)) <=> ((int)($a['datetime'] ?? 0)));
$itemsPerPage = (int)($settings['list']['paginate']['itemsPerPage'] ?? 0);
if ($itemsPerPage <= 0) {
$itemsPerPage = 12;
}
$total = count($rows);
$pages = max(1, (int)ceil($total / $itemsPerPage));
$page = min($requestedPage, $pages);
$pageRows = array_slice($rows, ($page - 1) * $itemsPerPage, $itemsPerPage);
$items = array_map(
fn (array $row): array => $this->serializeListCard($row, $settings, $categoriesByNews[(int)($row['uid'] ?? 0)] ?? []),
$pageRows
);
$response = [
'mode' => 'list',
'items' => $items,
'pagination' => [
'page' => $page,
'pages' => $pages,
'itemsPerPage' => $itemsPerPage,
'total' => $total,
],
'categories' => array_values($categoryIndex),
'activeCategory' => $activeCategory,
'settings' => $this->buildSettingsPayload($contentElement, $settings),
];
$json = json_encode($response) ?: '';
if ($cache !== null && $json !== '') {
try {
$cache->set($cacheId, $json, ['tx_news'], 900);
} catch (\Throwable $e) {
// caching is best effort
}
}
return $json;
}
/**
* Lean card shape for paginated pages - only what the cards render
* (0070-FE field list); canonicalUrl, pathSegment, tags, related* are
* deliberately absent here.
*
* @param array<string,mixed> $news
* @param array<string,mixed> $settings
* @param array<int,array<string,mixed>> $categories
* @return array<string,mixed>
*/
private function serializeListCard(array $news, array $settings, array $categories): array
{
$uid = (int)($news['uid'] ?? 0);
$urls = $this->buildNewsUrls($news, $settings);
$teaser = trim((string)($news['teaser'] ?? ''));
return [
'uid' => $uid,
'title' => (string)($news['title'] ?? ''),
'alternativeTitle' => (string)($news['alternative_title'] ?? ''),
'type' => (string)($news['type'] ?? '0'),
'detailUrl' => $urls['detailUrl'],
'datetime' => (int)($news['datetime'] ?? 0),
'istopnews' => (bool)($news['istopnews'] ?? false),
'teaser' => $teaser,
'bodytext' => $teaser !== '' ? '' : $this->listExcerpt((string)($news['bodytext'] ?? '')),
'categories' => array_map(
static fn (array $category): array => ['uid' => (int)$category['uid'], 'title' => (string)$category['title']],
$categories
),
'media' => $this->resolveFiles('tx_news_domain_model_news', 'fal_media', $uid),
];
}
/**
* uid => categories map for a set of news records, resolved in one query
* (the per-record resolveCategories() would fire ~600 queries here).
*
* @param list<int> $newsUids
* @return array<int,array<int,array{uid:int,title:string}>>
*/
private function categoriesForNewsUids(array $newsUids): array
{
$newsUids = array_values(array_filter($newsUids));
if ($newsUids === []) {
return [];
}
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_category');
$rows = $queryBuilder
->select('c.uid', 'c.title', 'mm.uid_foreign')
->from('sys_category', 'c')
->join('c', 'sys_category_record_mm', 'mm', 'mm.uid_local = c.uid')
->where(
$queryBuilder->expr()->eq('mm.tablenames', $queryBuilder->createNamedParameter('tx_news_domain_model_news', ParameterType::STRING)),
$queryBuilder->expr()->eq('mm.fieldname', $queryBuilder->createNamedParameter('categories', ParameterType::STRING)),
$queryBuilder->expr()->in('mm.uid_foreign', $queryBuilder->createNamedParameter($newsUids, ArrayParameterType::INTEGER)),
$queryBuilder->expr()->eq('c.deleted', 0),
$queryBuilder->expr()->eq('c.hidden', 0)
)
->orderBy('c.title', 'ASC')
->executeQuery()
->fetchAllAssociative();
$map = [];
foreach ($rows as $row) {
$map[(int)$row['uid_foreign']][] = [
'uid' => (int)$row['uid'],
'title' => (string)$row['title'],
];
}
return $map;
}
/**
* Category title -> filter slug ("Press Release" -> "press-release"),
* mirroring the slugs the page URLs already carry.
*/
private function slugify(string $value): string
{
$value = mb_strtolower(trim($value));
$value = preg_replace('/[^a-z0-9]+/u', '-', $value) ?? $value;
return trim($value, '-');
}
/**
* Plain-text excerpt of an RTE body for list cards (teaser fallback).
*/
private function listExcerpt(string $html): string
{
$html = \Evomedien\Vitec\Seo\EmbeddedJsonLd::extract($html)['html'];
$text = trim((string)preg_replace('/\s+/u', ' ', strip_tags($html)));
if (mb_strlen($text) <= 300) {
return $text;
}
$cut = mb_substr($text, 0, 300);
$lastSpace = mb_strrpos($cut, ' ');
if ($lastSpace !== false && $lastSpace > 180) {
$cut = mb_substr($cut, 0, $lastSpace);
}
return rtrim($cut, " .,;:") . '...';
}
/**
* Splits the trailing FAQ section out of an article body.
*
* Expected shape (the 17 SEO articles): an <h2>/<h3> whose text starts
* with "Frequently asked" or "FAQ", followed by alternating question
* paragraphs (plain text ending in "?") and answer paragraphs (may
* contain links). Fail-safe: anything that does not yield at least two
* clean Q&A pairs leaves the HTML untouched.
*
* @return array{html: string, faq: array{heading: string, items: array<int,array{question: string, answer: string}>}|null}
*/
private function extractFaqBand(string $html): array
{
if (!preg_match('#<h([23])[^>]*>\s*((?:Frequently asked|FAQ)[^<]*)</h\1>#i', $html, $headingMatch, PREG_OFFSET_CAPTURE)) {
return ['html' => $html, 'faq' => null];
}
$heading = trim((string)$headingMatch[2][0]);
$sectionStart = (int)$headingMatch[0][1];
$cursor = $sectionStart + strlen((string)$headingMatch[0][0]);
$sectionEnd = $cursor;
$items = [];
$question = null;
$answerParts = [];
while (preg_match('#\G\s*<p[^>]*>(.*?)</p>#s', $html, $paragraph, 0, $cursor)) {
$text = trim(strip_tags($paragraph[1]));
if ($text === '') {
$cursor += strlen($paragraph[0]);
$sectionEnd = $cursor;
continue;
}
if (str_ends_with($text, '?')) {
if ($question !== null && $answerParts !== []) {
$items[] = ['question' => $question, 'answer' => implode("\n", $answerParts)];
}
$question = $text;
$answerParts = [];
} elseif ($question !== null) {
$answerParts[] = '<p>' . trim($paragraph[1]) . '</p>';
} else {
break;
}
$cursor += strlen($paragraph[0]);
$sectionEnd = $cursor;
}
if ($question !== null && $answerParts !== []) {
$items[] = ['question' => $question, 'answer' => implode("\n", $answerParts)];
}
if (count($items) < 2) {
return ['html' => $html, 'faq' => null];
}
$clean = substr($html, 0, $sectionStart) . substr($html, $sectionEnd);
return [
'html' => trim($clean),
'faq' => ['heading' => $heading, 'items' => $items],
];
}
/**
* Cards for every product detail page (/product/<slug>) and product
* category page (/products/<slug>) linked inside an article body -
* order of first appearance, deduplicated, at most six.
*
* @return array<int,array<string,mixed>>
*/
private function collectMentionedProducts(string $html): array
{
if (!preg_match_all('#href="(/product/([a-z0-9-]+)|/products/([a-z0-9-]+))"#', $html, $matches, PREG_SET_ORDER)) {
return [];
}
$cards = [];
foreach ($matches as $match) {
$href = (string)$match[1];
if (isset($cards[$href]) || count($cards) >= 6) {
continue;
}
try {
if (!empty($match[2])) {
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_vitec_domain_model_product');
$row = $qb->select('uid', 'title', 'teaser', 'subtitle')
->from('tx_vitec_domain_model_product')
->where(
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0),
$qb->expr()->eq('hideonwebsite', 0),
$qb->expr()->eq('slug', $qb->createNamedParameter((string)$match[2], ParameterType::STRING))
)
->setMaxResults(1)->executeQuery()->fetchAssociative();
if (!is_array($row)) {
continue;
}
$cards[$href] = [
'type' => 'product',
'title' => (string)($row['title'] ?? ''),
'text' => trim(strip_tags((string)($row['teaser'] ?? ''))) ?: (string)($row['subtitle'] ?? ''),
'link' => $href,
'image' => $this->resolveFiles('tx_vitec_domain_model_product', 'productimage', (int)$row['uid'])[0] ?? null,
];
} else {
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('pages');
$row = $qb->select('uid', 'title', 'description', 'abstract')
->from('pages')
->where(
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0),
$qb->expr()->eq('slug', $qb->createNamedParameter('/products/' . (string)$match[3], ParameterType::STRING))
)
->setMaxResults(1)->executeQuery()->fetchAssociative();
if (!is_array($row)) {
continue;
}
$cards[$href] = [
'type' => 'category',
'title' => (string)($row['title'] ?? ''),
'text' => (string)(($row['description'] ?? '') ?: ($row['abstract'] ?? '')),
'link' => $href,
'image' => $this->resolveFiles('pages', 'media', (int)$row['uid'])[0] ?? null,
];
}
} catch (\Throwable $e) {
continue;
}
}
return array_values($cards);
}
/**
* Where a news item actually points.
*
@@ -673,6 +1097,9 @@ final class NewsJsonRenderer
$files[] = [
'uid' => (int)$row['uid'],
'url' => $imageService->getImageUri($processed),
// Editor-maintained alt text; the frontend falls back to
// the article title when this is empty (0070-FE, B5).
'alternative' => (string)($fileReference->getAlternative() ?? ''),
];
} catch (\Throwable $e) {
continue;
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\UserFunc;
use Evomedien\Vitec\Service\PageCheckService;
use TYPO3\CMS\Core\Attribute\AsAllowedCallable;
use TYPO3\CMS\Core\Service\FlexFormService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
/**
* JSON for the content element `vitec_pagecheck` (pages /markets/market-check
* and /solutions/solution-check): the page content audit of one scope,
* rendered by the React front end like the product check.
*
* Emitted under content.pagecheck:
*
* without query {mode: "overview", scope, scopeLabel, generatedAt, fields[], summary, pages[]}
* ?check=<pageUid> {mode: "detail", page: {…} | null}
* on failure {mode: "error", error}
*
* The scope (markets | solutions) comes from the element's FlexForm
* (settings.scope). Always USER_INT - the audit shows the database as it is
* right now; `check` is excluded from the cHash (ext_localconf.php, shared
* with the product check). The front end loads the detail variant
* client-side from /_json, one request per opened row.
*/
final class PageCheckJsonRenderer
{
protected ?ContentObjectRenderer $cObj = null;
public function setContentObjectRenderer(ContentObjectRenderer $cObj): void
{
$this->cObj = $cObj;
}
#[AsAllowedCallable]
public function render(string $content, array $conf): string
{
if ($this->cObj !== null && $this->cObj->getUserObjectType() === ContentObjectRenderer::OBJECTTYPE_USER) {
$this->cObj->convertToUserIntObject();
return '';
}
try {
$scope = 'markets';
$flex = (string)($this->cObj?->data['pi_flexform'] ?? '');
if (trim($flex) !== '') {
$settings = GeneralUtility::makeInstance(FlexFormService::class)->convertFlexFormContentToArray($flex)['settings'] ?? [];
if (isset(PageCheckService::SCOPES[(string)($settings['scope'] ?? '')])) {
$scope = (string)$settings['scope'];
}
}
$service = GeneralUtility::makeInstance(PageCheckService::class);
$queryParams = $GLOBALS['TYPO3_REQUEST']?->getQueryParams() ?? [];
$check = (int)($queryParams['check'] ?? 0);
if ($check > 0) {
$payload = ['mode' => 'detail', 'scope' => $scope, 'page' => $service->detail($check)];
} else {
$payload = ['mode' => 'overview'] + $service->overview($scope);
}
} catch (\Throwable $e) {
$payload = ['mode' => 'error', 'error' => $e->getMessage()];
}
return (string)json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
}
}
@@ -78,8 +78,17 @@ final class PageJsonLdRenderer
$nodes[] = $service->buildWebSite($base, $siteName, $hasOrganization);
}
// BreadcrumbList from the rootline.
$breadcrumb = $service->buildBreadcrumbList($this->breadcrumbItems($rootLine), $base);
// BreadcrumbList from the rootline. On record detail pages the last
// crumb is the generic shell page ("Story", "Product", "Article")
// and is swapped for the record itself (0108-FE, F11).
$args = $this->routeArguments($request);
$crumbs = $this->breadcrumbItems($rootLine);
$recordCrumb = $this->recordCrumb($args);
if ($recordCrumb !== null && $crumbs !== []) {
array_pop($crumbs);
$crumbs[] = $recordCrumb;
}
$breadcrumb = $service->buildBreadcrumbList($crumbs, $base);
if ($breadcrumb !== null) {
$nodes[] = $breadcrumb;
}
@@ -112,6 +121,59 @@ final class PageJsonLdRenderer
// Product on product detail pages (routed record, SEO audit 2026-11-09).
$productArg = (string)($args['tx_vitec_productshow']['product'] ?? '');
if ($productArg !== '') {
$productRow = $this->fetchDetailRecord(
'tx_vitec_domain_model_product',
is_numeric($productArg) ? 'uid' : 'slug',
$productArg
);
if ($productRow !== null) {
$productImages = $this->recordImageUrls('tx_vitec_domain_model_product', 'productimage', (int)$productRow['uid'], $base, $service);
if ($productImages === []) {
$productImages = $this->recordImageUrls('tx_vitec_domain_model_product', 'ogimage', (int)$productRow['uid'], $base, $service);
}
$nodes[] = $service->buildProduct([
'uid' => (int)$productRow['uid'],
'title' => (string)($productRow['title'] ?? ''),
'teaser' => (string)($productRow['teaser'] ?? ''),
'description' => (string)($productRow['description'] ?? ''),
'slug' => (string)($productRow['slug'] ?? ''),
], $base, $productImages, $hasOrganization);
}
}
// NewsArticle on EXT:news detail pages (the shell page carries no record data).
$newsUid = (int)($args['tx_news_pi1']['news'] ?? 0);
if ($newsUid > 0) {
$newsRow = $this->fetchDetailRecord('tx_news_domain_model_news', 'uid', (string)$newsUid);
if ($newsRow !== null) {
$newsTitle = trim((string)($newsRow['alternative_title'] ?? ''));
if ($newsTitle === '') {
$newsTitle = (string)($newsRow['title'] ?? '');
}
$nodes[] = $service->buildNewsArticle([
'seo_title' => '',
'title' => $newsTitle,
'description' => trim(strip_tags((string)($newsRow['teaser'] ?? ''))),
'abstract' => '',
'crdate' => (int)($newsRow['datetime'] ?? 0),
'SYS_LASTCHANGED' => (int)($newsRow['tstamp'] ?? 0),
'slug' => '/news/article/' . (string)($newsRow['path_segment'] ?? ''),
'author' => (string)($newsRow['author'] ?? ''),
'author_email' => (string)($newsRow['author_email'] ?? ''),
], $base, $this->recordImageUrls('tx_news_domain_model_news', 'fal_media', $newsUid, $base, $service), $hasOrganization);
// Editor-pasted schema blocks (e.g. FAQPage JSON in a
// trailing bodytext paragraph) join the @graph here;
// NewsJsonRenderer strips them out of the rendered body.
foreach (\Evomedien\Vitec\Seo\EmbeddedJsonLd::extract((string)($newsRow['bodytext'] ?? ''))['nodes'] as $embeddedNode) {
$nodes[] = $embeddedNode;
}
}
}
return $service->encodeGraph($nodes);
} catch (\Throwable $e) {
return '';
@@ -166,6 +228,40 @@ final class PageJsonLdRenderer
];
}
/**
* Breadcrumb item for the routed detail record (product, news, story), or
* null on plain pages.
*
* @param array<string,mixed> $args
* @return array{name:string, url:string}|null
*/
private function recordCrumb(array $args): ?array
{
$productArg = (string)($args['tx_vitec_productshow']['product'] ?? '');
if ($productArg !== '') {
$row = $this->fetchDetailRecord('tx_vitec_domain_model_product', is_numeric($productArg) ? 'uid' : 'slug', $productArg);
if ($row !== null && (string)($row['slug'] ?? '') !== '') {
return ['name' => (string)($row['title'] ?? ''), 'url' => '/product/' . $row['slug']];
}
}
$newsUid = (int)($args['tx_news_pi1']['news'] ?? 0);
if ($newsUid > 0) {
$row = $this->fetchDetailRecord('tx_news_domain_model_news', 'uid', (string)$newsUid);
if ($row !== null && (string)($row['path_segment'] ?? '') !== '') {
$name = trim((string)($row['alternative_title'] ?? '')) ?: (string)($row['title'] ?? '');
return ['name' => $name, 'url' => '/news/article/' . $row['path_segment']];
}
}
$usecaseUid = (int)($args['tx_vitec_usecaseshow']['usecase'] ?? 0);
if ($usecaseUid > 0) {
$row = $this->fetchDetailRecord('tx_vitec_domain_model_usecase', 'uid', (string)$usecaseUid);
if ($row !== null && (string)($row['slug'] ?? '') !== '') {
return ['name' => (string)($row['title'] ?? ''), 'url' => '/success-stories/' . $row['slug']];
}
}
return null;
}
/**
* Build ordered breadcrumb items (root → current) from the rootline.
* Skips spacers, folders and recyclers; uses nav_title with title fallback.
@@ -410,4 +506,77 @@ final class PageJsonLdRenderer
}
}
/**
* Route arguments of the current request (record detail context).
*
* @return array<string,mixed>
*/
private function routeArguments($request): array
{
$routing = $request->getAttribute('routing');
if ($routing instanceof \TYPO3\CMS\Core\Routing\PageArguments) {
return array_replace_recursive($request->getQueryParams(), $routing->getArguments());
}
return $request->getQueryParams();
}
/**
* One visible record by uid or slug. Null when missing.
*
* @return array<string,mixed>|null
*/
private function fetchDetailRecord(string $table, string $field, string $value): ?array
{
try {
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
$row = $qb->select('*')->from($table)
->where(
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0),
$field === 'uid'
? $qb->expr()->eq('uid', $qb->createNamedParameter((int)$value, ParameterType::INTEGER))
: $qb->expr()->eq($field, $qb->createNamedParameter($value, ParameterType::STRING))
)
->setMaxResults(1)->executeQuery()->fetchAssociative();
return is_array($row) ? $row : null;
} catch (\Throwable $e) {
return null;
}
}
/**
* Absolute URLs of a record's FAL images (unprocessed originals).
*
* @return string[]
*/
private function recordImageUrls(string $table, string $field, int $uid, string $base, StructuredDataService $service): array
{
try {
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_file_reference');
$rows = $qb->select('f.identifier')
->from('sys_file_reference', 'fr')
->join('fr', 'sys_file', 'f', 'fr.uid_local = f.uid')
->where(
$qb->expr()->eq('fr.tablenames', $qb->createNamedParameter($table, ParameterType::STRING)),
$qb->expr()->eq('fr.fieldname', $qb->createNamedParameter($field, ParameterType::STRING)),
$qb->expr()->eq('fr.uid_foreign', $qb->createNamedParameter($uid, ParameterType::INTEGER)),
$qb->expr()->eq('fr.deleted', 0),
$qb->expr()->eq('fr.hidden', 0),
$qb->expr()->eq('f.missing', 0)
)
->orderBy('fr.sorting_foreign', 'ASC')
->executeQuery()->fetchAllAssociative();
$urls = [];
foreach ($rows as $row) {
$identifier = (string)($row['identifier'] ?? '');
if ($identifier !== '') {
$urls[] = $service->absUrl('/fileadmin' . $identifier, $base);
}
}
return $urls;
} catch (\Throwable $e) {
return [];
}
}
}
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\UserFunc;
use Evomedien\Vitec\Service\ProductCheckService;
use TYPO3\CMS\Core\Attribute\AsAllowedCallable;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
/**
* JSON for the content element `vitec_productcheck` (page /products/
* product-check): the product content audit, rendered by the React front
* end instead of the standalone page public/product-check/.
*
* Emitted under content.productcheck:
*
* without query {mode: "overview", generatedAt, fields[], summary, products[]}
* ?check=<uid> {mode: "detail", product: {…} | null}
* on failure {mode: "error", error}
*
* Always USER_INT: the audit must show the database as it is right now,
* so neither the TYPO3 page cache nor the `check` variant may stick.
* `check` is excluded from the cHash in ext_localconf.php. The front end
* is expected to load the detail variant client-side from /_json (one
* request per opened row), not to navigate the SSR page with the query.
*/
final class ProductCheckJsonRenderer
{
protected ?ContentObjectRenderer $cObj = null;
public function setContentObjectRenderer(ContentObjectRenderer $cObj): void
{
$this->cObj = $cObj;
}
#[AsAllowedCallable]
public function render(string $content, array $conf): string
{
if ($this->cObj !== null && $this->cObj->getUserObjectType() === ContentObjectRenderer::OBJECTTYPE_USER) {
$this->cObj->convertToUserIntObject();
return '';
}
try {
$service = GeneralUtility::makeInstance(ProductCheckService::class);
$queryParams = $GLOBALS['TYPO3_REQUEST']?->getQueryParams() ?? [];
$check = (int)($queryParams['check'] ?? 0);
if ($check > 0) {
$payload = ['mode' => 'detail', 'product' => $service->detail($check)];
} else {
$payload = ['mode' => 'overview'] + $service->overview();
}
} catch (\Throwable $e) {
$payload = ['mode' => 'error', 'error' => $e->getMessage()];
}
return (string)json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
}
}
@@ -21,6 +21,12 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
*
* Extends ProductListJsonRenderer purely to reuse its serializeProduct()
* and image handling - same filters, same shape, one source of truth.
*
* "No product" returns an EMPTY STRING, never json_encode(null): headless
* only decodes strings that look like objects/arrays, so the text "null"
* went out as the string "null" and crashed the front end (ticket
* 0068-FE). The TypoScript sets ifEmptyReturnNull = 1 on this field,
* which turns the empty string into a real JSON null.
*/
final class ProductFeatureJsonRenderer extends ProductListJsonRenderer
{
@@ -33,7 +39,7 @@ final class ProductFeatureJsonRenderer extends ProductListJsonRenderer
->convertFlexFormContentToArray((string)($row['pi_flexform'] ?? ''));
$productUid = (int)($flexFormData['settings']['product'] ?? 0);
if ($productUid <= 0) {
return (string)json_encode(null);
return '';
}
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
@@ -47,12 +53,12 @@ final class ProductFeatureJsonRenderer extends ProductListJsonRenderer
)
->executeQuery()->fetchAssociative();
if (!$product) {
return (string)json_encode(null);
return '';
}
return (string)json_encode($this->serializeProduct($product), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
} catch (\Throwable) {
return (string)json_encode(null);
return '';
}
}
}
@@ -17,7 +17,13 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
*
* { "query": "mgw d",
* "suggestions": [ "MGW Diamond", "MGW Diamond-H", ... ],
* "products": [ { "title", "teaser", "image", "url" } ] }
* "overviews": [ { "title", "link", "path": [category, group] } ],
* "products": [ { "title", "teaser", "image", "url",
* "overview": { "title", "link", "path" } | null } ] }
*
* Since menu v2 (2026-11-02) overview pages of the products tree match by
* title (every typed term contained) and lead the suggestions; each product
* names the overview page linking it.
*
* Runs through Solr (decision Olli: synonyms, ranking and the indexed
* product set stay identical to the site search), restricted to product
@@ -60,12 +66,122 @@ final class ProductMenuSearchJsonRenderer
// e.g. a typed hyphen or a complete word the prefix form misses
$documents = $this->search($connection, $query, false);
}
return $this->json($query, $this->cards($documents));
// menu v2 (2026-11-02): overview pages are hits of their own, and
// every product names the overview page it belongs to (path)
$overviews = $this->overviewPages();
$products = $this->withOverview($this->cards($documents), $overviews);
return $this->json($query, $products, $this->matchingOverviews($overviews, $query));
} catch (\Throwable) {
return $this->json($query, []);
}
}
/**
* The overview pages of the products menu tree (/products > category >
* sub-category > page, see ProvideProductCatalog) with their menu path.
* Category and sub-category are pages since 2026-11-05 (folders before -
* both are read).
*
* @return array<int,array{uid:int,title:string,link:string,path:string[]}> page uid => overview
*/
private function overviewPages(): array
{
$pool = GeneralUtility::makeInstance(ConnectionPool::class);
$root = (int)$pool->getConnectionForTable('pages')->fetchOne(
"SELECT uid FROM pages WHERE deleted = 0 AND hidden = 0 AND slug = '/products' AND sys_language_uid IN (0,-1)"
);
if ($root === 0) {
return [];
}
$level = function (array $parents, array $doktypes) use ($pool): array {
if ($parents === []) {
return [];
}
$queryBuilder = $pool->getQueryBuilderForTable('pages');
$rows = $queryBuilder->select('uid', 'pid', 'title', 'nav_title', 'slug')->from('pages')
->where(
$queryBuilder->expr()->in('pid', $queryBuilder->createNamedParameter($parents, ArrayParameterType::INTEGER)),
$queryBuilder->expr()->in('doktype', $queryBuilder->createNamedParameter($doktypes, ArrayParameterType::INTEGER)),
$queryBuilder->expr()->eq('nav_hide', 0)
)
->orderBy('sorting')
->executeQuery()->fetchAllAssociative();
// menu label = nav_title when set (same rule as ProvideProductCatalog)
foreach ($rows as &$row) {
if (trim((string)($row['nav_title'] ?? '')) !== '') {
$row['title'] = $row['nav_title'];
}
}
return $rows;
};
$categories = array_column($level([$root], [1, 254]), null, 'uid');
$groups = array_column($level(array_keys($categories), [1, 254]), null, 'uid');
$overviews = [];
foreach ($level(array_keys($groups), [1]) as $page) {
$group = $groups[(int)$page['pid']];
$overviews[(int)$page['uid']] = [
'uid' => (int)$page['uid'],
'title' => (string)$page['title'],
'link' => (string)$page['slug'],
'path' => [(string)$categories[(int)$group['pid']]['title'], (string)$group['title']],
];
}
return $overviews;
}
/**
* Adds `overview` {title, link, path} to every product card whose detail
* page is linked from an overview page's cards (first one wins).
*
* @param array<int,array<string,mixed>> $products
* @param array<int,array<string,mixed>> $overviews
* @return array<int,array<string,mixed>>
*/
private function withOverview(array $products, array $overviews): array
{
if ($products === [] || $overviews === []) {
return $products;
}
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('tt_content');
$rows = $queryBuilder->select('pid', 'vitec_primary_cta')->from('tt_content')
->where(
$queryBuilder->expr()->in('pid', $queryBuilder->createNamedParameter(array_keys($overviews), ArrayParameterType::INTEGER)),
$queryBuilder->expr()->eq('CType', $queryBuilder->createNamedParameter('vitec_card')),
$queryBuilder->expr()->in('vitec_primary_cta', $queryBuilder->createNamedParameter(array_column($products, 'url'), ArrayParameterType::STRING))
)
->orderBy('uid')
->executeQuery()->fetchAllAssociative();
$overviewByUrl = [];
foreach ($rows as $row) {
$overviewByUrl[(string)$row['vitec_primary_cta']] ??= $overviews[(int)$row['pid']];
}
foreach ($products as &$product) {
$overview = $overviewByUrl[$product['url']] ?? null;
$product['overview'] = $overview === null ? null
: ['title' => $overview['title'], 'link' => $overview['link'], 'path' => $overview['path']];
}
return $products;
}
/**
* Overview pages whose title contains every typed term (case-insensitive).
*
* @param array<int,array<string,mixed>> $overviews
* @return array<int,array{title:string,link:string,path:string[]}>
*/
private function matchingOverviews(array $overviews, string $query): array
{
$terms = preg_split('/\s+/u', mb_strtolower($query), -1, PREG_SPLIT_NO_EMPTY) ?: [];
$hits = [];
foreach ($overviews as $overview) {
$haystack = mb_strtolower($overview['title'] . ' ' . implode(' ', $overview['path']));
if ($terms !== [] && array_reduce($terms, fn(bool $all, string $t): bool => $all && str_contains($haystack, $t), true)) {
$hits[] = ['title' => $overview['title'], 'link' => $overview['link'], 'path' => $overview['path']];
}
}
return $hits;
}
/** @return array<int,array<string,mixed>> Solr document fields, best first */
private function search(\ApacheSolrForTypo3\Solr\System\Solr\SolrConnection $connection, string $input, bool $prefixLastTerm): array
{
@@ -154,12 +270,16 @@ final class ProductMenuSearchJsonRenderer
return $teasers;
}
/** @param array<int,array<string,mixed>> $products */
private function json(string $query, array $products): string
/**
* @param array<int,array<string,mixed>> $products
* @param array<int,array<string,mixed>> $overviews
*/
private function json(string $query, array $products, array $overviews = []): string
{
return (string)json_encode([
'query' => $query,
'suggestions' => array_values(array_unique(array_column($products, 'title'))),
'suggestions' => array_values(array_unique(array_merge(array_column($overviews, 'title'), array_column($products, 'title')))),
'overviews' => $overviews,
'products' => $products,
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}
@@ -258,6 +258,11 @@ class ProductShowJsonRenderer
'resources' => $this->resourcesBand($uid, (string)(($product['resources_layout'] ?? '') ?: '50-50')),
];
// "Why choose <product>?" cards (contract 0036-FE): null while the
// product has no feature records - the front end then keeps parsing
// the `applications` HTML above.
$data['features'] = $this->featuresBand($uid, (string)$data['applications'], (string)$data['title']);
// schema.org JSON-LD (Product + optional VideoObject) as a ready-to-emit
// @graph string. The frontend injects it as <script type="application/ld+json">.
// Supersedes the manual `structureddata` field above (kept for backwards
@@ -267,6 +272,67 @@ class ProductShowJsonRenderer
return $data;
}
/**
* The "Why choose" card band: editor-maintained tx_vitec_product_feature
* records in sorting order, icons resolved to urls. The heading is the
* first <h3> of the applications HTML, falling back to the generic
* "Why choose <title>?".
*
* @return array{heading:string,items:array<int,array<string,mixed>>}|null
*/
protected function featuresBand(int $productUid, string $applicationsHtml, string $productTitle): ?array
{
try {
$pool = GeneralUtility::makeInstance(ConnectionPool::class);
$rows = $pool->getConnectionForTable('tx_vitec_product_feature')->fetchAllAssociative(
'SELECT uid, title, cardtext FROM tx_vitec_product_feature
WHERE deleted = 0 AND hidden = 0 AND product = ? ORDER BY sorting, uid',
[$productUid]
);
if ($rows === []) {
return null;
}
$icons = [];
foreach ($pool->getConnectionForTable('sys_file_reference')->fetchAllAssociative(
"SELECT r.uid_foreign, r.alternative, f.identifier FROM sys_file_reference r
JOIN sys_file f ON f.uid = r.uid_local
WHERE r.deleted = 0 AND r.hidden = 0 AND r.tablenames = 'tx_vitec_product_feature'
AND r.fieldname = 'icon'
AND r.uid_foreign IN (" . implode(',', array_map('intval', array_column($rows, 'uid'))) . ')
ORDER BY r.uid_foreign, r.sorting_foreign'
) as $iconRow) {
$icons[(int)$iconRow['uid_foreign']] ??= [
'url' => '/fileadmin' . (string)$iconRow['identifier'],
'alternative' => trim((string)($iconRow['alternative'] ?? '')),
];
}
$heading = '';
if (preg_match('#<h3[^>]*>(.*?)</h3>#is', $applicationsHtml, $m)) {
$heading = trim(strip_tags($m[1]));
}
if ($heading === '') {
$heading = 'Why choose ' . $productTitle . '?';
}
$items = [];
foreach ($rows as $row) {
$icon = $icons[(int)$row['uid']] ?? null;
if ($icon !== null && $icon['alternative'] === '') {
$icon['alternative'] = (string)$row['title'];
}
$items[] = [
'title' => (string)$row['title'],
'text' => trim((string)($row['cardtext'] ?? '')),
'icon' => $icon,
];
}
return ['heading' => $heading, 'items' => $items];
} catch (\Throwable) {
return null;
}
}
/**
* Build the product's structured-data @graph from the already-serialized
* product array. Exception-safe: returns '' on any failure.
@@ -165,9 +165,24 @@ class UsecaseListJsonRenderer
$rows = $ordered;
}
// Testimonial bands on sub-market/solution pages shipped ALL 70
// stories (226 kB per page, the same quotes on ~90 pages - Seobility
// 2026-11-10: 34 pages > 500 kB, 1,846 repeated text blocks). Only
// stories with a maintained quote are testimonials; the current
// market's stories come first, capped at twelve. Editor-picked
// lists stay untouched.
if ($layout === 'testimonials' && $selectedUids === []) {
$rows = $this->testimonialRows($rows);
}
$usecases = array_map(
static function (array $u) use ($serializer, $detailBase): array {
$item = $serializer->serializeListItem($u);
// The list cards read url/srcset/alternative only; the card
// crop variants cost about 1 kB per story (0102-FE, F8).
if (is_array($item['cardImage'] ?? null)) {
unset($item['cardImage']['crops']);
}
// A story with its own detail page wins; only the others
// fall back to the Single PID plus the slug, so nothing
// changes for the stories that have no page assigned.
@@ -210,6 +225,80 @@ class UsecaseListJsonRenderer
* Callers here build paths by concatenation, so the empty string stays the
* "no link" value; LinkResolver's null is coalesced away.
*/
/**
* Stories for a testimonial band: quoted ones only, current market first,
* at most twelve. Falls back to the first twelve rows when no story has
* a quote yet.
*
* @param array<int,array<string,mixed>> $rows
* @return array<int,array<string,mixed>>
*/
private function testimonialRows(array $rows): array
{
$quoted = array_values(array_filter(
$rows,
static fn (array $r): bool => trim((string)($r['quote'] ?? '')) !== ''
));
if ($quoted === []) {
return array_slice($rows, 0, 12);
}
try {
$marketUids = $this->currentMarketUids();
if ($marketUids !== []) {
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_vitec_usecase_market_mm');
$inMarket = array_map('intval', $qb
->select('uid_local')
->from('tx_vitec_usecase_market_mm')
->where($qb->expr()->in('uid_foreign', $qb->createNamedParameter($marketUids, \Doctrine\DBAL\ArrayParameterType::INTEGER)))
->executeQuery()
->fetchFirstColumn());
$matching = array_values(array_filter($quoted, static fn (array $r): bool => in_array((int)$r['uid'], $inMarket, true)));
if (count($matching) >= 3) {
$others = array_values(array_filter($quoted, static fn (array $r): bool => !in_array((int)$r['uid'], $inMarket, true)));
$quoted = array_merge($matching, $others);
}
}
} catch (\Throwable $e) {
// keep the quoted stories in default order
}
return array_slice($quoted, 0, 12);
}
/**
* Market records whose detail page lies on the current rootline.
*
* @return list<int>
*/
private function currentMarketUids(): array
{
$request = $GLOBALS['TYPO3_REQUEST'] ?? null;
$pageInfo = $request?->getAttribute('frontend.page.information');
if ($pageInfo === null) {
return [];
}
$pageUids = array_values(array_filter(array_map(
static fn (array $p): int => (int)($p['uid'] ?? 0),
$pageInfo->getRootLine()
)));
if ($pageUids === []) {
return [];
}
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_vitec_domain_model_market');
return array_map('intval', $qb
->select('uid')
->from('tx_vitec_domain_model_market')
->where(
$qb->expr()->eq('deleted', 0),
$qb->expr()->in('detail_page', $qb->createNamedParameter($pageUids, \Doctrine\DBAL\ArrayParameterType::INTEGER))
)
->executeQuery()
->fetchFirstColumn());
}
private function resolvePageUrl(int $pageUid): string
{
return LinkResolver::pageUrl($pageUid) ?? '';
@@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Widgets;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Shared markup for the three SSR dashboard tiles, in the look of the
* "VITEC SSR" cockpit module (dark VITEC navy, orange accents).
* The dashboard inserts widget HTML without running scripts, so rings
* and bars are drawn here as plain SVG/HTML. Styles:
* Resources/Public/Css/dashboard-ssr.css (scoped to .vc-widget).
*/
final class CockpitHtml
{
public const CSS = 'EXT:vitec/Resources/Public/Css/dashboard-ssr.css';
public static function e(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES);
}
/**
* Tile wrapper; $info becomes the round "?" button top right whose
* tooltip explains the tile (CSS only: hover or keyboard focus).
*/
public static function wrap(string $inner, string $info = '', string $id = ''): string
{
$button = '';
if ($info !== '') {
$tipId = 'vcw-tip-' . ($id !== '' ? $id : substr(md5($info), 0, 8));
$button = '<span class="vcw-info"><button type="button" class="vcw-info-btn" aria-label="About this tile"'
. ' aria-describedby="' . $tipId . '">?</button><span class="vcw-tip" role="tooltip" id="' . $tipId . '">'
. self::e($info) . '</span></span>';
}
return '<div class="vc-widget">' . $button . $inner . '</div>';
}
public static function pill(string $tone, string $text, string $title = ''): string
{
return '<span class="vcw-pill is-' . self::e($tone) . '"'
. ($title !== '' ? ' title="' . self::e($title) . '"' : '') . '>' . self::e($text) . '</span>';
}
/**
* Ring gauge, 0..1, with a short text in the middle.
*/
public static function ring(float $fraction, string $tone, string $center, int $size = 76): string
{
$c = 2 * M_PI * 40;
$offset = $c * (1 - max(0.0, min(1.0, $fraction)));
return '<div class="vcw-ring" style="width:' . $size . 'px;height:' . $size . 'px">'
. '<svg viewBox="0 0 96 96" width="' . $size . '" height="' . $size . '" aria-hidden="true">'
. '<circle class="vcw-track" cx="48" cy="48" r="40"/>'
. '<circle class="vcw-val is-' . self::e($tone) . '" cx="48" cy="48" r="40" stroke-dasharray="'
. round($c, 2) . '" stroke-dashoffset="' . round($offset, 2) . '"/></svg>'
. '<span class="vcw-ring-c">' . self::e($center) . '</span></div>';
}
/**
* Same labels and tones as the purge timeline in the module.
*
* @return array{0: string, 1: string, 2: string} [label, tone, user]
*/
public static function trigger(string $source): array
{
$who = '';
if (str_contains($source, ':')) {
[$source, $who] = explode(':', $source, 2);
}
return match ($source) {
'module' => ['manual', 'orange', $who],
'contextmenu' => ['context menu', 'navy', $who],
'editor-save', 'editor-command' => ['auto-purge', 'ok', $who],
'switch-on' => ['switch-on', 'warn', $who],
default => [$source, 'sky', $who],
};
}
/**
* Availability strip of the last 24 hours, 96 windows of 15 minutes,
* from the health log (cron vitec:ssr-health every 5 minutes).
*
* @return array{html: string, uptime: ?float, slow: int, down: int, samples: int}
*/
public static function strip(ConnectionPool $pool): array
{
$slot = 900;
$end = intdiv(time(), $slot) * $slot + $slot;
$windows = array_fill(0, 96, null);
$ok = 0;
$n = 0;
try {
$rows = $pool->getConnectionForTable('tx_vitec_ssrhealthlog')->executeQuery(
'SELECT crdate, ok, response_ms FROM tx_vitec_ssrhealthlog WHERE crdate > ?',
[$end - 96 * $slot]
)->fetchAllAssociative();
} catch (\Throwable) {
$rows = [];
}
foreach ($rows as $r) {
$i = 95 - intdiv($end - 1 - (int)$r['crdate'], $slot);
if ($i < 0 || $i > 95) {
continue;
}
$n++;
$isOk = (int)$r['ok'] === 1;
$ok += $isOk ? 1 : 0;
$state = !$isOk ? 2 : ((int)$r['response_ms'] > 400 ? 1 : 0);
if ($windows[$i] === null || $state > $windows[$i]['s']) {
$windows[$i] = ['s' => $state, 'ms' => (int)$r['response_ms']];
}
}
$slow = 0;
$down = 0;
$html = '<div class="vcw-bars" role="img" aria-label="Availability of the SSR node over the last 24 hours">';
foreach ($windows as $i => $w) {
$from = $end - (96 - $i) * $slot;
$label = date('H:i', $from) . '–' . date('H:i', $from + $slot) . ' · ';
if ($w === null) {
$html .= '<i class="is-none" title="' . self::e($label . 'no sample') . '"></i>';
continue;
}
$cls = ['', 'is-slow', 'is-down'][$w['s']];
$slow += $w['s'] === 1 ? 1 : 0;
$down += $w['s'] === 2 ? 1 : 0;
$text = $w['s'] === 2 ? 'down' : ($w['s'] === 1 ? 'slow, ' : 'ok, ') . ($w['s'] === 2 ? '' : $w['ms'] . ' ms');
$html .= '<i class="' . $cls . '" title="' . self::e($label . $text) . '"></i>';
}
$html .= '</div><div class="vcw-axis"><span>' . date('H:i', $end - 96 * $slot) . '</span><span>'
. date('H:i', $end - 48 * $slot) . '</span><span>now</span></div>';
return [
'html' => $html,
'uptime' => $n > 0 ? round($ok / $n * 100, 1) : null,
'slow' => $slow,
'down' => $down,
'samples' => $n,
];
}
public static function moduleLink(string $label = 'Open the SSR cockpit'): string
{
try {
$uri = (string)GeneralUtility::makeInstance(UriBuilder::class)->buildUriFromRoute('web_vitecssr');
} catch (\Throwable) {
return '';
}
return '<a class="vcw-link" href="' . self::e($uri) . '">' . self::e($label) . ' &rarr;</a>';
}
}
@@ -0,0 +1,171 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Widgets;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Dashboard\Widgets\AdditionalCssInterface;
use TYPO3\CMS\Dashboard\Widgets\WidgetConfigurationInterface;
use TYPO3\CMS\Dashboard\Widgets\WidgetInterface;
/**
* Dashboard tile: SSR error counters and cache hit rate.
*
* Source: the cumulative /__stats counters of the SSR node, sampled every
* 5 minutes by vitec:ssr-health into tx_vitec_ssrhealthlog (stat_* columns,
* -1 = no data for that sample). The counters reset whenever the node
* restarts (deploy!), so "totals" are "since node start" and the 24h error
* figures are sums of positive deltas between consecutive samples - a
* reset simply contributes nothing.
*/
final class SsrErrorsWidget implements WidgetInterface, AdditionalCssInterface
{
public function __construct(
private readonly WidgetConfigurationInterface $configuration,
private readonly ConnectionPool $connectionPool,
) {}
public function renderWidgetContent(): string
{
$h = CockpitHtml::class;
$latest = $this->latestStatsRow();
if ($latest === null) {
return $h::wrap('<p class="vcw-dim">No /__stats samples yet. They arrive with the next '
. '<code>vitec:ssr-health</code> cron run (every 5 minutes) once the node release '
. 'provides <code>/__stats</code>.</p>', 'Cache hit rate and error counters of the SSR node, '
. 'filled by the health cron every 5 minutes.', 'errors');
}
$hit = (int)$latest['stat_hit'];
$stale = (int)$latest['stat_stale'];
$miss = (int)$latest['stat_miss'];
$served = $hit + $miss + $stale;
$rate = $served > 0 ? ($hit + $stale) / $served : null;
[$newRender24h, $newBackend24h] = $this->errorDeltas24h();
$down24h = $this->downSamples24h();
// Hit rate ring + split bar hit / stale / miss
$pct = static fn(int $v): string => $served > 0 ? round($v / $served * 100, 1) . '%' : '0%';
$html = '<div class="vcw-gauge vcw-gauge-wide">'
. $h::ring($rate ?? 0.0, 'ok', $rate === null ? '–' : number_format($rate * 100, 1) . ' %', 84)
. '<div class="vcw-grow"><div class="vcw-label">Cache hit rate</div>'
. '<div class="vcw-sub">since the node started · ' . number_format($served, 0, '.', ',') . ' requests</div>'
. '<div class="vcw-split-bar" role="img" aria-label="Share of requests by cache result">'
. '<i class="is-navy" style="width:' . $pct($hit) . '" title="served from cache: ' . $hit . '"></i>'
. '<i class="is-sky" style="width:' . $pct($stale) . '" title="stale, refreshed in background: ' . $stale . '"></i>'
. '<i class="is-warn" style="width:' . $pct($miss) . '" title="rendered fresh: ' . $miss . '"></i></div>'
. '<div class="vcw-legend"><span><i class="is-navy"></i>cache ' . $hit . '</span>'
. '<span><i class="is-sky"></i>stale ' . $stale . '</span><span><i class="is-warn"></i>fresh ' . $miss . '</span></div>'
. '</div></div>';
$tile = static fn(string $label, string $value, string $sub, string $tone): string =>
'<div class="vcw-tile"><div class="vcw-label">' . $h::e($label) . '</div><div class="vcw-val ' . $tone . '">'
. $value . '</div><div class="vcw-sub">' . $sub . '</div></div>';
$newCell = static fn(?int $new): array => $new === null
? ['n/a', '']
: ['+' . $new, $new > 0 ? 'vcw-bad' : 'vcw-ok'];
[$renderVal, $renderTone] = $newCell($newRender24h);
[$backendVal, $backendTone] = $newCell($newBackend24h);
$html .= '<div class="vcw-tiles">'
. $tile('Render errors', $renderVal, 'last 24 h · ' . (int)$latest['stat_render_errors'] . ' since node start', $renderTone)
. $tile('Backend errors', $backendVal, 'last 24 h · ' . (int)$latest['stat_backend_errors'] . ' since node start', $backendTone)
. $tile('Down samples', (string)$down24h['count'], $down24h['count'] > 0 ? 'last ' . $h::e($down24h['last']) : 'last 24 h', $down24h['count'] > 0 ? 'vcw-bad' : 'vcw-ok')
. $tile('Sample age', $h::e($this->age((int)$latest['crdate'])), 'cron every 5 min', '')
. '</div>';
$html .= '<p class="vcw-note">Totals count since the node last started; a deploy resets them. '
. 'Error details: <code>journalctl -u vitec-ssr</code> on the VPS.</p>';
return $h::wrap($html, 'How well the SSR cache works and whether the node has problems. The ring is the '
. 'share of requests answered from the cache since the node last started; the bar splits them into '
. 'served from cache, stale and refreshed in the background, and rendered fresh. Render errors are pages '
. 'the node could not render, backend errors are failed requests to TYPO3. Down samples are failed '
. 'health checks in the last 24 hours.', 'errors');
}
public function getOptions(): array
{
return [];
}
public function getCssFiles(): array
{
return [CockpitHtml::CSS];
}
private function latestStatsRow(): ?array
{
try {
$row = $this->connectionPool->getConnectionForTable('tx_vitec_ssrhealthlog')
->executeQuery(
'SELECT crdate, stat_hit, stat_miss, stat_stale, stat_render_errors, stat_backend_errors'
. ' FROM tx_vitec_ssrhealthlog WHERE stat_hit >= 0 ORDER BY crdate DESC, uid DESC LIMIT 1'
)->fetchAssociative();
return $row === false ? null : $row;
} catch (\Throwable) {
return null; // Column missing before database:updateschema - render the empty state.
}
}
/**
* New errors in the last 24 h = sum of positive deltas between
* consecutive stats samples. Rows with -1 (node down / no stats) are
* skipped; counter resets produce a negative step, clamped to 0.
*
* @return array{0: ?int, 1: ?int} [renderErrors, backendErrors]
*/
private function errorDeltas24h(): array
{
try {
$rows = $this->connectionPool->getConnectionForTable('tx_vitec_ssrhealthlog')
->executeQuery(
'SELECT stat_render_errors, stat_backend_errors FROM tx_vitec_ssrhealthlog'
. ' WHERE crdate > ' . (time() - 86400) . ' AND stat_hit >= 0 ORDER BY crdate ASC, uid ASC'
)->fetchAllAssociative();
} catch (\Throwable) {
return [null, null];
}
if (count($rows) < 2) {
return [null, null];
}
$render = 0;
$backend = 0;
$prev = null;
foreach ($rows as $r) {
if ($prev !== null) {
$render += max(0, (int)$r['stat_render_errors'] - (int)$prev['stat_render_errors']);
$backend += max(0, (int)$r['stat_backend_errors'] - (int)$prev['stat_backend_errors']);
}
$prev = $r;
}
return [$render, $backend];
}
/**
* @return array{count: int, last: string}
*/
private function downSamples24h(): array
{
try {
$conn = $this->connectionPool->getConnectionForTable('tx_vitec_ssrhealthlog');
$count = (int)$conn->executeQuery(
'SELECT COUNT(*) FROM tx_vitec_ssrhealthlog WHERE crdate > ' . (time() - 86400) . ' AND ok = 0'
)->fetchOne();
$last = $count > 0
? date('d.m. H:i', (int)$conn->executeQuery(
'SELECT MAX(crdate) FROM tx_vitec_ssrhealthlog WHERE ok = 0'
)->fetchOne())
: '-';
return ['count' => $count, 'last' => $last];
} catch (\Throwable) {
return ['count' => 0, 'last' => '-'];
}
}
private function age(int $crdate): string
{
$minutes = (int)floor((time() - $crdate) / 60);
return $minutes < 1 ? 'just now' : $minutes . ' min';
}
}
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Widgets;
use Evomedien\Vitec\Service\ClientErrorClassifier;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Dashboard\Widgets\AdditionalCssInterface;
use TYPO3\CMS\Dashboard\Widgets\WidgetConfigurationInterface;
use TYPO3\CMS\Dashboard\Widgets\WidgetInterface;
/**
* Dashboard tile: purge and health statistics from the two protocol
* tables. History depth = what vitec:ssr-health sampling (cron, every
* 5 minutes) and the purge log hold (14 days). Cockpit look (CockpitHtml):
* availability strip of the last 24 h plus number tiles.
*/
final class SsrStatsWidget implements WidgetInterface, AdditionalCssInterface
{
public function __construct(
private readonly WidgetConfigurationInterface $configuration,
private readonly ConnectionPool $connectionPool,
) {}
public function renderWidgetContent(): string
{
$h = CockpitHtml::class;
$now = time();
$d1 = $now - 86400;
$d7 = $now - 7 * 86400;
$purges24h = $this->count('tx_vitec_ssrpurgelog', 'crdate > ' . $d1);
$purges7d = $this->count('tx_vitec_ssrpurgelog', 'crdate > ' . $d7);
$failed7d = $this->count('tx_vitec_ssrpurgelog', 'crdate > ' . $d7 . ' AND success = 0');
$full7d = $this->count('tx_vitec_ssrpurgelog', "crdate > " . $d7 . " AND paths = '*'");
$avgMs = $this->scalar('SELECT ROUND(AVG(response_ms)) FROM tx_vitec_ssrhealthlog WHERE crdate > ' . $d1 . ' AND ok = 1');
$maxCache = $this->scalar('SELECT MAX(cache_mb) FROM tx_vitec_ssrhealthlog WHERE crdate > ' . $d7);
// Browser-side errors reported via POST /api/vitec/client-error,
// without smoke/curl test reports (0114-FE).
[$clientErrors24h, $clientErrors7d] = $this->realClientErrors($d1, $d7);
$strip = $h::strip($this->connectionPool);
$uptime = $strip['uptime'];
$uptimeTone = $uptime === null ? '' : ($uptime >= 99.5 ? 'vcw-ok' : ($uptime >= 95 ? 'vcw-warn' : 'vcw-bad'));
$html = '<div class="vcw-strip-h"><div><div class="vcw-label">Availability · 24 h</div>'
. '<div class="vcw-num ' . $uptimeTone . '">' . ($uptime === null ? '–' : number_format($uptime, 1)) . '<small>%</small></div></div>'
. '<div class="vcw-sub vcw-right">' . $strip['samples'] . ' samples<br>' . $strip['slow'] . ' slow · '
. $strip['down'] . ' down</div></div>'
. $strip['html'];
$tile = static fn(string $label, string $value, string $sub = '', string $tone = ''): string =>
'<div class="vcw-tile"><div class="vcw-label">' . $h::e($label) . '</div><div class="vcw-val ' . $tone . '">'
. $value . '</div>' . ($sub !== '' ? '<div class="vcw-sub">' . $sub . '</div>' : '') . '</div>';
$html .= '<div class="vcw-tiles">'
. $tile('Purges', $purges24h . '<small>/ ' . $purges7d . '</small>', '24 h / 7 days')
. $tile('Full purges', (string)$full7d, 'last 7 days', $full7d > 0 ? 'vcw-warn' : '')
. $tile('Failed purges', (string)$failed7d, 'last 7 days', $failed7d > 0 ? 'vcw-bad' : 'vcw-ok')
. $tile('Avg. response', $avgMs !== null ? $avgMs . '<small>ms</small>' : '–', 'last 24 h')
. $tile('Max. cache', $maxCache !== null ? $maxCache . '<small>MB</small>' : '–', 'last 7 days')
. $tile('Client errors', $clientErrors24h . '<small>/ ' . $clientErrors7d . '</small>', '24 h / 7 days, no tests', $clientErrors24h > 0 ? 'vcw-warn' : 'vcw-ok')
. '</div>';
if ($strip['samples'] === 0) {
$html .= '<p class="vcw-dim">No samples yet: the cron job <code>vitec:ssr-health</code> fills this every 5 minutes.</p>';
}
$html .= '<div class="vcw-foot">' . $h::moduleLink('Charts and protocol in the cockpit') . '</div>';
return $h::wrap($html, 'Availability of the SSR node over the last 24 hours, one bar per 15 minutes: '
. 'green ok, yellow slower than 400 ms, red down, grey no sample. Below are counts from the purge and '
. 'health logs: purges, full purges and failed purges, average response time, the largest cache size '
. 'and the browser errors reported by the frontend.', 'stats');
}
public function getOptions(): array
{
return [];
}
public function getCssFiles(): array
{
return [CockpitHtml::CSS];
}
/**
* @return array{0: int, 1: int} real client error reports in 24 h / 7 days
*/
private function realClientErrors(int $d1, int $d7): array
{
try {
$rows = $this->connectionPool->getConnectionForTable('tx_vitec_clienterrorlog')->executeQuery(
'SELECT crdate, message, user_agent, `release` FROM tx_vitec_clienterrorlog WHERE crdate > ?',
[$d7]
)->fetchAllAssociative();
} catch (\Throwable) {
return [0, 0];
}
$day = 0;
$week = 0;
foreach ($rows as $r) {
if (ClientErrorClassifier::isTest((string)$r['message'], (string)$r['user_agent'], (string)$r['release'])) {
continue;
}
$week++;
$day += (int)$r['crdate'] > $d1 ? 1 : 0;
}
return [$day, $week];
}
private function count(string $table, string $where): int
{
try {
return (int)$this->connectionPool->getConnectionForTable($table)
->executeQuery('SELECT COUNT(*) FROM ' . $table . ' WHERE ' . $where)->fetchOne();
} catch (\Throwable) {
return 0;
}
}
private function scalar(string $sql): ?string
{
try {
$v = $this->connectionPool->getConnectionForTable('tx_vitec_ssrhealthlog')
->executeQuery($sql)->fetchOne();
return $v === null || $v === false ? null : (string)$v;
} catch (\Throwable) {
return null;
}
}
}
@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Widgets;
use Evomedien\Vitec\Service\SsrPurgeService;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Http\RequestFactory;
use TYPO3\CMS\Dashboard\Widgets\AdditionalCssInterface;
use TYPO3\CMS\Dashboard\Widgets\WidgetConfigurationInterface;
use TYPO3\CMS\Dashboard\Widgets\WidgetInterface;
/**
* Dashboard tile: live state of the SSR node on the Solr VPS plus the
* delivery switch - green/red at a glance after backend login. Same look
* as the "VITEC SSR" cockpit module (CockpitHtml).
*/
final class SsrStatusWidget implements WidgetInterface, AdditionalCssInterface
{
public function __construct(
private readonly WidgetConfigurationInterface $configuration,
private readonly SsrPurgeService $purgeService,
private readonly RequestFactory $requestFactory,
private readonly ConnectionPool $connectionPool,
) {}
public function renderWidgetContent(): string
{
$h = CockpitHtml::class;
$flagOn = file_exists(Environment::getVarPath() . '/vitec-ssr-on.flag');
$health = $this->fetchHealth();
$lastPurge = $this->lastPurgeRow();
$limit = $this->purgeService->getCacheLimitMb();
// Head: pulse + state
if ($health === null) {
$head = '<span class="vcw-pulse is-down" aria-hidden="true"></span><div>'
. '<div class="vcw-title">SSR <span class="vcw-bad">not reachable</span></div>'
. '<div class="vcw-sub">The cron falls back to the SPA after '
. $this->purgeService->getFailoverAfter() . ' failed checks in a row</div></div>';
} else {
$ok = (bool)($health['ok'] ?? false);
$head = '<span class="vcw-pulse' . ($ok ? '' : ' is-warn') . '" aria-hidden="true"></span><div>'
. '<div class="vcw-title">SSR <span class="' . ($ok ? 'vcw-ok">online' : 'vcw-warn">reports errors') . '</span></div>'
. '<div class="vcw-sub">' . ($flagOn ? 'Pages are rendered on the VPS' : 'Delivery is off: TYPO3 serves the SPA')
. ' · ' . (int)$health['responseMs'] . ' ms</div></div>';
}
$html = '<div class="vcw-head">' . $head . '</div>';
// Cache ring + facts
$html .= '<div class="vcw-split">';
if ($health !== null) {
$mb = (int)($health['cacheMB'] ?? 0);
$pages = (int)($health['cachedPages'] ?? 0);
$frac = $limit > 0 ? $mb / $limit : 0.0;
$html .= '<div class="vcw-gauge">' . $h::ring($frac, 'sky', $limit > 0 ? round($frac * 100) . ' %' : $mb . ' MB')
. '<div><div class="vcw-label">Cache</div><div class="vcw-num">' . number_format($pages, 0, '.', ',')
. '<small>pages</small></div><div class="vcw-sub">' . $mb . ($limit > 0 ? ' / ' . $limit : '') . ' MB</div></div></div>';
}
$html .= '<dl class="vcw-facts">'
. '<div><dt>Delivery</dt><dd>' . ($flagOn ? $h::pill('ok', 'ON') : $h::pill('muted', 'OFF')) . '</dd></div>';
if ($health !== null) {
$html .= '<div><dt>Release</dt><dd><code title="built ' . $h::e($this->fmtDate((string)($health['release']['builtAt'] ?? '')))
. '">' . $h::e((string)($health['release']['release'] ?? '?')) . '</code></dd></div>'
. '<div><dt>Commit</dt><dd><code>' . $h::e((string)($health['release']['commit'] ?? '?')) . '</code></dd></div>';
}
$html .= '<div><dt>Webhook</dt><dd>' . ($this->purgeService->isConfigured()
? $h::pill('ok', 'configured')
: $h::pill('warn', 'not configured', 'var/vitec-ssr-purge.env is missing or incomplete')) . '</dd></div>'
. '</dl></div>';
// Last purge
if ($lastPurge !== null) {
[$label, $tone, $who] = $h::trigger((string)$lastPurge['trigger_source']);
$paths = trim((string)$lastPurge['paths']);
$html .= '<div class="vcw-last"><span class="vcw-label">Last purge</span>'
. '<span class="vcw-time">' . date('d.m. H:i', (int)$lastPurge['crdate']) . '</span>'
. $h::pill($tone, $label) . ($who !== '' ? ' <span class="vcw-dim">' . $h::e($who) . '</span>' : '')
. ' ' . ((int)$lastPurge['success'] ? $h::pill('ok', 'ok') : $h::pill('bad', 'failed'))
. '<span class="vcw-path" title="' . $h::e($paths) . '">'
. ($paths === '*' ? 'all pages (*)' : $h::e(mb_strimwidth($paths, 0, 70, '…'))) . '</span></div>';
}
$html .= '<div class="vcw-foot">' . $h::moduleLink() . '</div>';
return $h::wrap($html, 'Live state of the SSR node on the VPS: whether it answers and how fast, '
. 'how full its page cache is against the limit, whether SSR delivery is switched on, which frontend '
. 'release runs, and the most recent cache purge. The link opens the cockpit with charts, protocol '
. 'and the purge and warm buttons.', 'status');
}
public function getOptions(): array
{
return [];
}
public function getCssFiles(): array
{
return [CockpitHtml::CSS];
}
private function fetchHealth(): ?array
{
$endpoint = $this->purgeService->getHealthEndpoint();
if ($endpoint === '') {
return null;
}
$start = microtime(true);
try {
$response = $this->requestFactory->request($endpoint, 'GET', [
'timeout' => 3, 'connect_timeout' => 2, 'http_errors' => false,
]);
if ($response->getStatusCode() !== 200) {
return null;
}
$data = json_decode((string)$response->getBody(), true);
if (!is_array($data)) {
return null;
}
$data['responseMs'] = (int)round((microtime(true) - $start) * 1000);
return $data;
} catch (\Throwable) {
return null;
}
}
private function lastPurgeRow(): ?array
{
try {
$qb = $this->connectionPool->getQueryBuilderForTable('tx_vitec_ssrpurgelog');
$row = $qb->select('*')->from('tx_vitec_ssrpurgelog')
->orderBy('uid', 'DESC')->setMaxResults(1)
->executeQuery()->fetchAssociative();
return $row ?: null;
} catch (\Throwable) {
return null;
}
}
private function fmtDate(string $iso): string
{
$ts = strtotime($iso);
return $ts ? date('Y-m-d H:i', $ts) : '?';
}
}
@@ -27,4 +27,16 @@ return [
'path' => '/vitec/product/field/save',
'target' => ProductTextImportController::class . '::fieldSave',
],
// Context menu "Purge from SSR cache" (SsrPurgeItemProvider +
// Resources/Public/Javascript/ssr-context-menu.js).
'vitec_ssr_purge_page' => [
'path' => '/vitec/ssr/purge-page',
'target' => \Evomedien\Vitec\Controller\Backend\SsrController::class . '::purgePage',
],
// Live status of the VITEC SSR cockpit
// (Resources/Public/Javascript/ssr-cockpit.js polls it).
'vitec_ssr_health' => [
'path' => '/vitec/ssr/health',
'target' => \Evomedien\Vitec\Controller\Backend\SsrController::class . '::healthJson',
],
];
@@ -127,4 +127,30 @@ return [
],
],
],
'web_vitecssr' => [
'parent' => 'web',
'position' => ['after' => 'web_vitecimport'],
'access' => 'user',
'workspaces' => 'live',
'path' => '/module/web/vitec-ssr',
'labels' => [
'title' => 'VITEC SSR',
'shortDescription' => 'SSR delivery: VPS status, manual purge, purge/health protocol',
],
'extensionName' => 'Vitec',
'iconIdentifier' => 'actions-system-cache-clear-impact-medium',
'routes' => [
'_default' => [
'target' => \Evomedien\Vitec\Controller\Backend\SsrController::class . '::indexAction',
],
'purge' => [
'target' => \Evomedien\Vitec\Controller\Backend\SsrController::class . '::purgeAction',
'methods' => ['POST'],
],
'warm' => [
'target' => \Evomedien\Vitec\Controller\Backend\SsrController::class . '::warmAction',
'methods' => ['POST'],
],
],
],
];
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<T3DataStructure>
<meta>
<langDisable>1</langDisable>
</meta>
<sheets>
<sDEF>
<ROOT>
<sheetTitle>Page Content Check</sheetTitle>
<type>array</type>
<el>
<settings.scope>
<label>Page tree to audit</label>
<description>Markets = the 11 markets and their sub-markets below /markets. Solutions = the 8 solution groups and their sub-solutions below /solutions.</description>
<config>
<type>select</type>
<renderType>selectSingle</renderType>
<items>
<numIndex index="0">
<label>Markets and sub-markets</label>
<value>markets</value>
</numIndex>
<numIndex index="1">
<label>Solutions and sub-solutions</label>
<value>solutions</value>
</numIndex>
</items>
<default>markets</default>
</config>
</settings.scope>
</el>
</ROOT>
</sDEF>
</sheets>
</T3DataStructure>
@@ -9,7 +9,7 @@ declare(strict_types=1);
* JavaScriptModuleInstruction::create('@evomedien/vitec/<file>.js').
*/
return [
'dependencies' => ['backend', 'core'],
'dependencies' => ['backend', 'core', 'dashboard'],
'imports' => [
'@evomedien/vitec/' => 'EXT:vitec/Resources/Public/Javascript/',
],
@@ -46,5 +46,50 @@ return [
'typo3/cms-frontend/site',
],
],
// Self-healing sync of the SSR routing flag file against the
// Site Set switch vitec.ssr.enabled (no core event fires on
// settings save, hence per-request sync; cheap file_exists when
// nothing changed). Also registered for the backend below.
'vitec/sync-ssr-flag' => [
'target' => \Evomedien\Vitec\Middleware\SyncSsrFlag::class,
'after' => [
'typo3/cms-core/normalized-params-attribute',
],
],
// Client error reports from the React frontend:
// POST /api/vitec/client-error -> tx_vitec_clienterrorlog, 204
// (0072-FE section 4 / 0077-BE). Answers before site resolution,
// no session, rate-limited per IP, 8 KB body limit. Placed after
// the /_json strip so the prefixed form reaches it as well.
'vitec/client-error' => [
'target' => \Evomedien\Vitec\Middleware\ClientErrorMiddleware::class,
'after' => [
'typo3/cms-core/normalized-params-attribute',
'vitec/json-prefix-rewrite',
],
'before' => [
'typo3/cms-frontend/site',
'vitec/form-submission',
],
],
// Outer response filter: Cache-Control: no-cache + ETag/304 for
// /_json (tickets 0011-FE / 0055-BE).
'vitec/json-cache-headers' => [
'target' => \Evomedien\Vitec\Middleware\JsonCacheHeaders::class,
'after' => [
'typo3/cms-core/normalized-params-attribute',
],
'before' => [
'vitec/json-prefix-rewrite',
],
],
],
'backend' => [
'vitec/sync-ssr-flag' => [
'target' => \Evomedien\Vitec\Middleware\SyncSsrFlag::class,
'after' => [
'typo3/cms-core/normalized-params-attribute',
],
],
],
];
@@ -22,3 +22,50 @@ services:
# the final page title (seo.title) into meta.title as well.
FriendsOfTYPO3\Headless\Seo\MetaHandlerInterface:
alias: Evomedien\Vitec\Seo\VitecMetaHandler
# The DataHandler hook fetches this via GeneralUtility::makeInstance,
# which resolves constructor arguments only for container-public services.
Evomedien\Vitec\Service\SsrPurgeService:
public: true
# Fetched via GeneralUtility::makeInstance from the USER func
# ProductCheckJsonRenderer (TypoScript userFunc, no DI there).
Evomedien\Vitec\Service\ProductCheckService:
public: true
Evomedien\Vitec\Service\PageCheckService:
public: true
# SSR dashboard tiles (the WidgetConfigurationInterface argument is
# injected by the dashboard compiler pass via the tag).
Evomedien\Vitec\Widgets\SsrStatusWidget:
tags:
- name: dashboard.widget
identifier: vitecSsrStatus
groupNames: systemInfo
title: 'VITEC: SSR / VPS status'
description: 'Live state of the SSR server (release, cache, reachability) and the delivery switch'
iconIdentifier: 'content-widget-text'
height: 'medium'
width: 'medium'
Evomedien\Vitec\Widgets\SsrStatsWidget:
tags:
- name: dashboard.widget
identifier: vitecSsrStats
groupNames: systemInfo
title: 'VITEC: SSR statistics'
description: 'Purge protocol and availability of the SSR server (14 days of history)'
iconIdentifier: 'content-widget-number'
height: 'medium'
width: 'medium'
Evomedien\Vitec\Widgets\SsrErrorsWidget:
tags:
- name: dashboard.widget
identifier: vitecSsrErrors
groupNames: systemInfo
title: 'VITEC: SSR errors & hit rate'
description: 'Render/backend error counters and cache hit rate of the SSR node (/__stats, sampled every 5 minutes)'
iconIdentifier: 'content-widget-list'
height: 'medium'
width: 'medium'
Loaded 100 of 124 files, more files were not shown because too many files have changed in this diff. Show more