PluginProbe
Friends / trunk
Friends vtrunk
4.3.1 4.3.0 4.2.2 4.2.1 4.2.0 4.1.0 2.7.4 2.7.5 2.7.6 2.7.7 2.7.8 2.7.9 2.8.0 2.8.1 2.8.2 2.8.3 2.8.4 2.8.5 2.8.6 2.8.7 2.8.8 2.8.9 2.9.0 2.9.1 2.9.2 All 87 releases
friends / includes / class-abilities.php

class-abilities.php in Friends trunk, at includes/class-abilities.php

1,416 lines 45.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WordPress Abilities API integration.
4 *
5 * @package Friends
6 */
7
8 namespace Friends;
9
10 /**
11 * Registers Friends abilities for AI Assistant and other Abilities API clients.
12 */
13 class Abilities {
14 const CATEGORY = 'friends';
15
16 /**
17 * A reference to the Friends object.
18 *
19 * @var Friends
20 */
21 private $friends;
22
23 /**
24 * Constructor.
25 *
26 * @param Friends $friends A reference to the Friends object.
27 */
28 public function __construct( Friends $friends ) {
29 $this->friends = $friends;
30
31 if ( function_exists( 'did_action' ) && did_action( 'wp_abilities_api_categories_init' ) ) {
32 $this->register_category();
33 } else {
34 add_action( 'wp_abilities_api_categories_init', array( $this, 'register_category' ) );
35 }
36
37 if ( function_exists( 'did_action' ) && did_action( 'wp_abilities_api_init' ) ) {
38 $this->register_abilities();
39 } else {
40 add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) );
41 }
42
43 add_filter( 'ai_assistant_ability_domains', array( $this, 'register_ability_domain' ) );
44 add_filter( 'ai_assistant_ability_instructions', array( $this, 'ability_instructions' ), 10, 4 );
45 }
46
47 /**
48 * Register the Friends ability category.
49 */
50 public function register_category() {
51 if ( ! function_exists( 'wp_register_ability_category' ) ) {
52 return;
53 }
54
55 wp_register_ability_category(
56 self::CATEGORY,
57 array(
58 'label' => __( 'Friends', 'friends' ),
59 'description' => __( 'Abilities for managing Friends subscriptions, feeds, and timeline items.', 'friends' ),
60 )
61 );
62 }
63
64 /**
65 * Register all Friends abilities.
66 */
67 public function register_abilities() {
68 if ( ! function_exists( 'wp_register_ability' ) ) {
69 return;
70 }
71
72 foreach ( $this->get_ability_configs() as $name => $config ) {
73 wp_register_ability( $name, $config );
74 }
75 }
76
77 /**
78 * Register AI Assistant domain hints.
79 *
80 * @param array $domains Registered ability domains.
81 * @return array Updated ability domains.
82 */
83 public function register_ability_domain( $domains ) {
84 $domains[ self::CATEGORY ] = 'Friends, friend posts, subscriptions, follows, feed reader, timeline, RSS, Atom, ActivityPub, Mastodon, Fediverse, refresh feeds, subscribe';
85 return $domains;
86 }
87
88 /**
89 * Add follow-up instructions for AI Assistant after specific ability calls.
90 *
91 * @param string $instructions Existing instructions.
92 * @param string $ability_id Ability ID that was executed.
93 * @param array $args Ability arguments.
94 * @param mixed $result Ability result.
95 * @return string Instructions for AI Assistant.
96 */
97 public function ability_instructions( $instructions, $ability_id, $args, $result ) {
98 unset( $args, $result );
99
100 switch ( $ability_id ) {
101 case 'friends/list-feed-items':
102 return 'Summarize the returned timeline items with the author name, date, title or excerpt, and a link from local_url when available. Keep long content concise unless the user asked for full text.';
103
104 case 'friends/add-subscription':
105 return 'Tell the user which subscription was added, which feeds were activated, and whether an initial refresh created cached posts. Include local_url when present.';
106
107 case 'friends/refresh-feed':
108 case 'friends/refresh-feeds':
109 return 'Report the number of feeds refreshed and cached posts created. If errors are present, list the affected feed URLs with the error messages.';
110 }
111
112 return $instructions;
113 }
114
115 /**
116 * Whether the current user may read Friends data.
117 *
118 * @return bool True if allowed.
119 */
120 public function can_read() {
121 return Friends::has_required_privileges();
122 }
123
124 /**
125 * Whether the current user may manage Friends data.
126 *
127 * @return bool True if allowed.
128 */
129 public function can_manage() {
130 return Friends::has_required_privileges();
131 }
132
133 /**
134 * List Friends subscriptions.
135 *
136 * @param array|null $input Ability input.
137 * @return array List result.
138 */
139 public function list_subscriptions( $input = null ) {
140 $input = $this->normalize_input( $input );
141 $limit = $this->sanitize_limit( $input['limit'] ?? 20, 1, 100 );
142 $include_feeds = $this->input_bool( $input, 'include_feeds', true );
143 $search = isset( $input['search'] ) ? sanitize_text_field( $input['search'] ) : '';
144
145 if ( '' !== $search ) {
146 $query = User_Query::search( $search );
147 } else {
148 $query = User_Query::all_subscriptions();
149 }
150
151 $subscriptions = array();
152 foreach ( array_slice( $query->get_results(), 0, $limit ) as $subscription ) {
153 $subscriptions[] = $this->subscription_to_array( $subscription, $include_feeds );
154 }
155
156 return array(
157 'count' => count( $subscriptions ),
158 'total' => (int) $query->get_total(),
159 'subscriptions' => $subscriptions,
160 );
161 }
162
163 /**
164 * Get one Friends subscription.
165 *
166 * @param array|null $input Ability input.
167 * @return array|\WP_Error Subscription result or error.
168 */
169 public function get_subscription( $input = null ) {
170 $input = $this->normalize_input( $input );
171 $subscription = $this->get_subscription_from_input( $input );
172
173 if ( is_wp_error( $subscription ) ) {
174 return $subscription;
175 }
176
177 return $this->subscription_to_array( $subscription, true );
178 }
179
180 /**
181 * Discover feeds for a URL.
182 *
183 * @param array|null $input Ability input.
184 * @return array|\WP_Error Discovery result or error.
185 */
186 public function discover_feeds( $input = null ) {
187 $input = $this->normalize_input( $input );
188 $url = $this->normalize_url( $input['url'] ?? '' );
189
190 if ( ! $url ) {
191 return new \WP_Error( 'invalid-url', __( 'A valid URL is required.', 'friends' ) );
192 }
193
194 $feeds = $this->friends->feed->discover_available_feeds( $url );
195 if ( is_wp_error( $feeds ) ) {
196 return $feeds;
197 }
198
199 return array(
200 'url' => $url,
201 'count' => count( $feeds ),
202 'feeds' => $this->discovered_feeds_to_array( $feeds ),
203 );
204 }
205
206 /**
207 * Add a Friends subscription.
208 *
209 * @param array|null $input Ability input.
210 * @return array|\WP_Error Add result or error.
211 */
212 public function add_subscription( $input = null ) {
213 $input = $this->normalize_input( $input );
214 $url = $this->normalize_url( $input['url'] ?? '' );
215
216 if ( ! $url ) {
217 return new \WP_Error( 'invalid-url', __( 'A valid URL is required.', 'friends' ) );
218 }
219
220 if ( 0 === strpos( $url, home_url() ) ) {
221 return new \WP_Error( 'friend-yourself', __( 'It seems like you sent a friend request to yourself.', 'friends' ) );
222 }
223
224 $discovered_feeds = array();
225 $selected_urls = $this->sanitize_url_list( $input['feed_urls'] ?? array() );
226 if ( empty( $selected_urls ) ) {
227 $discovered_feeds = $this->friends->feed->discover_available_feeds( $url );
228 if ( is_wp_error( $discovered_feeds ) ) {
229 return $discovered_feeds;
230 }
231 if ( empty( $discovered_feeds ) ) {
232 return new \WP_Error( 'no-feed-found', __( 'No suitable feed was found at the provided address.', 'friends' ) );
233 }
234 $selected_urls = $this->select_discovered_feed_urls( $discovered_feeds );
235 }
236
237 if ( empty( $selected_urls ) ) {
238 return new \WP_Error( 'no-subscribable-feed-found', __( 'No subscribable feed was found at the provided address.', 'friends' ) );
239 }
240
241 if ( empty( $discovered_feeds ) ) {
242 $discovered_feeds = $this->feeds_from_urls( $selected_urls );
243 }
244
245 $username = isset( $input['username'] ) ? User::sanitize_username( $input['username'] ) : '';
246 if ( ! $username ) {
247 $username = apply_filters( 'friends_suggest_user_login', User::get_user_login_for_url( $url ), $url );
248 $better_username = User::get_user_login_from_feeds( $discovered_feeds );
249 if ( $better_username ) {
250 $username = trim( $better_username, '-' );
251 }
252 }
253 $username = User::sanitize_username( $username );
254 if ( ! $username ) {
255 return new \WP_Error( 'invalid-username', __( 'The subscription username could not be determined.', 'friends' ) );
256 }
257
258 if ( ! is_multisite() && username_exists( $username ) ) {
259 return new \WP_Error( 'username-exists', __( 'This username is already registered. Please choose another one.', 'friends' ) );
260 }
261
262 $existing = User::get_user( $username );
263 if ( $existing && ! is_wp_error( $existing ) ) {
264 return new \WP_Error( 'already-subscribed', __( 'You are already subscribed to this site.', 'friends' ), $this->subscription_to_array( $existing, true ) );
265 }
266
267 $display_name = isset( $input['display_name'] ) ? sanitize_text_field( $input['display_name'] ) : '';
268 if ( ! $display_name ) {
269 $display_name = apply_filters( 'friends_suggest_display_name', User::get_display_name_for_url( $url ), $url );
270 $better_display_name = User::get_display_name_from_feeds( $discovered_feeds );
271 if ( $better_display_name ) {
272 $display_name = $better_display_name;
273 }
274 }
275
276 $feed_options = $this->build_feed_options( $selected_urls, $discovered_feeds, $display_name );
277 if ( empty( $feed_options ) ) {
278 return new \WP_Error( 'no-subscribable-feed-found', __( 'No subscribable feed was found at the provided address.', 'friends' ) );
279 }
280
281 $avatar = $this->first_feed_value( $feed_options, 'avatar' );
282 $description = $this->first_feed_value( $feed_options, 'description' );
283 $subscription = User::create( $username, 'subscription', $url, $display_name, $avatar, $description );
284 if ( is_wp_error( $subscription ) ) {
285 return $subscription;
286 }
287
288 $saved_feeds = $subscription->save_feeds( $feed_options );
289 if ( is_wp_error( $saved_feeds ) ) {
290 return $saved_feeds;
291 }
292
293 $activated_feeds = array();
294 foreach ( $feed_options as $feed_url => $options ) {
295 $new_feed = $subscription->subscribe( $feed_url, $options );
296 if ( is_wp_error( $new_feed ) ) {
297 return $new_feed;
298 }
299
300 do_action( 'friends_user_feed_activated', $new_feed );
301 $activated_feeds[] = $this->feed_to_array( $new_feed );
302 }
303
304 $refresh_result = null;
305 if ( $this->input_bool( $input, 'refresh', true ) ) {
306 $refresh_result = $this->refresh_feeds(
307 array(
308 'subscription_id' => $subscription->ID,
309 'force' => true,
310 )
311 );
312 }
313
314 $result = array(
315 'subscription' => $this->subscription_to_array( $subscription, true ),
316 'activated_feeds' => $activated_feeds,
317 );
318
319 if ( $refresh_result && ! is_wp_error( $refresh_result ) ) {
320 $result['refresh'] = $refresh_result;
321 }
322
323 return $result;
324 }
325
326 /**
327 * List cached feed items.
328 *
329 * @param array|null $input Ability input.
330 * @return array|\WP_Error Feed item result or error.
331 */
332 public function list_feed_items( $input = null ) {
333 $input = $this->normalize_input( $input );
334 $limit = $this->sanitize_limit( $input['limit'] ?? 20, 1, 50 );
335
336 $args = array(
337 'post_type' => Friends::CPT,
338 'post_status' => array( 'publish', 'private' ),
339 'posts_per_page' => $limit,
340 'orderby' => 'date',
341 'order' => 'DESC',
342 'ignore_sticky_posts' => true,
343 'no_found_rows' => true,
344 );
345
346 if ( isset( $input['search'] ) && '' !== trim( $input['search'] ) ) {
347 $args['s'] = sanitize_text_field( $input['search'] );
348 }
349
350 if ( isset( $input['post_format'] ) && '' !== trim( $input['post_format'] ) ) {
351 $args['tax_query'] = $this->friends->wp_query_get_post_format_tax_query( array(), sanitize_key( $input['post_format'] ) ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
352 }
353
354 $subscription = null;
355 if ( ! empty( $input['subscription_id'] ) || ! empty( $input['username'] ) ) {
356 $subscription = $this->get_subscription_from_input( $input );
357 if ( is_wp_error( $subscription ) ) {
358 return $subscription;
359 }
360 $args = $subscription->modify_get_posts_args_by_author( $args );
361 }
362
363 $query = new \WP_Query( $args );
364 $items = array();
365 foreach ( $query->posts as $post ) {
366 $items[] = $this->post_to_array( $post );
367 }
368
369 return array(
370 'count' => count( $items ),
371 'subscription' => $subscription ? $this->subscription_to_array( $subscription, false ) : null,
372 'items' => $items,
373 );
374 }
375
376 /**
377 * Refresh cached feeds.
378 *
379 * @param array|null $input Ability input.
380 * @return array|\WP_Error Refresh result or error.
381 */
382 public function refresh_feeds( $input = null ) {
383 $input = $this->normalize_input( $input );
384 $force = $this->input_bool( $input, 'force', false );
385 $subscription = null;
386
387 if ( ! empty( $input['subscription_id'] ) || ! empty( $input['username'] ) ) {
388 $subscription = $this->get_subscription_from_input( $input );
389 if ( is_wp_error( $subscription ) ) {
390 return $subscription;
391 }
392 $feeds = $force ? $subscription->get_active_feeds() : $subscription->get_due_feeds();
393 } else {
394 $feeds = User_Feed::get_all_due( $force );
395 }
396
397 $result = $this->new_refresh_result( $subscription );
398
399 add_filter( 'notify_about_new_friend_post', '__return_false', 999 );
400 foreach ( $feeds as $feed ) {
401 $this->refresh_feed_into_result( $result, $feed, $force );
402 }
403 remove_filter( 'notify_about_new_friend_post', '__return_false', 999 );
404
405 return $result;
406 }
407
408 /**
409 * Refresh one cached feed.
410 *
411 * @param array|null $input Ability input.
412 * @return array|\WP_Error Refresh result or error.
413 */
414 public function refresh_feed( $input = null ) {
415 $input = $this->normalize_input( $input );
416 $feed = $this->get_feed_from_input( $input );
417 if ( is_wp_error( $feed ) ) {
418 return $feed;
419 }
420
421 if ( ! $feed->is_active() ) {
422 return new \WP_Error( 'feed-inactive', __( 'The requested feed is not active.', 'friends' ) );
423 }
424
425 $friend_user = $feed->get_friend_user();
426 if ( ! $friend_user ) {
427 return new \WP_Error( 'subscription-not-found', __( 'The requested feed is not linked to a subscription.', 'friends' ) );
428 }
429
430 $result = $this->new_refresh_result( $friend_user );
431 $force = $this->input_bool( $input, 'force', true );
432
433 add_filter( 'notify_about_new_friend_post', '__return_false', 999 );
434 $this->refresh_feed_into_result( $result, $feed, $force );
435 remove_filter( 'notify_about_new_friend_post', '__return_false', 999 );
436
437 return $result;
438 }
439
440 /**
441 * Build ability registrations.
442 *
443 * @return array Ability configs keyed by ability ID.
444 */
445 private function get_ability_configs() {
446 return array(
447 'friends/list-subscriptions' => array(
448 'label' => __( 'List Friends subscriptions', 'friends' ),
449 'description' => __( 'Returns Friends subscriptions with profile details and optionally their configured feeds.', 'friends' ),
450 'category' => self::CATEGORY,
451 'input_schema' => $this->list_subscriptions_input_schema(),
452 'output_schema' => $this->list_subscriptions_output_schema(),
453 'execute_callback' => array( $this, 'list_subscriptions' ),
454 'permission_callback' => array( $this, 'can_read' ),
455 'meta' => $this->ability_meta( true, false, 'Use this to find subscription IDs, usernames, and feed IDs before calling get-subscription, list-feed-items, refresh-feed, refresh-feeds, or add-subscription follow-up actions.' ),
456 ),
457 'friends/get-subscription' => array(
458 'label' => __( 'Get Friends subscription', 'friends' ),
459 'description' => __( 'Returns full details for one Friends subscription by subscription_id or username, including configured feeds.', 'friends' ),
460 'category' => self::CATEGORY,
461 'input_schema' => $this->subscription_lookup_input_schema(),
462 'output_schema' => $this->subscription_output_schema(),
463 'execute_callback' => array( $this, 'get_subscription' ),
464 'permission_callback' => array( $this, 'can_read' ),
465 'meta' => $this->ability_meta( true, false, 'Call list-subscriptions first when the user gives an ambiguous name. The returned id can be passed as subscription_id to other Friends abilities.' ),
466 ),
467 'friends/discover-feeds' => array(
468 'label' => __( 'Discover feeds', 'friends' ),
469 'description' => __( 'Discovers RSS, Atom, ActivityPub, and other supported feeds for a URL without subscribing to them.', 'friends' ),
470 'category' => self::CATEGORY,
471 'input_schema' => $this->url_input_schema(),
472 'output_schema' => $this->discover_feeds_output_schema(),
473 'execute_callback' => array( $this, 'discover_feeds' ),
474 'permission_callback' => array( $this, 'can_read' ),
475 'meta' => $this->ability_meta( true, false, 'Use this before add-subscription when the user wants to review available feeds. It performs a remote fetch but does not change WordPress data.' ),
476 ),
477 'friends/add-subscription' => array(
478 'label' => __( 'Add Friends subscription', 'friends' ),
479 'description' => __( 'Creates a Friends subscription for a URL, activates selected or auto-discovered feeds, and optionally refreshes them immediately.', 'friends' ),
480 'category' => self::CATEGORY,
481 'input_schema' => $this->add_subscription_input_schema(),
482 'output_schema' => $this->add_subscription_output_schema(),
483 'execute_callback' => array( $this, 'add_subscription' ),
484 'permission_callback' => array( $this, 'can_manage' ),
485 'meta' => $this->ability_meta( false, false, 'This changes Friends subscription data and may fetch remote feeds. If feed_urls is omitted, it selects discovered feeds marked autoselect, otherwise the first supported discovered feed.' ),
486 ),
487 'friends/list-feed-items' => array(
488 'label' => __( 'List Friends feed items', 'friends' ),
489 'description' => __( 'Returns cached Friends timeline items, optionally filtered by subscription, search text, or post format.', 'friends' ),
490 'category' => self::CATEGORY,
491 'input_schema' => $this->list_feed_items_input_schema(),
492 'output_schema' => $this->list_feed_items_output_schema(),
493 'execute_callback' => array( $this, 'list_feed_items' ),
494 'permission_callback' => array( $this, 'can_read' ),
495 'meta' => $this->ability_meta( true, false, 'Use this for timeline questions and summaries. Prefer subscription_id when filtering to a specific subscription.' ),
496 ),
497 'friends/refresh-feed' => array(
498 'label' => __( 'Refresh Friends feed', 'friends' ),
499 'description' => __( 'Fetches one configured Friends feed by feed_id and stores new cached posts.', 'friends' ),
500 'category' => self::CATEGORY,
501 'input_schema' => $this->refresh_feed_input_schema(),
502 'output_schema' => $this->refresh_feeds_output_schema(),
503 'execute_callback' => array( $this, 'refresh_feed' ),
504 'permission_callback' => array( $this, 'can_manage' ),
505 'meta' => $this->ability_meta( false, false, 'Use this when the user asks to refresh one specific feed. Get feed_id from get-subscription or list-subscriptions. The ability refreshes immediately by default.' ),
506 ),
507 'friends/refresh-feeds' => array(
508 'label' => __( 'Refresh Friends feeds', 'friends' ),
509 'description' => __( 'Fetches due or forced Friends feeds and stores new cached posts.', 'friends' ),
510 'category' => self::CATEGORY,
511 'input_schema' => $this->refresh_feeds_input_schema(),
512 'output_schema' => $this->refresh_feeds_output_schema(),
513 'execute_callback' => array( $this, 'refresh_feeds' ),
514 'permission_callback' => array( $this, 'can_manage' ),
515 'meta' => $this->ability_meta( false, false, 'This fetches remote feeds and can create cached friend posts. Set force to true only when the user asks to refresh now rather than just due feeds.' ),
516 ),
517 );
518 }
519
520 /**
521 * Build common ability meta.
522 *
523 * @param bool $is_readonly Whether the ability is read-only.
524 * @param bool $destructive Whether the ability can be destructive.
525 * @param string $instructions AI Assistant instructions.
526 * @return array Ability meta.
527 */
528 private function ability_meta( $is_readonly, $destructive, $instructions ) {
529 return array(
530 'annotations' => array(
531 'readonly' => (bool) $is_readonly,
532 'destructive' => (bool) $destructive,
533 'instructions' => $instructions,
534 ),
535 'show_in_rest' => true,
536 );
537 }
538
539 /**
540 * Normalize ability input.
541 *
542 * @param mixed $input Raw input.
543 * @return array Normalized input.
544 */
545 private function normalize_input( $input ) {
546 return is_array( $input ) ? $input : array();
547 }
548
549 /**
550 * Read a boolean input value.
551 *
552 * @param array $input Input data.
553 * @param string $key Input key.
554 * @param bool $default_value Default value.
555 * @return bool Boolean input.
556 */
557 private function input_bool( $input, $key, $default_value = false ) {
558 if ( ! array_key_exists( $key, $input ) ) {
559 return $default_value;
560 }
561 return rest_sanitize_boolean( $input[ $key ] );
562 }
563
564 /**
565 * Sanitize a limit.
566 *
567 * @param mixed $limit Limit input.
568 * @param int $min Minimum limit.
569 * @param int $max Maximum limit.
570 * @return int Sanitized limit.
571 */
572 private function sanitize_limit( $limit, $min, $max ) {
573 $limit = absint( $limit );
574 if ( $limit < $min ) {
575 return $min;
576 }
577 if ( $limit > $max ) {
578 return $max;
579 }
580 return $limit;
581 }
582
583 /**
584 * Normalize a URL-like input.
585 *
586 * @param string $url URL input.
587 * @return string|false Valid URL or false.
588 */
589 private function normalize_url( $url ) {
590 $url = trim( (string) $url );
591 if ( '' === $url ) {
592 return false;
593 }
594
595 if ( ! wp_parse_url( $url, PHP_URL_SCHEME ) ) {
596 $url = apply_filters( 'friends_rewrite_incoming_url', 'https://' . $url, $url );
597 }
598
599 return Friends::check_url( $url ) ? esc_url_raw( $url ) : false;
600 }
601
602 /**
603 * Sanitize a list of URLs.
604 *
605 * @param mixed $urls URL list.
606 * @return array Sanitized URLs.
607 */
608 private function sanitize_url_list( $urls ) {
609 if ( ! is_array( $urls ) ) {
610 return array();
611 }
612
613 $sanitized = array();
614 foreach ( $urls as $url ) {
615 $url = $this->normalize_url( $url );
616 if ( $url ) {
617 $sanitized[] = $url;
618 }
619 }
620 return array_values( array_unique( $sanitized ) );
621 }
622
623 /**
624 * Get a subscription from lookup input.
625 *
626 * @param array $input Ability input.
627 * @return User|\WP_Error Subscription or error.
628 */
629 private function get_subscription_from_input( $input ) {
630 if ( ! empty( $input['subscription_id'] ) ) {
631 $subscription = User::get_user_by_id( absint( $input['subscription_id'] ) );
632 } elseif ( ! empty( $input['username'] ) ) {
633 $subscription = User::get_by_username( sanitize_text_field( $input['username'] ) );
634 } else {
635 return new \WP_Error( 'missing-subscription', __( 'A subscription_id or username is required.', 'friends' ) );
636 }
637
638 if ( is_wp_error( $subscription ) ) {
639 return new \WP_Error( 'subscription-not-found', __( 'The requested subscription was not found.', 'friends' ) );
640 }
641
642 if ( ! $subscription || ! $subscription instanceof User || ! $subscription->has_cap( 'subscription' ) ) {
643 return new \WP_Error( 'subscription-not-found', __( 'The requested subscription was not found.', 'friends' ) );
644 }
645
646 return $subscription;
647 }
648
649 /**
650 * Get a feed from lookup input.
651 *
652 * @param array $input Ability input.
653 * @return User_Feed|\WP_Error Feed or error.
654 */
655 private function get_feed_from_input( $input ) {
656 if ( empty( $input['feed_id'] ) ) {
657 return new \WP_Error( 'missing-feed', __( 'A feed_id is required.', 'friends' ) );
658 }
659
660 $term = get_term( absint( $input['feed_id'] ), User_Feed::TAXONOMY );
661 if ( ! $term || is_wp_error( $term ) ) {
662 return new \WP_Error( 'feed-not-found', __( 'The requested feed was not found.', 'friends' ) );
663 }
664
665 return new User_Feed( $term );
666 }
667
668 /**
669 * Create an empty refresh result structure.
670 *
671 * @param User|null $subscription Optional subscription context.
672 * @return array Refresh result.
673 */
674 private function new_refresh_result( ?User $subscription = null ) {
675 return array(
676 'feed_count' => 0,
677 'new_post_count' => 0,
678 'new_post_ids' => array(),
679 'errors' => array(),
680 'subscription' => $subscription ? $this->subscription_to_array( $subscription, false ) : null,
681 'refreshed_feeds' => array(),
682 );
683 }
684
685 /**
686 * Refresh a feed and merge its result into a refresh result structure.
687 *
688 * @param array $result Refresh result.
689 * @param User_Feed $feed Feed to refresh.
690 * @param bool $force Whether to ignore due date and polling locks.
691 */
692 private function refresh_feed_into_result( &$result, User_Feed $feed, $force ) {
693 if ( ! $force && ! $feed->can_be_polled_now() ) {
694 return;
695 }
696
697 $feed->set_polling_now();
698 $posts = $this->friends->feed->retrieve_feed( $feed );
699 $feed->was_polled();
700
701 ++$result['feed_count'];
702 $feed_result = $this->feed_to_array( $feed );
703 if ( is_wp_error( $posts ) ) {
704 $error = array(
705 'feed_id' => (int) $feed->get_id(),
706 'url' => $feed->get_url(),
707 'code' => $posts->get_error_code(),
708 'message' => $posts->get_error_message(),
709 );
710 $result['errors'][] = $error;
711 $feed_result['error'] = $error;
712 } else {
713 $post_ids = array_map( 'intval', array_keys( $posts ) );
714 $result['new_post_ids'] = array_merge( $result['new_post_ids'], $post_ids );
715 $result['new_post_count'] += count( $post_ids );
716 $feed_result['new_post_ids'] = $post_ids;
717 }
718 $result['refreshed_feeds'][] = $feed_result;
719 }
720
721 /**
722 * Serialize a subscription.
723 *
724 * @param User $subscription Subscription user.
725 * @param bool $include_feeds Whether to include feeds.
726 * @return array Serialized subscription.
727 */
728 private function subscription_to_array( User $subscription, $include_feeds = true ) {
729 $feeds = $subscription->get_feeds();
730 $active_feed_count = 0;
731 foreach ( $feeds as $feed ) {
732 if ( $feed->is_active() ) {
733 ++$active_feed_count;
734 }
735 }
736
737 $data = array(
738 'id' => (int) $subscription->ID,
739 'username' => (string) $subscription->user_login,
740 'name' => (string) ( $subscription->display_name ? $subscription->display_name : $subscription->user_login ),
741 'url' => (string) $subscription->user_url,
742 'description' => $this->clean_text( (string) $subscription->description, 600 ),
743 'avatar_url' => (string) $subscription->get_avatar_url(),
744 'local_url' => (string) $subscription->get_local_friends_page_url(),
745 'starred' => (bool) $subscription->is_starred(),
746 'feed_count' => count( $feeds ),
747 'active_feed_count' => $active_feed_count,
748 );
749
750 if ( $subscription instanceof Subscription ) {
751 $data['term_id'] = (int) $subscription->get_term_id();
752 }
753
754 if ( $include_feeds ) {
755 $data['feeds'] = array();
756 foreach ( $feeds as $feed ) {
757 $data['feeds'][] = $this->feed_to_array( $feed );
758 }
759 }
760
761 return $data;
762 }
763
764 /**
765 * Serialize a feed.
766 *
767 * @param User_Feed $feed User feed.
768 * @return array Serialized feed.
769 */
770 private function feed_to_array( User_Feed $feed ) {
771 $friend_user = $feed->get_friend_user();
772
773 return array(
774 'id' => (int) $feed->get_id(),
775 'url' => (string) $feed->get_url(),
776 'title' => (string) $feed->get_title(),
777 'active' => (bool) $feed->is_active(),
778 'parser' => (string) $feed->get_parser(),
779 'post_format' => (string) $feed->get_post_format(),
780 'mime_type' => (string) $feed->get_mime_type(),
781 'next_poll' => (string) $feed->get_next_poll(),
782 'last_log' => (string) $feed->get_last_log(),
783 'local_html_url' => $friend_user ? (string) $friend_user->get_local_friends_page_url() : '',
784 );
785 }
786
787 /**
788 * Serialize a post.
789 *
790 * @param \WP_Post $post Post object.
791 * @return array Serialized post.
792 */
793 private function post_to_array( \WP_Post $post ) {
794 $author = User::get_post_author( $post );
795 $post_format = get_post_format( $post );
796 if ( ! $post_format ) {
797 $post_format = 'standard';
798 }
799
800 return array(
801 'id' => (int) $post->ID,
802 'title' => (string) $post->post_title,
803 'excerpt' => $this->clean_text( $post->post_excerpt ? $post->post_excerpt : $post->post_content, 500 ),
804 'content_text' => $this->clean_text( $post->post_content, 1200 ),
805 'date' => mysql2date( DATE_ATOM, $post->post_date_gmt, false ),
806 'status' => (string) $post->post_status,
807 'post_format' => (string) $post_format,
808 'local_url' => (string) get_permalink( $post ),
809 'external_url' => (string) $post->guid,
810 'reblog' => (bool) get_post_meta( $post->ID, 'reblog', true ),
811 'author' => $author ? $this->subscription_to_array( $author, false ) : null,
812 );
813 }
814
815 /**
816 * Clean and truncate text for ability output.
817 *
818 * @param string $text Text input.
819 * @param int $max_length Maximum length.
820 * @return string Clean text.
821 */
822 private function clean_text( $text, $max_length ) {
823 $text = html_entity_decode( wp_strip_all_tags( (string) $text ), ENT_QUOTES, get_bloginfo( 'charset' ) );
824 $text = trim( preg_replace( '/\s+/', ' ', $text ) );
825
826 if ( function_exists( 'mb_strlen' ) && function_exists( 'mb_substr' ) ) {
827 if ( mb_strlen( $text ) > $max_length ) {
828 return rtrim( mb_substr( $text, 0, $max_length - 3 ) ) . '...';
829 }
830 return $text;
831 }
832
833 if ( strlen( $text ) > $max_length ) {
834 return rtrim( substr( $text, 0, $max_length - 3 ) ) . '...';
835 }
836
837 return $text;
838 }
839
840 /**
841 * Convert discovered feeds to serializable arrays.
842 *
843 * @param array $feeds Discovered feeds.
844 * @return array Serialized feeds.
845 */
846 private function discovered_feeds_to_array( $feeds ) {
847 $serialized = array();
848 foreach ( $feeds as $url => $feed ) {
849 $feed = array_merge(
850 array(
851 'url' => $url,
852 'title' => '',
853 'type' => '',
854 'rel' => '',
855 'parser' => '',
856 'parser_confidence' => 0,
857 'autoselect' => false,
858 'post-format' => '',
859 'avatar' => '',
860 'description' => '',
861 ),
862 (array) $feed
863 );
864
865 $serialized[] = array(
866 'url' => (string) $feed['url'],
867 'title' => (string) $feed['title'],
868 'type' => (string) $feed['type'],
869 'rel' => (string) $feed['rel'],
870 'parser' => (string) $feed['parser'],
871 'parser_confidence' => (int) $feed['parser_confidence'],
872 'autoselect' => (bool) $feed['autoselect'],
873 'post_format' => (string) $feed['post-format'],
874 'avatar' => (string) $feed['avatar'],
875 'description' => $this->clean_text( (string) $feed['description'], 600 ),
876 );
877 }
878
879 return $serialized;
880 }
881
882 /**
883 * Select feed URLs from discovered feeds.
884 *
885 * @param array $feeds Discovered feeds.
886 * @return array Selected feed URLs.
887 */
888 private function select_discovered_feed_urls( $feeds ) {
889 $selected = array();
890 foreach ( $feeds as $url => $feed ) {
891 if ( ! empty( $feed['autoselect'] ) && ! empty( $feed['parser'] ) && 'unsupported' !== $feed['parser'] ) {
892 $selected[] = $url;
893 }
894 }
895
896 if ( ! empty( $selected ) ) {
897 return $selected;
898 }
899
900 foreach ( $feeds as $url => $feed ) {
901 if ( ! empty( $feed['parser'] ) && 'unsupported' !== $feed['parser'] ) {
902 return array( $url );
903 }
904 }
905
906 return array();
907 }
908
909 /**
910 * Create discovered-feed-shaped records from URLs.
911 *
912 * @param array $urls Feed URLs.
913 * @return array Feed records.
914 */
915 private function feeds_from_urls( $urls ) {
916 $feeds = array();
917 foreach ( $urls as $url ) {
918 $feeds[ $url ] = array(
919 'url' => $url,
920 'title' => Friends::url_truncate( $url, 100 ),
921 'type' => 'application/rss+xml',
922 'rel' => 'alternate',
923 'parser' => 'simplepie',
924 'parser_confidence' => 0,
925 );
926 }
927 return $feeds;
928 }
929
930 /**
931 * Build feed options for subscription storage.
932 *
933 * @param array $selected_urls Selected URLs.
934 * @param array $discovered_feeds Discovered feeds.
935 * @param string $display_name Subscription display name.
936 * @return array Feed options.
937 */
938 private function build_feed_options( $selected_urls, $discovered_feeds, $display_name ) {
939 $options = array();
940 foreach ( $selected_urls as $feed_url ) {
941 if ( empty( $discovered_feeds[ $feed_url ] ) ) {
942 $discovered_feeds[ $feed_url ] = $this->feeds_from_urls( array( $feed_url ) )[ $feed_url ];
943 }
944
945 $feed = $discovered_feeds[ $feed_url ];
946 if ( empty( $feed['parser'] ) || 'unsupported' === $feed['parser'] ) {
947 continue;
948 }
949
950 if ( isset( $feed['type'] ) ) {
951 $feed['mime-type'] = $feed['type'];
952 unset( $feed['type'] );
953 }
954
955 $options[ $feed_url ] = array(
956 'active' => true,
957 'parser' => sanitize_key( $feed['parser'] ),
958 'post-format' => isset( $feed['post-format'] ) ? sanitize_key( $feed['post-format'] ) : 'standard',
959 'mime-type' => isset( $feed['mime-type'] ) ? sanitize_text_field( $feed['mime-type'] ) : 'application/rss+xml',
960 'title' => isset( $feed['title'] ) && $feed['title'] ? sanitize_text_field( $feed['title'] ) : sprintf(
961 // translators: %s is a subscription display name.
962 __( '%s RSS Feed', 'friends' ),
963 $display_name
964 ),
965 );
966
967 foreach ( array( 'avatar', 'description' ) as $key ) {
968 if ( ! empty( $feed[ $key ] ) ) {
969 $options[ $feed_url ][ $key ] = sanitize_text_field( $feed[ $key ] );
970 }
971 }
972 }
973
974 return $options;
975 }
976
977 /**
978 * Return the first non-empty value from feed options.
979 *
980 * @param array $feed_options Feed options.
981 * @param string $key Option key.
982 * @return string|null First value.
983 */
984 private function first_feed_value( $feed_options, $key ) {
985 foreach ( $feed_options as $feed ) {
986 if ( ! empty( $feed[ $key ] ) ) {
987 return $feed[ $key ];
988 }
989 }
990 return null;
991 }
992
993 /**
994 * Schema for a URL input.
995 *
996 * @return array Schema.
997 */
998 private function url_input_schema() {
999 return array(
1000 'type' => 'object',
1001 'properties' => array(
1002 'url' => array(
1003 'type' => 'string',
1004 'description' => __( 'Website, profile, or feed URL to inspect.', 'friends' ),
1005 'format' => 'uri',
1006 ),
1007 ),
1008 'required' => array( 'url' ),
1009 'additionalProperties' => false,
1010 );
1011 }
1012
1013 /**
1014 * Schema for subscription lookup input.
1015 *
1016 * @return array Schema.
1017 */
1018 private function subscription_lookup_input_schema() {
1019 return array(
1020 'type' => 'object',
1021 'properties' => array(
1022 'subscription_id' => array(
1023 'type' => 'integer',
1024 'description' => __( 'Subscription ID returned by friends/list-subscriptions.', 'friends' ),
1025 ),
1026 'username' => array(
1027 'type' => 'string',
1028 'description' => __( 'Subscription username, for example example.com.', 'friends' ),
1029 ),
1030 ),
1031 'additionalProperties' => false,
1032 );
1033 }
1034
1035 /**
1036 * Schema for listing subscriptions.
1037 *
1038 * @return array Schema.
1039 */
1040 private function list_subscriptions_input_schema() {
1041 return array(
1042 'type' => array( 'object', 'null' ),
1043 'properties' => array(
1044 'search' => array(
1045 'type' => 'string',
1046 'description' => __( 'Optional search text for subscription username or display name.', 'friends' ),
1047 ),
1048 'limit' => array(
1049 'type' => 'integer',
1050 'description' => __( 'Maximum number of subscriptions to return, from 1 to 100.', 'friends' ),
1051 'default' => 20,
1052 ),
1053 'include_feeds' => array(
1054 'type' => 'boolean',
1055 'description' => __( 'Whether to include each subscription\'s configured feeds.', 'friends' ),
1056 'default' => true,
1057 ),
1058 ),
1059 'additionalProperties' => false,
1060 );
1061 }
1062
1063 /**
1064 * Schema for adding a subscription.
1065 *
1066 * @return array Schema.
1067 */
1068 private function add_subscription_input_schema() {
1069 return array(
1070 'type' => 'object',
1071 'properties' => array(
1072 'url' => array(
1073 'type' => 'string',
1074 'description' => __( 'Website, profile, or feed URL to subscribe to.', 'friends' ),
1075 'format' => 'uri',
1076 ),
1077 'feed_urls' => array(
1078 'type' => 'array',
1079 'description' => __( 'Optional exact feed URLs to activate. If omitted, Friends auto-selects discovered feeds.', 'friends' ),
1080 'items' => array(
1081 'type' => 'string',
1082 'format' => 'uri',
1083 ),
1084 ),
1085 'username' => array(
1086 'type' => 'string',
1087 'description' => __( 'Optional Friends username. Defaults to a value inferred from the URL or discovered feed metadata.', 'friends' ),
1088 ),
1089 'display_name' => array(
1090 'type' => 'string',
1091 'description' => __( 'Optional display name. Defaults to a value inferred from the URL or discovered feed metadata.', 'friends' ),
1092 ),
1093 'refresh' => array(
1094 'type' => 'boolean',
1095 'description' => __( 'Whether to fetch the activated feeds immediately after subscribing.', 'friends' ),
1096 'default' => true,
1097 ),
1098 ),
1099 'required' => array( 'url' ),
1100 'additionalProperties' => false,
1101 );
1102 }
1103
1104 /**
1105 * Schema for listing feed items.
1106 *
1107 * @return array Schema.
1108 */
1109 private function list_feed_items_input_schema() {
1110 return array(
1111 'type' => array( 'object', 'null' ),
1112 'properties' => array(
1113 'subscription_id' => array(
1114 'type' => 'integer',
1115 'description' => __( 'Optional subscription ID to filter by.', 'friends' ),
1116 ),
1117 'username' => array(
1118 'type' => 'string',
1119 'description' => __( 'Optional subscription username to filter by.', 'friends' ),
1120 ),
1121 'search' => array(
1122 'type' => 'string',
1123 'description' => __( 'Optional search text for cached feed items.', 'friends' ),
1124 ),
1125 'post_format' => array(
1126 'type' => 'string',
1127 'description' => __( 'Optional WordPress post format slug, such as status, image, link, or standard.', 'friends' ),
1128 ),
1129 'limit' => array(
1130 'type' => 'integer',
1131 'description' => __( 'Maximum number of feed items to return, from 1 to 50.', 'friends' ),
1132 'default' => 20,
1133 ),
1134 ),
1135 'additionalProperties' => false,
1136 );
1137 }
1138
1139 /**
1140 * Schema for refreshing feeds.
1141 *
1142 * @return array Schema.
1143 */
1144 private function refresh_feeds_input_schema() {
1145 return array(
1146 'type' => array( 'object', 'null' ),
1147 'properties' => array(
1148 'subscription_id' => array(
1149 'type' => 'integer',
1150 'description' => __( 'Optional subscription ID to refresh. Omit to refresh all due feeds.', 'friends' ),
1151 ),
1152 'username' => array(
1153 'type' => 'string',
1154 'description' => __( 'Optional subscription username to refresh. Omit to refresh all due feeds.', 'friends' ),
1155 ),
1156 'force' => array(
1157 'type' => 'boolean',
1158 'description' => __( 'Whether to refresh active feeds immediately even if they are not due.', 'friends' ),
1159 'default' => false,
1160 ),
1161 ),
1162 'additionalProperties' => false,
1163 );
1164 }
1165
1166 /**
1167 * Schema for refreshing one feed.
1168 *
1169 * @return array Schema.
1170 */
1171 private function refresh_feed_input_schema() {
1172 return array(
1173 'type' => 'object',
1174 'properties' => array(
1175 'feed_id' => array(
1176 'type' => 'integer',
1177 'description' => __( 'Feed ID returned by friends/get-subscription or friends/list-subscriptions.', 'friends' ),
1178 ),
1179 'force' => array(
1180 'type' => 'boolean',
1181 'description' => __( 'Whether to refresh immediately even if the feed is not due.', 'friends' ),
1182 'default' => true,
1183 ),
1184 ),
1185 'required' => array( 'feed_id' ),
1186 'additionalProperties' => false,
1187 );
1188 }
1189
1190 /**
1191 * Output schema for subscription list.
1192 *
1193 * @return array Schema.
1194 */
1195 private function list_subscriptions_output_schema() {
1196 return array(
1197 'type' => 'object',
1198 'properties' => array(
1199 'count' => array( 'type' => 'integer' ),
1200 'total' => array( 'type' => 'integer' ),
1201 'subscriptions' => array(
1202 'type' => 'array',
1203 'items' => $this->subscription_output_schema(),
1204 ),
1205 ),
1206 );
1207 }
1208
1209 /**
1210 * Output schema for a subscription.
1211 *
1212 * @return array Schema.
1213 */
1214 private function subscription_output_schema() {
1215 return array(
1216 'type' => 'object',
1217 'properties' => array(
1218 'id' => array(
1219 'type' => 'integer',
1220 'description' => __( 'Use as subscription_id in related Friends abilities.', 'friends' ),
1221 ),
1222 'term_id' => array( 'type' => 'integer' ),
1223 'username' => array( 'type' => 'string' ),
1224 'name' => array( 'type' => 'string' ),
1225 'url' => array( 'type' => 'string' ),
1226 'description' => array( 'type' => 'string' ),
1227 'avatar_url' => array( 'type' => 'string' ),
1228 'local_url' => array( 'type' => 'string' ),
1229 'starred' => array( 'type' => 'boolean' ),
1230 'feed_count' => array( 'type' => 'integer' ),
1231 'active_feed_count' => array( 'type' => 'integer' ),
1232 'feeds' => array(
1233 'type' => 'array',
1234 'items' => $this->feed_output_schema(),
1235 ),
1236 ),
1237 );
1238 }
1239
1240 /**
1241 * Output schema for a feed.
1242 *
1243 * @return array Schema.
1244 */
1245 private function feed_output_schema() {
1246 return array(
1247 'type' => 'object',
1248 'properties' => array(
1249 'id' => array( 'type' => 'integer' ),
1250 'url' => array( 'type' => 'string' ),
1251 'title' => array( 'type' => 'string' ),
1252 'active' => array( 'type' => 'boolean' ),
1253 'parser' => array( 'type' => 'string' ),
1254 'post_format' => array( 'type' => 'string' ),
1255 'mime_type' => array( 'type' => 'string' ),
1256 'next_poll' => array( 'type' => 'string' ),
1257 'last_log' => array( 'type' => 'string' ),
1258 'local_html_url' => array( 'type' => 'string' ),
1259 'new_post_ids' => array(
1260 'type' => 'array',
1261 'items' => array( 'type' => 'integer' ),
1262 ),
1263 'error' => array(
1264 'type' => 'object',
1265 'properties' => array(
1266 'feed_id' => array( 'type' => 'integer' ),
1267 'url' => array( 'type' => 'string' ),
1268 'code' => array( 'type' => 'string' ),
1269 'message' => array( 'type' => 'string' ),
1270 ),
1271 ),
1272 ),
1273 );
1274 }
1275
1276 /**
1277 * Mark a schema as nullable.
1278 *
1279 * @param array $schema Schema.
1280 * @return array Nullable schema.
1281 */
1282 private function nullable_schema( $schema ) {
1283 $type = isset( $schema['type'] ) ? $schema['type'] : 'object';
1284 if ( ! is_array( $type ) ) {
1285 $type = array( $type );
1286 }
1287 if ( ! in_array( 'null', $type, true ) ) {
1288 $type[] = 'null';
1289 }
1290 $schema['type'] = $type;
1291 return $schema;
1292 }
1293
1294 /**
1295 * Output schema for discovered feeds.
1296 *
1297 * @return array Schema.
1298 */
1299 private function discover_feeds_output_schema() {
1300 return array(
1301 'type' => 'object',
1302 'properties' => array(
1303 'url' => array( 'type' => 'string' ),
1304 'count' => array( 'type' => 'integer' ),
1305 'feeds' => array(
1306 'type' => 'array',
1307 'items' => array(
1308 'type' => 'object',
1309 'properties' => array(
1310 'url' => array( 'type' => 'string' ),
1311 'title' => array( 'type' => 'string' ),
1312 'type' => array( 'type' => 'string' ),
1313 'rel' => array( 'type' => 'string' ),
1314 'parser' => array( 'type' => 'string' ),
1315 'parser_confidence' => array( 'type' => 'integer' ),
1316 'autoselect' => array( 'type' => 'boolean' ),
1317 'post_format' => array( 'type' => 'string' ),
1318 'avatar' => array( 'type' => 'string' ),
1319 'description' => array( 'type' => 'string' ),
1320 ),
1321 ),
1322 ),
1323 ),
1324 );
1325 }
1326
1327 /**
1328 * Output schema for add subscription.
1329 *
1330 * @return array Schema.
1331 */
1332 private function add_subscription_output_schema() {
1333 return array(
1334 'type' => 'object',
1335 'properties' => array(
1336 'subscription' => $this->subscription_output_schema(),
1337 'activated_feeds' => array(
1338 'type' => 'array',
1339 'items' => $this->feed_output_schema(),
1340 ),
1341 'refresh' => $this->refresh_feeds_output_schema(),
1342 ),
1343 );
1344 }
1345
1346 /**
1347 * Output schema for feed item list.
1348 *
1349 * @return array Schema.
1350 */
1351 private function list_feed_items_output_schema() {
1352 return array(
1353 'type' => 'object',
1354 'properties' => array(
1355 'count' => array( 'type' => 'integer' ),
1356 'subscription' => $this->nullable_schema( $this->subscription_output_schema() ),
1357 'items' => array(
1358 'type' => 'array',
1359 'items' => array(
1360 'type' => 'object',
1361 'properties' => array(
1362 'id' => array( 'type' => 'integer' ),
1363 'title' => array( 'type' => 'string' ),
1364 'excerpt' => array( 'type' => 'string' ),
1365 'content_text' => array( 'type' => 'string' ),
1366 'date' => array( 'type' => 'string' ),
1367 'status' => array( 'type' => 'string' ),
1368 'post_format' => array( 'type' => 'string' ),
1369 'local_url' => array( 'type' => 'string' ),
1370 'external_url' => array( 'type' => 'string' ),
1371 'reblog' => array( 'type' => 'boolean' ),
1372 'author' => $this->nullable_schema( $this->subscription_output_schema() ),
1373 ),
1374 ),
1375 ),
1376 ),
1377 );
1378 }
1379
1380 /**
1381 * Output schema for refresh feeds.
1382 *
1383 * @return array Schema.
1384 */
1385 private function refresh_feeds_output_schema() {
1386 return array(
1387 'type' => 'object',
1388 'properties' => array(
1389 'feed_count' => array( 'type' => 'integer' ),
1390 'new_post_count' => array( 'type' => 'integer' ),
1391 'new_post_ids' => array(
1392 'type' => 'array',
1393 'items' => array( 'type' => 'integer' ),
1394 ),
1395 'errors' => array(
1396 'type' => 'array',
1397 'items' => array(
1398 'type' => 'object',
1399 'properties' => array(
1400 'feed_id' => array( 'type' => 'integer' ),
1401 'url' => array( 'type' => 'string' ),
1402 'code' => array( 'type' => 'string' ),
1403 'message' => array( 'type' => 'string' ),
1404 ),
1405 ),
1406 ),
1407 'subscription' => $this->nullable_schema( $this->subscription_output_schema() ),
1408 'refreshed_feeds' => array(
1409 'type' => 'array',
1410 'items' => $this->feed_output_schema(),
1411 ),
1412 ),
1413 );
1414 }
1415 }
1416