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