// -------------------------------------------------------- // AJAX — BULK IMPORT // -------------------------------------------------------- /** * Resolve symbols using a ranked market list first, then fall back to full CoinGecko list. * This avoids bad matches where duplicate symbols exist across obscure coins. * * Returns: * [ * 'coin_ids' => ['bitcoin', 'ethereum'], * 'unresolved' => ['ABC'], * 'ambiguous' => ['PAY'], * ] */ private function resolve_bulk_symbols( string $symbols ): array { $raw_symbols = preg_split( '/[\s,;\n\r\t]+/', strtolower( trim( $symbols ) ), -1, PREG_SPLIT_NO_EMPTY ); $raw_symbols = is_array( $raw_symbols ) ? array_values( array_unique( $raw_symbols ) ) : []; if ( empty( $raw_symbols ) ) { return [ 'coin_ids' => [], 'unresolved' => [], 'ambiguous' => [], ]; } $resolved = []; $unresolved = []; $ambiguous = []; // 1) Prefer the top market-cap universe first // This is much safer than blindly taking the first match from /coins/list. $market_rows = []; $page = 1; $per_page = 250; $max_pages = 4; // top 1000 is enough for almost all real-world symbols while ( $page <= $max_pages ) { $rows = CPWP_CoinGecko::instance()->fetch_markets( $per_page, $page ); if ( empty( $rows ) ) { break; } $market_rows = array_merge( $market_rows, $rows ); if ( count( $rows ) < $per_page ) { break; } $page++; } $ranked_symbol_map = []; $ranked_id_map = []; foreach ( $market_rows as $row ) { $coin_id = sanitize_key( (string) ( $row['coin_id'] ?? '' ) ); $symbol = strtolower( (string) ( $row['symbol'] ?? '' ) ); if ( $coin_id === '' ) { continue; } $ranked_id_map[ $coin_id ] = $coin_id; if ( $symbol !== '' && ! isset( $ranked_symbol_map[ $symbol ] ) ) { // fetch_markets() is already rank-ordered by market cap desc $ranked_symbol_map[ $symbol ] = $coin_id; } } // 2) Fallback to full coin list only when needed $coin_list = CPWP_CoinGecko::instance()->fetch_coin_list(); $list_symbol_matches = []; $list_id_map = []; foreach ( $coin_list as $id => $info ) { $coin_id = sanitize_key( (string) $id ); $symbol = strtolower( (string) ( $info['symbol'] ?? '' ) ); if ( $coin_id === '' ) { continue; } $list_id_map[ $coin_id ] = $coin_id; if ( $symbol !== '' ) { if ( ! isset( $list_symbol_matches[ $symbol ] ) ) { $list_symbol_matches[ $symbol ] = []; } $list_symbol_matches[ $symbol ][] = $coin_id; } } foreach ( $raw_symbols as $input ) { $input = sanitize_key( $input ); if ( $input === '' ) { continue; } // Exact coin_id match always wins if ( isset( $ranked_id_map[ $input ] ) ) { $resolved[] = $input; continue; } if ( isset( $list_id_map[ $input ] ) ) { $resolved[] = $input; continue; } // Ranked market-cap symbol match if ( isset( $ranked_symbol_map[ $input ] ) ) { $resolved[] = $ranked_symbol_map[ $input ]; continue; } // Fallback symbol match from full list if ( isset( $list_symbol_matches[ $input ] ) ) { $matches = array_values( array_unique( $list_symbol_matches[ $input ] ) ); if ( count( $matches ) === 1 ) { $resolved[] = $matches[0]; } else { // Too risky to guess when the symbol is shared $ambiguous[] = strtoupper( $input ); } continue; } $unresolved[] = strtoupper( $input ); } return [ 'coin_ids' => array_values( array_unique( array_filter( $resolved ) ) ), 'unresolved' => array_values( array_unique( $unresolved ) ), 'ambiguous' => array_values( array_unique( $ambiguous ) ), ]; } /** * Fetch top-N coin ids by market cap with pagination support. * This is safer than a single min($top_n, 250) call. */ private function resolve_topn_coin_ids( int $top_n ): array { $top_n = max( 1, min( absint( $top_n ), 1000 ) ); $coin_ids = []; $page = 1; $per_page = min( 250, $top_n ); while ( count( $coin_ids ) < $top_n ) { $rows = CPWP_CoinGecko::instance()->fetch_markets( $per_page, $page ); if ( empty( $rows ) ) { break; } foreach ( $rows as $row ) { $coin_id = sanitize_key( (string) ( $row['coin_id'] ?? '' ) ); if ( $coin_id !== '' ) { $coin_ids[] = $coin_id; } if ( count( $coin_ids ) >= $top_n ) { break 2; } } if ( count( $rows ) < $per_page ) { break; } $page++; } return array_values( array_unique( array_filter( $coin_ids ) ) ); } /** * Step 1: Resolve a list of symbols or a "topN" request to coin_ids. * Returns an ordered array of coin_ids ready for sequential import. */ public function ajax_bulk_resolve(): void { $this->check_ajax(); $mode = sanitize_key( $_POST['mode'] ?? 'symbols' ); $top_n = absint( $_POST['top_n'] ?? 100 ); $symbols = sanitize_textarea_field( $_POST['symbols'] ?? '' ); if ( $mode === 'topn' ) { $coin_ids = $this->resolve_topn_coin_ids( $top_n ); if ( empty( $coin_ids ) ) { wp_send_json_error( 'No top coins could be resolved.' ); } wp_send_json_success( [ 'coin_ids' => $coin_ids, 'total' => count( $coin_ids ), 'unresolved' => [], 'ambiguous' => [], ] ); return; } $resolved = $this->resolve_bulk_symbols( $symbols ); if ( empty( $resolved['coin_ids'] ) ) { $message = 'No symbols resolved.'; if ( ! empty( $resolved['ambiguous'] ) ) { $message .= ' Ambiguous: ' . implode( ', ', $resolved['ambiguous'] ) . '.'; } if ( ! empty( $resolved['unresolved'] ) ) { $message .= ' Not found: ' . implode( ', ', $resolved['unresolved'] ) . '.'; } wp_send_json_error( $message ); } wp_send_json_success( [ 'coin_ids' => $resolved['coin_ids'], 'total' => count( $resolved['coin_ids'] ), 'unresolved' => $resolved['unresolved'], 'ambiguous' => $resolved['ambiguous'], ] ); } /** * Step 2: Import a single coin (called sequentially by JS for each coin in the batch). * Creates the page + fetches data + generates AI content. * One coin per AJAX call = each request is short and won't timeout. */ public function ajax_bulk_import_coin(): void { $this->check_ajax(); $coin_id = sanitize_key( $_POST['coin_id'] ?? '' ); $skip_ai = ! empty( $_POST['skip_ai'] ); $ai_section = sanitize_key( $_POST['ai_section'] ?? '' ); if ( ! $coin_id ) { wp_send_json_error( 'Missing coin_id' ); } $list = get_option( 'cpwp_coins_with_pages', [] ); if ( ! is_array( $list ) ) { $list = []; } $list = array_values( array_unique( array_filter( array_map( 'sanitize_key', $list ) ) ) ); $already_exists = in_array( $coin_id, $list, true ); if ( ! $already_exists ) { // Fetch and store full details first CPWP_Data_Manager::instance()->fetch_extended_details( $coin_id ); $list[] = $coin_id; $list = array_values( array_unique( array_filter( $list ) ) ); update_option( 'cpwp_coins_with_pages', $list, false ); } $ai_result = null; if ( ! $skip_ai ) { if ( $ai_section ) { $data = CPWP_Data_Manager::instance()->get_page_data( $coin_id ); if ( $data ) { $engine = CPWP_Content_Engine::instance(); switch ( $ai_section ) { case 'market_pulse': $ai_result = $engine->generate_market_pulse( $coin_id ); break; case 'price_drivers': $ai_result = $engine->generate_and_store_section( $coin_id, 'price_drivers', $data ); break; case 'faq': $ai_result = $engine->generate_and_store_section( $coin_id, 'faq', $data ); break; case 'about': $ai_result = $engine->generate_and_store_section( $coin_id, 'about', $data ); break; } } } else { $ai_result = CPWP_Content_Engine::instance()->generate_market_pulse( $coin_id ); } } wp_send_json_success( [ 'coin_id' => $coin_id, 'url' => CPWP_Helpers::coin_url( $coin_id ), 'ai_done' => (bool) $ai_result, 'existed' => $already_exists, ] ); }