diff --git a/src/wp-includes/link-template.php b/src/wp-includes/link-template.php index 10bda681154f9..f6daba20a4faf 100644 --- a/src/wp-includes/link-template.php +++ b/src/wp-includes/link-template.php @@ -1896,10 +1896,35 @@ function get_adjacent_post( $in_same_term = false, $excluded_terms = '', $previo // Remove any exclusions from the term array to include. $term_array = array_diff( $term_array, (array) $excluded_terms ); + /** + * Filters the IDs of the terms used to determine an adjacent post. + * + * The dynamic portion of the hook name, `$adjacent`, refers to the type + * of adjacency, 'next' or 'previous'. + * + * Possible hook names include: + * + * - `get_next_post_terms` + * - `get_previous_post_terms` + * + * Returning an empty array causes `get_adjacent_post()` to return an empty + * string, matching the behavior of a post that belongs to no terms. + * + * @since 7.2.0 + * + * @param int[] $term_array Array of term IDs belonging to the current post, with excluded terms removed. + * @param WP_Post $post WP_Post object. + * @param string $taxonomy Taxonomy. Used to identify the term used when `$in_same_term` is true. + * @param bool $in_same_term Whether post should be in the same taxonomy term. + * @param int[]|string $excluded_terms Array of excluded term IDs. Empty string if none were provided. + */ + $term_array = apply_filters( "get_{$adjacent}_post_terms", $term_array, $post, $taxonomy, $in_same_term, $excluded_terms ); + if ( ! $term_array ) { return ''; } + // Cast to integers so that filtered values are safe to interpolate into the query. $term_array = array_map( 'intval', $term_array ); $where .= ' AND tt.term_id IN (' . implode( ',', $term_array ) . ')'; diff --git a/tests/phpunit/tests/link/getAdjacentPost.php b/tests/phpunit/tests/link/getAdjacentPost.php index e10ff82c099dc..92e17d8209b2d 100644 --- a/tests/phpunit/tests/link/getAdjacentPost.php +++ b/tests/phpunit/tests/link/getAdjacentPost.php @@ -6,6 +6,31 @@ class Tests_Link_GetAdjacentPost extends WP_UnitTestCase { protected $exclude_term; + /** + * Term array returned by the adjacent post terms filter. + * + * @var int[] + */ + protected $filtered_terms = array(); + + /** + * Arguments captured by the adjacent post terms filter. + * + * @var array|null + */ + protected $captured_terms_args = null; + + public function tear_down() { + remove_filter( 'get_previous_post_terms', array( $this, 'filter_adjacent_post_terms' ) ); + remove_filter( 'get_previous_post_terms', array( $this, 'filter_adjacent_post_terms_capture_args' ) ); + remove_filter( 'get_next_post_terms', array( $this, 'filter_adjacent_post_terms' ) ); + + $this->filtered_terms = array(); + $this->captured_terms_args = null; + + parent::tear_down(); + } + /** * @ticket 17807 */ @@ -351,6 +376,38 @@ public function filter_excluded_terms( $excluded_terms ) { return $excluded_terms; } + /** + * Replaces the resolved adjacent post term array. + * + * @param int[] $term_array Term IDs for the current post. + * @return int[] Filtered term IDs. + */ + public function filter_adjacent_post_terms( $term_array ) { + return $this->filtered_terms; + } + + /** + * Captures the arguments passed to the adjacent post terms filter. + * + * @param int[] $term_array Term IDs for the current post. + * @param WP_Post $post WP_Post object. + * @param string $taxonomy Taxonomy. + * @param bool $in_same_term Whether post should be in the same taxonomy term. + * @param int[]|string $excluded_terms Excluded term IDs. + * @return int[] Unmodified term IDs. + */ + public function filter_adjacent_post_terms_capture_args( $term_array, $post, $taxonomy, $in_same_term, $excluded_terms ) { + $this->captured_terms_args = array( + 'term_array' => $term_array, + 'post' => $post, + 'taxonomy' => $taxonomy, + 'in_same_term' => $in_same_term, + 'excluded_terms' => $excluded_terms, + ); + + return $term_array; + } + /** * @ticket 63920 */ @@ -779,4 +836,211 @@ public function test_get_adjacent_post_identical_dates_with_category() { $this->assertInstanceOf( 'WP_Post', $next ); $this->assertSame( $post_ids[3], $next->ID ); // Post 4 (in category) } + + /** + * Tests that the dynamic `get_{$adjacent}_post_terms` filter is applied and + * receives the documented arguments. + * + * @ticket 35082 + */ + public function test_get_adjacent_post_terms_filter_receives_documented_arguments() { + register_taxonomy( 'wptests_tax', 'post', array( 'rewrite' => false ) ); + + $terms = self::factory()->term->create_many( + 2, + array( 'taxonomy' => 'wptests_tax' ) + ); + + $p1 = self::factory()->post->create( array( 'post_date' => '2015-08-27 12:00:00' ) ); + $p2 = self::factory()->post->create( array( 'post_date' => '2015-08-25 12:00:00' ) ); + $p3 = self::factory()->post->create( array( 'post_date' => '2015-08-26 12:00:00' ) ); + + wp_set_post_terms( $p1, array( $terms[0], $terms[1] ), 'wptests_tax' ); + wp_set_post_terms( $p2, array( $terms[0] ), 'wptests_tax' ); + wp_set_post_terms( $p3, array( $terms[0] ), 'wptests_tax' ); + + $this->go_to( get_permalink( $p1 ) ); + + add_filter( 'get_previous_post_terms', array( $this, 'filter_adjacent_post_terms_capture_args' ), 10, 5 ); + + $found = get_adjacent_post( true, array( $terms[1] ), true, 'wptests_tax' ); + + $args = $this->captured_terms_args; + + remove_filter( 'get_previous_post_terms', array( $this, 'filter_adjacent_post_terms_capture_args' ), 10 ); + + $this->assertIsArray( $args['term_array'] ); + // The excluded term is already removed by the time the filter runs. + $this->assertEquals( array( $terms[0] ), array_values( $args['term_array'] ) ); + + $this->assertInstanceOf( 'WP_Post', $args['post'] ); + $this->assertSame( $p1, $args['post']->ID ); + $this->assertSame( 'wptests_tax', $args['taxonomy'] ); + $this->assertTrue( $args['in_same_term'] ); + $this->assertEquals( array( $terms[1] ), $args['excluded_terms'] ); + + // The order of filtered terms must not change which post is returned. + $this->assertSame( $p3, $found->ID ); + } + + /** + * Tests that filtering the term array down to a single child term changes which + * post is treated as adjacent, which is the use case requested in the ticket. + * + * @ticket 35082 + */ + public function test_get_adjacent_post_terms_filter_can_restrict_matching_to_a_child_term() { + register_taxonomy( + 'wptests_tax', + 'post', + array( + 'hierarchical' => true, + 'rewrite' => false, + ) + ); + + $parent = self::factory()->term->create( + array( + 'taxonomy' => 'wptests_tax', + 'name' => 'Parent', + ) + ); + + $child = self::factory()->term->create( + array( + 'taxonomy' => 'wptests_tax', + 'name' => 'Child', + 'parent' => $parent, + ) + ); + + $p1 = self::factory()->post->create( + array( + 'post_title' => 'First', + 'post_date' => '2015-01-01 12:00:00', + ) + ); + + $p2 = self::factory()->post->create( + array( + 'post_title' => 'Second', + 'post_date' => '2015-02-01 12:00:00', + ) + ); + + $p3 = self::factory()->post->create( + array( + 'post_title' => 'Third', + 'post_date' => '2015-03-01 12:00:00', + ) + ); + + // First post is in the parent and the child term. + wp_set_post_terms( $p1, array( $parent, $child ), 'wptests_tax' ); + // Second post only shares the parent term. + wp_set_post_terms( $p2, array( $parent ), 'wptests_tax' ); + // Third post is in the parent and the child term. + wp_set_post_terms( $p3, array( $parent, $child ), 'wptests_tax' ); + + $this->go_to( get_permalink( $p1 ) ); + + // Without a filter the second post is adjacent, because it shares the parent term. + $unfiltered = get_adjacent_post( true, '', false, 'wptests_tax' ); + $this->assertInstanceOf( 'WP_Post', $unfiltered ); + $this->assertSame( $p2, $unfiltered->ID ); + + // Restricting the term array to the child term skips the second post. + $this->filtered_terms = array( $child ); + add_filter( 'get_next_post_terms', array( $this, 'filter_adjacent_post_terms' ) ); + + $filtered = get_adjacent_post( true, '', false, 'wptests_tax' ); + + remove_filter( 'get_next_post_terms', array( $this, 'filter_adjacent_post_terms' ) ); + + $this->assertInstanceOf( 'WP_Post', $filtered ); + $this->assertSame( $p3, $filtered->ID ); + } + + /** + * Tests that the default behavior of `$in_same_term` is unchanged: a post that + * only shares a parent term is still considered adjacent. + * + * @ticket 35082 + */ + public function test_get_adjacent_post_in_same_term_still_matches_shared_parent_term() { + register_taxonomy( + 'wptests_tax', + 'post', + array( + 'hierarchical' => true, + 'rewrite' => false, + ) + ); + + $parent = self::factory()->term->create( + array( + 'taxonomy' => 'wptests_tax', + 'name' => 'Parent', + ) + ); + + $child = self::factory()->term->create( + array( + 'taxonomy' => 'wptests_tax', + 'name' => 'Child', + 'parent' => $parent, + ) + ); + + $p1 = self::factory()->post->create( array( 'post_date' => '2015-01-01 12:00:00' ) ); + $p2 = self::factory()->post->create( array( 'post_date' => '2015-02-01 12:00:00' ) ); + $p3 = self::factory()->post->create( array( 'post_date' => '2015-03-01 12:00:00' ) ); + + wp_set_post_terms( $p1, array( $parent, $child ), 'wptests_tax' ); + wp_set_post_terms( $p2, array( $parent ), 'wptests_tax' ); + wp_set_post_terms( $p3, array( $parent, $child ), 'wptests_tax' ); + + $this->go_to( get_permalink( $p1 ) ); + + // The second post is adjacent because any shared term counts. + $next = get_adjacent_post( true, '', false, 'wptests_tax' ); + $this->assertInstanceOf( 'WP_Post', $next ); + $this->assertSame( $p2, $next->ID ); + + $previous = get_adjacent_post( true, '', true, 'wptests_tax' ); + $this->assertSame( '', $previous ); + } + + /** + * Tests that a filter removing every term does not generate an invalid query and + * results in the existing "no adjacent post" behavior. + * + * @ticket 35082 + */ + public function test_get_adjacent_post_terms_filter_returning_empty_array() { + global $wpdb; + + register_taxonomy( 'wptests_tax', 'post', array( 'rewrite' => false ) ); + + $term = self::factory()->term->create( array( 'taxonomy' => 'wptests_tax' ) ); + + $p1 = self::factory()->post->create( array( 'post_date' => '2015-01-01 12:00:00' ) ); + $p2 = self::factory()->post->create( array( 'post_date' => '2015-02-01 12:00:00' ) ); + + wp_set_post_terms( $p1, array( $term ), 'wptests_tax' ); + wp_set_post_terms( $p2, array( $term ), 'wptests_tax' ); + + $this->go_to( get_permalink( $p2 ) ); + + $this->filtered_terms = array(); + add_filter( 'get_next_post_terms', array( $this, 'filter_adjacent_post_terms' ) ); + + $wpdb->last_error = ''; + $found = get_adjacent_post( true, '', false, 'wptests_tax' ); + + remove_filter( 'get_next_post_terms', array( $this, 'filter_adjacent_post_terms' ) ); + + $this->assertSame( '', $wpdb->last_error ); + $this->assertSame( '', $found ); + } }