Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
92.03% |
127 / 138 |
|
71.43% |
5 / 7 |
CRAP | |
0.00% |
0 / 1 |
| Related_Posts_Abilities | |
92.03% |
127 / 138 |
|
71.43% |
5 / 7 |
34.59 | |
0.00% |
0 / 1 |
| get_category_slug | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| get_category_definition | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| get_abilities | |
100.00% |
76 / 76 |
|
100.00% |
1 / 1 |
1 | |||
| can_view_related_posts | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| get_related_posts | |
76.74% |
33 / 43 |
|
0.00% |
0 / 1 |
23.54 | |||
| related_posts_instance | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
2.15 | |||
| summarize_related_post | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
9 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * Jetpack Related Posts Abilities Registration |
| 4 | * |
| 5 | * Registers Jetpack Related Posts abilities with the WordPress Abilities API. |
| 6 | * |
| 7 | * @package automattic/jetpack |
| 8 | */ |
| 9 | |
| 10 | namespace Automattic\Jetpack\Plugin\Abilities; |
| 11 | |
| 12 | use Automattic\Jetpack\WP_Abilities\Registrar; |
| 13 | use Jetpack_RelatedPosts; |
| 14 | use WP_Error; |
| 15 | |
| 16 | /** |
| 17 | * Registers Jetpack Related Posts abilities with the WordPress Abilities API. |
| 18 | * |
| 19 | * Exposes related-post lookups through the standard `wp-abilities/v1` REST |
| 20 | * surface. Display-settings management is intentionally not exposed: classic |
| 21 | * themes consume the `relatedposts` option only when an off-by-default filter |
| 22 | * is enabled, and block themes ignore it altogether (rendering is controlled |
| 23 | * per-instance by the Jetpack Related Posts block in templates) — so an agent |
| 24 | * editing those values would be writing data nothing reads. |
| 25 | */ |
| 26 | class Related_Posts_Abilities extends Registrar { |
| 27 | |
| 28 | // Mirrors the cap the upstream Related Posts ES query enforces; raising it |
| 29 | // here would be silently truncated downstream. |
| 30 | private const MAX_SIZE = 20; |
| 31 | |
| 32 | private const DEFAULT_SIZE = 3; |
| 33 | |
| 34 | /** |
| 35 | * Returns the category slug this registrar owns. |
| 36 | */ |
| 37 | public static function get_category_slug(): string { |
| 38 | return 'jetpack-related-posts'; |
| 39 | } |
| 40 | |
| 41 | /** |
| 42 | * Returns the category definition passed to wp_register_ability_category(). |
| 43 | */ |
| 44 | public static function get_category_definition(): array { |
| 45 | return array( |
| 46 | // "Jetpack" is a product name and should not be translated. |
| 47 | 'label' => 'Jetpack Related Posts', |
| 48 | 'description' => __( 'Abilities for reading related posts.', 'jetpack' ), |
| 49 | ); |
| 50 | } |
| 51 | |
| 52 | /** |
| 53 | * Returns the abilities this registrar owns as a [ slug => spec ] map. |
| 54 | */ |
| 55 | public static function get_abilities(): array { |
| 56 | $related_post_schema = array( |
| 57 | 'type' => 'object', |
| 58 | 'properties' => array( |
| 59 | 'id' => array( 'type' => 'integer' ), |
| 60 | 'url' => array( 'type' => 'string' ), |
| 61 | 'title' => array( 'type' => 'string' ), |
| 62 | 'excerpt' => array( 'type' => 'string' ), |
| 63 | 'date' => array( 'type' => 'string' ), |
| 64 | 'post_type' => array( 'type' => 'string' ), |
| 65 | 'format' => array( 'type' => array( 'string', 'null' ) ), |
| 66 | ), |
| 67 | ); |
| 68 | |
| 69 | return array( |
| 70 | 'jetpack-related-posts/get-related-posts' => array( |
| 71 | 'label' => __( 'Get related posts', 'jetpack' ), |
| 72 | 'description' => __( 'Return related posts for a single post as an array of { id, url, title, excerpt, date, post_type, format }. The caller must be able to edit the source post (edit_post capability); unauthorized requests return jetpack_related_posts_forbidden. Backed by Elasticsearch via the Jetpack connection: when Related Posts is disabled, the post is unknown, or the ES backend is unreachable, the array is empty (not an error). Use per_page to control the result count (1..20, default 20); the underlying Elasticsearch query is hard-capped at 20, so values above 20 are rejected by the input schema and pagination beyond the first 20 results is not supported. The legacy "size" alias is accepted for backward compatibility and defaults to 3 when no per_page is supplied. Read-only and idempotent. Use jetpack-modules/get-modules to confirm the related-posts module is active.', 'jetpack' ), |
| 73 | 'input_schema' => array( |
| 74 | 'type' => 'object', |
| 75 | 'required' => array( 'post_id' ), |
| 76 | 'properties' => array( |
| 77 | 'post_id' => array( |
| 78 | 'type' => 'integer', |
| 79 | 'description' => __( 'WordPress post ID to find related posts for. Must reference an existing post.', 'jetpack' ), |
| 80 | 'minimum' => 1, |
| 81 | ), |
| 82 | 'per_page' => array( |
| 83 | 'type' => 'integer', |
| 84 | 'description' => __( 'Maximum number of related posts to return per call. Must be between 1 and 20 — the Elasticsearch backend hard-caps results at 20, so larger values are not supported and pagination past the first 20 results is unavailable. Defaults to 20.', 'jetpack' ), |
| 85 | 'minimum' => 1, |
| 86 | 'maximum' => self::MAX_SIZE, |
| 87 | 'default' => self::MAX_SIZE, |
| 88 | ), |
| 89 | 'size' => array( |
| 90 | 'type' => 'integer', |
| 91 | 'description' => __( 'Deprecated alias for per_page. Defaults to 3 when per_page is omitted. Capped at 20.', 'jetpack' ), |
| 92 | 'minimum' => 1, |
| 93 | 'maximum' => self::MAX_SIZE, |
| 94 | 'default' => self::DEFAULT_SIZE, |
| 95 | ), |
| 96 | 'post_type' => array( |
| 97 | 'type' => 'string', |
| 98 | 'description' => __( 'Restrict matches to a single post type slug (e.g. "post", "page"). Defaults to the source post\'s type.', 'jetpack' ), |
| 99 | 'minLength' => 1, |
| 100 | ), |
| 101 | 'exclude_post_ids' => array( |
| 102 | 'type' => 'array', |
| 103 | 'description' => __( 'Post IDs to exclude from the result.', 'jetpack' ), |
| 104 | 'items' => array( |
| 105 | 'type' => 'integer', |
| 106 | 'minimum' => 1, |
| 107 | ), |
| 108 | 'default' => array(), |
| 109 | ), |
| 110 | ), |
| 111 | 'additionalProperties' => false, |
| 112 | ), |
| 113 | 'output_schema' => array( |
| 114 | 'type' => 'array', |
| 115 | 'items' => $related_post_schema, |
| 116 | ), |
| 117 | 'execute_callback' => array( __CLASS__, 'get_related_posts' ), |
| 118 | 'permission_callback' => array( __CLASS__, 'can_view_related_posts' ), |
| 119 | 'meta' => array( |
| 120 | 'annotations' => array( |
| 121 | 'readonly' => true, |
| 122 | 'destructive' => false, |
| 123 | 'idempotent' => true, |
| 124 | ), |
| 125 | 'show_in_rest' => true, |
| 126 | 'mcp' => array( |
| 127 | 'public' => true, |
| 128 | 'type' => 'tool', // default is already "tool", but can be explicit. |
| 129 | ), |
| 130 | ), |
| 131 | ), |
| 132 | ); |
| 133 | } |
| 134 | |
| 135 | /** |
| 136 | * Permission gate for the ability menu. |
| 137 | * |
| 138 | * Returns true if the caller can edit any post — keeps the ability listed |
| 139 | * for agents that have at least one editable post. The actual per-post |
| 140 | * authorization runs inside `get_related_posts()` once the source post_id |
| 141 | * is known, mirroring the existing /wpcom/v2/related-posts/{id} endpoint. |
| 142 | */ |
| 143 | public static function can_view_related_posts(): bool { |
| 144 | return current_user_can( 'edit_posts' ); |
| 145 | } |
| 146 | |
| 147 | /** |
| 148 | * Execute: return related posts for a given post. |
| 149 | * |
| 150 | * @param array|null $input Input matching the ability's input_schema. |
| 151 | * @return array|WP_Error Array of related-post summaries, or WP_Error on validation failure. |
| 152 | */ |
| 153 | public static function get_related_posts( $input = null ) { |
| 154 | $input = is_array( $input ) ? $input : array(); |
| 155 | |
| 156 | $post_id = isset( $input['post_id'] ) ? (int) $input['post_id'] : 0; |
| 157 | if ( $post_id <= 0 ) { |
| 158 | return new WP_Error( |
| 159 | 'jetpack_related_posts_missing_post_id', |
| 160 | __( 'A post_id is required to fetch related posts.', 'jetpack' ) |
| 161 | ); |
| 162 | } |
| 163 | |
| 164 | $post = get_post( $post_id ); |
| 165 | if ( null === $post || empty( $post->ID ) ) { |
| 166 | return new WP_Error( |
| 167 | 'jetpack_related_posts_invalid_post_id', |
| 168 | __( 'Unknown post ID. Verify the post exists and is accessible.', 'jetpack' ) |
| 169 | ); |
| 170 | } |
| 171 | |
| 172 | // Match the per-post gate the existing /wpcom/v2/related-posts/{id} |
| 173 | // endpoint uses: the broad `edit_posts` cap on permission_callback lets |
| 174 | // the ability appear in the agent menu, but the actual lookup is |
| 175 | // authorized only when the caller can edit this specific post. |
| 176 | if ( ! current_user_can( 'edit_post', $post->ID ) ) { |
| 177 | return new WP_Error( |
| 178 | 'jetpack_related_posts_forbidden', |
| 179 | __( 'You are not allowed to fetch related posts for this post.', 'jetpack' ) |
| 180 | ); |
| 181 | } |
| 182 | |
| 183 | if ( isset( $input['per_page'] ) && is_int( $input['per_page'] ) ) { |
| 184 | // per_page wins when both are supplied; schema enforces the 1..MAX_SIZE |
| 185 | // range, the clamp here is defense in depth for direct callers that |
| 186 | // bypass schema validation. |
| 187 | $size = max( 1, min( self::MAX_SIZE, $input['per_page'] ) ); |
| 188 | } elseif ( isset( $input['size'] ) && is_int( $input['size'] ) ) { |
| 189 | $size = max( 1, min( self::MAX_SIZE, $input['size'] ) ); |
| 190 | } else { |
| 191 | $size = self::DEFAULT_SIZE; |
| 192 | } |
| 193 | |
| 194 | $args = array( 'size' => $size ); |
| 195 | |
| 196 | if ( isset( $input['post_type'] ) && is_string( $input['post_type'] ) && '' !== $input['post_type'] ) { |
| 197 | $args['post_type'] = $input['post_type']; |
| 198 | } |
| 199 | |
| 200 | if ( isset( $input['exclude_post_ids'] ) && is_array( $input['exclude_post_ids'] ) ) { |
| 201 | $args['exclude_post_ids'] = array_values( |
| 202 | array_filter( |
| 203 | array_map( 'intval', $input['exclude_post_ids'] ), |
| 204 | static function ( $id ) { |
| 205 | return $id > 0; |
| 206 | } |
| 207 | ) |
| 208 | ); |
| 209 | } |
| 210 | |
| 211 | $results = self::related_posts_instance()->get_for_post_id( $post->ID, $args ); |
| 212 | if ( ! is_array( $results ) || array() === $results ) { |
| 213 | return array(); |
| 214 | } |
| 215 | |
| 216 | // Prime the post cache once so `get_post_type()` inside summarize_related_post |
| 217 | // doesn't trigger N individual lookups when the cache is cold. |
| 218 | _prime_post_caches( wp_list_pluck( $results, 'id' ), false, false ); |
| 219 | |
| 220 | $out = array(); |
| 221 | foreach ( $results as $related ) { |
| 222 | $out[] = self::summarize_related_post( $related ); |
| 223 | } |
| 224 | return $out; |
| 225 | } |
| 226 | |
| 227 | /** |
| 228 | * Returns the Jetpack Related Posts raw instance, loading the class file lazily |
| 229 | * when it has not been included by the module's own load action yet. |
| 230 | * |
| 231 | * @return \Jetpack_RelatedPosts |
| 232 | */ |
| 233 | private static function related_posts_instance() { |
| 234 | if ( ! class_exists( Jetpack_RelatedPosts::class, false ) ) { |
| 235 | require_once __DIR__ . '/../jetpack-related-posts.php'; |
| 236 | } |
| 237 | return Jetpack_RelatedPosts::init_raw(); |
| 238 | } |
| 239 | |
| 240 | /** |
| 241 | * Reduce the rich Related Posts result to a high-signal summary. |
| 242 | * |
| 243 | * @param array $related Single related-post entry from get_for_post_id(). |
| 244 | * @return array |
| 245 | */ |
| 246 | private static function summarize_related_post( array $related ): array { |
| 247 | $id = isset( $related['id'] ) ? (int) $related['id'] : 0; |
| 248 | |
| 249 | return array( |
| 250 | 'id' => $id, |
| 251 | 'url' => isset( $related['url'] ) ? (string) $related['url'] : '', |
| 252 | 'title' => isset( $related['title'] ) ? (string) $related['title'] : '', |
| 253 | 'excerpt' => isset( $related['excerpt'] ) ? (string) $related['excerpt'] : '', |
| 254 | 'date' => isset( $related['date'] ) ? (string) $related['date'] : '', |
| 255 | 'post_type' => $id > 0 ? (string) get_post_type( $id ) : '', |
| 256 | 'format' => isset( $related['format'] ) && '' !== $related['format'] ? (string) $related['format'] : null, |
| 257 | ); |
| 258 | } |
| 259 | } |