PluginProbe ʕ •ᴥ•ʔ
Jetpack – WP Security, Backup, Speed, & Growth / 16.2-a.1
Jetpack – WP Security, Backup, Speed, & Growth v16.2-a.1
16.1.2 16.2-a.1 16.1.1 16.1 16.1-beta 16.1-beta.2 16.1-beta.3 16.1-a.5 16.1-a.3 16.0.1 16.1-a.1 16.0 16.0-beta 16.0-a.7 16.0-a.5 15.9.1 16.0-a.3 16.0-a.1 15.9 15.9-beta 15.9-a.7 15.9-a.5 15.9-a.3 15.9-a.1 15.8 15.8-beta 15.8-a.7 15.8-a.5 5.2.5 5.3.4 5.4.4 5.5.5 5.6.5 5.7.5 5.8.4 5.9.4 6.0.4 6.1 6.1.1 6.1.2 6.1.3 6.1.4 6.1.5 6.2 6.2.1 6.2.2 6.2.3 6.2.4 6.2.5 6.3 6.3.1 6.3.2 6.3.3 6.3.4 6.3.5 6.3.6 6.3.7 6.4 6.4.1 6.4.2 6.4.3 6.4.4 6.4.5 6.4.6 6.5 6.5.1 6.5.2 6.5.3 6.5.4 6.6 6.6.1 6.6.2 6.6.3 6.6.4 6.6.5 6.7 6.7.1 6.7.2 6.7.3 6.7.4 6.8 6.8.1 6.8.2 6.8.3 6.8.4 6.8.5 6.9 6.9.1 6.9.2 6.9.3 6.9.4 7.0 7.0.1 7.0.2 7.0.3 7.0.4 7.0.5 7.1 7.1.1 7.1.2 7.1.3 7.1.4 7.1.5 7.2 7.2.1 7.2.1.1 7.2.2 7.2.3 7.2.4 7.2.5 7.3 7.3.0.1 7.3.1 7.3.1.1 7.3.2 7.3.3 7.3.4 7.3.5 7.4 7.4.1 7.4.2 7.4.3 7.4.4 7.4.5 7.5 7.5.0.1 7.5.1 7.5.2 7.5.3 7.5.4 7.5.5 7.5.6 7.5.7 7.6 7.6.1 7.6.2 7.6.3 7.6.4 7.7 7.7.1 7.7.2 7.7.3 7.7.4 7.7.5 7.7.6 7.8 7.8.1 7.8.2 7.8.3 7.8.4 7.9 7.9.1 7.9.2 7.9.3 7.9.4 8.0 8.0.1 8.0.2 8.0.3 8.1 8.1.1 8.1.2 8.1.3 8.1.4 8.2 8.2.0.1 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.2.6 8.3 8.3.1 8.3.2 8.3.3 8.4 8.4.1 8.4.2 8.4.3 8.4.4 8.4.5 8.5 8.5.1 8.5.2 8.5.3 8.6 8.6.1 8.6.2 8.6.3 8.6.4 8.7 8.7.0.1 8.7.1 8.7.2 8.7.3 8.7.4 8.8 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.9 8.9.1 8.9.2 8.9.3 8.9.4 9.0 9.0.1 9.0.2 9.0.3 9.0.4 9.0.5 9.1 9.1.1 9.1.2 9.1.3 9.2 9.2.1 9.2.2 9.2.3 9.2.4 9.3 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.4 9.4.1 9.4.2 9.4.3 9.4.4 9.5 9.5.1 9.5.2 9.5.3 9.5.4 9.5.5 9.6 9.6.1 9.6.2 9.6.3 9.6.4 9.7 9.7.1 9.7.2 15.7-beta.2 9.7.3 15.7.1 9.8 15.8-a.1 9.8.1 15.8-a.3 9.8.2 2.0.9 9.8.3 2.1.7 9.9 2.2.10 9.9.1 2.3.10 9.9.2 2.4.7 9.9.3 2.5.5 2.6.6 2.7.5 2.8.5 2.9.6 3.0.6 3.1.5 3.2.5 3.3.6 3.4.6 3.5.6 3.6.4 3.7.5 3.8.5 3.9.10 4.0.7 4.1.4 4.2.5 4.3.5 4.4.5 4.5.3 4.6.3 4.7.4 4.8.5 4.9.3 5.0.3 5.1.4 trunk 10.0 10.0.1 10.0.2 10.1 10.1.1 10.1.2 10.2 10.2.1 10.2.2 10.2.3 10.3 10.3.1 10.3.2 10.4 10.4.1 10.4.2 10.5 10.5.1 10.5.2 10.5.3 10.6 10.6.1 10.6.2 10.7 10.7.1 10.7.2 10.8 10.8.1 10.8.2 10.9 10.9.1 10.9.2 10.9.3 11.0 11.0.1 11.0.2 11.1 11.1.1 11.1.2 11.1.3 11.1.4 11.2 11.2.1 11.2.2 11.3 11.3.1 11.3.2 11.3.3 11.3.4 11.4 11.4.1 11.4.2 11.5 11.5.1 11.5.2 11.5.3 11.6 11.6.1 11.6.2 11.7 11.7.1 11.7.2 11.7.3 11.8 11.8.3 11.8.4 11.8.5 11.8.6 11.9 11.9.1 11.9.2 11.9.3 12.0 12.0.1 12.0.2 12.1 12.1.1 12.1.2 12.2 12.2.1 12.2.2 12.3 12.3.1 12.4 12.4.1 12.5 12.5.1 12.6 12.6.1 12.6.2 12.6.3 12.7 12.7.1 12.7.2 12.8 12.8.1 12.8.2 12.9 12.9.1 12.9.2 12.9.3 12.9.4 13.0 13.0.1 13.1 13.1.1 13.1.2 13.1.3 13.1.4 13.2 13.2.1 13.2.2 13.2.3 13.3 13.3.1 13.3.2 13.4 13.4.1 13.4.2 13.4.3 13.4.4 13.5 13.5.1 13.6 13.6.1 13.7 13.7.1 13.8 13.8.1 13.8.2 13.9 13.9.1 14.0 14.1 14.2 14.2.1 14.3 14.4 14.4.1 14.5 14.6 14.7 14.8 14.9 14.9.1 15.0 15.0.1 15.0.2 15.1 15.1.1 15.2 15.3 15.3.1 15.4 15.5 15.6 15.7 15.7-a.1 15.7-a.3 15.7-a.5 15.7-a.7 15.7-beta
jetpack / jetpack_vendor / automattic / jetpack-stats / src / abilities / class-stats-abilities.php
jetpack / jetpack_vendor / automattic / jetpack-stats / src / abilities Last commit date
class-stats-abilities.php 1 week ago
class-stats-abilities.php
1413 lines
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