Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
13.24% covered (danger)
13.24%
67 / 506
12.50% covered (danger)
12.50%
3 / 24
CRAP
0.00% covered (danger)
0.00%
0 / 1
Create_AI_Podcast_Page
13.27% covered (danger)
13.27%
67 / 505
12.50% covered (danger)
12.50%
3 / 24
7298.28
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
 register_menu
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 on_load
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 render_resource_hints
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 enqueue_assets
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
12
 enqueue_post_publish_promo_assets
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
20
 is_current_post_published_for_post_publish_promo
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 is_post_block_editor
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
30
 has_enough_recent_posts_for_post_publish_promo
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
4
 has_visitors_for_post_publish_promo
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 get_post_publish_promo_visitor_count
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
5.01
 get_wpcom_simple_post_publish_promo_visitor_count
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 sum_visits_field
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
9.49
 is_post_publish_promo_site_eligible
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
4.07
 build_localized_data
0.00% covered (danger)
0.00%
0 / 73
0.00% covered (danger)
0.00%
0 / 1
2
 resolve_blog_id
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 bootstrap_data
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 bootstrap_data_via_proxy
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
56
 build_active_job_payload_wpcom
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
56
 build_upgrade_url_wpcom
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
72
 empty_episodes_envelope
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 normalize_episodes_payload
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
110
 bootstrap_data_wpcom
0.00% covered (danger)
0.00%
0 / 65
0.00% covered (danger)
0.00%
0 / 1
210
 render
0.00% covered (danger)
0.00%
0 / 129
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2/**
3 * Create AI Podcast — wp-admin page under Media.
4 *
5 * Standalone PHP page (no wp-build chassis, no React). Renders a static
6 * form server-side; a single vanilla-JS island fetches quota, drives the
7 * posts picker, submits the generate request, polls the job, and resumes
8 * across reloads via localStorage.
9 *
10 * Bootstrapped from `Podcast::init()` after the Host (Simple/WoA) gate has
11 * already been checked upstream.
12 *
13 * @package automattic/jetpack-podcast
14 */
15
16namespace Automattic\Jetpack\Podcast;
17
18require_once __DIR__ . '/admin-pages/create-ai-podcast/presets.php';
19
20use Automattic\Jetpack\Assets;
21use Automattic\Jetpack\Status\Host;
22use function Automattic\Jetpack\Podcast\Admin_Pages\Create_AI_Podcast\length_presets;
23use function Automattic\Jetpack\Podcast\Admin_Pages\Create_AI_Podcast\voice_presets;
24use function Automattic\Jetpack\Podcast\Admin_Pages\Create_AI_Podcast\window_presets;
25
26/**
27 * Registers the Media > Create AI Podcast submenu and renders the page.
28 */
29class Create_AI_Podcast_Page {
30
31    const PAGE_SLUG         = 'create-ai-podcast';
32    const SCRIPT_HANDLE     = 'jetpack-create-ai-podcast';
33    const STYLE_HANDLE      = 'jetpack-create-ai-podcast';
34    const EPISODES_PER_PAGE = 5;
35
36    /**
37     * Maximum number of posts that can be selected when generating from specific posts.
38     */
39    const MAX_SELECTED_POSTS = 25;
40
41    const POST_PUBLISH_PROMO_SCRIPT_HANDLE    = 'jetpack-post-publish-podcast-promo';
42    const POST_PUBLISH_PROMO_DISMISSED_OPTION = 'jetpack_posts_to_podcast_post_publish_promo_dismissed';
43    const POST_PUBLISH_PROMO_MIN_POSTS        = 5;
44    const POST_PUBLISH_PROMO_MIN_VISITORS     = 50;
45
46    /**
47     * Whether `init()` has wired its hooks.
48     *
49     * @var bool
50     */
51    private static $initialized = false;
52
53    /**
54     * Wire admin hooks. Idempotent.
55     */
56    public static function init() {
57        if ( self::$initialized ) {
58            return;
59        }
60        self::$initialized = true;
61
62        add_action( 'admin_menu', array( __CLASS__, 'register_menu' ) );
63        add_action( 'enqueue_block_editor_assets', array( __CLASS__, 'enqueue_post_publish_promo_assets' ) );
64    }
65
66    /**
67     * Register the Media > Create AI Podcast submenu.
68     */
69    public static function register_menu() {
70        $page_suffix = add_submenu_page(
71            'upload.php',
72            __( 'Create AI Podcast', 'jetpack-podcast' ),
73            __( 'Create AI Podcast', 'jetpack-podcast' ),
74            'upload_files',
75            self::PAGE_SLUG,
76            array( __CLASS__, 'render' )
77        );
78
79        if ( $page_suffix ) {
80            add_action( 'load-' . $page_suffix, array( __CLASS__, 'on_load' ) );
81        }
82    }
83
84    /**
85     * Wire enqueue once we know the Create AI Podcast page is loading.
86     */
87    public static function on_load() {
88        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_assets' ) );
89        add_action( 'admin_head-media_page_create-ai-podcast', array( __CLASS__, 'render_resource_hints' ) );
90    }
91
92    /**
93     * On wpcom Simple sites every wp.apiFetch call is routed through the
94     * wpcom-proxy iframe at public-api.wordpress.com. The iframe load adds a
95     * full DNS + TLS round-trip before our prefetched quota/episodes requests
96     * can leave the page. Preconnect shaves that off.
97     */
98    public static function render_resource_hints() {
99        ?>
100        <link rel="preconnect" href="https://public-api.wordpress.com" crossorigin>
101        <link rel="dns-prefetch" href="//public-api.wordpress.com">
102        <?php
103    }
104
105    /**
106     * Enqueue the static JS island, the page stylesheet, and the localized data bundle.
107     */
108    public static function enqueue_assets() {
109        $base_url  = plugins_url( 'admin-pages/create-ai-podcast/', __FILE__ );
110        $base_path = __DIR__ . '/admin-pages/create-ai-podcast/';
111
112        wp_enqueue_style(
113            self::STYLE_HANDLE,
114            $base_url . 'style.css',
115            array(),
116            (string) ( file_exists( $base_path . 'style.css' ) ? filemtime( $base_path . 'style.css' ) : '0.1.0' )
117        );
118
119        wp_enqueue_script(
120            self::SCRIPT_HANDLE,
121            $base_url . 'index.js',
122            array( 'wp-api-fetch', 'wp-i18n' ),
123            (string) ( file_exists( $base_path . 'index.js' ) ? filemtime( $base_path . 'index.js' ) : '0.1.0' ),
124            true
125        );
126
127        wp_localize_script(
128            self::SCRIPT_HANDLE,
129            'jetpackCreateAiPodcast',
130            self::build_localized_data()
131        );
132    }
133
134    /**
135     * Enqueue the post-publish modal in the post block editor for eligible sites.
136     */
137    public static function enqueue_post_publish_promo_assets() {
138        if (
139            ! self::is_post_block_editor()
140            || self::is_current_post_published_for_post_publish_promo()
141            || ! self::is_post_publish_promo_site_eligible()
142        ) {
143            return;
144        }
145
146        Assets::register_script(
147            self::POST_PUBLISH_PROMO_SCRIPT_HANDLE,
148            '../dist/blocks/post-publish-podcast-promo/editor.js',
149            __FILE__,
150            array(
151                'enqueue'    => true,
152                'in_footer'  => true,
153                'textdomain' => 'jetpack-podcast',
154            )
155        );
156
157        wp_add_inline_script(
158            self::POST_PUBLISH_PROMO_SCRIPT_HANDLE,
159            'window.jetpackPostPublishPodcastPromo = ' . wp_json_encode(
160                array(
161                    'createUrl'   => admin_url( 'upload.php?page=' . self::PAGE_SLUG ),
162                    'dismissPath' => Posts_To_Podcast_Endpoint::get_post_publish_promo_dismiss_rest_path(),
163                ),
164                JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
165            ) . ';',
166            'before'
167        );
168    }
169
170    /**
171     * Whether the current editor post has already been published.
172     */
173    private static function is_current_post_published_for_post_publish_promo(): bool {
174        $post = get_post();
175
176        return $post instanceof \WP_Post
177            && 'post' === $post->post_type
178            && 'publish' === $post->post_status;
179    }
180
181    /**
182     * Whether the current screen is the post block editor.
183     */
184    private static function is_post_block_editor(): bool {
185        if ( ! function_exists( 'get_current_screen' ) ) {
186            return false;
187        }
188
189        $screen = get_current_screen();
190        return ! empty( $screen )
191            && 'post' === $screen->base
192            && 'post' === $screen->post_type
193            && $screen->is_block_editor();
194    }
195
196    /**
197     * Whether the site has enough published posts to generate a better episode.
198     */
199    private static function has_enough_recent_posts_for_post_publish_promo(): bool {
200        /**
201         * Filters the minimum posts published in the last month needed for the Posts to Podcast post-publish promo.
202         *
203         * @since 1.0.0
204         *
205         * @param int $minimum Minimum number of published posts.
206         */
207        $minimum = (int) apply_filters(
208            'jetpack_posts_to_podcast_post_publish_promo_min_published_posts',
209            self::POST_PUBLISH_PROMO_MIN_POSTS
210        );
211        $minimum = max( 1, $minimum );
212
213        $published_posts = get_posts(
214            array(
215                'fields'           => 'ids',
216                'no_found_rows'    => true,
217                'post_status'      => 'publish',
218                'post_type'        => 'post',
219                'posts_per_page'   => $minimum,
220                'suppress_filters' => false,
221                'date_query'       => array(
222                    array(
223                        'after'     => '1 month ago',
224                        'inclusive' => true,
225                    ),
226                ),
227            )
228        );
229        $total           = count( $published_posts );
230
231        $post = get_post();
232        if ( $post && 'post' === $post->post_type && 'publish' !== $post->post_status ) {
233            ++$total;
234        }
235
236        return $total >= $minimum;
237    }
238
239    /**
240     * Whether the site has visitors who could benefit from a podcast episode.
241     */
242    private static function has_visitors_for_post_publish_promo(): bool {
243        $visitors = self::get_post_publish_promo_visitor_count();
244
245        /**
246         * Filters the minimum visitors in the last week needed for the Posts to Podcast post-publish promo.
247         *
248         * @since 1.0.0
249         *
250         * @param int $minimum Minimum number of visitors.
251         */
252        $minimum = (int) apply_filters(
253            'jetpack_posts_to_podcast_post_publish_promo_min_visitors',
254            self::POST_PUBLISH_PROMO_MIN_VISITORS
255        );
256
257        return $visitors >= max( 1, $minimum );
258    }
259
260    /**
261     * Fetch the last week's visitor count from Jetpack Stats when available.
262     */
263    private static function get_post_publish_promo_visitor_count(): int {
264        $host = new Host();
265        if ( $host->is_wpcom_simple() ) {
266            return self::get_wpcom_simple_post_publish_promo_visitor_count();
267        }
268
269        if ( class_exists( '\Automattic\Jetpack\Stats\WPCOM_Stats' ) ) {
270            $wpcom_stats = new \Automattic\Jetpack\Stats\WPCOM_Stats();
271            $stats       = $wpcom_stats->get_visits(
272                array(
273                    'unit'        => 'day',
274                    'quantity'    => 7,
275                    'stat_fields' => 'visitors',
276                )
277            );
278
279            if ( ! is_wp_error( $stats ) && is_array( $stats ) ) {
280                return self::sum_visits_field( $stats, 'visitors' );
281            }
282        }
283
284        return 0;
285    }
286
287    /**
288     * Fetch the last week's visitor count directly on WordPress.com Simple.
289     */
290    private static function get_wpcom_simple_post_publish_promo_visitor_count(): int {
291        if ( ! function_exists( 'stats_get_visitors' ) ) {
292            return 0;
293        }
294
295        $visitors = stats_get_visitors( get_current_blog_id(), gmdate( 'Y-m-d' ), 7, 1 );
296
297        return is_array( $visitors ) ? (int) array_sum( $visitors ) : 0;
298    }
299
300    /**
301     * Sum a metric from the Stats visits response.
302     *
303     * @param array  $stats Stats visits response.
304     * @param string $field Field to sum.
305     * @return int
306     */
307    private static function sum_visits_field( array $stats, string $field ): int {
308        if ( ! isset( $stats['data'] ) || ! is_array( $stats['data'] ) ) {
309            return 0;
310        }
311
312        $fields = isset( $stats['fields'] ) && is_array( $stats['fields'] ) ? $stats['fields'] : array();
313        $index  = array_search( $field, $fields, true );
314        if ( false === $index ) {
315            return 0;
316        }
317
318        $total = 0;
319        foreach ( $stats['data'] as $row ) {
320            if ( is_array( $row ) && isset( $row[ $index ] ) ) {
321                $total += (int) $row[ $index ];
322            }
323        }
324
325        return $total;
326    }
327
328    /**
329     * Whether the site is relevant for the post-publish promo.
330     */
331    public static function is_post_publish_promo_site_eligible(): bool {
332        $host = new Host();
333        if ( $host->is_p2_site() ) {
334            return false;
335        }
336
337        if ( get_user_option( self::POST_PUBLISH_PROMO_DISMISSED_OPTION, get_current_user_id() ) ) {
338            return false;
339        }
340
341        return self::has_enough_recent_posts_for_post_publish_promo() && self::has_visitors_for_post_publish_promo();
342    }
343
344    /**
345     * Build the data bundle passed to the JS island via wp_localize_script.
346     *
347     * @return array<string, mixed>
348     */
349    private static function build_localized_data(): array {
350        $max_posts = self::MAX_SELECTED_POSTS;
351
352        return array(
353            'maxPosts'  => $max_posts,
354            'endpoints' => array(
355                'enqueue'  => '/wpcom/v2/posts-to-podcast',
356                'job'      => '/wpcom/v2/posts-to-podcast/jobs/',
357                'quota'    => '/wpcom/v2/posts-to-podcast',
358                'posts'    => '/wp/v2/posts',
359                'episodes' => '/wpcom/v2/posts-to-podcast/episodes',
360            ),
361            'blogId'    => self::resolve_blog_id(),
362            'bootstrap' => self::bootstrap_data(),
363            'presets'   => array(
364                'window' => window_presets(),
365                'length' => length_presets(),
366                'voice'  => voice_presets(),
367            ),
368            'poll'      => array(
369                'fastMs'    => 3000,
370                'slowMs'    => 10000,
371                'switchMs'  => 30000,
372                'timeoutMs' => 5 * 60 * 1000,
373            ),
374            'i18n'      => array(
375                'submitting'          => __( 'Submitting…', 'jetpack-podcast' ),
376                'polling'             => __( 'Generating your episode…', 'jetpack-podcast' ),
377                'pollingSubtext'      => __( "This usually takes about 3 minutes. You can leave this page and come back — we'll keep working in the background.", 'jetpack-podcast' ),
378                'succeeded'           => __( 'Episode draft ready.', 'jetpack-podcast' ),
379                'editDraft'           => __( 'Edit draft', 'jetpack-podcast' ),
380                'failed'              => __( 'Generation failed.', 'jetpack-podcast' ),
381                'timedOut'            => __( 'Generation is taking longer than expected. Check your drafts.', 'jetpack-podcast' ),
382                'tryAgain'            => __( 'Try again', 'jetpack-podcast' ),
383                'dismiss'             => __( 'Dismiss', 'jetpack-podcast' ),
384                'notAvailable'        => __( 'Create AI Podcast isn\'t available on your current plan.', 'jetpack-podcast' ),
385                // translators: 1: number of credits used, 2: total credits available.
386                'creditsUsed'         => __( '%1$d of %2$d credits used.', 'jetpack-podcast' ),
387                'creditsLabel'        => __( 'Credits', 'jetpack-podcast' ),
388                // translators: 1: number of credits used, 2: total credits available.
389                'creditsCount'        => __( '%1$d / %2$d', 'jetpack-podcast' ),
390                'creditsUnlimited'    => __( 'Unlimited generations available.', 'jetpack-podcast' ),
391                // translators: %d: credits remaining.
392                'creditsRemaining'    => __( '%d remaining', 'jetpack-podcast' ),
393                // translators: %s: relative time, e.g. "in 12 days" or "tomorrow".
394                'creditsResetSummary' => __( 'Resets %s', 'jetpack-podcast' ),
395                'creditsResetMonthly' => __( 'Resets monthly', 'jetpack-podcast' ),
396                'relativeToday'       => __( 'today', 'jetpack-podcast' ),
397                'relativeTomorrow'    => __( 'tomorrow', 'jetpack-podcast' ),
398                // translators: %d: number of days until reset.
399                'relativeDays'        => __( 'in %d days', 'jetpack-podcast' ),
400                // translators: %s: formatted date, e.g. "May 20, 2026".
401                'relativeOn'          => __( 'on %s', 'jetpack-podcast' ),
402                'trialBannerTitle'    => __( 'Try before you buy', 'jetpack-podcast' ),
403                'trialBannerMessage'  => __( 'Generate a podcast from your posts and see how it sounds on your site. Free trial is limited to one podcast episode.', 'jetpack-podcast' ),
404                'runningLowTitle'     => __( 'Running low', 'jetpack-podcast' ),
405                'runningLowMessage'   => __( 'Upgrade your plan to keep generating without waiting for the monthly refresh.', 'jetpack-podcast' ),
406                'outOfCreditsTitle'   => __( 'Out of credits', 'jetpack-podcast' ),
407                // translators: %s: relative time, e.g. "in 12 days" or "tomorrow".
408                'outOfCreditsWait'    => __( 'Your credits will refresh %s.', 'jetpack-podcast' ),
409                // translators: %s: relative time, e.g. "in 12 days" or "tomorrow".
410                'outOfCreditsUpgrade' => __( 'Upgrade your plan for more credits, or wait until they refresh %s.', 'jetpack-podcast' ),
411                'outOfTrialCredits'   => __( 'You have used your one-time trial credit. Upgrade your plan for more credits.', 'jetpack-podcast' ),
412                'noPostsFound'        => __( 'No posts match.', 'jetpack-podcast' ),
413                'loadingPosts'        => __( 'Loading posts…', 'jetpack-podcast' ),
414                'pickPosts'           => __( 'Select at least one post to continue.', 'jetpack-podcast' ),
415                // translators: %d: maximum number of posts that can be selected.
416                'maxPostsReached'     => sprintf( __( 'You can select up to %d posts.', 'jetpack-podcast' ), $max_posts ),
417                'upgradeCta'          => __( 'Upgrade plan', 'jetpack-podcast' ),
418                'episodesTitle'       => __( 'Generated podcasts', 'jetpack-podcast' ),
419                'episodesEmpty'       => __( 'No generated podcasts yet.', 'jetpack-podcast' ),
420                'episodesLoading'     => __( 'Loading podcasts…', 'jetpack-podcast' ),
421                'editPost'            => __( 'Edit post', 'jetpack-podcast' ),
422                'statusDraft'         => __( 'Draft', 'jetpack-podcast' ),
423                'statusPublished'     => __( 'Published', 'jetpack-podcast' ),
424                // translators: 1: range start, 2: range end, 3: total count. Example: "Showing 1–5 of 12"
425                'paginationSummary'   => __( 'Showing %1$d–%2$d of %3$d', 'jetpack-podcast' ),
426                'paginationPrev'      => __( 'Previous', 'jetpack-podcast' ),
427                'paginationNext'      => __( 'Next', 'jetpack-podcast' ),
428                // translators: %d: page number. Example: "Go to page 3"
429                'paginationGoTo'      => __( 'Go to page %d', 'jetpack-podcast' ),
430                'paginationLabel'     => __( 'Episodes pagination', 'jetpack-podcast' ),
431                'unexpectedError'     => __( 'An unexpected error occurred.', 'jetpack-podcast' ),
432                'outOfCreditsError'   => __( 'Out of credits.', 'jetpack-podcast' ),
433            ),
434        );
435    }
436
437    /**
438     * Resolve the wpcom blog id for this site. On Atomic Jetpack stores it
439     * under the `id` option; on Simple sites it's the current blog id.
440     *
441     * @return int
442     */
443    private static function resolve_blog_id(): int {
444        if ( class_exists( '\\Jetpack_Options' ) ) {
445            $id = (int) \Jetpack_Options::get_option( 'id' );
446            if ( $id > 0 ) {
447                return $id;
448            }
449        }
450        return (int) get_current_blog_id();
451    }
452
453    /**
454     * Pre-warm the two initial reads (quota + episodes) server-side via
455     * rest_do_request so the client can render with data immediately instead
456     * of waiting on the wpcom-proxy iframe for the first paint. Failures fall
457     * through silently — the JS island falls back to fetch when an entry is
458     * null or absent.
459     *
460     * @return array{quota: array|null, episodes: array|null}
461     */
462    private static function bootstrap_data(): array {
463        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
464            return self::bootstrap_data_wpcom();
465        }
466        return self::bootstrap_data_via_proxy();
467    }
468
469    /**
470     * Atomic / self-hosted path: hit the local Jetpack-side proxy via
471     * rest_do_request, which forwards to wpcom over HTTPS using the current
472     * user's Jetpack token.
473     *
474     * @return array
475     */
476    private static function bootstrap_data_via_proxy(): array {
477        $bootstrap = array(
478            'quota'    => null,
479            'episodes' => self::empty_episodes_envelope(),
480        );
481
482        $quota_request  = new \WP_REST_Request( 'GET', '/wpcom/v2/posts-to-podcast' );
483        $quota_response = rest_do_request( $quota_request );
484        if ( $quota_response instanceof \WP_REST_Response ) {
485            if ( ! $quota_response->is_error() ) {
486                $bootstrap['quota'] = $quota_response->get_data();
487            } else {
488                $status = (int) $quota_response->get_status();
489                if ( 403 === $status || 404 === $status ) {
490                    $bootstrap['quota'] = array( 'notAvailable' => true );
491                } else {
492                    $bootstrap['quota'] = array(
493                        'quota' => 0,
494                        'used'  => 0,
495                    );
496                }
497            }
498        }
499
500        $episodes_request = new \WP_REST_Request( 'GET', '/wpcom/v2/posts-to-podcast/episodes' );
501        $episodes_request->set_param( 'page', 1 );
502        $episodes_request->set_param( 'per_page', self::EPISODES_PER_PAGE );
503        $episodes_response = rest_do_request( $episodes_request );
504        if ( $episodes_response instanceof \WP_REST_Response && ! $episodes_response->is_error() ) {
505            $bootstrap['episodes'] = self::normalize_episodes_payload( $episodes_response->get_data() );
506        }
507
508        return $bootstrap;
509    }
510
511    /**
512     * Replicate the wpcom-side endpoint's active-job payload shape so the
513     * client can resume polling the "Generating…" notice across reloads on
514     * Simple sites, the same way it does on Atomic through the proxy.
515     *
516     * @param int $blog_id Current blog id.
517     *
518     * @return array|\stdClass
519     */
520    private static function build_active_job_payload_wpcom( int $blog_id ) {
521        if ( ! function_exists( 'posts_to_podcast_get_active_job_record' ) ) {
522            return new \stdClass();
523        }
524        $record = posts_to_podcast_get_active_job_record( $blog_id );
525        if ( null === $record ) {
526            return new \stdClass();
527        }
528
529        $status_map = array(
530            'queued'    => 'pending',
531            'succeeded' => 'complete',
532            'failed'    => 'failed',
533        );
534        $raw        = function_exists( 'get_job_status' ) ? get_job_status( $record['id'] ) : 'queued';
535        $status     = $status_map[ $raw ] ?? 'pending';
536
537        $payload = array(
538            'jobId'     => (int) $record['id'],
539            'status'    => $status,
540            'createdAt' => gmdate( 'c', (int) $record['queued_at'] ),
541        );
542
543        if ( 'complete' === $status && function_exists( 'posts_to_podcast_get_job_result' ) ) {
544            $post_id = posts_to_podcast_get_job_result( $record['id'] );
545            if ( null !== $post_id ) {
546                $payload['postId']  = $post_id;
547                $payload['editUrl'] = (string) get_edit_post_link( $post_id, 'raw' );
548            }
549        }
550
551        return $payload;
552    }
553
554    /**
555     * Replicate the wpcom-side endpoint's upgrade URL builder so the Out of
556     * credits banner can surface an Upgrade plan CTA even on Simple sites
557     * (where we don't go through the REST proxy). Returns the Calypso
558     * checkout URL for the next tier up, or empty when the site is already
559     * on the top podcast tier.
560     *
561     * @param int $blog_id Current blog id.
562     *
563     * @return string
564     */
565    private static function build_upgrade_url_wpcom( int $blog_id ): string {
566        if ( ! class_exists( '\\WPCOM_Features' ) || ! function_exists( 'wpcom_site_has_feature' ) ) {
567            return '';
568        }
569
570        if ( wpcom_site_has_feature( \WPCOM_Features::POSTS_TO_PODCAST_TIER_3, $blog_id ) ) {
571            return '';
572        }
573        if ( wpcom_site_has_feature( \WPCOM_Features::POSTS_TO_PODCAST_TIER_2, $blog_id ) ) {
574            $plan = 'business';
575        } elseif ( wpcom_site_has_feature( \WPCOM_Features::POSTS_TO_PODCAST_TIER_1, $blog_id ) ) {
576            $plan = 'premium';
577        } else {
578            $plan = 'personal';
579        }
580
581        $site_slug = class_exists( '\\WPCOM_Masterbar' )
582            ? \WPCOM_Masterbar::get_calypso_site_slug( $blog_id )
583            : '';
584        if ( '' === $site_slug ) {
585            return '';
586        }
587
588        return sprintf( 'https://wordpress.com/checkout/%s/%s', $site_slug, $plan );
589    }
590
591    /**
592     * Default empty episodes envelope used while the page is still loading
593     * data or when an upstream request errors.
594     *
595     * @return array
596     */
597    private static function empty_episodes_envelope(): array {
598        return array(
599            'items'      => array(),
600            'total'      => 0,
601            'page'       => 1,
602            'perPage'    => self::EPISODES_PER_PAGE,
603            'totalPages' => 0,
604        );
605    }
606
607    /**
608     * Accept either the new envelope shape (preferred) or the legacy bare
609     * array (older sandboxes that haven't shipped the pagination upgrade yet)
610     * and return the envelope.
611     *
612     * @param mixed $payload Upstream response body.
613     *
614     * @return array
615     */
616    private static function normalize_episodes_payload( $payload ): array {
617        if ( is_array( $payload ) && isset( $payload['items'] ) && is_array( $payload['items'] ) ) {
618            return array(
619                'items'      => array_values( $payload['items'] ),
620                'total'      => isset( $payload['total'] ) ? (int) $payload['total'] : count( $payload['items'] ),
621                'page'       => isset( $payload['page'] ) ? max( 1, (int) $payload['page'] ) : 1,
622                'perPage'    => isset( $payload['perPage'] ) ? max( 1, (int) $payload['perPage'] ) : self::EPISODES_PER_PAGE,
623                'totalPages' => isset( $payload['totalPages'] ) ? max( 0, (int) $payload['totalPages'] ) : 0,
624            );
625        }
626        if ( is_array( $payload ) ) {
627            $items = array_values( $payload );
628            return array(
629                'items'      => $items,
630                'total'      => count( $items ),
631                'page'       => 1,
632                'perPage'    => self::EPISODES_PER_PAGE,
633                'totalPages' => count( $items ) > 0 ? 1 : 0,
634            );
635        }
636        return self::empty_episodes_envelope();
637    }
638
639    /**
640     * Simple (wpcom) path: rest_do_request can't reach the posts-to-podcast
641     * endpoint here — the wpcom REST plugin loader gates the endpoint files
642     * behind REST_API_PLUGINS, which isn't set in admin context. Call the
643     * underlying wpcom helpers directly. Permissions are still enforced via
644     * the admin caps required to render this page.
645     *
646     * @return array
647     */
648    private static function bootstrap_data_wpcom(): array {
649        $bootstrap = array(
650            'quota'    => null,
651            'episodes' => self::empty_episodes_envelope(),
652        );
653
654        if ( ! function_exists( 'require_lib' ) ) {
655            return $bootstrap;
656        }
657        require_lib( 'posts-to-podcast' );
658
659        $blog_id = (int) get_current_blog_id();
660
661        if ( function_exists( '\\Automattic\\Posts_To_Podcast\\get_usage' ) || function_exists( 'posts_to_podcast_get_usage' ) ) {
662            $usage              = function_exists( 'posts_to_podcast_get_usage' )
663                ? posts_to_podcast_get_usage( $blog_id )
664                : array();
665            $bootstrap['quota'] = array(
666                'quota'      => $usage,
667                'activeJob'  => self::build_active_job_payload_wpcom( $blog_id ),
668                'upgradeUrl' => self::build_upgrade_url_wpcom( $blog_id ),
669            );
670        }
671
672        $per_page = self::EPISODES_PER_PAGE;
673        $query    = new \WP_Query(
674            array(
675                'post_type'              => 'post',
676                'post_status'            => array( 'draft', 'publish' ),
677                'posts_per_page'         => $per_page,
678                'paged'                  => 1,
679                'orderby'                => 'date',
680                'order'                  => 'DESC',
681                'update_post_term_cache' => false,
682                'meta_query'             => array(
683                    array(
684                        'key'     => 'posts_to_podcast_metadata',
685                        'compare' => 'EXISTS',
686                    ),
687                ),
688            )
689        );
690
691        $items = array();
692        foreach ( $query->posts as $post ) {
693            $raw_meta = get_post_meta( $post->ID, 'posts_to_podcast_metadata', true );
694            $meta     = is_string( $raw_meta ) ? json_decode( $raw_meta, true ) : null;
695            $audio    = ( is_array( $meta ) && isset( $meta['audio'] ) && is_array( $meta['audio'] ) ) ? $meta['audio'] : array();
696            $title    = wp_strip_all_tags(
697                html_entity_decode( (string) get_the_title( $post ), ENT_QUOTES | ENT_HTML5, 'UTF-8' )
698            );
699            if ( '' === trim( $title ) ) {
700                // translators: Fallback shown in the Generated podcasts list when a draft has an empty title.
701                $title = __( '(no title)', 'jetpack-podcast' );
702            }
703
704            $items[] = array(
705                'id'        => $post->ID,
706                'title'     => $title,
707                'status'    => $post->post_status,
708                'date'      => mysql2date( 'c', $post->post_date_gmt, false ),
709                'editUrl'   => get_edit_post_link( $post->ID, 'raw' ),
710                'mediaUrl'  => isset( $audio['url'] ) ? esc_url_raw( (string) $audio['url'] ) : '',
711                'mediaType' => 'audio',
712                'mediaMime' => isset( $audio['mimeType'] ) ? (string) $audio['mimeType'] : '',
713                'duration'  => isset( $audio['durationSeconds'] ) ? (int) round( (float) $audio['durationSeconds'] ) : 0,
714            );
715        }
716
717        $total                 = (int) $query->found_posts;
718        $bootstrap['episodes'] = array(
719            'items'      => $items,
720            'total'      => $total,
721            'page'       => 1,
722            'perPage'    => $per_page,
723            'totalPages' => (int) ceil( $total / $per_page ),
724        );
725
726        return $bootstrap;
727    }
728
729    /**
730     * Render the page chrome and the static form HTML.
731     */
732    public static function render() {
733        $window = window_presets();
734        $length = length_presets();
735        $voice  = voice_presets();
736        ?>
737        <div class="wrap jetpack-create-ai-podcast">
738            <h1 class="jetpack-create-ai-podcast__page-title">
739                <?php echo esc_html__( 'Create AI Podcast', 'jetpack-podcast' ); ?>
740            </h1>
741
742            <div id="jetpack-create-ai-podcast-app">
743                <section class="jetpack-create-ai-podcast__intro" role="region" aria-labelledby="jetpack-create-ai-podcast-intro-title">
744                    <div class="jetpack-create-ai-podcast__intro-body">
745                        <div class="jetpack-create-ai-podcast__intro-badges">
746                            <p class="jetpack-create-ai-podcast__intro-eyebrow">
747                                <span class="jetpack-create-ai-podcast__intro-wpmark" aria-hidden="true"></span>
748                                <span><?php echo esc_html__( 'WordPress.com exclusive', 'jetpack-podcast' ); ?></span>
749                            </p>
750                            <p class="jetpack-create-ai-podcast__intro-eyebrow jetpack-create-ai-podcast__intro-eyebrow--experimental">
751                                <span><?php echo esc_html__( 'Experimental', 'jetpack-podcast' ); ?></span>
752                            </p>
753                        </div>
754                        <h2 id="jetpack-create-ai-podcast-intro-title" class="jetpack-create-ai-podcast__intro-title">
755                            <?php echo esc_html__( 'Turn your posts into a podcast episode', 'jetpack-podcast' ); ?>
756                        </h2>
757                        <p class="jetpack-create-ai-podcast__intro-text">
758                            <?php echo esc_html__( 'Pick a date range or a few specific posts and we’ll generate a two-host conversation, complete with narration and a ready-to-publish draft. Edit, refine, and hit Publish when you’re happy.', 'jetpack-podcast' ); ?>
759                        </p>
760                    </div>
761                    <div class="jetpack-create-ai-podcast__intro-art" aria-hidden="true">
762                        <svg viewBox="0 0 64 64" width="80" height="80" xmlns="http://www.w3.org/2000/svg">
763                            <defs>
764                                <linearGradient id="jetpack-create-ai-podcast-grad" x1="0" y1="0" x2="1" y2="1">
765                                    <stop offset="0%" stop-color="#ffffff" stop-opacity="0.35"/>
766                                    <stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
767                                </linearGradient>
768                            </defs>
769                            <circle cx="32" cy="32" r="28" fill="url(#jetpack-create-ai-podcast-grad)"/>
770                            <path fill="#fff" d="M32 14a8 8 0 0 0-8 8v10a8 8 0 0 0 16 0V22a8 8 0 0 0-8-8zm-12 18a1.5 1.5 0 0 1 3 0 9 9 0 0 0 18 0 1.5 1.5 0 0 1 3 0 12 12 0 0 1-10.5 11.9V48h4.5v3h-12v-3H30v-2.1A12 12 0 0 1 20 32z"/>
771                        </svg>
772                    </div>
773                </section>
774
775                <div
776                    class="jetpack-create-ai-podcast__card jetpack-create-ai-podcast__credits"
777                    data-region="credits"
778                ></div>
779
780                <form class="jetpack-create-ai-podcast__form" data-region="form">
781                    <section class="jetpack-create-ai-podcast__card">
782                        <h2 class="jetpack-create-ai-podcast__card-title">
783                            <?php echo esc_html__( 'Source', 'jetpack-podcast' ); ?>
784                        </h2>
785
786                        <div class="jetpack-create-ai-podcast__radio-group" role="radiogroup">
787                            <label class="jetpack-create-ai-podcast__radio">
788                                <input type="radio" name="source" value="window" checked>
789                                <span><?php echo esc_html__( 'From a date range', 'jetpack-podcast' ); ?></span>
790                            </label>
791                            <label class="jetpack-create-ai-podcast__radio">
792                                <input type="radio" name="source" value="posts">
793                                <span><?php echo esc_html__( 'From specific posts', 'jetpack-podcast' ); ?></span>
794                            </label>
795                        </div>
796
797                        <div class="jetpack-create-ai-podcast__field" data-source="window">
798                            <label for="jetpack-create-ai-podcast-window">
799                                <?php echo esc_html__( 'Date range', 'jetpack-podcast' ); ?>
800                            </label>
801                            <select id="jetpack-create-ai-podcast-window" name="window">
802                                <?php foreach ( $window as $opt ) : ?>
803                                    <option value="<?php echo esc_attr( $opt['id'] ); ?>"><?php echo esc_html( $opt['label'] ); ?></option>
804                                <?php endforeach; ?>
805                            </select>
806                        </div>
807
808                        <div class="jetpack-create-ai-podcast__field" data-source="posts" hidden>
809                            <label for="jetpack-create-ai-podcast-posts-search">
810                                <?php echo esc_html__( 'Search posts', 'jetpack-podcast' ); ?>
811                            </label>
812                            <input
813                                type="search"
814                                id="jetpack-create-ai-podcast-posts-search"
815                                placeholder="<?php echo esc_attr__( 'Type to filter…', 'jetpack-podcast' ); ?>"
816                            >
817                            <p class="jetpack-create-ai-podcast__field-hint">
818                                <?php
819                                echo esc_html(
820                                    sprintf(
821                                        /* translators: %d: maximum number of posts that can be selected. */
822                                        __( 'You can choose up to %d posts.', 'jetpack-podcast' ),
823                                        self::MAX_SELECTED_POSTS
824                                    )
825                                );
826                                ?>
827                            </p>
828                            <div class="jetpack-create-ai-podcast__posts" data-region="posts"></div>
829                        </div>
830
831                        <div class="jetpack-create-ai-podcast__advanced" aria-labelledby="jetpack-create-ai-podcast-advanced-title">
832                            <div class="jetpack-create-ai-podcast__advanced-header">
833                                <h3 id="jetpack-create-ai-podcast-advanced-title" class="jetpack-create-ai-podcast__advanced-title">
834                                    <?php echo esc_html__( 'Customize', 'jetpack-podcast' ); ?>
835                                </h3>
836                                <span class="jetpack-create-ai-podcast__soon-pill">
837                                    <?php echo esc_html__( 'Coming soon', 'jetpack-podcast' ); ?>
838                                </span>
839                            </div>
840
841                            <div class="jetpack-create-ai-podcast__field">
842                                <label for="jetpack-create-ai-podcast-length">
843                                    <?php echo esc_html__( 'Length', 'jetpack-podcast' ); ?>
844                                </label>
845                                <select id="jetpack-create-ai-podcast-length" name="length" disabled data-locked-disabled="true">
846                                    <?php foreach ( $length as $opt ) : ?>
847                                        <option value="<?php echo esc_attr( $opt['id'] ); ?>"><?php echo esc_html( $opt['label'] ); ?></option>
848                                    <?php endforeach; ?>
849                                </select>
850                            </div>
851
852                            <div class="jetpack-create-ai-podcast__field">
853                                <label for="jetpack-create-ai-podcast-voice">
854                                    <?php echo esc_html__( 'Voice', 'jetpack-podcast' ); ?>
855                                </label>
856                                <select id="jetpack-create-ai-podcast-voice" name="voice" disabled data-locked-disabled="true">
857                                    <?php foreach ( $voice as $opt ) : ?>
858                                        <option value="<?php echo esc_attr( $opt['id'] ); ?>"><?php echo esc_html( $opt['label'] ); ?></option>
859                                    <?php endforeach; ?>
860                                </select>
861                            </div>
862
863                            <div class="jetpack-create-ai-podcast__field">
864                                <label for="jetpack-create-ai-podcast-prompt">
865                                    <?php echo esc_html__( 'Prompt (optional)', 'jetpack-podcast' ); ?>
866                                </label>
867                                <textarea
868                                    id="jetpack-create-ai-podcast-prompt"
869                                    name="prompt"
870                                    rows="3"
871                                    disabled
872                                    data-locked-disabled="true"
873                                    placeholder="<?php echo esc_attr__( 'Steer the tone, framing, or focus of the episode…', 'jetpack-podcast' ); ?>"
874                                ></textarea>
875                            </div>
876                        </div>
877
878                        <div class="jetpack-create-ai-podcast__actions">
879                            <button type="submit" class="button button-primary button-hero">
880                                <?php echo esc_html__( 'Generate', 'jetpack-podcast' ); ?>
881                            </button>
882                        </div>
883                    </section>
884                </form>
885
886                <div class="jetpack-create-ai-podcast__status" aria-live="polite" data-region="status"></div>
887
888                <section
889                    class="jetpack-create-ai-podcast__card jetpack-create-ai-podcast__episodes"
890                    data-region="episodes"
891                    aria-busy="false"
892                >
893                    <h2 class="jetpack-create-ai-podcast__card-title">
894                        <?php echo esc_html__( 'Generated podcasts', 'jetpack-podcast' ); ?>
895                    </h2>
896                    <div class="jetpack-create-ai-podcast__episodes-list" data-region="episodes-list"></div>
897                </section>
898            </div>
899        </div>
900        <?php
901    }
902}