Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.29% covered (warning)
89.29%
125 / 140
81.82% covered (warning)
81.82%
9 / 11
CRAP
n/a
0 / 0
wpcom_marketplace_cards_start
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
wpcom_marketplace_search_matches
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
wpcom_marketplace_splice_search_results
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
13.02
wpcom_marketplace_card_action_links
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
wpcom_marketplace_back_url
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
wpcom_marketplace_card_description_markup
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
10
wpcom_marketplace_dependency_lookup
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
7
wpcom_marketplace_cached_dependency
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
wpcom_marketplace_remember_dependency
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
7
wpcom_marketplace_card_strip
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
3
wpcom_marketplace_card_assets
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Makes Marketplace plugins in core's plugin list table into Marketplace cards.
4 *
5 * The same cards on the Marketplace tab and at the top of matching search results. Follows
6 * Jetpack's plugin search hint (`modules/plugin-search.php`), which splices a card into the
7 * same results and swaps the same parts of it.
8 *
9 * @package automattic/jetpack-mu-wpcom
10 */
11
12use Automattic\Jetpack\Jetpack_Mu_Wpcom\Marketplace_Catalog;
13
14/**
15 * How many of our plugins a search can bring to the top.
16 */
17const WPCOM_MARKETPLACE_SEARCH_LIMIT = 2;
18
19/**
20 * Transient prefix for a dependency's name, as WordPress.org gives it.
21 */
22const WPCOM_MARKETPLACE_DEPENDENCY_PREFIX = 'wpcom_marketplace_dependency_';
23
24/**
25 * Hooks the cards in on the first page of the Add Plugins screen, and their assets on every page.
26 *
27 * Core's live search runs through admin-ajax.php but sets this same screen first, so one
28 * check covers the page and the Ajax results.
29 *
30 * @param WP_Screen $screen The current screen.
31 * @return void
32 */
33function wpcom_marketplace_cards_start( $screen ) {
34    // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Only reads which page of results this is.
35    $page = isset( $_GET['paged'] ) ? (int) $_GET['paged'] : 1;
36
37    if ( ! isset( $screen->base ) || 'plugin-install' !== $screen->base || ! wpcom_marketplace_tab_enabled() ) {
38        return;
39    }
40
41    // On every page: the script also keeps checkout's Back working, and any page can lead there.
42    add_action( 'admin_enqueue_scripts', 'wpcom_marketplace_card_assets' );
43
44    if ( $page > 1 ) {
45        return;
46    }
47
48    add_filter( 'plugins_api_result', 'wpcom_marketplace_splice_search_results', 10, 3 );
49    add_filter( 'plugin_install_action_links', 'wpcom_marketplace_card_action_links', 10, 2 );
50    add_filter( 'plugin_install_description', 'wpcom_marketplace_card_description_markup', 10, 2 );
51    add_filter( 'plugins_api', 'wpcom_marketplace_cached_dependency', 11, 3 );
52    add_filter( 'plugins_api_result', 'wpcom_marketplace_remember_dependency', 10, 3 );
53}
54add_action( 'current_screen', 'wpcom_marketplace_cards_start' );
55
56/**
57 * Our plugins that match a search term, best matches first.
58 *
59 * A match in the name ranks above one in the category or description. Within each, the
60 * catalog's own order holds, which is by sales.
61 *
62 * @param string $term  What was searched for.
63 * @param int    $limit How many to return at most.
64 * @return array[] Normalized product data.
65 */
66function wpcom_marketplace_search_matches( $term, $limit ) {
67    $needle = mb_strtolower( trim( wp_strip_all_tags( (string) $term ) ) );
68
69    // Anything shorter matches half the catalog.
70    if ( mb_strlen( $needle ) < 3 ) {
71        return array();
72    }
73
74    $in_name   = array();
75    $elsewhere = array();
76
77    foreach ( Marketplace_Catalog::get_products() as $card ) {
78        $about = ( $card['wpcom_category'] ?? '' ) . ' ' . wp_strip_all_tags( (string) ( $card['short_description'] ?? '' ) );
79
80        if ( false !== mb_strpos( mb_strtolower( (string) ( $card['name'] ?? '' ) ), $needle ) ) {
81            $in_name[] = $card;
82        } elseif ( false !== mb_strpos( mb_strtolower( $about ), $needle ) ) {
83            $elsewhere[] = $card;
84        }
85    }
86
87    return array_slice( array_merge( $in_name, $elsewhere ), 0, $limit );
88}
89
90/**
91 * Adds matching Marketplace plugins to the top of a keyword search's first page.
92 *
93 * @param object|WP_Error $result Plugin API response.
94 * @param string          $action Plugin API action.
95 * @param object          $args   Plugin API arguments.
96 * @return object|WP_Error
97 */
98function wpcom_marketplace_splice_search_results( $result, $action, $args ) {
99    if ( 'query_plugins' !== $action || ! is_object( $result ) || is_wp_error( $result ) || ! isset( $result->plugins ) ) {
100        return $result;
101    }
102
103    // Tag and author searches, and the browse tabs, name no term to match.
104    $term = is_object( $args ) && isset( $args->search ) ? (string) $args->search : '';
105    $page = is_object( $args ) && isset( $args->page ) ? (int) $args->page : 1;
106    if ( '' === $term || $page > 1 ) {
107        return $result;
108    }
109
110    $matches = wpcom_marketplace_search_matches( $term, WPCOM_MARKETPLACE_SEARCH_LIMIT );
111    if ( empty( $matches ) ) {
112        return $result;
113    }
114
115    $ours   = array_column( $matches, 'slug' );
116    $others = array_filter(
117        (array) $result->plugins,
118        function ( $plugin ) use ( $ours ) {
119            $slug = is_array( $plugin ) ? ( $plugin['slug'] ?? '' ) : ( $plugin->slug ?? '' );
120            return ! in_array( $slug, $ours, true );
121        }
122    );
123
124    $result->plugins = array_merge( $matches, array_values( $others ) );
125
126    return $result;
127}
128
129/**
130 * Swaps core's Install Now on our cards for the action the Marketplace tab shows.
131 *
132 * Core's would install the slug from WordPress.org, which has none of these.
133 *
134 * @param string[] $links  Card action links, the install button first.
135 * @param array    $plugin Plugin data.
136 * @return string[]
137 */
138function wpcom_marketplace_card_action_links( $links, $plugin ) {
139    if ( empty( $plugin['wpcom_marketplace'] ) || ! is_array( $links ) ) {
140        return $links;
141    }
142
143    $links[0] = wpcom_marketplace_card_button( $plugin, wpcom_marketplace_back_url() );
144
145    return array_values( array_filter( $links ) );
146}
147
148/**
149 * Where checkout's Back link should return to: the search being shown, or the tab.
150 *
151 * Core reads the term from the request for both the page and its Ajax live search.
152 *
153 * @return string Search results URL, or the Marketplace tab when there is no term.
154 */
155function wpcom_marketplace_back_url() {
156    // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Core's handler verifies the Ajax search; this only echoes the term back.
157    $term = isset( $_REQUEST['s'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['s'] ) ) : '';
158    if ( '' === $term ) {
159        return wpcom_marketplace_tab_url();
160    }
161
162    // add_query_arg() does not encode values.
163    return add_query_arg(
164        array(
165            's'    => rawurlencode( $term ),
166            'tab'  => 'search',
167            'type' => 'term',
168        ),
169        self_admin_url( 'plugin-install.php' )
170    );
171}
172
173/**
174 * Labels our cards, and carries the bottom strip and data attributes they should have.
175 *
176 * Core has no filter for the strip and fills it with WordPress.org ratings and install
177 * counts, which these plugins do not have. Ours rides in a template, which is valid inside
178 * core's <p>, and js/marketplace-cards.js moves it into place.
179 *
180 * @param string $description Card description.
181 * @param array  $plugin      Plugin data.
182 * @return string
183 */
184function wpcom_marketplace_card_description_markup( $description, $plugin ) {
185    if ( empty( $plugin['wpcom_marketplace'] ) ) {
186        return $description;
187    }
188
189    $installed = wpcom_marketplace_is_installed( $plugin );
190    $referral  = Marketplace_Catalog::is_referral( $plugin );
191
192    // Checkout installs everything a product needs, so its card says so rather than core's "required".
193    $requires_label = '';
194    if ( ! $installed && ! $referral && ! empty( $plugin['requires_plugins'] ) ) {
195        $requires_label = sprintf( ' data-requires-label="%s"', esc_attr__( 'Additional plugins will be installed', 'jetpack-mu-wpcom' ) );
196    }
197
198    // The data attributes are what the tab's Tracks and kept details modals read off a card.
199    return sprintf(
200        '<span class="wpcom-marketplace-label">%s</span>%s<template class="wpcom-marketplace-strip" data-plugin="%s" data-saas="%s" data-installed="%s" data-needs-plan="%s"%s>%s</template>',
201        esc_html__( 'WordPress.com Marketplace', 'jetpack-mu-wpcom' ),
202        esc_html( wpcom_marketplace_card_description( $plugin ) ),
203        esc_attr( (string) ( $plugin['wpcom_product_slug'] ?? $plugin['slug'] ?? '' ) ),
204        $referral ? 'true' : 'false',
205        $installed ? 'true' : 'false',
206        ! $installed && ! $referral && wpcom_marketplace_needs_plan() ? 'true' : 'false',
207        $requires_label,
208        wpcom_marketplace_card_strip( $plugin ) // Built from escaped parts.
209    );
210}
211
212/**
213 * The dependency a plugin API call is naming for core's dependency notice, if any.
214 *
215 * Core's notice asks with the slug alone, once per dependency per card. The details modal
216 * asks the same way, so it is told apart by its hook and keeps WordPress.org's full answer.
217 * WP_Plugin_Dependencies asks with `fields` set, and keeps its own cache.
218 *
219 * @param string $action Plugin API action.
220 * @param object $args   Plugin API arguments.
221 * @return string The dependency's slug, or an empty string when the call is anything else.
222 */
223function wpcom_marketplace_dependency_lookup( $action, $args ) {
224    if ( 'plugin_information' !== $action || ! is_object( $args ) || empty( $args->slug ) || isset( $args->fields ) ) {
225        return '';
226    }
227
228    if ( doing_action( 'install_plugins_pre_plugin-information' ) ) {
229        return '';
230    }
231
232    $slug = (string) $args->slug;
233
234    return in_array( $slug, Marketplace_Catalog::get_dependency_slugs(), true ) ? $slug : '';
235}
236
237/**
238 * Answers core's dependency notice from what WordPress.org said last time.
239 *
240 * Without this, a site without WooCommerce asks WordPress.org about it once for every
241 * WooCommerce extension card, while the tab renders.
242 *
243 * @param false|object|WP_Error $result Result so far.
244 * @param string                $action Plugin API action.
245 * @param object                $args   Plugin API arguments.
246 * @return false|object|WP_Error
247 */
248function wpcom_marketplace_cached_dependency( $result, $action, $args ) {
249    if ( false !== $result ) {
250        return $result;
251    }
252
253    $slug = wpcom_marketplace_dependency_lookup( $action, $args );
254    if ( '' === $slug ) {
255        return $result;
256    }
257
258    $cached = get_transient( WPCOM_MARKETPLACE_DEPENDENCY_PREFIX . $slug );
259    if ( is_array( $cached ) ) {
260        return (object) $cached;
261    }
262
263    // A lookup that just failed is not retried for every card. Core then shows the bare slug.
264    if ( 'unavailable' === $cached ) {
265        return new WP_Error( 'wpcom_marketplace_dependency_unavailable', $slug );
266    }
267
268    return $result;
269}
270
271/**
272 * Keeps WordPress.org's name for a dependency, for wpcom_marketplace_cached_dependency().
273 *
274 * @param object|WP_Error $result Plugin API response.
275 * @param string          $action Plugin API action.
276 * @param object          $args   Plugin API arguments.
277 * @return object|WP_Error
278 */
279function wpcom_marketplace_remember_dependency( $result, $action, $args ) {
280    $slug = wpcom_marketplace_dependency_lookup( $action, $args );
281    if ( '' === $slug || false !== get_transient( WPCOM_MARKETPLACE_DEPENDENCY_PREFIX . $slug ) ) {
282        return $result;
283    }
284
285    if ( is_object( $result ) && ! is_wp_error( $result ) && ! empty( $result->name ) ) {
286        $entry = array(
287            'name'    => (string) $result->name,
288            'slug'    => $slug,
289            'version' => (string) ( $result->version ?? '' ),
290        );
291
292        set_transient( WPCOM_MARKETPLACE_DEPENDENCY_PREFIX . $slug, $entry, DAY_IN_SECONDS );
293    } elseif ( is_wp_error( $result ) ) {
294        set_transient( WPCOM_MARKETPLACE_DEPENDENCY_PREFIX . $slug, 'unavailable', Marketplace_Catalog::MISS_CACHE_TTL );
295    }
296
297    return $result;
298}
299
300/**
301 * A card's bottom strip, in core's own columns so it lines up with the cards around it.
302 *
303 * The price takes the left column, where core shows ratings and installs, as the tab's flex
304 * rows so its mixed sizes keep core's row heights. The right keeps core's last updated date
305 * and compatibility, which WordPress.com manages for these plugins.
306 *
307 * @param array $plugin Plugin data.
308 * @return string Strip markup, built from escaped parts.
309 */
310function wpcom_marketplace_card_strip( array $plugin ) {
311    // Calypso drops the price once a plugin is installed.
312    $rows = ! wpcom_marketplace_is_installed( $plugin )
313        ? wpcom_marketplace_price_rows( $plugin )
314        : array(
315            'headline' => '',
316            'note'     => '',
317        );
318
319    $updated = strtotime( (string) ( $plugin['last_updated'] ?? '' ) );
320    $updated = $updated
321        ? sprintf(
322            '<strong>%s</strong> %s',
323            esc_html__( 'Last Updated:', 'jetpack-mu-wpcom' ),
324            /* translators: %s: Time since the plugin was updated, for example 2 days. */
325            esc_html( sprintf( __( '%s ago', 'jetpack-mu-wpcom' ), human_time_diff( $updated ) ) )
326        )
327        : esc_html__( 'Managed by WordPress.com', 'jetpack-mu-wpcom' );
328
329    return sprintf(
330        '<div class="column-rating wpcom-marketplace-card__headline">%s</div><div class="column-updated">%s</div><div class="column-downloaded wpcom-marketplace-card__alternative">%s</div><div class="column-compatibility"><span class="compatibility-compatible">%s</span></div>',
331        $rows['headline'],
332        $updated,
333        $rows['note'],
334        wp_kses( __( '<strong>Compatible</strong> with your version of WordPress', 'jetpack-mu-wpcom' ), array( 'strong' => array() ) )
335    );
336}
337
338/**
339 * Loads the card styles and the script that places each card's strip, on every Add Plugins tab.
340 *
341 * Search can start from any of them.
342 *
343 * @return void
344 */
345function wpcom_marketplace_card_assets() {
346    wp_enqueue_style(
347        'wpcom-marketplace-tab',
348        plugins_url( 'css/marketplace-tab.css', __FILE__ ),
349        array(),
350        (string) filemtime( __DIR__ . '/css/marketplace-tab.css' )
351    );
352
353    wp_enqueue_script(
354        'wpcom-marketplace-cards',
355        plugins_url( 'js/marketplace-cards.js', __FILE__ ),
356        array(),
357        (string) filemtime( __DIR__ . '/js/marketplace-cards.js' ),
358        true
359    );
360}