prefix; nothing user-supplied reaches a query. /** * Legacy — takes over the data of ClusterIQ, the plugin's earlier name (1.3.0). * * ClusterIQ stored everything under cci_ / clusteriq names. On the first * boot after the switch, Clusterlane renames its tables, copies its options, * renames post meta and moves the role capabilities, so a site keeps its * clusters, history, settings (including the encrypted AI key) and access * rules. Runs once per site; on multisite each site migrates the first time * Clusterlane boots there. * * While ClusterIQ itself is still active, nothing is moved and Clusterlane * does not start: a notice asks the user to deactivate ClusterIQ (a button * to WordPress's own Deactivate action). The move happens on the first * request after that. Clusterlane never deactivates a plugin by itself. */ final class Legacy { /** Set when the move is done (or there was nothing to move). */ const DONE = 'clusterlane_migrated'; /** Guards against two requests moving the data at the same time. */ const LOCK = 'clusterlane_migrating'; /** One-time notice for the admin after the move. */ const NOTICE = 'clusterlane_migrated_notice'; /** Plugin files of ClusterIQ (free and Pro, any folder) and its predecessor. */ const OLD_FILE = '#(^|/)(clusteriq|content-cluster-intelligence)\.php$#'; const TABLES = [ 'links', 'clusters', 'post_metrics', 'ai_cache', 'custom_members', 'tms_skipped', 'cluster_post_class', 'snapshots', 'activity' ]; const OPTIONS = [ 'settings', 'db_version', 'caps_version', 'ai_usage', 'chosen_pillars', 'cluster_sections', 'cluster_groups', 'data_version', 'last_snapshot', 'render_log', 'page_queue', 'hub_relations' ]; const POST_META = [ 'gap_cluster', 'elementor_backup', 'seo_check', 'seo_check_min', 'seo_original', 'page_stats' ]; const CAPS = [ 'manage_clusteriq' => 'manage_clusterlane', 'use_clusteriq' => 'use_clusterlane', 'use_clusteriq_ai' => 'use_clusterlane_ai', 'clusteriq_add_links' => 'clusterlane_add_links', 'clusteriq_write_seo' => 'clusterlane_write_seo', ]; /* ------------------------------------------------------------------ Entry points ------------------------------------------------------------------ */ /** * Called first thing in Plugin::boot(). Cheap once the site is done. * * @return bool false while ClusterIQ itself is still active: Clusterlane * then waits (it only shows a notice) until the user * deactivates ClusterIQ — it never switches a plugin off by itself. */ public static function maybe_migrate(): bool { add_action( 'admin_notices', [ self::class, 'notice' ] ); add_action( 'network_admin_notices', [ self::class, 'notice' ] ); if ( get_option( self::DONE ) ) return true; if ( self::old_active() ) return false; // never move tables out from under a running ClusterIQ if ( ! self::has_old_data() ) { update_option( self::DONE, [ 'at' => time(), 'moved' => false ], true ); return true; } if ( ! add_option( self::LOCK, time(), '', false ) ) { if ( time() - (int) get_option( self::LOCK, 0 ) < 5 * MINUTE_IN_SECONDS ) return true; update_option( self::LOCK, time(), false ); // a run that died: take over } self::migrate_site(); if ( is_multisite() ) self::migrate_network(); delete_option( self::LOCK ); update_option( self::DONE, [ 'at' => time(), 'moved' => true ], true ); set_transient( self::NOTICE, 1, DAY_IN_SECONDS ); wp_cache_flush(); // post meta and roles were changed directly return true; } /** * While ClusterIQ is active: what to do, with a button for each active * copy that leads to WordPress's own Deactivate action (the user decides). * After the move: one confirmation. */ public static function notice(): void { if ( ! current_user_can( 'activate_plugins' ) ) return; $old = array_values( array_filter( self::active_plugins(), static fn( $p ) => (bool) preg_match( self::OLD_FILE, $p ) ) ); if ( $old && ! get_option( self::DONE ) ) { echo '

Clusterlane: ' . esc_html__( 'ClusterIQ is now called Clusterlane. Deactivate ClusterIQ to move your clusters, history, settings and access rules over — Clusterlane waits until then, so the two never run at the same time.', 'clusterlane' ) . '

'; foreach ( $old as $plugin ) { $network = is_multisite() && array_key_exists( $plugin, (array) get_site_option( 'active_sitewide_plugins', [] ) ); $base = $network ? network_admin_url( 'plugins.php' ) : admin_url( 'plugins.php' ); $url = wp_nonce_url( add_query_arg( [ 'action' => 'deactivate', 'plugin' => rawurlencode( $plugin ) ], $base ), 'deactivate-plugin_' . $plugin ); echo '' . esc_html( /* translators: %s: plugin folder and file, e.g. clusteriq/clusteriq.php */ sprintf( __( 'Deactivate ClusterIQ (%s)', 'clusterlane' ), $plugin ) ) . ' '; } echo '

'; return; } if ( get_transient( self::NOTICE ) ) { delete_transient( self::NOTICE ); echo '

Clusterlane: ' . esc_html__( 'Your ClusterIQ data has moved over: clusters, history, settings and access rules. The old ClusterIQ plugin can be deleted — deleting it no longer touches this data.', 'clusterlane' ) . '

'; } } /* ------------------------------------------------------------------ The move ------------------------------------------------------------------ */ private static function migrate_site(): void { global $wpdb; $had_mapping = false !== get_option( 'cci_caps_version', false ); // ClusterIQ had applied its role rights // 1. Tables: rename, so nothing is copied. Clusterlane's activation // may already have created empty tables — those make way. foreach ( self::TABLES as $bare ) { $old = $wpdb->prefix . 'cci_' . $bare; $new = $wpdb->prefix . 'clusterlane_' . $bare; if ( ! self::table_exists( $old ) ) continue; if ( self::table_exists( $new ) ) { if ( (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $new ) ) > 0 ) continue; // already in use: keep it $wpdb->query( $wpdb->prepare( 'DROP TABLE %i', $new ) ); } $wpdb->query( $wpdb->prepare( 'RENAME TABLE %i TO %i', $old, $new ) ); } // 2. Options: ClusterIQ's values win over the defaults written on activation. foreach ( self::OPTIONS as $bare ) { $value = get_option( 'cci_' . $bare, null ); if ( null === $value ) continue; update_option( 'clusterlane_' . $bare, $value, false ); delete_option( 'cci_' . $bare ); } delete_option( 'cci_page_lock' ); // 3. Post meta. foreach ( self::POST_META as $bare ) { $wpdb->update( $wpdb->postmeta, [ 'meta_key' => '_clusterlane_' . $bare ], [ 'meta_key' => '_cci_' . $bare ] ); } // 4. Capabilities: the new ones follow exactly what each role had, so // a right an admin took away stays away. foreach ( wp_roles()->role_objects as $role ) { foreach ( self::CAPS as $old => $new ) { if ( isset( $role->capabilities[ $old ] ) ) { $role->add_cap( $new, (bool) $role->capabilities[ $old ] ); $role->remove_cap( $old ); } elseif ( $had_mapping && $role->has_cap( $new ) ) { $role->remove_cap( $new ); } } } // Rights given to single users. $users = get_users( [ 'blog_id' => get_current_blog_id(), 'meta_key' => $wpdb->get_blog_prefix() . 'capabilities', 'meta_value' => 'clusteriq', 'meta_compare' => 'LIKE', 'fields' => 'all', ] ); foreach ( $users as $user ) { foreach ( self::CAPS as $old => $new ) { if ( isset( $user->caps[ $old ] ) ) { $user->add_cap( $new, (bool) $user->caps[ $old ] ); $user->remove_cap( $old ); } } } // 5. Short-lived data (undo snapshots, locks) and ClusterIQ's scheduled events. $wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '\\_transient\\_cci\\_%' OR option_name LIKE '\\_transient\\_timeout\\_cci\\_%'" ); wp_clear_scheduled_hook( 'cci_daily_snapshot' ); wp_clear_scheduled_hook( 'cci_read_pages' ); // Pages still waiting to be read: pick up where ClusterIQ left off. $queue = get_option( 'clusterlane_page_queue' ); if ( ! empty( $queue ) && ! wp_next_scheduled( 'clusterlane_read_pages' ) ) { wp_schedule_single_event( time() + 60, 'clusterlane_read_pages' ); } } /** The network-wide AI key and policy (multisite). */ private static function migrate_network(): void { $value = get_site_option( 'cci_network_ai', null ); if ( null === $value ) return; if ( null === get_site_option( 'clusterlane_network_ai', null ) ) { update_site_option( 'clusterlane_network_ai', $value ); } delete_site_option( 'cci_network_ai' ); } /* ------------------------------------------------------------------ Helpers ------------------------------------------------------------------ */ private static function has_old_data(): bool { global $wpdb; if ( false !== get_option( 'cci_settings', false ) || false !== get_option( 'cci_db_version', false ) ) return true; if ( is_multisite() && null !== get_site_option( 'cci_network_ai', null ) ) return true; return self::table_exists( $wpdb->prefix . 'cci_clusters' ); } private static function table_exists( string $table ): bool { global $wpdb; return $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ) ) === $table; } private static function old_active(): bool { foreach ( self::active_plugins() as $plugin ) { if ( preg_match( self::OLD_FILE, $plugin ) ) return true; } return false; } /** @return string[] */ private static function active_plugins(): array { $active = (array) get_option( 'active_plugins', [] ); if ( is_multisite() ) $active = array_merge( $active, array_keys( (array) get_site_option( 'active_sitewide_plugins', [] ) ) ); return array_map( 'strval', $active ); } }