WebflowClient.php
1 month ago
WebflowFetcher.php
1 month ago
WebflowMapper.php
1 month ago
WebflowPlatform.php
1 month ago
WebflowFetcher.php
365 lines
| 1 | <?php |
| 2 | /** |
| 3 | * Webflow Fetcher |
| 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\PlatformFetcherInterface; |
| 13 | |
| 14 | defined( 'ABSPATH' ) || exit; |
| 15 | |
| 16 | /** |
| 17 | * Fetches products (and ecommerce category metadata) from the Webflow v2 API. |
| 18 | * |
| 19 | * The Webflow API exposes offset/limit pagination rather than cursors, but the |
| 20 | * migrator's PlatformFetcherInterface speaks cursors. We use the next offset as |
| 21 | * a stringified cursor and translate it back to an `offset` query parameter. |
| 22 | * |
| 23 | * Webflow does not expose a `/products/categories` endpoint. Categories live in |
| 24 | * an auto-provisioned CMS collection (slug `category`). We resolve them once |
| 25 | * via `/sites/{id}/collections` + `/collections/{id}/items` and stash a map of |
| 26 | * `cms_item_id => { name, slug }` and decorates each fetched product item with a |
| 27 | * `_resolved_categories` property so the mapper can read categories inline. |
| 28 | * |
| 29 | * @internal This class is part of the CLI Migrator feature and should not be used directly. |
| 30 | */ |
| 31 | class WebflowFetcher implements PlatformFetcherInterface { |
| 32 | |
| 33 | /** |
| 34 | * Maximum page size allowed by Webflow. |
| 35 | * |
| 36 | * @var int |
| 37 | */ |
| 38 | private const MAX_PAGE_SIZE = 100; |
| 39 | |
| 40 | /** |
| 41 | * The Webflow client instance. |
| 42 | * |
| 43 | * @var WebflowClient |
| 44 | */ |
| 45 | private WebflowClient $webflow_client; |
| 46 | |
| 47 | /** |
| 48 | * Cached category map: cms_item_id => array{name:string, slug:string}. |
| 49 | * |
| 50 | * Null until resolved; empty array if the site has no categories collection. |
| 51 | * |
| 52 | * @var array<string,array{name:string,slug:string}>|null |
| 53 | */ |
| 54 | private ?array $category_cache = null; |
| 55 | |
| 56 | /** |
| 57 | * Constructor. |
| 58 | * |
| 59 | * @param array $credentials Platform credentials array. |
| 60 | */ |
| 61 | public function __construct( array $credentials ) { |
| 62 | $this->webflow_client = new WebflowClient( $credentials ); |
| 63 | } |
| 64 | |
| 65 | /** |
| 66 | * Fetches a batch of products from the Webflow REST API. |
| 67 | * |
| 68 | * @param array $args Arguments for fetching. Supported keys: |
| 69 | * - 'limit': Max number of items per batch (default: 50, capped at 100). |
| 70 | * - 'after_cursor': Stringified next-offset for pagination (optional). |
| 71 | * |
| 72 | * @return array{items: array, cursor: ?string, has_next_page: bool} |
| 73 | */ |
| 74 | public function fetch_batch( array $args ): array { |
| 75 | $site_id = $this->webflow_client->get_site_id(); |
| 76 | if ( is_wp_error( $site_id ) ) { |
| 77 | // @phpstan-ignore-next-line class.notFound |
| 78 | \WP_CLI::warning( 'Failed to fetch Webflow products: ' . $site_id->get_error_message() ); |
| 79 | return $this->empty_batch(); |
| 80 | } |
| 81 | |
| 82 | $limit = (int) ( $args['limit'] ?? 50 ); |
| 83 | $limit = max( 1, min( $limit, self::MAX_PAGE_SIZE ) ); |
| 84 | $offset = 0; |
| 85 | if ( isset( $args['after_cursor'] ) && is_numeric( $args['after_cursor'] ) ) { |
| 86 | $offset = max( 0, (int) $args['after_cursor'] ); |
| 87 | } |
| 88 | |
| 89 | $query = array( |
| 90 | 'limit' => $limit, |
| 91 | 'offset' => $offset, |
| 92 | ); |
| 93 | |
| 94 | $response = $this->webflow_client->rest_request( "/sites/{$site_id}/products", $query ); |
| 95 | |
| 96 | if ( is_wp_error( $response ) ) { |
| 97 | // @phpstan-ignore-next-line class.notFound |
| 98 | \WP_CLI::warning( 'Failed to fetch products from Webflow: ' . $response->get_error_message() ); |
| 99 | return $this->empty_batch(); |
| 100 | } |
| 101 | |
| 102 | if ( ! is_object( $response ) || ! isset( $response->items ) || ! is_array( $response->items ) ) { |
| 103 | // @phpstan-ignore-next-line class.notFound |
| 104 | \WP_CLI::warning( 'Invalid Webflow response: missing items array.' ); |
| 105 | return $this->empty_batch(); |
| 106 | } |
| 107 | |
| 108 | $items = $response->items; |
| 109 | $count = count( $items ); |
| 110 | $total = isset( $response->pagination->total ) ? (int) $response->pagination->total : ( $offset + $count ); |
| 111 | $next = $offset + $count; |
| 112 | $has_next = $count > 0 && $next < $total; |
| 113 | |
| 114 | $this->ensure_category_cache_loaded( $site_id ); |
| 115 | $this->decorate_items_with_categories( $items ); |
| 116 | |
| 117 | // The cursor is simply the next offset. Unlike Shopify's stable cursors, offset paging can |
| 118 | // skip or double-import items if the Webflow catalog changes (a product added or removed) |
| 119 | // between batches. Acceptable here given Webflow's API offers no stable cursor. |
| 120 | return array( |
| 121 | 'items' => $items, |
| 122 | 'cursor' => $has_next ? (string) $next : null, |
| 123 | 'has_next_page' => $has_next, |
| 124 | ); |
| 125 | } |
| 126 | |
| 127 | /** |
| 128 | * Fetches the total count of products from Webflow. |
| 129 | * |
| 130 | * @param array $args Filter arguments (unused; Webflow's products endpoint does not accept filters). |
| 131 | * @return int Total product count, or 0 on failure. |
| 132 | */ |
| 133 | public function fetch_total_count( array $args ): int { |
| 134 | unset( $args ); |
| 135 | // Webflow does not support filtered count queries here. |
| 136 | |
| 137 | $site_id = $this->webflow_client->get_site_id(); |
| 138 | if ( is_wp_error( $site_id ) ) { |
| 139 | // @phpstan-ignore-next-line class.notFound |
| 140 | \WP_CLI::warning( 'Could not fetch Webflow product count: ' . $site_id->get_error_message() ); |
| 141 | return 0; |
| 142 | } |
| 143 | |
| 144 | $response = $this->webflow_client->rest_request( |
| 145 | "/sites/{$site_id}/products", |
| 146 | array( |
| 147 | 'limit' => 1, |
| 148 | 'offset' => 0, |
| 149 | ) |
| 150 | ); |
| 151 | |
| 152 | if ( is_wp_error( $response ) ) { |
| 153 | // @phpstan-ignore-next-line class.notFound |
| 154 | \WP_CLI::warning( 'Could not fetch Webflow product count: ' . $response->get_error_message() ); |
| 155 | return 0; |
| 156 | } |
| 157 | |
| 158 | if ( ! is_object( $response ) || ! isset( $response->pagination->total ) ) { |
| 159 | // @phpstan-ignore-next-line class.notFound |
| 160 | \WP_CLI::warning( 'Unexpected response from Webflow products endpoint - missing pagination.total.' ); |
| 161 | return 0; |
| 162 | } |
| 163 | |
| 164 | return (int) $response->pagination->total; |
| 165 | } |
| 166 | |
| 167 | /** |
| 168 | * Decorate each product item with a `_resolved_categories` property whose value is an |
| 169 | * array of `{name, slug}` for every recognized CMS item id in `product.fieldData.category`. |
| 170 | * |
| 171 | * This is how the mapper learns about category names without needing access to the |
| 172 | * fetcher or making its own API calls. Missing/archived ids are silently dropped. |
| 173 | * |
| 174 | * @param array<int,object> $items Items array (mutated in place). |
| 175 | * @return void |
| 176 | */ |
| 177 | private function decorate_items_with_categories( array &$items ): void { |
| 178 | if ( null === $this->category_cache || empty( $this->category_cache ) ) { |
| 179 | foreach ( $items as $item ) { |
| 180 | if ( is_object( $item ) ) { |
| 181 | // @phpstan-ignore-next-line property.notFound |
| 182 | $item->_resolved_categories = array(); |
| 183 | } |
| 184 | } |
| 185 | return; |
| 186 | } |
| 187 | |
| 188 | foreach ( $items as $item ) { |
| 189 | if ( ! is_object( $item ) ) { |
| 190 | continue; |
| 191 | } |
| 192 | |
| 193 | $category_ids = array(); |
| 194 | if ( isset( $item->product->fieldData->category ) && is_array( $item->product->fieldData->category ) ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Webflow API uses camelCase. |
| 195 | $category_ids = $item->product->fieldData->category; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Webflow API uses camelCase. |
| 196 | } |
| 197 | |
| 198 | $resolved = array(); |
| 199 | foreach ( $category_ids as $cms_item_id ) { |
| 200 | $cms_item_id = (string) $cms_item_id; |
| 201 | if ( isset( $this->category_cache[ $cms_item_id ] ) ) { |
| 202 | $resolved[] = $this->category_cache[ $cms_item_id ]; |
| 203 | } |
| 204 | } |
| 205 | |
| 206 | // @phpstan-ignore-next-line property.notFound |
| 207 | $item->_resolved_categories = $resolved; |
| 208 | } |
| 209 | } |
| 210 | |
| 211 | /** |
| 212 | * Ensures the category cache has been resolved at most once per fetcher instance. |
| 213 | * |
| 214 | * @param string $site_id Webflow site ID. |
| 215 | * @return void |
| 216 | */ |
| 217 | private function ensure_category_cache_loaded( string $site_id ): void { |
| 218 | if ( null !== $this->category_cache ) { |
| 219 | return; |
| 220 | } |
| 221 | |
| 222 | $this->category_cache = array(); |
| 223 | |
| 224 | $collections_response = $this->webflow_client->rest_request( "/sites/{$site_id}/collections" ); |
| 225 | if ( is_wp_error( $collections_response ) ) { |
| 226 | // @phpstan-ignore-next-line class.notFound |
| 227 | \WP_CLI::debug( 'Could not load Webflow collections list (categories will not be resolved): ' . $collections_response->get_error_message() ); |
| 228 | return; |
| 229 | } |
| 230 | |
| 231 | $collections = $this->extract_collections_list( $collections_response ); |
| 232 | $category_id = $this->find_category_collection_id( $collections ); |
| 233 | |
| 234 | if ( null === $category_id ) { |
| 235 | // @phpstan-ignore-next-line class.notFound |
| 236 | \WP_CLI::debug( 'No "category" collection found on Webflow site; product categories will be skipped.' ); |
| 237 | return; |
| 238 | } |
| 239 | |
| 240 | $this->category_cache = $this->load_collection_items_map( $category_id ); |
| 241 | } |
| 242 | |
| 243 | /** |
| 244 | * Webflow's /collections endpoint sometimes returns a bare array and sometimes an envelope. |
| 245 | * Normalize to a flat list of collection objects. |
| 246 | * |
| 247 | * @param mixed $response The raw decoded API response. |
| 248 | * @return array<int,object> |
| 249 | */ |
| 250 | private function extract_collections_list( $response ): array { |
| 251 | if ( is_array( $response ) ) { |
| 252 | return $response; |
| 253 | } |
| 254 | |
| 255 | if ( is_object( $response ) && isset( $response->collections ) && is_array( $response->collections ) ) { |
| 256 | return $response->collections; |
| 257 | } |
| 258 | |
| 259 | return array(); |
| 260 | } |
| 261 | |
| 262 | /** |
| 263 | * Find the auto-provisioned Ecommerce Categories collection. |
| 264 | * |
| 265 | * @param array<int,object> $collections List of collection objects. |
| 266 | * @return string|null Collection ID or null if not found. |
| 267 | */ |
| 268 | private function find_category_collection_id( array $collections ): ?string { |
| 269 | foreach ( $collections as $collection ) { |
| 270 | if ( ! is_object( $collection ) || empty( $collection->id ) ) { |
| 271 | continue; |
| 272 | } |
| 273 | $slug = isset( $collection->slug ) ? (string) $collection->slug : ''; |
| 274 | $display_name = isset( $collection->displayName ) ? (string) $collection->displayName : ''; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Webflow API uses camelCase. |
| 275 | |
| 276 | if ( 'category' === $slug || 'Categories' === $display_name ) { |
| 277 | return (string) $collection->id; |
| 278 | } |
| 279 | } |
| 280 | |
| 281 | return null; |
| 282 | } |
| 283 | |
| 284 | /** |
| 285 | * Page through a CMS collection and build an id => {name, slug} map. |
| 286 | * |
| 287 | * @param string $collection_id The Webflow CMS collection ID. |
| 288 | * @return array<string,array{name:string,slug:string}> |
| 289 | */ |
| 290 | private function load_collection_items_map( string $collection_id ): array { |
| 291 | $map = array(); |
| 292 | $offset = 0; |
| 293 | $page_size = self::MAX_PAGE_SIZE; |
| 294 | $max_pages = 100; |
| 295 | // Safety net to avoid runaway loops on broken pagination. |
| 296 | $page_index = 0; |
| 297 | |
| 298 | while ( $page_index < $max_pages ) { |
| 299 | $response = $this->webflow_client->rest_request( |
| 300 | "/collections/{$collection_id}/items", |
| 301 | array( |
| 302 | 'limit' => $page_size, |
| 303 | 'offset' => $offset, |
| 304 | ) |
| 305 | ); |
| 306 | |
| 307 | if ( is_wp_error( $response ) ) { |
| 308 | // @phpstan-ignore-next-line class.notFound |
| 309 | \WP_CLI::debug( 'Could not load Webflow category items: ' . $response->get_error_message() ); |
| 310 | break; |
| 311 | } |
| 312 | |
| 313 | $items = ( is_object( $response ) && isset( $response->items ) && is_array( $response->items ) ) ? $response->items : array(); |
| 314 | if ( empty( $items ) ) { |
| 315 | break; |
| 316 | } |
| 317 | |
| 318 | foreach ( $items as $item ) { |
| 319 | if ( ! is_object( $item ) || empty( $item->id ) ) { |
| 320 | continue; |
| 321 | } |
| 322 | $field_data = isset( $item->fieldData ) && is_object( $item->fieldData ) ? $item->fieldData : null; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Webflow API uses camelCase. |
| 323 | if ( null === $field_data ) { |
| 324 | continue; |
| 325 | } |
| 326 | |
| 327 | $name = isset( $field_data->name ) ? (string) $field_data->name : ''; |
| 328 | $slug = isset( $field_data->slug ) ? (string) $field_data->slug : sanitize_title( $name ); |
| 329 | |
| 330 | if ( '' === $name ) { |
| 331 | continue; |
| 332 | } |
| 333 | |
| 334 | $map[ (string) $item->id ] = array( |
| 335 | 'name' => $name, |
| 336 | 'slug' => $slug, |
| 337 | ); |
| 338 | } |
| 339 | |
| 340 | $total = isset( $response->pagination->total ) ? (int) $response->pagination->total : ( $offset + count( $items ) ); |
| 341 | $offset += count( $items ); |
| 342 | if ( $offset >= $total ) { |
| 343 | break; |
| 344 | } |
| 345 | |
| 346 | ++$page_index; |
| 347 | } |
| 348 | |
| 349 | return $map; |
| 350 | } |
| 351 | |
| 352 | /** |
| 353 | * Empty-batch response helper. |
| 354 | * |
| 355 | * @return array{items: array, cursor: null, has_next_page: false} |
| 356 | */ |
| 357 | private function empty_batch(): array { |
| 358 | return array( |
| 359 | 'items' => array(), |
| 360 | 'cursor' => null, |
| 361 | 'has_next_page' => false, |
| 362 | ); |
| 363 | } |
| 364 | } |
| 365 |