'ids', 'number' => 0)); // Store list of pending site IDs in network option update_site_option('abj404_pending_network_activation', $sites); update_site_option('abj404_network_activation_total', count($sites)); // Schedule first batch immediately wp_schedule_single_event(time(), 'abj404_network_activation_hook'); // Show admin notice that activation is happening in background add_action('network_admin_notices', function() { $pendingRaw = get_site_option('abj404_pending_network_activation', array()); $pending = is_array($pendingRaw) ? $pendingRaw : array(); $totalRaw = get_site_option('abj404_network_activation_total', 0); $total = is_scalar($totalRaw) ? (int)$totalRaw : 0; $completed = $total - count($pending); if (!empty($pending)) { echo '

404 Solution: Network activation in progress... ' . esc_html((string)$completed) . ' of ' . esc_html((string)$total) . ' sites activated. ' . 'This will complete in the background.

'; } }); } else { // Single site activation (or individual subsite activation) self::activateSingleSite(); } } /** * Activate plugin for a single site. * This contains the actual activation logic that was previously in runOnPluginActivation. * * @global type $abj404logic * @global type $abj404dao * @global type $abj404logging * @return void */ private static function activateSingleSite(): void { $abj404logic = ABJ_404_Solution_PluginLogic::getInstance(); add_option('abj404_settings', '', '', false); $upgradesEtc = ABJ_404_Solution_DatabaseUpgradesEtc::getInstance(); $upgradesEtc->createDatabaseTables(); ABJ_404_Solution_PluginLogic::doRegisterCrons(); $abj404logic->doUpdateDBVersionOption(); } /** * Background cron handler for network activation. * Processes one site at a time to prevent timeouts. * Reschedules itself if more sites remain. * @return void */ static function networkActivationCronHandler(): void { // Get list of pending sites $pendingRaw = get_site_option('abj404_pending_network_activation', array()); $pending = is_array($pendingRaw) ? $pendingRaw : array(); if (empty($pending)) { // All done! Clean up network options delete_site_option('abj404_pending_network_activation'); delete_site_option('abj404_network_activation_total'); return; } // Process one site $blog_id = array_shift($pending); $blog_id_int = is_scalar($blog_id) ? (int)$blog_id : 0; try { switch_to_blog($blog_id_int); self::activateSingleSite(); restore_current_blog(); } catch (Exception $e) { // Log error but continue with other sites error_log('404 Solution: Network activation failed for site ' . $blog_id_int . ': ' . $e->getMessage()); restore_current_blog(); } // Update pending list update_site_option('abj404_pending_network_activation', $pending); // Schedule next site (10 seconds delay to spread load) if (!empty($pending)) { wp_schedule_single_event(time() + 10, 'abj404_network_activation_hook'); } else { // All done! Clean up network options delete_site_option('abj404_pending_network_activation'); delete_site_option('abj404_network_activation_total'); } } /** * Handle new blog creation in multisite (WordPress < 5.1). * This is triggered by the wpmu_new_blog action. * * @param int $blog_id Blog ID of the new blog * @param int $user_id User ID of the user creating the blog * @param string $domain Domain of the new blog * @param string $path Path of the new blog * @param int $site_id Site ID (network ID) * @param array $meta Additional meta information * @return void */ static function activateNewSite($blog_id, $user_id, $domain, $path, $site_id, $meta): void { // Only activate if the plugin is network-activated if (is_plugin_active_for_network(plugin_basename(ABJ404_FILE))) { switch_to_blog($blog_id); self::activateSingleSite(); restore_current_blog(); } } /** * Handle new blog creation in multisite (WordPress >= 5.1). * This is triggered by the wp_initialize_site action. * * @param WP_Site $site The site object for the new site * @param array $args Additional arguments passed to the hook * @return void */ static function activateNewSiteModern($site, $args): void { // Only activate if the plugin is network-activated if (is_plugin_active_for_network(plugin_basename(ABJ404_FILE))) { switch_to_blog((int)$site->blog_id); self::activateSingleSite(); restore_current_blog(); } } /** * Handle plugin deactivation for both single-site and multisite. * * @param bool $network_wide Whether this is a network-wide deactivation * @return void */ static function runOnPluginDeactivation(bool $network_wide = false): void { if (is_multisite() && $network_wide) { // Network deactivation: deactivate for all sites $sites = get_sites(array('fields' => 'ids', 'number' => 0)); foreach ($sites as $blog_id) { switch_to_blog($blog_id); self::deactivateSingleSite(); restore_current_blog(); } } else { // Single site deactivation self::deactivateSingleSite(); } } /** * Deactivate plugin for a single site. * Unregisters cron jobs. * @return void */ private static function deactivateSingleSite(): void { self::doUnregisterCrons(); } /** * Clean up when a blog is deleted in multisite. * This is triggered by the delete_blog action. * * @global wpdb $wpdb WordPress database object * @param int $blog_id Blog ID being deleted * @param bool $drop Whether to drop the tables (true) or just deactivate (false) * @return void */ static function deleteBlogData($blog_id, $drop = false): void { if ($drop) { switch_to_blog($blog_id); global $wpdb; $dao = ABJ_404_Solution_DataAccess::getInstance(); $prefix = $dao->getLowercasePrefix(); // Remove ALL custom database tables via dynamic discovery. // SHOW TABLES is the source of truth — new tables are automatically included. $tables = $wpdb->get_results( $wpdb->prepare("SHOW TABLES LIKE %s", $wpdb->esc_like($prefix . 'abj404_') . '%'), ARRAY_N ); foreach ($tables as $tableRow) { $tblName = is_array($tableRow) && isset($tableRow[0]) ? $tableRow[0] : ''; if (preg_match('/^[a-zA-Z0-9_]+$/', $tblName) && strpos($tblName, 'abj404') !== false) { $wpdb->query("DROP TABLE IF EXISTS `{$tblName}`"); } } // Remove ALL plugin options $plugin_options = array( 'abj404_settings', 'abj404_db_version', 'abj404_migrated_to_relative_paths', 'abj404_migration_results', 'abj404_ngram_cache_initialized', 'abj404_ngram_rebuild_offset', 'abj404_ngram_usage_stats', 'abj404_installed_time', 'abj404_user_feedback', 'abj404_uninstall_preferences' ); foreach ($plugin_options as $option) { delete_option($option); } // Delete dynamic sync options (using LIKE pattern) $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", $wpdb->esc_like('abj404_sync_') . '%' ) ); // Clear ALL scheduled cron jobs for this blog $cron_hooks = array( 'abj404_cleanupCronAction', 'abj404_updateLogsHitsTableAction', 'abj404_updatePermalinkCacheAction', 'abj404_rebuild_ngram_cache_hook', 'abj404_gsc_fetch_cron', 'abj404_gsc_background_refresh', ); foreach ($cron_hooks as $hook) { wp_clear_scheduled_hook($hook); } // Also clear legacy cron hooks $legacy_hooks = array( 'abj404_duplicateCronAction', 'abj404_updatePermalinkCache', 'abj404_cleanupCron' ); foreach ($legacy_hooks as $hook) { wp_clear_scheduled_hook($hook); } restore_current_blog(); } } /** @return void */ static function doRegisterCrons(): void { if (!wp_next_scheduled('abj404_cleanupCronAction')) { // we randomize this so that when the geo2ip file is downloaded, there aren't a whole // lot of users that request the file at the same time. $timeForEvent = '0' . random_int(0, 5) . ':' . random_int(10, 59) . ':' . random_int(10, 59); $eventTimestamp = strtotime($timeForEvent); if ($eventTimestamp !== false) { wp_schedule_event($eventTimestamp, 'daily', 'abj404_cleanupCronAction'); } } if (!wp_next_scheduled('abj404_gsc_fetch_cron')) { $timeForGsc = '0' . random_int(1, 4) . ':' . random_int(10, 59) . ':' . random_int(10, 59); $gscTimestamp = strtotime($timeForGsc); if ($gscTimestamp !== false) { wp_schedule_event($gscTimestamp, 'daily', 'abj404_gsc_fetch_cron'); } } } }