'text/csv', 'txt' => 'text/plain', ) ); } /** * Constructor. */ public function __construct() { $default_steps = array( 'upload' => array( 'name' => __( 'Upload CSV file', 'woocommerce' ), 'view' => array( $this, 'upload_form' ), 'handler' => array( $this, 'upload_form_handler' ), ), 'mapping' => array( 'name' => __( 'Column mapping', 'woocommerce' ), 'view' => array( $this, 'mapping_form' ), 'handler' => '', ), 'import' => array( 'name' => __( 'Import', 'woocommerce' ), 'view' => array( $this, 'import' ), 'handler' => '', ), 'done' => array( 'name' => __( 'Done!', 'woocommerce' ), 'view' => array( $this, 'done' ), 'handler' => '', ), ); $this->steps = apply_filters( 'woocommerce_product_csv_importer_steps', $default_steps ); // phpcs:disable WordPress.Security.NonceVerification.Recommended $this->step = isset( $_REQUEST['step'] ) ? sanitize_key( $_REQUEST['step'] ) : current( array_keys( $this->steps ) ); $this->file = isset( $_REQUEST['file'] ) ? wc_clean( wp_unslash( $_REQUEST['file'] ) ) : ''; $this->update_existing = isset( $_REQUEST['update_existing'] ) ? (bool) $_REQUEST['update_existing'] : false; $this->delimiter = ! empty( $_REQUEST['delimiter'] ) ? wc_clean( wp_unslash( $_REQUEST['delimiter'] ) ) : ','; $this->map_preferences = isset( $_REQUEST['map_preferences'] ) ? (bool) $_REQUEST['map_preferences'] : false; $this->character_encoding = isset( $_REQUEST['character_encoding'] ) ? wc_clean( wp_unslash( $_REQUEST['character_encoding'] ) ) : 'UTF-8'; // phpcs:enable // Import mappings for CSV data. include_once __DIR__ . '/mappings/mappings.php'; if ( $this->map_preferences ) { add_filter( 'woocommerce_csv_product_import_mapped_columns', array( $this, 'auto_map_user_preferences' ), 9999 ); } } /** * Get the URL for the next step's screen. * * @param string $step slug (default: current step). * @return string URL for next step if a next step exists. * Admin URL if it's the last step. * Empty string on failure. */ public function get_next_step_link( $step = '' ) { if ( ! $step ) { $step = $this->step; } $keys = array_keys( $this->steps ); if ( end( $keys ) === $step ) { return admin_url(); } $step_index = array_search( $step, $keys, true ); if ( false === $step_index ) { return ''; } // add_query_arg() does not encode values, so characters like '+' in request-derived strings would be decoded as a space on the next request. $params = array( 'step' => $keys[ $step_index + 1 ], 'file' => rawurlencode( str_replace( DIRECTORY_SEPARATOR, '/', $this->file ) ), 'delimiter' => rawurlencode( $this->delimiter ), 'update_existing' => $this->update_existing, 'map_preferences' => $this->map_preferences, 'character_encoding' => rawurlencode( $this->character_encoding ), '_wpnonce' => wp_create_nonce( 'woocommerce-csv-importer' ), // wp_nonce_url() escapes & to & breaking redirects. ); return add_query_arg( $params ); } /** * Output header view. */ protected function output_header() { include __DIR__ . '/views/html-csv-import-header.php'; } /** * Output steps view. */ protected function output_steps() { include __DIR__ . '/views/html-csv-import-steps.php'; } /** * Output footer view. */ protected function output_footer() { include __DIR__ . '/views/html-csv-import-footer.php'; } /** * Add error message. * * @param string $message Error message. * @param array $actions List of actions with 'url' and 'label'. */ protected function add_error( $message, $actions = array() ) { $this->errors[] = array( 'message' => $message, 'actions' => $actions, ); } /** * Add error message. */ protected function output_errors() { if ( ! $this->errors ) { return; } foreach ( $this->errors as $error ) { echo '
'; echo '

' . esc_html( $error['message'] ) . '

'; if ( ! empty( $error['actions'] ) ) { echo '

'; foreach ( $error['actions'] as $action ) { echo '' . esc_html( $action['label'] ) . ' '; } echo '

'; } echo '
'; } } /** * Dispatch current step and show correct view. */ public function dispatch() { $output = ''; try { // phpcs:ignore WordPress.Security.NonceVerification.Missing if ( ! empty( $_POST['save_step'] ) && ! empty( $this->steps[ $this->step ]['handler'] ) ) { if ( is_callable( $this->steps[ $this->step ]['handler'] ) ) { call_user_func( $this->steps[ $this->step ]['handler'], $this ); } } ob_start(); if ( is_callable( $this->steps[ $this->step ]['view'] ) ) { call_user_func( $this->steps[ $this->step ]['view'], $this ); } $output = ob_get_clean(); } catch ( \Exception $e ) { $this->add_error( $e->getMessage() ); } $this->output_header(); $this->output_steps(); $this->output_errors(); echo $output; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- output is HTML we've generated ourselves. $this->output_footer(); } /** * Remove temporary products and mapping data left by the importer. * * @param int $cursor Highest placeholder ID earlier cleanup requests have examined. * @return int|null ID to resume from, or null once no placeholders are left. */ private static function cleanup_after_import( int $cursor = 0 ): ?int { global $wpdb; if ( 0 === $cursor ) { self::delete_original_id_markers(); } /** This filter is documented in includes/import/abstract-wc-product-importer.php */ $time_limit = (int) apply_filters( 'woocommerce_product_importer_default_time_limit', 20 ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingSinceComment $deadline = time() + $time_limit; // Page by ID so a placeholder that cannot be deleted is passed over instead of being read again. while ( true ) { // The claimed status is also selected so a request that died mid-batch does not strand its placeholders. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $post_ids = $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type IN ( 'product', 'product_variation' ) AND post_status IN ( %s, %s ) AND ID > %d ORDER BY ID ASC LIMIT %d", self::CLEANUP_PLACEHOLDER_STATUS, self::CLEANUP_CLAIMED_STATUS, $cursor, self::CLEANUP_BATCH_SIZE ) ); if ( ! $post_ids ) { return null; } $post_ids = array_map( 'absint', $post_ids ); $cursor = (int) end( $post_ids ); // Ascending IDs delete a placeholder parent before its variations, so WooCommerce removes them through the normal lifecycle. foreach ( $post_ids as $post_id ) { self::delete_claimed_placeholder( $post_id ); // One delete lifecycle can take seconds, so check the budget per placeholder rather than per batch. if ( time() >= $deadline ) { return $post_id; } } } } /** * Run one bounded pass of the post-import cleanup and tell the client where to resume. * * The cursor only ever skips placeholders forward, and the queries it feeds match nothing * but the importer's own temporary products, so it needs no server-side state of its own. * * @param int $cursor Highest placeholder ID earlier cleanup requests have examined. */ private static function dispatch_ajax_cleanup( int $cursor ): void { $next_cursor = self::cleanup_after_import( $cursor ); $response = array( 'position' => null === $next_cursor ? 'done' : self::CLEANUP_POSITION_PREFIX . $next_cursor, 'percentage' => 100, 'imported' => 0, 'imported_variations' => 0, 'failed' => 0, 'updated' => 0, 'skipped' => 0, ); if ( null === $next_cursor ) { $response['url'] = add_query_arg( array( '_wpnonce' => wp_create_nonce( 'woocommerce-csv-importer' ) ), admin_url( 'edit.php?post_type=product&page=product_importer&step=done' ) ); } wp_send_json_success( $response ); } /** * Release temporary products a cleanup request claimed but did not live to delete. * * A fatal inside the delete lifecycle leaves the claimed status behind, and the importer only * recognises a placeholder by the status it was created with, so the CSV row owning that SKU * would be skipped as an existing product. Healing at the start of a run keeps that contained. */ private static function release_stranded_cleanup_claims(): void { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $post_ids = $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type IN ( 'product', 'product_variation' ) AND post_status = %s", self::CLEANUP_CLAIMED_STATUS ) ); if ( ! $post_ids ) { return; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->posts} SET post_status = %s WHERE post_type IN ( 'product', 'product_variation' ) AND post_status = %s", self::CLEANUP_PLACEHOLDER_STATUS, self::CLEANUP_CLAIMED_STATUS ) ); wp_cache_delete_multiple( array_map( 'absint', $post_ids ), 'posts' ); } /** * Remove temporary mappings between CSV IDs and imported products. * * The last import batch clears them so a run abandoned during cleanup leaves none behind. * Markers from a run abandoned earlier are kept so a retry of the same file skips rows already imported. * Concurrent imports are unsupported: markers and placeholder cleanup are shared site-wide. */ private static function delete_original_id_markers(): void { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- The importer requires one uncached cleanup of its temporary mapping markers. $wpdb->delete( $wpdb->postmeta, array( 'meta_key' => '_original_id' ) ); } /** * Claim one importer placeholder and delete it only if the claim succeeded. * * Reading the status from the object cache cannot decide this: a process that completes a * placeholder does not invalidate this process's cache, so a published product would still * look like a placeholder and be force deleted. The guarded UPDATE makes the status check * atomic instead, the way ActionScheduler_DBStore::claim_actions() claims actions, and runs * per post so a placeholder completed while the batch is being deleted is still protected. * * @param int $post_id Candidate post ID. */ private static function delete_claimed_placeholder( int $post_id ): void { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->posts} SET post_status = %s WHERE ID = %d AND post_type IN ( 'product', 'product_variation' ) AND post_status IN ( %s, %s )", self::CLEANUP_CLAIMED_STATUS, $post_id, self::CLEANUP_PLACEHOLDER_STATUS, self::CLEANUP_CLAIMED_STATUS ) ); // The claim wrote the row behind the object cache's back, so read it back from the database. wp_cache_delete( $post_id, 'posts' ); $post = get_post( $post_id ); if ( ! $post || self::CLEANUP_CLAIMED_STATUS !== $post->post_status ) { return; } try { wp_delete_post( $post_id, true ); } finally { // Release the claim if the post outlived the delete lifecycle, so it stays a placeholder for the next run. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $released = $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->posts} SET post_status = %s WHERE ID = %d AND post_status = %s", self::CLEANUP_PLACEHOLDER_STATUS, $post_id, self::CLEANUP_CLAIMED_STATUS ) ); if ( $released ) { wp_cache_delete( $post_id, 'posts' ); } } } /** * Processes AJAX requests related to a product CSV import. * * @since 9.3.0 */ public static function dispatch_ajax() { check_ajax_referer( 'wc-product-import', 'security' ); try { $position = wc_clean( wp_unslash( $_POST['position'] ?? '' ) ); // Cleanup runs in requests of its own so it cannot time out behind the last import batch. if ( is_string( $position ) && preg_match( '/^' . preg_quote( self::CLEANUP_POSITION_PREFIX, '/' ) . '(\\d+)$/', $position, $matches ) ) { self::dispatch_ajax_cleanup( (int) $matches[1] ); return; } $file = wc_clean( wp_unslash( $_POST['file'] ?? '' ) ); // PHPCS: input var ok. self::validate_file_path( $file ); $params = array( 'delimiter' => ! empty( $_POST['delimiter'] ) ? wc_clean( wp_unslash( $_POST['delimiter'] ) ) : ',', // PHPCS: input var ok. 'start_pos' => absint( $position ), 'mapping' => isset( $_POST['mapping'] ) ? (array) wc_clean( wp_unslash( $_POST['mapping'] ) ) : array(), // PHPCS: input var ok. 'update_existing' => isset( $_POST['update_existing'] ) ? (bool) $_POST['update_existing'] : false, // PHPCS: input var ok. 'character_encoding' => isset( $_POST['character_encoding'] ) ? wc_clean( wp_unslash( $_POST['character_encoding'] ) ) : '', /** * Batch size for the product import process. * * @param int $size Batch size. * * @since 3.1.0 */ 'lines' => apply_filters( 'woocommerce_product_import_batch_size', 30 ), 'parse' => true, ); // Log failures. if ( 0 !== $params['start_pos'] ) { $error_log = array_filter( (array) get_user_option( 'product_import_error_log' ) ); } else { $error_log = array(); } if ( 0 === $params['start_pos'] ) { self::release_stranded_cleanup_claims(); } include_once WC_ABSPATH . 'includes/import/class-wc-product-csv-importer.php'; $importer = self::get_importer( $file, $params ); $results = $importer->import(); $percent_complete = $importer->get_percent_complete(); $error_log = array_merge( $error_log, $results['failed'], $results['skipped'] ); if ( 100 === $percent_complete ) { self::delete_original_id_markers(); } update_user_option( get_current_user_id(), 'product_import_error_log', $error_log ); // The last row hands over to the cleanup phase rather than running it in this request. $next_position = 100 === $percent_complete ? self::CLEANUP_POSITION_PREFIX . '0' : $importer->get_file_position(); wp_send_json_success( array( 'position' => $next_position, 'percentage' => $percent_complete, 'imported' => is_countable( $results['imported'] ) ? count( $results['imported'] ) : 0, 'imported_variations' => is_countable( $results['imported_variations'] ) ? count( $results['imported_variations'] ) : 0, 'failed' => is_countable( $results['failed'] ) ? count( $results['failed'] ) : 0, 'updated' => is_countable( $results['updated'] ) ? count( $results['updated'] ) : 0, 'skipped' => is_countable( $results['skipped'] ) ? count( $results['skipped'] ) : 0, ) ); } catch ( \Exception $e ) { wp_send_json_error( array( 'message' => $e->getMessage() ) ); } } /** * Output information about the uploading process. */ protected function upload_form() { $bytes = apply_filters( 'import_upload_size_limit', wp_max_upload_size() ); $size = size_format( $bytes ); $upload_dir = wp_upload_dir(); include __DIR__ . '/views/html-product-csv-import-form.php'; } /** * Handle the upload form and store options. */ public function upload_form_handler() { check_admin_referer( 'woocommerce-csv-importer' ); $file = $this->handle_upload(); if ( is_wp_error( $file ) ) { $this->add_error( $file->get_error_message() ); return; } else { $this->file = $file; } wp_redirect( esc_url_raw( $this->get_next_step_link() ) ); exit; } /** * Handles the CSV upload and initial parsing of the file to prepare for * displaying author import options. * * @return string|WP_Error */ public function handle_upload() { // phpcs:disable WordPress.Security.NonceVerification.Missing -- Nonce already verified in WC_Product_CSV_Importer_Controller::upload_form_handler() $file_url = isset( $_POST['file_url'] ) ? wc_clean( wp_unslash( $_POST['file_url'] ) ) : ''; try { if ( ! empty( $file_url ) ) { $path = ABSPATH . $file_url; self::validate_file_path( $path ); } else { $csv_import_util = wc_get_container()->get( Automattic\WooCommerce\Internal\Admin\ImportExport\CSVUploadHelper::class ); $upload = $csv_import_util->handle_csv_upload( 'product', 'import', self::get_valid_csv_filetypes() ); $path = $upload['file']; } return $path; } catch ( \Exception $e ) { return new \WP_Error( 'woocommerce_product_csv_importer_upload_invalid_file', $e->getMessage() ); } } /** * Mapping step. */ protected function mapping_form() { check_admin_referer( 'woocommerce-csv-importer' ); self::validate_file_path( $this->file ); $args = array( 'lines' => 1, 'delimiter' => $this->delimiter, 'character_encoding' => $this->character_encoding, ); $importer = self::get_importer( $this->file, $args ); $headers = $importer->get_raw_keys(); $mapped_items = $this->auto_map_columns( $headers ); $sample = current( $importer->get_raw_data() ); if ( empty( $sample ) ) { $this->add_error( __( 'The file is empty or using a different encoding than UTF-8, please try again with a new file.', 'woocommerce' ), array( array( 'url' => admin_url( 'edit.php?post_type=product&page=product_importer' ), 'label' => __( 'Upload a new file', 'woocommerce' ), ), ) ); // Force output the errors in the same page. $this->output_errors(); return; } include_once __DIR__ . '/views/html-csv-import-mapping.php'; } /** * Import the file if it exists and is valid. */ public function import() { // Displaying this page triggers Ajax action to run the import with a valid nonce, // therefore this page needs to be nonce protected as well. check_admin_referer( 'woocommerce-csv-importer' ); self::validate_file_path( $this->file ); if ( ! empty( $_POST['map_from'] ) && ! empty( $_POST['map_to'] ) ) { $mapping_from = wc_clean( wp_unslash( $_POST['map_from'] ) ); $mapping_to = wc_clean( wp_unslash( $_POST['map_to'] ) ); // Save mapping preferences for future imports. update_user_option( get_current_user_id(), 'woocommerce_product_import_mapping', $mapping_to ); } else { wp_redirect( esc_url_raw( $this->get_next_step_link( 'upload' ) ) ); exit; } wp_localize_script( 'wc-product-import', 'wc_product_import_params', array( 'import_nonce' => wp_create_nonce( 'wc-product-import' ), 'mapping' => array( 'from' => $mapping_from, 'to' => $mapping_to, ), 'file' => $this->file, 'update_existing' => $this->update_existing, 'delimiter' => $this->delimiter, 'character_encoding' => $this->character_encoding, ) ); wp_enqueue_script( 'wc-product-import' ); include_once __DIR__ . '/views/html-csv-import-progress.php'; } /** * Done step. */ protected function done() { check_admin_referer( 'woocommerce-csv-importer' ); $imported = isset( $_GET['products-imported'] ) ? absint( $_GET['products-imported'] ) : 0; $imported_variations = isset( $_GET['products-imported-variations'] ) ? absint( $_GET['products-imported-variations'] ) : 0; $updated = isset( $_GET['products-updated'] ) ? absint( $_GET['products-updated'] ) : 0; $failed = isset( $_GET['products-failed'] ) ? absint( $_GET['products-failed'] ) : 0; $skipped = isset( $_GET['products-skipped'] ) ? absint( $_GET['products-skipped'] ) : 0; $file_name = isset( $_GET['file-name'] ) ? sanitize_text_field( wp_unslash( $_GET['file-name'] ) ) : ''; $errors = array_filter( (array) get_user_option( 'product_import_error_log' ) ); include_once __DIR__ . '/views/html-csv-import-done.php'; } /** * Columns to normalize. * * @param array $columns List of columns names and keys. * @return array */ protected function normalize_columns_names( $columns ) { $normalized = array(); foreach ( $columns as $key => $value ) { $normalized[ strtolower( $key ) ] = $value; } return $normalized; } /** * Auto map column names. * * @param array $raw_headers Raw header columns. * @param bool $num_indexes If should use numbers or raw header columns as indexes. * @return array */ protected function auto_map_columns( $raw_headers, $num_indexes = true ) { $weight_unit_label = I18nUtil::get_weight_unit_label( get_option( 'woocommerce_weight_unit', 'kg' ) ); $dimension_unit_label = I18nUtil::get_dimensions_unit_label( get_option( 'woocommerce_dimension_unit', 'cm' ) ); $default_columns = array( __( 'ID', 'woocommerce' ) => 'id', __( 'Type', 'woocommerce' ) => 'type', __( 'SKU', 'woocommerce' ) => 'sku', __( 'Name', 'woocommerce' ) => 'name', __( 'Published', 'woocommerce' ) => 'published', __( 'Is featured?', 'woocommerce' ) => 'featured', __( 'Visibility in catalog', 'woocommerce' ) => 'catalog_visibility', __( 'Short description', 'woocommerce' ) => 'short_description', __( 'Description', 'woocommerce' ) => 'description', __( 'Date sale price starts', 'woocommerce' ) => 'date_on_sale_from', __( 'Date sale price ends', 'woocommerce' ) => 'date_on_sale_to', __( 'Tax status', 'woocommerce' ) => 'tax_status', __( 'Tax class', 'woocommerce' ) => 'tax_class', __( 'In stock?', 'woocommerce' ) => 'stock_status', __( 'Stock', 'woocommerce' ) => 'stock_quantity', __( 'Backorders allowed?', 'woocommerce' ) => 'backorders', __( 'Low stock amount', 'woocommerce' ) => 'low_stock_amount', __( 'Sold individually?', 'woocommerce' ) => 'sold_individually', /* translators: %s: Weight unit */ sprintf( __( 'Weight (%s)', 'woocommerce' ), $weight_unit_label ) => 'weight', /* translators: %s: Length unit */ sprintf( __( 'Length (%s)', 'woocommerce' ), $dimension_unit_label ) => 'length', /* translators: %s: Width unit */ sprintf( __( 'Width (%s)', 'woocommerce' ), $dimension_unit_label ) => 'width', /* translators: %s: Height unit */ sprintf( __( 'Height (%s)', 'woocommerce' ), $dimension_unit_label ) => 'height', __( 'Allow customer reviews?', 'woocommerce' ) => 'reviews_allowed', __( 'Purchase note', 'woocommerce' ) => 'purchase_note', __( 'Sale price', 'woocommerce' ) => 'sale_price', __( 'Regular price', 'woocommerce' ) => 'regular_price', __( 'Categories', 'woocommerce' ) => 'category_ids', __( 'Tags', 'woocommerce' ) => 'tag_ids', __( 'Shipping class', 'woocommerce' ) => 'shipping_class_id', __( 'Images', 'woocommerce' ) => 'images', __( 'Download limit', 'woocommerce' ) => 'download_limit', __( 'Download expiry days', 'woocommerce' ) => 'download_expiry', __( 'Parent', 'woocommerce' ) => 'parent_id', __( 'Upsells', 'woocommerce' ) => 'upsell_ids', __( 'Cross-sells', 'woocommerce' ) => 'cross_sell_ids', __( 'Grouped products', 'woocommerce' ) => 'grouped_products', __( 'External URL', 'woocommerce' ) => 'product_url', __( 'Button text', 'woocommerce' ) => 'button_text', __( 'Position', 'woocommerce' ) => 'menu_order', __( 'Commodity code (HS code)', 'woocommerce' ) => 'customs_commodity_code', __( 'Country of origin', 'woocommerce' ) => 'customs_country_of_origin', __( 'Customs description', 'woocommerce' ) => 'customs_description', ); if ( wc_get_container()->get( CostOfGoodsSoldController::class )->feature_is_enabled() ) { $default_columns[ __( 'Cost of goods', 'woocommerce' ) ] = 'cogs_value'; } /* * @hooked wc_importer_generic_mappings - 10 * @hooked wc_importer_wordpress_mappings - 10 * @hooked wc_importer_default_english_mappings - 100 */ $default_columns = $this->normalize_columns_names( apply_filters( 'woocommerce_csv_product_import_mapping_default_columns', $default_columns, $raw_headers ) ); $special_columns = $this->get_special_columns( $this->normalize_columns_names( apply_filters( 'woocommerce_csv_product_import_mapping_special_columns', array( /* translators: %d: Attribute number */ __( 'Attribute %d name', 'woocommerce' ) => 'attributes:name', /* translators: %d: Attribute number */ __( 'Attribute %d value(s)', 'woocommerce' ) => 'attributes:value', /* translators: %d: Attribute number */ __( 'Attribute %d visible', 'woocommerce' ) => 'attributes:visible', /* translators: %d: Attribute number */ __( 'Attribute %d global', 'woocommerce' ) => 'attributes:taxonomy', /* translators: %d: Attribute number */ __( 'Attribute %d default', 'woocommerce' ) => 'attributes:default', /* translators: %d: Download number */ __( 'Download %d ID', 'woocommerce' ) => 'downloads:id', /* translators: %d: Download number */ __( 'Download %d name', 'woocommerce' ) => 'downloads:name', /* translators: %d: Download number */ __( 'Download %d URL', 'woocommerce' ) => 'downloads:url', /* translators: %d: Meta number */ __( 'Meta: %s', 'woocommerce' ) => 'meta:', ), $raw_headers ) ) ); $headers = array(); foreach ( $raw_headers as $key => $field ) { $normalized_field = strtolower( $field ); $index = $num_indexes ? $key : $field; $headers[ $index ] = $normalized_field; if ( isset( $default_columns[ $normalized_field ] ) ) { $headers[ $index ] = $default_columns[ $normalized_field ]; } else { foreach ( $special_columns as $regex => $special_key ) { // Don't use the normalized field in the regex since meta might be case-sensitive. if ( preg_match( $regex, $field, $matches ) ) { $headers[ $index ] = $special_key . $matches[1]; break; } } } } return apply_filters( 'woocommerce_csv_product_import_mapped_columns', $headers, $raw_headers ); } /** * Map columns using the user's latest import mappings. * * @param array $headers Header columns. * @return array */ public function auto_map_user_preferences( $headers ) { $mapping_preferences = get_user_option( 'woocommerce_product_import_mapping' ); if ( ! empty( $mapping_preferences ) && is_array( $mapping_preferences ) ) { return $mapping_preferences; } return $headers; } /** * Sanitize special column name regex. * * @param string $value Raw special column name. * @return string */ protected function sanitize_special_column_name_regex( $value ) { return '/' . str_replace( array( '%d', '%s' ), '(.*)', trim( quotemeta( $value ) ) ) . '/i'; } /** * Get special columns. * * @param array $columns Raw special columns. * @return array */ protected function get_special_columns( $columns ) { $formatted = array(); foreach ( $columns as $key => $value ) { $regex = $this->sanitize_special_column_name_regex( $key ); $formatted[ $regex ] = $value; } return $formatted; } /** * Get mapping options. * * @param string $item Item name. * @return array */ protected function get_mapping_options( $item = '' ) { // Get index for special column names. $index = $item; if ( preg_match( '/\d+/', $item, $matches ) ) { $index = $matches[0]; } // Properly format for meta field. $meta = str_replace( 'meta:', '', $item ); // Available options. $weight_unit_label = I18nUtil::get_weight_unit_label( get_option( 'woocommerce_weight_unit', 'kg' ) ); $dimension_unit_label = I18nUtil::get_dimensions_unit_label( get_option( 'woocommerce_dimension_unit', 'cm' ) ); $options = array( 'id' => __( 'ID', 'woocommerce' ), 'type' => __( 'Type', 'woocommerce' ), 'sku' => __( 'SKU', 'woocommerce' ), 'global_unique_id' => __( 'GTIN, UPC, EAN, or ISBN', 'woocommerce' ), 'name' => __( 'Name', 'woocommerce' ), 'published' => __( 'Published', 'woocommerce' ), 'featured' => __( 'Is featured?', 'woocommerce' ), 'catalog_visibility' => __( 'Visibility in catalog', 'woocommerce' ), 'short_description' => __( 'Short description', 'woocommerce' ), 'description' => __( 'Description', 'woocommerce' ), 'price' => array( 'name' => __( 'Price', 'woocommerce' ), 'options' => array( 'regular_price' => __( 'Regular price', 'woocommerce' ), 'sale_price' => __( 'Sale price', 'woocommerce' ), 'date_on_sale_from' => __( 'Date sale price starts', 'woocommerce' ), 'date_on_sale_to' => __( 'Date sale price ends', 'woocommerce' ), ), ), 'tax_status' => __( 'Tax status', 'woocommerce' ), 'tax_class' => __( 'Tax class', 'woocommerce' ), 'stock_status' => __( 'In stock?', 'woocommerce' ), 'stock_quantity' => _x( 'Stock', 'Quantity in stock', 'woocommerce' ), 'backorders' => __( 'Backorders allowed?', 'woocommerce' ), 'low_stock_amount' => __( 'Low stock amount', 'woocommerce' ), 'sold_individually' => __( 'Sold individually?', 'woocommerce' ), /* translators: %s: weight unit */ 'weight' => sprintf( __( 'Weight (%s)', 'woocommerce' ), $weight_unit_label ), 'dimensions' => array( 'name' => __( 'Dimensions', 'woocommerce' ), 'options' => array( /* translators: %s: dimension unit */ 'length' => sprintf( __( 'Length (%s)', 'woocommerce' ), $dimension_unit_label ), /* translators: %s: dimension unit */ 'width' => sprintf( __( 'Width (%s)', 'woocommerce' ), $dimension_unit_label ), /* translators: %s: dimension unit */ 'height' => sprintf( __( 'Height (%s)', 'woocommerce' ), $dimension_unit_label ), ), ), 'category_ids' => __( 'Categories', 'woocommerce' ), 'tag_ids' => __( 'Tags (comma separated)', 'woocommerce' ), 'tag_ids_spaces' => __( 'Tags (space separated)', 'woocommerce' ), 'shipping_class_id' => __( 'Shipping class', 'woocommerce' ), 'images' => __( 'Images', 'woocommerce' ), 'parent_id' => __( 'Parent', 'woocommerce' ), 'upsell_ids' => __( 'Upsells', 'woocommerce' ), 'cross_sell_ids' => __( 'Cross-sells', 'woocommerce' ), 'grouped_products' => __( 'Grouped products', 'woocommerce' ), 'external' => array( 'name' => __( 'External product', 'woocommerce' ), 'options' => array( 'product_url' => __( 'External URL', 'woocommerce' ), 'button_text' => __( 'Button text', 'woocommerce' ), ), ), 'downloads' => array( 'name' => __( 'Downloads', 'woocommerce' ), 'options' => array( 'downloads:id' . $index => __( 'Download ID', 'woocommerce' ), 'downloads:name' . $index => __( 'Download name', 'woocommerce' ), 'downloads:url' . $index => __( 'Download URL', 'woocommerce' ), 'download_limit' => __( 'Download limit', 'woocommerce' ), 'download_expiry' => __( 'Download expiry days', 'woocommerce' ), ), ), 'attributes' => array( 'name' => __( 'Attributes', 'woocommerce' ), 'options' => array( 'attributes:name' . $index => __( 'Attribute name', 'woocommerce' ), 'attributes:value' . $index => __( 'Attribute value(s)', 'woocommerce' ), 'attributes:taxonomy' . $index => __( 'Is a global attribute?', 'woocommerce' ), 'attributes:visible' . $index => __( 'Attribute visibility', 'woocommerce' ), 'attributes:default' . $index => __( 'Default attribute', 'woocommerce' ), ), ), 'reviews_allowed' => __( 'Allow customer reviews?', 'woocommerce' ), 'purchase_note' => __( 'Purchase note', 'woocommerce' ), 'meta:' . $meta => __( 'Import as meta data', 'woocommerce' ), 'menu_order' => __( 'Position', 'woocommerce' ), ); $options['customs_commodity_code'] = __( 'Commodity code (HS code)', 'woocommerce' ); $options['customs_country_of_origin'] = __( 'Country of origin', 'woocommerce' ); $options['customs_description'] = __( 'Customs description', 'woocommerce' ); if ( wc_get_container()->get( CostOfGoodsSoldController::class )->feature_is_enabled() ) { $options['cogs_value'] = __( 'Cost of goods', 'woocommerce' ); } return apply_filters( 'woocommerce_csv_product_import_mapping_options', $options, $item ); } }