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
← All changes | src/bulk-editor/user-interface/bulk-editor-integration.php +276 -32 28.1 → trunk View file →
@@ -3,10 +3,14 @@
3 3 // phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure.
4 4 namespace Yoast\WP\SEO\Bulk_Editor\User_Interface;
5 5
6 6 use WPSEO_Admin_Asset_Manager;
7 +use WPSEO_Admin_Editor_Specific_Replace_Vars;
8 +use WPSEO_Admin_Recommended_Replace_Vars;
9 +use WPSEO_Replace_Vars;
7 10 use Yoast\WP\SEO\Bulk_Editor\Application\Content_Types\Content_Types_Repository;
8 11 use Yoast\WP\SEO\Bulk_Editor\Application\Endpoints\Endpoints_Repository;
12 +use Yoast\WP\SEO\Bulk_Editor\Domain\Updates\Batch_Limit;
9 13 use Yoast\WP\SEO\Bulk_Editor\Infrastructure\Nonces\Nonce_Repository;
10 14 use Yoast\WP\SEO\Conditionals\Admin_Conditional;
11 15 use Yoast\WP\SEO\General\User_Interface\General_Page_Integration;
12 16 use Yoast\WP\SEO\Helpers\Current_Page_Helper;
@@ -12,9 +16,12 @@
12 16 use Yoast\WP\SEO\Helpers\Current_Page_Helper;
13 17 use Yoast\WP\SEO\Helpers\Options_Helper;
14 18 use Yoast\WP\SEO\Helpers\Product_Helper;
15 19 use Yoast\WP\SEO\Helpers\Short_Link_Helper;
20 +use Yoast\WP\SEO\Helpers\User_Helper;
16 21 use Yoast\WP\SEO\Integrations\Integration_Interface;
22 +use Yoast\WP\SEO\Integrations\Settings_Integration;
23 +use Yoast\WP\SEO\MyYoast_Client\User_Interface\Myyoast_Connection_Data_Presenter;
17 24
18 25 /**
19 26 * Adds the bulk editor page to the Yoast admin menu.
20 27 */
@@ -30,8 +37,23 @@
30 37 */
31 38 public const ASSETS_NAME = 'bulk-editor-page';
32 39
33 40 /**
41 + * The URL parameter carrying the content type to preselect.
42 + */
43 + public const CONTENT_TYPE_PARAM = 'content_type';
44 +
45 + /**
46 + * The URL parameter carrying the post IDs to preselect, comma-separated.
47 + */
48 + public const POST_IDS_PARAM = 'post_ids';
49 +
50 + /**
51 + * The URL parameter carrying how many posts were selected on the overview the user came from.
52 + */
53 + public const SELECTED_COUNT_PARAM = 'selected_count';
54 +
55 + /**
34 56 * Holds the WPSEO_Admin_Asset_Manager.
35 57 *
36 58 * @var WPSEO_Admin_Asset_Manager
37 59 */
@@ -86,18 +108,42 @@
86 108 */
87 109 private $options_helper;
88 110
89 111 /**
112 + * Holds the User_Helper.
113 + *
114 + * @var User_Helper
115 + */
116 + private $user_helper;
117 +
118 + /**
119 + * Builds the MyYoast connection payload for script data.
120 + *
121 + * @var Myyoast_Connection_Data_Presenter
122 + */
123 + private $myyoast_connection_data_presenter;
124 +
125 + /**
126 + * The replace vars handler, used to build the replacement variable list for the editor.
127 + *
128 + * @var WPSEO_Replace_Vars
129 + */
130 + private $replace_vars;
131 +
132 + /**
90 133 * Constructs the instance.
91 134 *
92 - * @param WPSEO_Admin_Asset_Manager $asset_manager The WPSEO_Admin_Asset_Manager.
93 - * @param Current_Page_Helper $current_page_helper The Current_Page_Helper.
94 - * @param Product_Helper $product_helper The Product_Helper.
95 - * @param Short_Link_Helper $short_link_helper The Short_Link_Helper.
96 - * @param Content_Types_Repository $content_types_repository The Content_Types_Repository.
97 - * @param Nonce_Repository $nonce_repository The Nonce_Repository.
98 - * @param Endpoints_Repository $endpoints_repository The Endpoints_Repository.
99 - * @param Options_Helper $options_helper The Options_Helper.
135 + * @param WPSEO_Admin_Asset_Manager $asset_manager The WPSEO_Admin_Asset_Manager.
136 + * @param Current_Page_Helper $current_page_helper The Current_Page_Helper.
137 + * @param Product_Helper $product_helper The Product_Helper.
138 + * @param Short_Link_Helper $short_link_helper The Short_Link_Helper.
139 + * @param Content_Types_Repository $content_types_repository The Content_Types_Repository.
140 + * @param Nonce_Repository $nonce_repository The Nonce_Repository.
141 + * @param Endpoints_Repository $endpoints_repository The Endpoints_Repository.
142 + * @param Options_Helper $options_helper The Options_Helper.
143 + * @param User_Helper $user_helper The User_Helper.
144 + * @param Myyoast_Connection_Data_Presenter $myyoast_connection_data_presenter The MyYoast connection data presenter.
145 + * @param WPSEO_Replace_Vars $replace_vars The replace vars handler.
100 146 */
101 147 public function __construct(
102 148 WPSEO_Admin_Asset_Manager $asset_manager,
103 149 Current_Page_Helper $current_page_helper,
@@ -105,18 +151,24 @@
105 151 Short_Link_Helper $short_link_helper,
106 152 Content_Types_Repository $content_types_repository,
107 153 Nonce_Repository $nonce_repository,
108 154 Endpoints_Repository $endpoints_repository,
109 - Options_Helper $options_helper
155 + Options_Helper $options_helper,
156 + User_Helper $user_helper,
157 + Myyoast_Connection_Data_Presenter $myyoast_connection_data_presenter,
158 + WPSEO_Replace_Vars $replace_vars
110 159 ) {
111 - $this->asset_manager = $asset_manager;
112 - $this->current_page_helper = $current_page_helper;
113 - $this->product_helper = $product_helper;
114 - $this->short_link_helper = $short_link_helper;
115 - $this->content_types_repository = $content_types_repository;
116 - $this->nonce_repository = $nonce_repository;
117 - $this->endpoints_repository = $endpoints_repository;
118 - $this->options_helper = $options_helper;
160 + $this->asset_manager = $asset_manager;
161 + $this->current_page_helper = $current_page_helper;
162 + $this->product_helper = $product_helper;
163 + $this->short_link_helper = $short_link_helper;
164 + $this->content_types_repository = $content_types_repository;
165 + $this->nonce_repository = $nonce_repository;
166 + $this->endpoints_repository = $endpoints_repository;
167 + $this->options_helper = $options_helper;
168 + $this->user_helper = $user_helper;
169 + $this->myyoast_connection_data_presenter = $myyoast_connection_data_presenter;
170 + $this->replace_vars = $replace_vars;
119 171 }
120 172
121 173 /**
122 174 * Returns the conditionals based on which this loadable should be active.
@@ -143,8 +195,9 @@
143 195 // Are we on our page?
144 196 if ( $this->current_page_helper->get_current_yoast_seo_page() === self::PAGE ) {
145 197 \add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_assets' ] );
146 198 \add_action( 'in_admin_header', [ $this, 'remove_notices' ], \PHP_INT_MAX );
199 + \add_filter( 'removable_query_args', [ $this, 'add_removable_query_args' ] );
147 200 }
148 201 }
149 202
150 203 /**
@@ -205,31 +258,222 @@
205 258
206 259 /**
207 260 * Creates the script data.
208 261 *
209 - * @return array<string, string|array<string, string|bool|array<string, string>>> The script data.
262 + * @return array<string, string|array<string, string|bool>|array<array<string, string>>> The script data.
210 263 */
211 264 public function get_script_data() {
265 + $content_types = $this->content_types_repository->get_content_types();
266 + $is_premium = $this->product_helper->is_premium();
267 + $is_version_supported = $this->is_premium_version_supported( $is_premium );
268 +
212 269 return [
213 - 'contentTypes' => $this->content_types_repository->get_content_types(),
214 - 'endpoints' => $this->endpoints_repository->get_all_endpoints()->to_array(),
215 - // These must stay server-generated URLs: the bulk editor assigns them to window.location.href for its
216 - // "Back to Tools" / logo navigation. If a link ever derives from request input, validate it with
217 - // wp_validate_redirect() here before exposing it, to avoid an open redirect on the front-end.
218 - 'links' => [
270 + 'contentTypes' => $content_types,
271 + 'endpoints' => $this->endpoints_repository->get_all_endpoints()->to_array(),
272 + // These must stay server-generated URLs: the bulk editor assigns the dashboard and tools links to
273 + // window.location.href for its "Back to Tools" / logo navigation (the settings link is rendered as a plain
274 + // link). If a link ever derives from request input, validate it with wp_validate_redirect() here before
275 + // exposing it, to avoid an open redirect on the front-end.
276 + 'links' => [
219 277 'dashboard' => \admin_url( 'admin.php?page=' . General_Page_Integration::PAGE ),
220 278 'tools' => \admin_url( 'admin.php?page=wpseo_tools' ),
279 + 'settings' => \admin_url( 'admin.php?page=' . Settings_Integration::PAGE ),
221 280 ],
222 - 'nonce' => $this->nonce_repository->get_rest_nonce(),
223 - 'restRoot' => \esc_url_raw( \rest_url() ),
224 - 'preferences' => [
225 - 'isPremium' => $this->product_helper->is_premium(),
226 - 'isAiEnabled' => $this->options_helper->get( 'enable_ai_generator' ) === true,
227 - 'isRtl' => \is_rtl(),
228 - 'pluginUrl' => \plugins_url( '', \WPSEO_FILE ),
281 + 'nonce' => $this->nonce_repository->get_rest_nonce(),
282 + 'restRoot' => \esc_url_raw( \rest_url() ),
283 + 'preferences' => [
284 + 'isPremium' => $is_premium,
285 + 'isPremiumVersionSupported' => $is_version_supported,
286 + 'isAiEnabled' => $this->options_helper->get( 'enable_ai_generator' ) === true,
287 + 'isRtl' => \is_rtl(),
288 + 'pluginUrl' => \plugins_url( '', \WPSEO_FILE ),
289 + 'premiumUpdateUrl' => $this->get_premium_update_url(),
229 290 ],
230 - 'linkParams' => $this->short_link_helper->get_query_params(),
291 + 'linkParams' => $this->short_link_helper->get_query_params(),
292 + 'analysis' => [
293 + 'contentLocale' => \get_locale(),
294 + // Re-scoring only runs when SEO analysis is enabled, matching the post editor.
295 + 'keywordAnalysisActive' => $this->options_helper->get( 'keyword_analysis_active' ) === true,
296 + // Used when collecting the AI prompt content, so shortcode delimiters are stripped from the
297 + // text while the content they enclose is kept. Not gated on SEO analysis being enabled: the
298 + // prompt content is collected for AI suggestions, which do not depend on the analysis.
299 + 'shortcodes' => $this->get_valid_shortcode_tags(),
300 + ],
301 + 'optInNotificationSeen' => [
302 + 'bulk_editor_tour' => $this->is_tour_opt_in_notification_seen(),
303 + ],
304 + 'initialSelection' => $this->get_initial_selection( $content_types ),
305 + 'myyoastConnection' => $this->myyoast_connection_data_presenter->present(),
306 + 'replacementVariables' => $this->get_replacement_variables(),
231 307 ];
308 + }
309 +
310 + /**
311 + * Builds the replacement variable data passed to the JS editor.
312 + *
313 + * Mirrors Settings_Integration::get_replacement_variables() so the bulk editor's
314 + * ReplacementVariableEditor receives the same variable metadata as the settings page.
315 + *
316 + * @return array{variables: array<int, array<string, string|bool>>, recommended: array<string, string[]>, specific: array<string, string[]>, shared: string[]} The replacement variable data.
317 + */
318 + private function get_replacement_variables(): array {
319 + $recommended_replace_vars = new WPSEO_Admin_Recommended_Replace_Vars();
320 + $specific_replace_vars = new WPSEO_Admin_Editor_Specific_Replace_Vars();
321 + $replacement_variables = $this->replace_vars->get_replacement_variables_with_labels();
322 +
323 + return [
324 + 'variables' => $replacement_variables,
325 + 'recommended' => $recommended_replace_vars->get_recommended_replacevars(),
326 + 'specific' => $specific_replace_vars->get(),
327 + 'shared' => $specific_replace_vars->get_generic( $replacement_variables ),
328 + ];
329 + }
330 +
331 + /**
332 + * Checks whether the installed Premium version is compatible with the current Free plugin.
333 + *
334 + * @param bool $is_premium Whether Premium is the currently active plugin.
335 + *
336 + * @return bool False when Premium needs upgrading or not active.
337 + */
338 + private function is_premium_version_supported( bool $is_premium ): bool {
339 + if ( ! $is_premium ) {
340 + return false;
341 + }
342 +
343 + $premium_version = $this->product_helper->get_premium_version();
344 +
345 + if ( $premium_version === null ) {
346 + return false;
347 + }
348 +
349 + return \version_compare( $premium_version, '28.1-RC0', '>' );
350 + }
351 +
352 + /**
353 + * Returns the one-click Premium update URL for the current user, or an empty string when the user
354 + * lacks the `update_plugins` capability (and would hit a wp_die permission error on update.php).
355 + *
356 + * @return string The nonce-protected update URL, or an empty string.
357 + */
358 + private function get_premium_update_url(): string {
359 + if ( ! \current_user_can( 'update_plugins' ) ) {
360 + return '';
361 + }
362 +
363 + return \html_entity_decode(
364 + \wp_nonce_url(
365 + \self_admin_url( 'update.php?action=upgrade-plugin&plugin=wordpress-seo-premium%2Fwp-seo-premium.php' ),
366 + 'upgrade-plugin_wordpress-seo-premium/wp-seo-premium.php',
367 + ),
368 + \ENT_COMPAT,
369 + );
370 + }
371 +
372 + /**
373 + * Returns the selection carried over from a post overview bulk action, if any.
374 + *
375 + * The parameters only decide which rows start out selected in the app; the REST endpoints
376 + * enforce the actual per-post edit access when anything is saved.
377 + *
378 + * @param array<array<string, string>> $content_types The available content types.
379 + *
380 + * @return array<string, string|int|array<int>> The content type, post IDs and overview selection count.
381 + */
382 + private function get_initial_selection( array $content_types ): array {
383 + $initial_selection = [
384 + 'contentType' => '',
385 + 'postIds' => [],
386 + 'selectedCount' => 0,
387 + ];
388 +
389 + // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Reason: read-only display state, no action is taken.
390 + if ( ! isset( $_GET[ self::CONTENT_TYPE_PARAM ] ) || ! \is_string( $_GET[ self::CONTENT_TYPE_PARAM ] ) ) {
391 + return $initial_selection;
392 + }
393 +
394 + $content_type = \sanitize_text_field( \wp_unslash( $_GET[ self::CONTENT_TYPE_PARAM ] ) );
395 + if ( ! \in_array( $content_type, \array_column( $content_types, 'name' ), true ) ) {
396 + return $initial_selection;
397 + }
398 + $initial_selection['contentType'] = $content_type;
399 +
400 + if ( isset( $_GET[ self::POST_IDS_PARAM ] ) && \is_string( $_GET[ self::POST_IDS_PARAM ] ) ) {
401 + $post_ids = \explode( ',', \sanitize_text_field( \wp_unslash( $_GET[ self::POST_IDS_PARAM ] ) ) );
402 + $post_ids = \array_values(
403 + \array_unique(
404 + \array_filter(
405 + \array_map( 'intval', $post_ids ),
406 + static function ( $id ) {
407 + return $id > 0;
408 + },
409 + ),
410 + ),
411 + );
412 + $post_ids = \array_slice( $post_ids, 0, Batch_Limit::MAX_ITEMS );
413 +
414 + $initial_selection['postIds'] = $post_ids;
415 + $initial_selection['selectedCount'] = \count( $post_ids );
416 + }
417 +
418 + if (
419 + $initial_selection['postIds'] !== []
420 + && isset( $_GET[ self::SELECTED_COUNT_PARAM ] )
421 + && \is_string( $_GET[ self::SELECTED_COUNT_PARAM ] )
422 + ) {
423 + // The count can only grow beyond the carried IDs, never shrink below them.
424 + $initial_selection['selectedCount'] = \max(
425 + $initial_selection['selectedCount'],
426 + \absint( \wp_unslash( $_GET[ self::SELECTED_COUNT_PARAM ] ) ),
427 + );
428 + }
429 + // phpcs:enable WordPress.Security.NonceVerification.Recommended
430 +
431 + return $initial_selection;
432 + }
433 +
434 + /**
435 + * Registers the carried-over selection parameters as removable, so WordPress cleans them from the
436 + * address bar once the page has picked them up.
437 + *
438 + * @param array<string> $removable_query_args The removable query args.
439 + *
440 + * @return array<string> The removable query args.
441 + */
442 + public function add_removable_query_args( $removable_query_args ) {
443 + $removable_query_args[] = self::POST_IDS_PARAM;
444 + $removable_query_args[] = self::SELECTED_COUNT_PARAM;
445 +
446 + return $removable_query_args;
447 + }
448 +
449 + /**
450 + * Gets whether the bulk editor guided tour has been seen by the current user.
451 + *
452 + * @return bool True when the tour has been seen, false otherwise.
453 + */
454 + private function is_tour_opt_in_notification_seen(): bool {
455 + $current_user_id = $this->user_helper->get_current_user_id();
456 +
457 + return (bool) $this->user_helper->get_meta( $current_user_id, '_yoast_wpseo_bulk_editor_tour_opt_in_notification_seen', true );
458 + }
459 +
460 + /**
461 + * Returns the tags of all registered shortcodes.
462 + *
463 + * Mirrors what the post editor passes to the analysis (see WPSEO_Metabox::get_valid_shortcode_tags()), so the
464 + * parse tree treats shortcodes the same on both pages: the delimiters are removed and the text an enclosing
465 + * shortcode wraps is kept. Without this list the raw brackets stay in the text and consume prompt tokens.
466 + *
467 + * @return array<string> The registered shortcode tags.
468 + */
469 + private function get_valid_shortcode_tags(): array {
470 + // The global is always set by WordPress, but stay defensive: an empty list only costs shortcode parity.
471 + if ( ! isset( $GLOBALS['shortcode_tags'] ) || ! \is_array( $GLOBALS['shortcode_tags'] ) ) {
472 + return [];
473 + }
474 +
475 + return \array_keys( $GLOBALS['shortcode_tags'] );
232 476 }
233 477
234 478 /**
235 479 * Removes all current WP notices.