Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
60.13% covered (warning)
60.13%
95 / 158
36.84% covered (danger)
36.84%
7 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
Admin_Page
60.13% covered (warning)
60.13%
95 / 158
36.84% covered (danger)
36.84%
7 / 19
262.97
0.00% covered (danger)
0.00%
0 / 1
 init
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 add_wp_admin_submenu
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
4
 admin_init
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 enqueue_i18n_loader
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 enqueue_tracks_transport
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 maybe_load_wp_build
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 load_wp_build_for_spa
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
20
 add_spa_menu_route
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 inject_podcast_script_data
83.33% covered (warning)
83.33%
25 / 30
0.00% covered (danger)
0.00%
0 / 1
11.56
 get_tracks_user_data
44.44% covered (danger)
44.44%
4 / 9
0.00% covered (danger)
0.00%
0 / 1
9.29
 get_selected_category
30.00% covered (danger)
30.00%
3 / 10
0.00% covered (danger)
0.00%
0 / 1
6.09
 load_wp_build
30.00% covered (danger)
30.00%
3 / 10
0.00% covered (danger)
0.00%
0 / 1
6.09
 load_wp_build_with_screen_alias
75.00% covered (warning)
75.00%
9 / 12
0.00% covered (danger)
0.00%
0 / 1
2.06
 alias_screen_id_for_wp_build
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 restore_screen_id_after_wp_build
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 opt_out_of_jitms
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 hide_jitms_on_wp_build_dashboard
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 render
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 is_podcast_admin_request
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
3.33
1<?php
2/**
3 * Registers the Jetpack Podcast wp-admin page and loads the wp-build dashboard.
4 *
5 * @package automattic/jetpack-podcast
6 */
7
8namespace Automattic\Jetpack\Podcast;
9
10use Automattic\Jetpack\Admin_UI\Admin_Menu;
11use Automattic\Jetpack\Connection\Manager as Connection_Manager;
12use Automattic\Jetpack\My_Jetpack\Products as My_Jetpack_Products;
13use Automattic\Jetpack\Status\Host;
14use Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills;
15use Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Screen_Id;
16
17/**
18 * Adds the "Jetpack > Podcast" wp-admin screen.
19 */
20class Admin_Page {
21
22    const ADMIN_PAGE_SLUG = 'jetpack-podcast';
23
24    /**
25     * Where the Podcast item used to sit in the Jetpack submenu on self-hosted.
26     *
27     * Unread since Podcast registers without a position; kept so consumers do not fatal.
28     *
29     * @deprecated 2.1.1
30     */
31    const MENU_POSITION = 11;
32
33    /**
34     * Slug emitted by `@wordpress/build`. wp-build's auto-generated enqueue
35     * callback only fires when `$screen->id` matches this value, so we alias
36     * the screen id around that check without changing the user-facing URL.
37     */
38    const WP_BUILD_SLUG = 'jetpack-podcast-dashboard';
39
40    /**
41     * Whether `init()` has already wired its hooks.
42     *
43     * @var bool
44     */
45    private static $initialized = false;
46
47    /**
48     * The screen ID alias_screen_id_for_wp_build() replaced, until it is restored.
49     *
50     * @var string|null
51     */
52    private static $wp_build_original_screen_id = null;
53
54    /**
55     * The dashboard screen hide_jitms_on_wp_build_dashboard() opts out of JITMs.
56     *
57     * @var string|null
58     */
59    private static $jitm_opt_out_screen_id = null;
60
61    /**
62     * Wire admin hooks. Idempotent.
63     */
64    public static function init() {
65        if ( self::$initialized ) {
66            return;
67        }
68        self::$initialized = true;
69
70        add_action( 'admin_menu', array( __CLASS__, 'maybe_load_wp_build' ), 1 );
71        add_action( 'jetpack_wp_admin_spa_load', array( __CLASS__, 'load_wp_build_for_spa' ) );
72        add_filter( 'jetpack_wp_admin_spa_menu_routes', array( __CLASS__, 'add_spa_menu_route' ) );
73
74        // On Simple/Atomic, wpcom-admin-menu.php builds the Jetpack menu at
75        // priority 999999 and calls add_wp_admin_submenu() itself. Self-hosted
76        // has no such file, so we register our own. Priority 999 queues the item
77        // before Admin_Menu's priority-1000 callback.
78        if ( ! ( new Host() )->is_wpcom_platform() ) {
79            add_action( 'admin_menu', array( __CLASS__, 'add_wp_admin_submenu' ), 999 );
80        }
81    }
82
83    /**
84     * Register the Podcast submenu under the Jetpack menu.
85     */
86    public static function add_wp_admin_submenu() {
87        // Prefer the wp-build render function once it's defined (by
88        // maybe_load_wp_build() at admin_menu priority 1); fall back otherwise.
89        $wp_build_render = 'jetpack_podcast_jetpack_podcast_dashboard_wp_admin_render_page';
90        $callback        = function_exists( $wp_build_render ) ? $wp_build_render : array( __CLASS__, 'render' );
91
92        if ( ( new Host() )->is_wpcom_platform() ) {
93            $page_suffix = add_submenu_page(
94                'jetpack',
95                /** "Podcast" is a product name, do not translate. */
96                'Podcast',
97                'Podcast',
98                'manage_options',
99                self::ADMIN_PAGE_SLUG,
100                $callback
101            );
102        } else {
103            $page_suffix = Admin_Menu::add_menu(
104                /** "Podcast" is a product name, do not translate. */
105                'Podcast',
106                'Podcast',
107                'manage_options',
108                self::ADMIN_PAGE_SLUG,
109                $callback,
110                null,
111                // Podcast has no My Jetpack product class, so the module is the only gate available.
112                array(
113                    'module' => 'podcast',
114                    'key'    => 'jetpack-podcast',
115                )
116            );
117        }
118
119        if ( $page_suffix ) {
120            add_action( 'load-' . $page_suffix, array( __CLASS__, 'admin_init' ) );
121            self::opt_out_of_jitms( $page_suffix );
122        }
123    }
124
125    /**
126     * Wire admin-init actions once we know the Podcast page is loading.
127     */
128    public static function admin_init() {
129        // MediaUpload (cover-image-control) reads wp.media.view — only defined after this runs.
130        add_action( 'admin_enqueue_scripts', 'wp_enqueue_media' );
131        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_tracks_transport' ) );
132        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_i18n_loader' ) );
133    }
134
135    /**
136     * Enqueue the JS translation loader so the dashboard's init module can download its catalogs.
137     *
138     * The loader is registered on every admin page by jetpack-assets but only enqueued when
139     * something depends on it; the esbuild bundles don't pull it in.
140     */
141    public static function enqueue_i18n_loader() {
142        if ( wp_script_is( 'wp-jp-i18n-loader', 'registered' ) ) {
143            wp_enqueue_script( 'wp-jp-i18n-loader' );
144        }
145    }
146
147    /**
148     * Load the Tracks transport for the dashboard's client-side events.
149     *
150     * `jetpackAnalytics.tracks.recordEvent()` only pushes onto `window._tkq`,
151     * which stays an inert array until `w.js` loads and drains it. Nothing
152     * supplies that on Atomic or self-hosted, so without this the queue grows
153     * for the life of the page. Simple is skipped because stats.php already
154     * prints the same script on `admin_footer`, and loading it twice would
155     * re-drain a queue that has already been flushed.
156     */
157    public static function enqueue_tracks_transport() {
158        if ( ( new Host() )->is_wpcom_simple() ) {
159            return;
160        }
161
162        wp_enqueue_script( 'jp-tracks', '//stats.wp.com/w.js', array(), gmdate( 'YW' ), true );
163    }
164
165    /**
166     * Hooked at admin_menu priority 1 so polyfills register before
167     * `wp_default_scripts` fires and the wp-build render function is defined
168     * before `add_wp_admin_submenu()` runs (priority 999 on self-hosted, 999999
169     * on Simple/Atomic).
170     */
171    public static function maybe_load_wp_build() {
172        if ( ! self::is_podcast_admin_request() ) {
173            return;
174        }
175
176        self::load_wp_build_with_screen_alias();
177        add_filter( 'jetpack_admin_js_script_data', array( __CLASS__, 'inject_podcast_script_data' ) );
178    }
179
180    /**
181     * Load Podcast's wp-build output for the Jetpack plugin's top-level SPA page.
182     *
183     * @see Jetpack_WP_Admin_SPA::maybe_load_wp_build()
184     * @return void
185     */
186    public static function load_wp_build_for_spa() {
187        $build_index = dirname( __DIR__ ) . '/build/build.php';
188        if ( ! current_user_can( 'manage_options' ) || ! file_exists( $build_index ) ) {
189            return;
190        }
191        require_once $build_index;
192
193        // Declared on the host page only so wp-build emits the routes; the host does the booting.
194        remove_action( 'admin_enqueue_scripts', 'jetpack_podcast_jetpack_spa_wp_admin_enqueue_scripts' );
195        add_filter(
196            'jetpack_wp_admin_spa_route_getters',
197            function ( $getters ) {
198                $getters[] = 'jetpack_podcast_get_jetpack_spa_wp_admin_routes';
199                return $getters;
200            }
201        );
202
203        if ( function_exists( 'jetpack_podcast_register_script_modules' ) ) {
204            jetpack_podcast_register_script_modules(); // @phan-suppress-current-line PhanUndeclaredFunction -- Checked with function_exists(); defined in the generated build/modules.php, which Phan excludes.
205        }
206
207        add_filter( 'jetpack_admin_js_script_data', array( __CLASS__, 'inject_podcast_script_data' ) );
208        self::admin_init();
209    }
210
211    /**
212     * Point the Podcast sidebar entry at the SPA route, when the build can serve it.
213     *
214     * @param array $routes Menu slug => SPA route path.
215     * @return array
216     */
217    public static function add_spa_menu_route( $routes ) {
218        if ( file_exists( dirname( __DIR__ ) . '/build/build.php' ) ) {
219            $routes[ self::ADMIN_PAGE_SLUG ] = '/podcast';
220        }
221        return $routes;
222    }
223
224    /**
225     * Add the podcast gate boolean to `window.JetpackScriptData`.
226     *
227     * Hooked from `maybe_load_wp_build()` so it only runs when the request is
228     * for the podcast admin page.
229     *
230     * @param array $data Script data being injected.
231     * @return array
232     */
233    public static function inject_podcast_script_data( $data ) {
234        if ( ! is_array( $data ) ) {
235            $data = array();
236        }
237
238        $is_wpcom = ( new Host() )->is_wpcom_platform();
239
240        if ( ! $is_wpcom && empty( $data['site']['wpcom']['blog_id'] ) ) {
241            $blog_id = (int) Connection_Manager::get_site_id( true );
242            if ( $blog_id > 0 ) {
243                $data['site']['wpcom']['blog_id'] = $blog_id;
244            }
245        }
246
247        $upgrade = array(
248            'product_slug' => $is_wpcom ? 'premium' : 'jetpack_growth_yearly',
249            'plan_name'    => $is_wpcom ? 'Premium' : 'Growth',
250        );
251        if ( ! $is_wpcom && method_exists( My_Jetpack_Products::class, 'get_bundle_offer' ) ) {
252            $offer = My_Jetpack_Products::get_bundle_offer( 'growth' );
253            if ( 'growth' !== ( $offer['slug'] ?? '' ) ) {
254                $upgrade = array(
255                    'product_slug' => $offer['wpcom_product_slug'] ?? '',
256                    'plan_name'    => $offer['title'] ?? '',
257                );
258            }
259        }
260        $data['podcast'] = array(
261            'has_product_access'  => Podcast_Gate::has_product_access(),
262            'is_connected'        => $is_wpcom || ( new Connection_Manager( 'jetpack' ) )->is_connected(),
263            'show_url_hosts'      => Settings::SHOW_URL_HOSTS,
264            'show_url_max_length' => Settings::SHOW_URL_MAX_LENGTH,
265            'feed_limit_max'      => Settings::feed_limit_max(),
266            'preload'             => rest_preload_api_request( array(), '/wpcom/v2/podcast/settings' ),
267            'selected_category'   => self::get_selected_category(),
268            'tracks_user_data'    => self::get_tracks_user_data(),
269            'upgrade'             => $upgrade,
270        );
271
272        return $data;
273    }
274
275    /**
276     * Connected-user identity for Tracks, so client events aren't anonymous on
277     * Atomic and self-hosted. Null on Simple, where stats.php already pushes
278     * `identifyUser` before our bundle runs.
279     *
280     * Deliberately narrower than `get_connected_user_tracks_identity()`, which
281     * also returns email, blogid and locale — none of which Tracks needs here.
282     *
283     * @return array{userid:mixed, username:mixed}|null
284     */
285    private static function get_tracks_user_data() {
286        if ( ! class_exists( 'Jetpack_Tracks_Client' ) ) {
287            return null;
288        }
289
290        $identity = \Jetpack_Tracks_Client::get_connected_user_tracks_identity();
291        if ( ! is_array( $identity ) || ! isset( $identity['userid'] ) || ! isset( $identity['username'] ) ) {
292            return null;
293        }
294
295        return array(
296            'userid'   => $identity['userid'],
297            'username' => $identity['username'],
298        );
299    }
300
301    /**
302     * The currently designated podcast category, injected so the settings
303     * picker can label its selected option on first paint instead of waiting on
304     * the client-side taxonomy→terms fetch. The full list still loads lazily.
305     *
306     * @return array{id:int, name:string}|null Null when no category is set.
307     */
308    public static function get_selected_category() {
309        $category_id = (int) get_option( 'podcasting_category_id', 0 );
310        if ( $category_id <= 0 ) {
311            return null;
312        }
313
314        $term = get_term( $category_id, 'category' );
315        if ( ! $term instanceof \WP_Term ) {
316            return null;
317        }
318
319        return array(
320            'id'   => (int) $term->term_id,
321            'name' => $term->name,
322        );
323    }
324
325    /**
326     * The build artifact may be absent on a fresh checkout before
327     * `pnpm build` has run; in that case `add_wp_admin_submenu()` falls back
328     * to `render()` so the page still loads (just without the React app).
329     */
330    private static function load_wp_build() {
331        $build_index = dirname( __DIR__ ) . '/build/build.php';
332
333        if ( ! file_exists( $build_index ) ) {
334            return;
335        }
336
337        require_once $build_index;
338
339        // wp-build hooks module registration to wp_default_scripts, which has
340        // already fired by admin_menu — call it directly or the init module
341        // never reaches the import map.
342        if ( function_exists( 'jetpack_podcast_register_script_modules' ) ) {
343            jetpack_podcast_register_script_modules(); // @phan-suppress-current-line PhanUndeclaredFunction -- Checked with function_exists(); defined in the generated build/modules.php, which Phan excludes.
344        }
345
346        WP_Build_Polyfills::register(
347            'jetpack-podcast',
348            array_merge( WP_Build_Polyfills::SCRIPT_HANDLES, WP_Build_Polyfills::MODULE_IDS )
349        );
350    }
351
352    /**
353     * Load wp-build with the screen ID aliased across its generated enqueue check.
354     *
355     * @see WP_Build_Screen_Id::load_with_alias()
356     * @return void
357     */
358    private static function load_wp_build_with_screen_alias() {
359        // Fallback: an older wp-build-polyfills under the jetpack-autoloader may predate load_with_alias().
360        if ( method_exists( WP_Build_Screen_Id::class, 'load_with_alias' ) ) {
361            WP_Build_Screen_Id::load_with_alias(
362                array( __CLASS__, 'alias_screen_id_for_wp_build' ),
363                array( __CLASS__, 'restore_screen_id_after_wp_build' ),
364                function () {
365                    self::load_wp_build();
366                }
367            );
368            return;
369        }
370
371        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'alias_screen_id_for_wp_build' ) );
372        self::load_wp_build();
373        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'restore_screen_id_after_wp_build' ) );
374    }
375
376    /**
377     * Alias the current screen id to wp-build's expected slug.
378     *
379     * @since 2.1.3 Takes no argument; hooked on `admin_enqueue_scripts`.
380     */
381    public static function alias_screen_id_for_wp_build() {
382        $screen = get_current_screen();
383        if ( ! $screen ) {
384            return;
385        }
386
387        self::$wp_build_original_screen_id = $screen->id;
388        $screen->id                        = self::WP_BUILD_SLUG;
389    }
390
391    /**
392     * Undo alias_screen_id_for_wp_build(), so code after the generated check sees the real screen ID.
393     *
394     * @since 2.1.3
395     */
396    public static function restore_screen_id_after_wp_build() {
397        $screen = get_current_screen();
398        if ( ! $screen || null === self::$wp_build_original_screen_id ) {
399            return;
400        }
401
402        $screen->id                        = self::$wp_build_original_screen_id;
403        self::$wp_build_original_screen_id = null;
404    }
405
406    /**
407     * Opt the dashboard's screen out of JITMs.
408     *
409     * @param string $screen_id The hook suffix the page was registered under, which is its screen ID.
410     */
411    private static function opt_out_of_jitms( $screen_id ) {
412        self::$jitm_opt_out_screen_id = $screen_id;
413        add_filter( 'jetpack_display_jitms_on_screen', array( __CLASS__, 'hide_jitms_on_wp_build_dashboard' ), 10, 2 );
414    }
415
416    /**
417     * Keep JITMs off the wp-build dashboard, which has no `#jp-admin-notices` to show them in.
418     *
419     * Fetching a JITM records a view, so one the page hides would still be counted.
420     *
421     * @since 2.1.3
422     *
423     * @param bool   $show      Whether to show JITMs on the screen.
424     * @param string $screen_id The screen ID.
425     * @return bool
426     */
427    public static function hide_jitms_on_wp_build_dashboard( $show, $screen_id ) {
428        if ( null !== self::$jitm_opt_out_screen_id && self::$jitm_opt_out_screen_id === $screen_id ) {
429            return false;
430        }
431
432        return $show;
433    }
434
435    /**
436     * Fallback render used when the wp-build artifact is missing.
437     */
438    public static function render() {
439        ?>
440        <div class="wrap">
441            <h1>Podcast</h1>
442        </div>
443        <?php
444    }
445
446    /**
447     * Whether the current request targets the Podcast admin page.
448     */
449    private static function is_podcast_admin_request() {
450        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
451        if ( ! is_admin() || ! isset( $_GET['page'] ) ) {
452            return false;
453        }
454
455        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
456        return self::ADMIN_PAGE_SLUG === sanitize_text_field( wp_unslash( $_GET['page'] ) );
457    }
458}