PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-beta
Jetpack – WP Security, Backup, Speed, & Growth v16.3-beta
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
jetpack / jetpack_vendor / automattic / jetpack-stats / src / abilities / class-stats-abilities.php

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

1,275 lines 43.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Jetpack Stats Abilities Registration.
4 *
5 * Registers Jetpack Stats abilities with the WordPress Abilities API.
6 *
7 * @package automattic/jetpack-stats
8 */
9
10 namespace Automattic\Jetpack\Stats\Abilities;
11
12 use Automattic\Jetpack\Stats\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 }
1275