AI4SEO_NEXTGEN_GALLERY_POST_TYPE, 'public' => false, 'show_ui' => false, 'show_in_menu' => false, 'exclude_from_search' => true, 'publicly_queryable' => false, 'query_var' => false, 'rewrite' => false, 'capability_type' => 'post', 'supports' => array( 'title', 'editor' ), 'can_export' => false, 'show_in_rest' => false, ) ); } // user needs to accept tos? stop here, to prevent further plugin actions. if ( ai4seo_does_user_need_to_accept_tos_toc_and_pp() ) { return; } if ( $can_use_plugin_content ) { // Content editors retain the frontend metadata editor integration. add_action( 'admin_bar_menu', 'ai4seo_add_admin_menu_item', 999 ); } } /** * Function to init plugin essentials for admins in the backend * * @return void */ function ai4seo_init_admin_area_essentials() { // ADMIN AREA ONLY (REST OF INIT CODE FROM HERE) -> // make sure the robhub api communicator is initialized before anything else. try { if ( ! ai4seo_robhub_api( true ) || ! ai4seo_robhub_api()->is_initialized ) { // if the robhub api communicator could not be initialized, we cannot continue // show notice. if ( ai4seo_can_use_plugin_content() ) { add_action( 'admin_notices', function () { echo '

'; printf( /* translators: %s: plugin name */ esc_html__( "The %s plugin could not be initialized. Class 'Ai4Seo_RobHubApiCommunicator' could not be initialized. Please check your server configuration and try again.", 'ai-for-seo' ), esc_html( AI4SEO_PLUGIN_NAME ) ); echo '

'; } ); } add_action( 'admin_menu', 'ai4seo_register_local_update_diagnostics' ); return; } } catch ( Throwable $e ) { add_action( 'admin_menu', 'ai4seo_register_local_update_diagnostics' ); // could not initialize the robhub api communicator -> abort here, echoing a notice. if ( ai4seo_can_use_plugin_content() ) { add_action( 'admin_notices', function () { echo '

'; printf( /* translators: %s: plugin name */ esc_html__( "The %s plugin could not be initialized. Class 'Ai4Seo_RobHubApiCommunicator' could not be initialized. Please check your server configuration and try again.", 'ai-for-seo' ), esc_html( AI4SEO_PLUGIN_NAME ) ); echo '

'; } ); } return; } // Overwrite the plugin-details for white-label-settings. add_filter( 'all_plugins', 'ai4seo_modify_plugin_details_for_white_label', 10, 1 ); // Keep the three independent boundaries available for the request-specific registrations below. $can_use_plugin_content = ai4seo_can_use_plugin_content(); $can_administer_plugin = ai4seo_can_administer_plugin(); $can_recover_incognito = ai4seo_can_recover_incognito_mode(); if ( ! $can_use_plugin_content && ! $can_administer_plugin && ! $can_recover_incognito ) { return; } if ( ! ai4seo_singleton( __FUNCTION__ ) ) { return; } $is_user_inside_our_plugin_admin_pages = ai4seo_is_user_inside_our_plugin_admin_pages(); // Add menu-item to main menu, sub menu-items and page titles. add_filter( 'admin_title', 'ai4seo_filter_admin_title', 10, 2 ); add_filter( 'admin_body_class', 'ai4seo_add_admin_body_class' ); add_action( 'admin_menu', 'ai4seo_add_menu_entries' ); add_filter( 'parent_file', 'ai4seo_mark_parent_menu_active' ); add_filter( 'submenu_file', 'ai4seo_mark_submenu_active' ); // plugin action link use filter "plugin_action_links_ + plugin_basename". $this_plugin_basename = sanitize_text_field( ai4seo_get_plugin_basename() ); add_filter( "plugin_action_links_{$this_plugin_basename}", 'ai4seo_add_links_to_the_plugin_directory', 999 ); $does_user_need_to_accept_tos_toc_and_pp = ai4seo_does_user_need_to_accept_tos_toc_and_pp(); if ( $does_user_need_to_accept_tos_toc_and_pp && ! $can_recover_incognito ) { // Register the footer output only when the shared request-level TOS decision also enables its assets and schema. if ( $can_administer_plugin && ai4seo_should_show_terms_of_service_modal_on_current_request() ) { add_action( 'admin_footer', 'ai4seo_show_terms_of_service_modal' ); } // stop here, to prevent further plugin actions. return; } if ( $can_use_plugin_content ) { // Content integrations remain governed by the configured role boundary. add_filter( 'manage_post_posts_columns', 'ai4seo_add_metadata_editor_column_to_posts_table' ); add_filter( 'manage_page_posts_columns', 'ai4seo_add_metadata_editor_column_to_posts_table' ); add_action( 'manage_post_posts_custom_column', 'ai4seo_add_metadata_editor_button_to_posts_table', 10, 2 ); add_action( 'manage_page_posts_custom_column', 'ai4seo_add_metadata_editor_button_to_posts_table', 10, 2 ); } // add ajax nonce field to the footer. add_action( 'admin_print_footer_scripts', 'ai4seo_print_ajax_nonce_field' ); // if user is inside our plugin admin pages, check for account sync. if ( $is_user_inside_our_plugin_admin_pages && $can_administer_plugin ) { ai4seo_check_for_robhub_account_sync(); } } /** * Returns the exact current site/options identity for request-local caches. * * @return string Options-table and blog identity, or an empty string when unavailable. */ function ai4seo_get_site_options_request_cache_scope(): string { global $wpdb; $options_table = is_object( $wpdb ) && isset( $wpdb->options ) && is_string( $wpdb->options ) ? $wpdb->options : ''; $blog_id = function_exists( 'get_current_blog_id' ) ? absint( get_current_blog_id() ) : 0; if ( '' === $options_table || $blog_id <= 0 ) { return ''; } return $options_table . '|' . $blog_id; } /** * Activates the settings request-cache view for the current site. * * The compatibility globals remain a flat current-site view while exact site records preserve * independently initialized settings across switch_to_blog()/restore_current_blog() transitions. * * @return bool Whether the current site identity was available. */ function ai4seo_prepare_settings_request_cache_for_current_site(): bool { global $ai4seo_are_settings_initialized; global $ai4seo_settings; global $ai4seo_settings_request_cache_by_site; global $ai4seo_settings_request_cache_scope; $current_scope = ai4seo_get_site_options_request_cache_scope(); if ( '' === $current_scope ) { return false; } if ( ! is_array( $ai4seo_settings_request_cache_by_site ) ) { $ai4seo_settings_request_cache_by_site = array(); } if ( ! is_string( $ai4seo_settings_request_cache_scope ) ) { $ai4seo_settings_request_cache_scope = ''; } if ( $current_scope === $ai4seo_settings_request_cache_scope ) { if ( ! is_array( $ai4seo_settings ) ) { $ai4seo_settings = AI4SEO_DEFAULT_SETTINGS; $ai4seo_are_settings_initialized = false; } return true; } // Preserve the compatibility view before leaving its exact site identity. if ( '' !== $ai4seo_settings_request_cache_scope && is_array( $ai4seo_settings ) ) { $ai4seo_settings_request_cache_by_site[ $ai4seo_settings_request_cache_scope ] = array( 'settings' => $ai4seo_settings, 'initialized' => (bool) $ai4seo_are_settings_initialized, ); } // Adopt legacy/test request state on the first scoped access without discarding explicit values. if ( '' === $ai4seo_settings_request_cache_scope && is_array( $ai4seo_settings ) && ! empty( $ai4seo_are_settings_initialized ) ) { $ai4seo_settings_request_cache_scope = $current_scope; $ai4seo_settings_request_cache_by_site[ $current_scope ] = array( 'settings' => $ai4seo_settings, 'initialized' => true, ); return true; } $ai4seo_settings_request_cache_scope = $current_scope; $current_record = $ai4seo_settings_request_cache_by_site[ $current_scope ] ?? array(); if ( is_array( $current_record ) && isset( $current_record['settings'], $current_record['initialized'] ) && is_array( $current_record['settings'] ) && is_bool( $current_record['initialized'] ) ) { $ai4seo_settings = $current_record['settings']; $ai4seo_are_settings_initialized = $current_record['initialized']; } else { $ai4seo_settings = AI4SEO_DEFAULT_SETTINGS; $ai4seo_are_settings_initialized = false; } return true; } /** * Stores the flat current-site settings view in its exact scoped cache record. * * @return bool Whether the current scoped record was stored. */ function ai4seo_store_settings_request_cache_for_current_site(): bool { global $ai4seo_are_settings_initialized; global $ai4seo_settings; global $ai4seo_settings_request_cache_by_site; if ( ! ai4seo_prepare_settings_request_cache_for_current_site() || ! is_array( $ai4seo_settings ) ) { return false; } $current_scope = ai4seo_get_site_options_request_cache_scope(); $ai4seo_settings_request_cache_by_site[ $current_scope ] = array( 'settings' => $ai4seo_settings, 'initialized' => (bool) $ai4seo_are_settings_initialized, ); return true; } /** * Clears the current site's settings request cache without disturbing another site's record. * * @return bool Whether the current site identity was available and reset. */ function ai4seo_reset_settings_request_cache_for_current_site(): bool { global $ai4seo_are_settings_initialized; global $ai4seo_settings; global $ai4seo_settings_request_cache_by_site; global $ai4seo_settings_request_cache_scope; $current_scope = ai4seo_get_site_options_request_cache_scope(); if ( '' === $current_scope ) { return false; } unset( $GLOBALS['ai4seo_settings_loaded_views'][ $current_scope ], $GLOBALS['ai4seo_settings_loaded_states'][ $current_scope ] ); if ( is_array( $ai4seo_settings_request_cache_by_site ) ) { unset( $ai4seo_settings_request_cache_by_site[ $current_scope ] ); } else { $ai4seo_settings_request_cache_by_site = array(); } if ( $current_scope === $ai4seo_settings_request_cache_scope ) { $ai4seo_settings = AI4SEO_DEFAULT_SETTINGS; $ai4seo_are_settings_initialized = false; } return true; } /** * Function to init the plugin-settings * * @return void */ function ai4seo_init_settings() { global $ai4seo_settings; global $ai4seo_are_settings_initialized; global $ai4seo_settings_scopes_being_initialized; if ( ! ai4seo_prepare_settings_request_cache_for_current_site() ) { return; } $current_scope = ai4seo_get_site_options_request_cache_scope(); if ( ! is_array( $ai4seo_settings_scopes_being_initialized ) ) { $ai4seo_settings_scopes_being_initialized = array(); } // A scope-owned recursion guard allows independent sites to initialize in the same request. if ( isset( $ai4seo_settings_scopes_being_initialized[ $current_scope ] ) ) { ai4seo_debug_message( 223772145, 'Prevented loop', true ); return; } $ai4seo_settings_scopes_being_initialized[ $current_scope ] = true; $ai4seo_settings = AI4SEO_DEFAULT_SETTINGS; $ai4seo_are_settings_initialized = false; try { $state = ai4seo_inspect_settings(); $ai4seo_settings = $state['settings']; $GLOBALS['ai4seo_settings_loaded_views'][ $current_scope ] = $ai4seo_settings; $GLOBALS['ai4seo_settings_loaded_states'][ $current_scope ] = $state; $ai4seo_are_settings_initialized = true; ai4seo_store_settings_request_cache_for_current_site(); } finally { unset( $ai4seo_settings_scopes_being_initialized[ $current_scope ] ); } } /** * Read, decode, and sanitize the stored plugin settings. * * @return array Sanitized plugin settings. */ function ai4seo_read_settings(): array { // Prevent recursion without imposing a request-lifetime ceiling on ordinary settings reads. if ( ai4seo_prevent_loops( __FUNCTION__ ) ) { ai4seo_debug_message( 134874085, 'Prevented loop', true ); return array(); } // Read settings from database. $settings = ai4seo_get_option( AI4SEO_SETTINGS_OPTION_NAME ); // Make sure that settings could be read from database. if ( ! $settings ) { $settings = array(); } // Retain settings written with an additional legacy serialization layer after the option wrapper's first decode. $settings = ai4seo_safe_maybe_unserialize( $settings ); // Make sure that $settings is array. if ( ! is_array( $settings ) ) { if ( is_string( $settings ) && ai4seo_is_json( $settings ) ) { $settings = json_decode( $settings, true ); } } if ( ! is_array( $settings ) ) { $settings = array(); } $settings = ai4seo_deep_sanitize( $settings ); return $settings; } /** * Things to do on plugin activation * * @return void */ function ai4seo_on_activation() { if ( ! ai4seo_singleton( __FUNCTION__ ) ) { return; } // Activation can load this plugin after init has already fired. ai4seo_initialize_welcome_notification_state(); // set AI4SEO_ENVIRONMENTAL_VARIABLE_PLUGIN_ACTIVATION_TIME. if ( ! ai4seo_read_environmental_variable( AI4SEO_ENVIRONMENTAL_VARIABLE_PLUGIN_ACTIVATION_TIME ) ) { ai4seo_update_environmental_variable( AI4SEO_ENVIRONMENTAL_VARIABLE_PLUGIN_ACTIVATION_TIME, time() ); } // Reconcile durable checkout-return rows whose one-shot callbacks were missed while inactive. ai4seo_reconcile_purchase_return_tokens(); // init cron jobs. ai4seo_init_cron_jobs(); } /** * Deactivates AI for SEO for an authorized administrator. * * @return bool Whether the deactivation request completed. */ function ai4seo_deactivate_plugin(): bool { // Prevent repeated deactivation attempts when multiple request paths reach the same command. if ( ! ai4seo_singleton( __FUNCTION__ ) ) { return false; } // Check if the user has the required permissions. if ( ! current_user_can( 'activate_plugins' ) ) { return false; } // Deactivate the plugin. try { deactivate_plugins( ai4seo_get_plugin_basename() ); } catch ( Exception $e ) { return false; } return true; } /** * Things to do on plugin deactivation * * @return void */ function ai4seo_on_deactivation() { if ( ! ai4seo_singleton( __FUNCTION__ ) ) { return; } // un schedule all cron jobs. ai4seo_un_schedule_cron_jobs(); ai4seo_robhub_api()->perform_product_deactivated_call(); // Check for function get_current_user_id(). if ( ! function_exists( 'get_current_user_id' ) ) { return; } // Resolve the owner once so deactivation uses the same canonical permission boundary as runtime access. $ai4seo_setting_enable_incognito_mode = ai4seo_is_incognito_mode_enabled(); $ai4seo_setting_incognito_mode_user_id = ai4seo_get_incognito_mode_user_id(); $current_user_id = get_current_user_id(); // Delete plugin if it was deactivated by non-incognito mode user. if ( $ai4seo_setting_enable_incognito_mode && 0 !== $ai4seo_setting_incognito_mode_user_id && $ai4seo_setting_incognito_mode_user_id !== $current_user_id ) { // Make sure we can call delete_plugins(). if ( ! function_exists( 'delete_plugins' ) ) { include_once ABSPATH . 'wp-admin/includes/plugin.php'; } // Attempt to delete this plugin's files. if ( function_exists( 'delete_plugins' ) ) { delete_plugins( array( plugin_basename( AI4SEO_PLUGIN_FILE ) ) ); } } } /** * Suspends deferred modal loading before WordPress replaces this plugin's files. * * The upgrader filter is a public mixed-value boundary, so the original response must pass through * unchanged while malformed or unrelated hook metadata remains a no-op. * * @param mixed $response Installation response supplied by WordPress. * @param mixed $hook_extra Extra upgrader metadata supplied by WordPress. * @return mixed Unchanged installation response. */ function ai4seo_suspend_deferred_modal_loading_before_self_update( $response, $hook_extra ) { // Accept only the public upgrader payload for this plugin's own replacement operation. if ( ! is_array( $hook_extra ) || ! isset( $hook_extra['plugin'] ) || ! is_string( $hook_extra['plugin'] ) || plugin_basename( AI4SEO_PLUGIN_FILE ) !== $hook_extra['plugin'] ) { return $response; } // Keep a request-local signal for callbacks that were already selected before hook removal. $GLOBALS['ai4seo_deferred_modal_loading_suspended_for_self_update'] = true; // Mirror the deferred registrations split between user initialization and bootstrap hooks. $deferred_modal_hooks = array( 'wp_footer', 'get_footer', 'admin_footer', 'elementor/editor/footer', ); foreach ( $deferred_modal_hooks as $deferred_modal_hook ) { remove_action( $deferred_modal_hook, 'ai4seo_include_modal_schemas_file' ); } return $response; } /** * Determine whether this request has begun replacing the plugin's own runtime. * * @return bool Whether deferred modal loading must remain suspended. */ function ai4seo_is_deferred_modal_loading_suspended_for_self_update(): bool { return ! empty( $GLOBALS['ai4seo_deferred_modal_loading_suspended_for_self_update'] ); } /** * Function to check if we have updated recently and do some actions accordingly * * @param bool $force Whether to retry unresolved steps immediately. * @return void */ function ai4seo_check_and_handle_plugin_update( bool $force = false ) { if ( ! $force && ! ai4seo_singleton( __FUNCTION__ ) ) { return; } $origin = ai4seo_inspect_plugin_update_origin(); $progress = ai4seo_read_plugin_update_progress(); if ( null === $progress || 'newer' === $origin['status'] ) { return; } $post_state_storage = ai4seo_read_post_state_storage_status(); $title_order_state = ai4seo_read_title_order_state(); if ( ! $force && AI4SEO_PLUGIN_VERSION_NUMBER === $origin['source'] && AI4SEO_PLUGIN_VERSION_NUMBER === $progress['target'] && in_array( $post_state_storage['phase'] ?? '', array( 'active', 'legacy' ), true ) && ( 'legacy' === ( $post_state_storage['phase'] ?? '' ) || ( 'ready' === ( $title_order_state['phase'] ?? '' ) && ai4seo_title_order_signature() === $title_order_state['signature'] && $post_state_storage['generation'] === $title_order_state['source_generation'] ) ) && 'completed' === ( $progress['steps']['initialization']['status'] ?? '' ) && 'completed' === ( $progress['steps']['analysis_issue_inventory_v1']['status'] ?? '' ) && ! array_filter( $progress['steps'], static function ( array $step ): bool { return 'completed' !== $step['status']; } ) ) { return; } $welcome_ready = ai4seo_initialize_welcome_notification_state(); if ( 'fresh' === $origin['status'] && ! $welcome_ready ) { return; } $ready = ai4seo_tidy_up( $origin['source'], $force, $origin['status'] ); // Local background work remains independent of optional preferences and remote availability. ai4seo_schedule_active_metadata_migration_v235_cron_job(); if ( ! $ready || ! in_array( $origin['status'], array( 'known', 'fresh' ), true ) ) { return; } if ( AI4SEO_PLUGIN_VERSION_NUMBER === $origin['source'] ) { return; } $lock = ai4seo_get_plugin_update_lock_name(); if ( ! ai4seo_acquire_database_advisory_lock( $lock ) ) { return; } try { // Re-read version identity under the lock; a competing upgrade must never be rewound. $current = ai4seo_inspect_plugin_update_origin(); if ( $current['status'] !== $origin['status'] || $current['source'] !== $origin['source'] ) { return; } $raw = $current['snapshot']['value'] ?? array(); $raw[ AI4SEO_ENVIRONMENTAL_VARIABLE_LAST_KNOWN_PLUGIN_VERSION ] = AI4SEO_PLUGIN_VERSION_NUMBER; if ( ! ai4seo_is_database_advisory_lock_owned_by_current_connection( $lock ) || true !== ai4seo_compare_and_swap_option_snapshot( AI4SEO_ENVIRONMENTAL_VARIABLES_OPTION_NAME, $current['snapshot'], $raw, false ) ) { return; } ai4seo_read_all_environmental_variables( false ); } finally { ai4seo_release_database_advisory_lock( $lock ); } if ( $origin['source'] ) { try { ai4seo_robhub_api()->call( 'client/product-updated', array( 'old_version' => $origin['source'], 'new_version' => AI4SEO_PLUGIN_VERSION_NUMBER, ) ); ai4seo_sync_robhub_account( 'product_updated' ); ai4seo_check_for_plugin_update_notification( $origin['source'], true ); } catch ( Throwable $throwable ) { ai4seo_debug_message( 418620981, 'Local update completed; remote update reporting is unavailable.', true ); } if ( ai4seo_is_user_inside_our_plugin_admin_pages() ) { add_action( 'admin_init', 'ai4seo_refresh_plugin_page_assets_after_update', 0 ); } } ai4seo_check_for_welcome_notification(); } /** * Classify retained site history without confusing normalized defaults with missing data. * * @param array $snapshots Authoritative raw option snapshots. * @return string Pending for fresh data, handled for retained welcome history, otherwise suppressed. */ function ai4seo_classify_welcome_install_context( array $snapshots ): string { // Agreement acceptance can precede the first normal initialization without implying prior use. $consent_variables = array( AI4SEO_ENVIRONMENTAL_VARIABLE_TOS_TOC_AND_PP_ACCEPTED_TIME, AI4SEO_ENVIRONMENTAL_VARIABLE_TOS_LAST_MODAL_OPEN_TIME, AI4SEO_ENVIRONMENTAL_VARIABLE_ENHANCED_REPORTING_ACCEPTED, AI4SEO_ENVIRONMENTAL_VARIABLE_ENHANCED_REPORTING_ACCEPTED_TIME, ); // Compare persisted values, including unknown keys, rather than normalized runtime defaults. foreach ( $snapshots as $option_name => $snapshot ) { if ( ! $snapshot['exists'] ) { continue; } // The existence of a legacy scalar option is history even when its stored value is malformed. if ( in_array( $option_name, AI4SEO_WELCOME_LEGACY_HISTORY_OPTIONS, true ) ) { return 'suppressed'; } // Retain any previous welcome identity, including a dismissed record with empty copy. $value = $snapshot['value']; if ( AI4SEO_NOTIFICATIONS_OPTION_NAME === $option_name && is_array( $value ) && array_key_exists( AI4SEO_WELCOME_NOTIFICATION_INDEX, $value ) ) { return 'handled'; } // Non-array modern options cannot establish a confidently fresh installation. if ( ! is_array( $value ) ) { return 'suppressed'; } // Only settings and internal metadata have declared fresh defaults; other options must be empty. $defaults = array(); if ( AI4SEO_ENVIRONMENTAL_VARIABLES_OPTION_NAME === $option_name ) { $defaults = AI4SEO_DEFAULT_ENVIRONMENTAL_VARIABLES; } elseif ( AI4SEO_SETTINGS_OPTION_NAME === $option_name ) { $defaults = AI4SEO_DEFAULT_SETTINGS; } foreach ( $value as $name => $entry ) { if ( AI4SEO_ENVIRONMENTAL_VARIABLES_OPTION_NAME === $option_name && in_array( $name, $consent_variables, true ) && ai4seo_validate_environmental_variable_value( $name, $entry ) ) { continue; } if ( ! array_key_exists( $name, $defaults ) || $defaults[ $name ] !== $entry ) { return 'suppressed'; } } } return 'pending'; } /** * Enroll a fresh site once, before normal initialization creates retained history. * * @param bool $allow_historical_recovery Whether the update owner may recover malformed storage without enrolling it. * @return bool Whether eligibility is resolved or the caller explicitly allows historical recovery; false on failed enrollment. */ function ai4seo_initialize_welcome_notification_state( bool $allow_historical_recovery = false ): bool { // The gate is currently disabled; using its abstraction preserves any future acceptance requirement. if ( ai4seo_does_user_need_to_accept_tos_toc_and_pp() ) { return false; } // Existing decisions use the ordinary site-scoped request cache and need no further history probes. if ( in_array( ai4seo_read_environmental_variable( AI4SEO_ENVIRONMENTAL_VARIABLE_WELCOME_NOTIFICATION_STATE ), array( 'pending', 'handled', 'suppressed' ), true ) ) { return true; } // Resolve installed versions and welcome history before loading potentially large coverage payloads. $scope = ai4seo_get_site_options_request_cache_scope(); $primary_option_names = array( AI4SEO_NOTIFICATIONS_OPTION_NAME, AI4SEO_ENVIRONMENTAL_VARIABLES_OPTION_NAME ); $history_option_names = array_merge( $primary_option_names, array( AI4SEO_SETTINGS_OPTION_NAME, AI4SEO_ROBHUB_ENVIRONMENTAL_VARIABLES_OPTION_NAME, AI4SEO_LATEST_ACTIVITY_OPTION_NAME, AI4SEO_GENERATION_STATUS_SUMMARY_OPTION_NAME, ), AI4SEO_WELCOME_LEGACY_HISTORY_OPTIONS, AI4SEO_ALL_POST_ID_OPTIONS ); // Reclassify every conflict against current history, never replay a stale fresh-install decision. $attempt_limit = ai4seo_get_environmental_variable_mutation_attempt_limit(); for ( $attempt = 0; $attempt < $attempt_limit; ++$attempt ) { $snapshots = ai4seo_get_raw_option_snapshots( $primary_option_names ); if ( null === $snapshots ) { return false; } // Only inconclusive primary evidence needs the broader snapshot; it also rechecks concurrent primary changes. if ( 'pending' === ai4seo_classify_welcome_install_context( $snapshots ) ) { $snapshots = ai4seo_get_raw_option_snapshots( $history_option_names ); } if ( null === $snapshots || '' === $scope || ai4seo_get_site_options_request_cache_scope() !== $scope ) { return false; } // The welcome must not replace malformed history, but the existing update owner may still recover it. $snapshot = $snapshots[ AI4SEO_ENVIRONMENTAL_VARIABLES_OPTION_NAME ]; if ( $snapshot['exists'] && ! is_array( $snapshot['value'] ) ) { return $allow_historical_recovery; } // A competing enrollment or finalization always wins over the earlier request-local view. $overrides = $snapshot['exists'] ? $snapshot['value'] : array(); $state = $overrides[ AI4SEO_ENVIRONMENTAL_VARIABLE_WELCOME_NOTIFICATION_STATE ] ?? 'unknown'; if ( in_array( $state, array( 'pending', 'handled', 'suppressed' ), true ) ) { ai4seo_read_all_environmental_variables( false ); return true; } // Preserve every unrelated key and verify the exact snapshot before publishing the decision. $overrides[ AI4SEO_ENVIRONMENTAL_VARIABLE_WELCOME_NOTIFICATION_STATE ] = ai4seo_classify_welcome_install_context( $snapshots ); $write_result = ai4seo_compare_and_swap_option_snapshot( AI4SEO_ENVIRONMENTAL_VARIABLES_OPTION_NAME, $snapshot, $overrides, false ); ai4seo_read_all_environmental_variables( false ); if ( null === $write_result || true === $write_result ) { return true === $write_result; } } return false; } /** * Restore the previous version marker when a required update migration fails. * * This localized retry keeps the next request eligible for the failed migration without changing * the sequencing of established update routines. * * @param bool $migration_succeeded Whether the required migration completed. * @param string $last_known_plugin_version Previous installed plugin version. * @return bool True when no retry is needed or the retry marker was stored successfully. */ function ai4seo_restore_plugin_update_retry_after_failed_setting_migration( bool $migration_succeeded, string $last_known_plugin_version ): bool { // Successful migrations and fresh installs do not need the update marker rewound. if ( $migration_succeeded || '' === $last_known_plugin_version ) { return true; } // Rewind only the version marker so the next request re-enters the normal update migration path. $retry_marker_stored = ai4seo_update_environmental_variable( AI4SEO_ENVIRONMENTAL_VARIABLE_LAST_KNOWN_PLUGIN_VERSION, $last_known_plugin_version ); // Retain operational visibility if the retry marker itself cannot be persisted. if ( ! $retry_marker_stored ) { ai4seo_debug_message( 928451620, 'Could not retain the plugin update retry marker.', true ); } return $retry_marker_stored; } /** * Persists one validated set of required setting migrations without leaving optimistic request state. * * Settings writes update the request-global settings array before the database operation. A failed * upgrade write must restore the exact pre-migration request state so later code cannot consume values * that were never committed and the next request can retry the complete migration batch. * * @param array $setting_migration_values Setting values that must be committed together. * @return bool True when the complete batch is durable or no setting migration is required. */ function ai4seo_apply_required_setting_migration_values( array $setting_migration_values ): bool { return ai4seo_patch_plugin_update_settings( $setting_migration_values ); } /** * Function to clean up old version's options, variables etc. "Cleanup". "Clean_up", "Tidy-up" * * @param string $last_known_plugin_version The last known plugin version, used to determine which cleanup actions to perform. * @param bool $force Whether to retry unresolved steps immediately. * @param string $origin_status Verified installation-history classification. * @return bool True when every required update migration completed, false when the update must retry. */ function ai4seo_tidy_up( string $last_known_plugin_version = AI4SEO_PLUGIN_VERSION_NUMBER, bool $force = false, string $origin_status = 'known' ): bool { $lock = ai4seo_get_plugin_update_lock_name(); if ( ai4seo_is_database_advisory_lock_owned_by_current_connection( $lock ) || ! ai4seo_acquire_database_advisory_lock( $lock ) ) { return false; } try { $results = array(); $raw_settings = ai4seo_read_plugin_update_settings(); $environment = ai4seo_get_raw_option_snapshot( AI4SEO_ENVIRONMENTAL_VARIABLES_OPTION_NAME ); $raw_environmental_variables = null !== $environment && is_array( $environment['value'] ) ? $environment['value'] : array(); $history_known = in_array( $origin_status, array( 'known', 'fresh' ), true ); $results[] = ai4seo_run_plugin_update_step( 'analysis_issue_inventory_v1', 'ai4seo_initialize_analysis_issue_inventory', $force ); $results[] = ai4seo_run_plugin_update_step( 'history', static function () use ( $history_known ) { return $history_known ? true : ai4seo_plugin_update_step_result( 'blocked', 'history_unknown' ); }, $force, true ); // Optional preferences cannot stop compatibility work or rewrite an unreadable settings container. ai4seo_run_plugin_update_step( 'notifications', static function () use ( $raw_settings, $environment ) { return null !== $raw_settings && null !== $environment && ( ! $environment['exists'] || is_array( $environment['value'] ) ) && ai4seo_initialize_notification_preferences(); }, $force ); $results[] = ai4seo_run_plugin_update_step( 'legacy_cache', static function ( array $previous ): array { $result = ai4seo_tidy_up_old_ai4seo_cache_table( 250, $previous ); return $result['step']; }, $force ); if ( ! $history_known ) { return false; } $results[] = ai4seo_run_plugin_update_step( 'cache_invalidation', static function (): bool { return ai4seo_invalidate_all_environmental_variable_caches(); }, $force ); // Account maintenance is independent of local migrations and never resets queue ownership. ai4seo_run_plugin_update_step( 'account_maintenance', static function (): bool { $api = ai4seo_robhub_api( true ); if ( ! $api || ! $api->is_initialized ) { return false; } $snapshot = ai4seo_get_raw_option_snapshot( AI4SEO_ROBHUB_ENVIRONMENTAL_VARIABLES_OPTION_NAME ); return null !== $snapshot && $api->apply_plugin_update_environmental_changes( $snapshot, array( $api::ENVIRONMENTAL_VARIABLE_LAST_ACCOUNT_SYNC => 0, $api::ENVIRONMENTAL_VARIABLE_IS_ACCOUNT_SYNCED => false, ) ); }, $force ); $results[] = ai4seo_run_plugin_update_step( 'attachment_formats', static function () use ( $raw_settings, $last_known_plugin_version ) { return null === $raw_settings ? ai4seo_plugin_update_step_result( 'blocked', 'settings_unreadable' ) : ai4seo_apply_plugin_update_setting_group( ai4seo_get_attachment_file_type_migration_values( $last_known_plugin_version, $raw_settings ), $raw_settings ); }, $force ); $results[] = ai4seo_run_plugin_update_step( 'legacy_111', static function () use ( $last_known_plugin_version ) { // remove old options (from older versions) // required after V1.1.1. if ( $last_known_plugin_version && version_compare( $last_known_plugin_version, '1.1.1', '<' ) ) { ai4seo_delete_option( '_ai4seo_current_credits_balance' ); } return true; }, $force ); $results[] = ai4seo_run_plugin_update_step( 'legacy_112', static function () use ( $last_known_plugin_version ) { // if old option ai4seo_missing_seo_data_post_ids is set, rename it to ai4seo_processing_metadata_post_ids // required after V1.1.2. if ( $last_known_plugin_version && version_compare( $last_known_plugin_version, '1.1.2', '<' ) ) { if ( ! ai4seo_migrate_plugin_update_option( 'ai4seo_missing_seo_data_post_ids', 'ai4seo_processing_metadata_post_ids' ) ) { return false; } // if old option _ai4seo_num_existing_going_to_fill_this_post_ids_by_post_type is set, rename it to _ai4seo_num_processing_metadata_post_ids_by_post_type // required after V1.1.2. if ( ! ai4seo_migrate_plugin_update_option( '_ai4seo_num_existing_going_to_fill_this_post_ids_by_post_type', '_ai4seo_num_processing_metadata_post_ids_by_post_type' ) ) { return false; } // clear schedule of old cronjobs, as of V1.1.2 we use new cronjobs. wp_clear_scheduled_hook( 'ai4seo_search_missing_seo_data_posts' ); wp_clear_scheduled_hook( 'ai4seo_search_missing_metadata_posts' ); wp_clear_scheduled_hook( 'ai4seo_automated_seo_data_generation' ); } return true; }, $force ); $results[] = ai4seo_run_plugin_update_step( 'legacy_118', static function () use ( $last_known_plugin_version ) { // V1.1.8: clear schedule of old cronjob "ai4seo_automated_metadata_generation", it's now called "ai4seo_automated_generation_cron_job". if ( $last_known_plugin_version && version_compare( $last_known_plugin_version, '1.1.8', '<' ) ) { wp_clear_scheduled_hook( 'ai4seo_automated_metadata_generation' ); ai4seo_delete_option( 'ai4seo_is_automation_activated_for_posts' ); ai4seo_delete_option( 'ai4seo_is_automation_activated_for_pages' ); ai4seo_delete_option( 'ai4seo_is_automation_activated_for_products' ); } return true; }, $force ); $results[] = ai4seo_run_plugin_update_step( 'legacy_120', static function () use ( $last_known_plugin_version ) { // if old option ai4seo_already_filled_post_ids is set, rename it to ai4seo_already_filled_metadata_post_ids // required after V1.2. if ( $last_known_plugin_version && version_compare( $last_known_plugin_version, '1.2', '<' ) ) { if ( ! ai4seo_migrate_plugin_update_option( 'ai4seo_already_filled_post_ids', 'ai4seo_already_filled_metadata_post_ids' ) ) { return false; } // if old option ai4seo_failed_to_fill_post_ids is set, rename it to ai4seo_failed_to_fill_metadata_post_ids // required after V1.2. if ( ! ai4seo_migrate_plugin_update_option( 'ai4seo_failed_to_fill_post_ids', 'ai4seo_failed_to_fill_metadata_post_ids' ) ) { return false; } } return true; }, $force ); $results[] = ai4seo_run_plugin_update_step( 'legacy_121', static function () use ( $last_known_plugin_version ) { // V1.2.1: Delete old summary options. if ( $last_known_plugin_version && version_compare( $last_known_plugin_version, '1.2.1', '<' ) ) { if ( ai4seo_get_option( '_ai4seo_num_processing_metadata_post_ids_by_post_type' ) ) { ai4seo_delete_option( '_ai4seo_num_processing_metadata_post_ids_by_post_type' ); } if ( ai4seo_get_option( '_ai4seo_num_failed_to_fill_post_ids_by_post_type' ) ) { ai4seo_delete_option( '_ai4seo_num_failed_to_fill_post_ids_by_post_type' ); } if ( ai4seo_get_option( '_ai4seo_num_already_filled_post_ids_by_post_type' ) ) { ai4seo_delete_option( '_ai4seo_num_already_filled_post_ids_by_post_type' ); } if ( ai4seo_get_option( '_ai4seo_num_posts_not_filled_by_post_type' ) ) { ai4seo_delete_option( '_ai4seo_num_posts_not_filled_by_post_type' ); } if ( ai4seo_get_option( 'ai4seo_already_filled_metadata_post_ids' ) ) { ai4seo_delete_option( 'ai4seo_already_filled_metadata_post_ids' ); } if ( ai4seo_get_option( 'ai4seo_already_filled_attributes_attachment_post_ids' ) ) { ai4seo_delete_option( 'ai4seo_already_filled_attributes_attachment_post_ids' ); } // V1.2.1: Rename some post ids options // (ai4seo_failed_to_fill_metadata_post_ids -> ai4seo_failed_metadata_post_ids) // Transfer legacy attachment failures to the registered attachment queue. if ( ! ai4seo_migrate_plugin_update_option( 'ai4seo_failed_to_fill_metadata_post_ids', 'ai4seo_failed_metadata_post_ids' ) ) { return false; } if ( ! ai4seo_migrate_plugin_update_option( 'ai4seo_failed_to_fill_attributes_attachment_post_ids', AI4SEO_FAILED_ATTACHMENT_ATTRIBUTES_POST_IDS_OPTION_NAME ) ) { return false; } } return true; }, $force ); $results[] = ai4seo_run_plugin_update_step( 'legacy_126', static function () use ( $last_known_plugin_version ) { // V1.2.6: Save various options into the new environmental variables option. if ( $last_known_plugin_version && version_compare( $last_known_plugin_version, '1.2.6', '<' ) ) { if ( ai4seo_get_option( '_ai4seo_robhub_last_credit_balance_check' ) !== false ) { ai4seo_delete_option( '_ai4seo_robhub_last_credit_balance_check' ); } $credit_source = ai4seo_get_raw_option_snapshot( '_ai4seo_robhub_credits_balance' ); if ( null === $credit_source ) { return false; } if ( $credit_source['exists'] ) { $api = ai4seo_robhub_api( true ); $target = ai4seo_get_raw_option_snapshot( AI4SEO_ROBHUB_ENVIRONMENTAL_VARIABLES_OPTION_NAME ); $value = filter_var( $credit_source['value'], FILTER_VALIDATE_INT, array( 'options' => array( 'min_range' => 0 ) ) ); if ( ! $api || ! $api->is_initialized || null === $target || ( $target['exists'] && ! is_array( $target['value'] ) ) || false === $value ) { return false; } $raw = $target['exists'] ? $target['value'] : array(); if ( array_key_exists( $api::ENVIRONMENTAL_VARIABLE_CREDITS_BALANCE, $raw ) && $value !== $raw[ $api::ENVIRONMENTAL_VARIABLE_CREDITS_BALANCE ] ) { return false; } if ( ! $api->apply_plugin_update_environmental_changes( $target, array( $api::ENVIRONMENTAL_VARIABLE_CREDITS_BALANCE => $value ) ) || true !== ai4seo_compare_and_delete_option_snapshot( '_ai4seo_robhub_credits_balance', $credit_source ) ) { return false; } } if ( ai4seo_get_option( '_ai4seo_licence_key_shown' ) !== false ) { ai4seo_delete_option( '_ai4seo_licence_key_shown' ); } if ( ai4seo_get_option( '_ai4seo_last_cronjob_call' ) !== false ) { // This obsolete diagnostic timestamp is not a current processing lease. ai4seo_delete_option( '_ai4seo_last_cronjob_call' ); } if ( ai4seo_get_option( '_ai4seo_last_cronjob_call_for_ai4seo_automated_generation_cron_job' ) !== false ) { // This obsolete diagnostic timestamp is not a current processing lease. ai4seo_delete_option( '_ai4seo_last_cronjob_call_for_ai4seo_automated_generation_cron_job' ); } if ( ai4seo_get_option( '_ai4seo_last_cronjob_call_for_ai4seo_automated_metadata_generation' ) !== false ) { // This obsolete diagnostic timestamp is not a current processing lease. ai4seo_delete_option( '_ai4seo_last_cronjob_call_for_ai4seo_automated_metadata_generation' ); } if ( ai4seo_get_option( '_ai4seo_performance_notice_dismissed_timestamp' ) !== false ) { // This obsolete diagnostic timestamp is not a current processing lease. ai4seo_delete_option( '_ai4seo_performance_notice_dismissed_timestamp' ); } } return true; }, $force ); $results[] = ai4seo_run_plugin_update_step( 'account_compatibility', static function () use ( $last_known_plugin_version ): bool { return ! $last_known_plugin_version || version_compare( $last_known_plugin_version, '2.0.0', '>=' ) || ai4seo_migrate_plugin_update_account_data(); }, $force ); $results[] = ai4seo_run_plugin_update_step( 'settings_200', static function () use ( $last_known_plugin_version, $raw_settings, $raw_environmental_variables ) { if ( null === $raw_settings ) { return ai4seo_plugin_update_step_result( 'blocked', 'settings_unreadable' ); } $required_setting_migration_values = array(); // V2.0.0: Convert legacy automatic-generation preferences independently of account data. if ( $last_known_plugin_version && version_compare( $last_known_plugin_version, '2.0.0', '<' ) ) { foreach ( array( 'enabled_automated_generations', 'automated_generation_order', 'automated_generation_new_or_existing_filter' ) as $legacy_key ) { if ( array_key_exists( $legacy_key, $raw_settings ) && ! is_array( $raw_settings[ $legacy_key ] ) ) { return ai4seo_plugin_update_step_result( 'blocked', 'settings_unreadable' ); } } // V2.0.0: Settings migration // enabled_automated_generations -> enabled_bulk_generation_post_types. if ( isset( $raw_settings['enabled_automated_generations'] ) && is_array( $raw_settings['enabled_automated_generations'] ) ) { $new_enabled_bulk_generation_post_types = array(); foreach ( $raw_settings['enabled_automated_generations'] as $this_post_type => $this_is_enabled ) { $this_is_enabled = ai4seo_normalize_boolean_setting_value( AI4SEO_SETTING_ENABLE_EXTERNAL_METADATA_GENERATE_BUTTONS, $this_is_enabled ); if ( ! is_bool( $this_is_enabled ) ) { return ai4seo_plugin_update_step_result( 'blocked', 'settings_unreadable' ); } if ( $this_is_enabled ) { $new_enabled_bulk_generation_post_types[] = $this_post_type; } } if ( $new_enabled_bulk_generation_post_types ) { $required_setting_migration_values[ AI4SEO_SETTING_ENABLED_BULK_GENERATION_POST_TYPES ] = $new_enabled_bulk_generation_post_types; } } // automated_generation_order (array) -> bulk_generation_order (string). if ( isset( $raw_settings['automated_generation_order'] ) && is_array( $raw_settings['automated_generation_order'] ) ) { // get first element of the array, set it as the new value. if ( count( $raw_settings['automated_generation_order'] ) >= 1 ) { $new_bulk_generation_order = reset( $raw_settings['automated_generation_order'] ); if ( $new_bulk_generation_order ) { $required_setting_migration_values[ AI4SEO_SETTING_BULK_GENERATION_ORDER ] = $new_bulk_generation_order; } } } // automated_generation_new_or_existing_filter (array) -> bulk_generation_new_or_existing_filter (string). if ( isset( $raw_settings['automated_generation_new_or_existing_filter'] ) && is_array( $raw_settings['automated_generation_new_or_existing_filter'] ) ) { // get first element of the array, set it as the new value. if ( count( $raw_settings['automated_generation_new_or_existing_filter'] ) >= 1 ) { $new_bulk_generation_new_or_existing_filter = reset( $raw_settings['automated_generation_new_or_existing_filter'] ); if ( $new_bulk_generation_new_or_existing_filter ) { $required_setting_migration_values[ AI4SEO_SETTING_BULK_GENERATION_NEW_OR_EXISTING_FILTER ] = $new_bulk_generation_new_or_existing_filter; } } } // automated_generation_new_or_existing_filter_reference_times (array) -> bulk_generation_new_or_existing_filter_reference_time (string) // environmental variable. if ( isset( $raw_environmental_variables['automated_generation_new_or_existing_filter_reference_times'] ) && is_array( $raw_environmental_variables['automated_generation_new_or_existing_filter_reference_times'] ) ) { // get first element of the array, set it as the new value. if ( count( $raw_environmental_variables['automated_generation_new_or_existing_filter_reference_times'] ) >= 1 ) { $new_bulk_generation_new_or_existing_filter_reference_time = reset( $raw_environmental_variables['automated_generation_new_or_existing_filter_reference_times'] ); if ( $new_bulk_generation_new_or_existing_filter_reference_time ) { if ( ! ai4seo_update_environmental_variable( AI4SEO_ENVIRONMENTAL_VARIABLE_BULK_GENERATION_NEW_OR_EXISTING_FILTER_REFERENCE_TIME, $new_bulk_generation_new_or_existing_filter_reference_time ) ) { return false; } } } } } if ( ! ai4seo_apply_plugin_update_setting_group( $required_setting_migration_values, $raw_settings ) ) { return ai4seo_plugin_update_step_result( 'blocked', 'settings_not_committed' ); } return true; }, $force ); $results[] = ai4seo_run_plugin_update_step( 'legacy_210', static function () use ( $last_known_plugin_version ) { // V2.1.0:. if ( $last_known_plugin_version && version_compare( $last_known_plugin_version, '2.1.0', '<' ) ) { // Remove old environmental variable "performance_notice_dismissed_time" and dismissed_one_time_notices, feature was removed in V2.1.0. ai4seo_delete_environmental_variable( 'performance_notice_dismissed_time' ); ai4seo_delete_environmental_variable( 'dismissed_one_time_notices' ); ai4seo_delete_environmental_variable( 'is_first_purchase_discount_available' ); ai4seo_delete_environmental_variable( 'early_bird_discount_time_left' ); // delete option "_ai4seo_plugin_activation_time" -> deprecated in V2.1.0. if ( ai4seo_get_option( '_ai4seo_plugin_activation_time' ) !== false ) { ai4seo_delete_option( '_ai4seo_plugin_activation_time' ); } } return true; }, $force ); $results[] = ai4seo_run_plugin_update_step( 'legacy_211', static function () use ( $last_known_plugin_version ) { // V2.1.1:. if ( $last_known_plugin_version && version_compare( $last_known_plugin_version, '2.1.1', '<' ) ) { // delete old robhub environmental variable last_credit_balance_check. ai4seo_robhub_api()->delete_environmental_variable( 'last_credit_balance_check' ); } return true; }, $force ); $results[] = ai4seo_run_plugin_update_step( 'settings_220', static function () use ( $last_known_plugin_version, $raw_settings ) { if ( null === $raw_settings ) { return ai4seo_plugin_update_step_result( 'blocked', 'settings_unreadable' ); } $required_setting_migration_values = array(); if ( $last_known_plugin_version && version_compare( $last_known_plugin_version, '2.2.0', '<' ) ) { // handle default visible meta tags setting change to new active meta tags setting. if ( ! isset( $raw_settings[ AI4SEO_SETTING_VISIBLE_META_TAGS ] ) || ! $raw_settings[ AI4SEO_SETTING_VISIBLE_META_TAGS ] ) { // old default value for visible meta tags. $required_setting_migration_values[ AI4SEO_SETTING_ACTIVE_META_TAGS ] = array( 'meta-title', 'meta-description', 'facebook-title', 'facebook-description' ); } // if visible meta tags were set, apply the same to active meta tags. if ( isset( $raw_settings[ AI4SEO_SETTING_VISIBLE_META_TAGS ] ) && $raw_settings[ AI4SEO_SETTING_VISIBLE_META_TAGS ] ) { $required_setting_migration_values[ AI4SEO_SETTING_ACTIVE_META_TAGS ] = $raw_settings[ AI4SEO_SETTING_VISIBLE_META_TAGS ]; } // set AI4SEO_SETTING_METADATA_FALLBACK_TWITTER_TITLE to 'facebook-title' and set AI4SEO_SETTING_METADATA_FALLBACK_TWITTER_DESCRIPTION to 'facebook-description'. $required_setting_migration_values[ AI4SEO_SETTING_METADATA_FALLBACK_TWITTER_TITLE ] = 'facebook-title'; $required_setting_migration_values[ AI4SEO_SETTING_METADATA_FALLBACK_TWITTER_DESCRIPTION ] = 'facebook-description'; } if ( ! ai4seo_apply_plugin_update_setting_group( $required_setting_migration_values, $raw_settings ) ) { return ai4seo_plugin_update_step_result( 'blocked', 'settings_not_committed' ); } return true; }, $force ); $results[] = ai4seo_run_plugin_update_step( 'settings_235', static function () use ( $last_known_plugin_version, $raw_settings ) { if ( null === $raw_settings ) { return ai4seo_plugin_update_step_result( 'blocked', 'settings_unreadable' ); } $required_setting_migration_values = array(); $should_start_active_metadata_migration = false; // V2.3.5: migrate legacy active metadata postmeta keys to one JSON postmeta entry per post. if ( $last_known_plugin_version && version_compare( $last_known_plugin_version, '2.3.5', '<' ) ) { // Preserve pre-2.3.5 external button behavior for existing clients. $required_setting_migration_values[ AI4SEO_SETTING_ENABLE_EXTERNAL_METADATA_GENERATE_BUTTONS ] = true; $required_setting_migration_values[ AI4SEO_SETTING_ENABLE_EXTERNAL_MEDIA_GENERATE_BUTTONS ] = true; if ( ! empty( $raw_settings[ AI4SEO_SETTING_DEEP_CONTEXT_SEARCH_FOR_IMAGES ] ) && ! ai4seo_is_deep_context_search_supported_for_current_site() ) { $required_setting_migration_values[ AI4SEO_SETTING_DEEP_CONTEXT_SEARCH_FOR_IMAGES ] = false; } $should_start_active_metadata_migration = true; } if ( ! ai4seo_apply_plugin_update_setting_group( $required_setting_migration_values, $raw_settings ) ) { return ai4seo_plugin_update_step_result( 'blocked', 'settings_not_committed' ); } if ( $should_start_active_metadata_migration ) { ai4seo_schedule_active_metadata_migration_v235_cron_job(); } return true; }, $force ); $results[] = ai4seo_run_plugin_update_step( 'settings_sync', static function () use ( $last_known_plugin_version, $raw_settings ) { if ( null === $raw_settings ) { return ai4seo_plugin_update_step_result( 'blocked', 'settings_unreadable' ); } $required_setting_migration_values = array(); // V2.4.4: Existing users keep their former third-party SEO sync behavior unless they chose a selection explicitly. if ( ai4seo_should_migrate_default_third_party_seo_plugin_sync( $last_known_plugin_version, $raw_settings ) ) { $required_setting_migration_values[ AI4SEO_SETTING_APPLY_CHANGES_TO_THIRD_PARTY_SEO_PLUGINS ] = AI4SEO_LEGACY_DEFAULT_THIRD_PARTY_SEO_PLUGIN_SYNC_IDENTIFIERS; } if ( ! ai4seo_apply_plugin_update_setting_group( $required_setting_migration_values, $raw_settings ) ) { return ai4seo_plugin_update_step_result( 'blocked', 'settings_not_committed' ); } return true; }, $force ); $results[] = ai4seo_run_plugin_update_step( 'settings_editor', static function () use ( $last_known_plugin_version, $raw_settings ) { if ( null === $raw_settings ) { return ai4seo_plugin_update_step_result( 'blocked', 'settings_unreadable' ); } $required_setting_migration_values = array(); // V2.4.4: Existing users keep the field-first editor experience unless they already chose a mode explicitly. if ( ai4seo_should_migrate_default_editor_view_mode( $last_known_plugin_version, $raw_settings ) ) { $required_setting_migration_values[ AI4SEO_SETTING_DEFAULT_EDITOR_VIEW_MODE ] = AI4SEO_EDITOR_VIEW_MODE_EDITOR; } if ( ! ai4seo_apply_plugin_update_setting_group( $required_setting_migration_values, $raw_settings ) ) { return ai4seo_plugin_update_step_result( 'blocked', 'settings_not_committed' ); } return true; }, $force ); $results[] = ai4seo_run_plugin_update_step( 'settings_240', static function () use ( $last_known_plugin_version, $raw_settings ) { if ( null === $raw_settings ) { return ai4seo_plugin_update_step_result( 'blocked', 'settings_unreadable' ); } $required_setting_migration_values = array(); // V2.4.0: Initialize prompt slider settings for upgraded sites with current-behavior defaults. if ( $last_known_plugin_version && version_compare( $last_known_plugin_version, '2.4.0', '<' ) ) { $prompt_slider_setting_migration_values = ai4seo_get_prompt_slider_setting_pre_240_migration_values( $raw_settings ); $required_setting_migration_values = array_replace( $required_setting_migration_values, $prompt_slider_setting_migration_values ); } if ( ! ai4seo_apply_plugin_update_setting_group( $required_setting_migration_values, $raw_settings ) ) { return ai4seo_plugin_update_step_result( 'blocked', 'settings_not_committed' ); } return true; }, $force ); if ( ! in_array( false, $results, true ) ) { $results[] = ai4seo_run_plugin_update_step( 'post_state_storage_v1', static function (): array { return ai4seo_migrate_post_state_storage_batch(); }, $force, true ); } if ( ! in_array( false, $results, true ) ) { // Derived title preparation has its own checkpoint and does not pause generation. $title_result = ai4seo_build_title_order_batch( 0.05 ); if ( 'completed' !== $title_result['status'] ) { ai4seo_schedule_background_job_checked( AI4SEO_TITLE_ORDER_CRON_HOOK, 20 ); } } return ! in_array( false, $results, true ) && ai4seo_run_plugin_update_step( 'initialization', static function (): bool { return true; }, $force ); } finally { ai4seo_release_database_advisory_lock( $lock ); } } /** * Redirects an updated SOOZ admin page once so its assets bypass browser and optimizer caches. * * @return void */ function ai4seo_refresh_plugin_page_assets_after_update() { // Tidy-up can run through more than one bootstrap path, but the page must redirect only once. if ( ! ai4seo_singleton( __FUNCTION__ ) ) { return; } // Reuse the current-request URL helper so the active SOOZ subpage and its state are preserved. $current_url = ai4seo_get_current_prohibit_plugin_request_url(); if ( '' === $current_url ) { return; } // A timestamp changes only this request's asset version while the normal plugin version remains stable. $refresh_url = ai4seo_add_query_arg( AI4SEO_ASSET_REFRESH_QUERY_PARAMETER, time(), $current_url ); // Prevent intermediate caches from retaining the redirect response itself. nocache_headers(); wp_safe_redirect( $refresh_url ); exit; } /** * Validate and normalize one legacy generated-data cache row before any migration write occurs. * * @param mixed $source_row Raw database row. * @return array|null Validated row details, or null when the source row cannot be migrated losslessly. */ function ai4seo_validate_legacy_cache_migration_row( $source_row ): ?array { if ( ! is_array( $source_row ) || ! array_key_exists( 'id', $source_row ) || ! array_key_exists( 'post_id', $source_row ) || ! array_key_exists( 'data', $source_row ) || ! is_scalar( $source_row['id'] ) || ! is_numeric( $source_row['id'] ) || ! is_scalar( $source_row['post_id'] ) || ! is_numeric( $source_row['post_id'] ) || ! is_string( $source_row['data'] ) ) { return null; } $source_row_id = (int) $source_row['id']; $post_id = (int) $source_row['post_id']; if ( $source_row_id < 1 || (float) $source_row['id'] !== (float) $source_row_id || $post_id < 1 || (float) $source_row['post_id'] !== (float) $post_id ) { return null; } $decoded_data = json_decode( $source_row['data'], true ); if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $decoded_data ) ) { return null; } $supported_fields = ai4seo_get_supported_generated_data_field_identifiers(); $legacy_alias_destinations = array( 'social-media-title' => array( 'facebook-title', 'twitter-title', ), 'social-media-description' => array( 'facebook-description', 'twitter-description', ), ); $generated_data = array(); foreach ( $decoded_data as $field_identifier => $field_value ) { if ( ! is_string( $field_identifier ) || ( ! is_string( $field_value ) && ! is_scalar( $field_value ) ) ) { return null; } $normalized_field_identifier = sanitize_key( $field_identifier ); if ( '' === $normalized_field_identifier || 'generated_at' === $normalized_field_identifier || 'generated_at_by_field' === $normalized_field_identifier || array_key_exists( $normalized_field_identifier, $generated_data ) || ( ! in_array( $normalized_field_identifier, $supported_fields, true ) && ! array_key_exists( $normalized_field_identifier, $legacy_alias_destinations ) ) ) { return null; } $generated_data[ $normalized_field_identifier ] = ai4seo_deep_sanitize( $field_value ); } $build_succeeded = false; $expected_details = ai4seo_build_generated_data_details_for_save( array( 'generated_data' => array(), 'generated_at' => 0, 'generated_at_by_field' => array(), ), $generated_data, false, 0, array(), $build_succeeded ); if ( ! $build_succeeded || ! is_array( $expected_details['generated_data'] ?? null ) ) { return null; } $expected_field_states = array(); foreach ( array_keys( $generated_data ) as $field_identifier ) { $canonical_destinations = $legacy_alias_destinations[ $field_identifier ] ?? array( $field_identifier ); foreach ( $canonical_destinations as $canonical_destination ) { $expected_field_states[ $canonical_destination ] = array_key_exists( $canonical_destination, $expected_details['generated_data'] ) ? $expected_details['generated_data'][ $canonical_destination ] : null; } } return array( 'source_row_id' => $source_row_id, 'source_post_id' => $post_id, 'source_data' => $source_row['data'], 'post_id' => $post_id, 'generated_data' => $generated_data, 'expected_field_states' => $expected_field_states, ); } /** * Authoritatively classifies legacy-cache post references before any batch mutation. * * A missing posts row is a verified orphan and does not need a destination write. Query failures * and any malformed, duplicate, or out-of-scope result make the complete source batch retryable. * * @param array $validated_rows Fully validated legacy-cache source rows. * @return array|null Existing post IDs, or null when storage could not be classified safely. */ function ai4seo_read_legacy_cache_migration_existing_post_ids( array $validated_rows ): ?array { global $wpdb; $source_post_ids = array(); foreach ( $validated_rows as $validated_row ) { if ( ! is_array( $validated_row ) || ! isset( $validated_row['post_id'] ) || ! is_int( $validated_row['post_id'] ) || $validated_row['post_id'] <= 0 ) { return null; } $source_post_ids[ $validated_row['post_id'] ] = $validated_row['post_id']; } $source_post_ids = array_values( $source_post_ids ); if ( ! $source_post_ids ) { return array(); } $existing_post_ids = array(); $database_chunk_size = ai4seo_get_database_chunk_size(); $source_post_id_chunks = array_chunk( $source_post_ids, $database_chunk_size ); foreach ( $source_post_id_chunks as $source_post_id_chunk ) { $expected_post_id_lookup = array_fill_keys( $source_post_id_chunk, true ); $post_ids_query = ai4seo_prepare_database_query( 'SELECT ID FROM {{posts_table}} WHERE ID IN ({{post_ids}}) ORDER BY ID ASC', array( 'posts_table' => ai4seo_database_identifier_binding( 'table.posts' ), 'post_ids' => ai4seo_database_list_binding( '%d', $source_post_id_chunk ), ) ); if ( false === $post_ids_query ) { return null; } $wpdb->last_error = ''; // The typed query compiler prepared this bounded authoritative existence read. // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $this_existing_post_ids = $wpdb->get_col( $post_ids_query ); if ( $wpdb->last_error || ! is_array( $this_existing_post_ids ) ) { return null; } foreach ( $this_existing_post_ids as $this_existing_post_id ) { $this_existing_post_id = ai4seo_normalize_database_id( $this_existing_post_id ); if ( false === $this_existing_post_id || ! isset( $expected_post_id_lookup[ $this_existing_post_id ] ) || isset( $existing_post_ids[ $this_existing_post_id ] ) ) { return null; } $existing_post_ids[ $this_existing_post_id ] = $this_existing_post_id; } } return array_values( $existing_post_ids ); } /** * Migrate one bounded batch from the legacy ai4seo_cache table. * * Successfully verified source rows are removed after the complete batch persists. The table is * dropped only after a separate remaining-row probe proves that no source rows remain. * * @param int $batch_size Maximum number of legacy rows to process in this request. * @param array|null $progress Opt-in tolerant scan checkpoint; null preserves strict callers. * @return array { * Migration result. * * @type bool $success Whether this batch avoided a source or persistence failure. * @type bool $complete Whether the legacy table no longer requires migration. * @type string $status Stable result status identifier. * @type int $processed_rows Number of rows successfully migrated or classified as verified orphans. * @type int $failed_row_id Source row ID associated with a failure, when known. * } */ function ai4seo_tidy_up_old_ai4seo_cache_table( int $batch_size = 250, ?array $progress = null ): array { global $wpdb; $migration_result = array( 'success' => false, 'complete' => false, 'status' => 'source_read_failed', 'processed_rows' => 0, 'failed_row_id' => 0, ); $cursor = null !== $progress ? $progress['cursor'] : 0; $migration_result['cursor'] = $cursor; $migration_result['issues'] = $cursor > 0 ? $progress['issues'] : 0; $migration_result['examples'] = $cursor > 0 ? $progress['examples'] : array(); $last_source_id = $cursor; $legacy_table_name = "{$wpdb->prefix}ai4seo_cache"; $batch_size = max( 1, min( 250, $batch_size ) ); $wpdb->last_error = ''; // Escape LIKE wildcards in the WordPress prefix so only the exact legacy table satisfies the probe. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- One exact schema probe determines whether migration work exists. $table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $legacy_table_name ) ) ); if ( $wpdb->last_error ) { ai4seo_debug_message( 984321667, 'Database error: ' . $wpdb->last_error, true ); return ai4seo_finish_legacy_cache_update_batch( $migration_result, $progress ); } if ( $legacy_table_name !== $table_exists ) { $migration_result['success'] = true; $migration_result['complete'] = true; $migration_result['status'] = 'not_required'; return ai4seo_finish_legacy_cache_update_batch( $migration_result, $progress ); } // Only resumable updates apply this setting, after verifying that migration work exists. if ( null !== $progress && ai4seo_get_setting( AI4SEO_SETTING_DISABLE_HEAVY_DB_OPERATIONS ) ) { $migration_result['status'] = 'heavy_operations_disabled'; $migration_result['step'] = ai4seo_plugin_update_step_result( 'blocked', 'heavy_operations_disabled', $progress['cursor'], $progress['issues'], $progress['examples'] ); return $migration_result; } $wpdb->last_error = ''; // The internal table identifier cannot be prepared; LIMIT remains a validated integer placeholder. // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- The plugin-owned legacy table name is internal. // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- This bounded migration must read its plugin-owned legacy source directly. $source_rows = $wpdb->get_results( $wpdb->prepare( "SELECT id, post_id, data FROM `{$legacy_table_name}` WHERE id > %d ORDER BY id ASC LIMIT %d", $cursor, $batch_size ), ARRAY_A ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared if ( $wpdb->last_error || ! is_array( $source_rows ) ) { ai4seo_debug_message( 984321668, 'Database error: ' . $wpdb->last_error, true ); return ai4seo_finish_legacy_cache_update_batch( $migration_result, $progress ); } $validated_rows = array(); // Validate the complete batch first so one malformed row cannot leave earlier source rows partially drained. foreach ( $source_rows as $source_row ) { if ( null !== $progress ) { $source_id = ai4seo_normalize_database_id( $source_row['id'] ?? null ); if ( false === $source_id || $source_id <= $last_source_id ) { $migration_result['status'] = 'invalid_identity'; return ai4seo_finish_legacy_cache_update_batch( $migration_result, $progress ); } $last_source_id = $source_id; } $validated_row = ai4seo_validate_legacy_cache_migration_row( $source_row ); if ( null === $validated_row ) { if ( null !== $progress ) { ++$migration_result['issues']; $migration_result['examples'] = array_slice( array_merge( $migration_result['examples'], array( $last_source_id ) ), 0, 20 ); continue; } $migration_result['status'] = 'invalid_row'; $migration_result['failed_row_id'] = absint( is_array( $source_row ) ? ( $source_row['id'] ?? 0 ) : 0 ); return ai4seo_finish_legacy_cache_update_batch( $migration_result, $progress ); } if ( null !== $progress ) { $target = ai4seo_read_authoritative_generated_data_postmeta_snapshot( $validated_row['post_id'], $target_read, $target_failure ); if ( ! $target_read && 'damaged_history' !== $target_failure ) { $migration_result['status'] = 'verification_read_failed'; return ai4seo_finish_legacy_cache_update_batch( $migration_result, $progress ); } $conflict = ! $target_read; foreach ( $validated_row['expected_field_states'] as $field => $value ) { if ( array_key_exists( $field, $target['generated_data_details']['generated_data'] ) && $value !== $target['generated_data_details']['generated_data'][ $field ] ) { $conflict = true; } } if ( $conflict ) { ++$migration_result['issues']; $migration_result['examples'] = array_slice( array_merge( $migration_result['examples'], array( $last_source_id ) ), 0, 20 ); continue; } $validated_row['target_raw'] = $target['raw_value']; } $validated_rows[] = $validated_row; } $existing_post_ids = ai4seo_read_legacy_cache_migration_existing_post_ids( $validated_rows ); if ( null === $existing_post_ids ) { $migration_result['status'] = 'post_read_failed'; return ai4seo_finish_legacy_cache_update_batch( $migration_result, $progress ); } $existing_post_id_lookup = array_fill_keys( $existing_post_ids, true ); if ( null !== $progress ) { foreach ( $validated_rows as $index => $validated_row ) { if ( ! isset( $existing_post_id_lookup[ $validated_row['post_id'] ] ) ) { ++$migration_result['issues']; $migration_result['examples'] = array_slice( array_merge( $migration_result['examples'], array( $validated_row['source_row_id'] ) ), 0, 20 ); unset( $validated_rows[ $index ] ); } } } foreach ( $validated_rows as $validated_row ) { // A source row whose post is authoritatively absent has no valid destination and can be drained safely. if ( ! isset( $existing_post_id_lookup[ $validated_row['post_id'] ] ) ) { ++$migration_result['processed_rows']; continue; } $operation_details = array(); $write_succeeded = ai4seo_save_generated_data_to_postmeta( $validated_row['post_id'], $validated_row['generated_data'], false, 0, array(), $operation_details, null !== $progress ? array( 'legacy_cache_source' => $validated_row ) : array() ); $commit_state = is_string( $operation_details['commit_state'] ?? null ) ? $operation_details['commit_state'] : 'not_committed'; if ( ! $write_succeeded || 'committed' !== $commit_state ) { $migration_result['status'] = 'possibly_committed' === $commit_state ? 'write_possibly_committed' : 'write_failed'; $migration_result['failed_row_id'] = $validated_row['source_row_id']; return ai4seo_finish_legacy_cache_update_batch( $migration_result, $progress ); } $read_succeeded = false; $snapshot = ai4seo_read_authoritative_generated_data_postmeta_snapshot( $validated_row['post_id'], $read_succeeded ); if ( ! $read_succeeded || ! is_array( $snapshot['generated_data_details']['generated_data'] ?? null ) ) { $migration_result['status'] = 'verification_read_failed'; $migration_result['failed_row_id'] = $validated_row['source_row_id']; return ai4seo_finish_legacy_cache_update_batch( $migration_result, $progress ); } $stored_generated_data = $snapshot['generated_data_details']['generated_data']; foreach ( $validated_row['expected_field_states'] as $field_identifier => $expected_field_value ) { $field_exists = array_key_exists( $field_identifier, $stored_generated_data ); if ( ( null === $expected_field_value && $field_exists ) || ( null !== $expected_field_value && ( ! $field_exists || $expected_field_value !== $stored_generated_data[ $field_identifier ] ) ) ) { $migration_result['status'] = 'verification_failed'; $migration_result['failed_row_id'] = $validated_row['source_row_id']; return ai4seo_finish_legacy_cache_update_batch( $migration_result, $progress ); } } ++$migration_result['processed_rows']; } if ( $validated_rows ) { if ( null !== $progress && ! ai4seo_is_database_advisory_lock_owned_by_current_connection( ai4seo_get_plugin_update_lock_name() ) ) { $migration_result['status'] = 'lock_lost'; return ai4seo_finish_legacy_cache_update_batch( $migration_result, $progress ); } $source_row_predicates = array(); $source_row_values = array(); foreach ( $validated_rows as $validated_row ) { $source_row_predicates[] = '(id = %d AND post_id = %d AND BINARY data = BINARY %s)'; $source_row_values[] = $validated_row['source_row_id']; $source_row_values[] = $validated_row['source_post_id']; $source_row_values[] = $validated_row['source_data']; } $wpdb->last_error = ''; // Delete only exact, unchanged source snapshots whose complete batch was written and verified above. // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Internal identifier and generated predicates are paired with validated source snapshots. // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- The identifier and predicate list are internal and every bound source value was read and validated above. // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Dynamic predicates contain only fixed placeholders and are prepared with validated source values below. $delete_result = $wpdb->query( $wpdb->prepare( "DELETE FROM `{$legacy_table_name}` WHERE " . implode( ' OR ', $source_row_predicates ), $source_row_values ) ); // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare if ( count( $validated_rows ) !== $delete_result || $wpdb->last_error ) { $migration_result['status'] = 'source_cleanup_failed'; return ai4seo_finish_legacy_cache_update_batch( $migration_result, $progress ); } } $migration_result['cursor'] = $last_source_id; $wpdb->last_error = ''; // Always probe after cleanup; a full-size final batch is indistinguishable from an incomplete batch by count alone. // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Exact bounded completion probe on the internal legacy table. $remaining_source_row_id = $wpdb->get_var( "SELECT id FROM `{$legacy_table_name}` ORDER BY id ASC LIMIT 1" ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching if ( $wpdb->last_error ) { $migration_result['status'] = 'completion_probe_failed'; return ai4seo_finish_legacy_cache_update_batch( $migration_result, $progress ); } if ( null !== $progress && null !== $remaining_source_row_id && count( $source_rows ) < $batch_size ) { $migration_result['success'] = true; $migration_result['status'] = $migration_result['issues'] > 0 ? 'blocked' : 'incomplete'; $migration_result['cursor'] = 0; return ai4seo_finish_legacy_cache_update_batch( $migration_result, $progress ); } if ( null !== $remaining_source_row_id ) { $migration_result['success'] = true; $migration_result['status'] = 'incomplete'; return ai4seo_finish_legacy_cache_update_batch( $migration_result, $progress ); } $wpdb->last_error = ''; // The plugin-owned table is removed only after an exact empty-source probe. // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange -- Verified completion makes the legacy schema disposable. $drop_result = $wpdb->query( "DROP TABLE IF EXISTS `{$legacy_table_name}`" ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange if ( false === $drop_result || $wpdb->last_error ) { ai4seo_debug_message( 984321669, 'Database error: ' . $wpdb->last_error, true ); $migration_result['status'] = 'table_drop_failed'; return ai4seo_finish_legacy_cache_update_batch( $migration_result, $progress ); } $migration_result['success'] = true; $migration_result['complete'] = true; $migration_result['status'] = 'completed'; return ai4seo_finish_legacy_cache_update_batch( $migration_result, $progress ); } /** * Function to init cron jobs * * @return void */ function ai4seo_init_cron_jobs() { if ( ! ai4seo_singleton( __FUNCTION__ ) ) { return; } try { if ( ! ai4seo_robhub_api( true ) || ! ai4seo_robhub_api()->is_initialized ) { return; } } catch ( Throwable $e ) { return; } if ( ai4seo_does_user_need_to_accept_tos_toc_and_pp() ) { return; } // Add custom cron schedule. // phpcs:ignore WordPress.WP.CronInterval.ChangeDetected -- The callback defines the intentional 300- and 3600-second intervals. add_filter( 'cron_schedules', 'ai4seo_add_cron_job_intervals' ); // add cron jobs to automate content generation. add_action( AI4SEO_BULK_GENERATION_CRON_JOB_NAME, AI4SEO_BULK_GENERATION_CRON_JOB_NAME ); // add cron jobs to analyze current state of the plugins performance. add_action( AI4SEO_ANALYSE_PLUGIN_PERFORMANCE_CRON_JOB_NAME, AI4SEO_ANALYSE_PLUGIN_PERFORMANCE_CRON_JOB_NAME ); // add temporary cron job for the v235 active metadata migration. add_action( AI4SEO_ACTIVE_METADATA_MIGRATION_V235_CRON_JOB_NAME, AI4SEO_ACTIVE_METADATA_MIGRATION_V235_CRON_JOB_NAME ); // schedule cron jobs if not already scheduled. ai4seo_schedule_cron_jobs(); } // phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Preserve the admin_title callback contract. /** * Adjusts the browser title for all AI for SEO plugin admin pages. * * Uses ai4seo_is_user_inside_our_plugin_admin_pages(), ai4seo_get_active_subpage(), * ai4seo_get_active_post_type(), and ai4seo_get_plugins_menu_registry() * to determine the current page label. * * @param string $admin_title Default admin title. * @param string $title Original page title. * @return string Filtered admin title. */ function ai4seo_filter_admin_title( string $admin_title, string $title ): string { // phpcs:enable Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed if ( ! ai4seo_singleton( __FUNCTION__ ) ) { return false; } if ( ! ai4seo_is_user_inside_our_plugin_admin_pages() ) { return $admin_title; } $registry = ai4seo_get_plugins_menu_registry(); $active_subpage = ai4seo_get_active_subpage(); $active_post_type_subpage = ai4seo_get_active_post_type_subpage(); // default label. $active_page_label = __( 'Dashboard', 'ai-for-seo' ); if ( ! empty( $active_subpage ) && isset( $registry[ $active_subpage ] ) ) { // Static subpages: settings, media, account, help. $active_page_label = $registry[ $active_subpage ]['label']; } elseif ( 'post' === $active_subpage && ! empty( $active_post_type_subpage ) && isset( $registry['post_types'][ $active_post_type_subpage ] ) ) { // Dynamic post-type subpages. $active_page_label = $registry['post_types'][ $active_post_type_subpage ]['label']; } elseif ( 'post' === $active_subpage && AI4SEO_TAXONOMY_PROXY_POST_TYPE === $active_post_type_subpage ) { // Taxonomies have independent navigation identities rather than a shared post-type label. // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only title selection from the authorized registry. $taxonomy = isset( $_GET['ai4seo_taxonomy'] ) && is_string( $_GET['ai4seo_taxonomy'] ) ? sanitize_key( wp_unslash( $_GET['ai4seo_taxonomy'] ) ) : (string) array_key_first( $registry['taxonomies'] ); $active_page_label = $registry['taxonomies'][ $taxonomy ]['label'] ?? __( 'Taxonomies', 'ai-for-seo' ); } $website_name = get_bloginfo( 'name' ); // build everything together and sanitize. $browser_title = $active_page_label . ' ‹ ' . AI4SEO_PLUGIN_NAME . ' ‹ ' . $website_name; $browser_title = wp_strip_all_tags( $browser_title ); $browser_title = str_replace( array( '&', '&' ), '&', $browser_title ); $browser_title = str_replace( array( '<', '<' ), '<', $browser_title ); $browser_title = str_replace( array( '>', '>' ), '>', $browser_title ); $browser_title = str_replace( array( '"', '"' ), '"', $browser_title ); $browser_title = str_replace( array( ''', ''' ), "'", $browser_title ); return $browser_title; } /** * Add a scoped admin body class on AI for SEO admin screens. * * @param string $classes Admin body classes. * @return string Filtered admin body classes. */ function ai4seo_add_admin_body_class( string $classes ): string { // Scope moved WordPress-admin layout rules to plugin screens only. if ( ai4seo_is_user_inside_our_plugin_admin_pages() ) { $classes .= ' ai4seo-admin-page'; } return $classes; } /** * Get the localized label for the media navigation item. * * @return string */ function ai4seo_get_media_menu_label(): string { // Use WordPress' registered attachment label so customized admin wording is reflected in AI4SEO navigation. $attachment_post_type = get_post_type_object( 'attachment' ); if ( $attachment_post_type && ! empty( $attachment_post_type->labels->name ) ) { return $attachment_post_type->labels->name; } return _x( 'Media', 'plugin navigation menu label', 'ai-for-seo' ); } /** * Build a registry of AI4SEO menu entries: labels and slugs. * * @return array{ * dashboard: array{label:string, slug:string}, * media: array{label:string, slug:string}, * account: array{label:string, slug:string}, * settings: array{label:string, slug:string}, * help: array{label:string, slug:string}, * post_types: array, * taxonomies: array * } */ function ai4seo_get_plugins_menu_registry(): array { if ( ai4seo_prevent_loops( __FUNCTION__ ) ) { ai4seo_debug_message( 925974828, 'Prevented loop', true ); return array(); } // Static pages. $dashboard_slug = str_replace( '?page=', '', ai4seo_get_subpage_url( 'dashboard', array(), false ) ); // typically 'ai-for-seo'. $settings_slug = str_replace( '?page=', '', ai4seo_get_subpage_url( 'settings', array(), false ) ); // e.g. 'ai-for-seo&ai4seo_subpage=settings'. $media_slug = str_replace( '?page=', '', ai4seo_get_subpage_url( 'media', array(), false ) ); // e.g. 'ai-for-seo&ai4seo_subpage=media'. $account_slug = str_replace( '?page=', '', ai4seo_get_subpage_url( 'account', array(), false ) ); // e.g. 'ai-for-seo&ai4seo_subpage=account'. $help_slug = str_replace( '?page=', '', ai4seo_get_subpage_url( 'help', array(), false ) ); // e.g. 'ai-for-seo&ai4seo_subpage=help'. // Dynamic post-type pages: use a stable slug (no pagination or other volatile args). $post_types = ai4seo_get_supported_post_types(); $post_type_map = array(); foreach ( $post_types as $this_post_type ) { $this_post_type = sanitize_key( $this_post_type ); $label = ai4seo_get_nice_label( ai4seo_get_post_type_translation( $this_post_type, true ) ); $slug = AI4SEO_PLUGIN_IDENTIFIER . '&ai4seo_subpage=post&ai4seo_post_type=' . $this_post_type; $post_type_map[ $this_post_type ] = array( 'label' => $label, 'slug' => $slug, ); } return array( 'dashboard' => array( 'label' => __( 'Dashboard', 'ai-for-seo' ), 'slug' => $dashboard_slug, ), 'media' => array( 'label' => ai4seo_get_media_menu_label(), 'slug' => $media_slug, ), 'account' => array( 'label' => __( 'Account', 'ai-for-seo' ), 'slug' => $account_slug, ), 'settings' => array( 'label' => __( 'Settings', 'ai-for-seo' ), 'slug' => $settings_slug, ), 'help' => array( 'label' => __( 'Help', 'ai-for-seo' ), 'slug' => $help_slug, ), 'post_types' => $post_type_map, 'taxonomies' => ai4seo_get_taxonomy_content_navigation_entries(), ); } /** * Group registered content links without changing their labels, routes or eligibility. * * @param array $menu_registry Existing plugin menu registry. * @return array */ function ai4seo_get_content_navigation_groups( array $menu_registry ): array { $post_types = $menu_registry['post_types'] ?? array(); $groups = array( 'primary' => array( 'label' => '', 'post_types' => array(), ), 'taxonomies' => array( 'label' => __( 'Taxonomies', 'ai-for-seo' ), 'post_types' => array(), ), 'other_post_types' => array( 'label' => __( 'Other Post Types', 'ai-for-seo' ), 'post_types' => array(), ), ); // Media has its own route even when attachment attributes are currently disabled. foreach ( ai4seo_get_supported_attachment_post_types( false ) as $attachment_post_type ) { unset( $post_types[ $attachment_post_type ] ); } foreach ( array( 'page', 'post', 'product' ) as $post_type ) { if ( isset( $post_types[ $post_type ] ) ) { $groups['primary']['post_types'][ $post_type ] = $post_types[ $post_type ]; unset( $post_types[ $post_type ] ); } } $groups['taxonomies']['post_types'] = array_filter( $menu_registry['taxonomies'] ?? array(), static function ( $entry ): bool { return false !== ( $entry['has_entries'] ?? true ); } ); unset( $post_types[ AI4SEO_TAXONOMY_PROXY_POST_TYPE ] ); $groups['other_post_types']['post_types'] = $post_types; return $groups; } /** * Register AI4SEO menu and submenus. * * @return void */ function ai4seo_add_menu_entries() { if ( ! ai4seo_singleton( __FUNCTION__ ) ) { return; } // Register only the navigation branches reachable through the current user's explicit boundary. $can_use_plugin_content = ai4seo_can_use_plugin_content(); $can_administer_plugin = ai4seo_can_administer_plugin(); $can_recover_incognito = ai4seo_can_recover_incognito_mode(); if ( ! $can_use_plugin_content && ! $can_administer_plugin && ! $can_recover_incognito ) { return; } $svg_tags = ai4seo_get_svg_tags(); if ( ! isset( $svg_tags['ai-for-seo-main-menu-icon'] ) ) { return; } // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Base64 is the required transport encoding for the benign SVG data URI. $encoded_svg = 'data:image/svg+xml;base64,' . base64_encode( $svg_tags['ai-for-seo-main-menu-icon'] ); // The shared stored count may reflect another role's offer visibility, so refresh it before rendering menus. if ( ai4seo_get_setting( AI4SEO_SETTING_SHOW_MENU_COUNTERS ) ) { ai4seo_refresh_unread_notifications_count(); } // Append the unread-notification count to the normal menu title. $menu_title = AI4SEO_PLUGIN_NAME; $notification_count = ai4seo_get_setting( AI4SEO_SETTING_SHOW_MENU_COUNTERS ) ? ai4seo_get_num_unread_notification() : 0; if ( $notification_count > 0 ) { // The badge class keeps the WordPress menu count aligned while the plugin menu label remains long. $menu_title .= " {$notification_count}"; } // Central registry for labels and slugs. $plugins_menu_registries = ai4seo_get_plugins_menu_registry(); $content_navigation = ai4seo_get_content_navigation_groups( $plugins_menu_registries ); // WordPress grants the exist meta capability to every authenticated user; explicit gates above decide registration. $authenticated_user_menu_capability = 'exist'; // Collect the hook suffixes returned by WordPress so page-specific notice suppression covers every SOOZ screen. $ai4seo_menu_hook_suffixes = array(); // Top-level. $ai4seo_menu_hook_suffixes[] = add_menu_page( AI4SEO_PLUGIN_NAME, $menu_title, // Contains markup for bubble. Keep as-is. $authenticated_user_menu_capability, AI4SEO_PLUGIN_IDENTIFIER, 'ai4seo_include_menu_frame_file', $encoded_svg, 99 ); if ( $can_use_plugin_content ) { // Dashboard (main page uses parent slug as submenu slug). $ai4seo_menu_hook_suffixes[] = add_submenu_page( AI4SEO_PLUGIN_IDENTIFIER, $plugins_menu_registries['dashboard']['label'] . ' - ' . AI4SEO_PLUGIN_NAME, $plugins_menu_registries['dashboard']['label'], $authenticated_user_menu_capability, AI4SEO_PLUGIN_IDENTIFIER, 'ai4seo_include_menu_frame_file' ); // Media precedes the three primary post types in both navigation surfaces. $ai4seo_menu_hook_suffixes[] = add_submenu_page( AI4SEO_PLUGIN_IDENTIFIER, $plugins_menu_registries['media']['label'] . ' - ' . AI4SEO_PLUGIN_NAME, $plugins_menu_registries['media']['label'], $authenticated_user_menu_capability, $plugins_menu_registries['media']['slug'], 'ai4seo_include_menu_frame_file' ); // Grouped content stays reachable through the root callback without native submenu entries. foreach ( $content_navigation['primary']['post_types'] as $this_post_type ) { $ai4seo_menu_hook_suffixes[] = add_submenu_page( AI4SEO_PLUGIN_IDENTIFIER, $this_post_type['label'] . ' - ' . AI4SEO_PLUGIN_NAME, $this_post_type['label'], $authenticated_user_menu_capability, $this_post_type['slug'], 'ai4seo_include_menu_frame_file' ); } } // Account. if ( $can_administer_plugin || $can_recover_incognito ) { $ai4seo_menu_hook_suffixes[] = add_submenu_page( AI4SEO_PLUGIN_IDENTIFIER, $plugins_menu_registries['account']['label'] . ' - ' . AI4SEO_PLUGIN_NAME, $plugins_menu_registries['account']['label'], 'manage_options', $plugins_menu_registries['account']['slug'], 'ai4seo_include_menu_frame_file' ); } // Settings. if ( $can_administer_plugin ) { $ai4seo_menu_hook_suffixes[] = add_submenu_page( AI4SEO_PLUGIN_IDENTIFIER, $plugins_menu_registries['settings']['label'] . ' - ' . AI4SEO_PLUGIN_NAME, $plugins_menu_registries['settings']['label'], 'manage_options', $plugins_menu_registries['settings']['slug'], 'ai4seo_include_menu_frame_file' ); } // Help. if ( $can_use_plugin_content ) { $ai4seo_menu_hook_suffixes[] = add_submenu_page( AI4SEO_PLUGIN_IDENTIFIER, $plugins_menu_registries['help']['label'] . ' - ' . AI4SEO_PLUGIN_NAME, $plugins_menu_registries['help']['label'], $authenticated_user_menu_capability, $plugins_menu_registries['help']['slug'], 'ai4seo_include_menu_frame_file' ); } // Register suppression after all menu entries are known so dynamic post-type pages follow the same path. foreach ( $ai4seo_menu_hook_suffixes as $this_hook_suffix ) { ai4seo_register_plugin_admin_notice_suppression( (string) $this_hook_suffix ); } } /** * Mark our top-level menu as current when any AI for SEO page is open. * * @param string|null $parent_file Current top-level menu slug. * @return string|null The slug of the current top-level menu (if any). */ function ai4seo_mark_parent_menu_active( ?string $parent_file ): ?string { if ( ! ai4seo_singleton( __FUNCTION__ ) ) { return ''; } if ( ai4seo_is_user_inside_our_plugin_admin_pages() ) { $parent_file = AI4SEO_PLUGIN_IDENTIFIER; } return $parent_file; } /** * Mark the correct submenu entry as current. * * @param string|null $submenu_file Current submenu slug. * @return string|null The slug of the current submenu entry (if any). */ function ai4seo_mark_submenu_active( ?string $submenu_file ): ?string { if ( ! ai4seo_singleton( __FUNCTION__ ) ) { return ''; } if ( ai4seo_is_user_inside_our_plugin_admin_pages() ) { // Resolve post-type routes before generic subpages so WordPress highlights the most specific menu entry. $plugins_menu_registries = ai4seo_get_plugins_menu_registry(); $active_post_type = ai4seo_get_active_post_type_subpage(); if ( $active_post_type ) { // A grouped route keeps its own slug so WordPress does not highlight Dashboard as a fallback. $submenu_file = AI4SEO_TAXONOMY_PROXY_POST_TYPE === $active_post_type ? AI4SEO_PLUGIN_IDENTIFIER . '&ai4seo_subpage=post&ai4seo_post_type=' . AI4SEO_TAXONOMY_PROXY_POST_TYPE : ( $plugins_menu_registries['post_types'][ $active_post_type ]['slug'] ?? $submenu_file ); } else { $active_subpage = ai4seo_get_active_subpage(); if ( $active_subpage ) { $submenu_file = $plugins_menu_registries[ $active_subpage ]['slug'] ?? $submenu_file; } else { $submenu_file = AI4SEO_PLUGIN_IDENTIFIER; } } } return $submenu_file; } /** * Function to display the menu frame * * @return void */ function ai4seo_include_menu_frame_file() { if ( ! ai4seo_singleton( __FUNCTION__ ) ) { return; } include_once ai4seo_get_plugin_dir_path( 'includes/menu-frame.php' ); } /** * Function to add modal schemas to the footer * * @return void */ function ai4seo_include_modal_schemas_file() { // Only replacement of this plugin invalidates its request-local deferred runtime. if ( ai4seo_is_deferred_modal_loading_suspended_for_self_update() ) { return; } // Modal schemas accompany the primary bundle, so unrelated requests skip even the schema autoloader. if ( ! ai4seo_get_primary_asset_contexts() ) { return; } if ( ! ai4seo_singleton( __FUNCTION__ ) ) { return; } include_once ai4seo_get_includes_modal_schemas_path( 'autoload-modal-schemas.php' ); } /** * Enqueue frontend scripts required for render-level media injection. * * @return void */ function ai4seo_enqueue_frontend_scripts() { global $ai4seo_scripts_version_number; // prevent multiple calls of this function. if ( ! ai4seo_singleton( __FUNCTION__ ) ) { return; } // check if we are outside the admin area. if ( is_admin() ) { return; } // Enqueue ai-for-seo-alt-text-injection. $is_render_level_alt_text_enabled = ai4seo_get_setting( AI4SEO_SETTING_ENABLE_RENDER_LEVEL_ALT_TEXT_INJECTION ); $is_js_alt_text_enabled = ai4seo_get_setting( AI4SEO_SETTING_ENABLE_JS_ALT_TEXT_INJECTION ); if ( $is_render_level_alt_text_enabled && $is_js_alt_text_enabled ) { wp_enqueue_script( AI4SEO_INJECTION_SCRIPTS_HANDLE, ai4seo_get_assets_js_path( AI4SEO_INJECTION_SCRIPTS_FILE_NAME ), array( 'jquery' ), $ai4seo_scripts_version_number, true ); } } /** * Keeps JavaScript translation JSON lookup stable while the physical script filename changes per release. * * @param string|false $relative_path Script path relative to the plugin directory. * @return string|false Translation source path. */ function ai4seo_use_stable_script_translation_source_path( $relative_path ) { if ( 'assets/js/' . AI4SEO_SCRIPTS_FILE_NAME === $relative_path ) { return 'assets/js/ai-for-seo-scripts.js'; } return $relative_path; } /** * Returns the one-time asset version only for authorized SOOZ admin page requests. * * Keeping this query parameter out of public requests prevents arbitrary frontend cache variants * while preserving the post-update refresh for the plugin page that issued it. * * @return int */ function ai4seo_get_plugin_admin_asset_refresh_version(): int { // The refresh value is meaningful only where the plugin's primary admin assets are enqueued. if ( ! is_admin() || ( ! ai4seo_can_use_plugin_content() && ! ai4seo_can_administer_plugin() && ! ai4seo_can_recover_incognito_mode() ) || ! ai4seo_is_user_inside_our_plugin_admin_pages() ) { return 0; } // Update redirects generate a positive timestamp; all other values retain normal plugin versioning. // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Authorized admin-only cache-version read; no state is changed. return isset( $_GET[ AI4SEO_ASSET_REFRESH_QUERY_PARAMETER ] ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Authorized admin-only cache-version read; no state is changed. ? absint( wp_unslash( $_GET[ AI4SEO_ASSET_REFRESH_QUERY_PARAMETER ] ) ) : 0; } /** * Adds the optimizer opt-out attribute shared by the main admin script and stylesheet. * * The main script contains a localized nonce, and the stylesheet must be refreshed alongside it * after plugin updates. LiteSpeed and Autoptimize both honor the same data attribute. * * @param string $tag Asset tag generated by WordPress. * @param string $handle Registered script or style handle. * @return string Filtered asset tag. */ function ai4seo_add_no_optimize_attribute_to_admin_asset_tag( $tag, $handle ) { // Ignore unrelated assets so their optimizer behavior remains under the owning plugin's control. if ( ! in_array( $handle, array( AI4SEO_SCRIPTS_HANDLE, AI4SEO_STYLES_HANDLE ), true ) ) { return $tag; } // Select the opening tag from the registered handle so scripts and styles share one insertion path. $tag_opening = AI4SEO_SCRIPTS_HANDLE === $handle ? '