canonical AI-O Optimiser migration. * * Configuration that is expensive or risky to recreate is copied forward. * Derived audit, queue, crawler, cache and checkpoint data is intentionally * rebuilt under the canonical ai_o_optimiser_* namespace. * * Legacy identifiers in this file are compatibility input only. The current * runtime, storage, hooks and admin routes remain canonical AI-O Optimiser. */ if ( ! defined( 'ABSPATH' ) ) { exit; } final class AI_O_Optimiser_Migration { const TARGET_VERSION = '1.20.4'; const STATE_OPTION = 'ai_o_optimiser_migration_1203'; const MAX_NETWORK_SITES = 200; /** * Name prefix for a legacy table that could not be adopted but still holds * rows. Carries the canonical plugin prefix, so retaining the data still * satisfies the unique-prefix requirement that motivated the rename. */ const PRESERVED_TABLE_PREFIX = 'ai_o_optimiser_preserved_'; /** MySQL's maximum identifier length. */ const MAX_TABLE_NAME_LENGTH = 64; /** * Every basename a pre-canonical AI-O Optimiser has been installed under. * * The first three sit in their own folders, so a canonical install can * coexist with them on disk, and activation refuses to proceed while one * of them is active. The fourth does not: the externally built 1.21.1 * line shipped the legacy runtime inside the canonical `ai-o-optimiser/` * folder, so it occupies the same directory this plugin installs into and * can only ever be a predecessor, never a co-installed copy. It is listed * so that it is recognised as one when its file is still present. * * These names are compatibility input only. `ai-crawler-tracker` is not a * current product identifier and nothing here creates one — the canonical * runtime, storage, hooks and routes are `ai_o_optimiser_*` throughout. * * @return string[] */ private static function legacy_basenames() { return array( 'ai-o-optimised/ai-crawler-tracker.php', 'ai-o-vis-op/ai-crawler-tracker.php', 'ai-crawler-tracker/ai-crawler-tracker.php', 'ai-o-optimiser/ai-crawler-tracker.php', ); } /** * The Free edition, which shares this plugin's data namespace. * * AI-O Optimiser Free is published on WordPress.org under the * `ai-o-optimiser` slug, so that is the folder it installs into; the paid * edition lives in `ai-o-optimiser-pro`. Both read the same * ai_o_optimiser_* tables and options on purpose - it is what lets a site * move from Free to paid and keep every measurement it has collected. * * They must not both run. Two active copies mean two admin menus, two sets * of scheduled work, and the same request counted twice in the crawler log. * So this edition refuses to activate while Free is active, exactly as it * does for a pre-rename install, and says which plugin to deactivate first. * * Deliberately NOT part of legacy_basenames(): Free is a current, supported * product, not something to migrate away from and warn about. Nothing here * deletes it or touches its files, and a site can return to Free by * deactivating this edition and reactivating that one, history intact. * * @return string[] */ private static function sibling_edition_basenames() { return array( 'ai-o-optimiser-pro/ai-o-optimiser.php', ); } /** * Everything that must be inactive before this plugin activates: earlier * generations to migrate from, plus the Free edition that shares this * one's namespace. * * @return string[] */ private static function conflicting_basenames() { return array_merge( self::legacy_basenames(), self::sibling_edition_basenames() ); } private static function standalone_search_ops_active() { return defined( 'AIO_SEARCH_OPS_VERSION' ) || defined( 'AIO_SEARCH_OPS_STANDALONE' ) || class_exists( 'AIO_Search_Ops_Bootstrap', false ); } private static function preserved_options() { return array( // Short-lived 1.20.0-1.20.2 canonical generation. 'aio_optimiser_settings' => 'ai_o_optimiser_settings', 'aio_optimiser_near_miss_uas' => 'ai_o_optimiser_near_miss_uas', 'aio_optimiser_site_profile' => 'ai_o_optimiser_site_profile', 'aio_optimiser_schema_fallbacks' => 'ai_o_optimiser_schema_fallbacks', 'aio_optimiser_schema_fallback_payloads' => 'ai_o_optimiser_schema_fallback_payloads', 'aio_optimiser_schema_fallbacks_meta_migrated' => 'ai_o_optimiser_schema_fallbacks_meta_migrated', 'aio_optimiser_schema_has_fallbacks' => 'ai_o_optimiser_schema_has_fallbacks', 'aio_optimiser_schema_review_scope' => 'ai_o_optimiser_schema_review_scope', 'aio_optimiser_schema_review_scope_presets' => 'ai_o_optimiser_schema_review_scope_presets', 'aio_optimiser_schema_semantic_mappings' => 'ai_o_optimiser_schema_semantic_mappings', 'aio_optimiser_local_business_identity' => 'ai_o_optimiser_local_business_identity', 'aio_optimiser_local_google_evidence_enabled' => 'ai_o_optimiser_local_google_evidence_enabled', 'aio_optimiser_local_gbp_identity' => 'ai_o_optimiser_local_gbp_identity', 'aio_optimiser_metadata_settings' => 'ai_o_optimiser_metadata_settings', 'aio_optimiser_metadata_search_settings' => 'ai_o_optimiser_metadata_search_settings', 'aio_optimiser_metadata_google_tokens' => 'ai_o_optimiser_metadata_google_tokens', 'aio_optimiser_metadata_category_settings' => 'ai_o_optimiser_metadata_category_settings', 'act_settings' => 'ai_o_optimiser_settings', 'act_near_miss_uas' => 'ai_o_optimiser_near_miss_uas', 'act_site_profile' => 'ai_o_optimiser_site_profile', 'act_schema_fallbacks' => 'ai_o_optimiser_schema_fallbacks', 'act_schema_fallback_payloads' => 'ai_o_optimiser_schema_fallback_payloads', 'act_schema_fallbacks_meta_migrated' => 'ai_o_optimiser_schema_fallbacks_meta_migrated', 'act_schema_has_fallbacks' => 'ai_o_optimiser_schema_has_fallbacks', 'act_schema_review_scope' => 'ai_o_optimiser_schema_review_scope', 'act_schema_review_scope_presets' => 'ai_o_optimiser_schema_review_scope_presets', 'act_schema_semantic_mappings' => 'ai_o_optimiser_schema_semantic_mappings', 'act_local_business_identity' => 'ai_o_optimiser_local_business_identity', 'act_local_google_evidence_enabled' => 'ai_o_optimiser_local_google_evidence_enabled', 'act_local_gbp_identity' => 'ai_o_optimiser_local_gbp_identity', 'bcp_ai_seo_settings' => 'ai_o_optimiser_metadata_settings', 'bcp_ai_seo_search_settings' => 'ai_o_optimiser_metadata_search_settings', 'bcp_ai_seo_google_tokens' => 'ai_o_optimiser_metadata_google_tokens', 'bcp_ai_seo_category_settings' => 'ai_o_optimiser_metadata_category_settings', ); } private static function legacy_tables() { return array( 'aio_optimiser_crawler_log', 'aio_optimiser_visibility_daily', 'aio_optimiser_gsc_rows', 'aio_optimiser_opportunity_workflow', 'aio_optimiser_schema_activity', 'aio_optimiser_metadata_queue', 'aio_optimiser_metadata_search_rows', 'aio_optimiser_metadata_opportunities', 'act_crawler_log', 'act_visibility_daily', 'act_gsc_rows', 'act_opportunity_workflow', 'act_schema_activity', 'bcp_ai_seo_queue', 'bcp_ai_seo_search_rows', 'bcp_ai_seo_opportunities', ); } private static function legacy_cron_hooks() { return array( 'aio_optimiser_daily_cleanup', 'aio_optimiser_nightly_health_check', 'aio_optimiser_gsc_weekly_sync', 'aio_optimiser_visibility_aggregate_backfill', 'aio_optimiser_local_citation_nap_batch_event', 'aio_optimiser_local_profile_discovery_event', 'aio_optimiser_metadata_generate_queue_event', 'aio_optimiser_metadata_daily_digest_event', 'aio_optimiser_metadata_scan_batch_event', 'act_daily_cleanup', 'act_nightly_health_check', 'act_gsc_weekly_sync', 'act_visibility_aggregate_backfill', 'act_local_citation_nap_batch_event', 'act_local_profile_discovery_event', 'bcp_ai_seo_generate_queue_event', 'bcp_ai_seo_daily_digest_event', 'bcp_ai_seo_scan_batch_event', ); } private static function legacy_options() { return array( 'act_settings', 'act_version', 'act_visibility_aggregate_state', 'act_visibility_aggregate_backfill_lock', 'act_near_miss_uas', 'act_ga4_test_result', 'act_traffic_evidence_snapshots', 'act_gsc_opportunities', 'act_schema_review_cache', 'act_schema_fallbacks', 'act_schema_fallback_payloads', 'act_schema_fallbacks_meta_migrated', 'act_schema_has_fallbacks', 'act_schema_review_scope', 'act_schema_review_scope_presets', 'act_schema_review_history', 'act_schema_review_baseline', 'act_schema_scan_queue', 'act_schema_ai_semantic_assessment', 'act_schema_semantic_mappings', 'act_schema_diagnostics', 'act_site_profile', 'act_environment_safety_check', 'act_llms_audit_cache', 'act_system_check_cache', 'act_nightly_health_check_cache', 'act_local_business_identity', 'act_local_discovery_report', 'act_local_google_evidence_enabled', 'act_local_gbp_identity', 'act_local_citation_nap_sources', 'act_local_citation_nap_job', 'act_local_citation_nap_lock', 'act_local_profile_discovery', 'act_plugin_path_migration', 'bcp_ai_seo_settings', 'bcp_ai_seo_db_version', 'bcp_ai_seo_scan_job', 'bcp_ai_seo_scan_lock', 'bcp_ai_seo_search_settings', 'bcp_ai_seo_google_tokens', 'bcp_ai_seo_search_db_version', 'bcp_ai_seo_competitor_topics', 'bcp_ai_seo_category_settings', 'bcp_ai_seo_admin_messages', 'bcp_ai_seo_api_test', 'bcp_ai_seo_digest_items', 'bcp_ai_seo_last_cron_result', 'bcp_ai_seo_last_external_cron', 'bcp_ai_seo_last_external_cron_ts', 'bcp_ai_seo_last_generation_attempt_ts', 'bcp_ai_seo_queue_paused', 'bcp_ai_seo_search_last_analysis', 'bcp_ai_seo_search_last_insight', 'bcp_ai_seo_search_last_sync', ); } /** * Legacy tables whose data is worth keeping, grouped by the canonical table * they become. * * The publishing requirement that forced the rename is satisfied by the * table NAME. Where a legacy table is structurally identical to its * canonical counterpart it can simply be renamed into place, which meets * the same requirement without discarding rows. * * Groups are adopted atomically. Raw crawler evidence and its daily * aggregate MUST stay in the same group: aggregate totals accumulate * (`total = total + VALUES(total)`), so an aggregate table cannot be * safely rebuilt over rows it already counted. Adopting one without the * other would either lose history or double-count it. * * Only generations whose schema has been compared are listed. Anything not * here keeps the previous drop-and-recreate behaviour, and every rename is * additionally gated on a runtime column-signature match. * * @return array> group => (legacy suffix => canonical suffix) */ private static function adoptable_table_groups() { return array( // 1.19.x visibility evidence. 'visibility_act' => array( 'act_crawler_log' => 'ai_o_optimiser_crawler_log', 'act_visibility_daily' => 'ai_o_optimiser_visibility_daily', ), // 1.20.0-1.20.2 visibility evidence. 'visibility_aio' => array( 'aio_optimiser_crawler_log' => 'ai_o_optimiser_crawler_log', 'aio_optimiser_visibility_daily' => 'ai_o_optimiser_visibility_daily', ), 'schema_activity_act' => array( 'act_schema_activity' => 'ai_o_optimiser_schema_activity' ), 'schema_activity_aio' => array( 'aio_optimiser_schema_activity' => 'ai_o_optimiser_schema_activity' ), 'gsc_act' => array( 'act_gsc_rows' => 'ai_o_optimiser_gsc_rows' ), 'gsc_aio' => array( 'aio_optimiser_gsc_rows' => 'ai_o_optimiser_gsc_rows' ), 'opportunity_act' => array( 'act_opportunity_workflow' => 'ai_o_optimiser_opportunity_workflow' ), 'opportunity_aio' => array( 'aio_optimiser_opportunity_workflow' => 'ai_o_optimiser_opportunity_workflow' ), ); } /** * Decide whether one legacy table can be renamed into its canonical name. * * Kept free of database access so the decision is directly testable. * * @param bool $legacy_exists Legacy table present. * @param int $legacy_rows Rows in the legacy table. * @param bool $canonical_exists Canonical table present. * @param int $canonical_rows Rows in the canonical table. * @param bool $signatures_match Column signatures are identical. * @return bool */ public static function can_adopt_table( $legacy_exists, $legacy_rows, $canonical_exists, $canonical_rows, $signatures_match ) { if ( ! $legacy_exists || (int) $legacy_rows < 1 ) { return false; // Nothing worth keeping. } if ( $canonical_exists && (int) $canonical_rows > 0 ) { return false; // Canonical already holds data; renaming would destroy it. } if ( $canonical_exists && ! $signatures_match ) { return false; // Shapes differ; fall back to the documented reset. } return true; } /** * Reset categories that no longer apply because the data was adopted. * * The migration state advertises what it discards. Once a table is carried * forward intact, continuing to claim its category was reset would be false. * * @param string[] $adopted Adopted legacy table suffixes. * @return string[] */ public static function reset_categories_covered_by( $adopted ) { $covered = array(); foreach ( (array) $adopted as $suffix ) { if ( false !== strpos( $suffix, 'crawler_log' ) || false !== strpos( $suffix, 'visibility_daily' ) ) { $covered[] = 'crawler_history'; } } return array_values( array_unique( $covered ) ); } private static function table_exists( $table ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- One-time compatibility migration of plugin-owned schema; caching is not applicable. return $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) === $table; } private static function table_row_count( $table ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- One-time compatibility migration of plugin-owned schema; caching is not applicable. return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $table ) ); } /** Ordered column names, used to prove two tables are interchangeable. */ private static function column_signature( $table ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- One-time compatibility migration of plugin-owned schema; caching is not applicable. $columns = $wpdb->get_col( $wpdb->prepare( 'SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s ORDER BY ORDINAL_POSITION', $table ) ); return is_array( $columns ) ? implode( ',', $columns ) : ''; } /** * Canonical name for the retained copy of an unadoptable legacy table. * * @param string $legacy_suffix Unprefixed legacy table name. * @return string Unprefixed preserved table name. */ public static function preserved_table_suffix( $legacy_suffix ) { return self::PRESERVED_TABLE_PREFIX . (string) $legacy_suffix; } /** * Rename a legacy table that holds rows but could not be adopted. * * adopt_legacy_tables() deliberately refuses to rename over a canonical * table that already holds rows, or one whose columns differ. Dropping the * legacy table at that point destroys the only copy of that data -- which is * exactly the #193 loss, reached by a different route: an interrupted * activation leaves the canonical table present and already receiving live * tracker inserts, so adoption is refused on a site that has lost nothing * yet. * * Rows are never dropped to complete a rename. They are moved aside under * the canonical prefix, recorded in migration state, and surfaced to the * administrator. Recovery is then an ordinary table copy; see * docs/runbooks/recover-crawler-history-after-1.20-migration.md. * * @param string $legacy_suffix Unprefixed legacy table name. * @return string Preserved unprefixed table name, or '' when preservation failed. */ private static function preserve_legacy_table( $legacy_suffix ) { global $wpdb; $legacy = $wpdb->prefix . $legacy_suffix; $preserved_suffix = self::preserved_table_suffix( $legacy_suffix ); $preserved = $wpdb->prefix . $preserved_suffix; if ( ! preg_match( '/^[A-Za-z0-9_]+$/', $preserved ) || strlen( $preserved ) > self::MAX_TABLE_NAME_LENGTH ) { return ''; } if ( self::table_exists( $preserved ) ) { // A previous interrupted run already moved this table aside. Keep // whichever copy holds data rather than overwriting one with the other. if ( self::table_row_count( $preserved ) > 0 ) { return ''; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange -- One-time compatibility migration of plugin-owned schema; caching is not applicable. if ( false === $wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i', $preserved ) ) ) { return ''; } } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange -- One-time compatibility migration of plugin-owned schema; caching is not applicable. if ( false === $wpdb->query( $wpdb->prepare( 'RENAME TABLE %i TO %i', $legacy, $preserved ) ) ) { return ''; } return $preserved_suffix; } /** * Rename legacy tables into their canonical names instead of dropping them. * * Runs before the legacy-table cleanup. A group is adopted only when every * table in it qualifies, so raw and aggregate data can never diverge. * * @return string[] Legacy suffixes that were adopted and therefore need no drop. */ private static function adopt_legacy_tables() { global $wpdb; $adopted = array(); foreach ( self::adoptable_table_groups() as $pairs ) { $plan = array(); foreach ( $pairs as $legacy_suffix => $canonical_suffix ) { $legacy = $wpdb->prefix . $legacy_suffix; $canonical = $wpdb->prefix . $canonical_suffix; if ( ! preg_match( '/^[A-Za-z0-9_]+$/', $legacy ) || ! preg_match( '/^[A-Za-z0-9_]+$/', $canonical ) ) { $plan = array(); break; } $legacy_exists = self::table_exists( $legacy ); if ( ! $legacy_exists ) { continue; // This generation is not present; other pairs may still apply. } $canonical_exists = self::table_exists( $canonical ); $can_adopt = self::can_adopt_table( true, self::table_row_count( $legacy ), $canonical_exists, $canonical_exists ? self::table_row_count( $canonical ) : 0, $canonical_exists ? ( self::column_signature( $legacy ) === self::column_signature( $canonical ) ) : true ); if ( ! $can_adopt ) { $plan = array(); // One member disqualifies the whole group. break; } $plan[ $legacy_suffix ] = array( $legacy, $canonical, $canonical_exists ); } foreach ( $plan as $legacy_suffix => $move ) { list( $legacy, $canonical, $canonical_exists ) = $move; if ( $canonical_exists ) { // Empty and structurally identical; make room for the rename. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange -- One-time compatibility migration of plugin-owned schema; caching is not applicable. if ( false === $wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i', $canonical ) ) ) { continue; } } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange -- One-time compatibility migration of plugin-owned schema; caching is not applicable. if ( false !== $wpdb->query( $wpdb->prepare( 'RENAME TABLE %i TO %i', $legacy, $canonical ) ) ) { $adopted[] = $legacy_suffix; } } } return $adopted; } private static function canonical_tables() { $tables = array( 'ai_o_optimiser_crawler_log', 'ai_o_optimiser_visibility_daily', 'ai_o_optimiser_schema_activity', ); if ( ! self::standalone_search_ops_active() ) { $tables = array_merge( $tables, array( 'ai_o_optimiser_gsc_rows', 'ai_o_optimiser_opportunity_workflow', 'ai_o_optimiser_metadata_queue', 'ai_o_optimiser_metadata_search_rows', 'ai_o_optimiser_metadata_opportunities', ) ); } return $tables; } private static function state() { $state = get_option( self::STATE_OPTION, array() ); return is_array( $state ) ? $state : array(); } private static function complete() { $state = self::state(); return 'complete' === ( $state['status'] ?? '' ) && self::TARGET_VERSION === ( $state['version'] ?? '' ); } public static function needed() { if ( self::complete() ) { return false; } foreach ( self::preserved_options() as $legacy => $canonical ) { if ( self::standalone_search_ops_active() && 0 === strpos( $legacy, 'bcp_ai_seo_' ) ) { continue; } if ( false !== get_option( $legacy, false ) ) { return true; } } global $wpdb; foreach ( self::legacy_tables() as $suffix ) { if ( self::standalone_search_ops_active() && 0 === strpos( $suffix, 'bcp_ai_seo_' ) ) { continue; } $table = $wpdb->prefix . $suffix; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- One-time compatibility migration of plugin-owned data/schema; caching is not applicable. if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) === $table ) { return true; } } return ! empty( self::state() ); } /** * Refuse to activate beside another AI-O Optimiser, then copy preservable * state. * * This used to switch the other copies off itself. The plugin directory * forbids that: only the administrator may activate or deactivate a * plugin, and a plugin that finds itself unable to run beside another may * stand ITSELF down, not the other one. So activation now stops before * anything is written, names the plugin that is in the way, and leaves the * administrator to deactivate it and try again. Nothing else changes: the * migration that follows never depended on the deactivation, only on the * other copy not running at the same time. * * @param bool $network_wide Whether this is a network activation. * @return int[] Site IDs that must receive canonical resource installation. */ public static function prepare_activation( $network_wide = false ) { self::load_plugin_admin_api(); if ( is_multisite() && $network_wide ) { $sites = function_exists( 'get_sites' ) ? get_sites( array( 'fields' => 'ids', 'number' => self::MAX_NETWORK_SITES + 1 ) ) : array(); $sites = is_array( $sites ) ? array_values( array_map( 'intval', $sites ) ) : array(); if ( count( $sites ) > self::MAX_NETWORK_SITES ) { self::refuse_activation( esc_html__( 'AI-O Optimiser found more than 200 sites. Deactivate any earlier AI-O Optimiser copy network-wide first, then retry network activation so the namespace migration is not performed in one unbounded request.', 'ai-o-optimiser' ) ); } self::refuse_if_conflicting_plugin_active( $sites ); foreach ( $sites as $site_id ) { switch_to_blog( $site_id ); self::prepare_current_site(); restore_current_blog(); } return $sites; } self::refuse_if_conflicting_plugin_active( array() ); self::prepare_current_site(); return array( (int) get_current_blog_id() ); } /** * Earlier generations and the sibling edition that are active right now. * * A basename whose file is gone is not a running plugin, whatever the * active_plugins option still says: WordPress skips it when loading and * removes the entry itself the next time the Plugins screen validates the * list. That is core's job, and this plugin does not do it for core. * * @param int[] $site_ids Sites to inspect as well as the network, for a * network activation; empty for a single site. * @return string[] Basenames that are active, in the order declared. */ public static function active_conflicting_basenames( array $site_ids = array() ) { self::load_plugin_admin_api(); $active = array(); foreach ( self::conflicting_basenames() as $basename ) { if ( ! file_exists( trailingslashit( WP_PLUGIN_DIR ) . $basename ) ) { continue; } if ( is_plugin_active( $basename ) ) { $active[] = $basename; continue; } foreach ( $site_ids as $site_id ) { $site_active = get_blog_option( (int) $site_id, 'active_plugins', array() ); if ( is_array( $site_active ) && in_array( $basename, $site_active, true ) ) { $active[] = $basename; break; } } } return $active; } /** * Stop activation when an earlier generation or the sibling edition is * still active. * * @param int[] $site_ids Sites to inspect, for a network activation. */ private static function refuse_if_conflicting_plugin_active( array $site_ids ) { $active = self::active_conflicting_basenames( $site_ids ); if ( ! $active ) { return; } $names = array(); foreach ( $active as $basename ) { $data = function_exists( 'get_plugin_data' ) ? get_plugin_data( trailingslashit( WP_PLUGIN_DIR ) . $basename, false, false ) : array(); $name = is_array( $data ) && ! empty( $data['Name'] ) ? (string) $data['Name'] : ''; $names[] = '' !== $name ? sprintf( '%1$s (%2$s)', $name, $basename ) : $basename; } self::refuse_activation( sprintf( /* translators: %s: the plugin(s) that must be deactivated first, as "Name (folder/file.php)". */ esc_html__( 'AI-O Optimiser was not activated. Another copy of AI-O Optimiser is active and both would read and write the same data: %s. Deactivate it from the Plugins screen, then activate this one. Its data is kept and carried across.', 'ai-o-optimiser' ), esc_html( implode( ', ', $names ) ) ) ); } /** * Abandon the activation with a message and a way back. * * Runs inside the activation hook, before WordPress records the plugin as * active, so dying here leaves it inactive. The self-deactivation covers * the one path where it is already recorded: a re-run of the hook for a * plugin that is listed active, where dying alone would leave it so. * * @param string $message Escaped message to show. */ private static function refuse_activation( $message ) { self::load_plugin_admin_api(); if ( defined( 'AI_O_Optimiser_PLUGIN_FILE' ) ) { deactivate_plugins( plugin_basename( AI_O_Optimiser_PLUGIN_FILE ) ); } wp_die( $message, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped by every caller; the message is built from esc_html__() and esc_html(). esc_html__( 'AI-O Optimiser was not activated', 'ai-o-optimiser' ), array( 'back_link' => true ) ); } /** * Copy preserved configuration, but do not delete any legacy state yet. * * @return bool True when a migration exists and still requires finalisation. */ public static function prepare_current_site() { if ( self::complete() ) { return false; } $existing = self::state(); if ( in_array( (string) ( $existing['status'] ?? '' ), array( 'prepared', 'verification-pending', 'verifying', 'cleaning', 'cleanup-pending' ), true ) ) { return true; } if ( ! self::needed() ) { if ( empty( $existing ) ) { // Nothing to migrate, and no state on record: record that as // complete. The upgrade gate reads this option on every request, // and one that does not exist is a query per page view on any // site without a persistent object cache. self::save_state( array( 'version' => self::TARGET_VERSION, 'status' => 'complete', 'preserved' => array(), 'reset' => array(), 'started_at' => gmdate( 'c' ), 'completed_at' => gmdate( 'c' ), ) ); } return false; } $state = array( 'version' => self::TARGET_VERSION, 'status' => 'copying', 'preserved' => array(), 'reset' => array( 'metadata_audits', 'schema_audits', 'local_listing_checks', 'crawler_history', 'job_state', 'caches' ), 'started_at' => gmdate( 'c' ), ); self::save_state( $state ); foreach ( self::preserved_options() as $legacy => $canonical ) { if ( self::standalone_search_ops_active() && 0 === strpos( $legacy, 'bcp_ai_seo_' ) ) { continue; } $sentinel = new stdClass(); $value = get_option( $legacy, $sentinel ); if ( $sentinel === $value ) { continue; } if ( false === get_option( $canonical, false ) ) { add_option( $canonical, $value, '', 'no' ); } $state['preserved'][] = $canonical; } self::migrate_schema_post_meta(); $state['status'] = 'prepared'; $state['prepared_at'] = gmdate( 'c' ); self::save_state( $state ); return true; } /** * Verify the canonical install before deleting legacy tables/options/hooks. * * @return bool True once migration cleanup is complete or not required. */ public static function finalize_current_site() { if ( self::complete() ) { return true; } if ( ! self::needed() ) { return true; } $state = self::state(); if ( empty( $state ) || 'copying' === ( $state['status'] ?? '' ) ) { self::prepare_current_site(); $state = self::state(); } $missing = self::missing_canonical_tables(); if ( $missing ) { $state['status'] = 'verification-pending'; $state['missing_resources'] = $missing; $state['last_verified_at'] = gmdate( 'c' ); self::save_state( $state ); return false; } $state['status'] = 'cleaning'; $state['missing_resources'] = array(); $state['last_verified_at'] = gmdate( 'c' ); self::save_state( $state ); foreach ( self::legacy_cron_hooks() as $hook ) { if ( self::standalone_search_ops_active() && 0 === strpos( $hook, 'bcp_ai_seo_' ) ) { continue; } wp_clear_scheduled_hook( $hook ); } global $wpdb; // Keep what can be kept. Structurally identical legacy tables are // renamed into their canonical names rather than dropped, so upgrading // does not discard irreplaceable crawler evidence. $adopted = self::adopt_legacy_tables(); if ( $adopted ) { $state['adopted_tables'] = $adopted; $state['reset'] = array_values( array_diff( isset( $state['reset'] ) && is_array( $state['reset'] ) ? $state['reset'] : array(), self::reset_categories_covered_by( $adopted ) ) ); self::save_state( $state ); } $drop_failed = array(); $preserved = isset( $state['preserved_tables'] ) && is_array( $state['preserved_tables'] ) ? $state['preserved_tables'] : array(); foreach ( self::legacy_tables() as $suffix ) { if ( self::standalone_search_ops_active() && 0 === strpos( $suffix, 'bcp_ai_seo_' ) ) { continue; } if ( in_array( $suffix, $adopted, true ) ) { continue; // Renamed into the canonical name; nothing left to drop. } $table = $wpdb->prefix . $suffix; if ( ! preg_match( '/^[A-Za-z0-9_]+$/', $table ) ) { $drop_failed[] = $suffix; continue; } /* * Only an empty or absent legacy table is dropped. A table that * still holds rows was not adopted for a reason -- the canonical * table already has data, or the columns differ -- and none of * those reasons make the rows disposable. Move it aside instead. */ if ( self::table_exists( $table ) && self::table_row_count( $table ) > 0 ) { $preserved_suffix = self::preserve_legacy_table( $suffix ); if ( '' === $preserved_suffix ) { $drop_failed[] = $suffix; continue; } $preserved[ $suffix ] = $preserved_suffix; $state['preserved_tables'] = $preserved; self::save_state( $state ); continue; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange -- One-time compatibility migration of plugin-owned data/schema; caching is not applicable. $result = $wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i', $table ) ); if ( false === $result ) { $drop_failed[] = $suffix; } } if ( $drop_failed ) { $state['status'] = 'cleanup-pending'; $state['table_errors'] = $drop_failed; self::save_state( $state ); return false; } foreach ( self::legacy_options() as $option ) { if ( self::standalone_search_ops_active() && 0 === strpos( $option, 'bcp_ai_seo_' ) ) { continue; } delete_option( $option ); } self::delete_previous_generation_options(); self::delete_legacy_transients(); self::remove_legacy_capability(); unset( $state['table_errors'], $state['missing_resources'] ); $state['status'] = 'complete'; $state['completed_at'] = gmdate( 'c' ); self::save_state( $state ); return true; } /** Compatibility entry point for recovery paths. */ public static function run_current_site() { self::prepare_current_site(); return self::finalize_current_site(); } private static function missing_canonical_tables() { global $wpdb; $missing = array(); foreach ( self::canonical_tables() as $suffix ) { $table = $wpdb->prefix . $suffix; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- One-time compatibility migration of plugin-owned data/schema; caching is not applicable. if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) !== $table ) { $missing[] = $suffix; } } return $missing; } /** Preserve live managed Schema configuration while changing the private post-meta namespace. */ private static function migrate_schema_post_meta() { global $wpdb; $pairs = array( '_act_schema_fallbacks' => '_ai_o_optimiser_schema_fallbacks', '_act_schema_fallback_payloads' => '_ai_o_optimiser_schema_fallback_payloads', '_aio_optimiser_schema_fallbacks' => '_ai_o_optimiser_schema_fallbacks', '_aio_optimiser_schema_fallback_payloads' => '_ai_o_optimiser_schema_fallback_payloads', ); foreach ( $pairs as $legacy => $canonical ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- One-time compatibility migration of plugin-owned data/schema; caching is not applicable. $wpdb->query( $wpdb->prepare( 'UPDATE %i old_meta LEFT JOIN %i new_meta ON new_meta.post_id = old_meta.post_id AND new_meta.meta_key = %s SET old_meta.meta_key = %s WHERE old_meta.meta_key = %s AND new_meta.meta_id IS NULL', $wpdb->postmeta, $wpdb->postmeta, $canonical, $canonical, $legacy ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- one-time bounded namespace migration. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- One-time compatibility migration of plugin-owned data/schema; caching is not applicable. $wpdb->delete( $wpdb->postmeta, array( 'meta_key' => $legacy ) ); // Remove only duplicate/leftover legacy entries. } foreach ( array( '_act_ai_refresh_opportunity', '_act_ai_refresh_query', '_act_ai_refresh_at', '_act_schema_recheck_gate', '_act_schema_recheck_gate_bypass_once', '_aio_optimiser_ai_refresh_opportunity', '_aio_optimiser_ai_refresh_query', '_aio_optimiser_ai_refresh_at', '_aio_optimiser_schema_recheck_gate', '_aio_optimiser_schema_recheck_gate_bypass_once' ) as $legacy_meta ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- One-time compatibility migration of plugin-owned data/schema; caching is not applicable. $wpdb->delete( $wpdb->postmeta, array( 'meta_key' => $legacy_meta ) ); } } /** Remove the obsolete plugin-specific capability after the canonical one is introduced by bootstrap. */ private static function remove_legacy_capability() { if ( ! function_exists( 'wp_roles' ) ) { return; } $roles = wp_roles(); if ( ! is_object( $roles ) || empty( $roles->roles ) || ! is_array( $roles->roles ) ) { return; } foreach ( array_keys( $roles->roles ) as $role_name ) { $role = get_role( $role_name ); if ( $role && $role->has_cap( 'manage_ai_o_visop_sensitive' ) ) { $role->remove_cap( 'manage_ai_o_visop_sensitive' ); } } } /** Remove any remaining options from the short-lived 1.20.0-1.20.2 prefix generation. */ private static function delete_previous_generation_options() { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- One-time compatibility migration of plugin-owned data/schema; caching is not applicable. $names = $wpdb->get_col( $wpdb->prepare( 'SELECT option_name FROM %i WHERE option_name LIKE %s', $wpdb->options, $wpdb->esc_like( 'aio_optimiser_' ) . '%' ) ); foreach ( is_array( $names ) ? $names : array() as $name ) { delete_option( (string) $name ); } } private static function delete_legacy_transients() { global $wpdb; $patterns = array( '_transient_aio_optimiser_%', '_transient_timeout_aio_optimiser_%', '_site_transient_aio_optimiser_%', '_site_transient_timeout_aio_optimiser_%', '_transient_act_%', '_transient_timeout_act_%', '_site_transient_act_%', '_site_transient_timeout_act_%', '_transient_bcp_ai_seo_%', '_transient_timeout_bcp_ai_seo_%', '_site_transient_bcp_ai_seo_%', '_site_transient_timeout_bcp_ai_seo_%', ); foreach ( $patterns as $pattern ) { if ( self::standalone_search_ops_active() && false !== strpos( $pattern, 'bcp_ai_seo_' ) ) { continue; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.DirectQuery -- Bounded one-time compatibility migration of plugin-owned data; caching is not applicable. $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE option_name LIKE %s', $wpdb->options, $pattern ) ); } } private static function save_state( array $state ) { if ( false === get_option( self::STATE_OPTION, false ) ) { // Autoloaded: the upgrade gate in the plugin bootstrap reads this on // every request, including ordinary public page views. It is a small // array, and keeping it out of alloptions bought a query per page. add_option( self::STATE_OPTION, $state, '', 'yes' ); } else { update_option( self::STATE_OPTION, $state, true ); } } private static function load_plugin_admin_api() { if ( ! function_exists( 'deactivate_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } } public static function boot_admin_notice() { add_action( 'admin_notices', array( __CLASS__, 'render_admin_notice' ) ); } public static function render_admin_notice() { if ( ! current_user_can( 'activate_plugins' ) ) { return; } $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only screen state (page slug, tab, redirect result notices). Nothing is written here, so there is no form submission to carry a nonce. if ( ! in_array( $page, array( 'ai-o-optimiser-settings', 'ai-o-optimiser-notifications' ), true ) ) { return; } $legacy_present = array(); foreach ( self::legacy_basenames() as $basename ) { if ( file_exists( trailingslashit( WP_PLUGIN_DIR ) . $basename ) ) { $legacy_present[] = dirname( $basename ); } } $state = self::state(); /* * Tables kept because they still held rows but could not be renamed into * their canonical names. The data is safe but is not being read by the * plugin, so say so -- silently retaining it would look identical to * having lost it. */ $preserved = isset( $state['preserved_tables'] ) && is_array( $state['preserved_tables'] ) ? $state['preserved_tables'] : array(); if ( $preserved ) { echo '

' . esc_html__( 'AI-O Optimiser kept data from a previous installation.', 'ai-o-optimiser' ) . ' '; echo esc_html( sprintf( /* translators: %s: comma-separated list of retained database table names. */ __( 'These tables still contained records that could not be carried into the current ones, so they were retained rather than deleted: %s. The plugin is not reading them. Ask your developer to review them before removing them.', 'ai-o-optimiser' ), implode( ', ', array_map( 'strval', array_values( $preserved ) ) ) ) ); echo '

'; } if ( ! $legacy_present ) { return; } if ( 'complete' !== ( $state['status'] ?? '' ) ) { echo '

' . esc_html__( 'AI-O Optimiser migration still needs attention.', 'ai-o-optimiser' ) . ' '; echo esc_html__( 'The canonical plugin is keeping the previous installation intact until its new database resources are verified and migration cleanup completes.', 'ai-o-optimiser' ); echo '

'; return; } echo '

' . esc_html__( 'AI-O Optimiser migration complete.', 'ai-o-optimiser' ) . ' '; echo esc_html__( 'The previous plugin copy is still installed. After confirming this build is working, remove the inactive legacy copy from Plugins so WordPress tools only see the canonical AI-O Optimiser installation.', 'ai-o-optimiser' ); echo '

'; } }