Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.13% covered (success)
90.13%
210 / 233
80.00% covered (warning)
80.00%
16 / 20
CRAP
n/a
0 / 0
wpcom_themes_get_purchases
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
wpcom_themes_tab_register_flag
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
wpcom_themes_tab_enabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
wpcom_themes_tab_enqueue_script
100.00% covered (success)
100.00%
35 / 35
100.00% covered (success)
100.00%
1 / 1
2
wpcom_themes_tab_is_default_view
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
wpcom_themes_tab_redirect_to_default_view
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
wpcom_themes_tab_url
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
wpcom_themes_tab_checkout_url
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
wpcom_themes_tab_add_plan
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
wpcom_themes_tab_is_included
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
wpcom_themes_tab_add_subscription
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
6
wpcom_themes_tab_subscription
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
5.01
wpcom_themes_tab_api
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
wpcom_themes_tab_store
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
wpcom_themes_tab_mapper
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
wpcom_themes_tab_catalog
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
wpcom_themes_tab_serve_themes_api
100.00% covered (success)
100.00%
35 / 35
100.00% covered (success)
100.00%
1 / 1
13
wpcom_themes_tab_search_partners
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
7
wpcom_themes_tab_splice_search_partners
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
17
wpcom_themes_tab_prefer_wpcom_copies
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
14
1<?php
2/**
3 * Lists WordPress.com themes as a tab on the core Add Themes screen.
4 *
5 * @package automattic/jetpack-mu-wpcom
6 */
7
8use Automattic\Jetpack\Feature_Flags\Feature_Flags;
9use Automattic\Jetpack\Jetpack_Mu_Wpcom\Common;
10use Automattic\Jetpack\Status\Host;
11
12// @codeCoverageIgnoreStart -- shadowed by the test stub in tests/lib/functions-wordpress.php.
13if ( ! function_exists( 'wpcom_themes_get_purchases' ) ) {
14    /**
15     * Source of purchase data for the themes tab, pre-definable by a test as
16     * wpcom_expiry_get_purchases() is, and for the reason given there.
17     *
18     * @return array
19     *
20     * @phan-suppress PhanRedefineFunction -- phan sees both this and the test stub as definitions even though only one loads at runtime.
21     */
22    function wpcom_themes_get_purchases() {
23        if ( function_exists( 'wpcom_get_site_purchases' ) ) {
24            return wpcom_get_site_purchases();
25        }
26
27        return array();
28    }
29}
30// @codeCoverageIgnoreEnd
31
32/**
33 * The tab's `data-sort` value, which core's theme.js sends to themes_api() as `browse`.
34 */
35const WPCOM_THEMES_TAB = 'wpcom';
36
37/**
38 * Feature flag gating the tab.
39 */
40const WPCOM_THEMES_TAB_FLAG = 'wpcom-themes-marketplace-tab';
41
42/**
43 * Themes per request, which is also the grid's page size.
44 */
45const WPCOM_THEMES_TAB_PER_PAGE = 100;
46
47/**
48 * The `wpcom_pricing` value that lists only the themes this site can install with nothing to buy.
49 */
50const WPCOM_THEMES_TAB_INCLUDED = 'included';
51
52/**
53 * Registers the feature flag.
54 *
55 * @return void
56 */
57function wpcom_themes_tab_register_flag() {
58    Feature_Flags::register(
59        WPCOM_THEMES_TAB_FLAG,
60        array(
61            'default'     => false,
62            'description' => 'Show WordPress.com themes as a tab on the Add Themes screen.',
63            'owner'       => 'jetpack-mu-wpcom',
64        )
65    );
66}
67wpcom_themes_tab_register_flag();
68
69/**
70 * Whether to show the tab on this site.
71 *
72 * Core's Add Themes screen can't be reached on Simple, so there the tab goes on WordPress.com's
73 * own copy of it, which asks this before it draws.
74 *
75 * @return bool
76 */
77function wpcom_themes_tab_enabled() {
78    return ( new Host() )->is_wpcom_platform() && Feature_Flags::is_enabled( WPCOM_THEMES_TAB_FLAG );
79}
80
81/**
82 * Loads the script that adds the tab.
83 *
84 * Core hard-codes this screen's tabs with no filter (trac #65979), so the script inserts ours
85 * into the markup before core's theme.js reads it on DOM ready.
86 *
87 * @return void
88 */
89function wpcom_themes_tab_enqueue_script() {
90    if ( ! wpcom_themes_tab_enabled() ) {
91        return;
92    }
93
94    // Core's theme.js owns the views this decorates, and routes on DOM ready rather than on load.
95    wp_enqueue_script(
96        'wpcom-themes-tab',
97        plugins_url( 'js/themes-tab.js', __FILE__ ),
98        array( 'theme' ),
99        // The package version does not change between builds of it, and these are served for a year.
100        (string) filemtime( __DIR__ . '/js/themes-tab.js' ),
101        array( 'in_footer' => true )
102    );
103    wp_localize_script(
104        'wpcom-themes-tab',
105        'wpcomThemesTab',
106        array(
107            'sort'           => WPCOM_THEMES_TAB,
108            'label'          => _x( 'Marketplace', 'Theme Installer', 'jetpack-mu-wpcom' ),
109            'upgrade'        => __( 'Upgrade', 'jetpack-mu-wpcom' ),
110            /* translators: %s: Theme name. */
111            'upgradeLabel'   => __( 'Upgrade for %s', 'jetpack-mu-wpcom' ),
112            'subscribe'      => __( 'Subscribe', 'jetpack-mu-wpcom' ),
113            /* translators: %s: Theme name. */
114            'subscribeLabel' => __( 'Subscribe to %s', 'jetpack-mu-wpcom' ),
115            'pricingLabel'   => __( 'Filter themes by plan', 'jetpack-mu-wpcom' ),
116            'allPlans'       => __( 'All plans', 'jetpack-mu-wpcom' ),
117            'included'       => WPCOM_THEMES_TAB_INCLUDED,
118            'includedLabel'  => __( 'Included with my plan', 'jetpack-mu-wpcom' ),
119            'products'       => WPCom_Themes_Store::PRODUCTS_URL,
120            /* translators: %s: Price per month, for example $7.20. */
121            'price'          => __( '%s/mo, billed annually', 'jetpack-mu-wpcom' ),
122            /* translators: 1: Price per month, for example $7.20. 2: Plan name, for example Personal plan. */
123            'priceWithPlan'  => __( '%1$s/mo, billed annually + %2$s', 'jetpack-mu-wpcom' ),
124        )
125    );
126    wp_enqueue_style(
127        'wpcom-themes-tab',
128        plugins_url( 'css/themes-tab.css', __FILE__ ),
129        array(),
130        (string) filemtime( __DIR__ . '/css/themes-tab.css' )
131    );
132
133    Common\wpcom_enqueue_tracking_scripts( jetpack_mu_wpcom_enqueue_assets( 'wpcom-themes-tab-tracks', array( 'js' ) ) );
134}
135add_action( 'load-theme-install.php', 'wpcom_themes_tab_enqueue_script' );
136
137/**
138 * Whether a request to the Add Themes screen should land on this tab.
139 *
140 * @param array $query The request's query args.
141 * @return bool
142 */
143function wpcom_themes_tab_is_default_view( array $query ) {
144    if ( ! wpcom_themes_tab_enabled() ) {
145        return false;
146    }
147
148    // An explicit view, a search, an upload or a preview is left as asked for.
149    return ! array_intersect( array( 'browse', 'theme', 'search', 'upload' ), array_keys( $query ) );
150}
151
152/**
153 * Sends a bare Add Themes request to this tab.
154 *
155 * Core's theme.js routes a bare `theme-install.php` to `popular` and rewrites the URL to match, so
156 * the view has to be chosen before that runs rather than by ordering the tabs.
157 *
158 * @return void
159 */
160function wpcom_themes_tab_redirect_to_default_view() {
161    // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reads which view was asked for, nothing more.
162    if ( ! wpcom_themes_tab_is_default_view( $_GET ) ) {
163        return;
164    }
165
166    wp_safe_redirect( wpcom_themes_tab_url() );
167    exit;
168}
169add_action( 'load-theme-install.php', 'wpcom_themes_tab_redirect_to_default_view' );
170/**
171 * This tab's own URL.
172 *
173 * @return string
174 */
175function wpcom_themes_tab_url() {
176    return admin_url( 'theme-install.php?browse=' . WPCOM_THEMES_TAB );
177}
178
179/**
180 * Where to buy the plan a theme needs.
181 *
182 * Checkout returns to the theme itself, where the plan now allows an install, and back out to
183 * the tab if it is abandoned instead.
184 *
185 * @param string|array $products One product slug, or the several checkout should charge for at once.
186 * @param string       $slug     Theme slug.
187 * @return string
188 */
189function wpcom_themes_tab_checkout_url( $products, string $slug ) {
190    $url = sprintf(
191        'https://wordpress.com/checkout/%s/%s',
192        rawurlencode( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ),
193        // Checkout splits this segment on commas, so each slug is encoded but the separator is not.
194        implode( ',', array_map( 'rawurlencode', (array) $products ) )
195    );
196
197    // Checkout honours these on the site's own host, and add_query_arg() does not encode values.
198    return add_query_arg(
199        array(
200            'checkoutBackUrl' => rawurlencode( wpcom_themes_tab_url() ),
201            'redirect_to'     => rawurlencode( admin_url( 'theme-install.php?theme=' . rawurlencode( $slug ) ) ),
202        ),
203        $url
204    );
205}
206
207/**
208 * Says what a theme costs this site, for the badge every card carries.
209 *
210 * Core's grid offers Install for whatever it is given, so a theme the plan excludes also has to
211 * carry the upgrade that belongs in its place.
212 *
213 * @param stdClass $theme A theme in the themes API shape.
214 * @return stdClass The theme, carrying its badge and, when the plan excludes it, an upgrade.
215 */
216function wpcom_themes_tab_add_plan( $theme ) {
217    $theme->wpcom_marketplace_tab = true;
218
219    $tier = isset( $theme->theme_tier ) ? (string) $theme->theme_tier : '';
220
221    // A partner theme is sold rather than granted, so its badge is a price and not a plan.
222    if ( WPCom_Themes_Tiers::PARTNER === $tier ) {
223        return wpcom_themes_tab_add_subscription( $theme );
224    }
225
226    if ( wpcom_themes_tab_is_included( $tier, (string) ( $theme->slug ?? '' ) ) ) {
227        $theme->plan_badge = __( 'Included with plan', 'jetpack-mu-wpcom' );
228
229        return $theme;
230    }
231
232    $plan                 = WPCom_Themes_Tiers::plan_for( $tier );
233    $theme->plan_badge    = WPCom_Themes_Tiers::plan_name_for( $tier );
234    $theme->required_plan = $plan;
235    $theme->upgrade_url   = wpcom_themes_tab_checkout_url( $plan, (string) ( $theme->slug ?? '' ) );
236
237    return $theme;
238}
239
240/**
241 * Whether this site can install a theme with nothing more to buy.
242 *
243 * @param string $tier Tier slug.
244 * @param string $slug Theme slug.
245 * @return bool
246 */
247function wpcom_themes_tab_is_included( string $tier, string $slug ) {
248    // No plan includes a partner theme, so one counts only once its own subscription is paid for.
249    if ( WPCom_Themes_Tiers::PARTNER === $tier ) {
250        $subscription = wpcom_themes_tab_subscription( $slug );
251
252        return array() !== $subscription && $subscription['owned'];
253    }
254
255    return '' === WPCom_Themes_Tiers::plan_for( $tier ) || WPCom_Themes_Tiers::site_can_use( $tier );
256}
257
258/**
259 * Offers the subscription a partner theme needs, which no plan includes.
260 *
261 * The badge is left for the browser to write, because the store prices a request where it comes
262 * from: read here it would quote the web server's currency rather than the reader's.
263 *
264 * @param stdClass $theme A theme in the themes API shape.
265 * @return stdClass The theme, carrying what to price and where to buy it.
266 */
267function wpcom_themes_tab_add_subscription( $theme ) {
268    $slug         = (string) ( $theme->slug ?? '' );
269    $subscription = wpcom_themes_tab_subscription( $slug );
270
271    // A theme already paid for is core's to install, and one the store does not sell is not ours
272    // to sell. Both are left to core's own button, which a partner theme still defeats. DOTCOM-18722.
273    if ( array() === $subscription || $subscription['owned'] ) {
274        return $theme;
275    }
276
277    // Every paid plan grants the tier, so this is only ever the cheapest one.
278    $plan = WPCom_Themes_Tiers::site_can_use( WPCom_Themes_Tiers::PARTNER )
279        ? ''
280        : WPCom_Themes_Tiers::plan_for( WPCom_Themes_Tiers::PARTNER );
281
282    $theme->price_product = $subscription['product'];
283    $theme->price_plan    = '' === $plan ? '' : WPCom_Themes_Tiers::plan_name_for( WPCom_Themes_Tiers::PARTNER );
284    $theme->required_plan = $plan;
285    // Checkout charges for the plan and the theme together, so one purchase is enough to install.
286    $theme->purchase_url = wpcom_themes_tab_checkout_url(
287        '' === $plan ? $subscription['product'] : array( $plan, $subscription['product'] ),
288        $slug
289    );
290
291    return $theme;
292}
293
294/**
295 * How a partner theme is sold, and whether this site has already bought it.
296 *
297 * @param string $slug Theme slug.
298 * @return array{product: string, owned: bool}|array{} Empty when the store does not sell it.
299 */
300function wpcom_themes_tab_subscription( string $slug ) {
301    if ( '' === $slug ) {
302        return array();
303    }
304
305    $store    = wpcom_themes_tab_store();
306    $products = $store->products();
307
308    foreach ( wpcom_themes_tab_api()->fetch_partner_products()[ $slug ] ?? array() as $sold_as ) {
309        $product_slug = (string) ( $sold_as->product_slug ?? '' );
310        $product      = $products[ $product_slug ] ?? null;
311
312        if ( null === $product || WPCom_Themes_Store::TERM !== $product['term'] ) {
313            continue;
314        }
315
316        return array(
317            'product' => $product_slug,
318            'owned'   => in_array( $product['billing_slug'], $store->subscriptions(), true ),
319        );
320    }
321
322    return array();
323}
324
325/**
326 * The shared WordPress.com themes reader, which wpcomsh also uses for core's own tabs.
327 *
328 * @return WPCom_Themes_Api
329 */
330function wpcom_themes_tab_api() {
331    static $api;
332    if ( ! $api ) {
333        $api = new WPCom_Themes_Api( new WPCom_Themes_Cache() );
334    }
335
336    return $api;
337}
338
339/**
340 * The store reader, which prices the themes no plan includes.
341 *
342 * @return WPCom_Themes_Store
343 */
344function wpcom_themes_tab_store() {
345    static $store;
346    if ( ! $store ) {
347        $store = new WPCom_Themes_Store( new WPCom_Themes_Cache() );
348    }
349
350    return $store;
351}
352
353/**
354 * The shared mapper, which shapes a WordPress.com theme the way core's grid reads it.
355 *
356 * @return WPCom_Themes_Mapper
357 */
358function wpcom_themes_tab_mapper() {
359    static $mapper;
360    if ( ! $mapper ) {
361        $mapper = new WPCom_Themes_Mapper();
362    }
363
364    return $mapper;
365}
366
367/**
368 * Every theme the tab lists, in the order WordPress.com returns them.
369 *
370 * The shared reader fetches and caches the whole catalog, so the tab pages through it here rather
371 * than asking for a page at a time.
372 *
373 * @return array<stdClass>
374 */
375function wpcom_themes_tab_catalog() {
376    return array_values(
377        array_filter(
378            wpcom_themes_tab_api()->fetch_all_non_delisted_themes(),
379            static function ( $theme ) {
380                return is_object( $theme ) && ! empty( $theme->id ) && is_string( $theme->id );
381            }
382        )
383    );
384}
385
386/**
387 * Answers the tab's query in place of the WordPress.org themes API.
388 *
389 * @param false|object|array $result The result object or array. Default false.
390 * @param string             $action The type of information being requested.
391 * @param object             $args   Theme API arguments.
392 * @return false|object|array
393 */
394function wpcom_themes_tab_serve_themes_api( $result, $action, $args ) {
395    if ( false !== $result || 'query_themes' !== $action || ! wpcom_themes_tab_enabled() ) {
396        return $result;
397    }
398
399    // A `?theme=<slug>` preview link, loaded directly, looks its theme up by slug rather than by tab.
400    if ( ! isset( $args->browse ) && isset( $args->theme ) && is_string( $args->theme ) ) {
401        $theme = wpcom_themes_tab_api()->fetch_theme( $args->theme );
402
403        return null === $theme ? $result : (object) array(
404            'info'   => array(
405                'page'    => 1,
406                'pages'   => 1,
407                'results' => 1,
408            ),
409            'themes' => array( wpcom_themes_tab_add_plan( wpcom_themes_tab_mapper()->map_wpcom_to_wporg( $theme ) ) ),
410        );
411    }
412
413    if ( ! isset( $args->browse ) || WPCOM_THEMES_TAB !== $args->browse ) {
414        return $result;
415    }
416
417    $themes = wpcom_themes_tab_catalog();
418
419    // Filtered before paging, so the count and the pages match what the grid shows.
420    if ( isset( $args->wpcom_pricing ) && WPCOM_THEMES_TAB_INCLUDED === $args->wpcom_pricing ) {
421        $themes = array_values(
422            array_filter(
423                $themes,
424                static function ( $theme ) {
425                    // Read as the mapper reads it, so the filter agrees with the card's badge.
426                    return wpcom_themes_tab_is_included( (string) ( $theme->theme_tier->slug ?? '' ), $theme->id );
427                }
428            )
429        );
430    }
431
432    $page   = isset( $args->page ) ? max( 1, (int) $args->page ) : 1;
433    $mapper = wpcom_themes_tab_mapper();
434
435    // Only the page core asked for is mapped: mapping reads each theme from disk to say whether it
436    // is installed.
437    $shown = array_slice( $themes, ( $page - 1 ) * WPCOM_THEMES_TAB_PER_PAGE, WPCOM_THEMES_TAB_PER_PAGE );
438
439    return (object) array(
440        'info'   => array(
441            'page'    => $page,
442            'pages'   => (int) ceil( count( $themes ) / WPCOM_THEMES_TAB_PER_PAGE ),
443            'results' => count( $themes ),
444        ),
445        'themes' => array_values( array_map( 'wpcom_themes_tab_add_plan', array_map( array( $mapper, 'map_wpcom_to_wporg' ), $shown ) ) ),
446    );
447}
448add_filter( 'themes_api', 'wpcom_themes_tab_serve_themes_api', 10, 3 );
449
450/**
451 * How many partner themes a search can bring to the top, as on the plugins Marketplace tab.
452 */
453const WPCOM_THEMES_TAB_SEARCH_PARTNERS = 2;
454
455/**
456 * Partner themes that match a search term, best matches first.
457 *
458 * A match in the name ranks above one WordPress.com found elsewhere in the theme. Only themes the
459 * store sells are offered.
460 *
461 * @param string $term What was searched for.
462 * @return array<stdClass> Themes in the WordPress.com shape.
463 */
464function wpcom_themes_tab_search_partners( string $term ) {
465    $needle = mb_strtolower( trim( $term ) );
466
467    // Anything shorter matches half the catalog.
468    if ( mb_strlen( $needle ) < 3 ) {
469        return array();
470    }
471
472    $in_name   = array();
473    $elsewhere = array();
474    foreach ( wpcom_themes_tab_api()->search_themes( $term ) as $theme ) {
475        $slug = $theme->id ?? null;
476
477        if (
478            ! is_string( $slug )
479            || WPCom_Themes_Tiers::PARTNER !== ( $theme->theme_tier->slug ?? '' )
480            || array() === wpcom_themes_tab_subscription( $slug )
481        ) {
482            continue;
483        }
484
485        if ( false !== mb_strpos( mb_strtolower( (string) ( $theme->name ?? '' ) ), $needle ) ) {
486            $in_name[] = $theme;
487        } else {
488            $elsewhere[] = $theme;
489        }
490    }
491
492    return array_slice( array_merge( $in_name, $elsewhere ), 0, WPCOM_THEMES_TAB_SEARCH_PARTNERS );
493}
494
495/**
496 * Adds matching partner themes to the top of a keyword search's first page.
497 *
498 * Runs after wpcomsh merges in the WordPress.com themes a plan includes, which leaves partner
499 * themes out because they are sold rather than included.
500 *
501 * @param object|WP_Error $result Themes API response.
502 * @param string          $action Themes API action.
503 * @param object          $args   Themes API arguments.
504 * @return object|WP_Error
505 */
506function wpcom_themes_tab_splice_search_partners( $result, $action, $args ) {
507    $term = is_object( $args ) && isset( $args->search ) && is_string( $args->search ) ? $args->search : '';
508    $page = is_object( $args ) && isset( $args->page ) ? (int) $args->page : 1;
509
510    if (
511        'query_themes' !== $action
512        || '' === $term
513        || $page > 1
514        || ! is_object( $result )
515        || ! isset( $result->themes )
516        || ! is_array( $result->themes )
517        || ! wpcom_themes_tab_enabled()
518    ) {
519        return $result;
520    }
521
522    $back     = admin_url( 'theme-install.php?search=' . rawurlencode( $term ) );
523    $partners = array();
524    foreach ( wpcom_themes_tab_search_partners( $term ) as $theme ) {
525        $theme = wpcom_themes_tab_add_plan( wpcom_themes_tab_mapper()->map_wpcom_to_wporg( $theme ) );
526
527        // Listed by the search, which installs and purchase clicks are attributed to.
528        $theme->wpcom_marketplace_tab = false;
529        $theme->wpcom_search_slot     = true;
530
531        if ( isset( $theme->purchase_url ) ) {
532            $theme->purchase_url = add_query_arg( 'checkoutBackUrl', rawurlencode( $back ), $theme->purchase_url );
533        }
534
535        $partners[] = $theme;
536    }
537
538    if ( array() === $partners ) {
539        return $result;
540    }
541
542    // A WordPress.org theme with a partner theme's slug is another author's theme, so both stay listed.
543    $result->themes = array_merge( $partners, $result->themes );
544
545    if ( isset( $result->info['results'] ) ) {
546        $result->info['results'] += count( $partners );
547    }
548
549    return $result;
550}
551add_filter( 'themes_api_result', 'wpcom_themes_tab_splice_search_partners', 10, 3 );
552
553/**
554 * Shows WordPress.com's copy of a theme both directories list, on Simple.
555 *
556 * A Simple site can activate ours without moving to Atomic, so it takes the WordPress.org card's
557 * place. Not a partner theme's slug, which WordPress.org uses for another author's theme.
558 *
559 * @param object|WP_Error $result Themes API response.
560 * @param string          $action Themes API action.
561 * @return object|WP_Error
562 */
563function wpcom_themes_tab_prefer_wpcom_copies( $result, $action ) {
564    if (
565        'query_themes' !== $action
566        || ! is_object( $result )
567        || ! isset( $result->themes )
568        || ! is_array( $result->themes )
569        || ! ( new Host() )->is_wpcom_simple()
570        || ! wpcom_themes_tab_enabled()
571    ) {
572        return $result;
573    }
574
575    $ours = array();
576    foreach ( wpcom_themes_tab_catalog() as $theme ) {
577        if ( WPCom_Themes_Tiers::PARTNER !== WPCom_Themes_Tiers::tier_of( $theme ) ) {
578            $ours[ $theme->id ] = $theme;
579        }
580    }
581
582    foreach ( $result->themes as $i => $theme ) {
583        $slug = is_object( $theme ) && empty( $theme->is_wpcom_theme ) ? ( $theme->slug ?? null ) : null;
584
585        if ( ! is_string( $slug ) || ! isset( $ours[ $slug ] ) ) {
586            continue;
587        }
588
589        $copy = wpcom_themes_tab_add_plan( wpcom_themes_tab_mapper()->map_wpcom_to_wporg( $ours[ $slug ] ) );
590
591        // Listed by core's tab, which installs and purchase clicks are attributed to.
592        $copy->wpcom_marketplace_tab = false;
593
594        $result->themes[ $i ] = $copy;
595    }
596
597    return $result;
598}
599add_filter( 'themes_api_result', 'wpcom_themes_tab_prefer_wpcom_copies', 10, 2 );