| 1 |
<?php |
| 2 |
/** |
| 3 |
* Analytics package main class. |
| 4 |
* |
| 5 |
* @package automattic/jetpack-premium-analytics |
| 6 |
*/ |
| 7 |
|
| 8 |
namespace Automattic\Jetpack\PremiumAnalytics; |
| 9 |
|
| 10 |
use Automattic\Jetpack\Connection\Manager as Connection_Manager; |
| 11 |
use Automattic\Jetpack\PremiumAnalytics\Reports\Export\Export; |
| 12 |
use Automattic\Jetpack\PremiumAnalytics\REST\Api_Proxy_Controller; |
| 13 |
use Automattic\Jetpack\PremiumAnalytics\REST\Notices_Controller; |
| 14 |
use Automattic\Jetpack\PremiumAnalytics\Sync\Configuration as Sync_Configuration; |
| 15 |
use Automattic\Jetpack\PremiumAnalytics\Sync\Sync_Status_Tracker; |
| 16 |
use Automattic\Jetpack\Status\Host; |
| 17 |
use Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills; |
| 18 |
|
| 19 |
/** |
| 20 |
* Main Analytics class. |
| 21 |
* |
| 22 |
* Loads the wp-build output and registers the dashboard's admin page. |
| 23 |
*/ |
| 24 |
class Analytics { |
| 25 |
|
| 26 |
const PACKAGE_VERSION = '0.7.0'; |
| 27 |
|
| 28 |
/** |
| 29 |
* Whether the class has been initialized. |
| 30 |
* |
| 31 |
* @var bool |
| 32 |
*/ |
| 33 |
private static $initialized = false; |
| 34 |
|
| 35 |
/** |
| 36 |
* Menu title override for the admin page. Null falls back to the package's own |
| 37 |
* translated label, resolved on admin_menu — init runs far too early to translate. |
| 38 |
* |
| 39 |
* @var string|\Closure|null |
| 40 |
*/ |
| 41 |
private static $menu_title = null; |
| 42 |
|
| 43 |
/** |
| 44 |
* The menu label once resolved, so the menu and the missing-build notice can't |
| 45 |
* disagree if a caller hands us a closure that returns something different |
| 46 |
* each call. Reset whenever $menu_title is assigned. |
| 47 |
* |
| 48 |
* @var string|null |
| 49 |
*/ |
| 50 |
private static $resolved_menu_title = null; |
| 51 |
|
| 52 |
/** |
| 53 |
* Path to the wp-build entry point. Null uses the generated build. |
| 54 |
* |
| 55 |
* A test seam: `build/` is gitignored and test-php runs no build step, so tests redirect this |
| 56 |
* instead. Private, so — unlike the widget manifest's path — it needs no filter to stay out of reach. |
| 57 |
* |
| 58 |
* @var string|null |
| 59 |
*/ |
| 60 |
private static $build_entry = null; |
| 61 |
|
| 62 |
/** |
| 63 |
* Initialize the Analytics app on a connected Jetpack site. |
| 64 |
* |
| 65 |
* Registers the full local surface: the site serves the WPCOM data proxy, |
| 66 |
* notices, sync bootstrap, and the dashboard support routes itself. |
| 67 |
* |
| 68 |
* Hosts call this on every request once the flag is on, never only on admin ones: the |
| 69 |
* store-event tracker listens on the front end. {@see self::load_dashboard_surface()} is what |
| 70 |
* keeps the admin-only work off those requests. |
| 71 |
* |
| 72 |
* @param array $options Optional configuration options. |
| 73 |
* Supported keys: |
| 74 |
* - menu_title (string|\Closure): Admin menu label. Defaults to |
| 75 |
* the package's own translated label. Pass a closure to supply |
| 76 |
* a translated label of your own: it runs on admin_menu, where |
| 77 |
* a textdomain can load, unlike init time. |
| 78 |
* @return void |
| 79 |
*/ |
| 80 |
public static function init( $options = array() ) { |
| 81 |
if ( self::$initialized ) { |
| 82 |
return; |
| 83 |
} |
| 84 |
self::$initialized = true; |
| 85 |
self::apply_options( $options ); |
| 86 |
|
| 87 |
self::register_sync_bootstrap(); |
| 88 |
self::register_local_api(); |
| 89 |
|
| 90 |
// Piggybacks on the Jetpack Stats module; checks Jetpack connection state. |
| 91 |
Jetpack_Stats_Tracker::configure(); |
| 92 |
|
| 93 |
self::boot_shared_services(); |
| 94 |
self::register_dashboard_support_routes(); |
| 95 |
self::load_dashboard_surface(); |
| 96 |
} |
| 97 |
|
| 98 |
/** |
| 99 |
* Load the dashboard render surface, on the requests that can render it. |
| 100 |
* |
| 101 |
* With the rollout flag on, init() runs on every request — including every WPCOM public-api |
| 102 |
* request on Simple — so this stays gated for visitors who never use it (REST excluded; see load_build()). |
| 103 |
* |
| 104 |
* @return void |
| 105 |
*/ |
| 106 |
private static function load_dashboard_surface() { |
| 107 |
if ( ! self::renders_admin_chrome() ) { |
| 108 |
return; |
| 109 |
} |
| 110 |
|
| 111 |
self::load_dashboard_components(); |
| 112 |
self::load_build(); |
| 113 |
self::remove_full_page_interceptor(); |
| 114 |
self::register_admin_page(); |
| 115 |
} |
| 116 |
|
| 117 |
/** |
| 118 |
* Whether this request can render an admin screen. |
| 119 |
* |
| 120 |
* Core also sets is_admin() on admin-ajax.php and admin-post.php, which render no dashboard |
| 121 |
* and get no handlers from this package, so neither needs the build parsed. |
| 122 |
* |
| 123 |
* @return bool |
| 124 |
*/ |
| 125 |
private static function renders_admin_chrome() { |
| 126 |
if ( ! is_admin() || wp_doing_ajax() ) { |
| 127 |
return false; |
| 128 |
} |
| 129 |
|
| 130 |
// wp-includes/vars.php sets $pagenow before plugins load. |
| 131 |
return 'admin-post.php' !== ( $GLOBALS['pagenow'] ?? '' ); |
| 132 |
} |
| 133 |
|
| 134 |
/** |
| 135 |
* Initialize the Analytics app on WordPress.com Simple. |
| 136 |
* |
| 137 |
* Simple reaches public-api.wordpress.com directly via WPCOM's apiFetch bridge, registering |
| 138 |
* no local REST surface (no proxy, notices, sync bootstrap, or dashboard routes) — WPCOM handles those. |
| 139 |
* |
| 140 |
* @param array $options Optional configuration options. |
| 141 |
* Supported keys: |
| 142 |
* - menu_title (string|\Closure): Admin menu label. Defaults to |
| 143 |
* the package's own translated label. Pass a closure to supply |
| 144 |
* a translated label of your own: it runs on admin_menu, where |
| 145 |
* a textdomain can load, unlike init time. |
| 146 |
* @return void |
| 147 |
*/ |
| 148 |
public static function init_wpcom_simple( $options = array() ) { |
| 149 |
if ( self::$initialized ) { |
| 150 |
return; |
| 151 |
} |
| 152 |
self::$initialized = true; |
| 153 |
self::apply_options( $options ); |
| 154 |
|
| 155 |
self::boot_shared_services(); |
| 156 |
self::load_dashboard_surface(); |
| 157 |
} |
| 158 |
|
| 159 |
/** |
| 160 |
* Apply init-time configuration options. |
| 161 |
* |
| 162 |
* @param array $options Options passed to the init entry points. |
| 163 |
* @return void |
| 164 |
*/ |
| 165 |
private static function apply_options( $options ) { |
| 166 |
if ( ! empty( $options['menu_title'] ) ) { |
| 167 |
self::$menu_title = $options['menu_title']; |
| 168 |
self::$resolved_menu_title = null; |
| 169 |
} |
| 170 |
} |
| 171 |
|
| 172 |
/** |
| 173 |
* Boot the services every platform needs, whether or not the site serves the |
| 174 |
* dashboard support routes itself. |
| 175 |
* |
| 176 |
* @return void |
| 177 |
*/ |
| 178 |
private static function boot_shared_services() { |
| 179 |
// On every request: flags are read and toggled outside the admin too. |
| 180 |
if ( ! function_exists( __NAMESPACE__ . '\\register_dashboard_feature_flags' ) ) { |
| 181 |
require_once __DIR__ . '/dashboard-policy.php'; |
| 182 |
} |
| 183 |
register_dashboard_feature_flags(); |
| 184 |
|
| 185 |
// Must be hooked before admin_menu and rest_api_init check the capability. |
| 186 |
Capabilities::register(); |
| 187 |
|
| 188 |
// Emit WooCommerce store events into the Woo pipeline (ClickHouse + proxy). |
| 189 |
WooCommerce_Analytics_Tracker::configure(); |
| 190 |
|
| 191 |
// CSV report export pipeline (WOOA7S-1581): hooks rest_api_init, so it must |
| 192 |
// register on all requests. Self-gates on WooCommerce + Jetpack connection. |
| 193 |
Export::configure(); |
| 194 |
|
| 195 |
self::register_script_data(); |
| 196 |
|
| 197 |
// The posts and pages list tables link their views column here. |
| 198 |
Post_List_Link::register(); |
| 199 |
} |
| 200 |
|
| 201 |
/** |
| 202 |
* URL of a dashboard route on this site. |
| 203 |
* |
| 204 |
* The SPA path travels in `p`, encoded here since add_query_arg() leaves values alone and a |
| 205 |
* raw `?` inside it would read as an outer query param. |
| 206 |
* |
| 207 |
* @since 0.4.0 |
| 208 |
* |
| 209 |
* @param string $path Route path, e.g. `/post/123`. |
| 210 |
* @return string |
| 211 |
*/ |
| 212 |
public static function dashboard_url( $path = '/' ) { |
| 213 |
return admin_url( 'admin.php?page=' . self::MENU_PAGE_SLUG . '&p=' . rawurlencode( $path ) ); |
| 214 |
} |
| 215 |
|
| 216 |
/** |
| 217 |
* Announce to Jetpack's other surfaces that this dashboard is the site's analytics UI, |
| 218 |
* so they link here instead of the Stats page. |
| 219 |
* |
| 220 |
* @return void |
| 221 |
*/ |
| 222 |
private static function register_script_data() { |
| 223 |
add_filter( 'jetpack_admin_js_script_data', array( static::class, 'add_script_data' ) ); |
| 224 |
} |
| 225 |
|
| 226 |
/** |
| 227 |
* Runs on nearly every admin page load, so the payload stays to two strings, |
| 228 |
* a bool, and one capability check. |
| 229 |
* |
| 230 |
* @param array $data The script data. |
| 231 |
* @return array The script data with the analytics key added. |
| 232 |
*/ |
| 233 |
public static function add_script_data( $data ) { |
| 234 |
$data['analytics'] = array( |
| 235 |
'enabled' => true, |
| 236 |
'page_slug' => self::MENU_PAGE_SLUG, |
| 237 |
'can_view' => current_user_can( Capabilities::VIEW_ANALYTICS ), |
| 238 |
'timezone' => self::site_timezone(), |
| 239 |
); |
| 240 |
|
| 241 |
return $data; |
| 242 |
} |
| 243 |
|
| 244 |
/** |
| 245 |
* Prefers `timezone_string` over `gmt_offset`, matching the dashboard's own `siteTimeZone()`: |
| 246 |
* analytics links point at past dates, so a fixed offset applied to the far side of a |
| 247 |
* daylight-saving transition shifts the day. |
| 248 |
* |
| 249 |
* @return string An IANA timezone name, or a `+HH:MM` UTC offset. |
| 250 |
*/ |
| 251 |
private static function site_timezone() { |
| 252 |
$timezone_string = get_option( 'timezone_string' ); |
| 253 |
|
| 254 |
if ( is_string( $timezone_string ) && $timezone_string !== '' ) { |
| 255 |
return $timezone_string; |
| 256 |
} |
| 257 |
|
| 258 |
return self::format_gmt_offset( (float) get_option( 'gmt_offset' ) ); |
| 259 |
} |
| 260 |
|
| 261 |
/** |
| 262 |
* Format a GMT offset in hours as `+HH:MM`. |
| 263 |
* |
| 264 |
* @param float $offset The offset in hours, e.g. 5.5 or -8. |
| 265 |
* @return string The formatted offset. |
| 266 |
*/ |
| 267 |
private static function format_gmt_offset( $offset ) { |
| 268 |
$sign = $offset < 0 ? '-' : '+'; |
| 269 |
$absolute = abs( $offset ); |
| 270 |
$hours = (int) floor( $absolute ); |
| 271 |
$minutes = (int) round( ( $absolute - $hours ) * 60 ); |
| 272 |
|
| 273 |
return sprintf( '%s%02d:%02d', $sign, $hours, $minutes ); |
| 274 |
} |
| 275 |
|
| 276 |
/** |
| 277 |
* Register the sync services that feed the local data pipeline. |
| 278 |
* |
| 279 |
* @return void |
| 280 |
*/ |
| 281 |
private static function register_sync_bootstrap() { |
| 282 |
// Keep the shared connection available when another connection-owning plugin is deactivated. |
| 283 |
Connection_Configuration::configure(); |
| 284 |
|
| 285 |
Sync_Status_Tracker::configure(); |
| 286 |
|
| 287 |
// TEMPORARY (WOOA7S-1550): register the interim woocommerce_analytics sync module so |
| 288 |
// Sync_Status_Tracker has a full sync to observe. Remove when the shared sync-modules package lands. |
| 289 |
Sync_Configuration::register(); |
| 290 |
} |
| 291 |
|
| 292 |
/** |
| 293 |
* Register the site-served REST API: the WPCOM data proxy and notices. |
| 294 |
* |
| 295 |
* Both self-gate on their own rest_api_init hooks. |
| 296 |
* |
| 297 |
* @return void |
| 298 |
*/ |
| 299 |
private static function register_local_api() { |
| 300 |
Api_Proxy_Controller::register(); |
| 301 |
Notices_Controller::register(); |
| 302 |
} |
| 303 |
|
| 304 |
/** |
| 305 |
* Load the dashboard components every platform renders with. |
| 306 |
* |
| 307 |
* Admin-only, via load_dashboard_surface(); boot_routes() requires these |
| 308 |
* again for REST. |
| 309 |
* |
| 310 |
* @return void |
| 311 |
*/ |
| 312 |
private static function load_dashboard_components() { |
| 313 |
/* |
| 314 |
* Every include below is guarded on a symbol the target file declares. |
| 315 |
* |
| 316 |
* Two copies of this package can be loaded in one request — WPCOM Simple ships |
| 317 |
* one under jetpack-plugin and another under jetpack-mu-wpcom-plugin. The |
| 318 |
* autoloader dedupes classes by version, but these files declare functions and |
| 319 |
* constants at file scope, so they are absent from the classmap entirely and |
| 320 |
* reach us through `require_once`, which dedupes by path and not by symbol. |
| 321 |
* Once a class from one copy and a class from the other both run their |
| 322 |
* includes, PHP fatals on the redeclared functions. The guards make the second |
| 323 |
* copy's include a no-op, which also keeps the files' file-scope side effects |
| 324 |
* (add_filter() calls, registry bootstrapping) from running twice. |
| 325 |
*/ |
| 326 |
|
| 327 |
// Widget modules for the client's dynamic import() map. |
| 328 |
if ( ! function_exists( __NAMESPACE__ . '\\register_widget_modules_rest_route' ) ) { |
| 329 |
require_once __DIR__ . '/widget-modules.php'; |
| 330 |
} |
| 331 |
|
| 332 |
// Default layout's first-load preference injection. |
| 333 |
if ( ! function_exists( __NAMESPACE__ . '\\register_dashboard_default_layout_route' ) ) { |
| 334 |
require_once __DIR__ . '/dashboard-layout.php'; |
| 335 |
} |
| 336 |
|
| 337 |
// Dashboard sections and their default layout seeding. |
| 338 |
if ( ! function_exists( __NAMESPACE__ . '\\register_dashboard_section' ) ) { |
| 339 |
require_once __DIR__ . '/dashboard-sections.php'; |
| 340 |
} |
| 341 |
configure_dashboard_preview_scope(); |
| 342 |
|
| 343 |
// Default-on CSV export settings and server-side disable filter. |
| 344 |
if ( ! function_exists( __NAMESPACE__ . '\\configure_csv_exports' ) ) { |
| 345 |
require_once __DIR__ . '/csv-exports.php'; |
| 346 |
} |
| 347 |
configure_csv_exports(); |
| 348 |
|
| 349 |
// VideoPress availability for the client's video routes. The widget layer |
| 350 |
// reads the same signal through widget-type-support.php. |
| 351 |
if ( ! function_exists( __NAMESPACE__ . '\\configure_videopress_availability' ) ) { |
| 352 |
require_once __DIR__ . '/videopress-availability.php'; |
| 353 |
} |
| 354 |
configure_videopress_availability(); |
| 355 |
|
| 356 |
// The composition flag's answer, read by the dashboard policy; the file is |
| 357 |
// already loaded by boot_shared_services(). |
| 358 |
configure_dashboard_policy(); |
| 359 |
} |
| 360 |
|
| 361 |
/** |
| 362 |
* Serve the dashboard support routes from the site. Simple skips this — |
| 363 |
* WPCOM calls Dashboard_Support_Routes::register() itself instead. |
| 364 |
* |
| 365 |
* @return void |
| 366 |
*/ |
| 367 |
private static function register_dashboard_support_routes() { |
| 368 |
Dashboard_Support_Routes::register(); |
| 369 |
} |
| 370 |
|
| 371 |
/** |
| 372 |
* Load the wp-build output (interceptor, modules, routes, page render). |
| 373 |
* |
| 374 |
* Admin-only, via load_dashboard_surface(). REST does not need it: |
| 375 |
* boot_routes() and ensure_widget_registry_ready() load what they use. |
| 376 |
* |
| 377 |
* @return void |
| 378 |
*/ |
| 379 |
private static function load_build() { |
| 380 |
$build_entry = self::$build_entry ?? __DIR__ . '/../build/build.php'; |
| 381 |
if ( file_exists( $build_entry ) ) { |
| 382 |
require_once $build_entry; |
| 383 |
} |
| 384 |
} |
| 385 |
|
| 386 |
/** |
| 387 |
* Unhook wp-build's full-page render interceptor — security-relevant: it renders |
| 388 |
* `?page=jetpack-premium-analytics` from admin_init with no capability check, and only |
| 389 |
* renders_admin_chrome() gates the admin-post.php/admin-ajax.php paths that reach admin_init |
| 390 |
* without Core's own slug check. |
| 391 |
* |
| 392 |
* Because remove_action() no-ops on a callback name it can't find, a wp-build rename would |
| 393 |
* silently restore this entry point — hence the _doing_it_wrong() below when that happens. |
| 394 |
* |
| 395 |
* @return void |
| 396 |
*/ |
| 397 |
private static function remove_full_page_interceptor() { |
| 398 |
if ( remove_action( 'admin_init', 'jpa_jetpack_premium_analytics_intercept_render' ) ) { |
| 399 |
return; |
| 400 |
} |
| 401 |
|
| 402 |
if ( function_exists( 'jpa_jetpack_premium_analytics_intercept_render' ) ) { |
| 403 |
_doing_it_wrong( |
| 404 |
__METHOD__, |
| 405 |
'The Premium Analytics full-page interceptor could not be unhooked: wp-build changed the generated callback name or its admin_init priority.', |
| 406 |
'' |
| 407 |
); |
| 408 |
} |
| 409 |
} |
| 410 |
|
| 411 |
/** |
| 412 |
* Absolute path to the generated widget manifest. |
| 413 |
* |
| 414 |
* On the class, not beside its readers in widget-modules.php: two copies of this package can |
| 415 |
* load in one request, and only classes get the autoloader's version dedupe (see load_dashboard_components()). |
| 416 |
* |
| 417 |
* @return string |
| 418 |
*/ |
| 419 |
public static function widget_manifest_path() { |
| 420 |
/** |
| 421 |
* Filters the path to the generated widget manifest. |
| 422 |
* |
| 423 |
* @param string $path Absolute path to the generated widget manifest. |
| 424 |
*/ |
| 425 |
return apply_filters( |
| 426 |
'jetpack_premium_analytics_widgets_manifest_path', |
| 427 |
__DIR__ . '/../build/widgets.php' |
| 428 |
); |
| 429 |
} |
| 430 |
|
| 431 |
/** |
| 432 |
* Register the admin-only render path: polyfills, menu, and page hooks. |
| 433 |
* |
| 434 |
* @return void |
| 435 |
*/ |
| 436 |
private static function register_admin_page() { |
| 437 |
// Polyfills force-replace core handles (wp-private-apis) on wp_default_scripts; |
| 438 |
// scope to the dashboard page so no other admin page (e.g. block editor) is hit. |
| 439 |
if ( self::is_dashboard_request() ) { |
| 440 |
WP_Build_Polyfills::register( |
| 441 |
'jetpack-premium-analytics', |
| 442 |
array_merge( |
| 443 |
WP_Build_Polyfills::SCRIPT_HANDLES, |
| 444 |
WP_Build_Polyfills::MODULE_IDS |
| 445 |
) |
| 446 |
); |
| 447 |
|
| 448 |
add_action( 'admin_enqueue_scripts', array( static::class, 'enqueue_i18n_loader' ) ); |
| 449 |
add_action( 'admin_enqueue_scripts', array( static::class, 'enqueue_tracks_transport' ) ); |
| 450 |
add_filter( 'jetpack_admin_js_script_data', array( static::class, 'add_tracks_identity_script_data' ), 20 ); |
| 451 |
} |
| 452 |
|
| 453 |
add_action( 'admin_menu', array( static::class, 'register_admin_menu' ) ); |
| 454 |
} |
| 455 |
|
| 456 |
/** |
| 457 |
* The admin page slug the dashboard menu registers. Published in script data |
| 458 |
* so no caller has to hard-code it. |
| 459 |
*/ |
| 460 |
const MENU_PAGE_SLUG = 'jetpack-premium-analytics-wp-admin'; |
| 461 |
|
| 462 |
/** |
| 463 |
* Whether the current request is rendering the Premium Analytics dashboard. |
| 464 |
* |
| 465 |
* Scopes the wp-build polyfill registration (which force-replaces core script handles) to |
| 466 |
* this dashboard; reads the menu slug directly, not current_screen, to stay safe at plugin-load time. |
| 467 |
* |
| 468 |
* @return bool True when serving the dashboard page in wp-admin. |
| 469 |
*/ |
| 470 |
public static function is_dashboard_request() { |
| 471 |
if ( ! is_admin() ) { |
| 472 |
return false; |
| 473 |
} |
| 474 |
|
| 475 |
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading the menu page slug to scope asset loading; no state is changed. |
| 476 |
$page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; |
| 477 |
|
| 478 |
return self::MENU_PAGE_SLUG === $page; |
| 479 |
} |
| 480 |
|
| 481 |
/** |
| 482 |
* Register the admin menu page. |
| 483 |
* |
| 484 |
* Uses wp-build's `-wp-admin` variant so Core applies the menu capability check. Reports the |
| 485 |
* page and widget artifacts independently since the build loader includes each conditionally. |
| 486 |
* |
| 487 |
* @return void |
| 488 |
*/ |
| 489 |
public static function register_admin_menu() { |
| 490 |
$can_render = function_exists( 'jpa_jetpack_premium_analytics_wp_admin_render_page' ); |
| 491 |
$has_widget_manifest = file_exists( self::widget_manifest_path() ); |
| 492 |
|
| 493 |
$missing = array(); |
| 494 |
if ( ! $can_render ) { |
| 495 |
// Named by symbol, not by file: build/pages.php is only a loader, and the |
| 496 |
// callback can also go missing to a renamed page slug or an absent build entry. |
| 497 |
$missing[] = 'the jpa_jetpack_premium_analytics_wp_admin_render_page() callback, generated under build/pages/'; |
| 498 |
} |
| 499 |
if ( ! $has_widget_manifest ) { |
| 500 |
$missing[] = 'build/widgets.php (the widget manifest)'; |
| 501 |
} |
| 502 |
|
| 503 |
if ( $missing ) { |
| 504 |
// Surfaced here rather than only on the page itself, so a partial deploy shows up on |
| 505 |
// the first admin request instead of waiting for someone to open the dashboard. |
| 506 |
_doing_it_wrong( |
| 507 |
__METHOD__, |
| 508 |
// esc_html() only to satisfy WordPress.Security.EscapeOutput, which treats |
| 509 |
// this argument as output; every entry is a literal from just above. |
| 510 |
'The Premium Analytics build output is incomplete: ' . esc_html( implode( ', ', $missing ) ) . '. The package build did not run, or ran only partially, for this deploy.', |
| 511 |
'' |
| 512 |
); |
| 513 |
} |
| 514 |
|
| 515 |
$render_callback = $can_render |
| 516 |
? 'jpa_jetpack_premium_analytics_wp_admin_render_page' |
| 517 |
: array( __CLASS__, 'render_missing_build_notice' ); |
| 518 |
|
| 519 |
$menu_title = self::menu_title(); |
| 520 |
|
| 521 |
add_menu_page( |
| 522 |
esc_html( $menu_title ), |
| 523 |
esc_html( $menu_title ), |
| 524 |
Capabilities::VIEW_ANALYTICS, |
| 525 |
self::MENU_PAGE_SLUG, |
| 526 |
$render_callback, |
| 527 |
'dashicons-chart-bar', |
| 528 |
2 |
| 529 |
); |
| 530 |
} |
| 531 |
|
| 532 |
/** |
| 533 |
* Stand-in for the generated render callback when the build output is absent. |
| 534 |
* |
| 535 |
* The PHP classes come from Composer and the build output from pnpm, so a |
| 536 |
* partial deploy can leave the class loadable with nothing to render. |
| 537 |
* |
| 538 |
* @return void |
| 539 |
*/ |
| 540 |
public static function render_missing_build_notice() { |
| 541 |
printf( |
| 542 |
'<div class="wrap"><h1>%s</h1><p>%s</p></div>', |
| 543 |
esc_html( self::menu_title() ), |
| 544 |
esc_html__( 'The Premium Analytics assets are missing. The package build did not run for this deploy.', 'jetpack-premium-analytics-pkg' ) |
| 545 |
); |
| 546 |
} |
| 547 |
|
| 548 |
/** |
| 549 |
* The caller's menu label override, or the package's own translated label. |
| 550 |
* |
| 551 |
* Call only once translations can load — admin_menu or later — and memoize so every call site |
| 552 |
* agrees. Deliberately not is_callable(): PHP function names are case-insensitive, so a plain |
| 553 |
* label like "Analytics" could match a stray analytics() function and get called. |
| 554 |
* |
| 555 |
* @return string |
| 556 |
*/ |
| 557 |
private static function menu_title() { |
| 558 |
if ( null !== self::$resolved_menu_title ) { |
| 559 |
return self::$resolved_menu_title; |
| 560 |
} |
| 561 |
|
| 562 |
$title = self::$menu_title instanceof \Closure |
| 563 |
? ( self::$menu_title )() |
| 564 |
: self::$menu_title; |
| 565 |
|
| 566 |
// A positive check rather than a null coalesce: a closure may return an empty string, or |
| 567 |
// something that isn't a string at all, and either would reach esc_html() as a broken label. |
| 568 |
self::$resolved_menu_title = is_string( $title ) && '' !== $title |
| 569 |
? $title |
| 570 |
: __( 'Stats v2', 'jetpack-premium-analytics-pkg' ); |
| 571 |
|
| 572 |
return self::$resolved_menu_title; |
| 573 |
} |
| 574 |
|
| 575 |
/** |
| 576 |
* Enqueue the i18n loader so the wp-build init module can download its JS |
| 577 |
* translation catalogs. It's registered on every admin page by jetpack-assets |
| 578 |
* but only enqueued when depended on; the esbuild bundles don't pull it in. |
| 579 |
* |
| 580 |
* @return void |
| 581 |
*/ |
| 582 |
public static function enqueue_i18n_loader() { |
| 583 |
if ( wp_script_is( 'wp-jp-i18n-loader', 'registered' ) ) { |
| 584 |
wp_enqueue_script( 'wp-jp-i18n-loader' ); |
| 585 |
} |
| 586 |
} |
| 587 |
|
| 588 |
/** |
| 589 |
* Load the Tracks transport for the dashboard. |
| 590 |
* |
| 591 |
* `@automattic/jetpack-analytics` only queues events into `window._tkq` — its own w.js |
| 592 |
* loader is disabled — so without this handle no `jetpack_premium_analytics_*` event |
| 593 |
* ever flushes. Simple is skipped because stats.php already prints the same script. |
| 594 |
* |
| 595 |
* @return void |
| 596 |
*/ |
| 597 |
public static function enqueue_tracks_transport() { |
| 598 |
if ( ( new Host() )->is_wpcom_simple() ) { |
| 599 |
return; |
| 600 |
} |
| 601 |
|
| 602 |
wp_enqueue_script( 'jp-tracks', '//stats.wp.com/w.js', array(), gmdate( 'YW' ), true ); |
| 603 |
} |
| 604 |
|
| 605 |
/** |
| 606 |
* Publish the WPCOM identity the dashboard attributes its Tracks events to. |
| 607 |
* |
| 608 |
* Core's script data carries only the local user. Publicize is the one package that fills |
| 609 |
* `current_user.wpcom` in, and the standalone plugin does not bundle it, so without this |
| 610 |
* every event would land anonymous there. |
| 611 |
* |
| 612 |
* @param array $data The script data. |
| 613 |
* @return array The script data with the WPCOM identity added. |
| 614 |
*/ |
| 615 |
public static function add_tracks_identity_script_data( $data ) { |
| 616 |
if ( ( new Host() )->is_wpcom_simple() ) { |
| 617 |
$wpcom_user = array( |
| 618 |
'ID' => get_current_user_id(), |
| 619 |
'login' => wp_get_current_user()->user_login, |
| 620 |
); |
| 621 |
} else { |
| 622 |
$connected = ( new Connection_Manager() )->get_connected_user_data(); |
| 623 |
|
| 624 |
if ( empty( $connected['ID'] ) || empty( $connected['login'] ) ) { |
| 625 |
return $data; |
| 626 |
} |
| 627 |
|
| 628 |
// Only the two fields `identifyUser` needs: the rest of the connected-user payload |
| 629 |
// is profile data the dashboard never reads. |
| 630 |
$wpcom_user = array( |
| 631 |
'ID' => $connected['ID'], |
| 632 |
'login' => $connected['login'], |
| 633 |
); |
| 634 |
} |
| 635 |
|
| 636 |
$data['user']['current_user']['wpcom'] = array_merge( |
| 637 |
$data['user']['current_user']['wpcom'] ?? array(), |
| 638 |
$wpcom_user |
| 639 |
); |
| 640 |
|
| 641 |
return $data; |
| 642 |
} |
| 643 |
} |
| 644 |
|