*/ public static function settings_keys(): array { global $wpdb; $found = array(); if ( isset( $wpdb ) ) { $rows = $wpdb->get_col( $wpdb->prepare( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", $wpdb->esc_like( 'airanksuite_' ) . '%' . $wpdb->esc_like( '_settings' ) ) ); $found = is_array( $rows ) ? array_map( 'strval', $rows ) : array(); } // Configuration that does not follow the `_settings` convention. $extra = array( 'airanksuite_gsc_property', 'airanksuite_google_tokens', 'airanksuite_google_service_account', 'airanksuite_indexnow_key', 'airanksuite_schema_templates', 'airanksuite_redirect_rules', 'airanksuite_setup_done', 'airanksuite_whitelabel', // Code snippets were removed in 2.2 (they ran eval() on // administrator-typed PHP). The stored code is deliberately kept // and exported, so anyone who used them can retrieve it and move // it to a dedicated snippets plugin. 'airanksuite_snippets', ); /** * Filter the option keys backed up before every migration. * * @param array $keys Option names. */ return array_values( array_unique( (array) apply_filters( 'airanksuite/migrator/settings_keys', array_merge( $found, $extra ) ) ) ); } /** * Compare stored version against the running one and upgrade if needed. */ public static function maybe_upgrade(): void { $stored = (string) get_option( 'airanksuite_db_version', '' ); // An install from before the current prefix stores its schema version // under an older key. Without this the plugin reads "no version" as // "fresh install", creates a second, empty set of tables beside the // full ones, and the customer's data looks deleted. The Kernel has // normally done this already; a CLI or test entry point may not have. if ( '' === $stored ) { self::adopt_legacy(); $stored = (string) get_option( 'airanksuite_db_version', '' ); } if ( $stored === Database::VERSION ) { return; } // Prevent concurrent requests from migrating simultaneously. if ( get_transient( self::LOCK ) ) { return; } set_transient( self::LOCK, 1, 5 * MINUTE_IN_SECONDS ); try { if ( '' !== $stored ) { self::backup_settings( $stored ); } else { // A sub-site of a network activation, or a plugin dropped in // without the activation hook, meets the plugin here first. // Before Database::install() records the schema version, or // this would never again look like a first run. ModuleCatalog::seed_fresh_install(); } Database::install(); $finished = self::run( $stored ); if ( $finished ) { // Only a COMPLETE migration may declare the schema current. // While rows are pending, the stored version stays old, so // every later request (and the scheduled tick) re-enters here // and continues the cursor — a crash or timeout mid-move can // delay the migration but never truncate it. update_option( 'airanksuite_db_version', Database::VERSION, false ); } update_option( 'airanksuite_version', AIRANKSUITE_VERSION, false ); flush_rewrite_rules(); } finally { delete_transient( self::LOCK ); } } /** * Snapshot every settings option before upgrading, so a bad release can be * rolled back without the customer losing their configuration. * * @param string $from Previous schema version. */ private static function backup_settings( string $from ): void { $backup = array(); foreach ( self::settings_keys() as $key ) { $value = get_option( $key, null ); if ( null !== $value ) { $backup[ $key ] = $value; } } if ( empty( $backup ) ) { return; } update_option( 'airanksuite_settings_backup', array( 'from' => $from, 'to' => Database::VERSION, 'plugin' => AIRANKSUITE_VERSION, 'time' => time(), 'options' => $backup, ), false ); } /** * Restore the last backup (support tool, exposed via REST). */ public static function restore_backup(): bool { $backup = get_option( 'airanksuite_settings_backup' ); if ( ! is_array( $backup ) || empty( $backup['options'] ) ) { return false; } foreach ( (array) $backup['options'] as $key => $value ) { // Validate by shape, not by the current key list: an option that was // deleted since the backup would otherwise never be restorable. // A backup taken before the prefix changed names its options the // old way; they are restored under the name they have today. $key = is_string( $key ) ? self::current_key( $key ) : $key; if ( is_string( $key ) && 1 === preg_match( '/^airanksuite_[a-z0-9_]+$/', $key ) ) { update_option( $key, $value, false ); } } return true; } /** * Version-to-version data migrations. Forward-only, idempotent. * * @param string $from Previous schema version ('' on fresh install). */ /** * @return bool True when every migration for this jump has fully finished. * False means a data move still has rows pending — the caller * must NOT record the new schema version, and a cron tick is * scheduled to continue where the cursor stopped. */ private static function run( string $from ): bool { if ( '' === $from ) { return true; // Fresh install: defaults apply, nothing to migrate. } // Schema 1 → 2: crawler settings gained per-bot purposes; nothing to rewrite. // Schema 24: the product became AI Rank Suite, and every stored // identifier moved with it — tables wp_aigs_* to wp_airanksuite_*, options // and post meta aigs_ to airanksuite_, capabilities, cron hooks. Any 1.4.x // install carries its data across here. RENAME TABLE and one UPDATE // per key space copy no rows, so a million-row table costs // milliseconds; every step checks before acting, so a repeated run // changes nothing. if ( (int) $from < 24 ) { self::rebrand_identifiers( 'aigs' ); } // An older importer wrote a noindex flag to `_airanksuite_robots_noindex`, // a key nothing read — the head output and the editor use `_airanksuite_robots`. // Move the flag over so the imported setting takes effect. On a // 100,000-post site this cannot run in one request: one slice per // call, resumable, and the schema version waits for the last slice. // The stored value is a database schema number, not a plugin SemVer. // Looking at the queue itself keeps this migration correct for every // upgrade path, including sites that skipped several releases. // Schema 25: licensing became Freemius' job alone. Before that the // plugin also kept a serial number of its own invention under // `airanksuite_license`, encrypted, with a status ("preview") that nothing // ever advanced. Nothing reads it now, so it is removed rather than // left behind — a stored credential that no code path consults is a // liability, not a record. Only this option goes; no licence, no // setting and no content is affected. if ( (int) $from < 25 ) { delete_option( 'airanksuite_license' ); } // Schema 26: the crawler log moved from one shared option into its own // table with atomic counters — the option was rewritten whole on every // bot hit, which on a heavily crawled site is write load and lost // updates. Carry the recorded history across, day bucket by day // bucket, then remove the option. INSERT IGNORE-style: a row that // already exists (an interrupted migration re-running) is left alone. if ( (int) $from < 26 ) { $stored = get_option( 'airanksuite_crawler_hits' ); if ( is_array( $stored ) ) { global $wpdb; $table = Database::crawler_hits(); foreach ( $stored as $bot => $entry ) { $days = is_array( $entry['days'] ?? null ) ? $entry['days'] : array(); if ( empty( $days ) && ! empty( $entry['hits'] ) ) { // Pre-daily-bucket data: one row carrying the total. $days = array( gmdate( 'Y-m-d', (int) ( $entry['last'] ?? time() ) ) => (int) $entry['hits'] ); } $last_day = array_key_last( $days ); foreach ( $days as $day => $count ) { if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', (string) $day ) ) { continue; } // The per-day blocked split was never stored; the total // rides on the newest day so nothing is lost, only // placed. Same for last/last_path. $is_last = ( $day === $last_day ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery $exists = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE bot = %s AND day = %s", (string) $bot, (string) $day ) ); if ( $exists > 0 ) { continue; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->insert( $table, array( 'bot' => (string) $bot, 'day' => (string) $day, 'hits' => (int) $count, 'blocked' => $is_last ? (int) ( $entry['blocked'] ?? 0 ) : 0, 'last' => $is_last ? (int) ( $entry['last'] ?? 0 ) : 0, 'last_path' => $is_last ? mb_substr( (string) ( $entry['last_path'] ?? '' ), 0, 190 ) : '', ), array( '%s', '%s', '%d', '%d', '%d', '%s' ) ); } } } delete_option( 'airanksuite_crawler_hits' ); } $slice = self::move_noindex_flags(); if ( empty( $slice['done'] ) ) { if ( ! wp_next_scheduled( 'airanksuite_migrate_tick' ) ) { wp_schedule_single_event( time() + 30, 'airanksuite_migrate_tick' ); } return false; } return true; } /** * The prefixes this product stored its data under before the current one, * oldest first. AI GEO Suite wrote `aigs_`; AI Rank Suite up to 0.2.35 wrote * a four-letter abbreviation of its name, which the WordPress.org review * found too short to be safe from collisions. */ private const LEGACY_PREFIXES = array( 'aigs', 'airs' ); /** The prefix every stored identifier carries now. */ private const PREFIX = 'airanksuite'; /** * Take over the data of an install that still stores it under an older * prefix — before anything reads a setting. * * maybe_upgrade() runs on admin, REST and cron requests. That is too late * for this: a visitor's page view would boot every module against option * names that do not exist yet, and the site would serve default titles and * no redirects until somebody opened the dashboard. So the Kernel calls * this first thing on every request. When the current schema version is * stored — every request but the first after the update — it is one read * of an option the priming query has already fetched. * * @return bool True when data was moved on this request. */ public static function adopt_legacy(): bool { if ( '' !== (string) get_option( self::PREFIX . '_db_version', '' ) ) { return false; } foreach ( array_reverse( self::LEGACY_PREFIXES ) as $legacy ) { if ( '' === (string) get_option( $legacy . '_db_version', '' ) ) { continue; } if ( get_transient( self::LOCK ) ) { return false; } set_transient( self::LOCK, 1, 5 * MINUTE_IN_SECONDS ); try { self::rebrand_identifiers( $legacy ); } finally { delete_transient( self::LOCK ); } return true; } return false; } /** * The name an option from an older export or backup has today. * * @param string $key Option name, possibly under a legacy prefix. */ public static function current_key( string $key ): string { foreach ( self::LEGACY_PREFIXES as $legacy ) { if ( 0 === strpos( $key, $legacy . '_' ) ) { return self::PREFIX . substr( $key, strlen( $legacy ) ); } } return $key; } /** * Move every stored identifier from a legacy prefix to the current one. * * Tables first: a half-renamed schema is the one state that breaks reads. * Then options, post and term meta, capabilities and cron. Idempotent * throughout: every step checks before acting, so a repeated run changes * nothing. RENAME TABLE and one UPDATE per key space copy no rows, so a * million-row table costs milliseconds. * * @param string $from The legacy prefix, without the underscore. */ public static function rebrand_identifiers( string $from = 'aigs' ): void { global $wpdb; if ( ! in_array( $from, self::LEGACY_PREFIXES, true ) ) { return; } $to = self::PREFIX; $suffixes = array( 'redirects', '404', 'link_suggestions', 'keywords', 'ranks', 'ai_mentions', 'found_links', 'audit', 'backlinks', 'ai_usage', 'ai_reservations', 'consent_log', 'crawler_hits', 'urls', 'url_sitemaps', 'url_scans', 'import_undo', 'graph_runs', 'link_nodes', 'link_edges', ); $exists = static function ( string $table ) use ( $wpdb ): bool { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery return $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) === $table; }; $pending = array(); foreach ( $suffixes as $suffix ) { $old = $wpdb->prefix . $from . '_' . $suffix; $new = $wpdb->prefix . $to . '_' . $suffix; if ( ! $exists( $old ) ) { continue; } if ( $exists( $new ) ) { // Activation may have created the new table, empty, before this // ran. An empty newcomer must not shadow real data; a filled one // means the move already happened. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery if ( (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $new ) ) > 0 ) { continue; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.SchemaChange $wpdb->query( $wpdb->prepare( 'DROP TABLE %i', $new ) ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.SchemaChange $wpdb->query( $wpdb->prepare( 'ALTER TABLE %i RENAME TO %i', $old, $new ) ); // Verify rather than trust: the SQLite drop-in accepts this // statement and reports success without renaming anything. On // MySQL the rename is instant and this check simply passes. if ( ! $exists( $new ) ) { $pending[ $suffix ] = array( $old, $new ); } } if ( ! empty( $pending ) ) { // Fallback for engines whose RENAME is a no-op: let the installer // create each table with its real schema and indexes, copy the // rows in, then drop the original. Slower, but correct — and the // only path that a SQLite-backed site ever takes. Database::install(); foreach ( $pending as $pair ) { list( $old, $new ) = $pair; if ( ! $exists( $new ) || ! $exists( $old ) ) { continue; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $wpdb->query( $wpdb->prepare( 'INSERT INTO %i SELECT * FROM %i', $new, $old ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.SchemaChange $wpdb->query( $wpdb->prepare( 'DROP TABLE %i', $old ) ); } } // SUBSTRING is one-based: the first character kept is the underscore // that follows the prefix, so the new name is simply prefix + rest. $cut_option = strlen( $from ) + 1; $cut_meta = strlen( '_' . $from ) + 1; $old_option = $wpdb->esc_like( $from . '_' ) . '%'; $new_option = $wpdb->esc_like( $to . '_' ) . '%'; $old_meta = $wpdb->esc_like( '_' . $from . '_' ) . '%'; // Transients are options too, under two prefixes; dropping them is // safe by definition — they are caches. Before the rename, so none of // them is carried across under a name nothing will ever read. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE option_name LIKE %s', $wpdb->options, '%' . $wpdb->esc_like( 'transient' ) . '%' . $wpdb->esc_like( '_' . $from . '_' ) . '%' ) ); // option_name is unique, so a default seeded moments ago under the new // name would make the rename collide. The stored data wins. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $taken = $wpdb->get_col( $wpdb->prepare( 'SELECT option_name FROM %i WHERE option_name LIKE %s', $wpdb->options, $new_option ) ); foreach ( (array) $taken as $name ) { $legacy_name = $from . substr( (string) $name, strlen( $to ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $has_legacy = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE option_name = %s', $wpdb->options, $legacy_name ) ); if ( $has_legacy > 0 ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE option_name = %s', $wpdb->options, (string) $name ) ); } } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $wpdb->query( $wpdb->prepare( 'UPDATE %i SET option_name = CONCAT(%s, SUBSTRING(option_name, %d)) WHERE option_name LIKE %s', $wpdb->options, $to, $cut_option, $old_option ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $wpdb->query( $wpdb->prepare( 'UPDATE %i SET meta_key = CONCAT(%s, SUBSTRING(meta_key, %d)) WHERE meta_key LIKE %s', $wpdb->postmeta, '_' . $to, $cut_meta, $old_meta ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $wpdb->query( $wpdb->prepare( 'UPDATE %i SET meta_key = CONCAT(%s, SUBSTRING(meta_key, %d)) WHERE meta_key LIKE %s', $wpdb->termmeta, '_' . $to, $cut_meta, $old_meta ) ); // The stored rewrite rules name the old query variables; WordPress // rebuilds them on the next request when the option is absent. delete_option( 'rewrite_rules' ); wp_cache_flush(); foreach ( wp_roles()->role_objects as $role ) { foreach ( array_keys( (array) $role->capabilities ) as $cap ) { if ( is_string( $cap ) && 0 === strpos( $cap, $from . '_' ) ) { $role->add_cap( $to . substr( $cap, strlen( $from ) ), ! empty( $role->capabilities[ $cap ] ) ); $role->remove_cap( $cap ); } } } // Recurring events re-register themselves under the new hook names as // each module boots; the old ones would fire into nothing for ever. foreach ( (array) _get_cron_array() as $hooks ) { foreach ( array_keys( (array) $hooks ) as $hook ) { if ( is_string( $hook ) && ( 0 === strpos( $hook, $from . '_' ) || 0 === strpos( $hook, $from . '/' ) ) ) { wp_clear_scheduled_hook( $hook ); } } } } /** * `_airanksuite_robots_noindex` = 1 → 'noindex' in the `_airanksuite_robots` directive * list. One batch per call. The work queue is its own cursor: every moved * row deletes its source meta, so the next call's SELECT starts exactly * where this one stopped — resumable across requests, crashes and * concurrent runs without a stored offset that could drift. A timeout * mid-batch loses nothing: rows either moved (and left the queue) or did * not (and will be selected again). * * @param int $batch Rows per slice. * @return array{moved: int, remaining: int, done: bool} */ public static function move_noindex_flags( int $batch = 1000 ): array { global $wpdb; $batch = max( 1, $batch ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $ids = $wpdb->get_col( $wpdb->prepare( "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_airanksuite_robots_noindex' AND meta_value IN ('1','yes','true') ORDER BY post_id ASC LIMIT %d", $batch ) ); $n = 0; foreach ( (array) $ids as $id ) { $id = (int) $id; $robots = get_post_meta( $id, '_airanksuite_robots', true ); $robots = is_array( $robots ) ? array_values( array_map( 'strval', $robots ) ) : array(); $saved = true; if ( ! in_array( 'noindex', $robots, true ) ) { $robots[] = 'noindex'; $saved = false !== update_post_meta( $id, '_airanksuite_robots', $robots ); } $current = get_post_meta( $id, '_airanksuite_robots', true ); if ( ! $saved || ! is_array( $current ) || ! in_array( 'noindex', $current, true ) ) { continue; } delete_post_meta( $id, '_airanksuite_robots_noindex' ); ++$n; if ( Job::memory_tight() ) { break; // The queue keeps the rest for the next slice. } } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $remaining = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE meta_key = '_airanksuite_robots_noindex' AND meta_value IN ('1','yes','true')", $wpdb->postmeta ) ); return array( 'moved' => $n, 'remaining' => $remaining, 'done' => 0 === $remaining, ); } }