Commit 07ebe8af79 for woocommerce
commit 07ebe8af79bc9e3a176874f09bdb0ad472cf3ae6
Author: Naman Malhotra <naman03malhotra@gmail.com>
Date: Tue Nov 25 08:57:42 2025 +0530
[Migrator]: Fix the tax migration and add XSS filtering (#61453)
diff --git a/plugins/woocommerce/changelog/fix-migrator-cft b/plugins/woocommerce/changelog/fix-migrator-cft
new file mode 100644
index 0000000000..ad7aa1592a
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-migrator-cft
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fixes for Migrator CLI
diff --git a/plugins/woocommerce/src/Internal/CLI/Migrator/Core/ProductsController.php b/plugins/woocommerce/src/Internal/CLI/Migrator/Core/ProductsController.php
index edd1c1f096..2ad87806e3 100644
--- a/plugins/woocommerce/src/Internal/CLI/Migrator/Core/ProductsController.php
+++ b/plugins/woocommerce/src/Internal/CLI/Migrator/Core/ProductsController.php
@@ -175,8 +175,21 @@ class ProductsController {
: 'Importing Products from ' . ucfirst( $this->parsed_args['platform'] );
$progress = \WP_CLI\Utils\make_progress_bar( $progress_label, $total_count );
+ // Set initial progress - either show resumed progress or 1% for new sessions.
+ $initial_tick = max( 1, (int) ceil( $total_count * 0.01 ) );
+
if ( ! $this->parsed_args['dry_run'] ) {
- $progress->tick( $this->session->count_all_imported_entities() );
+ $already_imported = $this->session->count_all_imported_entities();
+ if ( $already_imported > 0 ) {
+ // Show actual resumed progress.
+ $progress->tick( $already_imported );
+ } else {
+ // Show 1% for new sessions to indicate activity has started.
+ $progress->tick( $initial_tick );
+ }
+ } else {
+ // For dry runs, show initial 1% tick.
+ $progress->tick( $initial_tick );
}
$this->configure_product_importer();
@@ -545,6 +558,13 @@ class ProductsController {
WP_CLI::line( sprintf( ' Last Cursor: %s', substr( $session->get_reentrancy_cursor(), 0, 50 ) . '...' ) );
}
+ $original_args = $session->get_original_arguments();
+ if ( $original_args ) {
+ WP_CLI::line( '' );
+ WP_CLI::line( WP_CLI::colorize( '%YOriginal Command Arguments:%n' ) );
+ $this->display_saved_arguments( $original_args );
+ }
+
WP_CLI::line( '' );
$should_resume = $parsed_args['resume'] ?? false;
@@ -561,6 +581,13 @@ class ProductsController {
if ( $should_resume ) {
WP_CLI::success( sprintf( 'Resuming migration session %d', $session->get_id() ) );
+
+ $original_args = $session->get_original_arguments();
+ if ( $original_args ) {
+ $this->restore_original_arguments( $original_args );
+ WP_CLI::line( 'Original command arguments have been restored.' );
+ }
+
return $session;
} else {
$session->archive();
@@ -595,6 +622,8 @@ class ProductsController {
)
);
+ $session->set_original_arguments( $parsed_args );
+
return $session;
} catch ( Exception $e ) {
@@ -969,4 +998,66 @@ class ProductsController {
protected function get_user_input(): string {
return strtolower( trim( fgets( STDIN ) ) );
}
+
+ /**
+ * Display the saved arguments from a previous session.
+ *
+ * @param array $args The saved arguments to display.
+ */
+ private function display_saved_arguments( array $args ): void {
+ $important_args = array(
+ 'platform' => 'Platform',
+ 'limit' => 'Product Limit',
+ 'batch_size' => 'Batch Size',
+ 'skip_existing' => 'Skip Existing',
+ 'dry_run' => 'Dry Run',
+ 'verbose' => 'Verbose',
+ 'assign_default_category' => 'Assign Default Category',
+ );
+
+ foreach ( $important_args as $key => $label ) {
+ if ( isset( $args[ $key ] ) ) {
+ $value = $args[ $key ];
+ if ( is_bool( $value ) ) {
+ $value = $value ? 'Yes' : 'No';
+ } elseif ( is_array( $value ) ) {
+ $value = implode( ', ', $value );
+ } elseif ( 'limit' === $key && PHP_INT_MAX === (int) $value ) {
+ $value = 'All';
+ }
+ WP_CLI::line( sprintf( ' %s: %s', $label, $value ) );
+ }
+ }
+
+ if ( ! empty( $args['filters'] ) && is_array( $args['filters'] ) ) {
+ WP_CLI::line( ' Filters:' );
+ foreach ( $args['filters'] as $filter_key => $filter_value ) {
+ if ( is_array( $filter_value ) ) {
+ $filter_value = implode( ', ', $filter_value );
+ }
+ WP_CLI::line( sprintf( ' %s: %s', $filter_key, $filter_value ) );
+ }
+ }
+
+ if ( ! empty( $args['fields'] ) && is_array( $args['fields'] ) ) {
+ WP_CLI::line( sprintf( ' Fields: %s', implode( ', ', $args['fields'] ) ) );
+ }
+ }
+
+ /**
+ * Restore the original arguments to the current parsed args.
+ *
+ * @param array $original_args The original arguments to restore.
+ */
+ private function restore_original_arguments( array $original_args ): void {
+ foreach ( $original_args as $key => $value ) {
+ if ( 'resume' !== $key ) {
+ $this->parsed_args[ $key ] = $value;
+ }
+ }
+
+ if ( isset( $original_args['fields'] ) ) {
+ $this->fields_to_process = $original_args['fields'];
+ }
+ }
}
diff --git a/plugins/woocommerce/src/Internal/CLI/Migrator/Core/WooCommerceProductImporter.php b/plugins/woocommerce/src/Internal/CLI/Migrator/Core/WooCommerceProductImporter.php
index 02d39141c3..bfff8ff7b8 100644
--- a/plugins/woocommerce/src/Internal/CLI/Migrator/Core/WooCommerceProductImporter.php
+++ b/plugins/woocommerce/src/Internal/CLI/Migrator/Core/WooCommerceProductImporter.php
@@ -492,6 +492,10 @@ class WooCommerceProductImporter {
$product->set_weight( $product_data['weight'] );
}
+ if ( ! empty( $product_data['tax_status'] ) ) {
+ $product->set_tax_status( $product_data['tax_status'] );
+ }
+
if ( ! empty( $product_data['metafields'] ) ) {
foreach ( $product_data['metafields'] as $key => $value ) {
if ( ! empty( $key ) ) {
@@ -805,6 +809,10 @@ class WooCommerceProductImporter {
$variation->set_weight( $var_data['weight'] ?? '' );
+ if ( ! empty( $var_data['tax_status'] ) ) {
+ $variation->set_tax_status( $var_data['tax_status'] );
+ }
+
$image_original_id = $var_data['image_original_id'] ?? null;
if ( $image_original_id && isset( $this->migration_data['images_mapping'][ $image_original_id ] ) ) {
$variation->set_image_id( $this->migration_data['images_mapping'][ $image_original_id ] );
diff --git a/plugins/woocommerce/src/Internal/CLI/Migrator/Lib/ImportSession.php b/plugins/woocommerce/src/Internal/CLI/Migrator/Lib/ImportSession.php
index baa96f79db..67e695c7e4 100644
--- a/plugins/woocommerce/src/Internal/CLI/Migrator/Lib/ImportSession.php
+++ b/plugins/woocommerce/src/Internal/CLI/Migrator/Lib/ImportSession.php
@@ -671,4 +671,23 @@ class ImportSession {
// requires an addslashes() call to preserve them.
update_post_meta( $this->post_id, 'importer_cursor', addslashes( $cursor ) );
}
-}
\ No newline at end of file
+
+ /**
+ * Save the original command arguments for session resumption.
+ *
+ * @param array $args The original command arguments
+ */
+ public function set_original_arguments( array $args ) {
+ update_post_meta( $this->post_id, 'original_arguments', $args );
+ }
+
+ /**
+ * Get the original command arguments for session resumption.
+ *
+ * @return array|null The original arguments or null if not found
+ */
+ public function get_original_arguments() {
+ $args = get_post_meta( $this->post_id, 'original_arguments', true );
+ return ( is_array( $args ) && ! empty( $args ) ) ? $args : null;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/CLI/Migrator/Platforms/Shopify/ShopifyFetcher.php b/plugins/woocommerce/src/Internal/CLI/Migrator/Platforms/Shopify/ShopifyFetcher.php
index 0d37f289e2..cf65d3ba21 100644
--- a/plugins/woocommerce/src/Internal/CLI/Migrator/Platforms/Shopify/ShopifyFetcher.php
+++ b/plugins/woocommerce/src/Internal/CLI/Migrator/Platforms/Shopify/ShopifyFetcher.php
@@ -84,6 +84,7 @@ class ShopifyFetcher implements PlatformFetcherInterface {
price
compareAtPrice
sku
+ taxable
inventoryPolicy
inventoryQuantity
position
diff --git a/plugins/woocommerce/src/Internal/CLI/Migrator/Platforms/Shopify/ShopifyMapper.php b/plugins/woocommerce/src/Internal/CLI/Migrator/Platforms/Shopify/ShopifyMapper.php
index 0ae9eefd36..73dc10db00 100644
--- a/plugins/woocommerce/src/Internal/CLI/Migrator/Platforms/Shopify/ShopifyMapper.php
+++ b/plugins/woocommerce/src/Internal/CLI/Migrator/Platforms/Shopify/ShopifyMapper.php
@@ -246,8 +246,8 @@ class ShopifyMapper implements PlatformMapperInterface {
foreach ( $shopify_product->collections->edges as $collection_edge ) {
$collection_node = $collection_edge->node;
$categories[] = array(
- 'name' => $collection_node->title,
- 'slug' => $collection_node->handle,
+ 'name' => wc_clean( $collection_node->title ),
+ 'slug' => sanitize_title( $collection_node->handle ),
);
}
@@ -270,7 +270,7 @@ class ShopifyMapper implements PlatformMapperInterface {
$trimmed_tag = trim( $tag );
if ( ! empty( $trimmed_tag ) ) {
$tags[] = array(
- 'name' => $trimmed_tag,
+ 'name' => wc_clean( $trimmed_tag ),
'slug' => sanitize_title( $trimmed_tag ),
);
}
@@ -320,15 +320,6 @@ class ShopifyMapper implements PlatformMapperInterface {
return (float) $weight * self::WEIGHT_CONVERSION_FACTORS[ $shopify_unit_key ][ $store_weight_unit ];
}
- /**
- * Basic sanitization for product description HTML.
- *
- * @param string $html Raw description HTML.
- * @return string Sanitized HTML.
- */
- private function sanitize_product_description( string $html ): string {
- return trim( $html );
- }
/**
* Checks if a specific field should be processed based on constructor args.
@@ -357,10 +348,10 @@ class ShopifyMapper implements PlatformMapperInterface {
$basic_data['original_product_id'] = ! empty( $shopify_product->id ) ? basename( $shopify_product->id ) : null;
// Basic Product Fields.
- $basic_data['name'] = $shopify_product->title;
- $basic_data['slug'] = $shopify_product->handle;
- $basic_data['description'] = $this->sanitize_product_description( $shopify_product->descriptionHtml ?? '' ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase.
- $basic_data['short_description'] = $shopify_product->descriptionPlainSummary ?? ''; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase.
+ $basic_data['name'] = wc_clean( $shopify_product->title );
+ $basic_data['slug'] = sanitize_title( $shopify_product->handle );
+ $basic_data['description'] = wp_kses_post( $shopify_product->descriptionHtml ?? '' ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase.
+ $basic_data['short_description'] = wp_kses_post( $shopify_product->descriptionPlainSummary ?? '' ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase.
$basic_data['status'] = $this->get_woo_product_status( $shopify_product );
$basic_data['date_created_gmt'] = $shopify_product->createdAt; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase.
@@ -394,7 +385,7 @@ class ShopifyMapper implements PlatformMapperInterface {
// Brand (Vendor).
$brand_name = $shopify_product->vendor ?? null;
$basic_data['brand'] = $brand_name ? array(
- 'name' => $brand_name,
+ 'name' => wc_clean( $brand_name ),
'slug' => sanitize_title( $brand_name ),
) : null;
@@ -424,7 +415,7 @@ class ShopifyMapper implements PlatformMapperInterface {
}
if ( $this->should_process( 'sku' ) ) {
- $simple_data['sku'] = $variant_node->sku;
+ $simple_data['sku'] = wc_clean( $variant_node->sku );
}
if ( $this->should_process( 'stock' ) ) {
@@ -455,17 +446,25 @@ class ShopifyMapper implements PlatformMapperInterface {
$simple_data['cost_of_goods'] = $variant_node->inventoryItem->unitCost->amount; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase.
}
+ if ( property_exists( $variant_node, 'taxable' ) ) {
+ $simple_data['tax_status'] = $variant_node->taxable ? 'taxable' : 'none';
+ }
+
$simple_data['original_variant_id'] = ! empty( $variant_node->id ) ? basename( $variant_node->id ) : null;
} else {
- // Defaults for variable or product with no variants.
- $simple_data['sku'] = null;
- $simple_data['regular_price'] = null;
- $simple_data['sale_price'] = null;
- $simple_data['stock_quantity'] = null;
- $simple_data['manage_stock'] = false;
- $simple_data['stock_status'] = 'instock';
- $simple_data['weight'] = null;
+ $simple_data['sku'] = null;
+ $simple_data['regular_price'] = null;
+ $simple_data['sale_price'] = null;
+ $simple_data['stock_quantity'] = null;
+ $simple_data['manage_stock'] = false;
+ $simple_data['stock_status'] = 'instock';
+ $simple_data['weight'] = null;
+
+ if ( property_exists( $shopify_product, 'taxable' ) ) {
+ $simple_data['tax_status'] = $shopify_product->taxable ? 'taxable' : 'none';
+ }
+
$simple_data['original_variant_id'] = null;
}
@@ -487,8 +486,8 @@ class ShopifyMapper implements PlatformMapperInterface {
if ( $is_variable && property_exists( $shopify_product, 'options' ) && ! empty( $shopify_product->options ) ) {
foreach ( $shopify_product->options as $option ) {
$variable_data['attributes'][] = array(
- 'name' => $option->name,
- 'options' => $option->values,
+ 'name' => wc_clean( $option->name ),
+ 'options' => array_map( 'wc_clean', $option->values ),
'position' => $option->position,
'is_visible' => true,
'is_variation' => true,
@@ -515,7 +514,7 @@ class ShopifyMapper implements PlatformMapperInterface {
}
if ( $this->should_process( 'sku' ) ) {
- $variation_data['sku'] = $variant_node->sku ?? null;
+ $variation_data['sku'] = wc_clean( $variant_node->sku ?? '' );
}
if ( $this->should_process( 'stock' ) ) {
@@ -546,11 +545,15 @@ class ShopifyMapper implements PlatformMapperInterface {
$variation_data['cost_of_goods'] = $variant_node->inventoryItem->unitCost->amount; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase.
}
+ if ( property_exists( $variant_node, 'taxable' ) ) {
+ $variation_data['tax_status'] = $variant_node->taxable ? 'taxable' : 'none';
+ }
+
if ( $this->should_process( 'attributes' ) ) {
$variation_data['attributes'] = array();
if ( ! empty( $variant_node->selectedOptions ) ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase.
foreach ( $variant_node->selectedOptions as $selectedOption ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase,WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase -- GraphQL uses camelCase.
- $variation_data['attributes'][ $selectedOption->name ] = $selectedOption->value; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase -- GraphQL uses camelCase.
+ $variation_data['attributes'][ wc_clean( $selectedOption->name ) ] = wc_clean( $selectedOption->value ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase -- GraphQL uses camelCase.
}
}
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/CLI/Migrator/Platforms/Shopify/ShopifyMapperTest.php b/plugins/woocommerce/tests/php/src/Internal/CLI/Migrator/Platforms/Shopify/ShopifyMapperTest.php
index 644c383289..192e30e88c 100644
--- a/plugins/woocommerce/tests/php/src/Internal/CLI/Migrator/Platforms/Shopify/ShopifyMapperTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/CLI/Migrator/Platforms/Shopify/ShopifyMapperTest.php
@@ -527,6 +527,75 @@ class ShopifyMapperTest extends \WC_Unit_Test_Case {
$this->assertEquals( array(), $result['images'] );
}
+ /**
+ * Test tax status mapping for simple products.
+ */
+ public function test_tax_status_mapping_simple_product() {
+ // Test non-taxable simple product.
+ $non_taxable_product = $this->create_simple_shopify_product();
+ $non_taxable_product->variants->edges[0]->node->taxable = false;
+
+ $result = $this->mapper->map_product_data( $non_taxable_product );
+
+ $this->assertEquals( 'none', $result['tax_status'] );
+
+ // Test taxable simple product.
+ $taxable_product = $this->create_simple_shopify_product();
+ $taxable_product->variants->edges[0]->node->taxable = true;
+
+ $result = $this->mapper->map_product_data( $taxable_product );
+
+ $this->assertEquals( 'taxable', $result['tax_status'] );
+ }
+
+ /**
+ * Test tax status mapping for variable products.
+ */
+ public function test_tax_status_mapping_variable_product() {
+ $variable_product = $this->create_variable_shopify_product();
+
+ // Set first variation as non-taxable, second as taxable.
+ $variable_product->variants->edges[0]->node->taxable = false;
+ $variable_product->variants->edges[1]->node->taxable = true;
+
+ $result = $this->mapper->map_product_data( $variable_product );
+
+ $this->assertTrue( $result['is_variable'] );
+ $this->assertCount( 2, $result['variations'] );
+
+ // Check first variation is not taxable.
+ $this->assertEquals( 'none', $result['variations'][0]['tax_status'] );
+
+ // Check second variation is taxable.
+ $this->assertEquals( 'taxable', $result['variations'][1]['tax_status'] );
+ }
+
+ /**
+ * Test tax status mapping when taxable field is missing (backwards compatibility).
+ */
+ public function test_tax_status_mapping_missing_field() {
+ // Test simple product without taxable field.
+ $simple_product = $this->create_simple_shopify_product();
+ // Intentionally don't set taxable field.
+
+ $result = $this->mapper->map_product_data( $simple_product );
+
+ // Should not have tax_status key when taxable field is missing.
+ $this->assertArrayNotHasKey( 'tax_status', $result );
+
+ // Test variable product without taxable field.
+ $variable_product = $this->create_variable_shopify_product();
+ // Intentionally don't set taxable field on variants.
+
+ $result = $this->mapper->map_product_data( $variable_product );
+
+ $this->assertTrue( $result['is_variable'] );
+ // Variations should not have tax_status key when taxable field is missing.
+ foreach ( $result['variations'] as $variation ) {
+ $this->assertArrayNotHasKey( 'tax_status', $variation );
+ }
+ }
+
/**
* Create a simple Shopify product object for testing.
*