Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.00% covered (warning)
85.00%
85 / 100
75.00% covered (warning)
75.00%
6 / 8
CRAP
n/a
0 / 0
wpcom_marketplace_cards_start
100.00% covered (success)
100.00%
7 / 7
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%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
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 * Hooks the cards in on the first page of the Add Plugins screen.
21 *
22 * Core's live search runs through admin-ajax.php but sets this same screen first, so one
23 * check covers the page and the Ajax results.
24 *
25 * @param WP_Screen $screen The current screen.
26 * @return void
27 */
28function wpcom_marketplace_cards_start( $screen ) {
29    // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Only reads which page of results this is.
30    $page = isset( $_GET['paged'] ) ? (int) $_GET['paged'] : 1;
31
32    if ( ! isset( $screen->base ) || 'plugin-install' !== $screen->base || $page > 1 || ! wpcom_marketplace_tab_enabled() ) {
33        return;
34    }
35
36    add_filter( 'plugins_api_result', 'wpcom_marketplace_splice_search_results', 10, 3 );
37    add_filter( 'plugin_install_action_links', 'wpcom_marketplace_card_action_links', 10, 2 );
38    add_filter( 'plugin_install_description', 'wpcom_marketplace_card_description_markup', 10, 2 );
39    add_action( 'admin_enqueue_scripts', 'wpcom_marketplace_card_assets' );
40}
41add_action( 'current_screen', 'wpcom_marketplace_cards_start' );
42
43/**
44 * Our plugins that match a search term, best matches first.
45 *
46 * A match in the name ranks above one in the category or description. Within each, the
47 * catalog's own order holds, which is by sales.
48 *
49 * @param string $term  What was searched for.
50 * @param int    $limit How many to return at most.
51 * @return array[] Normalized product data.
52 */
53function wpcom_marketplace_search_matches( $term, $limit ) {
54    $needle = mb_strtolower( trim( wp_strip_all_tags( (string) $term ) ) );
55
56    // Anything shorter matches half the catalog.
57    if ( mb_strlen( $needle ) < 3 ) {
58        return array();
59    }
60
61    $in_name   = array();
62    $elsewhere = array();
63
64    foreach ( Marketplace_Catalog::get_products() as $card ) {
65        $about = ( $card['wpcom_category'] ?? '' ) . ' ' . wp_strip_all_tags( (string) ( $card['short_description'] ?? '' ) );
66
67        if ( false !== mb_strpos( mb_strtolower( (string) ( $card['name'] ?? '' ) ), $needle ) ) {
68            $in_name[] = $card;
69        } elseif ( false !== mb_strpos( mb_strtolower( $about ), $needle ) ) {
70            $elsewhere[] = $card;
71        }
72    }
73
74    return array_slice( array_merge( $in_name, $elsewhere ), 0, $limit );
75}
76
77/**
78 * Adds matching Marketplace plugins to the top of a keyword search's first page.
79 *
80 * @param object|WP_Error $result Plugin API response.
81 * @param string          $action Plugin API action.
82 * @param object          $args   Plugin API arguments.
83 * @return object|WP_Error
84 */
85function wpcom_marketplace_splice_search_results( $result, $action, $args ) {
86    if ( 'query_plugins' !== $action || ! is_object( $result ) || is_wp_error( $result ) || ! isset( $result->plugins ) ) {
87        return $result;
88    }
89
90    // Tag and author searches, and the browse tabs, name no term to match.
91    $term = is_object( $args ) && isset( $args->search ) ? (string) $args->search : '';
92    $page = is_object( $args ) && isset( $args->page ) ? (int) $args->page : 1;
93    if ( '' === $term || $page > 1 ) {
94        return $result;
95    }
96
97    $matches = wpcom_marketplace_search_matches( $term, WPCOM_MARKETPLACE_SEARCH_LIMIT );
98    if ( empty( $matches ) ) {
99        return $result;
100    }
101
102    $ours   = array_column( $matches, 'slug' );
103    $others = array_filter(
104        (array) $result->plugins,
105        function ( $plugin ) use ( $ours ) {
106            $slug = is_array( $plugin ) ? ( $plugin['slug'] ?? '' ) : ( $plugin->slug ?? '' );
107            return ! in_array( $slug, $ours, true );
108        }
109    );
110
111    $result->plugins = array_merge( $matches, array_values( $others ) );
112
113    return $result;
114}
115
116/**
117 * Swaps core's Install Now on our cards for the action the Marketplace tab shows.
118 *
119 * Core's would install the slug from WordPress.org, which has none of these.
120 *
121 * @param string[] $links  Card action links, the install button first.
122 * @param array    $plugin Plugin data.
123 * @return string[]
124 */
125function wpcom_marketplace_card_action_links( $links, $plugin ) {
126    if ( empty( $plugin['wpcom_marketplace'] ) || ! is_array( $links ) ) {
127        return $links;
128    }
129
130    $links[0] = wpcom_marketplace_card_button( $plugin, wpcom_marketplace_back_url() );
131
132    return array_values( array_filter( $links ) );
133}
134
135/**
136 * Where checkout's Back link should return to: the search being shown, or the tab.
137 *
138 * Core reads the term from the request for both the page and its Ajax live search.
139 *
140 * @return string Search results URL, or the Marketplace tab when there is no term.
141 */
142function wpcom_marketplace_back_url() {
143    // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Core's handler verifies the Ajax search; this only echoes the term back.
144    $term = isset( $_REQUEST['s'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['s'] ) ) : '';
145    if ( '' === $term ) {
146        return wpcom_marketplace_tab_url();
147    }
148
149    // add_query_arg() does not encode values.
150    return add_query_arg(
151        array(
152            's'    => rawurlencode( $term ),
153            'tab'  => 'search',
154            'type' => 'term',
155        ),
156        self_admin_url( 'plugin-install.php' )
157    );
158}
159
160/**
161 * Labels our cards, and carries the bottom strip and data attributes they should have.
162 *
163 * Core has no filter for the strip and fills it with WordPress.org ratings and install
164 * counts, which these plugins do not have. Ours rides in a template, which is valid inside
165 * core's <p>, and js/marketplace-cards.js moves it into place.
166 *
167 * @param string $description Card description.
168 * @param array  $plugin      Plugin data.
169 * @return string
170 */
171function wpcom_marketplace_card_description_markup( $description, $plugin ) {
172    if ( empty( $plugin['wpcom_marketplace'] ) ) {
173        return $description;
174    }
175
176    $installed = 'install' !== install_plugin_install_status( $plugin )['status'];
177
178    // The data attributes are what the tab's Tracks and kept details modals read off a card.
179    return sprintf(
180        '<span class="wpcom-marketplace-label">%s</span>%s<template class="wpcom-marketplace-strip" data-plugin="%s" data-saas="%s" data-installed="%s">%s</template>',
181        esc_html__( 'WordPress.com Marketplace', 'jetpack-mu-wpcom' ),
182        esc_html( wpcom_marketplace_card_description( $plugin ) ),
183        esc_attr( (string) ( $plugin['wpcom_product_slug'] ?? $plugin['slug'] ?? '' ) ),
184        Marketplace_Catalog::is_referral( $plugin ) ? 'true' : 'false',
185        $installed ? 'true' : 'false',
186        wpcom_marketplace_card_strip( $plugin ) // Built from escaped parts.
187    );
188}
189
190/**
191 * A card's bottom strip, in core's own columns so it lines up with the cards around it.
192 *
193 * The price takes the left column, where core shows ratings and installs, as the tab's flex
194 * rows so its mixed sizes keep core's row heights. The right keeps core's last updated date
195 * and compatibility, which WordPress.com manages for these plugins.
196 *
197 * @param array $plugin Plugin data.
198 * @return string Strip markup, built from escaped parts.
199 */
200function wpcom_marketplace_card_strip( array $plugin ) {
201    // Calypso drops the price once a plugin is installed.
202    $rows = 'install' === install_plugin_install_status( $plugin )['status']
203        ? wpcom_marketplace_price_rows( $plugin )
204        : array(
205            'headline' => '',
206            'note'     => '',
207        );
208
209    $updated = strtotime( (string) ( $plugin['last_updated'] ?? '' ) );
210    $updated = $updated
211        ? sprintf(
212            '<strong>%s</strong> %s',
213            esc_html__( 'Last Updated:', 'jetpack-mu-wpcom' ),
214            /* translators: %s: Time since the plugin was updated, for example 2 days. */
215            esc_html( sprintf( __( '%s ago', 'jetpack-mu-wpcom' ), human_time_diff( $updated ) ) )
216        )
217        : esc_html__( 'Managed by WordPress.com', 'jetpack-mu-wpcom' );
218
219    return sprintf(
220        '<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>',
221        $rows['headline'],
222        $updated,
223        $rows['note'],
224        wp_kses( __( '<strong>Compatible</strong> with your version of WordPress', 'jetpack-mu-wpcom' ), array( 'strong' => array() ) )
225    );
226}
227
228/**
229 * Loads the card styles and the script that places each card's strip, on every Add Plugins tab.
230 *
231 * Search can start from any of them.
232 *
233 * @return void
234 */
235function wpcom_marketplace_card_assets() {
236    wp_enqueue_style(
237        'wpcom-marketplace-tab',
238        plugins_url( 'css/marketplace-tab.css', __FILE__ ),
239        array(),
240        (string) filemtime( __DIR__ . '/css/marketplace-tab.css' )
241    );
242
243    wp_enqueue_script(
244        'wpcom-marketplace-cards',
245        plugins_url( 'js/marketplace-cards.js', __FILE__ ),
246        array(),
247        (string) filemtime( __DIR__ . '/js/marketplace-cards.js' ),
248        true
249    );
250}