'1.0', 'plugin_version' => AWBSEO_VERSION, 'exported_at' => gmdate( 'c' ), 'site_url' => get_site_url(), 'settings' => [], 'redirects' => [], 'rank_history' => [], 'local_seo' => [], 'post_meta' => [], ]; // Options. foreach ( self::$option_keys as $key ) { $value = get_option( $key, null ); if ( null !== $value ) { $payload['settings'][ $key ] = $value; } } // Redirects table. $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery "SELECT source, target, type, hits, created_at FROM {$wpdb->prefix}awb_redirects", ARRAY_A ); $payload['redirects'] = $rows ?: []; // Rank history table. $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery "SELECT * FROM {$wpdb->prefix}awb_rank_history", ARRAY_A ); $payload['rank_history'] = $rows ?: []; // Local SEO table. $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery "SELECT * FROM {$wpdb->prefix}awb_local_seo", ARRAY_A ); $payload['local_seo'] = $rows ?: []; // Post meta - group by post, include URL for cross-site matching. $placeholders = implode( ',', array_fill( 0, count( self::$meta_keys ), '%s' ) ); $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( "SELECT post_id, meta_key, meta_value FROM {$wpdb->postmeta} WHERE meta_key IN ( {$placeholders} )", ...self::$meta_keys ), ARRAY_A ); $by_post = []; foreach ( $rows as $row ) { $by_post[ $row['post_id'] ][ $row['meta_key'] ] = $row['meta_value']; } foreach ( $by_post as $post_id => $meta ) { $post = get_post( $post_id ); if ( ! $post ) { continue; } $payload['post_meta'][] = [ 'post_id' => $post_id, 'post_url' => get_permalink( $post_id ), 'post_slug' => $post->post_name, 'post_type' => $post->post_type, 'meta' => $meta, ]; } return $payload; } /** * AJAX handler - streams the JSON file to the browser. */ public static function handle_export() { check_ajax_referer( 'awbseo_portability', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Permission denied.', 'adworksbid-seo' ), 403 ); } $payload = self::build_export(); $json = wp_json_encode( $payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ); $filename = 'awbseo-export-' . gmdate( 'Y-m-d' ) . '.json'; nocache_headers(); header( 'Content-Type: application/json; charset=utf-8' ); header( 'Content-Disposition: attachment; filename="' . $filename . '"' ); header( 'Content-Length: ' . strlen( $json ) ); echo $json; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped exit; } // ── Import ─────────────────────────────────────────────────────────────────── /** * AJAX handler - reads an uploaded JSON file and restores data. * * Accepts a `scope` POST param (comma-separated): * settings | redirects | rank_history | local_seo | post_meta | all */ public static function handle_import() { check_ajax_referer( 'awbseo_portability', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( [ 'message' => __( 'Permission denied.', 'adworksbid-seo' ) ], 403 ); } if ( empty( $_FILES['import_file']['tmp_name'] ) ) { wp_send_json_error( [ 'message' => __( 'No file uploaded.', 'adworksbid-seo' ) ] ); } $tmp = sanitize_text_field( wp_unslash( $_FILES['import_file']['tmp_name'] ) ); $json = file_get_contents( $tmp ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents $data = json_decode( $json, true ); if ( ! is_array( $data ) || empty( $data['awbseo_export_version'] ) ) { wp_send_json_error( [ 'message' => __( 'Invalid export file.', 'adworksbid-seo' ) ] ); } $scope = isset( $_POST['scope'] ) ? sanitize_text_field( wp_unslash( $_POST['scope'] ) ) : 'all'; $scopes = ( 'all' === $scope ) ? [ 'settings', 'redirects', 'rank_history', 'local_seo', 'post_meta' ] : explode( ',', $scope ); $results = []; if ( in_array( 'settings', $scopes, true ) ) { $results['settings'] = self::import_settings( $data['settings'] ?? [] ); } if ( in_array( 'redirects', $scopes, true ) ) { $results['redirects'] = self::import_redirects( $data['redirects'] ?? [] ); } if ( in_array( 'rank_history', $scopes, true ) ) { $results['rank_history'] = self::import_rank_history( $data['rank_history'] ?? [] ); } if ( in_array( 'local_seo', $scopes, true ) ) { $results['local_seo'] = self::import_local_seo( $data['local_seo'] ?? [] ); } if ( in_array( 'post_meta', $scopes, true ) ) { $results['post_meta'] = self::import_post_meta( $data['post_meta'] ?? [] ); } wp_send_json_success( [ 'message' => __( 'Import complete.', 'adworksbid-seo' ), 'results' => $results, 'source' => [ 'site_url' => $data['site_url'] ?? '', 'exported_at' => $data['exported_at'] ?? '', 'plugin_version' => $data['plugin_version'] ?? '', ], ] ); } private static function import_settings( array $settings ) { $count = 0; foreach ( $settings as $key => $value ) { // Only restore keys this plugin owns. if ( ! in_array( $key, self::$option_keys, true ) ) { continue; } // Never overwrite live API tokens - user must reconnect on the new site. if ( in_array( $key, [ 'awbseo_gsc_tokens', 'awbseo_ga4_tokens' ], true ) ) { continue; } update_option( $key, $value ); $count++; } return [ 'restored' => $count ]; } private static function import_redirects( array $rows ) { global $wpdb; $inserted = 0; $skipped = 0; foreach ( $rows as $row ) { if ( empty( $row['source'] ) || empty( $row['target'] ) ) { $skipped++; continue; } $result = $wpdb->replace( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prefix . 'awb_redirects', [ 'source' => $row['source'], 'target' => $row['target'], 'type' => absint( $row['type'] ?? 301 ), 'hits' => absint( $row['hits'] ?? 0 ), 'created_at' => $row['created_at'] ?? current_time( 'mysql', true ), ] ); $result ? $inserted++ : $skipped++; } return [ 'inserted' => $inserted, 'skipped' => $skipped ]; } private static function import_rank_history( array $rows ) { global $wpdb; $inserted = 0; foreach ( $rows as $row ) { unset( $row['id'] ); // Let auto-increment assign a new ID. $result = $wpdb->insert( $wpdb->prefix . 'awb_rank_history', $row ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery if ( $result ) { $inserted++; } } return [ 'inserted' => $inserted ]; } private static function import_local_seo( array $rows ) { global $wpdb; $inserted = 0; foreach ( $rows as $row ) { unset( $row['id'] ); $result = $wpdb->replace( $wpdb->prefix . 'awb_local_seo', $row ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery if ( $result ) { $inserted++; } } return [ 'inserted' => $inserted ]; } private static function import_post_meta( array $items ) { $matched = 0; $unmatched = 0; foreach ( $items as $item ) { $post_id = self::resolve_post_id( $item ); if ( ! $post_id ) { $unmatched++; continue; } foreach ( $item['meta'] as $key => $value ) { if ( ! in_array( $key, self::$meta_keys, true ) ) { continue; } update_post_meta( $post_id, $key, $value ); } $matched++; } return [ 'matched' => $matched, 'unmatched' => $unmatched ]; } /** * Find the local post ID for an exported post. * * Strategy: * 1. If the site URL matches (same site), trust the post ID directly. * 2. Otherwise try to find by URL (handles domain changes). * 3. Fall back to slug + post type. */ private static function resolve_post_id( array $item ) { $post_id = absint( $item['post_id'] ?? 0 ); $post_url = $item['post_url'] ?? ''; $post_slug = $item['post_slug'] ?? ''; $post_type = $item['post_type'] ?? 'any'; // Same-site: verify the post ID still exists. if ( $post_id && get_post( $post_id ) ) { return $post_id; } // Cross-site: match by URL path (strip the host so domain changes don't matter). if ( $post_url ) { $path = wp_parse_url( $post_url, PHP_URL_PATH ); if ( $path ) { $local_id = url_to_postid( home_url( $path ) ); if ( $local_id ) { return $local_id; } } } // Last resort: match by slug and post type. if ( $post_slug ) { $posts = get_posts( [ 'name' => $post_slug, 'post_type' => $post_type ?: 'any', 'post_status' => 'any', 'posts_per_page' => 1, 'fields' => 'ids', ] ); if ( ! empty( $posts ) ) { return $posts[0]; } } return 0; } }