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