AUM NexCart > Tools: * * 1. CSV Export — streams a CSV of every product. Synchronous; * served as an attachment download. * * 2. CSV Import — uploads a CSV, stores it in a transient, then * processes batches of 10 rows via AJAX with a progress bar. * A failures log is returned at the end. * * 3. Rebuild Attribute Index — walks every product and rewrites * the filterable shadow meta rows. Useful after changing the * filterable_attributes list in settings. * * Capability gate: manage_options for all three operations. */ defined( 'ABSPATH' ) || exit; class AumNexCart_Tools { const AJAX_IMPORT_BATCH = 'aumnexcart_import_batch'; const AJAX_REINDEX = 'aumnexcart_reindex'; const NONCE_TOOLS = 'aumnexcart_tools'; const TRANSIENT_IMPORT = 'aumnexcart_import_'; const BATCH_SIZE_IMPORT = 10; const BATCH_SIZE_REINDEX = 20; /** * Canonical CSV column order. Imports must match this header * exactly (case-sensitive); exports use these in this order. */ const CSV_COLUMNS = array( 'title', 'slug', 'status', 'categories', 'description', 'price_type', 'price_min', 'price_max', 'price_text', 'price_currency', 'attributes', 'media_urls', 'contact', ); public function __construct() { add_action( 'admin_post_aumnexcart_export_csv', array( $this, 'handle_export' ) ); add_action( 'admin_post_aumnexcart_import_upload', array( $this, 'handle_upload' ) ); add_action( 'wp_ajax_' . self::AJAX_IMPORT_BATCH, array( $this, 'ajax_import_batch' ) ); add_action( 'wp_ajax_' . self::AJAX_REINDEX, array( $this, 'ajax_reindex' ) ); } /* ================================================================ * EXPORT * ================================================================ */ /** * Streams the CSV directly to the browser. Uses php://output so * memory stays bounded even with thousands of products. */ public function handle_export() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Insufficient permissions.', 'aumnexcart' ) ); } check_admin_referer( self::NONCE_TOOLS ); $filename = 'aumnexcart-products-' . gmdate( 'Y-m-d' ) . '.csv'; nocache_headers(); header( 'Content-Type: text/csv; charset=utf-8' ); header( 'Content-Disposition: attachment; filename=' . $filename ); /* * No phpcs:ignore needed on this line: WPCS's AlternativeFunctions sniff keeps * `php://output` on an allowlist, so opening it is never reported as a filesystem call. * The writes below it are reported, and carry their own reason. */ $out = fopen( 'php://output', 'w' ); /* BOM for Excel UTF-8 compatibility */ // phpcs:ignore WordPress.WP.AlternativeFunctions -- this is the CSV download stream, not the filesystem: the handle is php://output, opened after the Content-Disposition header above, so every write goes straight to the browser. WP_Filesystem abstracts disk access (and FTP/SSH credentials) and has no path for an output stream. fwrite( $out, "\xEF\xBB\xBF" ); fputcsv( $out, self::CSV_COLUMNS ); $paged = 1; do { $q = new WP_Query( array( 'post_type' => AumNexCart_Post_Type::POST_TYPE, 'post_status' => array( 'publish', 'draft', 'pending', 'private' ), 'posts_per_page' => 50, 'paged' => $paged, 'no_found_rows' => true, 'orderby' => 'ID', 'order' => 'ASC', ) ); if ( ! $q->have_posts() ) { break; } foreach ( $q->posts as $post ) { fputcsv( $out, self::row_for_post( $post ) ); } $paged++; if ( count( $q->posts ) < 50 ) { break; } } while ( true ); // phpcs:ignore WordPress.WP.AlternativeFunctions -- this is the CSV download stream, not the filesystem: the handle is php://output, opened after the Content-Disposition header above, so every write goes straight to the browser. WP_Filesystem abstracts disk access (and FTP/SSH credentials) and has no path for an output stream. fclose( $out ); exit; } /** * Builds one CSV row from a post. * * @param WP_Post $post * @return array */ private static function row_for_post( $post ) { $cats = wp_get_object_terms( $post->ID, AumNexCart_Post_Type::TAXONOMY, array( 'fields' => 'names' ) ); if ( is_wp_error( $cats ) ) { $cats = array(); } $price = AumNexCart_Meta::get_price( $post->ID ); $media = AumNexCart_Meta::get_media( $post->ID ); $urls = array(); foreach ( $media as $m ) { $urls[] = $m['url']; } return array( $post->post_title, $post->post_name, $post->post_status, implode( '|', $cats ), $post->post_content, $price['type'], $price['min'], $price['max'], $price['text'], $price['currency'], wp_json_encode( AumNexCart_Meta::get_attributes( $post->ID ) ), implode( '|', $urls ), wp_json_encode( array_map( function ( $r ) { return array( 'type' => $r['type'], 'label' => $r['label'], 'value' => $r['value'], ); }, AumNexCart_Meta::get_contact( $post->ID ) ) ), ); } /* ================================================================ * IMPORT * ================================================================ */ /** * Receives the uploaded CSV, validates the header, parses all * rows into a transient, and redirects to the Tools tab with * the import token in the URL so JS can begin processing. */ public function handle_upload() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Insufficient permissions.', 'aumnexcart' ) ); } check_admin_referer( self::NONCE_TOOLS ); if ( empty( $_FILES['aumnexcart_csv']['tmp_name'] ) ) { self::redirect_tools( array( 'import_error' => 'no_file' ) ); } $path = sanitize_text_field( wp_unslash( $_FILES['aumnexcart_csv']['tmp_name'] ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions -- reading PHP's own upload temp file ($_FILES['tmp_name']) row by row. WP_Filesystem can only hand back the whole file at once (get_contents_array), which would put an arbitrarily large catalogue import in memory, and fgetcsv needs a handle. The path never comes from user input. $fh = fopen( $path, 'r' ); if ( ! $fh ) { self::redirect_tools( array( 'import_error' => 'unreadable' ) ); } $header = fgetcsv( $fh ); if ( ! $header ) { // phpcs:ignore WordPress.WP.AlternativeFunctions -- reading PHP's own upload temp file ($_FILES['tmp_name']) row by row. WP_Filesystem can only hand back the whole file at once (get_contents_array), which would put an arbitrarily large catalogue import in memory, and fgetcsv needs a handle. The path never comes from user input. fclose( $fh ); self::redirect_tools( array( 'import_error' => 'empty' ) ); } /* Strip BOM from first header cell if present */ if ( isset( $header[0] ) ) { $header[0] = preg_replace( '/^\xEF\xBB\xBF/', '', $header[0] ); } $expected = self::CSV_COLUMNS; $matches = array_map( 'strval', array_slice( $header, 0, count( $expected ) ) ); if ( $matches !== $expected ) { // phpcs:ignore WordPress.WP.AlternativeFunctions -- reading PHP's own upload temp file ($_FILES['tmp_name']) row by row. WP_Filesystem can only hand back the whole file at once (get_contents_array), which would put an arbitrarily large catalogue import in memory, and fgetcsv needs a handle. The path never comes from user input. fclose( $fh ); self::redirect_tools( array( 'import_error' => 'bad_header' ) ); } $rows = array(); while ( ( $row = fgetcsv( $fh ) ) !== false ) { if ( count( array_filter( $row, 'strlen' ) ) === 0 ) { continue; } $rows[] = $row; } // phpcs:ignore WordPress.WP.AlternativeFunctions -- reading PHP's own upload temp file ($_FILES['tmp_name']) row by row. WP_Filesystem can only hand back the whole file at once (get_contents_array), which would put an arbitrarily large catalogue import in memory, and fgetcsv needs a handle. The path never comes from user input. fclose( $fh ); if ( empty( $rows ) ) { self::redirect_tools( array( 'import_error' => 'empty' ) ); } $token = wp_generate_password( 12, false ); set_transient( self::TRANSIENT_IMPORT . $token, array( 'rows' => $rows, 'total' => count( $rows ), 'processed' => 0, 'failed' => array(), 'success' => 0, ), HOUR_IN_SECONDS ); self::redirect_tools( array( 'import_token' => $token ) ); } /** * Processes one batch of rows. Returns progress info as JSON. */ public function ajax_import_batch() { check_ajax_referer( self::NONCE_TOOLS, 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Permission denied.', 'aumnexcart' ) ) ); } $token = isset( $_POST['token'] ) ? sanitize_text_field( wp_unslash( $_POST['token'] ) ) : ''; if ( '' === $token ) { wp_send_json_error( array( 'message' => __( 'Missing token.', 'aumnexcart' ) ) ); } $state = get_transient( self::TRANSIENT_IMPORT . $token ); if ( ! is_array( $state ) ) { wp_send_json_error( array( 'message' => __( 'Import session expired.', 'aumnexcart' ) ) ); } $processed = (int) $state['processed']; $end = min( $processed + self::BATCH_SIZE_IMPORT, $state['total'] ); for ( $i = $processed; $i < $end; $i++ ) { $row = $state['rows'][ $i ]; $result = $this->import_row( $row, $i + 2 ); /* +2: header row + 1-based */ if ( is_wp_error( $result ) ) { $state['failed'][] = array( 'line' => $i + 2, 'message' => $result->get_error_message(), ); } else { $state['success']++; } } $state['processed'] = $end; $done = $end >= $state['total']; set_transient( self::TRANSIENT_IMPORT . $token, $state, HOUR_IN_SECONDS ); $response = array( 'processed' => $state['processed'], 'total' => $state['total'], 'success' => $state['success'], 'failed' => count( $state['failed'] ), 'done' => $done, ); if ( $done ) { $response['failures'] = $state['failed']; delete_transient( self::TRANSIENT_IMPORT . $token ); } wp_send_json_success( $response ); } /** * Imports one CSV row. * * @param array $row * @param int $line Line number in original file (for error reporting). * @return int|WP_Error Post ID on success. */ private function import_row( $row, $line ) { $cols = array_combine( self::CSV_COLUMNS, array_pad( array_slice( $row, 0, count( self::CSV_COLUMNS ) ), count( self::CSV_COLUMNS ), '' ) ); if ( '' === trim( (string) $cols['title'] ) ) { /* translators: %d: line number in the CSV file */ return new WP_Error( 'no_title', sprintf( __( 'Line %d: missing title.', 'aumnexcart' ), $line ) ); } $status = in_array( $cols['status'], array( 'publish', 'draft', 'pending', 'private' ), true ) ? $cols['status'] : 'publish'; /* * 🔴 **CSV 的内容不是导入者打出来的,是文件给的。** * * `wp_insert_post()` 只对**没有** `unfiltered_html` 的用户跑 kses,而单站管理员是有的。 * 于是一份 CSV 里的 `