PluginProbe
Yoast SEO – Advanced SEO with real-time guidance and built-in AI / trunk
Yoast SEO – Advanced SEO with real-time guidance and built-in AI vtrunk
28.5 28.4 28.3 28.2 28.1 28.0 27.9 27.8 27.7 27.6 27.5 trunk 18.0 18.1 18.2 18.3 18.4 18.4.1 18.5 18.5.1 18.6 18.7 18.8 18.9 19.0 All 129 releases
wordpress-seo / src / bulk-editor / user-interface / abstract-bulk-update-route.php

abstract-bulk-update-route.php in Yoast SEO – Advanced SEO with real-time guidance and built-in AI trunk, at src/bulk-editor/user-interface/abstract-bulk-update-route.php

252 lines 7.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 // phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure.
4 namespace Yoast\WP\SEO\Bulk_Editor\User_Interface;
5
6 use WP_Error;
7 use WP_REST_Request;
8 use WP_REST_Response;
9 use Yoast\WP\SEO\Bulk_Editor\Application\Updates\Bulk_Updater;
10 use Yoast\WP\SEO\Bulk_Editor\Domain\Updates\Batch_Limit;
11 use Yoast\WP\SEO\Bulk_Editor\Domain\Updates\Post_Update;
12 use Yoast\WP\SEO\Bulk_Editor\Domain\Updates\Post_Update_Collection;
13 use Yoast\WP\SEO\Bulk_Editor\Domain\Updates\Update_Type;
14 use Yoast\WP\SEO\Main;
15 use Yoast\WP\SEO\Routes\Route_Interface;
16 use YoastSEO_Vendor\Psr\Log\LoggerAwareInterface;
17 use YoastSEO_Vendor\Psr\Log\LoggerAwareTrait;
18 use YoastSEO_Vendor\Psr\Log\NullLogger;
19
20 /**
21 * Registers a route that applies a batch of per-post title, description and focus keyphrase updates.
22 */
23 abstract class Abstract_Bulk_Update_Route implements Route_Interface, LoggerAwareInterface {
24
25 use LoggerAwareTrait;
26
27 /**
28 * The namespace for this route.
29 *
30 * @var string
31 */
32 public const ROUTE_NAMESPACE = Main::API_V1_NAMESPACE;
33
34 /**
35 * The bulk updater.
36 *
37 * @var Bulk_Updater
38 */
39 private $bulk_updater;
40
41 /**
42 * The constructor.
43 *
44 * @param Bulk_Updater $bulk_updater The bulk updater.
45 */
46 public function __construct( Bulk_Updater $bulk_updater ) {
47 $this->bulk_updater = $bulk_updater;
48 $this->logger = new NullLogger();
49 }
50
51 /**
52 * Returns the conditionals based on which this loadable should be active.
53 *
54 * @return array<string> The conditionals.
55 */
56 public static function get_conditionals() {
57 return [];
58 }
59
60 /**
61 * Gets the appearance this route updates.
62 *
63 * @return Update_Type The appearance this route updates.
64 */
65 abstract protected function get_update_type(): Update_Type;
66
67 /**
68 * Gets the prefix for this route.
69 *
70 * @return string The prefix for this route.
71 */
72 abstract protected function get_route_prefix(): string;
73
74 /**
75 * Gets the name of the title argument in the request.
76 *
77 * @return string The name of the title argument.
78 */
79 abstract protected function get_title_arg_name(): string;
80
81 /**
82 * Gets the name of the description argument in the request.
83 *
84 * @return string The name of the description argument.
85 */
86 abstract protected function get_description_arg_name(): string;
87
88 /**
89 * Gets the name of the focus keyphrase argument in the request.
90 *
91 * The focus keyphrase is channel-agnostic, so the argument is the same for every route.
92 *
93 * @return string The name of the focus keyphrase argument.
94 */
95 protected function get_focus_keyphrase_arg_name(): string {
96 return 'focus_keyphrase';
97 }
98
99 /**
100 * Registers routes with WordPress.
101 *
102 * @return void
103 */
104 public function register_routes() {
105 \register_rest_route(
106 self::ROUTE_NAMESPACE,
107 $this->get_route_prefix(),
108 [
109 'methods' => 'POST',
110 'args' => [
111 'items' => [
112 'required' => true,
113 'type' => 'array',
114 'description' => 'The per-post updates to apply.',
115 'validate_callback' => [ $this, 'validate_items' ],
116 ],
117 ],
118 'callback' => [ $this, 'update' ],
119 'permission_callback' => [ $this, 'check_permissions' ],
120 ],
121 );
122 }
123
124 // phpcs:disable SlevomatCodingStandard.TypeHints.DisallowMixedTypeHint.DisallowedMixedTypeHint -- The validate callback receives whatever the client sent.
125
126 /**
127 * Validates the items argument on top of the schema validation.
128 *
129 * @param mixed $items The items argument value.
130 *
131 * @return true|WP_Error True when valid, a WP_Error otherwise.
132 */
133 public function validate_items( $items ) {
134 if ( ! \is_array( $items ) ) {
135 return $this->reject( 'rest_invalid_items', 'The items argument must be an array.' );
136 }
137
138 $count = \count( $items );
139
140 if ( $count < 1 ) {
141 return $this->reject( 'rest_no_items', 'A batch must contain at least one item.' );
142 }
143
144 if ( ! Batch_Limit::is_within_limit( $count ) ) {
145 return $this->reject(
146 'rest_too_many_items',
147 \sprintf( 'A batch may contain at most %d items.', Batch_Limit::MAX_ITEMS ),
148 );
149 }
150
151 foreach ( $items as $item ) {
152 if ( ! \is_array( $item ) ) {
153 return $this->reject( 'rest_invalid_item', 'Each item must be an object.' );
154 }
155
156 if ( ! \array_key_exists( 'id', $item ) || ! \is_int( $item['id'] ) ) {
157 return $this->reject( 'rest_invalid_item_id', 'Each item must contain an integer id.' );
158 }
159
160 $title_key = $this->get_title_arg_name();
161 $description_key = $this->get_description_arg_name();
162 $focus_keyphrase_key = $this->get_focus_keyphrase_arg_name();
163
164 if ( ! \array_key_exists( $title_key, $item )
165 && ! \array_key_exists( $description_key, $item )
166 && ! \array_key_exists( $focus_keyphrase_key, $item )
167 ) {
168 return $this->reject(
169 'rest_no_fields_to_update',
170 \sprintf( 'Each item must contain at least a %s, a %s or a %s.', $title_key, $description_key, $focus_keyphrase_key ),
171 );
172 }
173
174 if ( \array_key_exists( $title_key, $item ) && ! \is_string( $item[ $title_key ] ) ) {
175 return $this->reject( 'rest_invalid_item_field', \sprintf( 'The %s field must be a string.', $title_key ) );
176 }
177
178 if ( \array_key_exists( $description_key, $item ) && ! \is_string( $item[ $description_key ] ) ) {
179 return $this->reject( 'rest_invalid_item_field', \sprintf( 'The %s field must be a string.', $description_key ) );
180 }
181
182 if ( \array_key_exists( $focus_keyphrase_key, $item ) && ! \is_string( $item[ $focus_keyphrase_key ] ) ) {
183 return $this->reject( 'rest_invalid_item_field', \sprintf( 'The %s field must be a string.', $focus_keyphrase_key ) );
184 }
185 }
186
187 return true;
188 }
189
190 // phpcs:enable SlevomatCodingStandard.TypeHints.DisallowMixedTypeHint.DisallowedMixedTypeHint
191
192 /**
193 * Logs a rejected request and builds the matching error response.
194 *
195 * @param string $code The error code.
196 * @param string $message The human-readable error message.
197 *
198 * @return WP_Error The error response with a 400 status.
199 */
200 private function reject( string $code, string $message ): WP_Error {
201 $this->logger->debug( 'Bulk update request rejected: {code}.', [ 'code' => $code ] );
202
203 return new WP_Error( $code, $message, [ 'status' => 400 ] );
204 }
205
206 /**
207 * Checks whether the current user is allowed to use the bulk editor.
208 *
209 * @return bool Whether the current user is allowed to use the bulk editor.
210 */
211 public function check_permissions(): bool {
212 return \current_user_can( 'wpseo_manage_options' );
213 }
214
215 /**
216 * Runs the callback that applies the requested updates.
217 *
218 * @param WP_REST_Request $request The request object.
219 *
220 * @return WP_REST_Response The per-item results of the update.
221 */
222 public function update( WP_REST_Request $request ): WP_REST_Response {
223 $updates = new Post_Update_Collection();
224
225 foreach ( $request->get_param( 'items' ) as $item ) {
226 $updates->add(
227 new Post_Update(
228 (int) $item['id'],
229 // Use array_key_exists, not isset: an empty string is a value that clears the field.
230 ( \array_key_exists( $this->get_title_arg_name(), $item ) ) ? (string) $item[ $this->get_title_arg_name() ] : null,
231 ( \array_key_exists( $this->get_description_arg_name(), $item ) ) ? (string) $item[ $this->get_description_arg_name() ] : null,
232 ( \array_key_exists( $this->get_focus_keyphrase_arg_name(), $item ) ) ? (string) $item[ $this->get_focus_keyphrase_arg_name() ] : null,
233 ),
234 );
235 }
236
237 $type = $this->get_update_type();
238
239 $this->logger->debug(
240 'Received bulk {type} update for {count} item(s).',
241 [
242 'type' => ( $type->is_search() ) ? 'search' : 'social',
243 'count' => \count( $updates->get() ),
244 ],
245 );
246
247 $results = $this->bulk_updater->update( $type, $updates );
248
249 return new WP_REST_Response( $results->to_array() );
250 }
251 }
252