JezK
Edit File: posts-route.php
<?php // phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure. namespace Yoast\WP\SEO\Bulk_Editor\User_Interface; use WP_Error; use WP_REST_Request; use WP_REST_Response; use Yoast\WP\SEO\Bulk_Editor\Application\Content_Types\Content_Type_Access_Checker_Interface; use Yoast\WP\SEO\Bulk_Editor\Application\Content_Types\Content_Types_Repository; use Yoast\WP\SEO\Bulk_Editor\Application\Posts\Posts_Collector_Interface; use Yoast\WP\SEO\Bulk_Editor\Application\Posts\Posts_Repository; use Yoast\WP\SEO\Bulk_Editor\Domain\Posts\Posts_Query; use Yoast\WP\SEO\Conditionals\No_Conditionals; use Yoast\WP\SEO\Helpers\Options_Helper; use Yoast\WP\SEO\Helpers\Post_Type_Helper; use Yoast\WP\SEO\Helpers\User_Helper; use Yoast\WP\SEO\Main; use Yoast\WP\SEO\Routes\Route_Interface; /** * Registers a route that returns a page of posts with their SEO and social meta. */ class Posts_Route implements Route_Interface { use No_Conditionals; /** * The namespace for this route. * * @var string */ public const ROUTE_NAMESPACE = Main::API_V1_NAMESPACE; /** * The prefix for this route. * * @var string */ public const ROUTE_PREFIX = '/bulk_editor/posts'; /** * The default number of posts to return. * * @var int */ public const DEFAULT_PER_PAGE = 20; /** * The maximum number of posts that can be requested in one page. * * @var int */ public const MAX_PER_PAGE = 100; /** * The posts repository. * * @var Posts_Repository */ private $posts_repository; /** * The content types repository. * * @var Content_Types_Repository */ private $content_types_repository; /** * The content type access checker. * * @var Content_Type_Access_Checker_Interface */ private $content_type_access_checker; /** * The user helper. * * @var User_Helper */ private $user_helper; /** * The options helper. * * @var Options_Helper */ private $options_helper; /** * The post type helper. * * @var Post_Type_Helper */ private $post_type_helper; /** * The constructor. * * @param Posts_Repository $posts_repository The posts repository. * @param Content_Types_Repository $content_types_repository The content types repository. * @param Content_Type_Access_Checker_Interface $content_type_access_checker The content type access checker. * @param User_Helper $user_helper The user helper. * @param Options_Helper $options_helper The options helper. * @param Post_Type_Helper $post_type_helper The post type helper. */ public function __construct( Posts_Repository $posts_repository, Content_Types_Repository $content_types_repository, Content_Type_Access_Checker_Interface $content_type_access_checker, User_Helper $user_helper, Options_Helper $options_helper, Post_Type_Helper $post_type_helper ) { $this->posts_repository = $posts_repository; $this->content_types_repository = $content_types_repository; $this->content_type_access_checker = $content_type_access_checker; $this->user_helper = $user_helper; $this->options_helper = $options_helper; $this->post_type_helper = $post_type_helper; } /** * Registers routes with WordPress. * * @return void */ public function register_routes() { \register_rest_route( self::ROUTE_NAMESPACE, self::ROUTE_PREFIX, [ 'methods' => 'GET', 'args' => [ 'content_type' => [ 'required' => true, 'type' => 'string', 'description' => 'The content type to fetch posts for.', 'sanitize_callback' => 'sanitize_text_field', ], 'per_page' => [ 'required' => false, 'type' => 'integer', 'default' => self::DEFAULT_PER_PAGE, 'minimum' => 1, 'maximum' => self::MAX_PER_PAGE, 'description' => 'The number of posts to fetch.', 'sanitize_callback' => 'absint', ], 'page' => [ 'required' => false, 'type' => 'integer', 'default' => 1, 'minimum' => 1, 'description' => 'The page of posts to fetch.', 'sanitize_callback' => 'absint', ], 'search' => [ 'required' => false, 'type' => 'string', 'default' => '', 'description' => 'The term to search posts by.', 'sanitize_callback' => 'sanitize_text_field', ], 'status' => [ 'required' => false, 'type' => 'array', 'default' => Posts_Collector_Interface::STATUSES, 'items' => [ 'type' => 'string', 'enum' => Posts_Collector_Interface::STATUSES, ], 'description' => 'The post statuses to include.', ], 'needs_improvement' => [ 'required' => false, 'type' => 'array', 'default' => [], 'items' => [ 'type' => 'string', 'enum' => Posts_Collector_Interface::NEEDS_IMPROVEMENT_FIELDS, ], 'description' => 'The fields to filter posts by; a field matches when it is empty, or (for search fields, while SEO analysis is enabled and the content type shows Yoast\'s controls and assessments) when its score needs improvement.', ], 'include' => [ 'required' => false, 'type' => 'array', 'default' => [], 'maxItems' => self::MAX_PER_PAGE, 'items' => [ 'type' => 'integer', 'minimum' => 1, ], 'description' => 'Limits the posts to these post IDs, e.g. a selection carried over from the posts overview.', ], ], 'callback' => [ $this, 'get_posts' ], 'permission_callback' => [ $this, 'check_permissions' ], ], ); } /** * Returns a page of posts for the requested content type. * * @param WP_REST_Request $request The request object. * * @return WP_REST_Response|WP_Error The posts, or an error when the content type is not editable. */ public function get_posts( WP_REST_Request $request ) { $content_type = $request->get_param( 'content_type' ); if ( ! $this->is_valid_content_type( $content_type ) ) { return new WP_Error( 'rest_invalid_content_type', 'The requested content type is not editable.', [ 'status' => 400 ], ); } // An empty selection (no status filter) means all statuses, never zero results. $statuses = (array) $request->get_param( 'status' ); if ( empty( $statuses ) ) { $statuses = Posts_Collector_Interface::STATUSES; } // Narrow the query to the user's own posts when they cannot edit other authors' posts. This keeps // pagination cheap; the exact per-post edit permission is still enforced when collecting the page. $author_id = null; if ( ! $this->content_type_access_checker->can_edit_others( $content_type ) ) { $author_id = $this->user_helper->get_current_user_id(); } // The per-field scores only back the filter while SEO analysis is on globally and the content type // shows Yoast's controls and assessments; otherwise they go stale and the filter falls back to the // empty-field check. $scores_enabled = $this->options_helper->get( 'keyword_analysis_active' ) === true && $this->post_type_helper->has_metabox( $content_type ); // The schema already coerces the items to positive integers; deduplicate on top of that. $include = \array_values( \array_unique( \array_map( 'intval', (array) $request->get_param( 'include' ) ) ) ); $query = new Posts_Query( $content_type, (int) $request->get_param( 'page' ), (int) $request->get_param( 'per_page' ), (string) $request->get_param( 'search' ), $statuses, $author_id, (array) $request->get_param( 'needs_improvement' ), $scores_enabled, $include, ); // Posts the current user cannot edit are returned locked and without their SEO data; the per-post // permission is resolved while collecting the page. return new WP_REST_Response( $this->posts_repository->get_posts( $query )->to_array() ); } /** * Checks whether the current user is allowed to use the bulk editor. * * @return bool Whether the current user is allowed to use the bulk editor. */ public function check_permissions(): bool { return \current_user_can( 'wpseo_manage_options' ); } /** * Checks whether the given content type is one the bulk editor can edit. * * @param string $content_type The content type to check. * * @return bool Whether the content type is editable. */ private function is_valid_content_type( string $content_type ): bool { foreach ( $this->content_types_repository->get_content_types() as $editable ) { if ( $editable['name'] === $content_type ) { return true; } } return false; } }