Commit 3e9d9373e4b for woocommerce

commit 3e9d9373e4b319955f39f54542d08e827cb39e3a
Author: Justin P <228780+layoutd@users.noreply.github.com>
Date:   Tue Oct 27 17:40:41 2026 +0100

    Keep double-quoted values together in comma-separated CSV import fields (#69990)

    * Keep double-quoted values together when splitting CSV import fields

    * Add changelog entry for quoted CSV import values

    * Only let unbalanced quotes open or close a grouped CSV value

    * Reword changelog entry for quoted CSV import values

    * Drop the PHPStan baseline entry the CSV fix resolves

    * Set the since tag on group_quoted_values to the version trunk now targets

    * Parse attribute defaults like attribute values so a quoted default still matches

    * Only unwrap a single quoted segment when it holds exactly two quotes

    * Tighten the quoted-value docblock example and the changelog entry

    * Leave a lone quote segment as it is when grouping quoted values

    * Unescape a split attribute default and cover more quoted cells in tests

diff --git a/plugins/woocommerce/changelog/fix-csv-import-quoted-attribute-values b/plugins/woocommerce/changelog/fix-csv-import-quoted-attribute-values
new file mode 100644
index 00000000000..31729a520b7
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-csv-import-quoted-attribute-values
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Keep double-quoted values together when importing product CSV lists, such as attribute values and tags.
diff --git a/plugins/woocommerce/includes/import/abstract-wc-product-importer.php b/plugins/woocommerce/includes/import/abstract-wc-product-importer.php
index 772cca78478..01ebe413501 100644
--- a/plugins/woocommerce/includes/import/abstract-wc-product-importer.php
+++ b/plugins/woocommerce/includes/import/abstract-wc-product-importer.php
@@ -883,8 +883,8 @@ abstract class WC_Product_Importer implements WC_Importer_Interface {
 	}

 	/**
-	 * Explode CSV cell values using commas by default, and handling escaped
-	 * separators.
+	 * Explode CSV cell values using commas by default, handling escaped
+	 * separators and values wrapped in double quotes.
 	 *
 	 * @since  3.2.0
 	 * @param  string $value     Value to explode.
@@ -892,13 +892,99 @@ abstract class WC_Product_Importer implements WC_Importer_Interface {
 	 * @return array
 	 */
 	protected function explode_values( $value, $separator = ',' ) {
-		$value  = str_replace( '\\,', '::separator::', $value );
-		$values = explode( $separator, $value );
-		$values = array_map( array( $this, 'explode_values_formatter' ), $values );
+		$value    = str_replace( '\\,', '::separator::', $value );
+		$segments = explode( $separator, $value );
+		$values   = $this->group_quoted_values( is_array( $segments ) ? $segments : array( $value ), $separator );
+		$values   = array_map( array( $this, 'explode_values_formatter' ), $values );

 		return $values;
 	}

+	/**
+	 * Re-join exploded segments that were wrapped in double quotes, so a quoted value
+	 * can contain the separator, as in `Red, "Blue, navy", Green`. Quotes that are
+	 * balanced inside a segment (`"Large" size`) never open or close a value.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @param string[] $segments  Segments produced by splitting the cell on the separator.
+	 * @param string   $separator Separator the segments were split on.
+	 * @return string[]
+	 */
+	protected function group_quoted_values( array $segments, $separator ) {
+		$values        = array();
+		$count         = count( $segments );
+		$unclosed_from = $count;
+
+		for ( $i = 0; $i < $count; $i++ ) {
+			$segment = trim( $segments[ $i ] );
+			$end     = false;
+
+			if ( $this->opens_quoted_value( $segment ) ) {
+				if ( $this->is_self_closed_quoted_value( $segment ) ) {
+					$end = $i;
+				} elseif ( $i < $unclosed_from ) {
+					for ( $end = $i + 1; $end < $count; $end++ ) {
+						if ( $this->closes_quoted_value( trim( $segments[ $end ] ) ) ) {
+							break;
+						}
+					}
+
+					// Nothing after this segment closes a quote, so later openers cannot be closed either.
+					if ( $end >= $count ) {
+						$unclosed_from = $i;
+						$end           = false;
+					}
+				}
+			}
+
+			if ( false === $end ) {
+				$values[] = $segments[ $i ];
+				continue;
+			}
+
+			$quoted   = trim( implode( $separator, array_slice( $segments, $i, $end - $i + 1 ) ) );
+			$values[] = (string) substr( $quoted, 1, -1 );
+			$i        = $end;
+		}
+
+		return $values;
+	}
+
+	/**
+	 * Whether a trimmed segment starts a double-quoted value: it begins with a quote that the
+	 * segment either closes itself (`"Large"`) or leaves open (`"Blue`, an odd number of quotes).
+	 * A lone `"` is left as it is.
+	 *
+	 * @param string $segment Trimmed segment.
+	 * @return bool
+	 */
+	private function opens_quoted_value( string $segment ): bool {
+		return strlen( $segment ) > 1 && '"' === substr( $segment, 0, 1 ) && ( $this->is_self_closed_quoted_value( $segment ) || 1 === substr_count( $segment, '"' ) % 2 );
+	}
+
+	/**
+	 * Whether a trimmed segment is a complete double-quoted value on its own, such as `"Large"`.
+	 * A segment with more quotes, such as `"Pro" or "Max"`, is ambiguous and is left as it is.
+	 *
+	 * @param string $segment Trimmed segment.
+	 * @return bool
+	 */
+	private function is_self_closed_quoted_value( string $segment ): bool {
+		return strlen( $segment ) > 1 && '"' === substr( $segment, 0, 1 ) && '"' === substr( $segment, -1 ) && 2 === substr_count( $segment, '"' );
+	}
+
+	/**
+	 * Whether a trimmed segment ends a double-quoted value opened by an earlier segment: it ends
+	 * with a quote that is not balanced inside the segment, such as `navy"` but not `15" Wheel"` or a lone `"`.
+	 *
+	 * @param string $segment Trimmed segment.
+	 * @return bool
+	 */
+	private function closes_quoted_value( string $segment ): bool {
+		return strlen( $segment ) > 1 && '"' === substr( $segment, -1 ) && 1 === substr_count( $segment, '"' ) % 2;
+	}
+
 	/**
 	 * Remove formatting and trim each value.
 	 *
diff --git a/plugins/woocommerce/includes/import/class-wc-product-csv-importer.php b/plugins/woocommerce/includes/import/class-wc-product-csv-importer.php
index 42c1b0c9d0c..b7177108d19 100644
--- a/plugins/woocommerce/includes/import/class-wc-product-csv-importer.php
+++ b/plugins/woocommerce/includes/import/class-wc-product-csv-importer.php
@@ -409,6 +409,26 @@ class WC_Product_CSV_Importer extends WC_Product_Importer {
 		return array_map( 'wc_clean', $this->explode_values( $value ) );
 	}

+	/**
+	 * Parse an attribute default value from a CSV.
+	 *
+	 * The default is matched against the attribute options with a strict comparison, so
+	 * it goes through the same parsing as the values: a quoted default loses its quotes
+	 * the way the quoted option does. A cell that would split into several values is
+	 * kept whole, since the default is a single value.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @param string $value Field value.
+	 *
+	 * @return string
+	 */
+	public function parse_attribute_default_field( $value ) {
+		$values = $this->parse_comma_field( $value );
+
+		return 1 === count( $values ) ? $values[0] : wc_clean( $this->unescape_data( $value ) );
+	}
+
 	/**
 	 * Parse a field that is generally '1' or '0' but can be something else.
 	 *
@@ -925,6 +945,7 @@ class WC_Product_CSV_Importer extends WC_Product_Importer {
 		 */
 		$prefix_match_data_formatting = array(
 			'attributes:value'    => array( $this, 'parse_comma_field' ),
+			'attributes:default'  => array( $this, 'parse_attribute_default_field' ),
 			'attributes:visible'  => array( $this, 'parse_bool_field' ),
 			'attributes:taxonomy' => array( $this, 'parse_bool_field' ),
 			'downloads:url'       => array( $this, 'parse_download_file_field' ),
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index c445ec1e922..0fbf9bcb600 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -19746,12 +19746,6 @@ parameters:
 			count: 2
 			path: includes/import/abstract-wc-product-importer.php

-		-
-			message: '#^Parameter \#2 \$array of function array_map expects array, list\<string\>\|false given\.$#'
-			identifier: argument.type
-			count: 1
-			path: includes/import/abstract-wc-product-importer.php
-
 		-
 			message: '#^Parameter \#2 \$callback of function array_filter expects \(callable\(string\)\: bool\)\|null, ''strlen'' given\.$#'
 			identifier: argument.type
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/importer/product.php b/plugins/woocommerce/tests/legacy/unit-tests/importer/product.php
index 15afe87edac..185a419c3d7 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/importer/product.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/importer/product.php
@@ -406,6 +406,7 @@ class WC_Tests_Product_CSV_Importer extends WC_Unit_Test_Case {
 			'attributes:value1'    => array( $importer, 'parse_comma_field' ),
 			'attributes:visible1'  => array( $importer, 'parse_bool_field' ),
 			'attributes:taxonomy1' => array( $importer, 'parse_bool_field' ),
+			'attributes:default1'  => array( $importer, 'parse_attribute_default_field' ),
 			'downloads:url1'       => array( $importer, 'parse_download_file_field' ),
 			'meta:_my_field'       => 'wp_kses_post',
 			// Columns that merely contain a special name must fall back to wc_clean.
@@ -416,7 +417,6 @@ class WC_Tests_Product_CSV_Importer extends WC_Unit_Test_Case {
 			'a downloads:url1'     => 'wc_clean',
 			// Special columns handled elsewhere still get the default callback.
 			'attributes:name1'     => 'wc_clean',
-			'attributes:default1'  => 'wc_clean',
 			'downloads:name1'      => 'wc_clean',
 		);

diff --git a/plugins/woocommerce/tests/php/includes/importer/class-wc-product-csv-importer-test.php b/plugins/woocommerce/tests/php/includes/importer/class-wc-product-csv-importer-test.php
index 8ed928009ad..18fa7d90186 100644
--- a/plugins/woocommerce/tests/php/includes/importer/class-wc-product-csv-importer-test.php
+++ b/plugins/woocommerce/tests/php/includes/importer/class-wc-product-csv-importer-test.php
@@ -2115,4 +2115,106 @@ class WC_Product_CSV_Importer_Test extends \WC_Unit_Test_Case {
 			wp_delete_file( $file );
 		}
 	}
+
+	/**
+	 * @testdox Should keep double-quoted values together and leave other values untouched when splitting comma-separated fields.
+	 * @dataProvider provider_parse_comma_field_quoted_values
+	 *
+	 * @param string $value    Raw CSV cell value.
+	 * @param array  $expected Expected list of values.
+	 */
+	public function test_parse_comma_field_handles_quoted_values( string $value, array $expected ): void {
+		$importer = new WC_Product_CSV_Importer( __DIR__ . '/sample.csv' );
+
+		$this->assertSame( $expected, $importer->parse_comma_field( $value ) );
+	}
+
+	/**
+	 * Data provider for test_parse_comma_field_handles_quoted_values.
+	 *
+	 * @return array[]
+	 */
+	public function provider_parse_comma_field_quoted_values(): array {
+		return array(
+			'quoted value with commas'    => array( 'Red, "Blue, navy", Green', array( 'Red', 'Blue, navy', 'Green' ) ),
+			'two quoted groups'           => array( '"Blue, navy", "Red, dark"', array( 'Blue, navy', 'Red, dark' ) ),
+			'whole cell quoted'           => array( '"New, no box. Multiple quantity, minor shelf wear."', array( 'New, no box. Multiple quantity, minor shelf wear.' ) ),
+			'backslash-escaped comma'     => array( 'Red\\, Green, Blue', array( 'Red, Green', 'Blue' ) ),
+			'escaped comma inside quotes' => array( '"Red\\, Green, Blue", Black', array( 'Red, Green, Blue', 'Black' ) ),
+			'quote inside a value'        => array( 'Red, 5" Wheel, Green', array( 'Red', '5" Wheel', 'Green' ) ),
+			'quote not closing a segment' => array( '"Large" size, Small', array( '"Large" size', 'Small' ) ),
+			'closed quote then inch mark' => array( '"Large" size, 15"', array( '"Large" size', '15"' ) ),
+			'balanced quotes in both'     => array( '"Pro" edition, Small "S"', array( '"Pro" edition', 'Small "S"' ) ),
+			'quotes inside a group'       => array( '"a, "b", c"', array( 'a, "b", c' ) ),
+			'quoted words in one value'   => array( '"Pro" or "Max", Small', array( '"Pro" or "Max"', 'Small' ) ),
+			'nested quotes in one value'  => array( '"Pro "P" edition"', array( '"Pro "P" edition"' ) ),
+			'lone quote segments'         => array( 'a, ", b, "', array( 'a', '"', 'b', '"' ) ),
+			'empty quoted value'          => array( '""', array( '' ) ),
+			'unterminated quote'          => array( 'Red, "Blue', array( 'Red', '"Blue' ) ),
+			'several unterminated quotes' => array( '"a, "b, c', array( '"a', '"b', 'c' ) ),
+			'plain list'                  => array( 'Red, Green', array( 'Red', 'Green' ) ),
+		);
+	}
+
+	/**
+	 * @testdox Should keep a double-quoted tag together when tags are separated by spaces.
+	 */
+	public function test_parse_tags_spaces_field_handles_quoted_values(): void {
+		$importer = new WC_Product_CSV_Importer( __DIR__ . '/sample.csv' );
+
+		$term_ids = $importer->parse_tags_spaces_field( '"New York" Boston 15"' );
+
+		$this->assertSame( array( 'New York', 'Boston', '15"' ), array_map( fn( $id ) => get_term( $id, 'product_tag' )->name, $term_ids ) );
+	}
+
+	/**
+	 * @testdox Should parse an attribute default like a single attribute value.
+	 * @testWith ["\"Premium\"", "Premium"]
+	 *           ["\"Blue, navy\"", "Blue, navy"]
+	 *           ["Blue, navy", "Blue, navy"]
+	 *           ["Basic", "Basic"]
+	 *           ["'=Blue, navy", "=Blue, navy"]
+	 *           ["", ""]
+	 *
+	 * @param string $value    Raw CSV cell value.
+	 * @param string $expected Expected default.
+	 */
+	public function test_parse_attribute_default_field( string $value, string $expected ): void {
+		$importer = new WC_Product_CSV_Importer( __DIR__ . '/sample.csv' );
+
+		$this->assertSame( $expected, $importer->parse_attribute_default_field( $value ) );
+	}
+
+	/**
+	 * @testdox Should keep a quoted attribute default matched to its unquoted option when importing a variable product.
+	 */
+	public function test_import_keeps_quoted_attribute_default(): void {
+		$csv_file = trailingslashit( get_temp_dir() ) . 'import-quoted-attribute-default.csv';
+		file_put_contents( $csv_file, "SKU,Type,Name,Attribute 1 name,Attribute 1 value(s),Attribute 1 default,Attribute 1 visible,Attribute 1 global\nIMPORT-QUOTED-DEFAULT,variable,Quoted default,Edition,\"\"\"Premium\"\", Basic\",\"\"\"Premium\"\"\",1,0\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture written to the temp dir.
+
+		$importer = new WC_Product_CSV_Importer(
+			$csv_file,
+			array(
+				'parse'   => true,
+				'mapping' => array(
+					'SKU'                  => 'sku',
+					'Type'                 => 'type',
+					'Name'                 => 'name',
+					'Attribute 1 name'     => 'attributes:name1',
+					'Attribute 1 value(s)' => 'attributes:value1',
+					'Attribute 1 default'  => 'attributes:default1',
+					'Attribute 1 visible'  => 'attributes:visible1',
+					'Attribute 1 global'   => 'attributes:taxonomy1',
+				),
+			)
+		);
+		$data     = $importer->import();
+
+		wp_delete_file( $csv_file );
+
+		$this->assertCount( 1, $data['imported'] );
+		$product = wc_get_product( $data['imported'][0] );
+		$this->assertSame( array( 'Premium', 'Basic' ), $product->get_attributes()['edition']->get_options() );
+		$this->assertSame( array( 'edition' => 'Premium' ), $product->get_default_attributes() );
+	}
 }