diff --git a/CHANGELOG.md b/CHANGELOG.md index 919d7c7b..aa2b6554 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ All notable changes to `WP Curate` will be documented in this file. +## 1.7.0 - 2024-03-06 + +- Enhancement: Integration with [Parse.ly plugin](https://wordpress.org/plugins/wp-parsely/) to support querying trending posts. + ## 1.6.3 - 2024-02-14 - Bug Fix: Selecting a post more than once in a Query block causes empty slots at the end. diff --git a/blocks/query/block.json b/blocks/query/block.json index 5a0f498b..081a2198 100644 --- a/blocks/query/block.json +++ b/blocks/query/block.json @@ -94,6 +94,14 @@ "OR" ], "type": "string" + }, + "orderby": { + "default": "date", + "enum": [ + "date", + "trending" + ], + "type": "string" } } } diff --git a/blocks/query/edit.tsx b/blocks/query/edit.tsx index 31a5bc4e..7a73a26f 100644 --- a/blocks/query/edit.tsx +++ b/blocks/query/edit.tsx @@ -11,6 +11,7 @@ import { RangeControl, SelectControl, TextControl, + ToggleControl, } from '@wordpress/components'; import { useSelect } from '@wordpress/data'; import { @@ -22,7 +23,6 @@ import { import { __, sprintf } from '@wordpress/i18n'; import { addQueryArgs } from '@wordpress/url'; -import type { WP_REST_API_Post, WP_REST_API_Posts } from 'wp-types'; import { Template } from '@wordpress/blocks'; import type { EditProps, @@ -44,6 +44,7 @@ interface Window { wpCurateQueryBlock: { allowedPostTypes: Array; allowedTaxonomies: Array; + parselyAvailable: string, }; } @@ -69,6 +70,7 @@ export default function Edit({ terms = {}, termRelations = {}, taxRelation = 'AND', + orderby = 'date', }, setAttributes, }: EditProps) { @@ -76,6 +78,7 @@ export default function Edit({ wpCurateQueryBlock: { allowedPostTypes = [], allowedTaxonomies = [], + parselyAvailable = 'false', } = {}, } = (window as any as Window); @@ -154,13 +157,14 @@ export default function Edit({ } const fetchPosts = async () => { let path = addQueryArgs( - '/wp/v2/posts', + '/wp-curate/v1/posts', { search: debouncedSearchTerm, offset, type: postTypeString, status: 'publish', per_page: 20, + orderby, }, ); path += `&${termQueryArgs}`; @@ -168,10 +172,7 @@ export default function Edit({ apiFetch({ path, }).then((response) => { - const postIds: number[] = (response as WP_REST_API_Posts).map( - (post: WP_REST_API_Post) => post.id, - ); - setAttributes({ backfillPosts: postIds }); + setAttributes({ backfillPosts: response as Array }); }); }; fetchPosts(); @@ -181,6 +182,7 @@ export default function Edit({ offset, postTypeString, availableTaxonomies, + orderby, setAttributes, ]); @@ -384,6 +386,14 @@ export default function Edit({ onChange={(next) => setAttributes({ searchTerm: next })} value={searchTerm} /> + { parselyAvailable === 'true' ? ( + setAttributes({ orderby: next ? 'trending' : 'date' })} + /> + ) : null } diff --git a/blocks/query/index.php b/blocks/query/index.php index 4eac746a..090cee01 100644 --- a/blocks/query/index.php +++ b/blocks/query/index.php @@ -31,20 +31,31 @@ function wp_curate_query_block_init(): void { /** * Filter the post types that can be used in the Query block. + * + * @param array $allowed_post_types The allowed post types. */ $allowed_post_types = apply_filters( 'wp_curate_allowed_post_types', [ 'post' ] ); /** * Filter the taxonomies that can be used in the Query block. + * + * @param array $allowed_taxonomies The allowed taxonomies. */ $allowed_taxonomies = apply_filters( 'wp_curate_allowed_taxonomies', [ 'category', 'post_tag' ] ); + /** + * Filter whether to use Parsely. + * + * @param bool $use_parsely Whether to use Parsely. + */ + $parsely_available = apply_filters( 'wp_curate_use_parsely', false ); wp_localize_script( 'wp-curate-query-editor-script', 'wpCurateQueryBlock', [ 'allowedPostTypes' => $allowed_post_types, 'allowedTaxonomies' => $allowed_taxonomies, + 'parselyAvailable' => $parsely_available ? 'true' : 'false', ] ); } diff --git a/blocks/query/types.ts b/blocks/query/types.ts index d1d1a49c..077b92a1 100644 --- a/blocks/query/types.ts +++ b/blocks/query/types.ts @@ -19,6 +19,7 @@ interface EditProps { [key: string]: string; }; taxRelation?: string; + orderby?: string; }; setAttributes: (attributes: any) => void; } diff --git a/src/class-plugin-curated-posts.php b/src/class-plugin-curated-posts.php index d08839c1..1a841bed 100644 --- a/src/class-plugin-curated-posts.php +++ b/src/class-plugin-curated-posts.php @@ -43,7 +43,7 @@ public function with_query_context( array $context, array $attributes, WP_Block_ 'no_found_rows' => true, 'offset' => $attributes['offset'] ?? $block_type->attributes['offset']['default'], 'order' => 'DESC', - 'orderby' => 'date', + 'orderby' => $attributes['orderby'] ?? 'date', 'posts_per_page' => $attributes['numberOfPosts'] ?? $block_type->attributes['numberOfPosts']['default'], 'post_status' => 'publish', 'post_type' => $attributes['postTypes'] ?? $block_type->attributes['postTypes']['default'], diff --git a/src/class-trending-post-queries.php b/src/class-trending-post-queries.php new file mode 100644 index 00000000..5114825c --- /dev/null +++ b/src/class-trending-post-queries.php @@ -0,0 +1,47 @@ + $args The arguments to be used in the query. + * @return Post_Query + */ + public function query( array $args ): Post_Query { + if ( isset( $args['orderby'] ) && 'trending' === $args['orderby'] ) { + $trending = $this->parsely->get_trending_posts( $args ); + if ( ! empty( $trending ) ) { + return new Post_IDs_Query( $trending ); + } + } + + return $this->origin->query( $args ); + } +} diff --git a/src/features/class-parsely-support.php b/src/features/class-parsely-support.php new file mode 100644 index 00000000..adb9b84f --- /dev/null +++ b/src/features/class-parsely-support.php @@ -0,0 +1,178 @@ +api_secret_is_set() ) { + return; + } + add_filter( 'wp_curate_use_parsely', '__return_true' ); + add_filter( 'wp_curate_trending_posts_query', [ $this, 'add_parsely_trending_posts_query' ], 10, 2 ); + } + + /** + * Gets the trending posts from Parsely. + * + * @param array $posts The posts, which should be an empty array. + * @param array $args The WP_Query args. + * @return array Array of post IDs. + */ + public function add_parsely_trending_posts_query( array $posts, array $args ): array { + $parsely = $GLOBALS['parsely']; + if ( ! $parsely->api_secret_is_set() ) { + return $posts; + } + $trending_posts = $this->get_trending_posts( $args ); + return $trending_posts; + } + + /** + * Gets the trending posts from Parsely. + * + * @param array $args The WP_Query args. + * @return array An array of post IDs. + */ + public function get_trending_posts( array $args ): array { + if ( ! class_exists( '\Parsely\Parsely' ) || ! isset( $GLOBALS['parsely'] ) || ! $GLOBALS['parsely'] instanceof Parsely ) { + return []; + } + if ( ! class_exists( '\Parsely\RemoteAPI\Analytics_Posts_API' ) ) { + return []; + } + + $parsely_options = $GLOBALS['parsely']->get_options(); + /** + * Filter the period start for the Parsely API. + * + * @param string $period_start The period start. + * @param array $args The WP_Query args. + */ + $period_start = apply_filters( 'wp_curate_parsely_period_start', '1d', $args ); + /** + * Filter the period end for the Parsely API. + * + * @param string $period_end The period end. + * @param array $args The WP_Query args. + */ + $period_end = apply_filters( 'wp_curate_parsely_period_end', 'now', $args ); + $parsely_args = [ + 'limit' => $args['posts_per_page'] ?? get_option( 'posts_per_page' ), + 'sort' => 'views', + 'period_start' => $period_start, + 'period_end' => $period_end, + ]; + if ( isset( $args['tax_query'] ) && is_array( $args['tax_query'] ) ) { + foreach ( $args['tax_query'] as $tax_query ) { + if ( isset( $tax_query['taxonomy'] ) && $parsely_options['custom_taxonomy_section'] === $tax_query['taxonomy'] ) { + $parsely_args['section'] = implode( ', ', $this->get_slugs_from_term_ids( $tax_query['terms'], $tax_query['taxonomy'] ) ); + } + if ( isset( $tax_query['taxonomy'] ) && 'post_tag' === $tax_query['taxonomy'] ) { + $parsely_args['tag'] = implode( ', ', $this->get_slugs_from_term_ids( $tax_query['terms'], $tax_query['taxonomy'] ) ); + } + } + if ( $parsely_options['cats_as_tags'] ) { + $tags = explode( ', ', $parsely_args['tag'] ?? '' ); + $sections = explode( ', ', $parsely_args['section'] ?? '' ); + $parsely_args['tag'] = implode( ', ', array_merge( $tags, $sections ) ); + } + } + $cache_key = 'parsely_trending_posts_' . md5( wp_json_encode( $parsely_args ) ); // @phpstan-ignore-line - wp_Json_encode not likely to return false. + $ids = wp_cache_get( $cache_key ); + if ( false === $ids || ! is_array( $ids ) ) { + $api = new Analytics_Posts_API( $GLOBALS['parsely'] ); + $posts = $api->get_posts_analytics( $parsely_args ); + $ids = array_map( + function ( $post ) { + // Check if the metadata contains post_id, if not, use the URL to get the post ID. + $metadata = json_decode( $post['metadata'] ?? '', true ); + if ( is_array( $metadata ) && isset( $metadata['post_id'] ) ) { + $post_id = (int) $metadata['post_id']; + } elseif ( function_exists( 'wpcom_vip_url_to_postid' ) ) { + $post_id = wpcom_vip_url_to_postid( $post['url'] ); + } else { + $post_id = url_to_postid( $post['url'] ); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.url_to_postid_url_to_postid + } + /** + * Filters the post ID derived from Parsely post object. + * + * @param int $post_id The post ID. + * @param array $post The Parsely post object. + */ + return apply_filters( 'wp_curate_parsely_post_to_post_id', $post_id, $post ); + }, + $posts + ); + /** + * Filters the cache duration for the trending posts from Parsely. + * + * @param int $cache_duration The cache duration. + * @param array $args The WP_Query args. + */ + $cache_duration = apply_filters( 'wp_curate_parsely_trending_posts_cache_duration', 10 * MINUTE_IN_SECONDS, $args ); + if ( 300 > $cache_duration ) { + $cache_duration = 300; + } + wp_cache_set( $cache_key, $ids, '', $cache_duration ); // phpcs:ignore WordPressVIPMinimum.Performance.LowExpiryCacheTime.CacheTimeUndetermined + } + + /** + * Filters the trending posts from Parsely. + * + * @param array $ids The list of post IDs. + * @param array $parsely_args The Parsely API args. + * @param array $args The WP_Query args. + */ + $ids = apply_filters( 'wp_curate_parsely_trending_posts', $ids, $parsely_args, $args ); + + return $ids; + } + + /** + * Get slugs from term IDs. + * + * @param array $ids The list of term ids. + * @param string $taxonomy The taxonomy. + * @return array The list of term slugs. + */ + private function get_slugs_from_term_ids( $ids, $taxonomy ) { + $terms = array_filter( + array_map( + function ( $id ) use ( $taxonomy ) { + $term = get_term( $id, $taxonomy ); + if ( $term instanceof \WP_Term ) { + return $term->slug; + } + }, + $ids + ) + ); + return $terms; + } +} diff --git a/src/features/class-query-block-context.php b/src/features/class-query-block-context.php index 37873be8..e9bd5d63 100644 --- a/src/features/class-query-block-context.php +++ b/src/features/class-query-block-context.php @@ -11,10 +11,12 @@ use Alley\WP\Blocks\Parsed_Block; use Alley\WP\Post_IDs\Used_Post_IDs; use Alley\WP\Post_Queries\Exclude_Queries; +use Alley\WP\WP_Curate\Trending_Post_Queries; use Alley\WP\Post_Queries\Variable_Post_Queries; use Alley\WP\Types\Feature; use Alley\WP\Types\Post_Queries; use Alley\WP\Types\Post_Query; +use Alley\WP\WP_Curate\Features\Parsely_Support; use Alley\WP\WP_Curate\Must_Include_Curated_Posts; use Alley\WP\WP_Curate\Plugin_Curated_Posts; use Alley\WP\WP_Curate\Recorded_Curated_Posts; @@ -73,31 +75,34 @@ public function filter_query_context( $context, $parsed_block, $parent_block ): origin: new Must_Include_Curated_Posts( qv: $this->stop_queries_var, origin: new Plugin_Curated_Posts( - queries: new Variable_Post_Queries( - input: function () use ( $parsed_block ) { - $main_query = $this->main_query->query_object(); + queries: new Trending_Post_Queries( + parsely: new Parsely_Support(), + origin: new Variable_Post_Queries( + input: function () use ( $parsed_block ) { + $main_query = $this->main_query->query_object(); - if ( isset( $parsed_block['attrs']['deduplication'] ) && 'never' === $parsed_block['attrs']['deduplication'] ) { - return false; - } + if ( isset( $parsed_block['attrs']['deduplication'] ) && 'never' === $parsed_block['attrs']['deduplication'] ) { + return false; + } - if ( true === $main_query->is_singular() || true === $main_query->is_posts_page ) { - $post_level_deduplication = get_post_meta( $main_query->get_queried_object_id(), 'wp_curate_deduplication', true ); + if ( true === $main_query->is_singular() || true === $main_query->is_posts_page ) { + $post_level_deduplication = get_post_meta( $main_query->get_queried_object_id(), 'wp_curate_deduplication', true ); - if ( true === (bool) $post_level_deduplication ) { - return true; + if ( true === (bool) $post_level_deduplication ) { + return true; + } } - } - return false; - }, - test: new Comparison( [ 'compared' => true ] ), - is_true: new Exclude_Queries( - $this->history, - $this->default_per_page, - $this->post_queries, + return false; + }, + test: new Comparison( [ 'compared' => true ] ), + is_true: new Exclude_Queries( + $this->history, + $this->default_per_page, + $this->post_queries, + ), + is_false: $this->post_queries, ), - is_false: $this->post_queries, ), ), ), diff --git a/src/features/class-rest-api.php b/src/features/class-rest-api.php index dda6345a..f5bda601 100644 --- a/src/features/class-rest-api.php +++ b/src/features/class-rest-api.php @@ -23,26 +23,123 @@ public function __construct() {} * Boot the feature. */ public function boot(): void { - add_filter( 'rest_post_query', [ $this, 'add_type_param' ], 10, 2 ); + add_action( 'rest_api_init', [ $this, 'register_endpoints' ] ); } /** - * Add post_type to rest post query if the type param is set. + * Sets up the endpoint. * - * @param array|string> $query_args The existing query args. - * @param WP_REST_Request $request The REST request. - * @return array|string> + * @return void */ - // @phpstan-ignore-next-line - public function add_type_param( $query_args, $request ): array { // phpcs:ignore Squiz.Commenting.FunctionComment.WrongStyle - $type = $request->get_param( 'type' ); - - if ( ! empty( $type ) && is_string( $type ) ) { - $types = explode( ',', $type ); - $types = array_filter( $types, 'post_type_exists' ); - $query_args['post_type'] = $types; + public function register_endpoints(): void { + register_rest_route( + 'wp-curate/v1', + '/posts/', + [ + 'methods' => 'GET', + 'callback' => [ $this, 'get_posts' ], + 'permission_callback' => 'is_user_logged_in', + ] + ); + } + + /** + * Gets the posts. + * + * @param WP_REST_Request $request The request object. + * @return array The post IDs. + */ + public function get_posts( WP_REST_Request $request ): array { // phpcs:ignore Squiz.Functions.MultiLineFunctionDeclaration.ContentAfterBrace @phpstan-ignore-line + $search_term = $request->get_param( 'search' ) ?? ''; + $offset = $request->get_param( 'offset' ) ?? 0; + $post_type_string = $request->get_param( 'post_type' ) ?? 'post'; + $per_page = $request->get_param( 'per_page' ) ?? 20; + $trending = 'trending' === $request->get_param( 'orderby' ); + $tax_relation = $request->get_param( 'tax_relation' ) ?? 'OR'; + + if ( ! is_string( $post_type_string ) ) { + $post_type_string = 'post'; + } + + /** + * Filters the allowed taxonomies. + * + * @param array $allowed_taxonomies The allowed taxonomies. + */ + $allowed_taxonomies = apply_filters( 'wp_curate_allowed_taxonomies', [ 'category', 'post_tag' ] ); + $taxonomies = array_map( 'get_taxonomy', $allowed_taxonomies ); + $taxonomies = array_filter( $taxonomies, 'is_object' ); + $tax_query = []; + foreach ( $taxonomies as $taxonomy ) { + $rest_base = $taxonomy->rest_base; + if ( empty( $rest_base ) || ! is_string( $rest_base ) ) { + continue; + } + $tax_param = $request->get_param( $rest_base ); + if ( ! is_array( $tax_param ) ) { + continue; + } + $terms = isset( $tax_param['terms'] ) ? $tax_param['terms'] : []; + $operator = isset( $tax_param['operator'] ) ? $tax_param['operator'] : 'OR'; + if ( empty( $terms ) ) { + continue; + } + $terms = explode( ',', $terms ); + $terms = array_map( 'intval', $terms ); + $terms = array_filter( $terms, 'term_exists' ); // @phpstan-ignore-line + $tax_query[] = [ + 'taxonomy' => $taxonomy->name, + 'field' => 'term_id', + 'terms' => $terms, + 'operator' => 'AND' === $operator ? 'AND' : 'IN', + ]; + } + if ( ! empty( $tax_query ) && 1 < count( $tax_query ) ) { + $tax_query['relation'] = $tax_relation; + } + + $post_types = explode( ',', $post_type_string ); + /** + * Filters the allowed post types. + * + * @param array $allowed_post_types The allowed post types. + */ + $allowed_post_types = apply_filters( 'wp_curate_allowed_post_types', [ 'post' ] ); + + $post_types = array_filter( + $post_types, + function ( $post_type ) use ( $allowed_post_types ) { + return in_array( $post_type, $allowed_post_types, true ); + } + ); + + $args = [ + 'post_type' => $post_types, + 'posts_per_page' => $per_page, + 'offset' => $offset, + 'ignore_sticky_posts' => true, + 'fields' => 'ids', + ]; + if ( ! empty( $search_term ) ) { + $args['s'] = $search_term; + } + if ( ! empty( $tax_query ) ) { + $args['tax_query'] = $tax_query; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query } - return $query_args; + if ( $trending ) { + /** + * Filters the trending posts query. + * + * @param array $posts The posts. + * @param array $args The WP_Query arguments. + */ + $posts = apply_filters( 'wp_curate_trending_posts_query', [], $args ); + } + if ( empty( $posts ) ) { + $query = new \WP_Query( $args ); + $posts = $query->posts; + } + return array_map( 'intval', $posts ); // @phpstan-ignore-line } } diff --git a/src/main.php b/src/main.php index cc0a45fa..0459def0 100644 --- a/src/main.php +++ b/src/main.php @@ -38,6 +38,7 @@ function main(): void { stop_queries_var: $stop_queries_var, block_type_registry: WP_Block_Type_Registry::get_instance(), ); + $features[] = new Features\Parsely_Support(); $features[] = new Features\Rest_Api(); diff --git a/wp-curate.php b/wp-curate.php index 3275f5c5..65cd81ab 100644 --- a/wp-curate.php +++ b/wp-curate.php @@ -3,7 +3,7 @@ * Plugin Name: WP Curate * Plugin URI: https://github.com/alleyinteractive/wp-curate * Description: Plugin to curate homepages and other landing pages - * Version: 1.6.3 + * Version: 1.7.0 * Author: Alley Interactive * Author URI: https://github.com/alleyinteractive/wp-curate * Requires at least: 6.4