Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 225
0.00% covered (danger)
0.00%
0 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
Posts_To_Podcast_Endpoint
0.00% covered (danger)
0.00%
0 / 225
0.00% covered (danger)
0.00%
0 / 8
870
0.00% covered (danger)
0.00%
0 / 1
 init
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 get_post_publish_promo_dismiss_rest_path
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 register_routes
0.00% covered (danger)
0.00%
0 / 101
0.00% covered (danger)
0.00%
0 / 1
2
 read_episodes
0.00% covered (danger)
0.00%
0 / 51
0.00% covered (danger)
0.00%
0 / 1
132
 dismiss_post_publish_promo
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 read_feature_info
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
6
 enqueue_generation
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
90
 read_job_status
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2/**
3 * Local Jetpack-side REST endpoint for the Posts to Podcast feature.
4 *
5 * @package automattic/jetpack-podcast
6 */
7
8namespace Automattic\Jetpack\Podcast;
9
10use Automattic\Jetpack\Connection\Client;
11use Jetpack_Options;
12use WP_Error;
13use WP_REST_Controller;
14use WP_REST_Request;
15use WP_REST_Response;
16use WP_REST_Server;
17
18/**
19 * Forwards `wp.apiFetch` calls from the wp-admin Create tab to the wpcom-side
20 * endpoint as the current user (the upstream endpoint requires user identity).
21 */
22class Posts_To_Podcast_Endpoint extends WP_REST_Controller {
23
24    use Relay_Response;
25
26    const SUPPORTED_LENGTHS                     = array( 'short', 'medium', 'long' );
27    const SUPPORTED_VOICE_PRESETS               = array( 'witty', 'earnest', 'professional' );
28    const REST_NAMESPACE                        = 'wpcom/v2';
29    const REST_BASE                             = 'posts-to-podcast';
30    const POST_PUBLISH_PROMO_DISMISS_REST_ROUTE = 'post-publish-promo/dismiss';
31
32    /**
33     * Whether `init()` has wired its hooks.
34     *
35     * @var bool
36     */
37    private static $initialized = false;
38
39    /**
40     * Wire up routes. Idempotent.
41     */
42    public static function init() {
43        if ( self::$initialized ) {
44            return;
45        }
46        self::$initialized = true;
47
48        $instance = new self();
49        add_action( 'rest_api_init', array( $instance, 'register_routes' ) );
50    }
51
52    /**
53     * Get the REST API path used by apiFetch for post-publish promo dismissal.
54     *
55     * @return string
56     */
57    public static function get_post_publish_promo_dismiss_rest_path() {
58        return '/' . self::REST_NAMESPACE . '/' . self::REST_BASE . '/' . self::POST_PUBLISH_PROMO_DISMISS_REST_ROUTE;
59    }
60
61    /**
62     * Register feature info, enqueue, job-status, and promo dismissal routes.
63     */
64    public function register_routes() {
65        $this->namespace = self::REST_NAMESPACE;
66        $this->rest_base = self::REST_BASE;
67
68        register_rest_route(
69            $this->namespace,
70            $this->rest_base,
71            array(
72                array(
73                    'methods'             => WP_REST_Server::READABLE,
74                    'callback'            => array( $this, 'read_feature_info' ),
75                    'permission_callback' => array( Posts_To_Podcast_Helper::class, 'get_status_permission_check' ),
76                ),
77                array(
78                    'methods'             => WP_REST_Server::CREATABLE,
79                    'callback'            => array( $this, 'enqueue_generation' ),
80                    'permission_callback' => array( Posts_To_Podcast_Helper::class, 'get_status_permission_check' ),
81                    'args'                => array(
82                        'window'      => array(
83                            'type'        => 'object',
84                            'required'    => false,
85                            'description' => __( 'Either { unit: days|weeks|months, n: <positive int> } or { from, to } as ISO-8601 dates. Required when postIds is omitted.', 'jetpack-podcast' ),
86                        ),
87                        'postIds'     => array(
88                            'type'        => 'array',
89                            'required'    => false,
90                            'items'       => array( 'type' => 'integer' ),
91                            'maxItems'    => 25,
92                            'description' => __( 'Explicit list of published post IDs to draw from (up to 25). Required when window is omitted.', 'jetpack-podcast' ),
93                        ),
94                        'length'      => array(
95                            'type'        => 'string',
96                            'required'    => true,
97                            'enum'        => self::SUPPORTED_LENGTHS,
98                            'description' => __( 'Length preset id.', 'jetpack-podcast' ),
99                        ),
100                        'voicePreset' => array(
101                            'type'        => 'string',
102                            'required'    => true,
103                            'enum'        => self::SUPPORTED_VOICE_PRESETS,
104                            'description' => __( 'Voice preset id.', 'jetpack-podcast' ),
105                        ),
106                        'prompt'      => array(
107                            'type'        => 'string',
108                            'required'    => false,
109                            'description' => __( 'Optional free-form instructions appended to the generation prompt.', 'jetpack-podcast' ),
110                        ),
111                    ),
112                ),
113            )
114        );
115
116        register_rest_route(
117            $this->namespace,
118            $this->rest_base . '/jobs/(?P<job_id>\d+)',
119            array(
120                array(
121                    'methods'             => WP_REST_Server::READABLE,
122                    'callback'            => array( $this, 'read_job_status' ),
123                    'permission_callback' => array( Posts_To_Podcast_Helper::class, 'get_status_permission_check' ),
124                    'args'                => array(
125                        'job_id' => array(
126                            'type'     => 'integer',
127                            'required' => true,
128                        ),
129                    ),
130                ),
131            )
132        );
133
134        register_rest_route(
135            $this->namespace,
136            $this->rest_base . '/episodes',
137            array(
138                array(
139                    'methods'             => WP_REST_Server::READABLE,
140                    'callback'            => array( $this, 'read_episodes' ),
141                    'permission_callback' => array( Posts_To_Podcast_Helper::class, 'get_status_permission_check' ),
142                    'args'                => array(
143                        'page'     => array(
144                            'type'    => 'integer',
145                            'default' => 1,
146                            'minimum' => 1,
147                        ),
148                        'per_page' => array(
149                            'type'    => 'integer',
150                            'default' => 5,
151                            'minimum' => 1,
152                            'maximum' => 50,
153                        ),
154                    ),
155                ),
156            )
157        );
158
159        register_rest_route(
160            $this->namespace,
161            $this->rest_base . '/' . self::POST_PUBLISH_PROMO_DISMISS_REST_ROUTE,
162            array(
163                'methods'             => WP_REST_Server::CREATABLE,
164                'callback'            => array( $this, 'dismiss_post_publish_promo' ),
165                'permission_callback' => function () {
166                    return current_user_can( 'edit_posts' );
167                },
168            )
169        );
170    }
171
172    /**
173     * Return posts that embed a `jetpack/podcast-episode` block â€” the surface
174     * this feature creates on success â€” newest first. Drafts and published
175     * posts only; trashed/auto-drafts are excluded.
176     *
177     * @param WP_REST_Request $request Full details about the request.
178     *
179     * @return WP_REST_Response
180     */
181    public function read_episodes( WP_REST_Request $request ) {
182        $page     = max( 1, (int) $request->get_param( 'page' ) );
183        $per_page = max( 1, min( 50, (int) $request->get_param( 'per_page' ) ) );
184
185        $query = new \WP_Query(
186            array(
187                'post_type'              => 'post',
188                'post_status'            => array( 'draft', 'publish' ),
189                'posts_per_page'         => $per_page,
190                'paged'                  => $page,
191                'orderby'                => 'date',
192                'order'                  => 'DESC',
193                'update_post_term_cache' => false,
194                'meta_query'             => array(
195                    array(
196                        'key'     => 'posts_to_podcast_metadata',
197                        'compare' => 'EXISTS',
198                    ),
199                ),
200            )
201        );
202
203        $items = array();
204        foreach ( $query->posts as $post ) {
205            $raw_meta = get_post_meta( $post->ID, 'posts_to_podcast_metadata', true );
206            $meta     = is_string( $raw_meta ) ? json_decode( $raw_meta, true ) : null;
207            $audio    = ( is_array( $meta ) && isset( $meta['audio'] ) && is_array( $meta['audio'] ) ) ? $meta['audio'] : array();
208            $title    = wp_strip_all_tags(
209                html_entity_decode( (string) get_the_title( $post ), ENT_QUOTES | ENT_HTML5, 'UTF-8' )
210            );
211            if ( '' === trim( $title ) ) {
212                // translators: Fallback shown in the Generated podcasts list when a draft has an empty title.
213                $title = __( '(no title)', 'jetpack-podcast' );
214            }
215
216            $items[] = array(
217                'id'        => $post->ID,
218                'title'     => $title,
219                'status'    => $post->post_status,
220                'date'      => mysql2date( 'c', $post->post_date_gmt, false ),
221                'editUrl'   => get_edit_post_link( $post->ID, 'raw' ),
222                'mediaUrl'  => isset( $audio['url'] ) ? esc_url_raw( (string) $audio['url'] ) : '',
223                'mediaType' => 'audio',
224                'mediaMime' => isset( $audio['mimeType'] ) ? (string) $audio['mimeType'] : '',
225                'duration'  => isset( $audio['durationSeconds'] ) ? (int) round( (float) $audio['durationSeconds'] ) : 0,
226            );
227        }
228
229        $total       = (int) $query->found_posts;
230        $total_pages = $per_page > 0 ? (int) ceil( $total / $per_page ) : 0;
231
232        return rest_ensure_response(
233            array(
234                'items'      => $items,
235                'total'      => $total,
236                'page'       => $page,
237                'perPage'    => $per_page,
238                'totalPages' => $total_pages,
239            )
240        );
241    }
242
243    /**
244     * Persist post-publish promo dismissal for the current user and site.
245     *
246     * @return WP_REST_Response
247     */
248    public function dismiss_post_publish_promo() {
249        update_user_option( get_current_user_id(), Create_AI_Podcast_Page::POST_PUBLISH_PROMO_DISMISSED_OPTION, 1 );
250
251        return rest_ensure_response(
252            array(
253                'dismissed' => true,
254            )
255        );
256    }
257
258    /**
259     * Forward GET to the wpcom-side endpoint and return feature info
260     * (remaining credits, plan, supported presets).
261     *
262     * @return WP_REST_Response|WP_Error
263     */
264    public function read_feature_info() {
265        $blog_id = (int) Jetpack_Options::get_option( 'id' );
266        if ( ! $blog_id ) {
267            return new WP_Error( 'site-not-connected', __( 'Site is not connected to WordPress.com.', 'jetpack-podcast' ), array( 'status' => 400 ) );
268        }
269
270        $response = Client::wpcom_json_api_request_as_user(
271            sprintf( '/sites/%d/posts-to-podcast', $blog_id ),
272            '2',
273            array(
274                'method'  => 'GET',
275                'headers' => array( 'content-type' => 'application/json' ),
276                'timeout' => 15,
277            ),
278            null,
279            'wpcom'
280        );
281
282        return $this->relay_response( $response );
283    }
284
285    /**
286     * Forward POST to the wpcom-side endpoint and return the queued job descriptor.
287     *
288     * @param WP_REST_Request $request Full details about the request.
289     *
290     * @return WP_REST_Response|WP_Error
291     */
292    public function enqueue_generation( WP_REST_Request $request ) {
293        $blog_id = (int) Jetpack_Options::get_option( 'id' );
294        if ( ! $blog_id ) {
295            return new WP_Error( 'site-not-connected', __( 'Site is not connected to WordPress.com.', 'jetpack-podcast' ), array( 'status' => 400 ) );
296        }
297
298        $body_payload = array(
299            'length'      => $request->get_param( 'length' ),
300            'voicePreset' => $request->get_param( 'voicePreset' ),
301        );
302
303        $window = $request->get_param( 'window' );
304        if ( null !== $window ) {
305            $body_payload['window'] = $window;
306        }
307
308        $post_ids = $request->get_param( 'postIds' );
309        if ( is_array( $post_ids ) && ! empty( $post_ids ) ) {
310            $body_payload['postIds'] = array_values( array_map( 'intval', $post_ids ) );
311        }
312
313        $prompt = $request->get_param( 'prompt' );
314        if ( is_string( $prompt ) && '' !== $prompt ) {
315            $body_payload['prompt'] = $prompt;
316        }
317
318        if ( ! isset( $body_payload['window'] ) && ! isset( $body_payload['postIds'] ) ) {
319            return new WP_Error( 'missing-source', __( 'One of window or postIds is required.', 'jetpack-podcast' ), array( 'status' => 400 ) );
320        }
321
322        $response = Client::wpcom_json_api_request_as_user(
323            sprintf( '/sites/%d/posts-to-podcast', $blog_id ),
324            '2',
325            array(
326                'method'  => 'POST',
327                'headers' => array( 'content-type' => 'application/json' ),
328                'timeout' => 30,
329            ),
330            wp_json_encode( $body_payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ),
331            'wpcom'
332        );
333
334        return $this->relay_response( $response );
335    }
336
337    /**
338     * Forward GET to the wpcom-side polling endpoint and return the job record.
339     *
340     * @param WP_REST_Request $request Full details about the request.
341     *
342     * @return WP_REST_Response|WP_Error
343     */
344    public function read_job_status( WP_REST_Request $request ) {
345        $blog_id = (int) Jetpack_Options::get_option( 'id' );
346        if ( ! $blog_id ) {
347            return new WP_Error( 'site-not-connected', __( 'Site is not connected to WordPress.com.', 'jetpack-podcast' ), array( 'status' => 400 ) );
348        }
349
350        $job_id = (int) $request['job_id'];
351
352        $response = Client::wpcom_json_api_request_as_user(
353            sprintf( '/sites/%d/posts-to-podcast/jobs/%d', $blog_id, $job_id ),
354            '2',
355            array(
356                'method'  => 'GET',
357                'headers' => array( 'content-type' => 'application/json' ),
358                'timeout' => 15,
359            ),
360            null,
361            'wpcom'
362        );
363
364        return $this->relay_response( $response );
365    }
366}