Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.03% covered (success)
92.03%
127 / 138
71.43% covered (warning)
71.43%
5 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
Related_Posts_Abilities
92.03% covered (success)
92.03%
127 / 138
71.43% covered (warning)
71.43%
5 / 7
34.59
0.00% covered (danger)
0.00%
0 / 1
 get_category_slug
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_category_definition
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 get_abilities
100.00% covered (success)
100.00%
76 / 76
100.00% covered (success)
100.00%
1 / 1
1
 can_view_related_posts
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_related_posts
76.74% covered (warning)
76.74%
33 / 43
0.00% covered (danger)
0.00%
0 / 1
23.54
 related_posts_instance
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 summarize_related_post
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
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
10namespace Automattic\Jetpack\Plugin\Abilities;
11
12use Automattic\Jetpack\WP_Abilities\Registrar;
13use Jetpack_RelatedPosts;
14use 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 */
26class 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}