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 .= " ";
}
// 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 ? '