PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.3
16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 All 507 releases
jetpack / jetpack_vendor / automattic / jetpack-stats / src / abilities / class-stats-abilities.php

class-stats-abilities.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-a.3, at jetpack_vendor/automattic/jetpack-stats/src/abilities/class-stats-abilities.php

1,413 lines 48.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Jetpack Stats Abilities Registration.
4 *
5 * Registers Jetpack Stats abilities with the WordPress Abilities API.
6 *
7 * @package automattic/jetpack-stats
8 */
9
10 namespace Automattic\Jetpack\Stats\Abilities;
11
12 use Automattic\Jetpack\Stats\Options;
13 use Automattic\Jetpack\Stats\WPCOM_Stats;
14 use Automattic\Jetpack\WP_Abilities\Registrar;
15 use WP_Error;
16
17 /**
18 * Registers Jetpack Stats abilities with the WordPress Abilities API.
19 *
20 * Exposes a small, consolidated surface for reading Jetpack Stats traffic
21 * insights and managing site-level Stats settings so AI agents can
22 * answer site-owner questions through the standard `wp-abilities/v1` REST
23 * surface. Seven abilities wrap ~25 atomic WPCOM Stats endpoints plus the
24 * `stats_options` WP option.
25 */
26 class Stats_Abilities extends Registrar {
27
28 const CATEGORY_SLUG = 'jetpack-stats';
29 const ERROR_PREFIX = 'jetpack_stats_';
30
31 /**
32 * Whitelist of stats_options keys exposed via the settings abilities.
33 *
34 * Internal keys (`blog_id`, `notices`, `views`, `collapse_nudges`,
35 * `version`, `odyssey_stats_changed_at`) are deliberately excluded —
36 * agents can't act on them and they'd bloat the response.
37 * `enable_odyssey_stats` is also excluded: it's a UI dashboard toggle
38 * with no meaningful agent use case. The per-key type (bool /
39 * role-array) is read from `Options::get_defaults()` at runtime, not
40 * duplicated here.
41 */
42 const SETTINGS_KEYS = array( 'admin_bar', 'roles', 'count_roles', 'do_not_track' );
43
44 /**
45 * Allowed `type` values for `get-top-content`.
46 */
47 const TOP_CONTENT_TYPES = array( 'posts', 'referrers', 'search-terms', 'clicks', 'tags', 'authors', 'countries', 'downloads', 'video-plays' );
48
49 /**
50 * Allowed aggregation periods for timeseries + top-content reads.
51 */
52 const PERIODS = array( 'day', 'week', 'month', 'year' );
53
54 /**
55 * Allowed metric fields for `get-visits`.
56 */
57 const VISIT_FIELDS = array( 'views', 'visitors', 'likes', 'comments' );
58
59 /**
60 * Default metric fields for `get-visits` when the caller omits `fields`.
61 */
62 const DEFAULT_VISIT_FIELDS = array( 'views', 'visitors' );
63
64 /**
65 * Normalization table for `get-top-content`.
66 *
67 * Each entry describes how to project a WPCOM `days -> <date> -> <list>`
68 * array of rows into the uniform `{ rank, label, value, href? }` shape.
69 * `countries` (needs `country-info` join) and `tags` (flat `tags` array,
70 * no `days` envelope) are special-cased in the callback.
71 */
72 const TOP_CONTENT_MAP = array(
73 'posts' => array(
74 'list' => 'postviews',
75 'label' => 'title',
76 'value' => 'views',
77 'href' => 'href',
78 ),
79 'referrers' => array(
80 // WPCOM `stats/referrers` keys per-day data under `groups`, not `referrers` —
81 // each group exposes `name`, `total`, and (sometimes) `url`.
82 'list' => 'groups',
83 'label' => 'name',
84 'value' => 'total',
85 'href' => 'url',
86 ),
87 'search-terms' => array(
88 'list' => 'search_terms',
89 'label' => 'term',
90 'value' => 'views',
91 ),
92 'clicks' => array(
93 'list' => 'clicks',
94 'label' => 'name',
95 'value' => 'views',
96 'href' => 'url',
97 'label_fallback' => 'url',
98 ),
99 'authors' => array(
100 'list' => 'authors',
101 'label' => 'name',
102 'value' => 'views',
103 ),
104 'downloads' => array(
105 'list' => 'files',
106 'label' => 'filename',
107 'value' => 'download_count',
108 'href' => 'relative_url',
109 'label_fallback' => 'relative_url',
110 ),
111 'video-plays' => array(
112 'list' => 'plays',
113 'label' => 'title',
114 'value' => 'plays',
115 ),
116 );
117
118 /**
119 * {@inheritDoc}
120 */
121 public static function get_category_slug(): string {
122 return self::CATEGORY_SLUG;
123 }
124
125 /**
126 * {@inheritDoc}
127 */
128 public static function get_category_definition(): array {
129 return array(
130 // "Jetpack" is a product name and should not be translated.
131 'label' => 'Jetpack Stats',
132 'description' => __( 'Abilities for reading Jetpack Stats traffic insights and managing site-level Stats settings.', 'jetpack-stats-pkg' ),
133 );
134 }
135
136 /**
137 * {@inheritDoc}
138 */
139 public static function get_abilities(): array {
140 return array(
141 'jetpack-stats/get-site-overview' => self::spec_get_site_overview(),
142 'jetpack-stats/get-top-content' => self::spec_get_top_content(),
143 'jetpack-stats/get-post-views' => self::spec_get_post_views(),
144 'jetpack-stats/get-visits' => self::spec_get_visits(),
145 'jetpack-stats/get-followers' => self::spec_get_followers(),
146 'jetpack-stats/get-settings' => self::spec_get_settings(),
147 'jetpack-stats/update-settings' => self::spec_update_settings(),
148 );
149 }
150
151 /*
152 ---------------------------------------------------------------------
153 * Ability specs
154 * ---------------------------------------------------------------------
155 */
156
157 /**
158 * Spec: jetpack-stats/get-site-overview.
159 */
160 private static function spec_get_site_overview(): array {
161 return array(
162 'label' => __( 'Get site stats overview', 'jetpack-stats-pkg' ),
163 'description' => __(
164 'Return a single zero-argument snapshot answering "how is my site doing right now?" — today\'s views/visitors, this week/month totals, the current posting streak, today\'s top post, and top referrer. Shape: { date, views_today, visitors_today, views_week, views_month, streak: { current_length, longest_length, longest_start, longest_end }, top_post: { id, title, views }, top_referrer: { name, views }, partial: bool, errors?: [string] }. Composes the WPCOM stats/summary, stats/highlights, and stats/streak endpoints — if any sub-call fails, `partial` is true and `errors` lists the failed sub-calls; when `partial` is true, count fields owned by the failed sub-call(s) are placeholder zeros rather than confirmed counts (cross-reference `errors` before treating a `0` as authoritative). If every sub-call fails, returns `jetpack_stats_data_unavailable`. Precondition: the site must be connected to WordPress.com. Results cached for ~5 minutes by WPCOM_Stats — safe to poll.',
165 'jetpack-stats-pkg'
166 ),
167 'input_schema' => array(
168 'type' => 'object',
169 'default' => array(),
170 'properties' => new \stdClass(),
171 'additionalProperties' => false,
172 ),
173 'output_schema' => array(
174 'type' => 'object',
175 'properties' => array(
176 'date' => array( 'type' => 'string' ),
177 'views_today' => array( 'type' => 'integer' ),
178 'visitors_today' => array( 'type' => 'integer' ),
179 'views_week' => array( 'type' => 'integer' ),
180 'views_month' => array( 'type' => 'integer' ),
181 'streak' => array( 'type' => 'object' ),
182 'top_post' => array( 'type' => array( 'object', 'null' ) ),
183 'top_referrer' => array( 'type' => array( 'object', 'null' ) ),
184 'partial' => array( 'type' => 'boolean' ),
185 'errors' => array( 'type' => 'array' ),
186 ),
187 ),
188 'execute_callback' => array( __CLASS__, 'get_site_overview' ),
189 'permission_callback' => array( __CLASS__, 'can_view_stats' ),
190 'meta' => array(
191 'annotations' => array(
192 'readonly' => true,
193 'destructive' => false,
194 'idempotent' => true,
195 ),
196 'show_in_rest' => true,
197 'mcp' => array(
198 'public' => true,
199 'type' => 'tool', // default is already "tool", but can be explicit.
200 ),
201 ),
202 );
203 }
204
205 /**
206 * Spec: jetpack-stats/get-top-content.
207 */
208 private static function spec_get_top_content(): array {
209 return array(
210 'label' => __( 'Get top stats content', 'jetpack-stats-pkg' ),
211 'description' => __(
212 'Return the top items for a chosen content type — posts, referrers, search terms, outbound clicks, tags/categories, authors, countries, downloads, or video plays — in one filtered call. Replaces nine atomic WPCOM endpoints with a single ability. Uniform shape: { type, period, date, num, max, items: [ { rank, label, value, href? } ] } — agents MUST NOT see a different shape per type. `label` is human-readable (post title, referrer host, search term, country name, etc.). `value` is the view/hit count for that item. `href` is present only when the item has a canonical URL. Precondition: site must be connected to WordPress.com.',
213 'jetpack-stats-pkg'
214 ),
215 'input_schema' => array(
216 'type' => 'object',
217 'required' => array( 'type' ),
218 'properties' => array(
219 'type' => array(
220 'type' => 'string',
221 'description' => __( 'Which top-N surface to fetch.', 'jetpack-stats-pkg' ),
222 'enum' => self::TOP_CONTENT_TYPES,
223 ),
224 'period' => array(
225 'type' => 'string',
226 'description' => __( 'Aggregation period.', 'jetpack-stats-pkg' ),
227 'enum' => self::PERIODS,
228 'default' => 'day',
229 ),
230 'date' => array(
231 'type' => 'string',
232 'description' => __( 'End date (YYYY-MM-DD). Defaults to today.', 'jetpack-stats-pkg' ),
233 'pattern' => '^[0-9]{4}-[0-9]{2}-[0-9]{2}$',
234 ),
235 'num' => array(
236 'type' => 'integer',
237 'description' => __( 'How many prior periods to roll up (1-90).', 'jetpack-stats-pkg' ),
238 'minimum' => 1,
239 'maximum' => 90,
240 'default' => 1,
241 ),
242 'max' => array(
243 'type' => 'integer',
244 'description' => __( 'Results cap (1-100).', 'jetpack-stats-pkg' ),
245 'minimum' => 1,
246 'maximum' => 100,
247 'default' => 20,
248 ),
249 ),
250 'additionalProperties' => false,
251 ),
252 'output_schema' => array(
253 'type' => 'object',
254 'properties' => array(
255 'type' => array( 'type' => 'string' ),
256 'period' => array( 'type' => 'string' ),
257 'date' => array( 'type' => 'string' ),
258 'num' => array( 'type' => 'integer' ),
259 'max' => array( 'type' => 'integer' ),
260 'items' => array( 'type' => 'array' ),
261 ),
262 ),
263 'execute_callback' => array( __CLASS__, 'get_top_content' ),
264 'permission_callback' => array( __CLASS__, 'can_view_stats' ),
265 'meta' => array(
266 'annotations' => array(
267 'readonly' => true,
268 'destructive' => false,
269 'idempotent' => true,
270 ),
271 'show_in_rest' => true,
272 'mcp' => array(
273 'public' => true,
274 'type' => 'tool', // default is already "tool", but can be explicit.
275 ),
276 ),
277 );
278 }
279
280 /**
281 * Spec: jetpack-stats/get-post-views.
282 */
283 private static function spec_get_post_views(): array {
284 return array(
285 'label' => __( 'Get views for a post', 'jetpack-stats-pkg' ),
286 'description' => __(
287 'Return views history for a single post: total views, timeseries of per-period views, and the period metadata. Shape: { post_id, total_views, period, num, date, series: [ { date, views } ] }. Accepts post_id as integer or numeric string (the literal "0" is rejected only because WordPress has no post 0 — any positive numeric value is legal). Precondition: site must be connected to WordPress.com. Related: call jetpack-stats/get-top-content with type=posts first to discover which posts to drill into.',
288 'jetpack-stats-pkg'
289 ),
290 'input_schema' => array(
291 'type' => 'object',
292 'required' => array( 'post_id' ),
293 'properties' => array(
294 'post_id' => array(
295 'type' => array( 'integer', 'string' ),
296 'description' => __( 'The post ID to fetch views for. Must be positive.', 'jetpack-stats-pkg' ),
297 ),
298 'period' => array(
299 'type' => 'string',
300 'description' => __( 'Aggregation period.', 'jetpack-stats-pkg' ),
301 'enum' => self::PERIODS,
302 'default' => 'day',
303 ),
304 'num' => array(
305 'type' => 'integer',
306 'description' => __( 'How many prior periods to include (1-90).', 'jetpack-stats-pkg' ),
307 'minimum' => 1,
308 'maximum' => 90,
309 'default' => 30,
310 ),
311 'date' => array(
312 'type' => 'string',
313 'description' => __( 'End date (YYYY-MM-DD). Defaults to today.', 'jetpack-stats-pkg' ),
314 'pattern' => '^[0-9]{4}-[0-9]{2}-[0-9]{2}$',
315 ),
316 ),
317 'additionalProperties' => false,
318 ),
319 'output_schema' => array(
320 'type' => 'object',
321 'properties' => array(
322 'post_id' => array( 'type' => 'integer' ),
323 'total_views' => array( 'type' => 'integer' ),
324 'period' => array( 'type' => 'string' ),
325 'num' => array( 'type' => 'integer' ),
326 'date' => array( 'type' => 'string' ),
327 'series' => array( 'type' => 'array' ),
328 ),
329 ),
330 'execute_callback' => array( __CLASS__, 'get_post_views' ),
331 'permission_callback' => array( __CLASS__, 'can_view_stats' ),
332 'meta' => array(
333 'annotations' => array(
334 'readonly' => true,
335 'destructive' => false,
336 'idempotent' => true,
337 ),
338 'show_in_rest' => true,
339 'mcp' => array(
340 'public' => true,
341 'type' => 'tool', // default is already "tool", but can be explicit.
342 ),
343 ),
344 );
345 }
346
347 /**
348 * Spec: jetpack-stats/get-visits.
349 */
350 private static function spec_get_visits(): array {
351 return array(
352 'label' => __( 'Get site visits timeseries', 'jetpack-stats-pkg' ),
353 'description' => __(
354 'Return a site-level views/visitors/likes/comments timeseries — answers "is traffic trending up?". Shape: { unit, quantity, date, fields, series: [ { date, views, visitors, likes, comments } ] }. Every series row always includes every field listed in the request (no per-row omission). Precondition: site must be connected to WordPress.com.',
355 'jetpack-stats-pkg'
356 ),
357 'input_schema' => array(
358 'type' => 'object',
359 'default' => array(),
360 'properties' => array(
361 'unit' => array(
362 'type' => 'string',
363 'description' => __( 'Granularity of each data point.', 'jetpack-stats-pkg' ),
364 'enum' => self::PERIODS,
365 'default' => 'day',
366 ),
367 'quantity' => array(
368 'type' => 'integer',
369 'description' => __( 'How many data points to return (1-90).', 'jetpack-stats-pkg' ),
370 'minimum' => 1,
371 'maximum' => 90,
372 'default' => 30,
373 ),
374 'date' => array(
375 'type' => 'string',
376 'description' => __( 'End date (YYYY-MM-DD). Defaults to today.', 'jetpack-stats-pkg' ),
377 'pattern' => '^[0-9]{4}-[0-9]{2}-[0-9]{2}$',
378 ),
379 'fields' => array(
380 'type' => 'array',
381 'description' => __( 'Which metrics to include in each row. Defaults to views+visitors.', 'jetpack-stats-pkg' ),
382 'items' => array(
383 'type' => 'string',
384 'enum' => self::VISIT_FIELDS,
385 ),
386 'default' => self::DEFAULT_VISIT_FIELDS,
387 ),
388 ),
389 'additionalProperties' => false,
390 ),
391 'output_schema' => array(
392 'type' => 'object',
393 'properties' => array(
394 'unit' => array( 'type' => 'string' ),
395 'quantity' => array( 'type' => 'integer' ),
396 'date' => array( 'type' => 'string' ),
397 'fields' => array( 'type' => 'array' ),
398 'series' => array( 'type' => 'array' ),
399 ),
400 ),
401 'execute_callback' => array( __CLASS__, 'get_visits' ),
402 'permission_callback' => array( __CLASS__, 'can_view_stats' ),
403 'meta' => array(
404 'annotations' => array(
405 'readonly' => true,
406 'destructive' => false,
407 'idempotent' => true,
408 ),
409 'show_in_rest' => true,
410 'mcp' => array(
411 'public' => true,
412 'type' => 'tool', // default is already "tool", but can be explicit.
413 ),
414 ),
415 );
416 }
417
418 /**
419 * Spec: jetpack-stats/get-followers.
420 */
421 private static function spec_get_followers(): array {
422 return array(
423 'label' => __( 'Get follower counts', 'jetpack-stats-pkg' ),
424 'description' => __(
425 'Return a breakdown of follower counts across email, WordPress.com, comment, and publicize (per-service) — answers "how is my audience growing?" in one call. Shape: { total, email, wpcom, comment, publicize: { <service>: count }, partial: bool, errors?: [string] }. Composes three WPCOM endpoints — if any sub-call fails, `partial` is true and `errors` lists the failed sub-calls; when `partial` is true, source counts owned by the failed sub-call(s) are placeholder zeros rather than confirmed zero counts (cross-reference `errors` before treating a `0` as authoritative). Precondition: site must be connected to WordPress.com.',
426 'jetpack-stats-pkg'
427 ),
428 'input_schema' => array(
429 'type' => 'object',
430 'default' => array(),
431 'properties' => new \stdClass(),
432 'additionalProperties' => false,
433 ),
434 'output_schema' => array(
435 'type' => 'object',
436 'properties' => array(
437 'total' => array( 'type' => 'integer' ),
438 'email' => array( 'type' => 'integer' ),
439 'wpcom' => array( 'type' => 'integer' ),
440 'comment' => array( 'type' => 'integer' ),
441 'publicize' => array( 'type' => 'object' ),
442 'partial' => array( 'type' => 'boolean' ),
443 'errors' => array( 'type' => 'array' ),
444 ),
445 ),
446 'execute_callback' => array( __CLASS__, 'get_followers' ),
447 'permission_callback' => array( __CLASS__, 'can_view_stats' ),
448 'meta' => array(
449 'annotations' => array(
450 'readonly' => true,
451 'destructive' => false,
452 'idempotent' => true,
453 ),
454 'show_in_rest' => true,
455 'mcp' => array(
456 'public' => true,
457 'type' => 'tool', // default is already "tool", but can be explicit.
458 ),
459 ),
460 );
461 }
462
463 /**
464 * Spec: jetpack-stats/get-settings.
465 */
466 private static function spec_get_settings(): array {
467 return array(
468 'label' => __( 'Get Stats settings', 'jetpack-stats-pkg' ),
469 'description' => __(
470 'Read the current Jetpack Stats settings: who sees the Stats admin bar + menu, whose visits are counted, and DNT behavior. Shape: { admin_bar, roles, count_roles, do_not_track }. `roles` is an array of role slugs that can view Stats; `count_roles` is an array of role slugs whose visits are counted. Call jetpack-stats/update-settings to change any of these.',
471 'jetpack-stats-pkg'
472 ),
473 'input_schema' => array(
474 'type' => 'object',
475 'default' => array(),
476 'properties' => new \stdClass(),
477 'additionalProperties' => false,
478 ),
479 'output_schema' => array(
480 'type' => 'object',
481 'properties' => self::settings_output_properties(),
482 ),
483 'execute_callback' => array( __CLASS__, 'get_settings' ),
484 'permission_callback' => array( __CLASS__, 'can_view_stats' ),
485 'meta' => array(
486 'annotations' => array(
487 'readonly' => true,
488 'destructive' => false,
489 'idempotent' => true,
490 ),
491 'show_in_rest' => true,
492 'mcp' => array(
493 'public' => true,
494 'type' => 'tool', // default is already "tool", but can be explicit.
495 ),
496 ),
497 );
498 }
499
500 /**
501 * Spec: jetpack-stats/update-settings.
502 */
503 private static function spec_update_settings(): array {
504 return array(
505 'label' => __( 'Update Stats settings', 'jetpack-stats-pkg' ),
506 'description' => __(
507 'Update one or more Jetpack Stats settings. All fields are optional; only fields present in the call are written, and unrelated keys are preserved. Idempotent — setting a value to its current state returns changed=false. Shape: { changed, settings: { admin_bar, roles, count_roles, do_not_track } }. Role slugs in `roles` and `count_roles` are validated against the site\'s registered roles; unknown slugs return jetpack_stats_invalid_role. Narrowing `roles` can revoke Stats access for whole groups of users — confirm with the user before removing roles.',
508 'jetpack-stats-pkg'
509 ),
510 'input_schema' => array(
511 'type' => 'object',
512 'properties' => array(
513 'admin_bar' => array(
514 'type' => 'boolean',
515 'description' => __( 'Whether to show the Stats item in the admin bar for users who can view Stats.', 'jetpack-stats-pkg' ),
516 ),
517 'roles' => array(
518 'type' => 'array',
519 'description' => __( 'Role slugs that can view Stats. Must be non-empty; each slug must be a registered role.', 'jetpack-stats-pkg' ),
520 'items' => array( 'type' => 'string' ),
521 'minItems' => 1,
522 ),
523 'count_roles' => array(
524 'type' => 'array',
525 'description' => __( 'Role slugs whose visits are counted. May be empty (count visits from all users).', 'jetpack-stats-pkg' ),
526 'items' => array( 'type' => 'string' ),
527 ),
528 'do_not_track' => array(
529 'type' => 'boolean',
530 'description' => __( 'Whether to honor the browser Do Not Track header.', 'jetpack-stats-pkg' ),
531 ),
532 ),
533 'additionalProperties' => false,
534 'minProperties' => 1,
535 ),
536 'output_schema' => array(
537 'type' => 'object',
538 'properties' => array(
539 'changed' => array( 'type' => 'boolean' ),
540 'settings' => array(
541 'type' => 'object',
542 'properties' => self::settings_output_properties(),
543 ),
544 ),
545 ),
546 'execute_callback' => array( __CLASS__, 'update_settings' ),
547 'permission_callback' => array( __CLASS__, 'can_manage_settings' ),
548 'meta' => array(
549 'annotations' => array(
550 'readonly' => false,
551 'destructive' => false,
552 'idempotent' => true,
553 ),
554 'show_in_rest' => true,
555 'mcp' => array(
556 'public' => true,
557 'type' => 'tool', // default is already "tool", but can be explicit.
558 ),
559 ),
560 );
561 }
562
563 /**
564 * Output schema properties shared by get-settings and update-settings' `settings` field.
565 */
566 private static function settings_output_properties(): array {
567 return array(
568 'admin_bar' => array( 'type' => 'boolean' ),
569 'roles' => array(
570 'type' => 'array',
571 'items' => array( 'type' => 'string' ),
572 ),
573 'count_roles' => array(
574 'type' => 'array',
575 'items' => array( 'type' => 'string' ),
576 ),
577 'do_not_track' => array( 'type' => 'boolean' ),
578 );
579 }
580
581 /*
582 ---------------------------------------------------------------------
583 * Permission callbacks
584 * ---------------------------------------------------------------------
585 */
586
587 /**
588 * Read-side permission: view_stats.
589 *
590 * The Stats package's `Main::map_meta_caps` maps `view_stats` to the
591 * user's `read` capability when their role is listed in
592 * `stats_options['roles']`. Honored on self-hosted sites.
593 *
594 * @return bool
595 */
596 public static function can_view_stats(): bool {
597 return current_user_can( 'view_stats' );
598 }
599
600 /**
601 * Write-side permission: manage_options.
602 *
603 * Stats configuration writes modify the `stats_options` WP option,
604 * which includes the very roles that gate `view_stats`. Guard with the
605 * site-admin capability, not `view_stats`, so readers can't escalate.
606 *
607 * @return bool
608 */
609 public static function can_manage_settings(): bool {
610 return current_user_can( 'manage_options' );
611 }
612
613 /*
614 ---------------------------------------------------------------------
615 * Execute callbacks
616 * ---------------------------------------------------------------------
617 */
618
619 /**
620 * Execute: get-site-overview.
621 *
622 * @param array|null $input Ignored — zero-arg ability.
623 * @return array|WP_Error
624 */
625 public static function get_site_overview( $input = null ) {
626 unset( $input );
627 $stats = self::get_wpcom_stats();
628
629 $composed = self::compose_subcalls(
630 array(
631 'summary' => $stats->get_stats_summary(),
632 'highlights' => $stats->get_highlights(),
633 'streak' => $stats->get_streak(),
634 ),
635 __( 'Stats data could not be fetched from WordPress.com. Confirm the site is connected and try again.', 'jetpack-stats-pkg' )
636 );
637 if ( is_wp_error( $composed ) ) {
638 return $composed;
639 }
640 [ 'summary' => $summary, 'highlights' => $highlights, 'streak' => $streak ] = $composed['values'];
641 $errors = $composed['errors'];
642
643 $highlights_today = isset( $highlights['today'] ) && is_array( $highlights['today'] ) ? $highlights['today'] : array();
644 $highlights_top_post = isset( $highlights_today['top_post'] ) && is_array( $highlights_today['top_post'] )
645 ? $highlights_today['top_post']
646 : null;
647
648 $out = array(
649 'date' => self::first_string( array( $summary, $highlights_today ), 'date' ),
650 'views_today' => self::as_int( $summary, 'views' ),
651 'visitors_today' => self::as_int( $summary, 'visitors' ),
652 'views_week' => self::as_int( $summary, 'period_total_views' ),
653 'views_month' => isset( $highlights_today['views_month'] ) ? (int) $highlights_today['views_month'] : 0,
654 'streak' => self::extract_streak_summary( $streak ),
655 'top_post' => null === $highlights_top_post ? null : array(
656 'id' => isset( $highlights_top_post['id'] ) ? (int) $highlights_top_post['id'] : 0,
657 'title' => isset( $highlights_top_post['title'] ) ? (string) $highlights_top_post['title'] : '',
658 'views' => isset( $highlights_top_post['views'] ) ? (int) $highlights_top_post['views'] : 0,
659 ),
660 'top_referrer' => self::extract_top_referrer( $highlights_today ),
661 'partial' => ! empty( $errors ),
662 );
663
664 if ( ! empty( $errors ) ) {
665 $out['errors'] = $errors;
666 }
667
668 return $out;
669 }
670
671 /**
672 * Execute: get-top-content.
673 *
674 * @param array|null $input Input matching the ability's input_schema.
675 * @return array|WP_Error
676 */
677 public static function get_top_content( $input = null ) {
678 $input = is_array( $input ) ? $input : array();
679
680 if ( ! isset( $input['type'] ) || ! in_array( $input['type'], self::TOP_CONTENT_TYPES, true ) ) {
681 return new WP_Error(
682 self::ERROR_PREFIX . 'missing_type',
683 sprintf(
684 /* translators: %s: comma-separated list of valid type values. */
685 __( 'A `type` is required. Valid values: %s.', 'jetpack-stats-pkg' ),
686 implode( ', ', self::TOP_CONTENT_TYPES )
687 )
688 );
689 }
690
691 $type = $input['type'];
692 $period = self::pick_period( $input['period'] ?? null );
693 $date = self::sanitize_date( $input['date'] ?? null );
694 $num = self::clamp_int( $input['num'] ?? 1, 1, 90, 1 );
695 $max = self::clamp_int( $input['max'] ?? 20, 1, 100, 20 );
696
697 $args = array(
698 'period' => $period,
699 'date' => $date,
700 'num' => $num,
701 'max' => $max,
702 );
703
704 $stats = self::get_wpcom_stats();
705 $raw = self::fetch_top_content_raw( $stats, $type, $args );
706 if ( is_wp_error( $raw ) ) {
707 return $raw;
708 }
709
710 $items = self::normalize_top_content_items( $type, $raw, $max );
711
712 return array(
713 'type' => $type,
714 'period' => $period,
715 'date' => $date,
716 'num' => $num,
717 'max' => $max,
718 'items' => $items,
719 );
720 }
721
722 /**
723 * Execute: get-post-views.
724 *
725 * @param array|null $input Input matching the ability's input_schema.
726 * @return array|WP_Error
727 */
728 public static function get_post_views( $input = null ) {
729 $input = is_array( $input ) ? $input : array();
730
731 // Use isset()+is_numeric() — NOT empty() — so the literal "0" is rejected by the `> 0` check, not by a truthiness accident.
732 if ( ! isset( $input['post_id'] ) || ! is_numeric( $input['post_id'] ) || (int) $input['post_id'] <= 0 ) {
733 return new WP_Error(
734 self::ERROR_PREFIX . 'missing_post_id',
735 __( 'A positive post_id is required.', 'jetpack-stats-pkg' )
736 );
737 }
738
739 $post_id = (int) $input['post_id'];
740 $period = self::pick_period( $input['period'] ?? null );
741 $num = self::clamp_int( $input['num'] ?? 30, 1, 90, 30 );
742 $date = self::sanitize_date( $input['date'] ?? null );
743
744 $args = array(
745 'period' => $period,
746 'num' => $num,
747 'date' => $date,
748 );
749
750 $stats = self::get_wpcom_stats();
751 $raw = $stats->get_post_views( $post_id, $args );
752 if ( is_wp_error( $raw ) ) {
753 return $raw;
754 }
755
756 return array(
757 'post_id' => $post_id,
758 'total_views' => isset( $raw['views'] ) ? (int) $raw['views'] : 0,
759 'period' => $period,
760 'num' => $num,
761 'date' => $date,
762 'series' => self::extract_post_views_series( $raw ),
763 );
764 }
765
766 /**
767 * Execute: get-visits.
768 *
769 * @param array|null $input Input matching the ability's input_schema.
770 * @return array|WP_Error
771 */
772 public static function get_visits( $input = null ) {
773 $input = is_array( $input ) ? $input : array();
774
775 $unit = self::pick_period( $input['unit'] ?? null );
776 $quantity = self::clamp_int( $input['quantity'] ?? 30, 1, 90, 30 );
777 $date = self::sanitize_date( $input['date'] ?? null );
778
779 // Pass user input FIRST to array_intersect so caller-supplied field order is preserved.
780 $fields = isset( $input['fields'] ) && is_array( $input['fields'] )
781 ? array_values( array_intersect( $input['fields'], self::VISIT_FIELDS ) )
782 : array();
783 if ( empty( $fields ) ) {
784 $fields = self::DEFAULT_VISIT_FIELDS;
785 }
786
787 $args = array(
788 'unit' => $unit,
789 'quantity' => $quantity,
790 'date' => $date,
791 'stat_fields' => implode( ',', $fields ),
792 );
793
794 $stats = self::get_wpcom_stats();
795 $raw = $stats->get_visits( $args );
796 if ( is_wp_error( $raw ) ) {
797 return $raw;
798 }
799
800 return array(
801 'unit' => $unit,
802 'quantity' => $quantity,
803 'date' => $date,
804 'fields' => $fields,
805 'series' => self::normalize_visits_series( $raw, $fields ),
806 );
807 }
808
809 /**
810 * Execute: get-followers.
811 *
812 * @param array|null $input Ignored — zero-arg ability.
813 * @return array|WP_Error
814 */
815 public static function get_followers( $input = null ) {
816 unset( $input );
817 $stats = self::get_wpcom_stats();
818
819 $composed = self::compose_subcalls(
820 array(
821 'followers' => $stats->get_followers(),
822 'comment_followers' => $stats->get_comment_followers(),
823 'publicize_followers' => $stats->get_publicize_followers(),
824 ),
825 __( 'Follower data could not be fetched from WordPress.com. Confirm the site is connected and try again.', 'jetpack-stats-pkg' )
826 );
827 if ( is_wp_error( $composed ) ) {
828 return $composed;
829 }
830 $followers = $composed['values']['followers'];
831 $comment_followers = $composed['values']['comment_followers'];
832 $publicize = $composed['values']['publicize_followers'];
833 $errors = $composed['errors'];
834
835 $email = 0;
836 $wpcom = 0;
837 if ( isset( $followers['subscribers'] ) && is_array( $followers['subscribers'] ) ) {
838 foreach ( $followers['subscribers'] as $sub ) {
839 if ( isset( $sub['type'] ) && 'email' === $sub['type'] ) {
840 $email += isset( $sub['value'] ) ? (int) $sub['value'] : 0;
841 } elseif ( isset( $sub['type'] ) && 'wpcom' === $sub['type'] ) {
842 $wpcom += isset( $sub['value'] ) ? (int) $sub['value'] : 0;
843 }
844 }
845 } else {
846 $email = isset( $followers['email'] ) ? (int) $followers['email'] : 0;
847 $wpcom = isset( $followers['wpcom'] ) ? (int) $followers['wpcom'] : 0;
848 }
849
850 $comment = isset( $comment_followers['total'] ) ? (int) $comment_followers['total'] : 0;
851
852 $publicize_by_service = array();
853 if ( isset( $publicize['services'] ) && is_array( $publicize['services'] ) ) {
854 foreach ( $publicize['services'] as $row ) {
855 if ( isset( $row['service'] ) && isset( $row['followers'] ) ) {
856 $publicize_by_service[ (string) $row['service'] ] = (int) $row['followers'];
857 }
858 }
859 }
860
861 $total = $email + $wpcom + $comment + array_sum( $publicize_by_service );
862
863 $out = array(
864 'total' => $total,
865 'email' => $email,
866 'wpcom' => $wpcom,
867 'comment' => $comment,
868 'publicize' => $publicize_by_service,
869 'partial' => ! empty( $errors ),
870 );
871
872 if ( ! empty( $errors ) ) {
873 $out['errors'] = $errors;
874 }
875
876 return $out;
877 }
878
879 /**
880 * Execute: get-settings.
881 *
882 * @param array|null $input Ignored — zero-arg ability.
883 * @return array
884 */
885 public static function get_settings( $input = null ) {
886 unset( $input );
887 return self::settings_snapshot();
888 }
889
890 /**
891 * Execute: update-settings.
892 *
893 * @param array|null $input Input matching the ability's input_schema.
894 * @return array|WP_Error
895 */
896 public static function update_settings( $input = null ) {
897 $input = is_array( $input ) ? $input : array();
898
899 // At least one of the whitelisted keys must be present.
900 $provided = array_intersect_key( $input, array_flip( self::SETTINGS_KEYS ) );
901 if ( empty( $provided ) ) {
902 return new WP_Error(
903 self::ERROR_PREFIX . 'missing_setting_field',
904 sprintf(
905 /* translators: %s: comma-separated list of writable field names. */
906 __( 'Provide at least one of: %s.', 'jetpack-stats-pkg' ),
907 implode( ', ', self::SETTINGS_KEYS )
908 )
909 );
910 }
911
912 // Validate role slugs against registered roles — but only load the role list
913 // if the caller is actually writing a role field. Boolean-only writes skip
914 // the wp_roles() resolution entirely. Role fields are detected from the
915 // option's default value type (array → role list).
916 $defaults = Options::get_defaults();
917 $known_roles = null;
918 foreach ( self::SETTINGS_KEYS as $role_field ) {
919 if ( ! array_key_exists( $role_field, $provided ) ) {
920 continue;
921 }
922 if ( ! is_array( $defaults[ $role_field ] ?? null ) ) {
923 continue;
924 }
925 if ( null === $known_roles ) {
926 $known_roles = array_keys( wp_roles()->roles );
927 }
928 if ( ! is_array( $provided[ $role_field ] ) ) {
929 return new WP_Error(
930 self::ERROR_PREFIX . 'invalid_' . $role_field,
931 sprintf(
932 /* translators: %s: the offending field name. */
933 __( 'Field `%s` must be an array of role slugs.', 'jetpack-stats-pkg' ),
934 $role_field
935 )
936 );
937 }
938 // `roles` gates `view_stats` — an empty array would lock every user out, including
939 // the caller. Schema validation enforces minItems=1 on REST input, but direct PHP
940 // callers bypass that path; reject explicitly here.
941 if ( 'roles' === $role_field && empty( $provided[ $role_field ] ) ) {
942 return new WP_Error(
943 self::ERROR_PREFIX . 'invalid_roles',
944 __( 'Field `roles` must be a non-empty array of role slugs — an empty list would revoke Stats access for every user.', 'jetpack-stats-pkg' )
945 );
946 }
947 $sanitized = array();
948 foreach ( $provided[ $role_field ] as $role ) {
949 if ( ! is_string( $role ) || '' === $role ) {
950 return new WP_Error(
951 self::ERROR_PREFIX . 'invalid_role',
952 sprintf(
953 /* translators: 1: field name, 2: comma-separated list of valid role slugs. */
954 __( 'Role slugs in `%1$s` must be non-empty strings. Known roles: %2$s.', 'jetpack-stats-pkg' ),
955 $role_field,
956 implode( ', ', $known_roles )
957 )
958 );
959 }
960 if ( ! in_array( $role, $known_roles, true ) ) {
961 return new WP_Error(
962 self::ERROR_PREFIX . 'invalid_role',
963 sprintf(
964 /* translators: 1: unknown role slug, 2: field name, 3: comma-separated list of valid role slugs. */
965 __( 'Unknown role `%1$s` in `%2$s`. Known roles: %3$s.', 'jetpack-stats-pkg' ),
966 $role,
967 $role_field,
968 implode( ', ', $known_roles )
969 )
970 );
971 }
972 $sanitized[] = $role;
973 }
974 $provided[ $role_field ] = array_values( array_unique( $sanitized ) );
975 }
976
977 $before = self::settings_snapshot();
978 $changes = array();
979 foreach ( $provided as $key => $value ) {
980 if ( is_bool( $defaults[ $key ] ?? null ) ) {
981 $value = (bool) $value;
982 }
983 $current = $before[ $key ] ?? null;
984 if ( $current === $value ) {
985 continue;
986 }
987 $changes[ $key ] = $value;
988 }
989
990 // `changed` is derived from the POST-WRITE snapshot, not from `$changes` alone —
991 // if update_option fails or refuses to persist for any reason (DB error,
992 // serialization mismatch), we must not claim a change that didn't happen.
993 if ( ! empty( $changes ) ) {
994 // One merged write instead of N get+update cycles via set_option.
995 Options::set_options( $changes );
996 }
997
998 $after = self::settings_snapshot();
999
1000 return array(
1001 'changed' => $after !== $before,
1002 'settings' => $after,
1003 );
1004 }
1005
1006 /*
1007 ---------------------------------------------------------------------
1008 * Helpers
1009 * ---------------------------------------------------------------------
1010 */
1011
1012 /**
1013 * Build a whitelisted snapshot of the current Stats configuration.
1014 *
1015 * @return array
1016 */
1017 private static function settings_snapshot(): array {
1018 $options = Options::get_options();
1019 $defaults = Options::get_defaults();
1020 $out = array();
1021 foreach ( self::SETTINGS_KEYS as $key ) {
1022 $raw = $options[ $key ] ?? null;
1023 $default = $defaults[ $key ] ?? null;
1024 if ( is_bool( $default ) ) {
1025 $out[ $key ] = (bool) $raw;
1026 } elseif ( is_array( $default ) ) {
1027 $out[ $key ] = is_array( $raw ) ? array_values( $raw ) : array();
1028 } else {
1029 $out[ $key ] = $raw;
1030 }
1031 }
1032 return $out;
1033 }
1034
1035 /**
1036 * Compose multiple WPCOM sub-call results into a partial-tolerant envelope.
1037 *
1038 * Each named result is either an array (kept as-is) or a WP_Error
1039 * (replaced with `[]` and its key recorded under `errors`). Returns
1040 * `jetpack_stats_data_unavailable` if every sub-call failed.
1041 *
1042 * @param array $named_results Map of error-tag => array|WP_Error.
1043 * @param string $all_failed_message Message for the all-failed WP_Error.
1044 * @return array{values: array, errors: array}|WP_Error
1045 */
1046 private static function compose_subcalls( array $named_results, string $all_failed_message ) {
1047 $values = array();
1048 $errors = array();
1049 foreach ( $named_results as $tag => $result ) {
1050 if ( is_wp_error( $result ) ) {
1051 $errors[] = (string) $tag;
1052 $values[ $tag ] = array();
1053 } else {
1054 $values[ $tag ] = $result;
1055 }
1056 }
1057
1058 if ( count( $errors ) === count( $named_results ) ) {
1059 return new WP_Error( self::ERROR_PREFIX . 'data_unavailable', $all_failed_message );
1060 }
1061
1062 return array(
1063 'values' => $values,
1064 'errors' => $errors,
1065 );
1066 }
1067
1068 /**
1069 * Return the first non-empty string at `$key` across the given source arrays.
1070 *
1071 * @param array[] $sources Ordered list of arrays to probe.
1072 * @param string $key Key to read from each array.
1073 * @return string
1074 */
1075 private static function first_string( array $sources, string $key ): string {
1076 foreach ( $sources as $source ) {
1077 if ( isset( $source[ $key ] ) && '' !== $source[ $key ] ) {
1078 return (string) $source[ $key ];
1079 }
1080 }
1081 return '';
1082 }
1083
1084 /**
1085 * Resolve an aggregation period from raw input, defaulting to `day`.
1086 *
1087 * @param mixed $raw Raw input value.
1088 * @return string One of self::PERIODS.
1089 */
1090 private static function pick_period( $raw ): string {
1091 return is_string( $raw ) && in_array( $raw, self::PERIODS, true ) ? $raw : 'day';
1092 }
1093
1094 /**
1095 * Return a WPCOM_Stats instance. Filterable for tests.
1096 *
1097 * @return WPCOM_Stats
1098 */
1099 protected static function get_wpcom_stats(): WPCOM_Stats {
1100 /**
1101 * Filters the WPCOM_Stats instance used by the Stats abilities.
1102 *
1103 * @since 0.19.0
1104 *
1105 * @param WPCOM_Stats $wpcom_stats The default instance.
1106 */
1107 $instance = apply_filters( 'jetpack_stats_abilities_wpcom_stats', new WPCOM_Stats() );
1108 return $instance instanceof WPCOM_Stats ? $instance : new WPCOM_Stats();
1109 }
1110
1111 /**
1112 * Dispatch top-content raw fetch to the right WPCOM_Stats method.
1113 *
1114 * @param WPCOM_Stats $stats Client.
1115 * @param string $type Content type enum.
1116 * @param array $args Pre-built `{ period, date, num, max }` args — ignored for `tags` which takes only `max`.
1117 * @return array|WP_Error
1118 */
1119 private static function fetch_top_content_raw( WPCOM_Stats $stats, string $type, array $args ) {
1120 switch ( $type ) {
1121 case 'posts':
1122 return $stats->get_top_posts( $args );
1123 case 'referrers':
1124 return $stats->get_referrers( $args );
1125 case 'search-terms':
1126 return $stats->get_search_terms( $args );
1127 case 'clicks':
1128 return $stats->get_clicks( $args );
1129 case 'tags':
1130 // get_tags has a narrower arg surface — pass only `max`.
1131 return $stats->get_tags( array( 'max' => $args['max'] ) );
1132 case 'authors':
1133 return $stats->get_top_authors( $args );
1134 case 'countries':
1135 return $stats->get_views_by_country( $args );
1136 case 'downloads':
1137 return $stats->get_file_downloads( $args );
1138 case 'video-plays':
1139 return $stats->get_video_plays( $args );
1140 }
1141
1142 return new WP_Error( self::ERROR_PREFIX . 'invalid_type', __( 'Unknown top-content type.', 'jetpack-stats-pkg' ) );
1143 }
1144
1145 /**
1146 * Normalize a WPCOM top-content response into the uniform item shape.
1147 *
1148 * Most `type` values follow the `days -> <first-day> -> <list-key>` shape
1149 * and project through `TOP_CONTENT_MAP`. `tags` (flat `tags` array, no
1150 * `days` envelope) and `countries` (needs `country-info` code-to-name
1151 * join) are special-cased.
1152 *
1153 * @param string $type Content type enum.
1154 * @param array $raw Raw WPCOM response.
1155 * @param int $max Result cap.
1156 * @return array List of { rank, label, value, href? } items.
1157 */
1158 private static function normalize_top_content_items( string $type, array $raw, int $max ): array {
1159 if ( 'tags' === $type ) {
1160 $rows = array();
1161 $tags = isset( $raw['tags'] ) && is_array( $raw['tags'] ) ? $raw['tags'] : array();
1162 foreach ( $tags as $tag ) {
1163 $rows[] = array(
1164 'label' => isset( $tag['tag'] ) ? (string) $tag['tag'] : '',
1165 'value' => isset( $tag['views'] ) ? (int) $tag['views'] : 0,
1166 );
1167 }
1168 return self::rank_and_cap( $rows, $max );
1169 }
1170
1171 $day_data = self::first_day( $raw );
1172
1173 if ( 'countries' === $type ) {
1174 $rows = array();
1175 $list = isset( $day_data['views'] ) && is_array( $day_data['views'] ) ? $day_data['views'] : array();
1176 $country_info = isset( $raw['country-info'] ) && is_array( $raw['country-info'] ) ? $raw['country-info'] : array();
1177 foreach ( $list as $v ) {
1178 $code = isset( $v['country_code'] ) ? (string) $v['country_code'] : '';
1179 $rows[] = array(
1180 'label' => (string) ( $country_info[ $code ]['country_full'] ?? $code ),
1181 'value' => isset( $v['views'] ) ? (int) $v['views'] : 0,
1182 );
1183 }
1184 return self::rank_and_cap( $rows, $max );
1185 }
1186
1187 $map = self::TOP_CONTENT_MAP[ $type ] ?? null;
1188 if ( null === $map ) {
1189 return array();
1190 }
1191
1192 $rows = array();
1193 $list = isset( $day_data[ $map['list'] ] ) && is_array( $day_data[ $map['list'] ] ) ? $day_data[ $map['list'] ] : array();
1194 foreach ( $list as $row ) {
1195 if ( ! is_array( $row ) ) {
1196 continue;
1197 }
1198 $label = isset( $row[ $map['label'] ] ) && '' !== $row[ $map['label'] ] ? (string) $row[ $map['label'] ] : '';
1199 if ( '' === $label && isset( $map['label_fallback'] ) && isset( $row[ $map['label_fallback'] ] ) ) {
1200 $label = (string) $row[ $map['label_fallback'] ];
1201 }
1202 $entry = array(
1203 'label' => $label,
1204 'value' => isset( $row[ $map['value'] ] ) ? (int) $row[ $map['value'] ] : 0,
1205 );
1206 if ( isset( $map['href'] ) && isset( $row[ $map['href'] ] ) ) {
1207 $entry['href'] = (string) $row[ $map['href'] ];
1208 }
1209 $rows[] = $entry;
1210 }
1211 return self::rank_and_cap( $rows, $max );
1212 }
1213
1214 /**
1215 * Pick the first `days` entry from a WPCOM days-keyed response.
1216 *
1217 * Different top-content endpoints key their per-day data under `days`
1218 * (posts, referrers, authors, countries, ...) or `days -> <date>`; a few
1219 * flatten it entirely (tags). This helper handles the common case.
1220 *
1221 * @param array $raw Raw WPCOM response.
1222 * @return array The first day's sub-array, or [].
1223 */
1224 private static function first_day( array $raw ): array {
1225 if ( ! isset( $raw['days'] ) || ! is_array( $raw['days'] ) || empty( $raw['days'] ) ) {
1226 return array();
1227 }
1228 $first = reset( $raw['days'] );
1229 return is_array( $first ) ? $first : array();
1230 }
1231
1232 /**
1233 * Rank, cap, and strip null href fields.
1234 *
1235 * @param array $rows Unranked rows.
1236 * @param int $max Result cap.
1237 * @return array Ranked + capped rows with `rank` injected.
1238 */
1239 private static function rank_and_cap( array $rows, int $max ): array {
1240 $rows = array_slice( $rows, 0, $max );
1241 $out = array();
1242 foreach ( $rows as $i => $row ) {
1243 $entry = array(
1244 'rank' => $i + 1,
1245 'label' => isset( $row['label'] ) ? (string) $row['label'] : '',
1246 'value' => isset( $row['value'] ) ? (int) $row['value'] : 0,
1247 );
1248 if ( isset( $row['href'] ) && '' !== $row['href'] ) {
1249 $entry['href'] = $row['href'];
1250 }
1251 $out[] = $entry;
1252 }
1253 return $out;
1254 }
1255
1256 /**
1257 * Extract a compact streak summary from the WPCOM streak response.
1258 *
1259 * @param array $streak Raw WPCOM streak response.
1260 * @return array Compact `{ current_length, longest_length, longest_start, longest_end }`.
1261 */
1262 private static function extract_streak_summary( array $streak ): array {
1263 $data = isset( $streak['streak'] ) && is_array( $streak['streak'] ) ? $streak['streak'] : array();
1264 return array(
1265 'current_length' => isset( $data['currentStreakLength'] ) ? (int) $data['currentStreakLength'] : 0,
1266 'longest_length' => isset( $data['longestStreakLength'] ) ? (int) $data['longestStreakLength'] : 0,
1267 'longest_start' => isset( $data['longestStreakStart'] ) ? (string) $data['longestStreakStart'] : '',
1268 'longest_end' => isset( $data['longestStreakEnd'] ) ? (string) $data['longestStreakEnd'] : '',
1269 );
1270 }
1271
1272 /**
1273 * Extract the top referrer from a highlights `today` block.
1274 *
1275 * @param array $today Highlights today block.
1276 * @return array|null { name, views } or null.
1277 */
1278 private static function extract_top_referrer( array $today ): ?array {
1279 $list = isset( $today['top_referrers'] ) && is_array( $today['top_referrers'] ) ? $today['top_referrers'] : array();
1280 if ( empty( $list ) ) {
1281 return null;
1282 }
1283 $first = $list[0];
1284 if ( ! is_array( $first ) ) {
1285 return null;
1286 }
1287 return array(
1288 'name' => isset( $first['name'] ) ? (string) $first['name'] : '',
1289 'views' => isset( $first['views'] ) ? (int) $first['views'] : 0,
1290 );
1291 }
1292
1293 /**
1294 * Extract a post-views series from the WPCOM get_post_views response.
1295 *
1296 * WPCOM returns `data` as a list of `[date, views]` tuples under the
1297 * `fields` header. We normalize to `[{ date, views }]`.
1298 *
1299 * @param array $raw Raw WPCOM response.
1300 * @return array
1301 */
1302 private static function extract_post_views_series( array $raw ): array {
1303 if ( ! isset( $raw['data'] ) || ! is_array( $raw['data'] ) ) {
1304 return array();
1305 }
1306
1307 $fields = isset( $raw['fields'] ) && is_array( $raw['fields'] ) ? $raw['fields'] : array( 'period', 'views' );
1308 $date_idx = array_search( 'period', $fields, true );
1309 $views_idx = array_search( 'views', $fields, true );
1310 if ( false === $date_idx || false === $views_idx ) {
1311 // If either column is missing from the WPCOM response, the positional
1312 // fallback is unsafe (we might collide date/views on column 0). Drop
1313 // to empty rather than emit lies.
1314 return array();
1315 }
1316
1317 $series = array();
1318 foreach ( $raw['data'] as $row ) {
1319 if ( ! is_array( $row ) ) {
1320 continue;
1321 }
1322 $series[] = array(
1323 'date' => isset( $row[ $date_idx ] ) ? (string) $row[ $date_idx ] : '',
1324 'views' => isset( $row[ $views_idx ] ) ? (int) $row[ $views_idx ] : 0,
1325 );
1326 }
1327 return $series;
1328 }
1329
1330 /**
1331 * Normalize the WPCOM get_visits response into `[{ date, <field>: int, ... }]`.
1332 *
1333 * @param array $raw Raw WPCOM response.
1334 * @param array $fields Requested metric fields.
1335 * @return array
1336 */
1337 private static function normalize_visits_series( array $raw, array $fields ): array {
1338 if ( ! isset( $raw['data'] ) || ! is_array( $raw['data'] ) ) {
1339 return array();
1340 }
1341
1342 $raw_fields = isset( $raw['fields'] ) && is_array( $raw['fields'] ) ? $raw['fields'] : array();
1343 $field_idx = array();
1344 foreach ( $raw_fields as $idx => $name ) {
1345 $field_idx[ (string) $name ] = $idx;
1346 }
1347 $date_idx = $field_idx['period'] ?? 0;
1348
1349 $series = array();
1350 foreach ( $raw['data'] as $row ) {
1351 if ( ! is_array( $row ) ) {
1352 continue;
1353 }
1354 $entry = array(
1355 'date' => isset( $row[ $date_idx ] ) ? (string) $row[ $date_idx ] : '',
1356 );
1357 foreach ( $fields as $field ) {
1358 $idx = $field_idx[ $field ] ?? null;
1359 $entry[ $field ] = ( null !== $idx && isset( $row[ $idx ] ) ) ? (int) $row[ $idx ] : 0;
1360 }
1361 $series[] = $entry;
1362 }
1363 return $series;
1364 }
1365
1366 /**
1367 * Normalize a candidate date string. Returns today's date (UTC) on bad input.
1368 *
1369 * @param mixed $raw Raw input value.
1370 * @return string YYYY-MM-DD.
1371 */
1372 private static function sanitize_date( $raw ): string {
1373 if ( is_string( $raw ) && 1 === preg_match( '/^\d{4}-\d{2}-\d{2}$/', $raw ) ) {
1374 return $raw;
1375 }
1376 return gmdate( 'Y-m-d' );
1377 }
1378
1379 /**
1380 * Clamp an integer into [$min, $max] with a default on bad input.
1381 *
1382 * @param mixed $raw Raw input.
1383 * @param int $min Minimum.
1384 * @param int $max Maximum.
1385 * @param int $default Default on bad input.
1386 * @return int
1387 */
1388 private static function clamp_int( $raw, int $min, int $max, int $default ): int {
1389 if ( ! is_numeric( $raw ) ) {
1390 return $default;
1391 }
1392 $v = (int) $raw;
1393 if ( $v < $min ) {
1394 return $min;
1395 }
1396 if ( $v > $max ) {
1397 return $max;
1398 }
1399 return $v;
1400 }
1401
1402 /**
1403 * Safely read an int field from an array.
1404 *
1405 * @param array $arr Array.
1406 * @param string $key Key.
1407 * @return int
1408 */
1409 private static function as_int( array $arr, string $key ): int {
1410 return isset( $arr[ $key ] ) ? (int) $arr[ $key ] : 0;
1411 }
1412 }
1413