PluginProbe ʕ •ᴥ•ʔ
WooCommerce / 11.1.0
WooCommerce v11.1.0
11.1.0 11.1.0-rc.2 11.1.0-rc.1 11.1.0-beta.2 11.1.0-beta.1 11.0.1 11.0.0 11.0.0-rc.3 11.0.0-rc.2 11.0.0-rc.1 11.0.0-beta.2 11.0.0-beta.1 10.9.4 10.9.3 10.9.2 10.9.1 10.9.0 10.9.0-rc.1 10.9.0-beta.2 10.9.0-beta.1 10.8.1 10.8.0 10.8.0-rc.1 10.8.0-beta.2 10.8.0-beta.1 7.8.0-beta.1 7.8.0-beta.2 7.8.0-rc.1 7.8.0-rc.2 7.8.1 7.8.2 7.8.3 7.8.4 7.9.0 7.9.0-beta.1 7.9.0-beta.2 7.9.0-rc.2 7.9.0-rc.3 7.9.1 7.9.2 8.0.0 8.0.0-beta.1 8.0.0-beta.2 8.0.0-rc.1 8.0.0-rc.2 8.0.1 8.0.2 8.0.3 8.0.4 8.0.5 8.1.0 8.1.0-beta.1 8.1.0-rc.1 8.1.0-rc.2 8.1.1 8.1.2 8.1.3 8.1.4 8.2.0 8.2.0-beta.1 8.2.0-rc.1 8.2.0-rc.2 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.3.0 8.3.0-beta.1 8.3.0-rc.1 8.3.0-rc.2 8.3.1 8.3.2 8.3.3 8.3.4 8.4.0 8.4.0-beta.1 8.4.0-rc.1 8.4.1 8.4.2 8.4.3 8.5.0 8.5.0-beta.1 8.5.0-rc.1 8.5.1 8.5.2 8.5.3 8.5.4 8.5.5 8.6.0 8.6.0-beta.1 8.6.0-rc.1 8.6.1 8.6.2 8.6.3 8.6.4 8.7.0 8.7.0-beta.1 8.7.0-beta.2 8.7.0-rc.1 8.7.1 8.7.2 8.7.3 8.8.0 8.8.0-beta.1 8.8.0-rc.1 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.8.6 8.8.7 8.9.0 8.9.0-beta.1 8.9.0-rc.1 8.9.1 8.9.2 8.9.3 8.9.4 8.9.5 9.0.0 9.0.0-beta.1 9.0.0-beta.2 9.0.0-rc.1 9.0.1 9.0.2 9.0.3 9.0.4 9.1.0 9.1.0-beta.1 9.1.0-rc.1 9.1.1 9.1.2 9.1.3 9.1.4 9.1.5 9.1.6 9.2.0 9.2.0-beta.1 9.2.0-rc.1 9.2.1 9.2.2 9.2.3 9.2.4 9.2.5 9.3.0 9.3.0-beta.1 9.3.0-rc.1 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.3.6 9.4.0 9.4.0-beta.1 9.4.0-beta.2 9.4.0-rc.1 9.4.0-rc.2 9.4.0-rc.3 9.4.0-rc.4 9.4.1 9.4.2 9.4.3 9.4.4 9.4.5 9.5.0 9.5.0-beta.1 9.5.0-beta.2 9.5.0-rc.1 9.5.1 9.5.2 9.5.3 9.5.4 9.6.0 9.6.0-beta.1 9.6.0-beta.2 9.6.0-rc.1 9.6.1 9.6.2 9.6.3 9.6.4 9.7.0 9.7.0-beta.1 9.7.0-rc.1 9.7.1 9.7.2 9.7.3 9.8.0 9.8.0-beta.1 9.8.0-rc.1 9.8.1 9.8.2 9.8.3 9.8.4 9.8.5 9.8.6 9.8.7 9.9.0 9.9.0-beta.1 9.9.0-rc.1 9.9.1 9.9.2 9.9.3 9.9.4 9.9.5 9.9.6 9.9.7 3.7.3 7.1.2 3.8.0 7.2.0 3.8.0-beta.1 7.2.0-beta.1 3.8.0-rc.1 7.2.0-beta.2 3.8.0-rc.2 7.2.0-rc.1 3.8.1 7.2.0-rc.2 3.8.2 7.2.1 3.8.3 7.2.2 3.9.0 7.2.3 3.9.0-beta.1 7.2.4 3.9.0-beta.2 7.3.0 3.9.0-rc.1 7.3.0-beta.1 3.9.0-rc.2 7.3.0-beta.2 3.9.0-rc.3 7.3.0-rc.1 3.9.0-rc.4 7.3.0-rc.2 3.9.1 7.3.1 3.9.2 7.4.0 3.9.3 7.4.0-beta.1 3.9.4 7.4.0-beta.2 3.9.5 7.4.0-rc.1 4.0.0 7.4.0-rc.2 4.0.0-beta.1 7.4.1 4.0.0-rc.1 7.4.2 4.0.0-rc.2 7.5.0 4.0.1 7.5.0-beta.1 4.0.2 7.5.0-beta.2 4.0.3 7.5.0-rc.1 4.0.4 7.5.1 4.1.0 7.5.2 4.1.0-beta.1 7.6.0 4.1.0-beta.2 7.6.0-beta.1 4.1.0-rc.1 7.6.0-beta.2 4.1.0-rc.2 7.6.0-rc.1 4.1.1 7.6.0-rc.2 4.1.2 7.6.0-rc.3 4.1.3 7.6.1 4.1.4 7.6.2 4.2.0 7.7.0 4.2.0-RC.1 7.7.0-beta.1 4.2.0-RC.2 7.7.0-beta.2 4.2.0-beta.1 7.7.0-rc.1 4.2.1 7.7.1 4.2.2 7.7.2 4.2.3 7.7.3 4.2.4 7.8.0 4.2.5 4.3.0 4.3.0-beta.1 4.3.0-rc.1 4.3.0-rc.2 4.3.0-rc.3 4.3.1 4.3.2 4.3.3 4.3.4 4.3.5 4.3.6 4.4.0 4.4.0-beta.1 4.4.0-rc.1 4.4.1 4.4.2 4.4.3 4.4.4 4.5.0 4.5.0-beta.1 4.5.0-rc.1 4.5.0-rc.3 4.5.1 4.5.2 4.5.3 4.5.4 4.5.5 4.6.0 4.6.0-beta.1 4.6.0-rc.1 4.6.1 4.6.2 4.6.3 4.6.4 4.6.5 4.7.0 4.7.0-beta.1 4.7.0-beta.2 4.7.0-rc.1 4.7.1 4.7.1-beta.1 4.7.2 4.7.3 4.7.4 4.8.0 4.8.0-beta.1 4.8.0-rc.1 4.8.0-rc.2 4.8.1 4.8.2 4.8.3 4.9.0 4.9.0-beta.1 4.9.0-rc.1 4.9.0-rc.2 4.9.1 4.9.2 4.9.3 4.9.4 4.9.5 5.0.0 5.0.0-beta.1 5.0.0-beta.2 5.0.0-rc.1 5.0.0-rc.2 5.0.0-rc.3 5.0.1 5.0.2 5.0.3 5.1.0 5.1.0-beta.1 5.1.0-rc.1 trunk 5.1.1 10.0.0 5.1.2 10.0.0-rc.1 5.1.3 10.0.0-rc.2 5.2.0 10.0.1 5.2.0-beta.1 10.0.2 5.2.0-rc.1 10.0.3 5.2.0-rc.2 10.0.4 5.2.1 10.0.5 5.2.2 10.0.6 5.2.3 10.1.0 5.2.4 10.1.0-rc.1 5.2.5 10.1.0-rc.2 5.3.0 10.1.0-rc.3 5.3.0-beta.1 10.1.0-rc.4 5.3.0-rc.1 10.1.1 5.3.0-rc.2 10.1.2 5.3.1 10.1.3 5.3.2 10.1.4 5.3.3 10.2.0 5.4.0 10.2.0-beta.1 5.4.0-beta.1 10.2.0-beta.2 5.4.0-rc.1 10.2.0-rc.1 5.4.1 10.2.1 5.4.2 10.2.2 5.4.3 10.2.3 5.4.4 10.2.4 5.4.5 10.3.0 5.5.0 10.3.0-beta.1 5.5.0-beta.1 10.3.0-beta.2 5.5.0-rc.1 10.3.0-rc.1 5.5.0-rc.2 10.3.0-rc.2 5.5.1 10.3.1 5.5.2 10.3.2 5.5.3 10.3.3 5.5.4 10.3.4 5.5.5 10.3.5 5.6.0 10.3.6 5.6.0-beta.1 10.3.7 5.6.0-rc.1 10.3.8 5.6.0-rc.2 10.4.0 5.6.1 10.4.0-beta.1 5.6.2 10.4.0-beta.2 5.6.3 10.4.0-rc.1 5.7.0 10.4.1 5.7.0-beta.1 10.4.2 5.7.0-rc.1 10.4.3 5.7.1 10.4.4 5.7.2 10.5.0 5.7.3 10.5.0-beta.1 5.8.0 10.5.0-beta.2 5.8.0-beta.1 10.5.0-rc.1 5.8.0-beta.2 10.5.0-rc.2 5.8.0-rc.1 10.5.0-rc.3 5.8.1 10.5.1 5.8.2 10.5.2 5.9.0 10.5.3 5.9.0-beta.1 10.6.0 5.9.0-rc.1 10.6.0-beta.1 5.9.0-rc.2 10.6.0-beta.2 5.9.1 10.6.0-rc.1 5.9.2 10.6.1 6.0.0 10.6.2 6.0.0-beta.1 10.7.0 6.0.0-rc.1 10.7.0-beta.1 6.0.1 10.7.0-beta.2 6.0.2 10.7.0-rc.1 6.1.0 3.0.0 6.1.0-beta.1 3.0.1 6.1.0-rc.1 3.0.2 6.1.0-rc.2 3.0.3 6.1.1 3.0.4 6.1.2 3.0.5 6.1.3 3.0.6 6.2.0 3.0.7 6.2.0-beta.1 3.0.8 6.2.0-rc.1 3.0.9 6.2.0-rc.2 3.1.0 6.2.1 3.1.1 6.2.2 3.1.2 6.2.3 3.2.0 6.3.0 3.2.1 6.3.0-beta.1 3.2.2 6.3.0-rc.1 3.2.3 6.3.0-rc.2 3.2.4 6.3.1 3.2.5 6.3.2 3.2.6 6.4.0 3.3.0 6.4.0-beta.1 3.3.1 6.4.0-rc.1 3.3.2 6.4.1 3.3.2-rc.1 6.4.2 3.3.3 6.5.0 3.3.4 6.5.0-beta.1 3.3.5 6.5.0-rc.1 3.3.6 6.5.0-rc.2 3.4.0 6.5.1 3.4.0-beta.1 6.5.2 3.4.0-rc.2 6.6.0 3.4.1 6.6.0-beta.1 3.4.2 6.6.0-rc.1 3.4.3 6.6.0-rc.2 3.4.4 6.6.1 3.4.5 6.6.2 3.4.6 6.7.0 3.4.7 6.7.0-beta.1 3.4.8 6.7.0-beta.2 3.5.0 6.7.0-rc.1 3.5.0-beta.1 6.7.1 3.5.0-rc.1 6.8.0 3.5.0-rc.2 6.8.0-beta.1 3.5.1 6.8.0-beta.2 3.5.10 6.8.0-rc.1 3.5.2 6.8.1 3.5.3 6.8.2 3.5.4 6.8.3 3.5.5 6.9.0 3.5.6 6.9.0-beta.1 3.5.7 6.9.0-beta.2 3.5.8 6.9.0-rc.1 3.5.9 6.9.1 3.6.0 6.9.2 3.6.0-beta.1 6.9.3 3.6.0-rc.1 6.9.4 3.6.0-rc.2 6.9.5 3.6.0-rc.3 7.0.0 3.6.1 7.0.0-beta.1 3.6.2 7.0.0-beta.2 3.6.3 7.0.0-beta.3 3.6.4 7.0.0-rc.1 3.6.5 7.0.0-rc.2 3.6.6 7.0.1 3.6.7 7.0.2 3.7.0 7.1.0 3.7.0-beta.1 7.1.0-beta.1 3.7.0-rc.1 7.1.0-beta.2 3.7.0-rc.2 7.1.0-rc.1 3.7.1 7.1.0-rc.2 3.7.2 7.1.1
woocommerce / src / Internal / CLI / Migrator / Platforms / Webflow / WebflowMapper.php
woocommerce / src / Internal / CLI / Migrator / Platforms / Webflow Last commit date
WebflowClient.php 1 month ago WebflowFetcher.php 1 month ago WebflowMapper.php 1 month ago WebflowPlatform.php 1 month ago
WebflowMapper.php
811 lines
1 <?php
2 /**
3 * Webflow Mapper
4 *
5 * @package Automattic\WooCommerce\Internal\CLI\Migrator\Platforms\Webflow
6 */
7
8 declare( strict_types=1 );
9
10 namespace Automattic\WooCommerce\Internal\CLI\Migrator\Platforms\Webflow;
11
12 use Automattic\WooCommerce\Internal\CLI\Migrator\Interfaces\PlatformMapperInterface;
13
14 defined( 'ABSPATH' ) || exit;
15
16 /**
17 * Transforms a raw Webflow product+SKUs payload into the standardized array
18 * consumed by WooCommerceProductImporter.
19 *
20 * Webflow's eCommerce model differs from Shopify's in several ways:
21 *
22 * - Variants ("SKUs") live nested under the product in a single response.
23 * - Variant options live in `product.fieldData['sku-properties']` as an array
24 * of `{ id, name, enum: [ { id, name, slug } ] }`. Each SKU's `sku-values`
25 * maps property id => enum id, so the mapper must resolve ids to human
26 * names before producing WC attributes / variation attributes.
27 * - Prices are integer minor units (e.g. cents). The unit lives alongside.
28 * - Categories are CMS item references that the fetcher pre-resolves onto
29 * the item as `_resolved_categories` (an array of `{name, slug}`).
30 * - Images come from `main-image` + `more-images` on each SKU, plus
31 * `more-images` on the product itself. They must all live in a single
32 * `images[]` array so the importer can build the original_id => attachment
33 * map that variations rely on.
34 *
35 * @internal This class is part of the CLI Migrator feature and should not be used directly.
36 */
37 class WebflowMapper implements PlatformMapperInterface {
38
39 /**
40 * Fields to process during mapping (selected via --fields/--exclude-fields).
41 *
42 * @var array
43 */
44 private array $fields_to_process = array();
45
46 /**
47 * Map of ISO currency code => minor-unit count, lazily built from core's locale-info.
48 *
49 * @var array<string,int>|null
50 */
51 private ?array $currency_decimals = null;
52
53 /**
54 * Constructor.
55 *
56 * @param array $args Optional arguments. Recognized keys:
57 * - 'fields': array of field keys to process. Empty/missing means all.
58 */
59 public function __construct( array $args = array() ) {
60 $this->fields_to_process = $args['fields'] ?? array();
61 }
62
63 /**
64 * Maps a raw Webflow product+SKUs item into the importer's standardized array shape.
65 *
66 * @param object $platform_data Webflow product item: `{ product: { id, fieldData: ... }, skus: [...], _resolved_categories?: [...] }`.
67 * @return array Standardized data array.
68 */
69 public function map_product_data( object $platform_data ): array {
70 $product = $this->extract_product( $platform_data );
71 $field_data = $this->extract_field_data( $product );
72 $skus = $this->extract_skus( $platform_data );
73
74 $properties = $this->extract_sku_properties( $field_data );
75 // Needs properties AND more than one SKU. A single-SKU product is imported as simple,
76 // intentionally dropping its lone option (a one-value attribute adds no variation choice).
77 $is_variable = ! empty( $properties ) && count( $skus ) > 1;
78
79 $wc_data = $this->map_basic_fields( $product, $field_data, $is_variable );
80
81 $wc_data['categories'] = $this->should_process( 'categories' ) ? $this->map_categories( $platform_data ) : array();
82 $wc_data['tags'] = array();
83
84 $images = $this->should_process( 'images' ) ? $this->build_images( $field_data, $skus ) : array();
85 $wc_data['images'] = $images;
86
87 if ( $is_variable ) {
88 $wc_data = array_merge( $wc_data, $this->map_variable_data( $properties, $skus, $images ) );
89 } else {
90 $wc_data = array_merge( $wc_data, $this->map_simple_data( $skus ) );
91 $wc_data['attributes'] = array();
92 $wc_data['variations'] = array();
93 }
94
95 $wc_data['metafields'] = $this->map_seo( $field_data );
96
97 return $wc_data;
98 }
99
100 /**
101 * Webflow list-products items wrap a `product` object and a `skus` array.
102 *
103 * Accept either shape (the wrapped item, or a bare product object) so tests
104 * and downstream callers can be lenient.
105 *
106 * @param object $platform_data Raw item.
107 * @return object Product object (with fieldData).
108 */
109 private function extract_product( object $platform_data ): object {
110 if ( isset( $platform_data->product ) && is_object( $platform_data->product ) ) {
111 return $platform_data->product;
112 }
113 return $platform_data;
114 }
115
116 /**
117 * Returns the fieldData object on a product, or an empty stdClass for safety.
118 *
119 * @param object $product Product object.
120 * @return object
121 */
122 private function extract_field_data( object $product ): object {
123 if ( isset( $product->fieldData ) && is_object( $product->fieldData ) ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Webflow API uses camelCase.
124 return $product->fieldData; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Webflow API uses camelCase.
125 }
126 return new \stdClass();
127 }
128
129 /**
130 * Returns the SKUs array off a wrapped item, or empty array.
131 *
132 * @param object $platform_data Raw item.
133 * @return array<int,object>
134 */
135 private function extract_skus( object $platform_data ): array {
136 if ( isset( $platform_data->skus ) && is_array( $platform_data->skus ) ) {
137 return $platform_data->skus;
138 }
139 return array();
140 }
141
142 /**
143 * Returns the sku-properties array off fieldData, or empty array.
144 *
145 * @param object $field_data Field data.
146 * @return array<int,object>
147 */
148 private function extract_sku_properties( object $field_data ): array {
149 $key = 'sku-properties';
150 if ( isset( $field_data->{$key} ) && is_array( $field_data->{$key} ) ) {
151 return $field_data->{$key};
152 }
153 return array();
154 }
155
156 /**
157 * Map fields common to every product (name, slug, description, status…).
158 *
159 * @param object $product Product object.
160 * @param object $field_data Field data object.
161 * @param bool $is_variable Whether this product has multiple SKUs to expose as variations.
162 * @return array
163 */
164 private function map_basic_fields( object $product, object $field_data, bool $is_variable ): array {
165 $basic = array();
166
167 $basic['is_variable'] = $is_variable;
168 $basic['original_product_id'] = isset( $product->id ) ? (string) $product->id : null;
169
170 $basic['name'] = isset( $field_data->name ) ? sanitize_text_field( (string) $field_data->name ) : '';
171 $basic['slug'] = isset( $field_data->slug ) ? sanitize_title( (string) $field_data->slug ) : sanitize_title( $basic['name'] );
172 $basic['description'] = isset( $field_data->description ) ? wp_kses_post( (string) $field_data->description ) : '';
173
174 $short_description_key = 'short-description';
175 $basic['short_description'] = isset( $field_data->{$short_description_key} )
176 ? wp_kses_post( (string) $field_data->{$short_description_key} )
177 : '';
178
179 // Status and visibility mirror the source on every import, so a re-run resets any manual
180 // draft/hide a merchant applied to a previously imported product. This is intentional: the
181 // migrator treats Webflow as the source of truth for these fields.
182 $basic['status'] = $this->map_status( $product );
183 $basic['catalog_visibility'] = 'visible';
184
185 if ( isset( $product->createdOn ) ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Webflow API uses camelCase.
186 $basic['date_created_gmt'] = (string) $product->createdOn; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Webflow API uses camelCase.
187 }
188 if ( isset( $product->lastUpdated ) ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Webflow API uses camelCase.
189 $basic['date_modified_gmt'] = (string) $product->lastUpdated; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Webflow API uses camelCase.
190 }
191
192 $basic['brand'] = null;
193
194 return $basic;
195 }
196
197 /**
198 * Map Webflow product publication flags to WC status.
199 *
200 * @param object $product Product object.
201 * @return string
202 */
203 private function map_status( object $product ): string {
204 $is_archived = ! empty( $product->isArchived ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Webflow API uses camelCase.
205 $is_draft = ! empty( $product->isDraft ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Webflow API uses camelCase.
206
207 if ( $is_archived ) {
208 return 'draft';
209 }
210 if ( $is_draft ) {
211 return 'draft';
212 }
213 return 'publish';
214 }
215
216 /**
217 * Read the pre-resolved categories the fetcher attached to the item.
218 *
219 * @param object $platform_data Raw item.
220 * @return array<int,array{name:string,slug:string}>
221 */
222 private function map_categories( object $platform_data ): array {
223 $resolved_key = '_resolved_categories';
224 if ( ! isset( $platform_data->{$resolved_key} ) || ! is_array( $platform_data->{$resolved_key} ) ) {
225 return array();
226 }
227
228 $categories = array();
229 foreach ( $platform_data->{$resolved_key} as $entry ) {
230 if ( is_array( $entry ) && ! empty( $entry['name'] ) ) {
231 $categories[] = array(
232 'name' => sanitize_text_field( (string) $entry['name'] ),
233 'slug' => sanitize_title( (string) ( $entry['slug'] ?? $entry['name'] ) ),
234 );
235 } elseif ( is_object( $entry ) && ! empty( $entry->name ) ) {
236 $categories[] = array(
237 'name' => sanitize_text_field( (string) $entry->name ),
238 'slug' => sanitize_title( (string) ( $entry->slug ?? $entry->name ) ),
239 );
240 }
241 }
242 return $categories;
243 }
244
245 /**
246 * Build the unified images array (product gallery + SKU images, deduped by URL).
247 *
248 * The importer requires every image referenced from a variation to also appear
249 * in this top-level array — that's how the original_id => attachment mapping is
250 * populated. We assign stable `original_id`s here (preferring Webflow's fileId,
251 * falling back to a URL hash) so SKU references resolve cleanly later.
252 *
253 * @param object $field_data Product fieldData.
254 * @param array<int,object> $skus SKUs array.
255 * @return array<int,array{original_id:string,src:string,alt:?string,is_featured:bool}>
256 */
257 private function build_images( object $field_data, array $skus ): array {
258 $images = array();
259 $by_url = array();
260 $featured_url = null;
261
262 // Product-level gallery: fieldData['more-images'].
263 $more_images_key = 'more-images';
264 if ( isset( $field_data->{$more_images_key} ) && is_array( $field_data->{$more_images_key} ) ) {
265 foreach ( $field_data->{$more_images_key} as $img ) {
266 $entry = $this->normalize_image_object( $img );
267 if ( null === $entry ) {
268 continue;
269 }
270 if ( null === $featured_url ) {
271 $featured_url = $entry['src'];
272 }
273 $this->add_unique_image( $images, $by_url, $entry );
274 }
275 }
276
277 // SKU main-image + more-images.
278 $main_image_key = 'main-image';
279 foreach ( $skus as $sku ) {
280 $sku_field = isset( $sku->fieldData ) && is_object( $sku->fieldData ) ? $sku->fieldData : null; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Webflow API uses camelCase.
281 if ( null === $sku_field ) {
282 continue;
283 }
284
285 $main_entry = isset( $sku_field->{$main_image_key} ) ? $this->normalize_image_object( $sku_field->{$main_image_key} ) : null;
286 if ( $main_entry ) {
287 if ( null === $featured_url ) {
288 $featured_url = $main_entry['src'];
289 }
290 $this->add_unique_image( $images, $by_url, $main_entry );
291 }
292
293 if ( isset( $sku_field->{$more_images_key} ) && is_array( $sku_field->{$more_images_key} ) ) {
294 foreach ( $sku_field->{$more_images_key} as $img ) {
295 $entry = $this->normalize_image_object( $img );
296 if ( $entry ) {
297 $this->add_unique_image( $images, $by_url, $entry );
298 }
299 }
300 }
301 }
302
303 // Mark featured.
304 foreach ( $images as &$image ) {
305 $image['is_featured'] = ( $image['src'] === $featured_url );
306 }
307 unset( $image );
308
309 return $images;
310 }
311
312 /**
313 * Normalize a Webflow image object into our images-array entry, or return null if unusable.
314 *
315 * @param mixed $img Raw Webflow image entry.
316 * @return array{original_id:string,src:string,alt:?string,is_featured:bool}|null
317 */
318 private function normalize_image_object( $img ): ?array {
319 if ( ! is_object( $img ) ) {
320 return null;
321 }
322
323 $url = isset( $img->url ) ? (string) $img->url : '';
324 if ( '' === $url ) {
325 return null;
326 }
327
328 $alt = isset( $img->alt ) ? (string) $img->alt : null;
329
330 $original_id = '';
331 if ( isset( $img->fileId ) && '' !== (string) $img->fileId ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Webflow API uses camelCase.
332 $original_id = (string) $img->fileId; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Webflow API uses camelCase.
333 } elseif ( isset( $img->id ) && '' !== (string) $img->id ) {
334 $original_id = (string) $img->id;
335 } else {
336 $original_id = 'webflow-' . md5( $url );
337 }
338
339 return array(
340 'original_id' => $original_id,
341 'src' => $url,
342 'alt' => $alt,
343 'is_featured' => false,
344 );
345 }
346
347 /**
348 * Append an image entry to $images keyed by URL, deduping repeats.
349 *
350 * @param array $images Image list (mutated).
351 * @param array $by_url URL => index map (mutated).
352 * @param array $entry The image entry to add.
353 * @return void
354 */
355 private function add_unique_image( array &$images, array &$by_url, array $entry ): void {
356 $url = $entry['src'];
357 if ( isset( $by_url[ $url ] ) ) {
358 return;
359 }
360 $by_url[ $url ] = count( $images );
361 $images[] = $entry;
362 }
363
364 /**
365 * Map fields specific to a simple (single-SKU) product.
366 *
367 * @param array<int,object> $skus SKUs.
368 * @return array
369 */
370 private function map_simple_data( array $skus ): array {
371 $simple = array(
372 'sku' => null,
373 'regular_price' => null,
374 'sale_price' => null,
375 'manage_stock' => false,
376 'stock_quantity' => null,
377 'stock_status' => 'instock',
378 'tax_status' => 'taxable',
379 );
380
381 if ( empty( $skus ) ) {
382 return $simple;
383 }
384
385 $sku = $skus[0];
386 $sku_field = isset( $sku->fieldData ) && is_object( $sku->fieldData ) ? $sku->fieldData : null; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Webflow API uses camelCase.
387 if ( null === $sku_field ) {
388 return $simple;
389 }
390
391 if ( $this->should_process( 'price' ) ) {
392 $prices = $this->extract_prices( $sku_field );
393 $simple['regular_price'] = $prices['regular_price'];
394 $simple['sale_price'] = $prices['sale_price'];
395 }
396
397 if ( $this->should_process( 'sku' ) && isset( $sku_field->sku ) ) {
398 $simple['sku'] = wc_clean( (string) $sku_field->sku );
399 }
400
401 if ( $this->should_process( 'stock' ) ) {
402 $stock = $this->extract_stock( $sku_field );
403 $simple['manage_stock'] = $stock['manage_stock'];
404 $simple['stock_quantity'] = $stock['stock_quantity'];
405 $simple['stock_status'] = $stock['stock_status'];
406 }
407
408 if ( $this->should_process( 'weight' ) ) {
409 $simple['weight'] = $this->extract_weight( $sku_field );
410 }
411
412 if ( $this->should_process( 'dimensions' ) ) {
413 $simple = array_merge( $simple, $this->extract_dimensions( $sku_field ) );
414 }
415
416 return $simple;
417 }
418
419 /**
420 * Map variable-product data: attribute definitions + per-variation rows.
421 *
422 * @param array<int,object> $properties sku-properties array.
423 * @param array<int,object> $skus SKUs array.
424 * @param array<int,array> $images Already-built images list (to resolve image references).
425 * @return array{attributes: array, variations: array}
426 */
427 private function map_variable_data( array $properties, array $skus, array $images ): array {
428 $attributes = array();
429
430 // Build a lookup: property_id => [ 'name' => string, 'enums' => [ enum_id => option_name ] ].
431 $property_lookup = array();
432 $position = 0;
433 foreach ( $properties as $property ) {
434 if ( ! is_object( $property ) || empty( $property->id ) || empty( $property->name ) ) {
435 continue;
436 }
437
438 $enum_map = array();
439 $enum_options = array();
440 $enums = ( isset( $property->enum ) && is_array( $property->enum ) ) ? $property->enum : array();
441
442 foreach ( $enums as $enum ) {
443 if ( ! is_object( $enum ) || empty( $enum->id ) || empty( $enum->name ) ) {
444 continue;
445 }
446 $enum_map[ (string) $enum->id ] = (string) $enum->name;
447 $enum_options[] = (string) $enum->name;
448 }
449
450 $property_lookup[ (string) $property->id ] = array(
451 'name' => (string) $property->name,
452 'enums' => $enum_map,
453 );
454
455 if ( $this->should_process( 'attributes' ) ) {
456 $attributes[] = array(
457 'name' => wc_clean( (string) $property->name ),
458 'options' => array_map( 'wc_clean', $enum_options ),
459 'position' => $position,
460 'is_visible' => true,
461 'is_variation' => true,
462 );
463 }
464
465 ++$position;
466 }
467
468 // Build URL => original_id map so we can resolve a SKU's main-image back to an entry in images[].
469 $url_to_original_id = array();
470 foreach ( $images as $image ) {
471 $url_to_original_id[ $image['src'] ] = $image['original_id'];
472 }
473
474 $variations = array();
475 $menu_order = 0;
476 foreach ( $skus as $sku ) {
477 $sku_field = isset( $sku->fieldData ) && is_object( $sku->fieldData ) ? $sku->fieldData : null; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Webflow API uses camelCase.
478 if ( null === $sku_field ) {
479 continue;
480 }
481
482 $variation = array(
483 'original_id' => isset( $sku->id ) ? (string) $sku->id : null,
484 'sku' => null,
485 'regular_price' => null,
486 'sale_price' => null,
487 'manage_stock' => false,
488 'stock_quantity' => null,
489 'stock_status' => 'instock',
490 'tax_status' => 'taxable',
491 'attributes' => array(),
492 'image_original_id' => null,
493 'menu_order' => $menu_order,
494 );
495
496 if ( $this->should_process( 'sku' ) && isset( $sku_field->sku ) ) {
497 $variation['sku'] = wc_clean( (string) $sku_field->sku );
498 }
499
500 if ( $this->should_process( 'price' ) ) {
501 $prices = $this->extract_prices( $sku_field );
502 $variation['regular_price'] = $prices['regular_price'];
503 $variation['sale_price'] = $prices['sale_price'];
504 }
505
506 if ( $this->should_process( 'stock' ) ) {
507 $stock = $this->extract_stock( $sku_field );
508 $variation['manage_stock'] = $stock['manage_stock'];
509 $variation['stock_quantity'] = $stock['stock_quantity'];
510 $variation['stock_status'] = $stock['stock_status'];
511 }
512
513 if ( $this->should_process( 'weight' ) ) {
514 $variation['weight'] = $this->extract_weight( $sku_field );
515 }
516
517 if ( $this->should_process( 'dimensions' ) ) {
518 $variation = array_merge( $variation, $this->extract_dimensions( $sku_field ) );
519 }
520
521 if ( $this->should_process( 'attributes' ) ) {
522 $variation['attributes'] = $this->resolve_variation_attributes( $sku_field, $property_lookup );
523 if ( empty( $variation['attributes'] ) ) {
524 wc_get_logger()->debug(
525 sprintf(
526 'Webflow variation %s resolved to zero attributes; WooCommerce will store it as an "Any" variation, which can collide with sibling variations.',
527 $variation['original_id'] ?? 'unknown'
528 ),
529 array( 'source' => 'wc-migrator' )
530 );
531 }
532 }
533
534 if ( $this->should_process( 'images' ) ) {
535 $main_image_key = 'main-image';
536 if ( isset( $sku_field->{$main_image_key}->url ) ) {
537 $url = (string) $sku_field->{$main_image_key}->url;
538 if ( isset( $url_to_original_id[ $url ] ) ) {
539 $variation['image_original_id'] = $url_to_original_id[ $url ];
540 }
541 }
542 }
543
544 $variations[] = $variation;
545 ++$menu_order;
546 }
547
548 return array(
549 'attributes' => $attributes,
550 'variations' => $variations,
551 );
552 }
553
554 /**
555 * Resolve a SKU's `sku-values` (property_id => enum_id) into a human-readable
556 * `attribute_name => option_name` array using the property lookup.
557 *
558 * @param object $sku_field SKU fieldData.
559 * @param array $property_lookup Property lookup map.
560 * @return array<string,string>
561 */
562 private function resolve_variation_attributes( object $sku_field, array $property_lookup ): array {
563 $resolved = array();
564 $values_key = 'sku-values';
565 if ( ! isset( $sku_field->{$values_key} ) ) {
566 return $resolved;
567 }
568
569 $values = $sku_field->{$values_key};
570 if ( ! is_object( $values ) && ! is_array( $values ) ) {
571 return $resolved;
572 }
573
574 foreach ( (array) $values as $property_id => $enum_id ) {
575 $property_id = (string) $property_id;
576 $enum_id = (string) $enum_id;
577 if ( ! isset( $property_lookup[ $property_id ] ) ) {
578 continue;
579 }
580 $property_name = $property_lookup[ $property_id ]['name'];
581 $option_name = $property_lookup[ $property_id ]['enums'][ $enum_id ] ?? null;
582 if ( null === $option_name ) {
583 continue;
584 }
585 $resolved[ sanitize_text_field( $property_name ) ] = sanitize_text_field( $option_name );
586 }
587
588 return $resolved;
589 }
590
591 /**
592 * Convert Webflow `price.value` (minor units, e.g. cents) into a decimal string,
593 * and detect sale pricing via `compare-at-price`.
594 *
595 * @param object $sku_field SKU fieldData.
596 * @return array{regular_price: ?string, sale_price: ?string}
597 */
598 private function extract_prices( object $sku_field ): array {
599 $price = $this->price_to_decimal( $sku_field->price ?? null );
600 $compare_key = 'compare-at-price';
601 $compare_price = $this->price_to_decimal( $sku_field->{$compare_key} ?? null );
602
603 if ( null !== $compare_price && null !== $price && (float) $compare_price > (float) $price ) {
604 return array(
605 'regular_price' => $compare_price,
606 'sale_price' => $price,
607 );
608 }
609
610 return array(
611 'regular_price' => $price,
612 'sale_price' => null,
613 );
614 }
615
616 /**
617 * Convert a Webflow money object `{ value: int (minor units), unit: "USD" }` into a decimal string.
618 *
619 * The number of minor units per major unit varies by currency (e.g. JPY has 0,
620 * KWD has 3, most have 2), so the divisor is derived from the currency's
621 * `num_decimals` rather than a hardcoded `/ 100`.
622 *
623 * @param mixed $money Webflow money object.
624 * @return string|null
625 */
626 private function price_to_decimal( $money ): ?string {
627 if ( ! is_object( $money ) || ! isset( $money->value ) ) {
628 return null;
629 }
630 $minor = (int) $money->value;
631 if ( $minor < 0 ) {
632 return null;
633 }
634 $unit = isset( $money->unit ) ? (string) $money->unit : 'USD';
635 $decimals = $this->get_currency_decimals( $unit );
636 return number_format( $minor / ( 10 ** $decimals ), $decimals, '.', '' );
637 }
638
639 /**
640 * Get the number of decimal places (minor units) for an ISO currency code.
641 *
642 * Reads core's `i18n/locale-info.php` so the migrator stays in sync with
643 * WooCommerce's own per-currency data. Falls back to 2 for unknown codes.
644 *
645 * @param string $currency ISO 4217 currency code.
646 * @return int
647 */
648 private function get_currency_decimals( string $currency ): int {
649 if ( null === $this->currency_decimals ) {
650 $this->currency_decimals = array();
651 $locale_info = include WC()->plugin_path() . '/i18n/locale-info.php';
652 if ( is_array( $locale_info ) ) {
653 foreach ( $locale_info as $info ) {
654 if ( isset( $info['currency_code'], $info['num_decimals'] ) ) {
655 $this->currency_decimals[ $info['currency_code'] ] = (int) $info['num_decimals'];
656 }
657 }
658 }
659 }
660 return $this->currency_decimals[ strtoupper( $currency ) ] ?? 2;
661 }
662
663 /**
664 * Map Webflow inventory shape to WC stock fields.
665 *
666 * @param object $sku_field SKU fieldData.
667 * @return array{manage_stock: bool, stock_quantity: ?int, stock_status: string}
668 */
669 private function extract_stock( object $sku_field ): array {
670 $inventory = isset( $sku_field->inventory ) && is_object( $sku_field->inventory ) ? $sku_field->inventory : null;
671
672 if ( null === $inventory ) {
673 return array(
674 'manage_stock' => false,
675 'stock_quantity' => null,
676 'stock_status' => 'instock',
677 );
678 }
679
680 $type = isset( $inventory->type ) ? (string) $inventory->type : 'infinite';
681 if ( 'finite' !== $type ) {
682 return array(
683 'manage_stock' => false,
684 'stock_quantity' => null,
685 'stock_status' => 'instock',
686 );
687 }
688
689 $quantity = isset( $inventory->quantity ) ? (int) $inventory->quantity : 0;
690 return array(
691 'manage_stock' => true,
692 'stock_quantity' => $quantity,
693 'stock_status' => $quantity > 0 ? 'instock' : 'outofstock',
694 );
695 }
696
697 /**
698 * Extract Webflow SKU dimensions (length, width, height).
699 *
700 * Webflow returns these as raw numerics with no associated unit on the SKU
701 * payload — the unit is a store-level setting on the Webflow side, with no
702 * API representation. We pass through as-is; WooCommerce will interpret them
703 * in whatever `woocommerce_dimension_unit` is configured for the destination
704 * store. Null/zero/non-numeric values are dropped.
705 *
706 * @param object $sku_field SKU fieldData.
707 * @return array{length: ?float, width: ?float, height: ?float}
708 */
709 private function extract_dimensions( object $sku_field ): array {
710 $dimensions = array(
711 'length' => null,
712 'width' => null,
713 'height' => null,
714 );
715 foreach ( array_keys( $dimensions ) as $key ) {
716 if ( isset( $sku_field->{$key} ) && is_numeric( $sku_field->{$key} ) && (float) $sku_field->{$key} > 0 ) {
717 $dimensions[ $key ] = (float) $sku_field->{$key};
718 }
719 }
720 return $dimensions;
721 }
722
723 /**
724 * Convert Webflow `weight` (with optional `weight-unit`) to the store's weight unit.
725 *
726 * @param object $sku_field SKU fieldData.
727 * @return float|null
728 */
729 private function extract_weight( object $sku_field ): ?float {
730 if ( ! isset( $sku_field->weight ) ) {
731 return null;
732 }
733
734 $weight = (float) $sku_field->weight;
735 if ( $weight <= 0 ) {
736 return null;
737 }
738
739 $unit_key = 'weight-unit';
740 $source_unit = isset( $sku_field->{$unit_key} ) ? strtolower( (string) $sku_field->{$unit_key} ) : 'lbs';
741 $source_unit = $this->normalize_weight_unit( $source_unit );
742
743 $store_unit = strtolower( (string) get_option( 'woocommerce_weight_unit', 'kg' ) );
744
745 if ( $source_unit === $store_unit ) {
746 return $weight;
747 }
748
749 if ( function_exists( 'wc_get_weight' ) ) {
750 $converted = wc_get_weight( $weight, $store_unit, $source_unit );
751 return is_numeric( $converted ) ? (float) $converted : $weight;
752 }
753
754 return $weight;
755 }
756
757 /**
758 * Normalize a Webflow weight unit string to a wc_get_weight compatible unit.
759 *
760 * @param string $unit Raw unit string.
761 * @return string
762 */
763 private function normalize_weight_unit( string $unit ): string {
764 $map = array(
765 'oz' => 'oz',
766 'lb' => 'lbs',
767 'lbs' => 'lbs',
768 'pound' => 'lbs',
769 'g' => 'g',
770 'gram' => 'g',
771 'kg' => 'kg',
772 );
773 return $map[ $unit ] ?? 'lbs';
774 }
775
776 /**
777 * Map Webflow SEO fields (`seo-title`, `seo-description`) into a metafields array.
778 *
779 * @param object $field_data Field data.
780 * @return array<string,string>
781 */
782 private function map_seo( object $field_data ): array {
783 $meta = array();
784
785 $title_key = 'seo-title';
786 $desc_key = 'seo-description';
787
788 if ( isset( $field_data->{$title_key} ) && '' !== (string) $field_data->{$title_key} ) {
789 $meta['global_title_tag'] = (string) $field_data->{$title_key};
790 }
791 if ( isset( $field_data->{$desc_key} ) && '' !== (string) $field_data->{$desc_key} ) {
792 $meta['global_description_tag'] = (string) $field_data->{$desc_key};
793 }
794
795 return $meta;
796 }
797
798 /**
799 * Should this field be processed?
800 *
801 * @param string $field_key The field key.
802 * @return bool
803 */
804 private function should_process( string $field_key ): bool {
805 if ( empty( $this->fields_to_process ) ) {
806 return true;
807 }
808 return in_array( $field_key, $this->fields_to_process, true );
809 }
810 }
811