Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
82.19% covered (warning)
82.19%
120 / 146
33.33% covered (danger)
33.33%
3 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
Custom_Taxonomy_Slot_Mapping
82.19% covered (warning)
82.19%
120 / 146
33.33% covered (danger)
33.33%
3 / 9
67.27
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 get_map
92.86% covered (success)
92.86%
26 / 28
0.00% covered (danger)
0.00%
0 / 1
10.04
 resolve_slot
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 reset_cache_for_testing
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 register_slot_taxonomies
92.86% covered (success)
92.86%
26 / 28
0.00% covered (danger)
0.00%
0 / 1
7.02
 mirror_assignment
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
4.10
 mirror_removal
72.73% covered (warning)
72.73%
8 / 11
0.00% covered (danger)
0.00%
0 / 1
4.32
 mirror_deletion
69.23% covered (warning)
69.23%
9 / 13
0.00% covered (danger)
0.00%
0 / 1
8.43
 backfill
70.45% covered (warning)
70.45%
31 / 44
0.00% covered (danger)
0.00%
0 / 1
20.80
1<?php
2/**
3 * Custom taxonomy → reserved Jetpack Search slot mapping.
4 *
5 * @package automattic/jetpack-search
6 */
7
8namespace Automattic\Jetpack\Search;
9
10/**
11 * Power-user escape hatch for taxonomies Jetpack Search doesn't index natively.
12 *
13 * A site adds a mapping via the `jetpack_search_custom_taxonomy_map` filter
14 * (e.g. `'genre' => 'jetpack-search-tag1'`). The class then:
15 *
16 *   1. Registers each in-use slot (`jetpack-search-tag0…9`) as a private
17 *      shadow taxonomy on the same object types as its user-side source.
18 *   2. Mirrors assignments onto the slot via `set_object_terms`,
19 *      `deleted_term_relationships`, and `delete_term` so Sync ships the
20 *      slot rows to WPCOM (slot taxonomies are in `Sync\Modules\Search`).
21 *   3. Resolves user-facing slug → slot at query-build time
22 *      (`Filter_Checkbox::build_config()` writes the `effectiveSlug`).
23 *
24 * Empty filter default = feature off. A site with no entry pays only an
25 * `isset()` check inside `mirror_assignment()`.
26 *
27 * See https://jetpack.com/support/search/frequently-asked-questions/#troubleshoot-custom-tax
28 */
29class Custom_Taxonomy_Slot_Mapping {
30
31    /**
32     * Backfill modes accepted by `backfill()`.
33     *
34     * - `mirror`: per-post replacement only. Walks user-side terms and resets
35     *   each post's slot post-set to match. **Posts that lost every user-side
36     *   term during an inactive-mirror gap aren't visited** — their stale slot
37     *   relationships orphan. Common case for first-time setup.
38     * - `rebuild`: delete every slot term first (cascades to drop relationships),
39     *   then mirror. Byte-for-byte fresh projection, no orphans. Costly on
40     *   large sites — N deletes for N slot terms.
41     */
42    const BACKFILL_MODES = array( 'mirror', 'rebuild' );
43
44    /**
45     * Per-request memo backing `get_map()`. Validation runs once per request
46     * so `_doing_it_wrong()` for a bad map doesn't multiply.
47     *
48     * @var array<string, string>|null
49     */
50    private static $map_cache = null;
51
52    /**
53     * Wire bootstrap + mirror hooks. Called once from `Search_Blocks::init()`.
54     *
55     * Mirror hooks attach unconditionally — they short-circuit on
56     * `! isset( $map[ $taxonomy ] )` so sites without a mapping pay only one
57     * cached read + `isset` call. Avoids a load-order trap if a site declares
58     * the map after `init` fires.
59     *
60     * Slot-registration priority 20 so user-side taxonomies declared at
61     * default priority 10 are present when we read their `object_type`.
62     */
63    public static function init(): void {
64        add_action( 'init', array( static::class, 'register_slot_taxonomies' ), 20 );
65        add_action( 'set_object_terms', array( static::class, 'mirror_assignment' ), 10, 6 );
66        // `wp_remove_object_terms()` fires `deleted_term_relationships`, not
67        // `set_object_terms`, so the slot drifts unless we hook both. Block-editor
68        // saves go through the full replace path and are covered above.
69        add_action( 'deleted_term_relationships', array( static::class, 'mirror_removal' ), 10, 3 );
70        add_action( 'delete_term', array( static::class, 'mirror_deletion' ), 10, 4 );
71    }
72
73    /**
74     * Map of user-facing taxonomy slug → reserved slot (`jetpack-search-tag0…9`).
75     *
76     * Slots must match `jetpack-search-tag[0-9]` exactly (else dropped with a
77     * `_doing_it_wrong()` notice — routing to a non-existent ES field would
78     * silently return nothing). Two slugs claiming the same slot: first wins;
79     * second is dropped (term spaces would otherwise merge silently).
80     *
81     * @return array<string, string>
82     */
83    public static function get_map(): array {
84        if ( null !== self::$map_cache ) {
85            return self::$map_cache;
86        }
87
88        /**
89         * Map custom taxonomy slugs to a reserved Jetpack Search index slot.
90         *
91         * Default is an empty array, which leaves the slot-mapping feature
92         * entirely off — no slot taxonomies registered, no auto-mirror, no
93         * query rewrite. A site enables the feature by returning a non-empty
94         * map from this filter.
95         *
96         * @since 0.60.0
97         *
98         * @param array<string, string> $map Empty by default; entries shape
99         *                                   `[ 'user_slug' => 'jetpack-search-tagN' ]`.
100         */
101        $raw = apply_filters( 'jetpack_search_custom_taxonomy_map', array() );
102        if ( ! is_array( $raw ) ) {
103            $msg = esc_html__( 'The jetpack_search_custom_taxonomy_map filter must return an array of user-slug => jetpack-search-tagN pairs.', 'jetpack-search-pkg' );
104            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $msg is esc_html__() output.
105            _doing_it_wrong( 'jetpack_search_custom_taxonomy_map', $msg, 'jetpack-search-pkg 0.60.0' );
106            self::$map_cache = array();
107            return self::$map_cache;
108        }
109
110        $map        = array();
111        $slot_owner = array();
112        foreach ( $raw as $user_slug => $slot ) {
113            if ( ! is_string( $user_slug ) || '' === $user_slug || ! is_string( $slot ) ) {
114                continue;
115            }
116            $user_slug = sanitize_key( $user_slug );
117            if ( '' === $user_slug ) {
118                continue;
119            }
120            if ( ! preg_match( '/^jetpack-search-tag[0-9]$/', $slot ) ) {
121                /* translators: 1: invalid slot value, 2: user-facing taxonomy slug */
122                $msg = sprintf( esc_html__( 'Invalid Jetpack Search slot "%1$s" for taxonomy "%2$s"; expected one of jetpack-search-tag0…jetpack-search-tag9.', 'jetpack-search-pkg' ), esc_html( $slot ), esc_html( $user_slug ) );
123                // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $msg is sprintf() of esc_html__() with esc_html()-wrapped args.
124                _doing_it_wrong( 'jetpack_search_custom_taxonomy_map', $msg, 'jetpack-search-pkg 0.60.0' );
125                continue;
126            }
127            if ( isset( $slot_owner[ $slot ] ) ) {
128                /* translators: 1: slot, 2: first user-facing slug that owns the slot, 3: second user-facing slug attempting to claim it */
129                $msg = sprintf( esc_html__( 'Slot "%1$s" is already mapped to "%2$s"; ignoring duplicate mapping from "%3$s".', 'jetpack-search-pkg' ), esc_html( $slot ), esc_html( $slot_owner[ $slot ] ), esc_html( $user_slug ) );
130                // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $msg is sprintf() of esc_html__() with esc_html()-wrapped args.
131                _doing_it_wrong( 'jetpack_search_custom_taxonomy_map', $msg, 'jetpack-search-pkg 0.60.0' );
132                continue;
133            }
134            $map[ $user_slug ]   = $slot;
135            $slot_owner[ $slot ] = $user_slug;
136        }
137
138        self::$map_cache = $map;
139        return $map;
140    }
141
142    /**
143     * Resolve a user-facing slug to the ES field it should query — the
144     * matching slot when mapped, otherwise the slug itself. Built-ins
145     * (category, post_tag, product_*) are returned verbatim so a stray
146     * map entry can't silently redirect them. Empty in → empty out.
147     *
148     * @param string $taxonomy User-facing taxonomy slug.
149     * @return string Effective ES field slug.
150     */
151    public static function resolve_slot( string $taxonomy ): string {
152        if ( '' === $taxonomy ) {
153            return '';
154        }
155        if ( in_array( $taxonomy, Search_Blocks::BUILT_IN_CUSTOM_TAXONOMY_EXCLUSIONS, true ) ) {
156            return $taxonomy;
157        }
158        $map = self::get_map();
159        return $map[ $taxonomy ] ?? $taxonomy;
160    }
161
162    /**
163     * Reset the `get_map()` memo. Tests only.
164     *
165     * @internal
166     */
167    public static function reset_cache_for_testing(): void {
168        self::$map_cache = null;
169    }
170
171    /**
172     * Register each in-use slot as a private shadow taxonomy on the same
173     * object types as its user-side source. They have to be real registered
174     * taxonomies so `wp_set_object_terms()` accepts them and Sync ships them
175     * to WPCOM — but invisible (no UI, REST, rewrite, query var, etc.)
176     * because only `mirror_assignment()` ever writes to them.
177     *
178     * Forced flat: WPCOM aggregates slot taxonomies as bag-of-terms and
179     * parent/child wouldn't survive the round trip.
180     */
181    public static function register_slot_taxonomies(): void {
182        $map = self::get_map();
183        if ( empty( $map ) ) {
184            return;
185        }
186        // Union object_types per slot in case a slot shadows multiple taxonomies.
187        $object_types_by_slot = array();
188        foreach ( $map as $user_slug => $slot ) {
189            $tax = get_taxonomy( $user_slug );
190            if ( ! $tax ) {
191                continue;
192            }
193            foreach ( (array) $tax->object_type as $object_type ) {
194                $object_types_by_slot[ $slot ][ $object_type ] = true;
195            }
196        }
197        foreach ( $object_types_by_slot as $slot => $object_types ) {
198            if ( taxonomy_exists( $slot ) ) {
199                continue;
200            }
201            register_taxonomy(
202                $slot,
203                array_keys( $object_types ),
204                array(
205                    'public'            => false,
206                    'show_ui'           => false,
207                    'show_in_menu'      => false,
208                    'show_in_rest'      => false,
209                    'show_in_nav_menus' => false,
210                    'show_admin_column' => false,
211                    'rewrite'           => false,
212                    'query_var'         => false,
213                    'hierarchical'      => false,
214                )
215            );
216        }
217    }
218
219    /**
220     * Mirror assignments onto the slot. Uses term names because slot terms
221     * need to display the same label as the source (and `wp_set_object_terms()`
222     * creates matching terms by name). Idempotent. Recursion bounded by the
223     * map gate: the inner call fires with `$taxonomy = jetpack-search-tagN`,
224     * which isn't a map key.
225     *
226     * @param int    $object_id  Post id receiving the terms.
227     * @param array  $terms      Raw input (unused — re-fetched).
228     * @param array  $tt_ids     Term taxonomy ids (unused).
229     * @param string $taxonomy   Taxonomy slug the assignment targeted.
230     * @param bool   $append     Append flag (unused — full mirror).
231     * @param array  $old_tt_ids Previous tt_ids (unused).
232     */
233    public static function mirror_assignment( $object_id, $terms, $tt_ids, $taxonomy, $append, $old_tt_ids ): void {
234        unset( $terms, $tt_ids, $append, $old_tt_ids );
235
236        $map = self::get_map();
237        if ( ! isset( $map[ $taxonomy ] ) ) {
238            return;
239        }
240        $slot = $map[ $taxonomy ];
241        if ( ! taxonomy_exists( $slot ) ) {
242            return;
243        }
244        $names = wp_get_object_terms( (int) $object_id, $taxonomy, array( 'fields' => 'names' ) );
245        if ( is_wp_error( $names ) ) {
246            return;
247        }
248        wp_set_object_terms( (int) $object_id, $names, $slot, false );
249    }
250
251    /**
252     * Mirror removals onto the slot. Re-reads the canonical post-set rather
253     * than diffing the removed tt_ids, so the slot tracks the current state.
254     *
255     * @param int    $object_id Post receiving the removal.
256     * @param array  $tt_ids    Term taxonomy ids removed (unused).
257     * @param string $taxonomy  Taxonomy targeted.
258     */
259    public static function mirror_removal( $object_id, $tt_ids, $taxonomy ): void {
260        unset( $tt_ids );
261
262        $map = self::get_map();
263        if ( ! isset( $map[ $taxonomy ] ) ) {
264            return;
265        }
266        $slot = $map[ $taxonomy ];
267        if ( ! taxonomy_exists( $slot ) ) {
268            return;
269        }
270        $names = wp_get_object_terms( (int) $object_id, $taxonomy, array( 'fields' => 'names' ) );
271        if ( is_wp_error( $names ) ) {
272            return;
273        }
274        wp_set_object_terms( (int) $object_id, $names, $slot, false );
275    }
276
277    /**
278     * Mirror deletions onto the slot. Without this a deleted user-side
279     * "Fantasy" leaves an orphan slot term that ES keeps returning as a
280     * zero-doc bucket on retained-option lists.
281     *
282     * @param int    $term_id      User-side term id (unused).
283     * @param int    $tt_id        Term taxonomy id (unused).
284     * @param string $taxonomy     Taxonomy the term lived in.
285     * @param object $deleted_term Term object as it existed pre-delete.
286     */
287    public static function mirror_deletion( $term_id, $tt_id, $taxonomy, $deleted_term ): void {
288        unset( $term_id, $tt_id );
289
290        $map = self::get_map();
291        if ( ! isset( $map[ $taxonomy ] ) ) {
292            return;
293        }
294        $slot = $map[ $taxonomy ];
295        if ( ! taxonomy_exists( $slot ) ) {
296            return;
297        }
298        // Match by slug — `wp_set_object_terms()` uses `sanitize_title()` and
299        // `get_term_by('name')` is case-sensitive on case-sensitive collations,
300        // so name lookup misses "fantasy" when the source is "Fantasy".
301        $slug = isset( $deleted_term->slug ) ? (string) $deleted_term->slug : '';
302        if ( '' === $slug ) {
303            return;
304        }
305        $slot_term = get_term_by( 'slug', $slug, $slot );
306        if ( $slot_term && ! is_wp_error( $slot_term ) ) {
307            wp_delete_term( (int) $slot_term->term_id, $slot );
308        }
309    }
310
311    /**
312     * One-shot backfill: walk posts with mapped-taxonomy terms and mirror onto
313     * the slot. Use after introducing a mapping on a site with pre-existing
314     * tagged posts. Idempotent. Not hooked — invoke from a script or `wp eval`.
315     *
316     * See `BACKFILL_MODES` for `mirror` vs `rebuild` semantics.
317     *
318     * @param string $mode `mirror` (default) or `rebuild`.
319     * @return int Number of (post, taxonomy) pairs mirrored.
320     */
321    public static function backfill( string $mode = 'mirror' ): int {
322        if ( ! in_array( $mode, self::BACKFILL_MODES, true ) ) {
323            /* translators: %s: invalid mode value passed to backfill(). */
324            $msg = sprintf( esc_html__( 'Unknown backfill mode "%s"; expected one of mirror | rebuild.', 'jetpack-search-pkg' ), esc_html( $mode ) );
325            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $msg is sprintf() of esc_html__() with esc_html()-wrapped args.
326            _doing_it_wrong( __METHOD__, $msg, 'jetpack-search-pkg 0.60.0' );
327            $mode = 'mirror';
328        }
329
330        $map = self::get_map();
331        if ( empty( $map ) ) {
332            return 0;
333        }
334        $mirrored = 0;
335        foreach ( $map as $user_slug => $slot ) {
336            if ( ! taxonomy_exists( $user_slug ) || ! taxonomy_exists( $slot ) ) {
337                continue;
338            }
339            // Rebuild: drop every slot term first. `wp_delete_term()` cascades
340            // to remove relationships, so the mirror loop projects a fresh copy
341            // with no orphans. Map keys are user-side slugs, never slot slugs,
342            // so the inner `delete_term` fires don't recurse.
343            if ( 'rebuild' === $mode ) {
344                $existing_slot_terms = get_terms(
345                    array(
346                        'taxonomy'   => $slot,
347                        'hide_empty' => false,
348                        'fields'     => 'ids',
349                    )
350                );
351                if ( ! is_wp_error( $existing_slot_terms ) ) {
352                    foreach ( (array) $existing_slot_terms as $slot_term_id ) {
353                        wp_delete_term( (int) $slot_term_id, $slot );
354                    }
355                }
356            }
357            $terms = get_terms(
358                array(
359                    'taxonomy'   => $user_slug,
360                    'hide_empty' => false,
361                    'fields'     => 'all',
362                )
363            );
364            if ( is_wp_error( $terms ) || empty( $terms ) ) {
365                continue;
366            }
367            $object_ids = get_objects_in_term(
368                wp_list_pluck( $terms, 'term_id' ),
369                $user_slug
370            );
371            if ( is_wp_error( $object_ids ) || empty( $object_ids ) ) {
372                continue;
373            }
374            foreach ( array_unique( array_map( 'intval', (array) $object_ids ) ) as $object_id ) {
375                $names = wp_get_object_terms( $object_id, $user_slug, array( 'fields' => 'names' ) );
376                if ( is_wp_error( $names ) ) {
377                    continue;
378                }
379                wp_set_object_terms( $object_id, $names, $slot, false );
380                ++$mirrored;
381            }
382        }
383        return $mirrored;
384    }
385}