Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.14% covered (success)
99.14%
692 / 698
78.26% covered (warning)
78.26%
18 / 23
CRAP
0.00% covered (danger)
0.00%
0 / 1
Main_Features
99.14% covered (success)
99.14%
692 / 698
78.26% covered (warning)
78.26%
18 / 23
118
0.00% covered (danger)
0.00%
0 / 1
 module_works_locally
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 get_feature_definitions
100.00% covered (success)
100.00%
426 / 426
100.00% covered (success)
100.00%
1 / 1
3
 get_plan_badges
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
7
 filter_upgrade
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 get_upgrade
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
6
 is_included_in_plan
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
11
 pays_for_product
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
8
 get_switchable_plugins
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 get_plugin_status
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 get_plugin_override
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
6.02
 get_plugin_file
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
8
 get_hosting_plugin_slug
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_hosting_plugin
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
3.58
 plugin_slug_from_path
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 is_only_my_jetpack_provider
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 get_product_class_for_plugin
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
6.10
 get_module_groups
100.00% covered (success)
100.00%
53 / 53
100.00% covered (success)
100.00%
1 / 1
1
 get_hidden_modules
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 get_state
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 get_install_access
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 get_features
98.11% covered (success)
98.11%
52 / 53
0.00% covered (danger)
0.00%
0 / 1
21
 get_screenshot_url
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_feature_manage_url
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
9.06
1<?php
2/**
3 * Static catalog of the main Jetpack features.
4 *
5 * @package automattic/my-jetpack
6 */
7
8namespace Automattic\Jetpack\My_Jetpack;
9
10use Automattic\Jetpack\Plugins_Installer;
11use Automattic\Jetpack\Status\Host;
12
13/**
14 * Describes the main Jetpack features: their copy, links and how a site owner gets each one.
15 *
16 * The descriptive fields may later come from a WordPress.com endpoint; what a feature is
17 * delivered by stays local, since only the site knows what is installed on it.
18 */
19class Main_Features {
20
21    /**
22     * A plugin is not on the site at all.
23     */
24    const PLUGIN_NOT_INSTALLED = 'not-installed';
25
26    /**
27     * A plugin is installed and switched off.
28     */
29    const PLUGIN_INACTIVE = 'inactive';
30
31    /**
32     * A plugin is installed and running.
33     */
34    const PLUGIN_ACTIVE = 'active';
35
36    /**
37     * The current user may install plugins here.
38     */
39    const INSTALLS_ALLOWED = 'allowed';
40
41    /**
42     * Nobody may install plugins here: file changes are turned off for the whole site.
43     */
44    const INSTALLS_DISABLED = 'disabled';
45
46    /**
47     * Others may install plugins here, but not the current user.
48     */
49    const INSTALLS_NOT_PERMITTED = 'not_permitted';
50
51    /**
52     * Whether module metadata proves it can run without either connection.
53     *
54     * @since 6.10.0
55     * @param string $slug Module slug.
56     * @return bool
57     */
58    public static function module_works_locally( $slug ) {
59        // Standalone module callbacks can ignore connection filters; missing metadata cannot prove local operation.
60        $details = ( new \Automattic\Jetpack\Modules() )->get( $slug );
61        return $details && ! $details['requires_connection'] && ! $details['requires_user_connection'];
62    }
63
64    /**
65     * The static feature catalog.
66     *
67     * Keyed by feature slug. `delivery` says what switches the feature: Protect ships inside
68     * Jetpack too, but its switch is its own plugin, so it sets `jetpack` false and names
69     * only that. `needs_account` repeats the feature module's "Requires User Connection" header.
70     *
71     * @return array<string, array<string, mixed>> Feature definitions keyed by feature slug.
72     */
73    public static function get_feature_definitions() {
74        // Built once per locale: the REST route asks for it on every `rest_api_init` to
75        // bound its plugin argument, and rebuilding a hundred translated strings for that
76        // is a cost every request to the site pays, not just this page.
77        static $definitions = array();
78
79        $locale = get_user_locale();
80
81        if ( isset( $definitions[ $locale ] ) ) {
82            return $definitions[ $locale ];
83        }
84
85        $definitions[ $locale ] = array(
86            'activity-log'      => array(
87                'info_url'         => 'https://jetpack.com/security/activity-log/',
88                'docs_url'         => 'https://jetpack.com/support/activity-log/',
89                'name'             => __( 'Activity Log', 'jetpack-my-jetpack' ),
90                'description'      => __( 'See what changed on your site, when it happened, and who did it.', 'jetpack-my-jetpack' ),
91                'long_description' => __( 'When something breaks, see exactly what changed and who changed it, from published posts to plugin updates and logins. Every connected site gets its 20 most recent events for free.', 'jetpack-my-jetpack' ),
92                'icon'             => 'list',
93                'admin_page'       => 'jetpack-activity-log',
94                'module'           => 'activity-log',
95                'needs_account'    => true,
96                'free_highlights'  => array(
97                    __( 'Your 20 most recent events', 'jetpack-my-jetpack' ),
98                    __( 'Who changed what, from posts to plugin updates and logins', 'jetpack-my-jetpack' ),
99                ),
100                'paid_highlights'  => array(
101                    __( '30 days of history with VaultPress Backup or Jetpack Security, a full year with Jetpack Complete', 'jetpack-my-jetpack' ),
102                    __( 'Filter events by activity type and date range', 'jetpack-my-jetpack' ),
103                ),
104                'delivery'         => array(
105                    'jetpack' => true,
106                    'plugin'  => '',
107                    'free'    => true,
108                ),
109                'plans'            => array( 'backup', 'security', 'pro', 'complete' ),
110                // The WordPress.com site feature the Activity Log package gates its paid history on.
111                'paid_feature'     => 'full-activity-log',
112            ),
113            'anti-spam'         => array(
114                'info_url'         => 'https://akismet.com/',
115                'docs_url'         => 'https://akismet.com/support/',
116                'name'             => __( 'Akismet Anti-spam', 'jetpack-my-jetpack' ),
117                'description'      => __( 'Stop comment and form spam without making visitors solve CAPTCHAs.', 'jetpack-my-jetpack' ),
118                'long_description' => __( 'Akismet filters spam out of your comments and form submissions in the background, so you stop moderating junk and visitors never have to prove they are human. When it gets one wrong, mark it and Akismet learns from your correction.', 'jetpack-my-jetpack' ),
119                'icon'             => 'comment',
120                'product'          => 'anti-spam',
121                'interstitial'     => '/add-akismet',
122                'free_highlights'  => array(
123                    __( 'Comment and form spam filtering', 'jetpack-my-jetpack' ),
124                    __( 'No CAPTCHAs for your visitors', 'jetpack-my-jetpack' ),
125                    __( 'For personal, non-commercial sites', 'jetpack-my-jetpack' ),
126                ),
127                'paid_highlights'  => array(
128                    __( 'Use it on business and commercial sites', 'jetpack-my-jetpack' ),
129                    __( 'Advanced stats and diagnostics for your spam activity', 'jetpack-my-jetpack' ),
130                    __( 'Email support', 'jetpack-my-jetpack' ),
131                ),
132                'delivery'         => array(
133                    'jetpack'     => false,
134                    'plugin'      => 'akismet',
135                    'plugin_name' => __( 'Akismet Anti-spam', 'jetpack-my-jetpack' ),
136                    'plugin_url'  => 'https://wordpress.org/plugins/akismet/',
137                    'free'        => true,
138                ),
139                'plans'            => array( 'security', 'pro', 'complete' ),
140                'paid_product'     => __( 'Jetpack Akismet Anti-spam', 'jetpack-my-jetpack' ),
141            ),
142            'backup'            => array(
143                'info_url'         => 'https://jetpack.com/backup/',
144                'docs_url'         => 'https://jetpack.com/support/backup/',
145                'name'             => __( 'VaultPress Backup', 'jetpack-my-jetpack' ),
146                'description'      => __( 'Get your site back online in one click if anything goes wrong.', 'jetpack-my-jetpack' ),
147                'long_description' => __( 'Every change you make is saved off-site as it happens, so your content and WooCommerce orders stay safe even if your host goes down. Restore your site in one click, even when it is offline.', 'jetpack-my-jetpack' ),
148                'icon'             => 'backup',
149                'product'          => 'backup',
150                // The plan decides it, whichever plugin delivers it, so its card has no switch.
151                'plan_driven'      => true,
152                'needs_account'    => true,
153                'interstitial'     => '/add-backup',
154                'setup_note'       => __( 'Backups start once you add a paid plan.', 'jetpack-my-jetpack' ),
155                'paid_highlights'  => array(
156                    __( 'Real-time backups of your files, database and WooCommerce orders', 'jetpack-my-jetpack' ),
157                    __( 'One-click restores, even from the Jetpack mobile app', 'jetpack-my-jetpack' ),
158                    __( 'Move your site to any host without a developer', 'jetpack-my-jetpack' ),
159                ),
160                'delivery'         => array(
161                    'jetpack'     => true,
162                    'plugin'      => 'jetpack-backup',
163                    'plugin_name' => __( 'Jetpack VaultPress Backup', 'jetpack-my-jetpack' ),
164                    'plugin_url'  => 'https://wordpress.org/plugins/jetpack-backup/',
165                    'free'        => false,
166                ),
167                'plans'            => array( 'security', 'pro', 'complete' ),
168                'paid_product'     => __( 'Jetpack VaultPress Backup', 'jetpack-my-jetpack' ),
169            ),
170            'blaze'             => array(
171                'info_url'         => 'https://jetpack.com/blaze/',
172                'docs_url'         => 'https://jetpack.com/support/blaze/',
173                'name'             => __( 'Blaze Ads', 'jetpack-my-jetpack' ),
174                'description'      => __( 'Put your best posts in front of new readers on WordPress.com and Tumblr.', 'jetpack-my-jetpack' ),
175                'long_description' => __( 'Turn a post or page into an ad shown across Tumblr and WordPress.com, aimed at the locations, languages and interests you choose.', 'jetpack-my-jetpack' ),
176                'icon'             => 'megaphone',
177                'admin_page'       => 'advertising',
178                'module'           => 'blaze',
179                // No plan sells Blaze, so it says how campaigns are paid for instead.
180                'pricing_notes'    => array(
181                    __( 'No subscription required', 'jetpack-my-jetpack' ),
182                    __( 'Set a budget for each campaign', 'jetpack-my-jetpack' ),
183                    __( 'Pay only for the ad views you get', 'jetpack-my-jetpack' ),
184                ),
185                'delivery'         => array(
186                    'jetpack'     => true,
187                    'plugin'      => 'blaze-ads',
188                    'plugin_name' => __( 'Blaze Ads', 'jetpack-my-jetpack' ),
189                    'plugin_url'  => 'https://wordpress.org/plugins/blaze-ads/',
190                    'free'        => false,
191                ),
192            ),
193            'boost'             => array(
194                'info_url'         => 'https://jetpack.com/boost/',
195                'docs_url'         => 'https://jetpack.com/support/jetpack-boost/',
196                'name'             => __( 'Boost', 'jetpack-my-jetpack' ),
197                'description'      => __( 'Make your site load faster in a few clicks, no developer required.', 'jetpack-my-jetpack' ),
198                'long_description' => __( 'Your pages reach visitors sooner: Boost caches them, defers non-essential JavaScript and serves resized images from a worldwide network. Mobile and desktop performance scores show you the impact of each change.', 'jetpack-my-jetpack' ),
199                'icon'             => 'trending-up',
200                'product'          => 'boost',
201                'essential'        => true,
202                'interstitial'     => '/add-boost',
203                'free_highlights'  => array(
204                    __( 'Page caching and deferred JavaScript', 'jetpack-my-jetpack' ),
205                    __( 'Resized images from a worldwide image CDN', 'jetpack-my-jetpack' ),
206                    __( 'Mobile and desktop performance scores', 'jetpack-my-jetpack' ),
207                ),
208                'paid_highlights'  => array(
209                    __( 'Critical CSS that updates automatically as your site changes', 'jetpack-my-jetpack' ),
210                    __( 'Performance history for mobile and desktop, including Core Web Vitals', 'jetpack-my-jetpack' ),
211                    __( 'Control over the quality and size of images from the image CDN', 'jetpack-my-jetpack' ),
212                ),
213                'delivery'         => array(
214                    'jetpack'     => false,
215                    'plugin'      => 'jetpack-boost',
216                    'plugin_name' => __( 'Jetpack Boost', 'jetpack-my-jetpack' ),
217                    'plugin_url'  => 'https://wordpress.org/plugins/jetpack-boost/',
218                    'free'        => true,
219                ),
220                'plans'            => array( 'pro', 'complete' ),
221                'paid_product'     => __( 'Jetpack Boost', 'jetpack-my-jetpack' ),
222            ),
223            'crm'               => array(
224                'info_url'         => 'https://jetpackcrm.com/',
225                'docs_url'         => 'https://kb.jetpackcrm.com/article-categories/getting-started/',
226                'name'             => __( 'Jetpack CRM', 'jetpack-my-jetpack' ),
227                'description'      => __( 'Keep every lead and customer in one place, right inside WordPress.', 'jetpack-my-jetpack' ),
228                'long_description' => __( 'Track leads, send quotes and invoices, and see each customer’s full history from your dashboard. Because it runs on your own site, your customer data stays yours.', 'jetpack-my-jetpack' ),
229                'icon'             => 'people',
230                'product'          => 'crm',
231                'interstitial'     => '/add-crm',
232                'free_highlights'  => array(
233                    __( 'Leads, contacts and each customer’s history', 'jetpack-my-jetpack' ),
234                    __( 'Quotes and invoices', 'jetpack-my-jetpack' ),
235                    __( 'Customer data that stays on your site', 'jetpack-my-jetpack' ),
236                ),
237                'paid_highlights'  => array(
238                    __( 'A sales dashboard, sales funnels and automations to follow up on leads', 'jetpack-my-jetpack' ),
239                    __( 'Take payments through Stripe and PayPal', 'jetpack-my-jetpack' ),
240                    __( 'Sync contacts from Gravity Forms, Contact Form 7 and Mailchimp', 'jetpack-my-jetpack' ),
241                ),
242                'delivery'         => array(
243                    'jetpack'     => false,
244                    'plugin'      => 'zero-bs-crm',
245                    'plugin_name' => __( 'Jetpack CRM', 'jetpack-my-jetpack' ),
246                    'plugin_url'  => 'https://wordpress.org/plugins/zero-bs-crm/',
247                    'free'        => true,
248                ),
249                'plans'            => array( 'pro', 'complete' ),
250                'paid_product'     => __( 'Jetpack CRM Entrepreneur', 'jetpack-my-jetpack' ),
251            ),
252            'jetpack-ai'        => array(
253                'info_url'         => 'https://jetpack.com/ai/',
254                'docs_url'         => 'https://jetpack.com/support/create-better-content-with-jetpack-ai/',
255                'name'             => __( 'Jetpack AI', 'jetpack-my-jetpack' ),
256                'description'      => __( 'Turn your ideas into ready-to-publish content without leaving the editor.', 'jetpack-my-jetpack' ),
257                'long_description' => __( 'Generate posts, tables, forms and images from a prompt, then change the tone, translate, or fix unclear sentences to make your writing easier to read. Before you publish, improve your title and create a featured image in one click.', 'jetpack-my-jetpack' ),
258                'icon'             => 'star',
259                'product'          => 'jetpack-ai',
260                'needs_account'    => true,
261                'interstitial'     => '/add-jetpack-ai',
262                'free_highlights'  => array(
263                    /* translators: %d is the number of free requests, such as 20. */
264                    sprintf( _n( '%d request to try it out', '%d requests to try it out', Products\Jetpack_Ai::FREE_REQUESTS, 'jetpack-my-jetpack' ), Products\Jetpack_Ai::FREE_REQUESTS ),
265                    __( 'Generate, rewrite and translate in the editor', 'jetpack-my-jetpack' ),
266                ),
267                'paid_highlights'  => array(
268                    /* translators: %d is the number of free requests, such as 20. */
269                    sprintf( _n( 'Keep creating after your %d free request runs out', 'Keep creating after your %d free requests run out', Products\Jetpack_Ai::FREE_REQUESTS, 'jetpack-my-jetpack' ), Products\Jetpack_Ai::FREE_REQUESTS ),
270                    __( 'Generate a logo for your site', 'jetpack-my-jetpack' ),
271                    __( 'Priority support', 'jetpack-my-jetpack' ),
272                ),
273                'delivery'         => array(
274                    'jetpack' => true,
275                    'plugin'  => '',
276                    'free'    => true,
277                ),
278                'plans'            => array( 'pro', 'complete' ),
279                'paid_product'     => __( 'Jetpack AI Assistant', 'jetpack-my-jetpack' ),
280            ),
281            'jetpack-forms'     => array(
282                'info_url'         => 'https://jetpack.com/forms/',
283                'docs_url'         => 'https://jetpack.com/support/jetpack-blocks/contact-form/',
284                'name'             => __( 'Forms', 'jetpack-my-jetpack' ),
285                'description'      => __( 'From quick contact forms to multistep surveys, everything you need is included.', 'jetpack-my-jetpack' ),
286                'long_description' => __( 'Break long forms into steps with a progress bar, and get every response by email and in your dashboard, with no limit on responses. Upgrade to add secure file uploads that keep harmful files off your server.', 'jetpack-my-jetpack' ),
287                'icon'             => 'list-bullets',
288                'product'          => 'jetpack-forms',
289                'essential'        => true,
290                'free_highlights'  => array(
291                    __( 'Unlimited responses, by email and in your dashboard', 'jetpack-my-jetpack' ),
292                    __( 'Multi-step forms with a progress bar', 'jetpack-my-jetpack' ),
293                ),
294                'paid_highlights'  => array(
295                    __( 'Secure file uploads that keep harmful files off your server', 'jetpack-my-jetpack' ),
296                    __( 'Visitors’ files stay private, visible only to you and your editors', 'jetpack-my-jetpack' ),
297                    __( 'Up to 10 files per field, 20 MB each', 'jetpack-my-jetpack' ),
298                ),
299                'delivery'         => array(
300                    'jetpack' => true,
301                    'plugin'  => '',
302                    'free'    => true,
303                ),
304                'plans'            => array( 'pro', 'complete' ),
305            ),
306            'newsletter'        => array(
307                'info_url'         => 'https://jetpack.com/newsletter/',
308                'docs_url'         => 'https://jetpack.com/support/newsletter/',
309                'name'             => __( 'Newsletter', 'jetpack-my-jetpack' ),
310                'description'      => __( 'Build your subscriber list and email your newsletter automatically.', 'jetpack-my-jetpack' ),
311                'long_description' => __( 'Every post you publish goes out by email, with no cap on subscribers or sends. New readers find you through the WordPress.com Reader, and when you’re ready, you can charge for your newsletter.', 'jetpack-my-jetpack' ),
312                'icon'             => 'envelope',
313                'product'          => 'newsletter',
314                'module'           => 'subscriptions',
315                'needs_account'    => true,
316                'free_highlights'  => array(
317                    __( 'No limit on subscribers or email sends', 'jetpack-my-jetpack' ),
318                    __( 'Import up to 100 subscribers', 'jetpack-my-jetpack' ),
319                    __( 'Subscribe forms and pop-ups to collect subscribers on your site', 'jetpack-my-jetpack' ),
320                    __( 'New subscribers from the WordPress.com Reader', 'jetpack-my-jetpack' ),
321                    /* translators: %d is the percentage fee, such as 10. */
322                    sprintf( __( 'Paid newsletter with a %d%% fee on subscriptions', 'jetpack-my-jetpack' ), 10 ),
323                ),
324                'paid_highlights'  => array(
325                    __( 'Import as many subscribers as you like', 'jetpack-my-jetpack' ),
326                    /* translators: %d is the percentage fee, such as 10. */
327                    sprintf( __( 'Paid newsletter with a %d%% fee on subscriptions', 'jetpack-my-jetpack' ), 2 ),
328                ),
329                'delivery'         => array(
330                    'jetpack' => true,
331                    'plugin'  => '',
332                    'free'    => true,
333                ),
334                'plans'            => array( 'growth', 'pro', 'complete' ),
335            ),
336            'podcast'           => array(
337                'info_url'         => 'https://jetpack.com/podcast/',
338                'docs_url'         => 'https://jetpack.com/support/jetpack-podcast/',
339                'name'             => __( 'Podcast', 'jetpack-my-jetpack' ),
340                'description'      => __( 'Start a podcast on your own site and reach listeners on every major app.', 'jetpack-my-jetpack' ),
341                'long_description' => __( 'Your show lives on your own site, so the feed, the audio and your listeners stay yours, with no separate podcast service to sign up for. Submit it to Apple Podcasts, Spotify, Pocket Casts and more, and your newsletter subscribers get new episodes too.', 'jetpack-my-jetpack' ),
342                'icon'             => 'audio',
343                'admin_page'       => 'jetpack-podcast',
344                'module'           => 'podcast',
345                'free_highlights'  => array(
346                    __( 'Unlimited episodes, each one a post on your site', 'jetpack-my-jetpack' ),
347                    __( 'A podcast feed on your own domain', 'jetpack-my-jetpack' ),
348                    __( 'Submit to Apple Podcasts, Spotify and more', 'jetpack-my-jetpack' ),
349                    __( 'New episodes go out to your newsletter subscribers and the WordPress.com Reader', 'jetpack-my-jetpack' ),
350                ),
351                'paid_highlights'  => array(
352                    __( 'Listener stats by episode, app and country', 'jetpack-my-jetpack' ),
353                    __( 'An episodes dashboard for your whole back catalog', 'jetpack-my-jetpack' ),
354                    __( 'An episode player with chapters, transcripts and soundbites', 'jetpack-my-jetpack' ),
355                    __( 'Video episodes and Podcasting 2.0 support', 'jetpack-my-jetpack' ),
356                ),
357                'delivery'         => array(
358                    'jetpack' => true,
359                    'plugin'  => '',
360                    'free'    => true,
361                ),
362                'plans'            => array( 'growth', 'pro', 'complete' ),
363            ),
364            'protect-dashboard' => array(
365                'info_url'         => 'https://jetpack.com/protect/',
366                'docs_url'         => 'https://jetpack.com/support/jetpack-protect/',
367                'name'             => __( 'Protect', 'jetpack-my-jetpack' ),
368                'description'      => __( 'Get warned about vulnerable plugins and stop login attacks on your site.', 'jetpack-my-jetpack' ),
369                'long_description' => __( 'Your WordPress version, plugins and themes are checked daily against a database of known vulnerabilities, with results in your dashboard. Login attacks are blocked automatically, and you can block specific IP addresses yourself.', 'jetpack-my-jetpack' ),
370                'icon'             => 'shield',
371                'product'          => 'protect',
372                // Not `protect`: that module is Brute Force Protection, which has its own row.
373                'module'           => Products\Protect::DASHBOARD_MODULE_NAME,
374                'essential'        => true,
375                'interstitial'     => '/add-protect',
376                'free_highlights'  => array(
377                    __( 'Daily checks for known vulnerabilities', 'jetpack-my-jetpack' ),
378                    __( 'Automatic blocking of login attacks', 'jetpack-my-jetpack' ),
379                    __( 'Block IP addresses yourself', 'jetpack-my-jetpack' ),
380                ),
381                'paid_highlights'  => array(
382                    __( 'Daily malware scanning with one-click fixes for most threats', 'jetpack-my-jetpack' ),
383                    __( 'Automatic firewall rules that block harmful requests', 'jetpack-my-jetpack' ),
384                    __( 'Instant email alerts when a threat is found', 'jetpack-my-jetpack' ),
385                ),
386                'delivery'         => array(
387                    // Never on WordPress.com Simple, whose partial bootstrap skips the module's flag.
388                    'jetpack'     => ! ( new Host() )->is_wpcom_simple() && Products\Protect::is_dashboard_module_available(),
389                    'plugin'      => 'jetpack-protect',
390                    'plugin_name' => __( 'Jetpack Protect', 'jetpack-my-jetpack' ),
391                    'plugin_url'  => 'https://wordpress.org/plugins/jetpack-protect/',
392                    'free'        => true,
393                ),
394                'plans'            => array( 'security', 'pro', 'complete' ),
395                'paid_product'     => __( 'Jetpack Scan', 'jetpack-my-jetpack' ),
396            ),
397            'search'            => array(
398                'info_url'         => 'https://jetpack.com/search/',
399                'docs_url'         => 'https://jetpack.com/support/search/',
400                'name'             => __( 'Search', 'jetpack-my-jetpack' ),
401                'description'      => __( 'Help your visitors find the right post or product as they type.', 'jetpack-my-jetpack' ),
402                'long_description' => __( 'Visitors see relevant results as they type and can filter by category, tag, date or author, so they find what they need instead of leaving. Match search to your theme without writing code.', 'jetpack-my-jetpack' ),
403                'icon'             => 'search',
404                'product'          => 'search',
405                'interstitial'     => '/add-search',
406                'setup_note'       => __( 'To start using it, choose a plan next, including a free one.', 'jetpack-my-jetpack' ),
407                'free_highlights'  => array(
408                    __( 'Up to 500 search requests per month', 'jetpack-my-jetpack' ),
409                    __( 'Results as visitors type', 'jetpack-my-jetpack' ),
410                    __( 'Filters for category, tag, date and author', 'jetpack-my-jetpack' ),
411                ),
412                'paid_highlights'  => array(
413                    __( 'Room for more records and monthly searches as your site grows', 'jetpack-my-jetpack' ),
414                    __( 'AI Answers', 'jetpack-my-jetpack' ),
415                    __( 'No Jetpack branding, plus priority support', 'jetpack-my-jetpack' ),
416                ),
417                'delivery'         => array(
418                    'jetpack'     => true,
419                    'plugin'      => 'jetpack-search',
420                    'plugin_name' => __( 'Jetpack Search', 'jetpack-my-jetpack' ),
421                    'plugin_url'  => 'https://wordpress.org/plugins/jetpack-search/',
422                    'free'        => true,
423                ),
424                'plans'            => array( 'pro', 'complete' ),
425                'paid_product'     => __( 'Jetpack Search', 'jetpack-my-jetpack' ),
426            ),
427            'social'            => array(
428                'info_url'         => 'https://jetpack.com/social/',
429                'docs_url'         => 'https://jetpack.com/support/jetpack-social/',
430                'name'             => __( 'Social', 'jetpack-my-jetpack' ),
431                'description'      => __( 'Share your posts to your social networks automatically when you publish.', 'jetpack-my-jetpack' ),
432                'long_description' => __( 'Reach your followers on Facebook, Instagram, Threads, Bluesky, LinkedIn, Mastodon, Nextdoor and Tumblr without leaving WordPress. Posts share automatically when you publish, and you can re-share or schedule them later.', 'jetpack-my-jetpack' ),
433                'icon'             => 'share',
434                'product'          => 'social',
435                'needs_account'    => true,
436                'interstitial'     => '/add-social',
437                'free_highlights'  => array(
438                    __( 'Unlimited shares', 'jetpack-my-jetpack' ),
439                    __( 'Automatic sharing when you publish', 'jetpack-my-jetpack' ),
440                    __( 'Facebook, Instagram, Threads, Bluesky, LinkedIn and more', 'jetpack-my-jetpack' ),
441                ),
442                'paid_highlights'  => array(
443                    __( 'Tailor the message and media for each network', 'jetpack-my-jetpack' ),
444                    __( 'Save reusable message templates for each account', 'jetpack-my-jetpack' ),
445                    __( 'Branded share images from Social Image Generator', 'jetpack-my-jetpack' ),
446                ),
447                'delivery'         => array(
448                    'jetpack'     => true,
449                    'plugin'      => 'jetpack-social',
450                    'plugin_name' => __( 'Jetpack Social', 'jetpack-my-jetpack' ),
451                    'plugin_url'  => 'https://wordpress.org/plugins/jetpack-social/',
452                    'free'        => true,
453                ),
454                'plans'            => array( 'growth', 'pro', 'complete' ),
455                'paid_product'     => __( 'Jetpack Social', 'jetpack-my-jetpack' ),
456            ),
457            'stats'             => array(
458                'info_url'         => 'https://jetpack.com/stats/',
459                'docs_url'         => 'https://jetpack.com/support/jetpack-stats/',
460                'name'             => __( 'Stats', 'jetpack-my-jetpack' ),
461                'description'      => __( 'See how many people visit your site and which posts they read.', 'jetpack-my-jetpack' ),
462                'long_description' => __( 'Track views, visitors, top posts, referrers and visitor countries right from your WordPress dashboard. Learn what works without seeing who your individual visitors are.', 'jetpack-my-jetpack' ),
463                'icon'             => 'chart-bar',
464                'product'          => 'stats',
465                'essential'        => true,
466                'interstitial'     => '/add-stats',
467                'free_highlights'  => array(
468                    __( 'Views, visitors and top posts', 'jetpack-my-jetpack' ),
469                    __( 'Referrers and visitor countries', 'jetpack-my-jetpack' ),
470                ),
471                'paid_highlights'  => array(
472                    __( 'UTM tracking to see which campaigns bring you traffic', 'jetpack-my-jetpack' ),
473                    __( 'Device stats to see how visitors browse your site', 'jetpack-my-jetpack' ),
474                    __( 'Region and city locations, beyond country level', 'jetpack-my-jetpack' ),
475                ),
476                'delivery'         => array(
477                    'jetpack'     => true,
478                    'plugin'      => 'jetpack-stats',
479                    'plugin_name' => __( 'Jetpack Stats', 'jetpack-my-jetpack' ),
480                    'plugin_url'  => 'https://wordpress.org/plugins/jetpack-stats/',
481                    'free'        => true,
482                ),
483                'plans'            => array( 'growth', 'pro', 'complete' ),
484                'paid_product'     => __( 'Jetpack Stats', 'jetpack-my-jetpack' ),
485            ),
486            'videopress'        => array(
487                'info_url'         => 'https://jetpack.com/videopress/',
488                'docs_url'         => 'https://jetpack.com/support/jetpack-videopress/',
489                'name'             => __( 'VideoPress', 'jetpack-my-jetpack' ),
490                'description'      => __( 'Share ad-free, high-quality video that keeps visitors on your site.', 'jetpack-my-jetpack' ),
491                'long_description' => __( 'Keep the focus on your videos with ad-free playback that fits your brand. Drag and drop videos into the WordPress editor to get started.', 'jetpack-my-jetpack' ),
492                'icon'             => 'video',
493                'product'          => 'videopress',
494                'interstitial'     => '/add-videopress',
495                'free_highlights'  => array(
496                    __( 'Host one video', 'jetpack-my-jetpack' ),
497                    __( 'Ad-free playback up to 4K', 'jetpack-my-jetpack' ),
498                    __( 'Match the player to your brand', 'jetpack-my-jetpack' ),
499                ),
500                'paid_highlights'  => array(
501                    __( 'Host unlimited videos', 'jetpack-my-jetpack' ),
502                    __( 'Up to 1TB of video storage', 'jetpack-my-jetpack' ),
503                ),
504                'delivery'         => array(
505                    'jetpack'     => true,
506                    'plugin'      => 'jetpack-videopress',
507                    'plugin_name' => __( 'Jetpack VideoPress', 'jetpack-my-jetpack' ),
508                    'plugin_url'  => 'https://wordpress.org/plugins/jetpack-videopress/',
509                    'free'        => true,
510                ),
511                'plans'            => array( 'pro', 'complete' ),
512                'paid_product'     => __( 'Jetpack VideoPress', 'jetpack-my-jetpack' ),
513            ),
514        );
515
516        return $definitions[ $locale ];
517    }
518
519    /**
520     * The paid bundles that include a feature, named for display.
521     *
522     * Named by the bundle products themselves, so the Features tab and the rest of My Jetpack
523     * call a plan the same thing.
524     *
525     * @param array $definition One feature's catalog entry.
526     * @param bool  $local      Skip ownership lookups.
527     * @return array List of slug/name/owned triples, without ownership in local mode.
528     */
529    private static function get_plan_badges( $definition, $local = false ) {
530        $badges = array();
531
532        foreach ( $definition['plans'] ?? array() as $slug ) {
533            $bundle_class = Products::get_product_class( $slug );
534
535            if ( $bundle_class && ( $local ? 'pro' !== $slug : ( $bundle_class::has_paid_plan_for_product() || ! empty( $bundle_class::get_pricing_for_ui()['available'] ) ) ) ) {
536                $badge = array(
537                    'slug' => $slug,
538                    'name' => $bundle_class::get_title(),
539                );
540                if ( ! $local ) {
541                    $badge['owned'] = $bundle_class::has_paid_plan_for_product();
542                }
543                $badges[] = $badge;
544            }
545        }
546
547        return $badges;
548    }
549
550    /**
551     * Lets a host send a feature's Upgrade button to its own plans instead of Jetpack's.
552     *
553     * @param array  $upgrade  The Jetpack route, from get_upgrade().
554     * @param string $slug     The feature slug.
555     * @param bool   $included Whether the site already pays for the feature.
556     * @return array{path: string, name: string, url?: string}
557     */
558    private static function filter_upgrade( array $upgrade, $slug, $included ) {
559        /**
560         * Filters where a My Jetpack feature's Upgrade button goes.
561         *
562         * Set `url` to an absolute address to replace the My Jetpack route, which also drops the
563         * Jetpack plans the feature's details would otherwise link to. Ignored when `$included`.
564         *
565         * @since $$next-version$$
566         *
567         * @param array  $upgrade  `path` (a My Jetpack route) and `name` (the product sold), both empty when nothing is.
568         * @param string $slug     The feature slug.
569         * @param bool   $included Whether the site already pays for the feature.
570         */
571        $filtered = apply_filters( 'jetpack_my_jetpack_feature_upgrade', $upgrade, $slug, $included );
572
573        if ( ! is_array( $filtered ) ) {
574            return $upgrade;
575        }
576
577        $result = array(
578            'path' => (string) ( $filtered['path'] ?? '' ),
579            'name' => (string) ( $filtered['name'] ?? '' ),
580        );
581
582        // A site that already pays has nothing to buy, wherever a host would send it.
583        if ( ! $included && ! empty( $filtered['url'] ) ) {
584            $result['url'] = esc_url_raw( (string) $filtered['url'] );
585        }
586
587        return $result;
588    }
589
590    /**
591     * Where the modal's Upgrade button goes: the feature's own product page, else the cheapest bundle that includes it.
592     *
593     * @param array       $definition    A single entry from the feature catalog.
594     * @param string|null $product_class The product behind the feature, when it has one.
595     * @param bool        $included      Whether the site already pays for the feature.
596     * @return array{path: string, name: string} The My Jetpack route and the product it sells, both empty when nothing does.
597     */
598    private static function get_upgrade( $definition, $product_class, $included ) {
599        $none = array(
600            'path' => '',
601            'name' => '',
602        );
603
604        // Nothing to sell a site that already pays for the feature.
605        if ( $included ) {
606            return $none;
607        }
608
609        if ( ! empty( $definition['interstitial'] ) ) {
610            return array(
611                'path' => $definition['interstitial'],
612                'name' => $definition['paid_product'] ?? '',
613            );
614        }
615
616        // Plans list the cheapest bundle first, so this sells the least a site needs to buy.
617        foreach ( $definition['plans'] ?? array() as $plan ) {
618            $bundle_class = Products::get_product_class( $plan );
619
620            if ( $bundle_class && ! empty( $bundle_class::get_pricing_for_ui()['available'] ) ) {
621                return array(
622                    'path' => '/add-' . $plan,
623                    'name' => $bundle_class::get_title(),
624                );
625            }
626        }
627
628        return $none;
629    }
630
631    /**
632     * Whether the site already pays for a feature, directly or through a bundle.
633     *
634     * @param array       $definition    A single entry from the feature catalog.
635     * @param string|null $product_class The product behind the feature, when it has one.
636     * @return bool
637     */
638    private static function is_included_in_plan( $definition, $product_class ) {
639        // An expired purchase still matches by slug; a plan-driven card offers to renew it instead.
640        $expiry_counts = ! empty( $definition['plan_driven'] );
641
642        if ( $product_class && self::pays_for_product( $product_class ) && ! ( $expiry_counts && $product_class::is_paid_plan_expired() ) ) {
643            return true;
644        }
645
646        foreach ( $definition['plans'] ?? array() as $plan ) {
647            $bundle_class = Products::get_product_class( $plan );
648
649            if ( $bundle_class && $bundle_class::has_paid_plan_for_product() && ! ( $expiry_counts && $bundle_class::is_paid_plan_expired() ) ) {
650                return true;
651            }
652        }
653
654        // Covers plans no bundle class lists, such as the legacy Jetpack Personal, Premium and Professional.
655        return ! empty( $definition['paid_feature'] ) && Product::does_site_have_feature( $definition['paid_feature'] );
656    }
657
658    /**
659     * Whether the site pays for a product, not counting the product's own free plan.
660     *
661     * @param string $product_class The product behind the feature.
662     * @return bool
663     */
664    private static function pays_for_product( $product_class ) {
665        if ( ! $product_class::has_paid_plan_for_product() ) {
666            return false;
667        }
668
669        $free_slug = $product_class::get_wpcom_free_product_slug();
670        $purchases = Wpcom_Products::get_site_current_purchases();
671        $purchases = is_array( $purchases ) ? $purchases : array();
672        $paid      = $free_slug
673            ? array_filter( $purchases, fn( $purchase ) => false === strpos( $purchase->product_slug, $free_slug ) )
674            : $purchases;
675
676        if ( count( $paid ) === count( $purchases ) ) {
677            return true;
678        }
679
680        /*
681         * A free plan such as `jetpack_search_free` matches the paid slug by substring and gets the
682         * same site feature, so only a purchase besides it counts.
683         */
684        $paid_slugs = array_merge(
685            $product_class::get_paid_bundles_that_include_product(),
686            $product_class::get_paid_plan_product_slugs()
687        );
688
689        foreach ( $paid as $purchase ) {
690            foreach ( $paid_slugs as $paid_slug ) {
691                if ( false !== strpos( $purchase->product_slug, $paid_slug ) ) {
692                    return true;
693                }
694            }
695        }
696
697        return false;
698    }
699
700    /**
701     * The plugins the Features tab may install or switch: every standalone plugin in the map,
702     * and Jetpack itself.
703     *
704     * @return string[] WordPress.org plugin slugs.
705     */
706    public static function get_switchable_plugins() {
707        $plugins = array_filter( array_column( array_column( self::get_feature_definitions(), 'delivery' ), 'plugin' ) );
708
709        return array_values( array_unique( array_merge( array( Product::JETPACK_PLUGIN_SLUG ), $plugins ) ) );
710    }
711
712    /**
713     * Whether a plugin is missing, installed and off, or running.
714     *
715     * @param string      $slug          WordPress.org plugin slug.
716     * @param string|null $product_class The product behind the plugin, when it has one.
717     * @return string One of the PLUGIN_* constants.
718     */
719    public static function get_plugin_status( $slug, $product_class = null ) {
720        $file = self::get_plugin_file( $slug, $product_class );
721
722        if ( ! $file ) {
723            return self::PLUGIN_NOT_INSTALLED;
724        }
725
726        // A network-activated plugin is running here, whatever this site's own list says.
727        return 'inactive' === Plugins_Installer::get_plugin_status( $file )
728            ? self::PLUGIN_INACTIVE
729            : self::PLUGIN_ACTIVE;
730    }
731
732    /**
733     * Whether a plugin is forced on or off where this site's own switch can't change it.
734     *
735     * Mirrors `Jetpack_Modules_Overrides`: an `option_active_plugins` filter that adds or
736     * drops the plugin whatever the stored list says, or a network activation.
737     *
738     * @since 6.6.0
739     *
740     * @param string      $slug          WordPress.org plugin slug.
741     * @param string|null $product_class The product behind the plugin, when it has one.
742     * @return string 'active' or 'inactive' when forced, or an empty string when the owner decides.
743     */
744    public static function get_plugin_override( $slug, $product_class = null ) {
745        $file = self::get_plugin_file( $slug, $product_class );
746
747        if ( ! $file ) {
748            return '';
749        }
750
751        if ( 'network-active' === Plugins_Installer::get_plugin_status( $file ) ) {
752            return self::PLUGIN_ACTIVE;
753        }
754
755        if ( ! has_filter( 'option_active_plugins' ) ) {
756            return '';
757        }
758
759        /** This filter is documented in wp-includes/option.php */
760        if ( in_array( $file, (array) apply_filters( 'option_active_plugins', array(), 'active_plugins' ), true ) ) {
761            return self::PLUGIN_ACTIVE;
762        }
763
764        /** This filter is documented in wp-includes/option.php */
765        if ( ! in_array( $file, (array) apply_filters( 'option_active_plugins', array( $file ), 'active_plugins' ), true ) ) {
766            return self::PLUGIN_INACTIVE;
767        }
768
769        return '';
770    }
771
772    /**
773     * The installed file for a plugin, by the names its product declares where there is one.
774     *
775     * Prefer an active copy when several declared folders are installed, including -dev checkouts.
776     *
777     * @param string      $slug          WordPress.org plugin slug.
778     * @param string|null $product_class The product behind the plugin, when it has one.
779     * @return string|false The plugin file, or false when it is not installed.
780     */
781    public static function get_plugin_file( $slug, $product_class = null ) {
782        if ( Product::JETPACK_PLUGIN_SLUG === $slug ) {
783            $filenames = Product::JETPACK_PLUGIN_FILENAME;
784        } elseif ( $product_class && $product_class::$has_standalone_plugin ) {
785            $filenames = $product_class::get_plugin_filename();
786        } else {
787            return Plugins_Installer::get_plugin_id_by_slug( $slug );
788        }
789
790        $plugins   = Plugins_Installer::get_plugins();
791        $installed = false;
792        foreach ( (array) $filenames as $file ) {
793            if ( ! isset( $plugins[ $file ] ) ) {
794                continue;
795            }
796            if ( $plugins[ $file ]['active'] ) {
797                return $file;
798            }
799            if ( false === $installed ) {
800                $installed = $file;
801            }
802        }
803
804        return $installed;
805    }
806
807    /**
808     * The plugin this copy of My Jetpack is running from.
809     *
810     * My Jetpack ships inside Jetpack, Boost, Protect, Social, Search and VideoPress, and
811     * whichever one wins the autoloader renders this page â€” so that is the plugin a switch
812     * here must never turn off.
813     *
814     * @return string The plugin's folder name, or an empty string when it is not under one.
815     */
816    public static function get_hosting_plugin_slug() {
817        return self::plugin_slug_from_path( WP_PLUGIN_DIR, __DIR__ );
818    }
819
820    /**
821     * Whether a plugin slug names the plugin this copy of My Jetpack runs from.
822     *
823     * Compared by folder rather than by slug: a checkout in `jetpack-protect-dev` serves
824     * the page just as well, and never equals the `jetpack-protect` a request carries.
825     *
826     * @param string $slug WordPress.org plugin slug.
827     * @return bool True when the plugin is the one this page is being served from.
828     */
829    public static function is_hosting_plugin( $slug ) {
830        $host = self::get_hosting_plugin_slug();
831
832        if ( '' === $host ) {
833            return false;
834        }
835
836        $file = self::get_plugin_file( $slug, self::get_product_class_for_plugin( $slug ) );
837
838        return $file ? dirname( (string) $file ) === $host : $host === $slug;
839    }
840
841    /**
842     * The plugin folder a path sits in.
843     *
844     * @param string $plugins_dir The plugin directory, as WP_PLUGIN_DIR gives it.
845     * @param string $path        The path to place.
846     * @return string The plugin's folder name, or an empty string when the path is outside.
847     */
848    public static function plugin_slug_from_path( $plugins_dir, $path ) {
849        $plugins_dir = wp_normalize_path( trailingslashit( $plugins_dir ) );
850        $path        = wp_normalize_path( $path );
851
852        if ( ! str_starts_with( $path, $plugins_dir ) ) {
853            return '';
854        }
855
856        $segments = explode( '/', substr( $path, strlen( $plugins_dir ) ) );
857
858        return $segments[0];
859    }
860
861    /**
862     * Whether this plugin is the only active one carrying My Jetpack.
863     *
864     * Several plugins bundle it and the autoloader picks one, so the plugin serving this
865     * page is often not the only one that could. Switching that one off is only a problem
866     * when nothing else is left to take over.
867     *
868     * @param string $slug The plugin's folder name.
869     * @return bool True when no other active plugin carries My Jetpack.
870     */
871    public static function is_only_my_jetpack_provider( $slug ) {
872        $active = array_merge(
873            (array) get_option( 'active_plugins', array() ),
874            array_keys( (array) get_site_option( 'active_sitewide_plugins', array() ) )
875        );
876
877        foreach ( $active as $file ) {
878            $folder = dirname( (string) $file );
879
880            if ( '.' === $folder || $folder === $slug ) {
881                continue;
882            }
883
884            if ( is_dir( WP_PLUGIN_DIR . '/' . $folder . '/jetpack_vendor/automattic/jetpack-my-jetpack' ) ) {
885                return false;
886            }
887        }
888
889        return true;
890    }
891
892    /**
893     * The product class behind a plugin in the map, for surfaces holding only its slug.
894     *
895     * @param string $slug WordPress.org plugin slug.
896     * @return string|null The product class, or null when no product ships that plugin.
897     */
898    public static function get_product_class_for_plugin( $slug ) {
899        foreach ( self::get_feature_definitions() as $definition ) {
900            if ( ( $definition['delivery']['plugin'] ?? '' ) !== $slug || ! isset( $definition['product'] ) ) {
901                continue;
902            }
903
904            $product_class = Products::get_product_class( $definition['product'] );
905
906            if ( $product_class && $product_class::$has_standalone_plugin ) {
907                return $product_class;
908            }
909        }
910
911        return null;
912    }
913
914    /**
915     * Headings for the modules the feature list does not cover.
916     *
917     * Grouped by the job a site owner is doing, not by Jetpack's module tags, which describe
918     * mechanism (the Image CDN is tagged Appearance). Anything unlisted falls into Other.
919     *
920     * @return array Groups, each with a label and its module slugs. The UI sorts both by name.
921     */
922    public static function get_module_groups() {
923        return array(
924            array(
925                'label'   => __( 'Security', 'jetpack-my-jetpack' ),
926                'modules' => array( 'account-protection', 'monitor', 'protect', 'sso', 'waf' ),
927            ),
928            array(
929                'label'   => __( 'Performance', 'jetpack-my-jetpack' ),
930                'modules' => array( 'photon', 'photon-cdn' ),
931            ),
932            array(
933                'label'   => __( 'Search engines', 'jetpack-my-jetpack' ),
934                'modules' => array( 'sitemaps', 'seo-tools', 'canonical-urls', 'verification-tools' ),
935            ),
936            array(
937                'label'   => __( 'Engagement', 'jetpack-my-jetpack' ),
938                'modules' => array(
939                    'comments',
940                    'likes',
941                    'comment-likes',
942                    'gravatar-hovercards',
943                    'related-posts',
944                    'sharedaddy',
945                    'shortlinks',
946                ),
947            ),
948            array(
949                'label'   => __( 'Writing', 'jetpack-my-jetpack' ),
950                'modules' => array(
951                    'blocks',
952                    'markdown',
953                    'latex',
954                    'shortcodes',
955                    'copy-post',
956                    'custom-content-types',
957                    'post-by-email',
958                    'post-list',
959                    'carousel',
960                    'tiled-gallery',
961                ),
962            ),
963            array(
964                'label'   => __( 'Design', 'jetpack-my-jetpack' ),
965                'modules' => array( 'google-fonts', 'widgets', 'widget-visibility' ),
966            ),
967            array(
968                'label'   => __( 'Earn', 'jetpack-my-jetpack' ),
969                'modules' => array( 'wordads' ),
970            ),
971            array(
972                'label'   => __( 'Analytics', 'jetpack-my-jetpack' ),
973                'modules' => array( 'woocommerce-analytics' ),
974            ),
975        );
976    }
977
978    /**
979     * Slugs the host has hidden, plus the module behind each hidden feature or product.
980     *
981     * @since 6.11.0
982     * @return string[] Hidden module and feature slugs.
983     */
984    public static function get_hidden_modules() {
985        $hidden = Feature_Visibility::get_hidden();
986        foreach ( self::get_feature_definitions() as $slug => $definition ) {
987            $names = array_filter( array( $slug, $definition['product'] ?? '', $definition['module'] ?? '' ) );
988            if ( array_intersect( $names, $hidden ) ) {
989                $product_class = isset( $definition['product'] ) ? Products::get_product_class( $definition['product'] ) : null;
990                $hidden[]      = $definition['module'] ?? ( $product_class ? $product_class::get_module_name() : '' );
991            }
992        }
993        return array_values( array_unique( array_filter( $hidden ) ) );
994    }
995
996    /**
997     * Everything the Features tab renders from: the Jetpack plugin's status, each feature, the
998     * headings for Jetpack's other modules, and whether the current user may install plugins.
999     *
1000     * @param bool $local Use the local feature contract documented in get_features().
1001     * @return array{jetpack: string, features: array, module_groups: array, plugin_installs: string, hidden_modules: string[]} The state.
1002     */
1003    public static function get_state( $local = false ) {
1004        return array(
1005            'jetpack'         => self::get_plugin_status( Product::JETPACK_PLUGIN_SLUG ),
1006            'features'        => self::get_features( $local ),
1007            'module_groups'   => self::get_module_groups(),
1008            'plugin_installs' => self::get_install_access(),
1009            'hidden_modules'  => self::get_hidden_modules(),
1010        );
1011    }
1012
1013    /**
1014     * Whether the current user may install plugins here, and if not, why not.
1015     *
1016     * @since 6.7.0
1017     *
1018     * @return string One of the INSTALLS_* constants.
1019     */
1020    public static function get_install_access() {
1021        if ( current_user_can( 'install_plugins' ) ) {
1022            return self::INSTALLS_ALLOWED;
1023        }
1024
1025        // Core's own map_meta_cap() check for install_plugins: DISALLOW_FILE_MODS or the file_mod_allowed filter.
1026        if ( ! wp_is_file_mod_allowed( 'capability_update_core' ) ) {
1027            return self::INSTALLS_DISABLED;
1028        }
1029
1030        return self::INSTALLS_NOT_PERMITTED;
1031    }
1032
1033    /**
1034     * The feature catalog merged with each feature's live state, sorted by name.
1035     *
1036     * @param bool $local Keep only local management URLs, set upgrade to null, and omit
1037     *                    included, setup_note, and plan owned fields without resolving ownership.
1038     * @return array List of features, each with slug, name, description, icon, manage_url,
1039     *               the plugin it ships as and the product/module join keys.
1040     */
1041    public static function get_features( $local = false ) {
1042        $features = array();
1043        $hidden   = Feature_Visibility::get_hidden();
1044
1045        foreach ( self::get_feature_definitions() as $slug => $definition ) {
1046            // A host can name the feature by its own slug or by its product's or module's.
1047            $names = array_filter( array( $slug, $definition['product'] ?? '', $definition['module'] ?? '' ) );
1048            if ( array_intersect( $names, $hidden ) ) {
1049                continue;
1050            }
1051
1052            $delivery      = $definition['delivery'] ?? array();
1053            $plugin        = $delivery['plugin'] ?? '';
1054            $product_class = isset( $definition['product'] ) ? Products::get_product_class( $definition['product'] ) : null;
1055            $included      = ! $local && self::is_included_in_plan( $definition, $product_class );
1056
1057            $feature = array(
1058                'slug'             => $slug,
1059                'name'             => $definition['name'],
1060                'description'      => $definition['description'],
1061                'long_description' => $definition['long_description'] ?? '',
1062                'icon'             => $definition['icon'],
1063                'manage_url'       => self::get_feature_manage_url( $definition, $product_class, $local ),
1064                'essential'        => ! empty( $definition['essential'] ),
1065                // An older Jetpack plugin still lets the module decide, so the card keeps its switch there.
1066                'plan_driven'      => ! empty( $definition['plan_driven'] ) && ( ! $product_class || ! method_exists( $product_class, 'is_plan_driven' ) || $product_class::is_plan_driven() ),
1067                'in_jetpack'       => $delivery['jetpack'] ?? false,
1068                'plugin'           => $plugin,
1069                'plugin_name'      => $plugin ? ( $delivery['plugin_name'] ?? $definition['name'] ) : '',
1070                'plugin_url'       => $plugin ? ( $delivery['plugin_url'] ?? '' ) : '',
1071                'plugin_status'    => $plugin ? self::get_plugin_status( $plugin, $product_class ) : self::PLUGIN_NOT_INSTALLED,
1072                'plugin_override'  => $plugin ? self::get_plugin_override( $plugin, $product_class ) : '',
1073                'free_highlights'  => $definition['free_highlights'] ?? array(),
1074                'paid_highlights'  => $definition['paid_highlights'] ?? array(),
1075                'pricing_notes'    => $definition['pricing_notes'] ?? array(),
1076                'upgrade'          => $local ? null : self::filter_upgrade( self::get_upgrade( $definition, $product_class, $included ), $slug, $included ),
1077                'included'         => $included,
1078                // Both notes tell the site what to buy next, which a site that pays already has.
1079                'setup_note'       => $included ? '' : ( $definition['setup_note'] ?? '' ),
1080                'screenshot'       => self::get_screenshot_url( $slug ),
1081                'plans'            => self::get_plan_badges( $definition, $local ),
1082                'info_url'         => $definition['info_url'],
1083                'docs_url'         => $definition['docs_url'],
1084                // Join keys: the UI reads live state from the module and plugin it names,
1085                // so the catalog never ships a status of its own.
1086                'product'          => $definition['product'] ?? '',
1087                'module'           => $definition['module'] ?? '',
1088                // Read even without Jetpack, whose module list would otherwise say so.
1089                'needs_account'    => ! empty( $definition['needs_account'] ),
1090            );
1091            if ( 'activity-log' === $slug && ! $local && true === ( Products\Pro::get_pricing_for_ui()['catalog_available'] ?? false ) ) {
1092                $feature['paid_highlights'][0] = __( '30 days of history with VaultPress Backup or Jetpack Pro, a full year with Jetpack Complete', 'jetpack-my-jetpack' );
1093            } elseif ( 'activity-log' === $slug && ! $local && false === ( Products\Security::get_pricing_for_ui()['available'] ?? null ) ) {
1094                $feature['paid_highlights'][0] = __( '30 days of history with VaultPress Backup, a full year with Jetpack Complete', 'jetpack-my-jetpack' );
1095            }
1096            if ( $local ) {
1097                unset( $feature['included'], $feature['setup_note'] );
1098            }
1099            $features[] = $feature;
1100        }
1101
1102        usort(
1103            $features,
1104            function ( $a, $b ) {
1105                return strnatcasecmp( $a['name'], $b['name'] );
1106            }
1107        );
1108
1109        return $features;
1110    }
1111
1112    /**
1113     * The feature's artwork, bundled with this package so the details window needs no third-party request.
1114     *
1115     * @param string $slug The feature's catalog slug, which names its image.
1116     * @return string URL of the image.
1117     */
1118    private static function get_screenshot_url( $slug ) {
1119        return Initializer::get_assets_url() . 'components/my-jetpack-tab-panel/features/images/' . $slug . '.webp';
1120    }
1121
1122    /**
1123     * Where an active feature lives.
1124     *
1125     * @param array       $definition    A single entry from the feature catalog.
1126     * @param string|null $product_class The product behind the feature, when it has one.
1127     * @param bool        $local         Keep local destinations without resolving ownership.
1128     * @return string Admin URL, or an empty string when the feature has nowhere to go.
1129     */
1130    private static function get_feature_manage_url( array $definition, $product_class = null, $local = false ) {
1131        if ( isset( $definition['admin_page'] ) ) {
1132            return admin_url( 'admin.php?page=' . $definition['admin_page'] );
1133        }
1134
1135        if ( $product_class ) {
1136            // Protect's normal destination depends on ownership, which is unknown offline.
1137            if ( $local && 'protect' === ( $definition['product'] ?? '' ) ) {
1138                return Products\Protect::has_protect_page() ? admin_url( 'admin.php?page=jetpack-protect' ) : '';
1139            }
1140
1141            $url = (string) $product_class::get_manage_url();
1142            if ( ! $local ) {
1143                return $url;
1144            }
1145
1146            wp_parse_str( (string) wp_parse_url( $url, PHP_URL_QUERY ), $query );
1147            return 0 === strpos( $url, admin_url() ) && 'my-jetpack' !== ( $query['page'] ?? '' ) ? $url : '';
1148        }
1149
1150        return '';
1151    }
1152}