get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $wpdb->prefix . 'loggle_hits' ) ) ); } /** * Is this plugin file PlugPress's Loggle? The file name alone is not proof — * another plugin could be called loggle.php — so its header must say so too. */ public static function is_loggle( string $file ): bool { if ( 'loggle.php' !== basename( $file ) ) { return false; } if ( ! function_exists( 'get_plugin_data' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $path = WP_PLUGIN_DIR . '/' . $file; if ( ! is_readable( $path ) ) { return false; } $data = get_plugin_data( $path, false, false ); return 0 === strpos( (string) $data['Name'], 'Loggle' ) && 'PlugPress' === (string) $data['AuthorName']; } public static function state(): array { $state = get_option( self::STATE, array() ); return is_array( $state ) ? $state : array(); } /** Tracking waits while a migration is owed, running, or failed. */ public static function blocking(): bool { return in_array( self::state()['status'] ?? '', array( 'pending', 'running', 'failed' ), true ); } /** * Activation (and admin_init, belt and braces): if Loggle data exists and no * migration has been recorded, deactivate Loggle and queue the move. * * @return bool True when a migration is owed — callers then skip create_tables(). */ public static function queue_if_needed(): bool { $status = self::state()['status'] ?? ''; if ( 'done' === $status ) { return false; } if ( '' === $status && ! self::detect() ) { return false; } self::deactivate_loggle(); if ( '' === $status ) { self::save( array( 'status' => 'pending', 'step' => self::STEPS[0], 'cursors' => array(), 'counts' => array(), 'queued' => time(), ) ); } return true; } /** The 503 the admin API answers while the move is owed, running or failed. */ public static function busy_error(): WP_Error { $failed = 'failed' === ( self::state()['status'] ?? '' ); return new WP_Error( 'saddle_analytics_migrating', $failed ? __( 'Moving your Loggle data failed. Use Try again in the notice above, or run `wp saddle-analytics migrate --retry`.', 'saddle-analytics' ) : __( 'Your Loggle data is being moved into Saddle Analytics. Reload in a moment.', 'saddle-analytics' ), array( 'status' => 503 ) ); } /** A failed migration left its source untouched; put it back in the queue. */ public static function retry(): array { $state = self::state(); if ( 'failed' === ( $state['status'] ?? '' ) ) { $state['status'] = 'pending'; unset( $state['error'] ); self::save( $state ); } return $state; } /** Loggle and Saddle Analytics must never both track: switch Loggle off, whatever its folder. */ public static function deactivate_loggle(): void { if ( ! function_exists( 'deactivate_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } foreach ( (array) get_option( 'active_plugins', array() ) as $file ) { if ( self::is_loggle( (string) $file ) ) { deactivate_plugins( (string) $file, true ); } } } /** admin_init, priority 5: advance a pending migration for an administrator. */ public static function maybe_run(): void { if ( ! self::blocking() || 'failed' === ( self::state()['status'] ?? '' ) ) { return; } if ( wp_doing_ajax() || ! current_user_can( 'manage_options' ) ) { return; } self::run( (float) apply_filters( 'saddle_analytics_migrate_budget', 15 ) ); } /** * Advance the migration. $budget is seconds of work before handing back; * 0 means run to the end (the CLI). */ public static function run( float $budget = 0.0 ): array { $state = self::state(); if ( ! in_array( $state['status'] ?? '', array( 'pending', 'running' ), true ) ) { return $state; } if ( ! self::lock() ) { return $state; // Another request holds it; it will finish or go stale. } $deadline = $budget > 0 ? microtime( true ) + $budget : 0.0; $state['status'] = 'running'; try { while ( 'running' === $state['status'] ) { $state = self::step( $state, $deadline ); self::save( $state ); if ( $deadline && microtime( true ) >= $deadline ) { break; } } } catch ( Throwable $e ) { $state['status'] = 'failed'; $state['error'] = $e->getMessage(); self::save( $state ); } finally { self::unlock(); } return $state; } /** Run one step (or one slice of the tables step) and return the next state. */ private static function step( array $state, float $deadline ): array { switch ( $state['step'] ) { case 'settings': $state = self::migrate_settings( $state ); $state['step'] = 'tables'; return $state; case 'tables': $state = Saddle_Analytics_Migrate_Tables::run( $state, $deadline ); if ( ! empty( $state['tables_done'] ) ) { $state['step'] = 'ledger'; } return $state; case 'ledger': global $wpdb; $wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i', $wpdb->prefix . 'loggle_conversions' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange -- Loggle's revenue ledger; Saddle Analytics has no revenue data. $state['step'] = 'cleanup'; return $state; case 'cleanup': Saddle_Analytics_Migrate_Cleanup::clean(); $state['step'] = 'finish'; return $state; case 'finish': default: Saddle_Analytics_Install::create_tables(); Saddle_Analytics_Cron::schedule(); update_option( 'saddle_analytics_version', SADDLE_ANALYTICS_VERSION, false ); $state['status'] = 'done'; $state['step'] = 'done'; $state['finished'] = time(); return $state; } } /** Step 1. The delete flag goes first — it is what arms Loggle's uninstall. */ private static function migrate_settings( array $state ): array { $flag = get_option( 'loggle_delete_data_on_uninstall' ); delete_option( 'loggle_delete_data_on_uninstall' ); if ( false !== $flag ) { $state['delete_flag'] = (bool) $flag; } $old = get_option( 'loggle_settings', array() ); // Evaluated while the site's own code is loaded, so a loggle_hits_retention // filter in a theme or mu-plugin is honoured one last time and kept. $retention = (int) apply_filters( 'loggle_hits_retention', 90 ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- reading Loggle's own filter, once, to carry its value over. $mapped = self::map_settings( is_array( $old ) ? $old : array(), $retention ); if ( false === get_option( Saddle_Analytics_Settings::OPTION ) ) { Saddle_Analytics_Settings::update( $mapped ); } if ( array_key_exists( 'delete_flag', $state ) ) { update_option( 'saddle_analytics_delete_data_on_uninstall', $state['delete_flag'], false ); } $state['settings'] = $mapped; return $state; } /** * Loggle's settings → Saddle Analytics'. Pure. Migrated sites stay in cookie * mode so their returning-visitor numbers continue; revenue and goal keys * (freemius_*, goals) have nowhere to go and are dropped. */ public static function map_settings( array $old, int $retention ): array { return array( 'track_admins' => ! empty( $old['track_admins'] ), 'attribution' => 'last' === ( $old['attribution'] ?? 'first' ) ? 'last' : 'first', 'remember_visitors' => true, 'retention_days' => max( 7, min( 1095, $retention ) ), ); } public static function save( array $state ): void { update_option( self::STATE, $state, true ); } /** * A real mutex: INSERT IGNORE is atomic in the database, where add_option() * is a read-then-upsert that two requests can both win. A lock older than * ten minutes belongs to a request that died, and is taken over. */ private static function lock(): bool { global $wpdb; $take = static fn() => 1 === (int) $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'off')", self::LOCK, (string) time() ) ); if ( $take() ) { return true; } $held = (int) $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s", self::LOCK ) ); if ( $held && time() - $held > 10 * MINUTE_IN_SECONDS ) { self::unlock(); return $take(); } return false; } private static function unlock(): void { global $wpdb; $wpdb->delete( $wpdb->options, array( 'option_name' => self::LOCK ) ); wp_cache_delete( self::LOCK, 'options' ); } }