PluginProbe
Yoast SEO – Advanced SEO with real-time guidance and built-in AI / 28.6
Yoast SEO – Advanced SEO with real-time guidance and built-in AI v28.6
28.6 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 All 130 releases
wordpress-seo / src / bulk-editor / user-interface / bulk-editor-integration.php

bulk-editor-integration.php in Yoast SEO – Advanced SEO with real-time guidance and built-in AI 28.6, at src/bulk-editor/user-interface/bulk-editor-integration.php

490 lines 17.1 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 WPSEO_Admin_Asset_Manager;
7 use WPSEO_Admin_Editor_Specific_Replace_Vars;
8 use WPSEO_Admin_Recommended_Replace_Vars;
9 use WPSEO_Replace_Vars;
10 use Yoast\WP\SEO\Bulk_Editor\Application\Content_Types\Content_Types_Repository;
11 use Yoast\WP\SEO\Bulk_Editor\Application\Endpoints\Endpoints_Repository;
12 use Yoast\WP\SEO\Bulk_Editor\Domain\Updates\Batch_Limit;
13 use Yoast\WP\SEO\Bulk_Editor\Infrastructure\Nonces\Nonce_Repository;
14 use Yoast\WP\SEO\Conditionals\Admin_Conditional;
15 use Yoast\WP\SEO\General\User_Interface\General_Page_Integration;
16 use Yoast\WP\SEO\Helpers\Current_Page_Helper;
17 use Yoast\WP\SEO\Helpers\Options_Helper;
18 use Yoast\WP\SEO\Helpers\Product_Helper;
19 use Yoast\WP\SEO\Helpers\Short_Link_Helper;
20 use Yoast\WP\SEO\Helpers\User_Helper;
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;
24
25 /**
26 * Adds the bulk editor page to the Yoast admin menu.
27 */
28 class Bulk_Editor_Integration implements Integration_Interface {
29
30 /**
31 * The page name.
32 */
33 public const PAGE = 'wpseo_page_bulk_edit';
34
35 /**
36 * The assets name.
37 */
38 public const ASSETS_NAME = 'bulk-editor-page';
39
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 /**
56 * Holds the WPSEO_Admin_Asset_Manager.
57 *
58 * @var WPSEO_Admin_Asset_Manager
59 */
60 private $asset_manager;
61
62 /**
63 * Holds the Current_Page_Helper.
64 *
65 * @var Current_Page_Helper
66 */
67 private $current_page_helper;
68
69 /**
70 * Holds the Product_Helper.
71 *
72 * @var Product_Helper
73 */
74 private $product_helper;
75
76 /**
77 * Holds the Short_Link_Helper.
78 *
79 * @var Short_Link_Helper
80 */
81 private $short_link_helper;
82
83 /**
84 * Holds the Content_Types_Repository.
85 *
86 * @var Content_Types_Repository
87 */
88 private $content_types_repository;
89
90 /**
91 * Holds the Nonce_Repository.
92 *
93 * @var Nonce_Repository
94 */
95 private $nonce_repository;
96
97 /**
98 * Holds the Endpoints_Repository.
99 *
100 * @var Endpoints_Repository
101 */
102 private $endpoints_repository;
103
104 /**
105 * Holds the Options_Helper.
106 *
107 * @var Options_Helper
108 */
109 private $options_helper;
110
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 /**
133 * Constructs the instance.
134 *
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.
146 */
147 public function __construct(
148 WPSEO_Admin_Asset_Manager $asset_manager,
149 Current_Page_Helper $current_page_helper,
150 Product_Helper $product_helper,
151 Short_Link_Helper $short_link_helper,
152 Content_Types_Repository $content_types_repository,
153 Nonce_Repository $nonce_repository,
154 Endpoints_Repository $endpoints_repository,
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
159 ) {
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;
171 }
172
173 /**
174 * Returns the conditionals based on which this loadable should be active.
175 *
176 * @return array<string> The conditionals.
177 */
178 public static function get_conditionals() {
179 return [ Admin_Conditional::class ];
180 }
181
182 /**
183 * Initializes the integration.
184 *
185 * This is the place to register hooks and filters.
186 *
187 * @return void
188 */
189 public function register_hooks() {
190 \add_filter( 'wpseo_submenu_pages', [ $this, 'add_page' ] );
191
192 // Hide the menu item without losing the page. See remove_menu_item() for why this runs on admin_head.
193 \add_action( 'admin_head', [ $this, 'remove_menu_item' ] );
194
195 // Are we on our page?
196 if ( $this->current_page_helper->get_current_yoast_seo_page() === self::PAGE ) {
197 \add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_assets' ] );
198 \add_action( 'in_admin_header', [ $this, 'remove_notices' ], \PHP_INT_MAX );
199 \add_filter( 'removable_query_args', [ $this, 'add_removable_query_args' ] );
200 }
201 }
202
203 /**
204 * Removes the bulk editor's submenu item from the Yoast SEO menu while keeping the page reachable by URL.
205 *
206 * Runs on admin_head rather than admin_menu on purpose: by then WordPress has already resolved the page's
207 * parent and capability (so the page stays accessible and keeps its `seo_page_wpseo_page_bulk_edit` body
208 * class, which its styles depend on), but the menu HTML has not been rendered yet, so the item is hidden.
209 * The page is opened from the Tools page instead of its own menu item.
210 *
211 * @return void
212 */
213 public function remove_menu_item() {
214 \remove_submenu_page( 'wpseo_dashboard', self::PAGE );
215 }
216
217 /**
218 * Adds the page to the (currently) last position in the array.
219 *
220 * @param array<array<string|callable|null>> $pages The pages.
221 *
222 * @return array<array<string|callable|null>> The pages.
223 */
224 public function add_page( $pages ) {
225 $pages[] = [
226 'wpseo_dashboard',
227 '',
228 \__( 'Bulk editor', 'wordpress-seo' ),
229 'wpseo_manage_options',
230 self::PAGE,
231 [ $this, 'display_page' ],
232 ];
233
234 return $pages;
235 }
236
237 /**
238 * Displays the page.
239 *
240 * @return void
241 */
242 public function display_page() {
243 echo '<div id="yoast-seo-bulk-editor"></div>';
244 }
245
246 /**
247 * Enqueues the assets.
248 *
249 * @return void
250 */
251 public function enqueue_assets() {
252 // Remove the emoji script as it is incompatible with both React and any contenteditable fields.
253 \remove_action( 'admin_print_scripts', 'print_emoji_detection_script' );
254 $this->asset_manager->enqueue_script( self::ASSETS_NAME );
255 $this->asset_manager->enqueue_style( self::ASSETS_NAME );
256 $this->asset_manager->localize_script( self::ASSETS_NAME, 'wpseoBulkEditorData', $this->get_script_data() );
257 }
258
259 /**
260 * Creates the script data.
261 *
262 * @return array<string, string|array<string, string|bool>|array<array<string, string>>> The script data.
263 */
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
269 return [
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' => [
277 'dashboard' => \admin_url( 'admin.php?page=' . General_Page_Integration::PAGE ),
278 'tools' => \admin_url( 'admin.php?page=wpseo_tools' ),
279 'settings' => \admin_url( 'admin.php?page=' . Settings_Integration::PAGE ),
280 ],
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(),
290 ],
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(),
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'] );
476 }
477
478 /**
479 * Removes all current WP notices.
480 *
481 * @return void
482 */
483 public function remove_notices() {
484 \remove_all_actions( 'admin_notices' );
485 \remove_all_actions( 'user_admin_notices' );
486 \remove_all_actions( 'network_admin_notices' );
487 \remove_all_actions( 'all_admin_notices' );
488 }
489 }
490