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