asset_manager = $asset_manager; $this->current_page_helper = $current_page_helper; $this->product_helper = $product_helper; $this->short_link_helper = $short_link_helper; $this->content_types_repository = $content_types_repository; $this->nonce_repository = $nonce_repository; $this->endpoints_repository = $endpoints_repository; $this->options_helper = $options_helper; $this->user_helper = $user_helper; $this->myyoast_connection_data_presenter = $myyoast_connection_data_presenter; $this->replace_vars = $replace_vars; $this->woo_seo_inactive_conditional = $woo_seo_inactive_conditional; $this->addon_manager = $addon_manager; $this->keyphrase_analysis = $keyphrase_analysis; } /** * Returns the conditionals based on which this loadable should be active. * * @return array The conditionals. */ public static function get_conditionals() { return [ Admin_Conditional::class ]; } /** * Initializes the integration. * * This is the place to register hooks and filters. * * @return void */ public function register_hooks() { \add_filter( 'wpseo_submenu_pages', [ $this, 'add_page' ] ); // Hide the menu item without losing the page. See remove_menu_item() for why this runs on admin_head. \add_action( 'admin_head', [ $this, 'remove_menu_item' ] ); // Are we on our page? if ( $this->current_page_helper->get_current_yoast_seo_page() === self::PAGE ) { \add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_assets' ] ); \add_action( 'in_admin_header', [ $this, 'remove_notices' ], \PHP_INT_MAX ); \add_filter( 'removable_query_args', [ $this, 'add_removable_query_args' ] ); } } /** * Removes the bulk editor's submenu item from the Yoast SEO menu while keeping the page reachable by URL. * * Runs on admin_head rather than admin_menu on purpose: by then WordPress has already resolved the page's * parent and capability (so the page stays accessible and keeps its `seo_page_wpseo_page_bulk_edit` body * class, which its styles depend on), but the menu HTML has not been rendered yet, so the item is hidden. * The page is opened from the Tools page instead of its own menu item. * * @return void */ public function remove_menu_item() { \remove_submenu_page( 'wpseo_dashboard', self::PAGE ); } /** * Adds the page to the (currently) last position in the array. * * @param array> $pages The pages. * * @return array> The pages. */ public function add_page( $pages ) { $pages[] = [ 'wpseo_dashboard', '', \__( 'Bulk editor', 'wordpress-seo' ), 'wpseo_manage_options', self::PAGE, [ $this, 'display_page' ], ]; return $pages; } /** * Displays the page. * * @return void */ public function display_page() { echo '
'; } /** * Enqueues the assets. * * @return void */ public function enqueue_assets() { // Remove the emoji script as it is incompatible with both React and any contenteditable fields. \remove_action( 'admin_print_scripts', 'print_emoji_detection_script' ); $this->asset_manager->enqueue_script( self::ASSETS_NAME ); $this->asset_manager->enqueue_style( self::ASSETS_NAME ); $this->asset_manager->localize_script( self::ASSETS_NAME, 'wpseoBulkEditorData', $this->get_script_data() ); } /** * Creates the script data. * * @return array|array>> The script data. */ public function get_script_data() { $content_types = $this->content_types_repository->get_content_types(); $is_premium = $this->product_helper->is_premium(); $is_version_supported = $this->is_premium_version_supported( $is_premium ); $is_woo_seo_active = ! $this->woo_seo_inactive_conditional->is_met(); $is_woo_seo_supported = $this->is_woo_seo_version_supported( $is_woo_seo_active ); $woo_seo_needs_update = $is_woo_seo_active && ! $is_woo_seo_supported; $can_update_plugins = \current_user_can( 'update_plugins' ); return [ 'contentTypes' => $content_types, 'endpoints' => $this->endpoints_repository->get_all_endpoints()->to_array(), // These must stay server-generated URLs: the bulk editor assigns the dashboard and tools links to // window.location.href for its "Back to Tools" / logo navigation (the settings link is rendered as a plain // link). If a link ever derives from request input, validate it with wp_validate_redirect() here before // exposing it, to avoid an open redirect on the front-end. 'links' => [ 'dashboard' => \admin_url( 'admin.php?page=' . General_Page_Integration::PAGE ), 'tools' => \admin_url( 'admin.php?page=wpseo_tools' ), 'settings' => \admin_url( 'admin.php?page=' . Settings_Integration::PAGE ), ], 'nonce' => $this->nonce_repository->get_rest_nonce(), 'restRoot' => \esc_url_raw( \rest_url() ), 'preferences' => [ 'isPremium' => $is_premium, 'isPremiumVersionSupported' => $is_version_supported, 'isAiEnabled' => $this->options_helper->get( 'enable_ai_generator' ) === true, // Without Yoast WooCommerce SEO, the products "Image alt text" tab shows the upsell instead; with an // active version that predates the tab, it asks for an update. 'isWooSeoActive' => $is_woo_seo_active, 'isWooSeoVersionSupported' => $is_woo_seo_supported, 'isRtl' => \is_rtl(), 'pluginUrl' => \plugins_url( '', \WPSEO_FILE ), 'premiumUpdateUrl' => $this->get_premium_update_url( $can_update_plugins ), 'wooSeoUpdateUrl' => $this->get_woo_seo_update_url( $woo_seo_needs_update, $can_update_plugins ), ], 'linkParams' => $this->short_link_helper->get_query_params(), 'analysis' => [ 'contentLocale' => \get_locale(), // Re-scoring only runs when SEO analysis is enabled, matching the post editor: on for the site and not // turned off in the user's profile. 'keywordAnalysisActive' => $this->keyphrase_analysis->is_enabled(), // Used when collecting the AI prompt content, so shortcode delimiters are stripped from the // text while the content they enclose is kept. Not gated on SEO analysis being enabled: the // prompt content is collected for AI suggestions, which do not depend on the analysis. 'shortcodes' => $this->get_valid_shortcode_tags(), ], 'optInNotificationSeen' => [ 'bulk_editor_tour' => $this->is_tour_opt_in_notification_seen(), ], 'initialSelection' => $this->get_initial_selection( $content_types ), 'myyoastConnection' => $this->myyoast_connection_data_presenter->present(), 'replacementVariables' => $this->get_replacement_variables(), ]; } /** * Builds the replacement variable data passed to the JS editor. * * Mirrors Settings_Integration::get_replacement_variables() so the bulk editor's * ReplacementVariableEditor receives the same variable metadata as the settings page. * * @return array{variables: array>, recommended: array, specific: array, shared: string[]} The replacement variable data. */ private function get_replacement_variables(): array { $recommended_replace_vars = new WPSEO_Admin_Recommended_Replace_Vars(); $specific_replace_vars = new WPSEO_Admin_Editor_Specific_Replace_Vars(); $replacement_variables = $this->replace_vars->get_replacement_variables_with_labels(); return [ 'variables' => $replacement_variables, 'recommended' => $recommended_replace_vars->get_recommended_replacevars(), 'specific' => $specific_replace_vars->get(), 'shared' => $specific_replace_vars->get_generic( $replacement_variables ), ]; } /** * Checks whether the installed Premium version is compatible with the current Free plugin. * * @param bool $is_premium Whether Premium is the currently active plugin. * * @return bool False when Premium needs upgrading or not active. */ private function is_premium_version_supported( bool $is_premium ): bool { if ( ! $is_premium ) { return false; } $premium_version = $this->product_helper->get_premium_version(); if ( $premium_version === null ) { return false; } return \version_compare( $premium_version, '28.1-RC0', '>' ); } /** * Checks whether the active Yoast WooCommerce SEO version fills the products "Image alt text" tab. * * @param bool $is_woo_seo_active Whether Yoast WooCommerce SEO is active. * * @return bool False when the add-on is not active or needs an update. */ private function is_woo_seo_version_supported( bool $is_woo_seo_active ): bool { if ( ! $is_woo_seo_active ) { return false; } $versions = $this->addon_manager->get_installed_addons_versions(); if ( ! isset( $versions[ WPSEO_Addon_Manager::WOOCOMMERCE_SLUG ] ) ) { return false; } return \version_compare( $versions[ WPSEO_Addon_Manager::WOOCOMMERCE_SLUG ], self::LAST_UNSUPPORTED_WOO_SEO_VERSION, '>' ); } /** * Returns the one-click Premium update URL for the current user, or an empty string when the user * lacks the `update_plugins` capability (and would hit a wp_die permission error on update.php). * * @param bool $can_update_plugins Whether the current user may update plugins. * * @return string The nonce-protected update URL, or an empty string. */ private function get_premium_update_url( bool $can_update_plugins ): string { if ( ! $can_update_plugins ) { return ''; } return $this->get_plugin_update_url( 'wordpress-seo-premium/wp-seo-premium.php' ); } /** * Returns where the current user can update Yoast WooCommerce SEO, or an empty string when it needs no update or * the user lacks the `update_plugins` capability. * * The one-click update URL when WordPress has a package for a version that fills the tab. Otherwise the Updates * screen, which checks for updates again and, on multisite, redirects to the network screen, where plugin updates * are listed. Subscriptions are left to Yoast WooCommerce SEO, which handles them once it fills the tab. * * @param bool $needs_update Whether an active Yoast WooCommerce SEO predates the tab. * @param bool $can_update_plugins Whether the current user may update plugins. * * @return string The one-click update or Updates screen URL, or an empty string. */ private function get_woo_seo_update_url( bool $needs_update, bool $can_update_plugins ): string { if ( ! $needs_update || ! $can_update_plugins ) { return ''; } $plugin_file = $this->addon_manager->get_plugin_file( WPSEO_Addon_Manager::WOOCOMMERCE_SLUG ); if ( \is_string( $plugin_file ) && $plugin_file !== '' && $this->has_update_package( $plugin_file ) ) { return $this->get_plugin_update_url( $plugin_file ); } return \self_admin_url( 'update-core.php' ); } /** * Checks whether WordPress has an update package ready to install for Yoast WooCommerce SEO, for a version that * fills the products "Image alt text" tab. * * An update cached before that version came out would install an older one, after which the modal reopens. * * @param string $plugin_file The plugin file, relative to the plugins directory. * * @return bool Whether update.php can install a version that fills the tab. */ private function has_update_package( string $plugin_file ): bool { $updates = \get_site_transient( 'update_plugins' ); if ( ! \is_object( $updates ) || ! isset( $updates->response[ $plugin_file ] ) ) { return false; } $update = $updates->response[ $plugin_file ]; return ! empty( $update->package ) && isset( $update->new_version ) && \version_compare( $update->new_version, self::LAST_UNSUPPORTED_WOO_SEO_VERSION, '>' ); } /** * Builds the nonce-protected one-click update URL of a plugin, as the Plugins screen links it. * * @param string $plugin_file The plugin file, relative to the plugins directory. * * @return string The update URL. */ private function get_plugin_update_url( string $plugin_file ): string { return \html_entity_decode( \wp_nonce_url( \self_admin_url( 'update.php?action=upgrade-plugin&plugin=' . \rawurlencode( $plugin_file ) ), 'upgrade-plugin_' . $plugin_file, ), \ENT_COMPAT, ); } /** * Returns the selection carried over from a post overview bulk action, if any. * * The parameters only decide which rows start out selected in the app; the REST endpoints * enforce the actual per-post edit access when anything is saved. * * @param array> $content_types The available content types. * * @return array> The content type, post IDs and overview selection count. */ private function get_initial_selection( array $content_types ): array { $initial_selection = [ 'contentType' => '', 'postIds' => [], 'selectedCount' => 0, ]; // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Reason: read-only display state, no action is taken. if ( ! isset( $_GET[ self::CONTENT_TYPE_PARAM ] ) || ! \is_string( $_GET[ self::CONTENT_TYPE_PARAM ] ) ) { return $initial_selection; } $content_type = \sanitize_text_field( \wp_unslash( $_GET[ self::CONTENT_TYPE_PARAM ] ) ); if ( ! \in_array( $content_type, \array_column( $content_types, 'name' ), true ) ) { return $initial_selection; } $initial_selection['contentType'] = $content_type; if ( isset( $_GET[ self::POST_IDS_PARAM ] ) && \is_string( $_GET[ self::POST_IDS_PARAM ] ) ) { $post_ids = \explode( ',', \sanitize_text_field( \wp_unslash( $_GET[ self::POST_IDS_PARAM ] ) ) ); $post_ids = \array_values( \array_unique( \array_filter( \array_map( 'intval', $post_ids ), static function ( $id ) { return $id > 0; }, ), ), ); $post_ids = \array_slice( $post_ids, 0, Batch_Limit::MAX_ITEMS ); $initial_selection['postIds'] = $post_ids; $initial_selection['selectedCount'] = \count( $post_ids ); } if ( $initial_selection['postIds'] !== [] && isset( $_GET[ self::SELECTED_COUNT_PARAM ] ) && \is_string( $_GET[ self::SELECTED_COUNT_PARAM ] ) ) { // The count can only grow beyond the carried IDs, never shrink below them. $initial_selection['selectedCount'] = \max( $initial_selection['selectedCount'], \absint( \wp_unslash( $_GET[ self::SELECTED_COUNT_PARAM ] ) ), ); } // phpcs:enable WordPress.Security.NonceVerification.Recommended return $initial_selection; } /** * Registers the carried-over selection parameters as removable, so WordPress cleans them from the * address bar once the page has picked them up. * * @param array $removable_query_args The removable query args. * * @return array The removable query args. */ public function add_removable_query_args( $removable_query_args ) { $removable_query_args[] = self::POST_IDS_PARAM; $removable_query_args[] = self::SELECTED_COUNT_PARAM; return $removable_query_args; } /** * Gets whether the bulk editor guided tour has been seen by the current user. * * @return bool True when the tour has been seen, false otherwise. */ private function is_tour_opt_in_notification_seen(): bool { $current_user_id = $this->user_helper->get_current_user_id(); return (bool) $this->user_helper->get_meta( $current_user_id, '_yoast_wpseo_bulk_editor_tour_opt_in_notification_seen', true ); } /** * Returns the tags of all registered shortcodes. * * Mirrors what the post editor passes to the analysis (see WPSEO_Metabox::get_valid_shortcode_tags()), so the * parse tree treats shortcodes the same on both pages: the delimiters are removed and the text an enclosing * shortcode wraps is kept. Without this list the raw brackets stay in the text and consume prompt tokens. * * @return array The registered shortcode tags. */ private function get_valid_shortcode_tags(): array { // The global is always set by WordPress, but stay defensive: an empty list only costs shortcode parity. if ( ! isset( $GLOBALS['shortcode_tags'] ) || ! \is_array( $GLOBALS['shortcode_tags'] ) ) { return []; } return \array_keys( $GLOBALS['shortcode_tags'] ); } /** * Removes all current WP notices. * * @return void */ public function remove_notices() { \remove_all_actions( 'admin_notices' ); \remove_all_actions( 'user_admin_notices' ); \remove_all_actions( 'network_admin_notices' ); \remove_all_actions( 'all_admin_notices' ); } }