=')) { return; // Already migrated. } // Ensure tables exist before migration. if (!AILM_Link_DB::tables_exist()) { AILM_Link_DB::create_tables(); } // v1 → v2: migrate legacy scattered storage into unified tables. if (version_compare($current, '2.0', '<')) { self::migrate_frozen_maps_to_rules(); self::migrate_link_monitor_to_links(); } // v2.0 → v2.1: re-run create_tables() so dbDelta + ensure_in_html_column() // add the new `in_html` column to existing installs. This is additive and // non-breaking: existing rules get in_html=0 (treated as "needs injection", // i.e. the previous behaviour). if (version_compare($current, '2.1', '<')) { AILM_Link_DB::create_tables(); } update_option(self::MIGRATION_VERSION_KEY, self::CURRENT_DB_VERSION); } /** * Migrate _ailm_frozen_map postmeta entries to wp_ailm_link_rules. * * Each frozen map item becomes an individual rule row. * Maps old origins to new `source` values: * - 'rule-*' origin → 'ki_manual' * - Everything else → 'ai' * * @return int Number of rules migrated. */ private static function migrate_frozen_maps_to_rules() { global $wpdb; $rules_table = AILM_Link_DB::get_rules_table(); // Skip if rules already exist (re-run safety). // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $existing = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$rules_table}"); if ($existing > 0) { return 0; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $frozen_results = $wpdb->get_results( "SELECT post_id, meta_value FROM {$wpdb->postmeta} WHERE meta_key = '_ailm_frozen_map'" ); if (empty($frozen_results)) { return 0; } $count = 0; foreach ($frozen_results as $result) { $map = maybe_unserialize($result->meta_value); if (!is_array($map) || empty($map)) { continue; } foreach ($map as $item) { if (empty($item['url']) || empty($item['word'])) { continue; } $origin = isset($item['origin']) ? $item['origin'] : 'ai'; $occurrence = isset($item['occurrence']) ? (int) $item['occurrence'] : 1; $target_id = url_to_postid($item['url']); // Map old origin format to new source values. if (strpos($origin, 'rule-') === 0) { $source = 'ki_manual'; } elseif ('dashboard' === $origin) { $source = 'dashboard'; } else { $source = 'ai'; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $wpdb->insert( $rules_table, array( 'donor_id' => (int) $result->post_id, 'target_id' => $target_id ? $target_id : 0, 'target_url' => esc_url_raw($item['url']), 'anchor' => sanitize_text_field($item['word']), 'occurrence' => $occurrence, 'source' => $source, 'status' => 'active', ), array('%d', '%d', '%s', '%s', '%d', '%s', '%s') ); $count++; } } return $count; } /** * Migrate wp_ailm_link_monitor entries to wp_ailm_links. * * Maps old `link_origin` values to simplified `origin`: * - 'ai', 'ailm' → 'plugin' * - 'manual' → 'manual' * * Resolves target_id via url_to_postid for internal links. * * @return int Number of links migrated. */ private static function migrate_link_monitor_to_links() { global $wpdb; $links_table = AILM_Link_DB::get_links_table(); $old_table = $wpdb->prefix . 'ailm_link_monitor'; // Check if old table exists. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $old_exists = $wpdb->get_var( $wpdb->prepare('SHOW TABLES LIKE %s', $old_table) ); if ($old_exists !== $old_table) { return 0; } // Skip if links already exist (re-run safety). // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $existing = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$links_table}"); if ($existing > 0) { return 0; } // Process in batches of 200 to avoid memory issues. $batch_size = 200; $offset = 0; $total = 0; do { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$old_table} ORDER BY id ASC LIMIT %d OFFSET %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $batch_size, $offset ) ); if (empty($rows)) { break; } foreach ($rows as $row) { // Map old link_origin to new simplified origin. $origin = 'manual'; if (in_array($row->link_origin, array('ai', 'ailm'), true)) { $origin = 'plugin'; } // Resolve target_id for internal links. $target_id = 0; if ('internal' === $row->link_type && !empty($row->url)) { $target_id = url_to_postid($row->url); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $wpdb->insert( $links_table, array( 'donor_id' => (int) $row->post_id, 'target_id' => $target_id, 'url' => $row->url, 'anchor' => $row->anchor, 'origin' => $origin, 'link_type' => $row->link_type, 'position' => 0.5, // Default; will be recalculated on next scan. 'status_code' => (int) $row->status_code, 'last_checked' => $row->last_checked, 'rule_id' => null, ), array('%d', '%d', '%s', '%s', '%s', '%s', '%f', '%d', '%s', '%d') ); $total++; } $offset += $batch_size; } while (count($rows) === $batch_size); return $total; } }