Compare commits

..
103 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
orascheandClaude Opus 5.5 da2ebdca23 Product detail: resources section layout 3 "Hardware option"
- The Resources Section Layout select gained a third value, hardware
  ("Layout 3 - Hardware option"): Technical Highlights spread over two
  columns, no resource cards
- ProductShowJsonRenderer::resourcesBand() emits the same envelope with
  items: [] for hardware even when cards are maintained, so the cards
  stay on the product and reappear when switching back to layout 1 or 2
- Spec v1.27 (resources.sectionLayout values)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 15:11:26 +01:00
orascheandClaude Fable 5 d87e64f73b Success stories: downloads and product sections become real plugins
- comparison report migrations/check_product_downloads.php (kept,
  read-only): download records vs. the delivered product workbooks -
  34 of 74 workbook products have a datasheet, the entire Datapath
  line has none in the pool (import came from vitec.com; queued for
  Joe), ~35 orphan datasheets belong to products without records;
  a vitec:link-product-downloads re-run added seven clean links
  (Artio x2, Diamond-H, the three TOUGH records, X-View)
- RTE resolution fixed at the root: ContentElementResolver and
  ContainerChildrenProcessor passed bodytext RAW into the payload, so
  t3:// links leaked unresolved; both now run RTE columns (bodytext,
  vitec_quote) through RteResolver::html()
- the imported "Download Success Story" RTE buttons carried the wrong
  link identifier (tx_vitec_download -> download, one-time migration
  fix_download_record_links.php) AND old-site uids - missing here or,
  worse, accidentally pointing at foreign PDFs. New
  vitec:convert-story-download-buttons replaces them with real
  vitec_downloadcard plugins: the download is resolved by STORY title
  (manual map for ISR / "Okada Manilla", exact-title fallback for
  suffixless PDFs like "Lord's Cricket Ground"); 29 converted, 4
  stories without a delivered PDF have their button hidden. New
  FlexForm layout "Success Story Download" (story_download) applied
  everywhere; the FE renders it as the previous centered button
- the ADIB "Featured Products" text section (the only one site-wide,
  verified by scan_story_headers.php) is now ONE vitec_productlist
  with hand-picked products via old-name aliases and the new FlexForm
  layout "Success Story Products" (story_products); the four text
  elements are deleted. vitec:convert-story-product-sections stays
  re-runnable for future imports
- the productlist envelope additionally carries "columns" (= number
  of products, capped at 4) so the story grid renders as many columns
  as it has products - additive key, available to every product list;
  and hand-picked lists now ship successStories as an empty array (the
  empty category selection used to aggregate stories over the WHOLE
  tree, leaking story cards into the section)
- the story downloadcards carry header "Downloads" (set on convert
  and backfilled): the front end builds the story anchor nav from
  element headers, so the section now appears there
- diagnosis scripts kept: check_download_record, check_story_elements,
  scan_story_headers; verified live on the ADIB payload (structured
  download object, productlist [5,18,6], texts 10 -> 5)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-28 14:10:59 +01:00
orascheandClaude Fable 5 80f6da7949 Retire the six old-taxonomy category stubs under /products
- diagnosis first (migrations/check_old_stubs.php, kept): the six
  old-taxonomy pages (#451-454, #456, #458) were completely empty - no
  content elements and NO family child pages (the family workbooks were
  deliberately never imported), so nothing needed moving
- migrations/retire_old_category_stubs.php (dry-run by default, guards
  against children/content) soft-deletes each stub and queues a 301
  sys_redirect onto its new-taxonomy equivalent; existing redirects for
  a path are kept
- applied and verified live: all six old paths answer with the 301
  redirect envelope onto the correct new category page, and the main
  navigation now shows exactly the nine new categories in sitemap order

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-27 15:19:38 +01:00
orascheandClaude Fable 5 b0690c4cdc Cards visible in the FE; /products cards wrapped into 33/33/33 grids
- the freshly attached card images did not show: text_only is the
  explicit no-image variant, so vitec:attach-card-images now flips a
  card to "vertical" whenever it carries (or receives) an image; and
  the new placeholder PNGs sat in the sshfs 770 trap - fix-asset-perms
  covers them, with public/product-audit added to its targets
- new generic vitec:wrap-cards-grid --page=<slug>: wraps a page's cards
  into vitec_cols_33_33_33 containers (b13 columns 221/222/223), three
  per container, section-aware - every top-level intro paragraph starts
  a section, other element types close it, so the category headings on
  /products keep their grids beneath them. Existing grid containers are
  pooled and reused, surplus ones hidden
- the first apply aborted mid-move and left a half state (15 empty
  shells, three cards moved; diagnosis migrations/check_grid_447.php,
  kept) - v2 repairs such states by design: global container pool,
  hard asserts after the create pass, try/catch around the move pass,
  and a final "no card left top-level" verification
- applied on /products and verified live: 38 cards in 15 grids
  (sections 5/7/3/6/2/4/2/6/3 -> 2/3/1/2/1/2/1/2/1 containers), order
  intro -> grids -> banner -> faq intact, children delivered in the
  known items/contentElements shape

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-27 15:10:24 +01:00
orascheandClaude Fable 5 c8d653b39b Card images: product photos plus numbered placeholders on the new cards
- new vitec:attach-card-images (dry-run by default) for the ~145
  vitec_card elements the 2026-10-27 workbook imports created across
  /products, /insights, /support and /contact: a card linking
  /product/<slug> gets the record's first product photo (hero banner
  as fallback), every other card gets the numbered placeholder
  placeholders/pages/<pid>-NN.png counted from slot 10 in card order
  per page (slots 01-04 and 08 are taken by the page conventions; the
  number doubles as the image wish list towards the agency). 110
  placeholder PNGs (640x480) were generated from the live payloads so
  the numbering is deterministic; cards that already carry an image are
  never touched but keep advancing the counter, so re-runs stay stable
- container children are included: the editor had arranged the /support
  cards into a grid container (colPos 221-223/251), which the first
  top-level-only query skipped
- applied and verified live: 42 product photos, 110 numbered
  placeholders, 18 generic grey cards - the grey ones map the records
  without any image at all (ArtioSign, ArtioView, Arqa, the Aetria
  family, Wall Control 10, X-Series; all Datapath software without an
  image delivery, queued for Joe)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-27 14:26:15 +01:00
orascheandClaude Fable 5 c6147d020e Fill the /products portfolio page from the agency workbook
- new vitec:build-product-portfolio (dry-run by default): hero, nine
  category sections (intro paragraphs with the exact sitemap H2s and
  Learn-more buttons onto the new category pages), 38 product cards
  with record-first link resolution (30 resolved; the 8 open record
  gaps stay reported), a Case Studies & Events banner (midnight,
  success stories / events), ONE vitec_faq accordion whose four
  questions are created as vitec_faq_items collection children
  (field names probed against the schema at runtime), and the orange
  prefooter banner (contact / solutions); full page properties and
  page layout 0 -> 1 "Index Page"
- diagnosis first (new read-only migrations/check_products_page.php
  and check_products_page2.php): the dry run reported zero legacy
  elements although the payload rendered ~40 - every own element of
  page 447 was already soft-deleted, the visible rendering came from
  the "Collection Product Infos" compose page. Building in place was
  therefore safe; unconsumed visible legacy would have been hidden,
  not deleted
- side finding, left open on purpose: six old-taxonomy category stubs
  (#451-454, #456, #458) still sit visible under /products with the
  family pages beneath them - hide/move/redirect is an editorial
  decision queued for Olli
- applied and verified live (hero, 9 category buttons, 30 card links,
  4 FAQ items, both banner CTA pairs, meta)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-27 14:12:39 +01:00
orascheandClaude Fable 5 e2d4367d7a Products run: missing records, asset re-run, audit page, category pages
- new vitec:create-missing-products: MGW Diamond TOUGH, MGW Pico TOUGH,
  MGW Pico+ TOUGH and X-View Integrated Displays as visible records
  (teaser drafts, pid and sys_category assignments copied from the
  sibling product; titles equal the Dropbox folder names so the image
  importer matches immediately). IQS4 stays out - its delivery folder
  is empty, question to Joe
- asset re-run via vitec:import-product-images after re-staging the
  Dropbox delivery (the misfiled Diamond-OG banner corrected into the
  OG inbox folder again): new photos for 95-Series, Avedia chassis,
  SFF PRISM and the new TOUGH/X-View records
- new migrations/build_product_audit.php: generates the internal
  product-asset audit at /product-audit/ (same basic auth, same-origin
  thumbnails) - one row per record with thumbnail, detail link, badges
  for hero/photos/texts/resources/datasheets/solutions, flags, stats
  and client-side filters; re-run any time
- new vitec:import-product-category-pages: builds the nine
  /products/<category> pages (frontend layout 9, hero/main/preFooter)
  from the agency "Product Category Pages" workbooks - intro
  paragraphs, Why-VITEC / product / solution cards, the comparison
  rows as ONE text element with a plain table (no comparison CType
  exists; FE styles it), prefooter banner to /contact, full page
  properties, page sorting per sitemap 3.1-3.9; the empty shells #455
  and #457 are reused. Product card links resolve record-first (exact
  title, stripped parenthesis, family alias, unique slug prefix);
  ProductFamilyAliases gains ten category-level family labels. Solution
  cards tolerate US/UK spelling (center/centre, visualization/
  visualisation). Applied and verified live (nine routes, links,
  tables, meta)
- known gaps reported, queued for Joe: IQS4, Media Players (93/94),
  Chassis Systems, Express Backplanes, Accessories & Cables, PCIe
  Encoders, VMS Plugins & Integrations; the Vision family card awaits
  a flagship decision

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-27 13:31:03 +01:00
orascheandClaude Fable 5 8497dad06a Megamenu follow-ups: nav_hide honoured, entries insertable, page tiles
- getMenu() treats hidden/time/fe_group as enable fields but NOT
  nav_hide - the treeColumns doc comment claimed otherwise, and the
  news detail page ("article") stayed in the new spotlight rail after
  the editor hid it in menus. All three getMenu() calls (spotlight nav,
  pagetree columns and their items) now pass 'AND nav_hide = 0';
  verified live (6 rail items)
- all four tx_evomegamenujson_* tables get
  security.ignorePageTypeRestriction: they are inline children of the
  menu plugin and live on whatever page the plugin sits on - creating
  a new entry on the standard page failed with "table is not allowed"
- new vitec:attach-page-media --root=<slug> (dry-run by default):
  attaches the numbered placeholder pages/<pid>-08.png (slot 08 = page
  media / menu tile, the number doubles as the image wish list) as page
  media to every child page without one; existing media is never
  touched. Run for /support (8 pages) so the planned Support menu entry
  renders as two 4-tile rows via the pagetree layout's column images

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-27 12:08:26 +01:00
orascheandClaude Fable 5 6423b7c7f8 Megamenu goes visual: spotlight layout, page images on pagetree columns
- customer wish (via Olli, 2026-10-27): a more visual main menu in the
  style of rossvideo.com - image cards instead of plain link lists; for
  Insights a left-rail submenu whose hover swaps a visual area showing
  the latest news and the next events
- new entry layout "spotlight" in evo_megamenu_json, built strictly on
  the catalog pattern so the extension stays project-neutral: `nav` is
  a flat rail from the linked page's live children (abstract as teaser,
  first page media as 640px-webp image from stage 2 - the hover swap
  material), `spotlight` is emitted verbatim from the new
  BuildSpotlightEvent (null without a listener); curated columns are
  ignored like on pagetree
- new vitec listener ProvideInsightsSpotlight supplies the cards: the
  three latest news (storage pid 11, type 0 - the Solr convention;
  link /news/article/<path_segment> per the NewsDetail route enhancer;
  fal_media as processed 640px webp) and the next two events
  (hideonwebsite = 0, upcoming by eventstart/eventend; link = eventurl
  or the events page)
- pagetree columns can now carry an image (stage 2+, first page media
  of the column's page): attach images in the page properties and the
  markets/solutions panels turn into image cards without further code
- TCA select and label extended; README brought up to date (the
  pagetree layout had never been documented) with the spotlight JSON
  contract, reference mirror synced; frontend briefing updated
- activation is editorial: switch the Insights menu entry to the
  spotlight layout and link it to the Insights page

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-27 11:47:23 +01:00
orascheandClaude Fable 5 90c2f97c5b Market records: delete the dropped legacy set; defuse the rename map
- vitec:align-market-records gains --delete-dropped: soft-deletes the
  records the agency dropped from the taxonomy, including their MM rows
  (DataHandler does not clean the foreign side on delete). Decision:
  all nine legacy records go, including Healthcare #42 - the Overlake
  story loses its badge on purpose and reports as unresolved on future
  importer runs
- the run exposed a regression: the RENAME_MAP entries Government ->
  Government & Agencies and Education -> Education Communications are
  not idempotent because both old names double as NEW main-market
  names. The re-run hijacked the main records (stories and teaser
  travelled along), created empty replacements and deleted the real
  sub-market records as unclaimed
- repair: migrations/fix_market_rename_regression.php (dry-run default,
  guarded) swaps the pairs back - main records keep their stories and
  workbook teasers, the fresh empties take over the sub-market roles;
  applied and verified live (marketlist 11/11 with main detail pages,
  badge distribution back to 20/9/9/8/6/4/1, no legacy names left)
- the two non-idempotent entries are removed from RENAME_MAP with a
  comment: a rename mapping whose old name is reused by the new
  taxonomy must not survive its one-time run

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-27 11:28:26 +01:00
orascheandClaude Fable 5 3b3ef470b0 Content pages: Insights / Support / Contact branches from the workbooks
- new vitec:build-content-pages (dry-run by default, --branch filter,
  --publish): fills all 21 content-page workbooks (Insights 7, Support 9,
  Contact 5, agency status "Draft for review") with embedded copy -
  the workbooks' instruction rows are deliberately not imported; two
  DataHandler passes (pages first, element links need real uids),
  explicit page sorting and element sequencing
- /insights became a hero page (layout 1) with six navigation cards;
  the news plugin moved onto the new /insights/news (layout 12); the
  standalone /events page moved under /insights (slug /insights/events,
  layout 19, redirect queued - note below)
- NEW /support branch (layout 11) with eight layout-16 subpages:
  HelpDesk login (external link + vitec_helpdeskform for access
  requests), support plans (Silver/Gold/Platinum cards), training plus
  the Aetria/VSN course pages, commissioning, supported devices,
  materials compliance (no dedicated form yet - routes to /contact)
- /contact became a hero page with six route cards; the form container
  (vitec_contactform) moved onto the new /contact/contact-form; new
  /contact/request-a-demo (vitec_demoform), /contact/locations
  (vitec_locationlist; three Datapath offices as HIDDEN location
  records pending confirmation) and /contact/showrooms (16 venues)
- unverifiable sections (sustainability claims, careers vacancies)
  are hidden editorial shells; full page-properties set everywhere
- applied with --publish and verified live (all 21 routes, forms,
  lists, moved plugins). Known follow-ups: /events answers 307 - an
  auto-created redirect row shadows the command's 301
  (migrations/fix_events_redirect.php shows and repairs it); the
  "Get Support" meta-nav page still points at /components-showcase;
  two CTA targets (support-plans datasheet, careers vacancies) are
  editorial
- Datapath stories stay parked ("do not infer records" per the
  Success Stories workbook) - documented, questions queued for Joe

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-27 11:03:33 +01:00
orascheandClaude Fable 5 c4ec979f78 Fill the /markets main page from "Markets Hero Page.xlsx"
- new vitec:import-markets-main (dry-run by default): one page with
  fixed approved copy, so the workbook content is embedded in the
  command instead of parsed at runtime; every target is resolved at
  runtime (pages by slug, elements by CType/colPos/container, market
  records by normalised title) and the command aborts on an unexpected
  structure
- the page already had the workbook structure (template vitec__2), so
  this updates the hero, the 50/50 markets intro (CTA -> /solutions)
  and the overview header in place
- the marketlist flexform selection moves from the old 9-record set
  (which still included the dropped "Security & Surveillance") to the
  11 main-market records in workbook order
- the 11 market card bodies land in the market records' teaser field
  (all were empty; the marketlist cards render from the records -
  frontend briefing asks for teaser on the cards)
- the old single-button prefooter container is hidden, replaced by a
  vitec_ctabanner (centered/orange, as on the 11 market hero pages)
  with both workbook CTAs: "Talk to our team" -> /contact and
  "Explore solutions" -> /solutions; explicit sorting after DataHandler
- page properties per the established set: seo/og/twitter title and
  description, canonical, abstract, curated keywords
- applied and verified live in the JSON (cards, order, teasers, CTA
  targets, meta tags); no spec bump - no new JSON keys involved

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-27 10:15:02 +01:00
orascheandClaude Fable 5 46049bf30d Product detail CR: resources band layout select, fields parked in Misc
- customer change request (2026-10-26): Resources = Downloads, the
  detail band is simplified - capabilities leave the section, Technical
  Highlights move left, the resource cards into the right column
  (frontend already built on dev.vitec.com)
- TCA: capabilities, datasheets and downloads moved to the Misc tab -
  parked, not removed; their JSON keys are unchanged. The planned
  automatic newest-datasheet/documentation cards stay deferred by
  request (the download route already serves the newest file per
  Collateral convention, so the foundation is there)
- new resources_layout select on the Resources tab, English label and
  description: Layout 1 = 50-50 (default, highlights left / cards
  right), Layout 2 = 33-33-33 (highlights over two columns, cards in
  the third); ext_tables.sql column added
- the manual resource card restype vocabulary gains Datasheet and
  Brochure (select items plus icon picker seeds, no code change - the
  renderer passes restype through as type): an editor can pin a single
  datasheet card by linking the download record, which stays current
  because the download route always serves the newest file
- ProductShowJsonRenderer: the resources envelope gains sectionLayout
  ('50-50'|'33-33-33'); verified live - Diamond-H delivers 33-33-33,
  untouched products the 50-50 default, layout/items unchanged
- spec bumped to v1.26 (additive revision row, field table 7.15.1),
  mirrored; frontend briefing notes the new key

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-26 19:36:18 +01:00
orascheandClaude Fable 5 8adbe8cc90 Market record taxonomy aligned with the agency structure
- new vitec:align-market-records: the visible /markets page tree is the
  target (it implements the agreed agency taxonomy). RENAME_MAP carries
  16 records - story relations included - onto the new names, exact
  matches get their detail_page wired, 10 records are created (CNI,
  Government, Public Safety and seven sub-markets). Records the agency
  dropped are never touched: they are reported with their attached
  stories as the editorial decision list - which turned out empty, all
  nine carried zero stories
- INDUSTRY_TO_MARKET of the story importer moved to the new names. The
  old combined defense market split cleanly: the Government industry now
  maps to its own market, Military stays on Defense and Security, so the
  --markets-only re-run redistributed those stories by their original
  industry. Healthcare deliberately lost its target (market dropped) and
  surfaces as unresolved from now on
- re-run applied: 48/48 stories relinked, badges verified live (no old
  names left), Solr reindex queued - the market facet now speaks the new
  taxonomy

Open: Overlake Medical Center still points at the dropped Healthcare
record (title-match fallback), the nine orphaned old records await
deletion, Public Safety has no stories yet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-26 10:40:42 +01:00
orascheandClaude Fable 5 a9a25e4b1e Engagament rename; redirects installed, CLI-queued and backfilled
- new generic vitec:rename-page --page=<uid|slug> --title="...": renames
  a page, recalculates its own slug from the parent slug plus the new
  title and cascades every descendant slug - the gap a backend rename
  leaves open. Used to fix the "Engagament" page (#465): title, own
  slug and five child slugs in one run, verified live (zero occurrences
  left in the JSON)
- SubsolutionWorkbookImporter::norm() tolerates the engagament spelling:
  the 39 delivered workbook metas carry the old page title, re-imports
  keep matching (verified)
- typo3/cms-redirects installed - it had NEVER been installed, so every
  "redirect follows" of the 10-23 renames was wishful thinking and all
  old URLs answered 404. Backend slug changes now auto-create redirects
- EXT:redirects' auto-creation does not fire for CLI DataHandler runs,
  so vitec:rename-page, vitec:normalize-slugs and
  vitec:align-market-pages now queue their 301 sys_redirect rows
  themselves in the same DataHandler run
- new one-off vitec:backfill-redirects: the 28 paths vacated on
  2026-10-23/26, reconstructed from the command protocols, guarded
  against existing redirects and live slugs; applied and verified.
  Note the headless semantics: a redirect arrives as the JSON envelope
  {"redirectUrl": ..., "statusCode": 301} with HTTP 200 - the front end
  must act on redirectUrl (briefing updated)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-26 10:26:43 +01:00
orascheandClaude Fable 5 6b0b2a3511 Engagament rename; redirects installed, CLI-queued and backfilled
- new generic vitec:rename-page --page=<uid|slug> --title="...": renames
  a page, recalculates its own slug from the parent slug plus the new
  title and cascades every descendant slug - the gap a backend rename
  leaves open. Used to fix the "Engagament" page (#465): title, own
  slug and five child slugs in one run, verified live (zero occurrences
  left in the JSON)
- SubsolutionWorkbookImporter::norm() tolerates the engagament spelling:
  the 39 delivered workbook metas carry the old page title, re-imports
  keep matching (verified)
- typo3/cms-redirects installed - it had NEVER been installed, so every
  "redirect follows" of the 10-23 renames was wishful thinking and all
  old URLs answered 404. Backend slug changes now auto-create redirects
- EXT:redirects' auto-creation does not fire for CLI DataHandler runs,
  so vitec:rename-page, vitec:normalize-slugs and
  vitec:align-market-pages now queue their 301 sys_redirect rows
  themselves in the same DataHandler run
- new one-off vitec:backfill-redirects: the 28 paths vacated on
  2026-10-23/26, reconstructed from the command protocols, guarded
  against existing redirects and live slugs; applied and verified.
  Note the headless semantics: a redirect arrives as the JSON envelope
  {"redirectUrl": ..., "statusCode": 301} with HTTP 200 - the front end
  must act on redirectUrl (briefing updated)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-26 10:19:16 +01:00
orascheandClaude Fable 5 b1357a6ffe Megamenu: ship menu images as processed 640px webp
MenuBuilder::image() returned getPublicUrl() of the ORIGINAL upload, so
the Success Stories teaser cards (and item/featured images) carried
multi-MB files into every page payload. All menu images now go through
the ImageService (maxWidth 640, webp - the search-tile convention);
width/height report the processed size, and the original URL remains
the fallback when processing fails so the menu never loses an image.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-23 14:13:56 +02:00
orascheandClaude Fable 5 cbe46cdba2 Megamenu page-tree panels; generic slug normaliser
- evo_megamenu_json: new entry layout "pagetree" - the panel columns
  mirror the live subtree of the page the entry links to (one child per
  column, grandchildren as items, getMenu() restrictions apply), so the
  menu can never age against the page structure again. The stale
  9-market panel after the markets realign was the trigger. Curated
  columns are ignored on this layout; teasers and the featured slot
  stay editorial. Same columns shape in the payload - front ends need
  no change beyond knowing the new layout string
- new vitec:normalize-slugs --root=<slug> [--style=plain|and]:
  recursively realigns every slug below a root to
  <parent-slug>/<slugified-title>, cascading, with a sibling-collision
  warning; EXT:redirects covers the old URLs. TYPO3's slug generator
  keeps slashes from page titles (third hit: rtcc-/-rtic), this sweeps
  a whole tree. Run on /solutions: four fixes, including the
  long-standing passenger page and solution page 467. The misspelled
  "engagament" slug stays deliberately - it follows the page title and
  is fixed in the coordinated typo task

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-23 14:10:57 +02:00
orascheandClaude Fable 5 303defe24d Markets branch: 11-market tree, sub-market import, hero pipeline
Structure per the agency agreement (Joe/Christina, confirmed 2026-10-23):
/markets -> eleven market hero pages -> their sub-market pages; the
use-case level stays sections on the sub-market pages.

- new vitec:align-market-pages: renames 8 main market pages onto the
  agency names (slug change -> EXT:redirects), creates the 3 new markets,
  hides the dropped "Security & Surveillance" (records/stories
  untouched), reports unknown children. A child-slug pass realigns every
  child to <parent-slug>/<slugified-title> (DataHandler does not cascade
  slug changes; this also repaired the third embedded-slash slug,
  rtcc-/-rtic). --publish unhides the tree (dev convenience; element
  shells stay hidden)
- sub-market import: all 24 agency workbooks applied (4 existing pages,
  20 created) - their Meta sheets were clones of Corporate Environments
  (record_uid 32) and were repaired beforehand (market/sub-market set,
  record_uid 0; the record taxonomy alignment is a separate step)
- SubmarketWorkbookImporter: page-properties parity (og/twitter titles
  and descriptions, canonical, abstract from the intro copy) and a
  placeholder upgrade pass - pages created in the same run only get the
  generic grey (their uid is still NEW), the re-run swaps it for the
  numbered per-page placeholder
- new market hero pipeline: frontend layout 23 "Market Hero Page"
  (zones hero=colPos1, main=colPos0), Import\MarketHeroPageImporter +
  vitec:import-market-hero-workbooks (inbox migrations/markethero_xlsx_in,
  gitignored). Hero H1 travels on the introparagraph header (markethero
  has no headline field), the hero CTA anchors #submarkets, sub-market
  cards link their child page via t3://page, customerlogos and
  usecaselist ship hidden until filled editorially, prefooter ctabanner;
  full page-properties set with 11 curated keyword sets
- applied end to end and live-verified (layout 23 heroes with linked
  cards, layout 21 sub pages with all six colPos groups, clean slugs)
- spec v1.25

Open: market RECORD taxonomy (story badges / Solr facets still use the
old 9 mains), featured product "ISR Video Mission Commander" has no
record, the /markets main page workbook (template 2) is a separate flow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-23 13:52:43 +02:00
orascheandClaude Fable 5 115952a132 Product banners and photos: vitec:import-product-images
- new command attaching the agency asset delivery (one Dropbox folder
  per product) to the product records: files containing "banner" go to
  heroimage (first reference reused/swapped), every other jpg/png -
  carousel subfolders included - is APPENDED to productimage with the
  full reference list, so the live-site images from 2026-09-04 stay
- folder -> record matching: normalised title, title without the
  parenthetical, slug, then IMAGE_ALIASES (Joe's "+1" Vision folder
  names map onto the existing records - their banner files still carry
  the old product names; 95-Series -> avedia-end-points)
- uploads land in fileadmin/product_import/<slug>/ with REPLACE, so
  re-runs are idempotent; inbox migrations/product_images_in/ gitignored
- applied: 39 banners + 14 photos across 42 products, 0 errors,
  live-verified (srcset/webp processing included)
- the "MGW Diamond (SDI encoder)" Dropbox folder held the OG blade's
  banner (image inspected): moved to the OG product in the inbox, the
  appliance deliberately keeps no hero; the Dropbox source still has it
  in the wrong folder
- still without records: MGW Diamond/Pico/Pico+ TOUGH and X-View - their
  assets wait in the inbox, a re-run attaches them once records exist

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-23 13:05:32 +02:00
orascheandClaude Fable 5 3e2ebf3823 Solution group pages: layout 22 + Template-06 workbook import
- new frontend layout 22 "Solution Group Page" (provider LAYOUT_MAP,
  TCA layout item, locallang_pages.xlf; zones hero=colPos1, main=colPos0
  like layout 5 so the front end reuses its components)
- new pipeline Import\SolutionGroupPageImporter +
  vitec:import-solution-group-workbooks (inbox
  migrations/solutiongroups_xlsx_in/, gitignored): reads the agency
  "Template 06" group workbooks by column-A label scan, resolves the
  group via "Hero H1" -> is_group record -> detail_page (pages must
  exist), cursor-based slotNext() allows repeated CTypes per column
- element plan per page: markethero (+#capabilities CTA anchor) + hero
  intro paragraph; intro + supporting 50/50; challenges header + 4
  text_only cards; capabilities header + 4 alternating horizontal cards
  (bullets as <ul>); usecaselist shipped HIDDEN (an empty selection
  would list ALL stories - editors pick, then unhide); marketinsights;
  prefooter ctabanner. Page properties as on the sub-solution pages,
  8 curated keyword sets
- 56 numbered grey placeholders for pages 459-466 (7 slots per page)
- applied to all 8 group pages, live-verified on 459/465; the solutions
  branch is now content-complete (groups + 39 details)
- spec v1.24 (revision row, head/footer)

Found on the way: the "Engagament" typo lives in page 465's title AND
slug; the record is spelled correctly. Fix needs coordination with the
sub-solution workbook metas - own task, not done here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-23 12:19:05 +02:00
orascheandClaude Fable 5 d115ffb144 Sub-solutions filled from Joe's 39 workbooks; dummy assets wired
- Joe's 39 "- Completed" workbooks applied to all 39 sub-solution pages
  (#467-#515); both workbook inboxes are now gitignored and the tracked
  test workbooks untracked - filled deliveries live in the Nextcloud
- new Import\ProductFamilyAliases: the workbooks reference product
  FAMILIES in the product slots; 11 labels map to a representative
  product, consulted by BOTH importers after the exact title match and
  reported as "-> family alias"
- flip-card icons: dummy SVGs fileadmin/icons/flow/<name>.svg for Joe's
  10-name vocabulary, attached by the new attachIconFile() through the
  shared idempotent attachFalFile() (extracted from attachUrlFile);
  real icons later REPLACE those files in the Filelist (same sys_file
  uid), every card follows without a re-import
- image slots: numbered grey placeholders per page and slot
  (fileadmin/placeholders/pages/<pid>-NN.png, the id rendered into the
  image; generic grey as fallback). An existing reference is never
  overwritten; a real URL on re-import swaps the placeholder away
- page properties per sub-solution: og/twitter title + description
  mirror the SEO fields, canonical_link points at the page itself,
  abstract derived from the intro copy (~300 chars, word boundary),
  keywords from the curated PAGE_KEYWORDS set
- spec head-matter fix rides along (version table was stuck at 1.21,
  real state 1.23)

Open afterwards: 3 missing Datapath stories + re-apply, quotes on 16
story records (list to Joe), slug slash on page 467.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-19 18:51:21 +02:00
orasche 74502e60a3 fe_comment: recipients are the FE users of the feedback group
- The selectable recipients are no longer a hand-maintained setting but
  every active frontend user of the feedback group (feUserGroup) with a
  valid e-mail address; the id is the fe_users uid, so a stored selection
  survives users joining or leaving. The `recipients` setting is marked
  unused
- Each recipient in the state payload carries label, email and selected
  (the stored choice), for a checkbox list with "select all" above the
  submit button
- submit accepts the checked ids directly ({"recipients": [uids]}),
  validates them against the group, stores and uses them, and reports
  the addresses used as sentTo; no selection falls back to mailTo
- migrations/check_fecomment_user.php: all summaries and items of one
  user incl. deleted ones, for diagnosis
2026-10-09 16:09:07 +02:00
orasche eb0d0d9ebc fe_comment: Feedback Overview content element in the page JSON
- New content element "Feedback Overview" (CType fecomment_overview):
  the current FE user's feedback state under content.feedbackOverview,
  so the overview page renders like any other page instead of a
  front-end special case
- Rendered as USER_INT inside the cached page JSON (EXT:headless replaces
  the field per request): data is per user, visitors without a session
  get {"allowed": false} - verified that nothing leaks from the cache
- One implementation for both consumers: the new FeedbackState service
  builds the state payload; the API middleware delegates to it
- State payload (API and element) gained canSubmit and actions (all
  write endpoints with method and URL), so the front end needs no
  hard-coded endpoints for the submit button below the ticket list
- New site set evomedien/fe-comment carries the TypoScript, added to the
  site configuration; icon fecomment-overview
2026-10-09 14:56:10 +02:00
orasche 887caed2aa Products megamenu as a data-driven catalog with a Solr product type-ahead
- evo_megamenu_json stays project-neutral: new panel layout "catalog";
  for such entries the MenuBuilder dispatches the new PSR-14 event
  BuildCatalogEvent and emits whatever a listener sets as `catalog`
  (null without a listener). The entry's teaser cards serve as the
  featured tiles; the teaser inline field now also shows for "catalog",
  and the entry form reloads when the layout changes. README section
  "Layout catalog"
- VITEC listener ProvideProductCatalog fills it, modelled on the Ross
  Video products menu: main categories below the "Product" root, their
  subcategories as groups, products with a plain-text one-line teaser;
  same visibility rules as the product list, Product Finder links
  (/products?cat=&subcat=), empty categories and groups left out, three
  queries for the whole catalog
- Product type-ahead GET /_json/?type=7385&q= (USER_INT page type,
  ProductMenuSearchJsonRenderer): Solr restricted to products, last term
  completed as a prefix, up to six product cards plus their titles as
  autocomplete suggestions; endpoint announced in catalog.search
- Spec v1.23 (clause 7.16: product type-ahead)
2026-10-09 13:40:23 +02:00
orascheandClaude Opus 5.5 b5fcdb2b5a Megamenu entry: reload the form when the layout changes
The teaser cards field depends on the layout via displayCond; it now appears right after switching to catalog/teasers instead of only after saving.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:33:44 +02:00
orascheandClaude Opus 5.5 490bac500e Untrack daten.md (credentials)
daten.md holds credentials and must never be versioned; 91ad5e6 committed it together with the search cards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:03:39 +02:00
orasche 91ad5e6596 Search cards above the results, detail-page fallback for market/solution documents
- New record type tx_vitec_domain_model_searchcard ("best bets"):
  keyword list, priority, optional referenced record (page, product,
  story, solution, market, news) and overridable title/teaser/image/link;
  hidden/start/end time allow timed campaigns
- SearchCardService delivers content.search.cards on page 1: max. 3,
  priority-ordered, deduplicated by URL, independent of filter, facets
  and result count. Keywords match as whole words/phrases, case- and
  punctuation-insensitive, including one level of the Solr managed
  synonyms in both directions (cached for an hour, fail-soft). Record
  cards read the record's indexed Solr document, so a card always
  matches the record's own search result
- Index fallback for markets and solutions: when the record carries no
  image or text, the document takes the intro image (then the hero
  background) and the hero subline, intro headline and intro text from
  the content blocks of its detail page - selected by CType, so it works
  for layout 21 and layout 5 alike; improves results and cards
- Fix: a search request without q returned an empty body instead of the
  documented empty shape (stdClass resolved inside the renderer namespace)
- migrations/check_solution_fields.php: shows live/workspace versions of
  solution records
- Spec v1.22 (clause 7.16: cards)
2026-10-09 12:59:12 +02:00
orasche 8ead213bdf README: update the full-text search highlight
List all indexed types (markets and solutions were missing), the PDF
datasheet text extracted through Apache Tika, the /_json/search
endpoint and the current feature set (facets, tile images,
autocomplete).
2026-10-07 16:24:42 +02:00
orasche 9b1ad202a9 Extend Solr search: content coverage, 14.0.2, news recency, result images
- Index the text columns of the market/solution Content Blocks and the
  vitec_items/vitec_faq_items collection children; story documents now
  include their body (inline tt_content children via
  tx_vitec_usecase_content, same per-element extraction as pages) -
  stories were indexed with subtitle and teaser only until now
- Upgrade EXT:solr 14.0.0-RC1 -> 14.0.2 final (the Solr image moved to a
  fresh volume because the configset changed; synonyms restored via REST)
- News decay with age by publication date: crdate is the import date, so
  news map datetime to created; boost function gated to news only
- Results carry `image`: processed 640px tile image per type,
  root-relative, null for pages and downloads
- Spec v1.21: 7.16 states the /_json prefix and lists `solution` in the
  type vocabulary; footer had been stuck at v1.19
- suggest-demo.html fetches through /_json after the URL swap
2026-10-07 16:22:50 +02:00
orasche 8e67f5db23 Sub-solution workbook pipeline on layout 5, fix element order in both importers
- New, deliberately separate pipeline for sub-solutions (markets and
  solutions must never break each other): generator
  vitec:generate-subsolution-workbooks (list from the page tree below
  /solutions, 39 workbooks, every sub-solution matched to its record),
  SubsolutionWorkbookImporter + CLI vitec:import-subsolution-workbooks,
  and a "Sub-Solutions" tab in the VITEC Import module with its own
  inbox migrations/subsolutions_xlsx_in/
- Targets the existing template 5 "Solution Detail" without changing
  it: hero element in colPos 1, the remaining sections in fixed order in
  colPos 0, found again by CType on re-import; existing empty sub-solution
  pages are switched to layout 5, missing ones created hidden;
  detail_page set on the solution record
- Workbook per the Figma "07 Solution Detail page": hero without video
  teaser, intro with headline and without learn-more/portfolio, extended
  icon list, own page-layout reference image
- Shared content elements: market hero/intro/insights relabelled
  "Market/Solution", market intro gains the optional intro_headline
  field (new column, requires extension:setup)
- Fix: DataHandler puts every NEW record with a positive pid at the top
  of its column, so pages built in one run came out reversed. Both
  importers now track the page order per colPos and write
  tt_content.sorting explicitly after the DataHandler run; re-applying
  repairs pages imported earlier (verified on the control-room
  sub-solution and sub-market page 443)
- migrations/fill_test_subsolution.php: realistic test content from
  vitec.com copy and images for the control-room sub-solution
- Docs section 5z
2026-10-07 15:39:18 +02:00
orasche 3d5459b8d2 Sub-market workbook import as a backend module tab
- Extract the complete import logic (label-scan reading, page
  find-or-create, slot filling, content-idempotent image attach) from
  ImportSubmarketWorkbooksCommand into the shared service
  SubmarketWorkbookImporter; the CLI command and the new controller are
  thin shells around importFile(), so both entry points cannot drift.
  CLI regression verified: identical dry-run report for the reference
  workbook (12 elements, page 443)
- New "Sub-Markets" tab in the VITEC Import module
  (SubmarketImportController + Import/Submarkets.html): upload multiple
  filled workbooks into the SAME inbox the CLI uses
  (migrations/submarkets_xlsx_in/), per-file checkboxes, dry-run and
  apply buttons, result cards with the familiar report and a direct
  link into the page module; backend dry-run verified against the CLI
- Submitted filenames are whitelisted against the actual directory
  listing (no path traversal); uploads restricted to sanitized .xlsx
  basenames
- Routes submarkets/submarket_upload/submarket_process in Modules.php,
  Sub-Markets tab link in the sibling module views, ok/danger badge
  colors in backend-import.css
- Docs section 5y (incl. operations note: apply in small batches in the
  module, mass runs via CLI - image downloads run inside the web
  request)
2026-10-05 15:35:40 +02:00
orasche d496877a53 fe_comment: widget v2, overview link setting and inline mail images
- Rebuild the widget server-backed: it renders only when GET state
  allows (logged-in FE user of the configured group), so the script
  tag is safe on public pages; form with mini rich text (bold/italic/
  list), file attachment chips and Ctrl+V image paste - a paste lazily
  creates the draft feedback, uploads and inserts the image inline
- Note pad badge mirrors the server item count; the pad button links
  to the overview page via new extension setting overviewPid, exposed
  as overviewUrl in GET state (slug lookup)
- Embed /fileadmin images of notes into the feedback mail as CID
  parts: relative srcs are dead in mail clients and dev sits behind
  basic auth; attachment links stay absolute URLs
- public/widget-test.html: dev test page, doubles as the reference
  implementation of the React login flow (fetch felogin JSON form
  incl. __RequestToken, urlencoded POST to /_json + form.action)
- Docs section 5x (fe_comment v2 incl. CSRF login lesson,
  templateRootPaths.10 collision, cache:flush before extension:setup)
2026-10-02 16:57:52 +02:00
orasche 872c6e715c fe_comment v2: FE-user feedback tickets with summary, uploads and API
- Replace the v1 submit endpoint with a full JSON API under
  /api/fe-comment/ (state, feedback create/update/delete, uploads,
  summary notes/recipients, submit), guarded by frontend login and a
  configurable FE user group; unauthorized callers get {"allowed":false}
- New models: tx_fecomment_summary (one open ticket per user, status
  open/sent) and tx_fecomment_feedback (url, RTE note, FAL attachments),
  both editable in the backend on the ticket storage page
- FeedbackApiMiddleware registered after frontend authentication (the
  frontend.user attribute does not exist earlier); uploads land in
  fileadmin/fe_comment/summary-<uid>/ with mime/extension check, 20 MB cap
- Submit sends one HTML mail (author block, items with attachment links,
  reply-to = FE user mail) to selected recipients with mailTo fallback,
  then closes the summary
- felogin wired React-compatible: felogin.pid=520 plus headless FeLogin
  JSON templates via site settings - felogin v14 and EXT:headless both
  claim templateRootPaths.10, the felogin.view.* settings feed exactly
  that index, so the structured JSON form (incl. __RequestToken) wins
- Read the sys_file_reference insert id before syncAttachmentCount():
  DBAL throws "no identity value" once the count UPDATE has run
- migrations: drop_fecomment_tables.php (repairs the no-uid tables,
  only drops when empty), check_feuser.php (login diagnosis),
  fec_login_test.sh / fec_api_test.sh (curl chains incl. CSRF token flow)
2026-10-02 16:37:01 +02:00
orasche 78af83d2e3 - The three market Content Blocks preview as compact collapsible rows:
thumbnail of the actual image (hero background, intro image, insights
  cover), key copy in the summary line, full details expanded; card and
  intro-paragraph previews are wrapped in the same mechanism
- New SubmarketPluginPreview for vitec_usecaselist / vitec_productfeature /
  vitec_productlist: instead of bare standard boxes they now list the
  selected story titles (flagging stories that still lack a quote), the
  featured product and the hand-picked products in order
- Container-style collapse for whole page-module COLUMNS: a chevron at the
  right of every column header folds a section down to its title row,
  state kept per page and colPos in localStorage - the slot view of a
  sub-market page folds to six slim bands
- Previews sit under the page module's pointer-events lock and its
  click-to-edit layer: pointer events are re-enabled inside the collapse
  markup and the toggle runs on pointerdown in the capture phase
- Two more v14 removals fixed on the way, both latent in the OLDER preview
  classes too (Usecaseshow, Modelcard would have crashed the page module):
  GridColumnItem::getRecord() now returns a Domain\Record object, and
  StandaloneView is gone - previews normalize the record via a shared
  helper and render through the ViewFactory
2026-10-02 15:00:05 +02:00
orasche 67be9de316 The embedded Page-layout image ended after the Products block - the Figma
frame 'Sub-Market Page' does not contain the Industry Insights band or the
footer, they live as siblings inside the surrounding section. The asset is
now rendered from the section (contentsOnly), so the customer sees the
complete page including Insights and footer; the section label rows fit
unchanged.
2026-10-02 13:50:51 +02:00
296 changed files with 30990 additions and 1614 deletions

No files matched your search

+21 -2
View File
@@ -9,8 +9,10 @@
.env .env
.env.* .env.*
!.env.example !.env.example
/config/system/settings.php # The whole directory, not just the two known files: editor backups like
/config/system/additional.php # additional.php.save would otherwise slip into a careless `git add -A`
# (settings.php holds credentials).
/config/system/
auth.json auth.json
# -------------------------------------------------- # --------------------------------------------------
@@ -128,3 +130,20 @@ CLAUDE.local.md
# workbook feeds which product record. # workbook feeds which product record.
migrations/products_xlsx/* migrations/products_xlsx/*
!migrations/products_xlsx/manifest.json !migrations/products_xlsx/manifest.json
/daten.md
# Workbook inboxes: filled customer/agency workbooks live in the Nextcloud
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. [GPL‑2.0‑or‑later](LICENSE) — © evomedien.
<div align="center"><sub>VITEC · TYPO3 headless backend + React front end</sub></div> <div align="center"><sub>VITEC · TYPO3 headless backend + React front end</sub></div>
vitecdev
17ZJfy75sJoU68J6
+2 -1
View File
@@ -15,7 +15,7 @@
"sort-packages": true "sort-packages": true
}, },
"require": { "require": {
"apache-solr-for-typo3/solr": "^14.0@RC", "apache-solr-for-typo3/solr": "^14.0.2",
"b13/container": "^3.1", "b13/container": "^3.1",
"b13/typo3-updater": "^1.1", "b13/typo3-updater": "^1.1",
"evomedien/evo-megamenu-json": "^1.0.0", "evomedien/evo-megamenu-json": "^1.0.0",
@@ -45,6 +45,7 @@
"typo3/cms-install": "^14.3", "typo3/cms-install": "^14.3",
"typo3/cms-reactions": "^14.3", "typo3/cms-reactions": "^14.3",
"typo3/cms-recycler": "^14.3", "typo3/cms-recycler": "^14.3",
"typo3/cms-redirects": "^14.3",
"typo3/cms-rte-ckeditor": "^14.3", "typo3/cms-rte-ckeditor": "^14.3",
"typo3/cms-scheduler": "^14.3", "typo3/cms-scheduler": "^14.3",
"typo3/cms-seo": "^14.3", "typo3/cms-seo": "^14.3",
Generated
+593 -498
View File
File diff suppressed because it is too large. Load diff
+20 -2
View File
@@ -24,6 +24,7 @@ dependencies:
- apache-solr-for-typo3/solr-stylesheets - apache-solr-for-typo3/solr-stylesheets
- apache-solr-for-typo3/solr-bootstrap-css - apache-solr-for-typo3/solr-bootstrap-css
- evomedien/megamenu - evomedien/megamenu
- evomedien/fe-comment
- vitec/quotation - vitec/quotation
- vitec/featured-content - vitec/featured-content
frontendBase: '' frontendBase: ''
@@ -40,6 +41,23 @@ languages:
solr_core_read: core_en solr_core_read: core_en
rootPageId: 1 rootPageId: 1
routeEnhancers: 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: ProductPlugin:
type: Extbase type: Extbase
limitToPages: limitToPages:
@@ -102,7 +120,7 @@ routeEnhancers:
solr_enabled_read: true solr_enabled_read: true
solr_host_read: vitecsolr.evomedien.de solr_host_read: vitecsolr.evomedien.de
solr_host_write: localhost solr_host_write: localhost
solr_password_read: DvgpSaqrxr9QCd4ES36V solr_password_read: '%env(SOLR_PASSWORD)%'
solr_path_read: / solr_path_read: /
solr_path_write: / solr_path_write: /
solr_port_read: '443' solr_port_read: '443'
@@ -110,5 +128,5 @@ solr_port_write: '8983'
solr_scheme_read: https solr_scheme_read: https
solr_scheme_write: '' solr_scheme_write: ''
solr_use_write_connection: false solr_use_write_connection: false
solr_username_read: solr solr_username_read: '%env(SOLR_USERNAME)%'
websiteTitle: '' websiteTitle: ''
+22 -1
View File
@@ -1,6 +1,27 @@
vitec.debugMode: true vitec.debugMode: true
my.example.setting: Test
menu.footer.pageUids: '1,2,3' menu.footer.pageUids: '1,2,3'
menu.meta.pageUids: '5' menu.meta.pageUids: '5'
megamenu.contentUid: 10366 megamenu.contentUid: 10366
vitec.storyDetailPid: 26 vitec.storyDetailPid: 26
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
-3
View File
@@ -1,3 +0,0 @@
solr
DvgpSaqrxr9QCd4ES36V
+2 -1
View File
@@ -20,7 +20,8 @@ echo "[perms] Project root: $(pwd)"
# importer output lands there (success_stories_import, blog-import, downloads, # importer output lands there (success_stories_import, blog-import, downloads,
# user_upload/products ...) and hits the same 403 otherwise. Found 2026-09-11, # user_upload/products ...) and hits the same 403 otherwise. Found 2026-09-11,
# when the imported success-story images stayed invisible in the megamenu. # when the imported success-story images stayed invisible in the megamenu.
TARGETS="public/_assets public/fileadmin packages/vitec/Resources/Public" # public/product-audit: generated report page (build_product_audit.php), same 403 trap
TARGETS="public/_assets public/fileadmin public/product-audit packages/vitec/Resources/Public"
echo "[perms] Setting files 644 / directories 755 under $TARGETS ..." echo "[perms] Setting files 644 / directories 755 under $TARGETS ..."
find $TARGETS -type f -exec chmod 644 {} \; 2>/dev/null find $TARGETS -type f -exec chmod 644 {} \; 2>/dev/null
@@ -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";
+215
View File
@@ -0,0 +1,215 @@
<?php
declare(strict_types=1);
/**
* Builds the internal product-asset audit page: one row per product record
* with thumbnail and badges for everything we track (hero, photos, texts,
* resources, datasheets, solutions, flags). Output is a static page under
* the dev host's basic auth:
*
* public/product-audit/index.html -> https://dev.vitec.com/product-audit/
*
* Plain PDO against config/system/settings.php (pattern
* migrations/check_bg.php), re-run any time the data changes:
*
* php migrations/build_product_audit.php
*/
error_reporting(E_ALL);
ini_set('display_errors', '1');
$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]
);
// ---- data -----------------------------------------------------------------
$products = $pdo->query(
"SELECT uid, title, slug, teaser, subtitle, description, description2, highlights,
capabilities, applications, resources, resources_layout, datasheets, solutions,
hidden, legacy, supportproduct, subproduct, hideonwebsite, hideonproducts, showdatapath
FROM tx_vitec_domain_model_product WHERE deleted = 0 ORDER BY title"
)->fetchAll();
$refs = $pdo->query(
"SELECT r.uid_foreign, r.fieldname, COUNT(*) AS cnt, MIN(r.sorting_foreign) AS minsort
FROM sys_file_reference r
WHERE r.deleted = 0 AND r.hidden = 0 AND r.tablenames = 'tx_vitec_domain_model_product'
AND r.fieldname IN ('heroimage','productimage','ogimage','videofile')
GROUP BY r.uid_foreign, r.fieldname"
)->fetchAll();
$refCount = [];
foreach ($refs as $r) {
$refCount[(int)$r['uid_foreign']][$r['fieldname']] = (int)$r['cnt'];
}
// first thumbnail per product: prefer a product photo, fall back to the hero
$thumbRows = $pdo->query(
"SELECT r.uid_foreign, r.fieldname, 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_domain_model_product'
AND r.fieldname IN ('heroimage','productimage')
ORDER BY r.uid_foreign, FIELD(r.fieldname,'productimage','heroimage'), r.sorting_foreign"
)->fetchAll();
$thumb = [];
foreach ($thumbRows as $r) {
$uid = (int)$r['uid_foreign'];
if (!isset($thumb[$uid])) {
$thumb[$uid] = '/fileadmin' . $r['identifier'];
}
}
// ---- helpers ----------------------------------------------------------------
function h(string $v): string
{
return htmlspecialchars($v, ENT_QUOTES | ENT_HTML5);
}
function badge(string $label, bool $ok, string $detail = ''): string
{
$cls = $ok ? 'ok' : 'miss';
$txt = $label . ($detail !== '' ? ' ' . $detail : '');
return '<span class="b ' . $cls . '">' . h($txt) . '</span>';
}
// ---- stats ------------------------------------------------------------------
$total = count($products);
$stat = ['hero' => 0, 'photo' => 0, 'teaser' => 0, 'highlights' => 0, 'description' => 0, 'resources' => 0, 'datasheets' => 0, 'solutions' => 0];
foreach ($products as $p) {
$uid = (int)$p['uid'];
if (($refCount[$uid]['heroimage'] ?? 0) > 0) { $stat['hero']++; }
if (($refCount[$uid]['productimage'] ?? 0) > 0) { $stat['photo']++; }
if (trim((string)$p['teaser']) !== '') { $stat['teaser']++; }
if (trim((string)$p['highlights']) !== '') { $stat['highlights']++; }
if (trim((string)$p['description']) !== '') { $stat['description']++; }
if ((int)$p['resources'] > 0) { $stat['resources']++; }
if ((int)$p['datasheets'] > 0) { $stat['datasheets']++; }
if ((int)$p['solutions'] > 0) { $stat['solutions']++; }
}
// ---- rows -------------------------------------------------------------------
$rowsHtml = '';
foreach ($products as $p) {
$uid = (int)$p['uid'];
$hero = $refCount[$uid]['heroimage'] ?? 0;
$photos = $refCount[$uid]['productimage'] ?? 0;
$og = $refCount[$uid]['ogimage'] ?? 0;
$video = $refCount[$uid]['videofile'] ?? 0;
$hasTeaser = trim((string)$p['teaser']) !== '';
$hasHighlights = trim((string)$p['highlights']) !== '';
$hasDescription = trim((string)$p['description']) !== '';
$classes = ['row'];
if ($hero === 0) { $classes[] = 'f-nohero'; }
if ($photos === 0) { $classes[] = 'f-nophoto'; }
if (!$hasTeaser) { $classes[] = 'f-noteaser'; }
if (!$hasHighlights) { $classes[] = 'f-nohighlights'; }
if ((int)$p['legacy'] === 1) { $classes[] = 'f-legacy'; }
if ((int)$p['hidden'] === 1 || (int)$p['hideonwebsite'] === 1) { $classes[] = 'f-hidden'; }
$flags = [];
foreach (['hidden' => 'hidden', 'legacy' => 'legacy', 'supportproduct' => 'support', 'subproduct' => 'sub', 'hideonwebsite' => 'hideWeb', 'hideonproducts' => 'hideList', 'showdatapath' => 'datapath'] as $col => $label) {
if ((int)$p[$col] === 1) {
$flags[] = '<span class="b flag">' . $label . '</span>';
}
}
$img = isset($thumb[$uid])
? '<img loading="lazy" src="' . h($thumb[$uid]) . '" alt="">'
: '<div class="noimg">no image</div>';
$slug = trim((string)$p['slug'], '/');
$rowsHtml .= '<div class="' . implode(' ', $classes) . '">'
. '<div class="thumb">' . $img . '</div>'
. '<div class="info">'
. '<div class="t"><a href="/product/' . h($slug) . '" target="_blank">' . h((string)$p['title']) . '</a>'
. ' <span class="uid">#' . $uid . '</span></div>'
. '<div class="badges">'
. badge('Hero', $hero > 0, $hero > 1 ? '(' . $hero . '!)' : '')
. badge('Photos', $photos > 0, (string)($photos ?: ''))
. badge('Teaser', $hasTeaser)
. badge('Description', $hasDescription)
. badge('Highlights', $hasHighlights)
. badge('Resources', (int)$p['resources'] > 0, (string)((int)$p['resources'] ?: ''))
. badge('Datasheets', (int)$p['datasheets'] > 0, (string)((int)$p['datasheets'] ?: ''))
. badge('Solutions', (int)$p['solutions'] > 0, (string)((int)$p['solutions'] ?: ''))
. badge('OG', $og > 0)
. badge('Video', $video > 0)
. '<span class="b lay">' . h((string)$p['resources_layout']) . '</span>'
. implode('', $flags)
. '</div></div></div>' . "\n";
}
// ---- page -------------------------------------------------------------------
$generated = date('Y-m-d H:i');
$statLine = sprintf(
'Hero %d/%d · Photos %d/%d · Teaser %d/%d · Description %d/%d · Highlights %d/%d · Resources %d/%d · Datasheets %d/%d · Solutions %d/%d',
$stat['hero'], $total, $stat['photo'], $total, $stat['teaser'], $total, $stat['description'], $total,
$stat['highlights'], $total, $stat['resources'], $total, $stat['datasheets'], $total, $stat['solutions'], $total
);
$html = '<!doctype html><html lang="en"><head><meta charset="utf-8">'
. '<meta name="viewport" content="width=device-width, initial-scale=1">'
. '<meta name="robots" content="noindex,nofollow">'
. '<title>VITEC Product Audit</title><style>'
. ':root{--ok:#1a7f37;--miss:#c0392b;--line:#e3e5e8;color-scheme:light}'
. 'body{font:14px/1.45 system-ui,sans-serif;margin:0;background:#f6f7f9;color:#1c1e21}'
. 'header{position:sticky;top:0;background:#fff;border-bottom:1px solid var(--line);padding:12px 20px;z-index:2}'
. 'h1{font-size:17px;margin:0 0 4px}.stats{color:#555;font-size:12.5px}'
. '.filters{margin-top:8px;display:flex;gap:6px;flex-wrap:wrap}'
. '.filters button{border:1px solid var(--line);background:#fff;border-radius:14px;padding:3px 11px;font-size:12.5px;cursor:pointer}'
. '.filters button.on{background:#26358c;color:#fff;border-color:#26358c}'
. 'main{max-width:1200px;margin:14px auto;padding:0 20px}'
. '.row{display:flex;gap:14px;background:#fff;border:1px solid var(--line);border-radius:8px;margin-bottom:8px;padding:10px;align-items:center}'
. '.row.hide{display:none}'
. '.thumb{flex:0 0 150px;height:84px;display:flex;align-items:center;justify-content:center;background:#fafbfc;border-radius:6px;overflow:hidden}'
. '.thumb img{max-width:100%;max-height:100%;object-fit:contain}'
. '.noimg{color:#b23;font-size:12px}'
. '.t{font-weight:600;margin-bottom:5px}.t a{color:#1c1e21;text-decoration:none}.t a:hover{text-decoration:underline}'
. '.uid{color:#999;font-weight:400;font-size:12px}'
. '.badges{display:flex;flex-wrap:wrap;gap:4px}'
. '.b{font-size:11.5px;border-radius:10px;padding:1px 8px;border:1px solid var(--line)}'
. '.b.ok{background:#e8f5ec;color:var(--ok);border-color:#bfe3c8}'
. '.b.miss{background:#fdecea;color:var(--miss);border-color:#f3c3bd}'
. '.b.flag{background:#fff7e0;color:#8a6d00;border-color:#eadfa8}'
. '.b.lay{background:#eef1fb;color:#26358c;border-color:#ccd4f0}'
. '</style></head><body>'
. '<header><h1>VITEC Product Audit <span class="uid">(' . $total . ' records, generated ' . $generated . ')</span></h1>'
. '<div class="stats">' . h($statLine) . '</div>'
. '<div class="filters">'
. '<button class="on" data-f="">all</button>'
. '<button data-f="f-nohero">no hero</button>'
. '<button data-f="f-nophoto">no photos</button>'
. '<button data-f="f-noteaser">no teaser</button>'
. '<button data-f="f-nohighlights">no highlights</button>'
. '<button data-f="f-legacy">legacy</button>'
. '<button data-f="f-hidden">hidden</button>'
. '</div></header><main>' . "\n" . $rowsHtml . '</main>'
. '<script>document.querySelectorAll(".filters button").forEach(b=>b.addEventListener("click",()=>{'
. 'document.querySelectorAll(".filters button").forEach(x=>x.classList.remove("on"));b.classList.add("on");'
. 'const f=b.dataset.f;document.querySelectorAll(".row").forEach(r=>{r.classList.toggle("hide",f!==""&&!r.classList.contains(f));});'
. '}));</script></body></html>';
$outDir = $root . '/public/product-audit';
if (!is_dir($outDir)) {
mkdir($outDir, 0775, true);
}
file_put_contents($outDir . '/index.html', $html);
echo "geschrieben: public/product-audit/index.html ($total Produkte)\n";
echo "-> https://dev.vitec.com/product-audit/\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";
+15
View File
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
// Diagnose: state of one download record (uid as argv). READ-ONLY.
error_reporting(E_ALL); ini_set('display_errors', '1');
$uid = (int)($argv[1] ?? 0);
$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]);
$r = $pdo->query("SELECT uid, pid, title, slug, hidden, deleted, starttime, endtime, private_download, file, fileprefix, useolddl FROM tx_vitec_domain_model_download WHERE uid = $uid")->fetch();
print_r($r ?: "not found\n");
if ($r) {
foreach ($pdo->query("SELECT r.uid, r.uid_local, f.identifier FROM sys_file_reference r LEFT JOIN sys_file f ON f.uid = r.uid_local WHERE r.deleted = 0 AND r.tablenames = 'tx_vitec_domain_model_download' AND r.uid_foreign = $uid") as $ref) { print_r($ref); }
}
+35
View File
@@ -0,0 +1,35 @@
<?php
/**
* Diagnose: all fe_comment summaries and items of one FE user, including
* deleted ones, with timestamps. Notes are shortened, no secrets printed.
* php migrations/check_fecomment_user.php 2
*/
$feUser = (int)($argv[1] ?? 0);
if ($feUser <= 0) {
fwrite(STDERR, "Aufruf: php migrations/check_fecomment_user.php <fe_users-uid>\n");
exit(1);
}
$settings = require __DIR__ . '/../config/system/settings.php';
$db = $settings['DB']['Connections']['Default'];
$pdo = new PDO('mysql:host=' . $db['host'] . ';dbname=' . $db['dbname'] . ';charset=utf8mb4', $db['user'], $db['password']);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$summaries = $pdo->prepare(
"SELECT uid, status, deleted, FROM_UNIXTIME(crdate) AS created, FROM_UNIXTIME(tstamp) AS changed,
FROM_UNIXTIME(NULLIF(sent_at,0)) AS sent, recipients, CHAR_LENGTH(general_notes) AS notes_len
FROM tx_fecomment_summary WHERE fe_user = ? ORDER BY uid"
);
$summaries->execute([$feUser]);
$items = $pdo->prepare(
"SELECT uid, deleted, FROM_UNIXTIME(crdate) AS created, FROM_UNIXTIME(tstamp) AS changed, url,
CHAR_LENGTH(note) AS note_len
FROM tx_fecomment_feedback WHERE summary = ? ORDER BY sorting"
);
foreach ($summaries->fetchAll(PDO::FETCH_ASSOC) as $s) {
echo "Summary {$s['uid']} | status={$s['status']} deleted={$s['deleted']} | erstellt {$s['created']} | geaendert {$s['changed']}"
. ($s['sent'] ? " | gesendet {$s['sent']}" : '') . " | recipients=[{$s['recipients']}] | general_notes " . (int)$s['notes_len'] . " Zeichen\n";
$items->execute([(int)$s['uid']]);
foreach ($items->fetchAll(PDO::FETCH_ASSOC) as $i) {
echo " Item {$i['uid']} | deleted={$i['deleted']} | erstellt {$i['created']} | geaendert {$i['changed']} | note " . (int)$i['note_len'] . " Zeichen | {$i['url']}\n";
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
/**
* Diagnose: warum schlaegt der FE-Login fehl?
* Aufruf: php migrations/check_feuser.php <username> '<passwort>'
* Gibt KEINE Geheimnisse aus - nur Statusfelder und ein verify-Ergebnis.
*/
if ($argc < 2) {
fwrite(STDERR, "Aufruf: php migrations/check_feuser.php <username> ['<passwort>']\n");
exit(1);
}
$username = $argv[1];
$password = $argv[2] ?? null;
$settings = require __DIR__ . '/../config/system/settings.php';
$db = $settings['DB']['Connections']['Default'];
$pdo = new PDO(
'mysql:host=' . $db['host'] . ';dbname=' . $db['dbname'] . ';charset=utf8mb4',
$db['user'],
$db['password']
);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$stmt = $pdo->prepare(
'SELECT uid, pid, username, usergroup, disable, deleted, starttime, endtime, password
FROM fe_users WHERE username = ?'
);
$stmt->execute([$username]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (!$rows) {
echo "KEIN fe_users-Datensatz mit username='$username' gefunden (auch kein geloeschter).\n";
exit(0);
}
$now = time();
foreach ($rows as $r) {
echo "--- fe_users uid={$r['uid']} ---\n";
echo 'pid: ' . $r['pid'] . ($r['pid'] == 520 ? ' (ok)' : ' <-- NICHT 520! Login-POST braucht pid=' . $r['pid']) . "\n";
echo 'usergroup: "' . $r['usergroup'] . '"' . (in_array('1', array_map('trim', explode(',', (string)$r['usergroup'])), true) ? ' (enthaelt Gruppe 1, ok)' : ' <-- Gruppe 1 FEHLT') . "\n";
echo 'deleted: ' . $r['deleted'] . ($r['deleted'] ? ' <-- GELOESCHT' : ' (ok)') . "\n";
echo 'disable: ' . $r['disable'] . ($r['disable'] ? ' <-- DEAKTIVIERT' : ' (ok)') . "\n";
echo 'starttime: ' . $r['starttime'] . ($r['starttime'] > $now ? ' <-- liegt in der ZUKUNFT' : ' (ok)') . "\n";
echo 'endtime: ' . $r['endtime'] . (($r['endtime'] && $r['endtime'] < $now) ? ' <-- ABGELAUFEN' : ' (ok)') . "\n";
$hash = (string)$r['password'];
$prefix = substr($hash, 0, strpos($hash, '$', 1) ?: 8);
echo 'hash-typ: ' . ($prefix !== '' ? $prefix : '(leer!)') . "\n";
if ($password !== null) {
if (str_starts_with($hash, '$argon2') || str_starts_with($hash, '$2y$')) {
echo 'passwort: ' . (password_verify($password, $hash) ? 'VERIFIZIERT - stimmt mit dem Hash ueberein' : 'FALSCH - stimmt NICHT mit dem Hash ueberein') . "\n";
} else {
echo "passwort: Hash-Format unbekannt, kein lokaler Verify moeglich\n";
}
}
}
// Gruppe 1 existiert und ist nicht versteckt/geloescht?
$g = $pdo->query('SELECT uid, pid, title, hidden, deleted FROM fe_groups WHERE uid = 1')->fetch(PDO::FETCH_ASSOC);
echo "--- fe_groups uid=1 ---\n";
echo $g
? "title: \"{$g['title']}\" pid: {$g['pid']} hidden: {$g['hidden']} deleted: {$g['deleted']}\n"
: "EXISTIERT NICHT!\n";
+41
View File
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
/**
* Diagnose: state of the /products grid wrap (vitec:wrap-cards-grid run,
* 2026-10-27). Cards and containers on pid 447 with colPos /
* tx_container_parent / sorting / hidden / deleted.
*
* READ-ONLY. Usage: php migrations/check_grid_447.php
*/
error_reporting(E_ALL);
ini_set('display_errors', '1');
$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 "=== all vitec_card / vitec_cols_33_33_33 / vitec_introparagraph on pid 447 ===\n";
$rows = $pdo->query(
"SELECT uid, CType, LEFT(header,32) h, colPos, tx_container_parent par, sorting, hidden, deleted
FROM tt_content WHERE pid = 447 AND CType IN ('vitec_card','vitec_cols_33_33_33','vitec_introparagraph')
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";
+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";
+50
View File
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
/**
* Diagnose: the six old-taxonomy category stubs under /products
* (#451-454, #456, #458) - their own content, their child pages (family
* level) and those children's content/state. Basis for the
* hide/move/redirect decision (2026-10-27).
*
* READ-ONLY. Usage: php migrations/check_old_stubs.php
*/
error_reporting(E_ALL);
ini_set('display_errors', '1');
$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]
);
function tree(PDO $pdo, int $pid, int $depth): void
{
$rows = $pdo->query("SELECT uid, title, slug, doktype, layout, hidden, nav_hide, sorting
FROM pages WHERE deleted = 0 AND pid = $pid ORDER BY sorting")->fetchAll();
foreach ($rows as $r) {
$ce = (int)$pdo->query("SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND hidden = 0 AND pid = {$r['uid']}")->fetchColumn();
printf("%s#%-5d dok=%d lay=%-2d hid=%d nav=%d ce=%-3d %-55s %s\n",
str_repeat(' ', $depth), $r['uid'], $r['doktype'], $r['layout'], $r['hidden'], $r['nav_hide'], $ce, $r['slug'], $r['title']);
tree($pdo, (int)$r['uid'], $depth + 1);
}
}
foreach ([451, 452, 453, 454, 456, 458] as $stub) {
$r = $pdo->query("SELECT uid, title, slug, hidden, nav_hide FROM pages WHERE uid = $stub")->fetch();
$ce = (int)$pdo->query("SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND hidden = 0 AND pid = $stub")->fetchColumn();
printf("STUB #%-4d hid=%d nav=%d ce=%-3d %-50s %s\n", $r['uid'], $r['hidden'], $r['nav_hide'], $ce, $r['slug'], $r['title']);
tree($pdo, $stub, 1);
echo "\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";
+127
View File
@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
/**
* Comparison report: download records vs. the delivered product workbooks
* (request Olli 2026-10-28). Per product: linked downloads
* (tx_vitec_product_download_mm, uid_local = product), datasheet coverage
* (download title suffix "datasheet" or a Datasheet category), the manual
* Resources-tab selection (tx_vitec_product_datasheet_mm) - matched against
* the workbook coverage from migrations/products_xlsx/manifest.json.
*
* READ-ONLY. Usage: php migrations/check_product_downloads.php
*/
error_reporting(E_ALL);
ini_set('display_errors', '1');
$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]
);
// --- workbook coverage from the manifest ------------------------------------
$manifest = json_decode((string)file_get_contents($root . '/migrations/products_xlsx/manifest.json'), true) ?: [];
$workbookSlugs = [];
foreach (($manifest['workbooks'] ?? []) as $file => $entry) {
if (!empty($entry['product'])) {
$workbookSlugs[(string)$entry['product']] = basename((string)$file);
}
}
// --- data ---------------------------------------------------------------------
$products = $pdo->query('SELECT uid, title, slug, hidden, legacy, supportproduct, subproduct FROM tx_vitec_domain_model_product WHERE deleted = 0 ORDER BY title')->fetchAll();
$downloads = $pdo->query('SELECT uid, title, hidden FROM tx_vitec_domain_model_download WHERE deleted = 0')->fetchAll();
$downloadTitle = array_column($downloads, 'title', 'uid');
$linksByProduct = [];
$linkedDownloadUids = [];
foreach ($pdo->query('SELECT uid_local, uid_foreign FROM tx_vitec_product_download_mm') as $r) {
$linksByProduct[(int)$r['uid_local']][] = (int)$r['uid_foreign'];
$linkedDownloadUids[(int)$r['uid_foreign']] = true;
}
$sheetsByProduct = [];
foreach ($pdo->query('SELECT uid_local, COUNT(*) c FROM tx_vitec_product_datasheet_mm GROUP BY uid_local') as $r) {
$sheetsByProduct[(int)$r['uid_local']] = (int)$r['c'];
}
$isDatasheet = static function (string $title): bool {
return (bool)preg_match('/data\s*sheet\s*$/i', trim($title));
};
$docType = static function (string $title): string {
foreach (['datasheet', 'data sheet', 'brochure', 'user guide', 'quick start guide', 'application note',
'white paper', 'case study', 'success story', 'manual', 'firmware', 'software', 'flyer', 'guide'] as $suffix) {
if (preg_match('/' . preg_quote($suffix, '/') . '\s*$/i', trim($title))) {
return $suffix;
}
}
return '(no type suffix)';
};
// --- per-product evaluation ------------------------------------------------------
$noDownloads = [];
$noDatasheet = [];
$okCount = 0;
$withoutWorkbook = [];
foreach ($products as $p) {
$uid = (int)$p['uid'];
$slug = (string)$p['slug'];
$linked = $linksByProduct[$uid] ?? [];
$hasSheet = false;
foreach ($linked as $dUid) {
if ($isDatasheet((string)($downloadTitle[$dUid] ?? ''))) { $hasSheet = true; break; }
}
$flag = ((int)$p['legacy'] ? ' [legacy]' : '') . ((int)$p['hidden'] ? ' [hidden]' : '')
. ((int)$p['supportproduct'] ? ' [support]' : '') . ((int)$p['subproduct'] ? ' [sub]' : '');
$line = sprintf('#%-4d %-45s dl=%-2d sheetsTab=%d%s', $uid, $p['title'], count($linked), $sheetsByProduct[$uid] ?? 0, $flag);
if (!isset($workbookSlugs[$slug])) {
$withoutWorkbook[] = $line;
}
if (isset($workbookSlugs[$slug])) {
if ($linked === []) { $noDownloads[] = $line; }
elseif (!$hasSheet) { $noDatasheet[] = $line; }
else { $okCount++; }
}
}
// --- orphan downloads ---------------------------------------------------------------
$orphans = [];
$orphanTypes = [];
foreach ($downloads as $d) {
if (!isset($linkedDownloadUids[(int)$d['uid']])) {
$type = $docType((string)$d['title']);
$orphanTypes[$type] = ($orphanTypes[$type] ?? 0) + 1;
$orphans[] = $d['title'];
}
}
printf("Produkte: %d (davon %d mit Workbook) · Downloads: %d (davon %d verlinkt, %d verwaist)\n\n",
count($products), count($workbookSlugs), count($downloads), count($linkedDownloadUids), count($orphans));
echo "=== A) Workbook-Produkte OHNE verknuepfte Downloads (" . count($noDownloads) . ") ===\n";
foreach ($noDownloads as $l) { echo " $l\n"; }
echo "\n=== B) Workbook-Produkte mit Downloads, aber OHNE Datasheet (" . count($noDatasheet) . ") ===\n";
foreach ($noDatasheet as $l) { echo " $l\n"; }
echo "\n=== C) Workbook-Produkte mit Datasheet-Abdeckung: $okCount ===\n";
echo "\n=== D) Produkte OHNE Workbook (" . count($withoutWorkbook) . ") - zur Einordnung ===\n";
foreach ($withoutWorkbook as $l) { echo " $l\n"; }
arsort($orphanTypes);
echo "\n=== E) Verwaiste Downloads nach Typ ===\n";
foreach ($orphanTypes as $type => $n) { printf(" %-22s %d\n", $type, $n); }
echo "\n Beispiele (max 30):\n";
foreach (array_slice($orphans, 0, 30) as $t) { echo " - $t\n"; }
+46
View File
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
/**
* Diagnose: where does the /products page (#447) content actually live?
* The portfolio dry-run found ZERO visible elements on pid 447 although the
* JSON payload renders ~40. Suspicion: content_from_pid or elements on
* another pid (10331/10368 = payload samples).
*
* READ-ONLY, plain PDO (pattern migrations/check_bg.php).
* Usage: php migrations/check_products_page.php
*/
error_reporting(E_ALL);
ini_set('display_errors', '1');
$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 "=== page 447 ===\n";
$p = $pdo->query('SELECT uid, title, slug, doktype, layout, content_from_pid, l10n_parent, sys_language_uid FROM pages WHERE uid = 447')->fetch();
print_r($p);
echo "\n=== tt_content on pid 447 (any state) ===\n";
foreach ($pdo->query('SELECT colPos, CType, COUNT(*) c, SUM(hidden) hid, SUM(deleted) del FROM tt_content WHERE pid = 447 GROUP BY colPos, CType ORDER BY colPos') as $r) {
printf(" colPos %-4s %-24s n=%-3d hidden=%d deleted=%d\n", $r['colPos'], $r['CType'], $r['c'], $r['hid'], $r['del']);
}
echo "\n=== sample payload elements ===\n";
foreach ([10331, 10368, 10306] as $uid) {
$r = $pdo->query("SELECT uid, pid, CType, colPos, tx_container_parent, hidden, deleted, sys_language_uid FROM tt_content WHERE uid = $uid")->fetch();
print_r($r);
}
+43
View File
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
/**
* Diagnose part 2: page 448 (where the rendered /products elements live)
* plus every page whose slug starts with /products, and the visible
* element summary of 448.
*
* READ-ONLY. Usage: php migrations/check_products_page2.php
*/
error_reporting(E_ALL);
ini_set('display_errors', '1');
$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 "=== pages with slug like /products% (incl. deleted/hidden) ===\n";
foreach ($pdo->query("SELECT uid, pid, title, slug, doktype, layout, hidden, deleted, sorting FROM pages WHERE slug LIKE '/products%' ORDER BY uid") as $r) {
printf(" #%-4d pid=%-4d dok=%d lay=%-2d hid=%d del=%d sort=%-5d %-42s %s\n",
$r['uid'], $r['pid'], $r['doktype'], $r['layout'], $r['hidden'], $r['deleted'], $r['sorting'], $r['slug'], $r['title']);
}
echo "\n=== page 448 ===\n";
print_r($pdo->query('SELECT uid, pid, title, slug, doktype, layout, hidden, deleted, content_from_pid FROM pages WHERE uid = 448')->fetch());
echo "\n=== visible tt_content on pid 448 (top level) ===\n";
foreach ($pdo->query('SELECT colPos, CType, COUNT(*) c FROM tt_content WHERE pid = 448 AND deleted = 0 AND hidden = 0 AND tx_container_parent = 0 GROUP BY colPos, CType ORDER BY colPos') as $r) {
printf(" colPos %-4s %-24s n=%d\n", $r['colPos'], $r['CType'], $r['c']);
}
+29
View File
@@ -0,0 +1,29 @@
<?php
/**
* Diagnose: what do the solution records really contain (all versions)?
* php migrations/check_solution_fields.php 50 1
*/
$uids = array_map('intval', array_slice($argv, 1)) ?: [50, 1];
$settings = require __DIR__ . '/../config/system/settings.php';
$db = $settings['DB']['Connections']['Default'];
$pdo = new PDO('mysql:host=' . $db['host'] . ';dbname=' . $db['dbname'] . ';charset=utf8mb4', $db['user'], $db['password']);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$in = implode(',', $uids);
$rows = $pdo->query(
"SELECT uid, pid, deleted, hidden, sys_language_uid, l10n_parent, t3ver_oid, t3ver_wsid, t3ver_state,
FROM_UNIXTIME(tstamp) AS changed, title, teaser, LEFT(description, 90) AS description
FROM tx_vitec_domain_model_solution
WHERE uid IN ($in) OR t3ver_oid IN ($in) OR l10n_parent IN ($in)
ORDER BY uid"
)->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as $r) {
$kind = $r['t3ver_oid'] > 0
? 'WORKSPACE-VERSION (ws ' . $r['t3ver_wsid'] . ') von uid ' . $r['t3ver_oid']
: ($r['l10n_parent'] > 0 ? 'Uebersetzung von uid ' . $r['l10n_parent'] : 'LIVE-Datensatz');
echo "--- uid {$r['uid']} | $kind | deleted={$r['deleted']} hidden={$r['hidden']} | geaendert {$r['changed']}\n";
echo " title: {$r['title']}\n";
echo " teaser: " . ($r['teaser'] === '' ? '(leer)' : $r['teaser']) . "\n";
echo " description: " . ($r['description'] === '' || $r['description'] === null ? '(leer)' : $r['description']) . "\n";
}
+25
View File
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
// Diagnose: ordered content elements of one usecase (argv: usecase uid,
// default 12 = ADIB) incl. columns children. READ-ONLY.
error_reporting(E_ALL); ini_set('display_errors', '1');
$ucUid = (int)($argv[1] ?? 12);
$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]);
$story = $pdo->query("SELECT uid, title, products FROM tx_vitec_domain_model_usecase WHERE uid = $ucUid")->fetch();
echo "Story #{$story['uid']} \"{$story['title']}\" products-MM-count-field={$story['products']}\n";
echo "verknuepfte Produkte: ";
foreach ($pdo->query("SELECT p.uid, p.title FROM tx_vitec_usecase_product_mm mm JOIN tx_vitec_domain_model_product p ON p.uid = mm.uid_foreign WHERE mm.uid_local = $ucUid ORDER BY mm.sorting") as $p) {
echo "#{$p['uid']} {$p['title']} | ";
}
echo "\n\n";
function dump(PDO $pdo, string $where, int $depth): void {
foreach ($pdo->query("SELECT uid, CType, LEFT(header,45) h, colPos, sorting, hidden, LEFT(REPLACE(REPLACE(COALESCE(bodytext,''),CHAR(10),' '),CHAR(13),''),70) b FROM tt_content WHERE deleted = 0 AND $where ORDER BY sorting") as $r) {
printf("%s#%-5d %-20s col=%-4d sort=%-6d hid=%d %-45s | %s\n", str_repeat(' ', $depth), $r['uid'], $r['CType'], $r['colPos'], $r['sorting'], $r['hidden'], $r['h'], $r['b']);
dump($pdo, "tx_container_parent = {$r['uid']}", $depth + 1);
}
}
dump($pdo, "tx_vitec_usecase_content = $ucUid AND tx_container_parent = 0", 0);
@@ -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";
+36
View File
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
/**
* One-time repair 2026-10-02: the fe_comment tables were created while the
* TCA cache was stale, so TYPO3's schema enrichment (uid/pid/...) never ran
* and MySQL cannot add an auto-increment PK afterwards. Both tables are
* still EMPTY - drop them and let extension:setup recreate them properly.
*
* Usage: php migrations/drop_fecomment_tables.php
* (then: vendor/bin/typo3 cache:flush && vendor/bin/typo3 extension:setup)
*/
$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]
);
foreach (['tx_fecomment_summary', 'tx_fecomment_feedback'] as $table) {
$count = 0;
try { $count = (int)$pdo->query('SELECT COUNT(*) FROM `' . $table . '`')->fetchColumn(); } catch (\Throwable) { echo "$table: existiert nicht - ok\n"; continue; }
if ($count > 0) { exit("$table enthaelt $count Zeilen - ABBRUCH, nicht droppen!\n"); }
$pdo->exec('DROP TABLE `' . $table . '`');
echo "$table: geloescht (war leer)\n";
}
echo "Fertig. Jetzt: vendor/bin/typo3 cache:flush && vendor/bin/typo3 extension:setup\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";
+43
View File
@@ -0,0 +1,43 @@
#!/bin/bash
# fe_comment API-Testkette (braucht eingeloggte Session aus fec_login_test.sh)
# Aufruf: bash migrations/fec_api_test.sh
set -e
cd /usr/www/users/vitecevo/live
BASE=https://dev.vitec.com
JAR=/tmp/fec.jar
[ -s "$JAR" ] || { echo "Cookie-Jar fehlt - erst migrations/fec_login_test.sh laufen lassen."; exit 1; }
echo '--- 1) Feedback anlegen (mit Sanitizer-Probe: script-Tag muss verschwinden, strong bleiben) ---'
curl -sS -b "$JAR" -H 'Content-Type: application/json' \
-d '{"url":"https://dev.vitec.com/testseite","note":"<p>Erster Test <script>alert(1)</script> mit <strong>fett</strong></p>"}' \
"$BASE/api/fe-comment/feedback" | tee /tmp/fec_fb.json; echo
FB=$(php -r '$j=json_decode(file_get_contents("/tmp/fec_fb.json"),true);echo (int)($j["uid"]??0);')
echo "Feedback-UID: $FB"
[ "$FB" -gt 0 ] || { echo "Anlegen fehlgeschlagen."; exit 1; }
echo '--- 2) Feedback aendern ---'
curl -sS -b "$JAR" -H 'Content-Type: application/json' \
-d '{"note":"<p>Geaenderte Notiz, <em>kursiv</em>.</p>"}' \
"$BASE/api/fe-comment/feedback/$FB"; echo
echo '--- 3) Datei-Upload (Test-PNG vom Server) ---'
curl -sS -b "$JAR" \
-F "file=@public/testimages/placeholder_1920x600.png;type=image/png" \
-F "feedback=$FB" \
"$BASE/api/fe-comment/upload"; echo
echo '--- 4) General Notes an der Summary ---'
curl -sS -b "$JAR" -H 'Content-Type: application/json' \
-d '{"generalNotes":"<p>Bitte alles einmal durchsehen.</p>","recipients":[]}' \
"$BASE/api/fe-comment/summary"; echo
echo '--- 5) Gesamtzustand ---'
curl -sS -b "$JAR" "$BASE/api/fe-comment/state"; echo
read -rp "6) Submit ausloesen (Mail an die mailTo-Adresse)? [j/N] " yn
if [[ "$yn" =~ ^[jJyY] ]]; then
curl -sS -b "$JAR" -X POST "$BASE/api/fe-comment/submit"; echo
echo '--- state nach Submit (Summary sollte weg/neu sein) ---'
curl -sS -b "$JAR" "$BASE/api/fe-comment/state"; echo
fi
+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
+53
View File
@@ -0,0 +1,53 @@
#!/bin/bash
# fe_comment Login-Testkette: GET (Nonce+Token) -> Login-POST -> state
# Aufruf: bash migrations/fec_login_test.sh (fragt das Passwort unsichtbar ab)
set -e
cd /usr/www/users/vitecevo/live
BASE=https://dev.vitec.com
read -rsp "Passwort fuer joe: " PASS; echo
rm -f /tmp/fec.jar
curl -sS -c /tmp/fec.jar -o /tmp/fec_form.json "$BASE/_json/fe-user-profile"
TOKEN=$(php -r '
$j = json_decode(file_get_contents("/tmp/fec_form.json"), true);
$f = "";
$w = function ($n) use (&$w, &$f) {
if (is_array($n)) {
// Variante 1: strukturiertes headless-Formular {name: "__RequestToken", value: "..."}
if (($n["name"] ?? "") === "__RequestToken" && isset($n["value"])) {
$f = (string)$n["value"];
}
foreach ($n as $c) { $w($c); }
} elseif (is_string($n) && $f === "") {
// Variante 2: felogin rendert klassisches HTML als String im JSON
if (preg_match("/name=\"__RequestToken\" value=\"([^\"]+)\"/", $n, $m)) {
$f = $m[1];
}
}
};
$w($j);
echo $f;
')
echo "Nonce-Cookies im Jar: $(grep -c typo3nonce /tmp/fec.jar || true)"
echo "Token-Laenge: ${#TOKEN}"
if [ -z "$TOKEN" ]; then
echo "KEIN __RequestToken im Seiten-JSON gefunden."
echo "Vorkommen von 'RequestToken' im JSON (zur Diagnose):"
grep -o 'RequestToken[^"]*"[^"]*"' /tmp/fec_form.json | head -n 3 || echo "(keine)"
exit 1
fi
curl -sS -b /tmp/fec.jar -c /tmp/fec.jar -o /tmp/fec_login.json \
--data-urlencode "user=joe" \
--data-urlencode "pass=$PASS" \
--data-urlencode "logintype=login" \
--data-urlencode "pid=520" \
--data-urlencode "__RequestToken=$TOKEN" \
"$BASE/_json/fe-user-profile"
echo "Session-Cookies (fe_typo_user): $(grep -c fe_typo_user /tmp/fec.jar || true)"
echo '--- state ---'
curl -sS -b /tmp/fec.jar "$BASE/api/fe-comment/state"; echo
+146
View File
@@ -0,0 +1,146 @@
<?php
/**
* Test data for the sub-solution pipeline: fills the generated workbook
* "Control Room Platforms SOC NOC Command Centres" with realistic copy
* (based on vitec.com product content) and real vitec.com images, and
* drops it into the import inbox.
*
* Starts from the FRESH generated file (named ranges intact), never from
* an Excel-saved copy. Stories/products are picked from the workbook's own
* dropdown lists, so every value is a valid DB title.
*
* php migrations/fill_test_subsolution.php
*/
require __DIR__ . '/../vendor/autoload.php';
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
$source = __DIR__ . '/subsolutions_xlsx_out/Control Room & Command Centre Solutions/Control Room Platforms SOC NOC Command Centres.xlsx';
$target = __DIR__ . '/subsolutions_xlsx_in/Control Room Platforms SOC NOC Command Centres.xlsx';
if (!is_file($source)) {
fwrite(STDERR, "Source workbook not found: $source\n(run vendor/bin/typo3 vitec:generate-subsolution-workbooks first)\n");
exit(1);
}
if (is_file($target)) {
copy($target, $target . '.bak');
echo "Backup of the existing inbox file: " . basename($target) . ".bak\n";
}
$spreadsheet = IOFactory::load($source);
// ---- dropdown lists from the workbook itself ------------------------------
$lists = $spreadsheet->getSheetByName('Lists');
$column = static function (string $col) use ($lists): array {
$values = [];
for ($r = 1; $r <= $lists->getHighestRow(); $r++) {
$v = trim((string)$lists->getCell($col . $r)->getValue());
if ($v !== '') {
$values[] = $v;
}
}
return $values;
};
$stories = $column('A');
$products = $column('B');
/** First list entry matching each keyword, in keyword order, no duplicates. */
$pick = static function (array $list, array $keywords, int $max) : array {
$chosen = [];
foreach ($keywords as $keyword) {
foreach ($list as $entry) {
if (stripos($entry, $keyword) !== false && !in_array($entry, $chosen, true)) {
$chosen[] = $entry;
break;
}
}
if (count($chosen) >= $max) {
break;
}
}
return $chosen;
};
$storyPicks = $pick($stories, ['National Defense', 'Defense Visual', 'ISR Video Mission', 'Disease Control', 'Parliament', 'Police', 'Government'], 3);
$productPicks = $pick($products, ['VSN V3', 'VSNMicro', 'VSNMini', 'Aligo', 'QTX100', 'WallControl', 'Fx4'], 4);
$featured = array_shift($productPicks) ?? '';
// ---- content ----------------------------------------------------------------
$values = [
'hero_subline' => 'Unified, low-latency video visualisation for security, network and command operations - built for 24/7 reliability.',
'hero_cta_label' => 'Talk to an expert',
'hero_cta_url' => '/contact',
'hero_bgimage_url' => 'https://www.vitec.com/fileadmin/user_upload/gov_16-9.jpg',
'intro_headline' => 'Every source, every screen, one operational picture',
'intro_text' => 'Security operations centres, network operations centres and command centres all face the same challenge: operators must see and act on many live sources at once - CCTV, IP streams, dashboards, sensor data and broadcast feeds - without delay and without interruption. VITEC control room platforms bring these sources together on configurable video walls and operator workstations. VSN Series controllers render any mix of HDMI, DisplayPort, SDI and IP inputs, Aligo extends the wall to remote positions with visually lossless 4K and sub-millisecond latency, and WallControl 10 lets operators rearrange layouts in seconds as situations evolve.',
'intro_image_url' => 'https://www.vitec.com/fileadmin/user_upload/control-room_control-launch.png',
'flow_headline' => 'Built for mission-critical operations',
'flow_subtext' => 'VITEC control room platforms are designed for environments where every second counts and downtime is not an option. Key advantages include:',
'flow_card1_icon' => 'capture',
'flow_card1_title' => 'Ingest',
'flow_card1_front' => 'Bring every source onto the wall: HDMI, DisplayPort, SDI and IP streams.',
'flow_card1_back' => 'Vision and ActiveSQX capture cards ingest local and networked sources side by side, so CCTV, PCs, dashboards and broadcast feeds share one display surface.',
'flow_card2_icon' => 'view',
'flow_card2_title' => 'Visualise',
'flow_card2_front' => 'Build any wall layout and change it in seconds with WallControl 10.',
'flow_card2_back' => 'Operators drag and drop sources, recall saved layouts per shift or incident, and scale from a single screen to walls of 24 displays and beyond.',
'flow_card3_icon' => 'distribute',
'flow_card3_title' => 'Extend',
'flow_card3_front' => 'Take the wall to every operator position with Aligo AVoIP.',
'flow_card3_back' => 'Visually lossless 4K60 over 10Gb networks with sub-millisecond latency, plus KVM control of remote sources - ideal for supervisor desks and overflow rooms.',
'flow_card4_icon' => 'respond',
'flow_card4_title' => 'Stay resilient',
'flow_card4_front' => 'Rack-mounted VSN V3 controllers with dual-redundant power for 24/7 duty.',
'flow_card4_back' => 'Hardware-based rendering keeps playback smooth and synchronised, while redundant power supplies keep the picture up when it matters most.',
'testi_headline' => 'Trusted in control rooms worldwide',
'prod_featured' => $featured,
'insights_text1' => 'Modern control rooms are moving from fixed matrix switching to flexible, IP-based visualisation - for faster situational awareness, simpler expansion and lower operating costs.',
'insights_text2' => 'See how organisations in defense, government and critical infrastructure use VITEC technology to keep operators informed and in control:',
'insights_cta_label' => 'Explore success stories',
'insights_cta_target' => '/success-stories',
'seo_title' => 'Control Room Platforms for SOC, NOC & Command Centres | VITEC',
'seo_description' => 'Unify CCTV, IP streams and data on resilient video walls: VITEC VSN controllers, Aligo AVoIP and WallControl 10 for SOC, NOC and command centres.',
];
foreach ($storyPicks as $i => $story) {
$values['testi_story_' . ($i + 1)] = $story;
}
foreach ($productPicks as $i => $product) {
$values['prod_card_' . ($i + 1)] = $product;
}
// ---- write via named ranges -------------------------------------------------
$missing = [];
foreach ($values as $name => $value) {
$range = $spreadsheet->getNamedRange($name);
if ($range === null || $range->getWorksheet() === null) {
$missing[] = $name;
continue;
}
$cell = str_replace('$', '', (string)$range->getValue());
$cell = str_contains($cell, '!') ? substr($cell, strpos($cell, '!') + 1) : $cell;
$range->getWorksheet()->getCell($cell)->setValue($value);
}
@mkdir(dirname($target), 0775, true);
(new Xlsx($spreadsheet))->save($target);
$spreadsheet->disconnectWorksheets();
@chmod($target, 0664);
echo "Written: " . $target . "\n";
echo "Featured product: " . ($featured !== '' ? $featured : '(none found!)') . "\n";
echo "Product cards: " . ($productPicks !== [] ? implode(' | ', $productPicks) : '(none found)') . "\n";
echo "Case studies: " . ($storyPicks !== [] ? implode(' | ', $storyPicks) : '(none found)') . "\n";
if ($missing !== []) {
echo "WARNING - named ranges not found: " . implode(', ', $missing) . "\n";
}
+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";
}
+62
View File
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
/**
* Repairs the record links the success-story import wrote into RTE bodies:
* it used t3://record?identifier=tx_vitec_download&uid=N (the TABLE name),
* but the configured link handler key - and therefore the only identifier
* config.recordLinks resolves - is `download`. Result: raw t3://record
* hrefs leaked into the JSON payload ("Download Success Story" buttons,
* found 2026-10-28).
*
* This migration rewrites identifier=tx_vitec_download -> identifier=download
* in tt_content.bodytext (plain and &amp;-encoded variants). One-time,
* idempotent, dry-run by default.
*
* Usage: php migrations/fix_download_record_links.php (dry run)
* php migrations/fix_download_record_links.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]
);
$needle = 'identifier=tx_vitec_download';
$rows = $pdo->query(
"SELECT uid, pid, CType, LEFT(header, 40) h FROM tt_content
WHERE deleted = 0 AND bodytext LIKE '%identifier=tx_vitec_download%' ORDER BY pid, uid"
)->fetchAll();
echo ($apply ? "APPLY\n" : "DRY-RUN\n");
echo count($rows) . " element(s) carry the legacy identifier:\n";
foreach ($rows as $r) {
printf(" #%-6d pid=%-5d %-18s %s\n", $r['uid'], $r['pid'], $r['CType'], $r['h']);
}
if (!$apply) {
exit("DRY RUN - add --apply to rewrite identifier=tx_vitec_download -> identifier=download.\n");
}
$stmt = $pdo->prepare(
"UPDATE tt_content SET bodytext = REPLACE(bodytext, 'identifier=tx_vitec_download', 'identifier=download'),
tstamp = UNIX_TIMESTAMP() WHERE uid = ?"
);
foreach ($rows as $r) {
$stmt->execute([(int)$r['uid']]);
}
echo count($rows) . " element(s) rewritten. Flush the cache, then re-check a story payload.\n";
+87
View File
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
/**
* Diagnose + repair: the /events redirect answers 307 instead of 301 after
* vitec:build-content-pages (2026-10-27). Suspicion: EXT:redirects
* auto-created a second sys_redirect row for the slug change (TYPO3 default
* 307) that shadows the command's own 301 row.
*
* Plain PDO against config/system/settings.php (pattern migrations/check_bg.php).
*
* Usage: php migrations/fix_events_redirect.php (show rows)
* php migrations/fix_events_redirect.php --apply (keep ONE 301, delete the rest)
*/
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 "=== sys_redirect rows for /events (and children) ===\n";
$rows = $pdo->query(
"SELECT uid, deleted, source_host, source_path, target, target_statuscode, createdon, creation_type
FROM sys_redirect WHERE source_path LIKE '/events%' ORDER BY uid"
)->fetchAll();
foreach ($rows as $r) {
echo sprintf(" #%-5d del=%d %s %s -> %s [%d] creation_type=%s created=%s\n",
$r['uid'], $r['deleted'], $r['source_host'], $r['source_path'], $r['target'],
$r['target_statuscode'], (string)($r['creation_type'] ?? '?'),
date('Y-m-d H:i', (int)$r['createdon']));
}
if ($rows === []) {
exit(" none - nothing to fix.\n");
}
$live = array_values(array_filter($rows, fn(array $r): bool => (int)$r['deleted'] === 0 && $r['source_path'] === '/events'));
if (count($live) === 0) {
exit("\nNo live /events row - check manually.\n");
}
// keep the command's own row (source_host '*', target t3://page) as 301,
// delete every other live /events row
$keep = null;
foreach ($live as $r) {
if ($r['source_host'] === '*' && str_starts_with((string)$r['target'], 't3://page')) {
$keep = $r;
break;
}
}
$keep ??= $live[0];
echo "\nPlan: keep #{$keep['uid']} (force 301), delete " . (count($live) - 1) . " other live row(s)\n";
if (!$apply) {
exit("DRY RUN - add --apply to write.\n");
}
$pdo->prepare('UPDATE sys_redirect SET target_statuscode = 301 WHERE uid = ?')->execute([(int)$keep['uid']]);
foreach ($live as $r) {
if ((int)$r['uid'] !== (int)$keep['uid']) {
$pdo->prepare('UPDATE sys_redirect SET deleted = 1 WHERE uid = ?')->execute([(int)$r['uid']]);
echo " deleted #{$r['uid']}\n";
}
}
echo "Done. Flush the cache (redirects are cached), then re-test /events.\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";
+121
View File
@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
/**
* Repair for the 2026-10-27 --delete-dropped run: the RENAME_MAP entries
* 'Government' -> 'Government & Agencies' and 'Education' -> 'Education
* Communications' are NOT idempotent (the old names double as the NEW main
* market names). The re-run therefore renamed the MAIN records (#50/#20,
* stories and teaser travelled along), created fresh empty "Government" /
* "Education" records, and pass 4 deleted the original (empty) sub-market
* records as unclaimed.
*
* This script swaps everything back:
* - #<main> "Government & Agencies" -> title Government, slug government,
* detail_page = page /markets/government
* - the empty record titled "Government" (created today) -> title
* Government & Agencies, slug government-agencies, detail_page =
* page /markets/government/government-and-agencies
* - same pair for Education / Education Communications
*
* Plain PDO (pattern migrations/check_bg.php). Dry-run by default.
*
* Usage: php migrations/fix_market_rename_regression.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]
);
function pageUid(PDO $pdo, string $slug): int
{
$stmt = $pdo->prepare('SELECT uid FROM pages WHERE deleted = 0 AND slug = ?');
$stmt->execute([$slug]);
return (int)($stmt->fetchColumn() ?: 0);
}
function record(PDO $pdo, string $title): ?array
{
$stmt = $pdo->prepare('SELECT uid, title, slug, detail_page,
(SELECT COUNT(*) FROM tx_vitec_usecase_market_mm mm WHERE mm.uid_foreign = m.uid) AS stories
FROM tx_vitec_domain_model_market m WHERE deleted = 0 AND title = ?');
$stmt->execute([$title]);
$rows = $stmt->fetchAll();
if (count($rows) !== 1) {
echo " !! " . count($rows) . " live record(s) titled \"$title\" - manual check needed\n";
return null;
}
return $rows[0];
}
$pairs = [
['mainTitle' => 'Government', 'mainSlug' => 'government', 'mainPage' => '/markets/government',
'subTitle' => 'Government & Agencies', 'subSlug' => 'government-agencies', 'subPage' => '/markets/government/government-and-agencies'],
['mainTitle' => 'Education', 'mainSlug' => 'education', 'mainPage' => '/markets/education',
'subTitle' => 'Education Communications', 'subSlug' => 'education-communications', 'subPage' => '/markets/education/education-communications'],
];
$updates = [];
foreach ($pairs as $pair) {
echo "=== {$pair['mainTitle']} ===\n";
// the record carrying the stories currently holds the SUB title
$hijacked = record($pdo, $pair['subTitle']); // ex-main, renamed today
$fresh = record($pdo, $pair['mainTitle']); // empty, created today
if ($hijacked === null || $fresh === null) {
continue;
}
$mainPageUid = pageUid($pdo, $pair['mainPage']);
$subPageUid = pageUid($pdo, $pair['subPage']);
if (!$mainPageUid || !$subPageUid) {
echo " !! page lookup failed ({$pair['mainPage']} -> $mainPageUid, {$pair['subPage']} -> $subPageUid)\n";
continue;
}
echo sprintf(" #%d \"%s\" (%d stories, slug %s, detail_page %d) -> BACK TO \"%s\" / %s / page %d\n",
$hijacked['uid'], $hijacked['title'], $hijacked['stories'], $hijacked['slug'], $hijacked['detail_page'],
$pair['mainTitle'], $pair['mainSlug'], $mainPageUid);
echo sprintf(" #%d \"%s\" (%d stories, slug %s, detail_page %d) -> BECOMES \"%s\" / %s / page %d\n",
$fresh['uid'], $fresh['title'], $fresh['stories'], $fresh['slug'], $fresh['detail_page'],
$pair['subTitle'], $pair['subSlug'], $subPageUid);
if ((int)$fresh['stories'] > 0) {
echo " !! the fresh record unexpectedly has stories - aborting this pair\n";
continue;
}
$updates[] = [(int)$hijacked['uid'], $pair['mainTitle'], $pair['mainSlug'], $mainPageUid];
$updates[] = [(int)$fresh['uid'], $pair['subTitle'], $pair['subSlug'], $subPageUid];
}
if (!$apply) {
exit("\nDRY RUN - add --apply to write.\n");
}
if ($updates === []) {
exit("\nNothing to update.\n");
}
$stmt = $pdo->prepare('UPDATE tx_vitec_domain_model_market SET title = ?, slug = ?, detail_page = ?, tstamp = UNIX_TIMESTAMP() WHERE uid = ?');
foreach ($updates as [$uid, $title, $slug, $pageUid]) {
$stmt->execute([$title, $slug, $pageUid, $uid]);
echo " updated #$uid -> \"$title\"\n";
}
echo "Done. Flush the cache, then verify the /markets marketlist and the story badges.\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 "$@"
+85
View File
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
/**
* Retires the six old-taxonomy category stubs under /products (decision
* Olli 2026-10-27; diagnosis check_old_stubs.php: all six are EMPTY -
* no content elements, no child pages, so nothing needs moving):
*
* soft-delete the stub page and queue a 301 sys_redirect onto its new
* category equivalent. Guards: a stub with children or visible content
* is skipped and reported; an existing redirect for the path is kept.
*
* Usage: php migrations/retire_old_category_stubs.php (dry run)
* php migrations/retire_old_category_stubs.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]
);
$map = [
'/products/platforms-and-end-points' => '/products/iptv-and-digital-signage',
'/products/ip-video-streaming' => '/products/video-encoding-and-streaming',
'/products/control-room-solutions' => '/products/control-room-platforms',
'/products/avoip-distribution-kvm' => '/products/av-over-ip-and-kvm',
'/products/graphics-multi-display' => '/products/graphics-and-multi-display',
'/products/infrastructure-accessories' => '/products/infrastructure-and-accessories',
];
echo ($apply ? "APPLY\n" : "DRY-RUN\n");
foreach ($map as $oldSlug => $newSlug) {
$stub = $pdo->prepare('SELECT uid, title FROM pages WHERE deleted = 0 AND slug = ?');
$stub->execute([$oldSlug]);
$stub = $stub->fetch();
if (!$stub) {
echo " = $oldSlug: already gone\n";
continue;
}
$uid = (int)$stub['uid'];
$children = (int)$pdo->query("SELECT COUNT(*) FROM pages WHERE deleted = 0 AND pid = $uid")->fetchColumn();
$content = (int)$pdo->query("SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND pid = $uid")->fetchColumn();
if ($children > 0 || $content > 0) {
echo " !! #$uid $oldSlug has $children child page(s) / $content element(s) - SKIPPED, decide manually\n";
continue;
}
$target = $pdo->prepare('SELECT uid FROM pages WHERE deleted = 0 AND slug = ?');
$target->execute([$newSlug]);
$targetUid = (int)($target->fetchColumn() ?: 0);
if ($targetUid === 0) {
echo " !! target $newSlug not found - #$uid skipped\n";
continue;
}
$stmt = $pdo->prepare('SELECT COUNT(*) FROM sys_redirect WHERE deleted = 0 AND source_path = ?');
$stmt->execute([$oldSlug]);
$hasRedirect = (int)$stmt->fetchColumn();
echo " ~ DELETE #$uid \"{$stub['title']}\" ($oldSlug) -> 301 to $newSlug (#$targetUid)" . ($hasRedirect ? ' [redirect exists, kept]' : '') . "\n";
if (!$apply) {
continue;
}
$pdo->prepare('UPDATE pages SET deleted = 1, tstamp = UNIX_TIMESTAMP() WHERE uid = ?')->execute([$uid]);
if (!$hasRedirect) {
$pdo->prepare(
"INSERT INTO sys_redirect (pid, createdon, updatedon, source_host, source_path, target, target_statuscode)
VALUES (0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), '*', ?, ?, 301)"
)->execute([$oldSlug, 't3://page?uid=' . $targetUid]);
}
}
echo $apply ? "Done. Flush the cache (redirects are cached).\n" : "DRY RUN - add --apply to write.\n";
+29
View File
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
// Scan: all text-element headers inside usecase content, grouped. READ-ONLY.
error_reporting(E_ALL); ini_set('display_errors', '1');
$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]);
echo "=== Header von text-Elementen in Story-Content (gruppiert) ===\n";
foreach ($pdo->query("SELECT TRIM(header) h, COUNT(*) c FROM tt_content WHERE deleted = 0 AND tx_vitec_usecase_content > 0 AND CType = 'text' AND TRIM(COALESCE(header,'')) <> '' GROUP BY TRIM(header) ORDER BY c DESC, h") as $r) {
printf(" %3d x %s\n", $r['c'], $r['h']);
}
echo "\n=== CTypes in Story-Content gesamt ===\n";
foreach ($pdo->query("SELECT CType, COUNT(*) c FROM tt_content WHERE deleted = 0 AND tx_vitec_usecase_content > 0 GROUP BY CType ORDER BY c DESC") as $r) {
printf(" %3d x %s\n", $r['c'], $r['CType']);
}
echo "\n=== Header von Kind-Elementen in Story-vitec_columns ===\n";
foreach ($pdo->query(
"SELECT TRIM(c.header) h, c.CType, COUNT(*) n FROM tt_content c
WHERE c.deleted = 0 AND TRIM(COALESCE(c.header,'')) <> ''
AND c.tx_container_parent IN (
SELECT p.uid FROM tt_content p
WHERE p.deleted = 0 AND p.tx_vitec_usecase_content > 0 AND p.CType = 'vitec_columns')
GROUP BY TRIM(c.header), c.CType ORDER BY n DESC, h"
) as $r) {
printf(" %3d x %-14s %s\n", $r['n'], $r['CType'], $r['h']);
}
+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";
Binary file not shown.
Loaded 100 of 296 files, more files were not shown because too many files have changed in this diff. Show more