Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
53.09% covered (warning)
53.09%
43 / 81
10.00% covered (danger)
10.00%
1 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Admin_Page
53.09% covered (warning)
53.09%
43 / 81
10.00% covered (danger)
10.00%
1 / 10
115.83
0.00% covered (danger)
0.00%
0 / 1
 init
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 add_wp_admin_submenu
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
4
 admin_init
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 maybe_load_wp_build
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 inject_podcast_script_data
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
8
 get_selected_category
30.00% covered (danger)
30.00%
3 / 10
0.00% covered (danger)
0.00%
0 / 1
6.09
 load_wp_build
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 alias_screen_id_for_wp_build
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 render
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 is_podcast_admin_request
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
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\Status\Host;
13use Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills;
14
15/**
16 * Adds the "Jetpack > Podcast" wp-admin screen.
17 */
18class Admin_Page {
19
20    const ADMIN_PAGE_SLUG = 'jetpack-podcast';
21
22    /**
23     * Where the Podcast item sits in the Jetpack submenu on self-hosted.
24     *
25     * Placed after content/product items like Newsletter and Search (10), and
26     * above Activity Log (12) so Activity Log stays immediately before Settings (13).
27     */
28    const MENU_POSITION = 11;
29
30    /**
31     * Slug emitted by `@wordpress/build`. wp-build's auto-generated enqueue
32     * callback only fires when `$screen->id` matches this value, so we alias
33     * the screen id via `current_screen` without changing the user-facing URL.
34     */
35    const WP_BUILD_SLUG = 'jetpack-podcast-dashboard';
36
37    /**
38     * Whether `init()` has already wired its hooks.
39     *
40     * @var bool
41     */
42    private static $initialized = false;
43
44    /**
45     * Wire admin hooks. Idempotent.
46     */
47    public static function init() {
48        if ( self::$initialized ) {
49            return;
50        }
51        self::$initialized = true;
52
53        add_action( 'admin_menu', array( __CLASS__, 'maybe_load_wp_build' ), 1 );
54
55        // On Simple/Atomic, wpcom-admin-menu.php builds the Jetpack menu at
56        // priority 999999 and calls add_wp_admin_submenu() itself. Self-hosted
57        // has no such file, so we register our own. Priority 999 queues the item
58        // before Admin_Menu's priority-1000 callback.
59        if ( ! ( new Host() )->is_wpcom_platform() ) {
60            add_action( 'admin_menu', array( __CLASS__, 'add_wp_admin_submenu' ), 999 );
61        }
62    }
63
64    /**
65     * Register the Podcast submenu under the Jetpack menu.
66     */
67    public static function add_wp_admin_submenu() {
68        // Prefer the wp-build render function once it's defined (by
69        // maybe_load_wp_build() at admin_menu priority 1); fall back otherwise.
70        $wp_build_render = 'jetpack_podcast_jetpack_podcast_dashboard_wp_admin_render_page';
71        $callback        = function_exists( $wp_build_render ) ? $wp_build_render : array( __CLASS__, 'render' );
72
73        if ( ( new Host() )->is_wpcom_platform() ) {
74            $page_suffix = add_submenu_page(
75                'jetpack',
76                /** "Podcast" is a product name, do not translate. */
77                'Podcast',
78                'Podcast',
79                'manage_options',
80                self::ADMIN_PAGE_SLUG,
81                $callback
82            );
83        } else {
84            $page_suffix = Admin_Menu::add_menu(
85                /** "Podcast" is a product name, do not translate. */
86                'Podcast',
87                'Podcast',
88                'manage_options',
89                self::ADMIN_PAGE_SLUG,
90                $callback,
91                self::MENU_POSITION
92            );
93        }
94
95        if ( $page_suffix ) {
96            add_action( 'load-' . $page_suffix, array( __CLASS__, 'admin_init' ) );
97        }
98    }
99
100    /**
101     * Wire admin-init actions once we know the Podcast page is loading.
102     */
103    public static function admin_init() {
104        // MediaUpload (cover-image-control) reads wp.media.view — only defined after this runs.
105        add_action( 'admin_enqueue_scripts', 'wp_enqueue_media' );
106    }
107
108    /**
109     * Hooked at admin_menu priority 1 so polyfills register before
110     * `wp_default_scripts` fires and the wp-build render function is defined
111     * before `add_wp_admin_submenu()` runs (priority 999 on self-hosted, 999999
112     * on Simple/Atomic).
113     */
114    public static function maybe_load_wp_build() {
115        if ( ! self::is_podcast_admin_request() ) {
116            return;
117        }
118
119        self::load_wp_build();
120        add_action( 'current_screen', array( __CLASS__, 'alias_screen_id_for_wp_build' ) );
121        add_filter( 'jetpack_admin_js_script_data', array( __CLASS__, 'inject_podcast_script_data' ) );
122    }
123
124    /**
125     * Add the podcast gate boolean to `window.JetpackScriptData`.
126     *
127     * Hooked from `maybe_load_wp_build()` so it only runs when the request is
128     * for the podcast admin page.
129     *
130     * @param array $data Script data being injected.
131     * @return array
132     */
133    public static function inject_podcast_script_data( $data ) {
134        if ( ! is_array( $data ) ) {
135            $data = array();
136        }
137
138        $is_wpcom = ( new Host() )->is_wpcom_platform();
139
140        if ( ! $is_wpcom && empty( $data['site']['wpcom']['blog_id'] ) ) {
141            $blog_id = (int) Connection_Manager::get_site_id( true );
142            if ( $blog_id > 0 ) {
143                $data['site']['wpcom']['blog_id'] = $blog_id;
144            }
145        }
146
147        // Self-hosted upsells the Growth plan; WordPress.com keeps Premium.
148        // `product_slug` is fed straight to the checkout URL; `plan_name` is a
149        // product name shown in the locked-preview copy (not translated).
150        $data['podcast'] = array(
151            'has_product_access'  => Podcast_Gate::has_product_access(),
152            'is_connected'        => $is_wpcom || ( new Connection_Manager( 'jetpack' ) )->is_connected(),
153            'show_url_hosts'      => Settings::SHOW_URL_HOSTS,
154            'show_url_max_length' => Settings::SHOW_URL_MAX_LENGTH,
155            'preload'             => rest_preload_api_request( array(), '/wpcom/v2/podcast/settings' ),
156            'selected_category'   => self::get_selected_category(),
157            'upgrade'             => array(
158                'product_slug' => $is_wpcom ? 'premium' : 'jetpack_growth_yearly',
159                'plan_name'    => $is_wpcom ? 'Premium' : 'Growth',
160            ),
161        );
162
163        return $data;
164    }
165
166    /**
167     * The currently designated podcast category, injected so the settings
168     * picker can label its selected option on first paint instead of waiting on
169     * the client-side taxonomy→terms fetch. The full list still loads lazily.
170     *
171     * @return array{id:int, name:string}|null Null when no category is set.
172     */
173    public static function get_selected_category() {
174        $category_id = (int) get_option( 'podcasting_category_id', 0 );
175        if ( $category_id <= 0 ) {
176            return null;
177        }
178
179        $term = get_term( $category_id, 'category' );
180        if ( ! $term instanceof \WP_Term ) {
181            return null;
182        }
183
184        return array(
185            'id'   => (int) $term->term_id,
186            'name' => $term->name,
187        );
188    }
189
190    /**
191     * The build artifact may be absent on a fresh checkout before
192     * `pnpm build` has run; in that case `add_wp_admin_submenu()` falls back
193     * to `render()` so the page still loads (just without the React app).
194     */
195    private static function load_wp_build() {
196        $build_index = dirname( __DIR__ ) . '/build/build.php';
197
198        if ( ! file_exists( $build_index ) ) {
199            return;
200        }
201
202        require_once $build_index;
203
204        WP_Build_Polyfills::register(
205            'jetpack-podcast',
206            array_merge( WP_Build_Polyfills::SCRIPT_HANDLES, WP_Build_Polyfills::MODULE_IDS )
207        );
208    }
209
210    /**
211     * Alias the current screen id to wp-build's expected slug.
212     *
213     * @param \WP_Screen|null $screen The current screen object (passed by WP).
214     */
215    public static function alias_screen_id_for_wp_build( $screen ) {
216        if ( ! is_object( $screen ) ) {
217            return;
218        }
219
220        $screen->id = self::WP_BUILD_SLUG;
221    }
222
223    /**
224     * Fallback render used when the wp-build artifact is missing.
225     */
226    public static function render() {
227        ?>
228        <div class="wrap">
229            <h1>Podcast</h1>
230        </div>
231        <?php
232    }
233
234    /**
235     * Whether the current request targets the Podcast admin page.
236     */
237    private static function is_podcast_admin_request() {
238        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
239        if ( ! is_admin() || ! isset( $_GET['page'] ) ) {
240            return false;
241        }
242
243        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
244        return self::ADMIN_PAGE_SLUG === sanitize_text_field( wp_unslash( $_GET['page'] ) );
245    }
246}