Файловый менеджер - Редактировать - /home/tuudkjt/globeasy/wp-includes/ID3/CLI.tar
Назад
Migrator/Interfaces/PlatformMapperInterface.php 0000777 00000001412 15252251042 0015701 0 ustar 00 <?php declare( strict_types=1 ); namespace Automattic\WooCommerce\Internal\CLI\Migrator\Interfaces; /** * Defines the contract for classes responsible for transforming * raw platform data into a standardized format suitable for the WooCommerce Importer. */ interface PlatformMapperInterface { /** * Maps raw platform product data to a standardized array format. * * @param object $platform_data The raw product data object from the source platform (e.g., Shopify product node). * * @return array A standardized array representing the product, understandable by the WooCommerce_Product_Importer. * The specific structure of this array needs to be defined and adhered to. */ public function map_product_data( object $platform_data ): array; } Migrator/Interfaces/PlatformFetcherInterface.php 0000777 00000002641 15252251042 0016042 0 ustar 00 <?php declare( strict_types=1 ); namespace Automattic\WooCommerce\Internal\CLI\Migrator\Interfaces; /** * Defines the contract for classes responsible for retrieving * data (like products or orders) from a source platform API. * * Implementations should accept platform credentials via constructor: * public function __construct(array $credentials) */ interface PlatformFetcherInterface { /** * Fetches a batch of items from the source platform. * * @param array $args Arguments for fetching (e.g., limit, cursor, filters). * Specific arguments depend on the implementation. * * @return array An array containing: * 'items' => array Raw items fetched from the platform. * 'cursor' => ?string The cursor for the next page, or null if no more pages. * 'has_next_page' => bool Indicates if there are more pages to fetch. */ public function fetch_batch( array $args ): array; /** * Fetches the estimated total count of items available for migration. * * Used primarily for progress indicators. If a total count is not available, * this method should return 0. * * @param array $args Arguments for filtering the count (e.g., status, date range). * Specific arguments depend on the implementation. * * @return int The total estimated count. */ public function fetch_total_count( array $args ): int; } Migrator/Core/ProductsController.php 0000777 00000102622 15252251042 0013610 0 ustar 00 <?php /** * Products Controller * * @package Automattic\WooCommerce\Internal\CLI\Migrator\Core */ declare( strict_types=1 ); namespace Automattic\WooCommerce\Internal\CLI\Migrator\Core; use Automattic\WooCommerce\Internal\CLI\Migrator\Core\CredentialManager; use Automattic\WooCommerce\Internal\CLI\Migrator\Core\MigratorTracker; use Automattic\WooCommerce\Internal\CLI\Migrator\Core\PlatformRegistry; use Automattic\WooCommerce\Internal\CLI\Migrator\Core\WooCommerceProductImporter; use Automattic\WooCommerce\Internal\CLI\Migrator\Lib\ImportSession; use Exception; use WP_CLI; defined( 'ABSPATH' ) || exit; /** * ProductsController class. * * Main orchestration engine for product migration that integrates existing components * (PlatformRegistry, CredentialManager, ShopifyFetcher/Mapper, ImportSession) to create * a cohesive migration system with cursor-based resumption. * * @internal This class is part of the CLI Migrator feature and should not be used directly. */ class ProductsController { /** * The credential manager. * * @var CredentialManager */ private CredentialManager $credential_manager; /** * The platform registry. * * @var PlatformRegistry */ private PlatformRegistry $platform_registry; /** * Current import session. * * @var ImportSession|null */ private ?ImportSession $session = null; /** * Parsed command arguments. * * @var array */ private array $parsed_args = array(); /** * Fields to process during migration. * * @var array */ private array $fields_to_process = array(); /** * WooCommerce Product Importer instance. * * @var WooCommerceProductImporter */ private WooCommerceProductImporter $product_importer; /** * Migration tracker instance. * * @var MigratorTracker */ private MigratorTracker $tracker; /** * Run start time for this CLI invocation (used for timing metrics). * * @var int */ private int $session_start_time = 0; /** * Initialize the controller with its dependencies. * Called automatically by the WooCommerce DI container. * * @internal * * @param CredentialManager $credential_manager The credential manager. * @param PlatformRegistry $platform_registry The platform registry. * @param WooCommerceProductImporter $product_importer The product importer. * @param MigratorTracker $tracker The migration tracker. */ final public function init( CredentialManager $credential_manager, PlatformRegistry $platform_registry, WooCommerceProductImporter $product_importer, MigratorTracker $tracker ): void { $this->credential_manager = $credential_manager; $this->platform_registry = $platform_registry; $this->product_importer = $product_importer; $this->tracker = $tracker; } /** * Main entry point for migrating products. * * @param array $assoc_args Command-line arguments. * @param string $platform Optional pre-resolved platform (to avoid duplicate resolution). * @return void */ public function migrate_products( array $assoc_args, string $platform = '' ): void { $this->parsed_args = $this->parse_and_validate_args( $assoc_args, $platform ); if ( empty( $this->parsed_args ) ) { return; } $this->session_start_time = time(); if ( $this->parsed_args['dry_run'] ) { WP_CLI::line( WP_CLI::colorize( '%Y--- DRY RUN MODE ENABLED ---%n' ) ); WP_CLI::line( 'No products will be created or modified. This is a simulation only.' ); WP_CLI::line( '' ); } if ( ! $this->parsed_args['dry_run'] ) { $this->session = $this->manage_session_lifecycle( $this->parsed_args ); if ( ! $this->session ) { return; } /** * Fires when a migration session starts. * * @since 10.3.0 * * @param string $platform The platform being migrated from. * @param array $metadata Session metadata including session_id, filters, and fields. */ do_action( 'wc_migrator_session_started', $this->parsed_args['platform'], array( 'session_id' => $this->session->get_id(), 'filters' => $this->parsed_args['filters'], 'fields' => $this->fields_to_process, 'is_dry_run' => $this->parsed_args['dry_run'], 'resume' => $this->parsed_args['resume'], ) ); } $fetcher = $this->platform_registry->get_fetcher( $this->parsed_args['platform'] ); $mapper = $this->platform_registry->get_mapper( $this->parsed_args['platform'], array( 'fields' => $this->fields_to_process ) ); $total_count = $fetcher->fetch_total_count( $this->parsed_args['filters'] ); if ( ! $this->parsed_args['dry_run'] ) { $existing_total = $this->session->count_all_total_entities(); if ( 0 < $total_count && 0 === $existing_total ) { $this->session->bump_total_number_of_entities( array( 'post' => $total_count ) ); } } WP_CLI::line( "Total entities found: {$total_count}" ); $progress_label = $this->parsed_args['dry_run'] ? 'Simulating Products from ' . ucfirst( $this->parsed_args['platform'] ) : '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'] ) { $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(); $this->execute_migration_loop( $fetcher, $mapper, $progress ); $progress->finish(); $this->display_migration_summary(); $this->display_feedback_survey(); if ( ! $this->parsed_args['dry_run'] ) { $final_stats = array( 'total_found' => $total_count, 'total_imported' => $this->session->count_all_imported_entities(), ); /** * Fires when a migration session completes. * * @since 10.3.0 * * @param string $platform The platform being migrated from. * @param array $final_stats Final migration statistics. */ do_action( 'wc_migrator_session_completed', $this->parsed_args['platform'], $final_stats ); $this->log_session_time_metrics( $final_stats ); } if ( $this->parsed_args['dry_run'] ) { WP_CLI::success( 'Dry-run completed successfully. No products were actually created or modified.' ); } else { WP_CLI::success( 'Migration completed successfully.' ); } } /** * Execute the main cursor-based migration loop. * * @param object $fetcher The platform fetcher instance. * @param object $mapper The platform mapper instance. * @param object $progress The WP_CLI progress bar instance. * @return void */ private function execute_migration_loop( $fetcher, $mapper, $progress ): void { $limit_remaining = $this->parsed_args['limit']; $session_cursor = $this->parsed_args['dry_run'] ? null : $this->session->get_reentrancy_cursor(); $after_cursor = ! empty( $session_cursor ) ? $session_cursor : null; $has_next_page = true; $total_processed_in_session = 0; do { $batch_limit = min( $this->parsed_args['batch_size'], $limit_remaining ); if ( $batch_limit <= 0 ) { break; } $batch_args = array( 'limit' => $batch_limit, 'after_cursor' => $after_cursor, ); if ( ! empty( $this->parsed_args['filters'] ) ) { $batch_args = array_merge( $batch_args, $this->parsed_args['filters'] ); } try { $batch_data = $fetcher->fetch_batch( $batch_args ); } catch ( Exception $e ) { /** * Fires when an error occurs during migration. * * @since 10.3.0 * * @param string $error_type The type of error (fetch, mapping, import). * @param string $message The error message. * @param array $context Additional error context. */ do_action( 'wc_migrator_error_occurred', 'fetch', $e->getMessage(), array( 'batch_args' => $batch_args, 'platform' => $this->parsed_args['platform'], ) ); WP_CLI::warning( "Error fetching batch: {$e->getMessage()}" ); break; } if ( empty( $batch_data['items'] ) ) { break; } $processed_count = $this->process_batch( $batch_data['items'], $mapper ); $total_processed_in_session += $processed_count; if ( ! $this->parsed_args['dry_run'] ) { $this->session->bump_imported_entities_counts( array( 'post' => $processed_count ) ); $after_cursor = $batch_data['cursor']; $this->session->set_reentrancy_cursor( $after_cursor ); } else { $after_cursor = $batch_data['cursor']; } $limit_remaining -= count( $batch_data['items'] ); $has_next_page = $batch_data['has_next_page'] ?? false; $progress->tick( $processed_count, sprintf( 'Processed %d products', $total_processed_in_session ) ); } while ( $has_next_page && $limit_remaining > 0 ); if ( ! $has_next_page && ! $this->parsed_args['dry_run'] ) { $this->session->set_stage( ImportSession::STAGE_FINISHED ); } } /** * Parse and validate command-line arguments. * * @param array $assoc_args Raw associative arguments. * @param string $platform Optional pre-resolved platform. * @return array Parsed and validated arguments or empty array on error. */ private function parse_and_validate_args( array $assoc_args, string $platform = '' ): array { $parsed = array(); // Platform validation - use pre-resolved platform if provided, otherwise resolve. if ( empty( $platform ) ) { $platform = $this->platform_registry->resolve_platform( $assoc_args ); if ( empty( $platform ) ) { return array(); } } $parsed['platform'] = $platform; $this->fields_to_process = $this->parse_field_selection( $assoc_args ); $parsed['fields'] = $this->fields_to_process; $parsed['limit'] = isset( $assoc_args['limit'] ) ? max( 1, (int) $assoc_args['limit'] ) : PHP_INT_MAX; $parsed['batch_size'] = isset( $assoc_args['batch-size'] ) ? max( 1, min( 250, (int) $assoc_args['batch-size'] ) ) : 20; $parsed['skip_existing'] = isset( $assoc_args['skip-existing'] ); $parsed['dry_run'] = isset( $assoc_args['dry-run'] ); $parsed['resume'] = isset( $assoc_args['resume'] ); $parsed['verbose'] = isset( $assoc_args['verbose'] ); $parsed['assign_default_category'] = isset( $assoc_args['assign-default-category'] ); $parsed['filters'] = $this->parse_query_filters( $assoc_args ); if ( ! $this->credential_manager->has_credentials( $platform ) ) { $platform_display_name = $this->platform_registry->get_platform_display_name( $platform ); WP_CLI::error( sprintf( "No credentials found for platform '%s'. Please run: wp wc migrate setup --platform=%s", $platform_display_name, $platform ) ); return array(); } return $parsed; } /** * Parse field selection from command arguments. * * @param array $assoc_args Command arguments. * @return array Selected fields to process. */ private function parse_field_selection( array $assoc_args ): array { $default_fields = array( 'name', 'slug', 'description', 'status', 'date_created', 'catalog_visibility', 'categories', 'tags', 'price', 'sku', 'stock', 'weight', 'brand', 'images', 'attributes', 'metafields', ); $excluded_fields = array(); $explicitly_selected = false; if ( isset( $assoc_args['fields'] ) ) { $explicitly_selected = true; $selected_fields = array_map( 'trim', explode( ',', $assoc_args['fields'] ) ); $selected_fields = array_filter( $selected_fields ); $invalid_fields = array_diff( $selected_fields, $default_fields ); if ( ! empty( $invalid_fields ) ) { WP_CLI::warning( sprintf( 'Invalid field names: %s. Valid fields: %s', implode( ', ', $invalid_fields ), implode( ', ', $default_fields ) ) ); } $fields = array_intersect( $selected_fields, $default_fields ); $excluded_fields = array_diff( $default_fields, $fields ); } else { $fields = $default_fields; } // Handle --exclude-fields argument. if ( isset( $assoc_args['exclude-fields'] ) ) { $exclude_fields_input = array_map( 'trim', explode( ',', $assoc_args['exclude-fields'] ) ); $excluded_fields = array_merge( $excluded_fields, $exclude_fields_input ); $fields = array_diff( $fields, $exclude_fields_input ); } if ( empty( $fields ) ) { WP_CLI::error( 'No valid fields selected for migration.' ); return array(); } // Log field selection information. if ( $explicitly_selected || isset( $assoc_args['exclude-fields'] ) || ! empty( $assoc_args['verbose'] ) ) { $include_message = sprintf( 'Including fields: %s', implode( ', ', $fields ) ); WP_CLI::log( $include_message ); wc_get_logger()->info( $include_message, array( 'source' => 'wc-migrator' ) ); if ( ! empty( $excluded_fields ) ) { $exclude_message = sprintf( 'Excluding fields: %s', implode( ', ', array_unique( $excluded_fields ) ) ); WP_CLI::log( $exclude_message ); wc_get_logger()->info( $exclude_message, array( 'source' => 'wc-migrator' ) ); } } return $fields; } /** * Parse query filters for platform-agnostic filtering. * * @param array $assoc_args Command arguments. * @return array Parsed query filters. */ private function parse_query_filters( array $assoc_args ): array { $filters = array(); if ( isset( $assoc_args['status'] ) ) { $valid_statuses = array( 'active', 'archived', 'draft' ); $status = strtolower( $assoc_args['status'] ); if ( in_array( $status, $valid_statuses, true ) ) { $filters['status'] = $status; } else { WP_CLI::warning( sprintf( 'Invalid status "%s". Valid options: %s', $status, implode( ', ', $valid_statuses ) ) ); } } if ( isset( $assoc_args['created-after'] ) ) { $date = $this->validate_date_filter( $assoc_args['created-after'], 'created-after' ); if ( $date ) { $filters['created_after'] = $date; } } if ( isset( $assoc_args['created-before'] ) ) { $date = $this->validate_date_filter( $assoc_args['created-before'], 'created-before' ); if ( $date ) { $filters['created_before'] = $date; } } if ( isset( $assoc_args['product-type'] ) && 'all' !== $assoc_args['product-type'] ) { $filters['product_type'] = $assoc_args['product-type']; } if ( isset( $assoc_args['handle'] ) ) { $filters['handle'] = sanitize_title( $assoc_args['handle'] ); } if ( isset( $assoc_args['vendor'] ) ) { $filters['vendor'] = $assoc_args['vendor']; } if ( isset( $assoc_args['ids'] ) ) { $filters['ids'] = $assoc_args['ids']; } return $filters; } /** * Validate date filter input. * * @param string $date_input The date input string. * @param string $filter_name The filter name for error messages. * @return string|null Formatted date string or null on error. */ private function validate_date_filter( string $date_input, string $filter_name ): ?string { $timestamp = strtotime( $date_input ); if ( false === $timestamp ) { WP_CLI::warning( sprintf( 'Invalid date format for --%s: %s', $filter_name, $date_input ) ); return null; } return gmdate( 'Y-m-d\\TH:i:s\\Z', $timestamp ); } /** * Manage the session lifecycle - create new or resume existing. * * @param array $parsed_args Parsed command arguments. * @return ImportSession|null Import session instance or null on error. */ private function manage_session_lifecycle( array $parsed_args ): ?ImportSession { $active_session = ImportSession::get_active(); if ( $active_session && ! $active_session->is_finished() ) { return $this->handle_existing_session( $active_session, $parsed_args ); } return $this->create_new_session( $parsed_args ); } /** * Handle existing session with user prompt for resume decision. * * @param ImportSession $session The existing session. * @param array $parsed_args Parsed command arguments. * @return ImportSession|null Session to use or null on error. */ private function handle_existing_session( ImportSession $session, array $parsed_args ): ?ImportSession { // Display session information. $metadata = $session->get_metadata(); $total_imported = $session->count_all_imported_entities(); $total_entities = $session->count_all_total_entities(); $started_timestamp = $session->get_started_at(); $started_at = is_numeric( $started_timestamp ) ? get_date_from_gmt( gmdate( 'Y-m-d H:i:s', (int) $started_timestamp ) ) : $started_timestamp; WP_CLI::line( '' ); WP_CLI::line( WP_CLI::colorize( '%YExisting Migration Session Found:%n' ) ); WP_CLI::line( sprintf( ' Session ID: %d', $session->get_id() ) ); WP_CLI::line( sprintf( ' Platform: %s', $metadata['data_source'] ) ); WP_CLI::line( sprintf( ' Started: %s', $started_at ) ); WP_CLI::line( sprintf( ' Progress: %d / %d products imported', $total_imported, $total_entities ) ); if ( ( $parsed_args['verbose'] ?? false ) && $session->get_reentrancy_cursor() ) { 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; if ( ! $should_resume ) { WP_CLI::out( 'Do you want to resume this migration session? [y/n] ' ); $answer = $this->get_user_input(); if ( 'y' === $answer ) { $should_resume = true; } else { $should_resume = false; } } 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(); WP_CLI::line( 'Previous session archived. Starting a new import session.' ); $new_session = $this->create_new_session( $parsed_args ); if ( $new_session ) { WP_CLI::success( sprintf( 'Starting fresh migration from the beginning (Session %d)', $new_session->get_id() ) ); } return $new_session; } } /** * Create a new import session. * * @param array $parsed_args Parsed command arguments. * @return ImportSession|null New session instance or null on error. */ private function create_new_session( array $parsed_args ): ?ImportSession { try { $session = ImportSession::create( array( 'data_source' => $parsed_args['platform'], 'file_name' => sprintf( '%s Migration - %s', ucfirst( $parsed_args['platform'] ), current_time( 'mysql' ) ), ) ); $session->set_original_arguments( $parsed_args ); return $session; } catch ( Exception $e ) { WP_CLI::error( sprintf( 'Failed to create migration session: %s', $e->getMessage() ) ); return null; } } /** * Process a batch of items using the mapper and importer. * * @param array $batch_items Array of source platform items. * @param object $mapper Platform mapper instance. * @return int Number of successfully processed items. */ private function process_batch( array $batch_items, $mapper ): int { $processed_count = 0; $mapped_products = array(); $source_data_batch = array(); foreach ( $batch_items as $item ) { try { // Extract the actual product node from GraphQL response structure. // Handle both object and array GraphQL shapes. if ( is_object( $item ) && isset( $item->node ) ) { $product_data = $item->node; } elseif ( is_array( $item ) && isset( $item['node'] ) ) { $product_data = $item['node']; } else { $product_data = $item; } $mapped_product = $mapper->map_product_data( $product_data ); if ( ! empty( $mapped_product ) ) { $mapped_products[] = $mapped_product; $source_data_batch[] = is_object( $product_data ) ? (array) $product_data : $product_data; } } catch ( Exception $e ) { /** * Fires when an error occurs during migration. * * @since 10.3.0 * * @param string $error_type The type of error (fetch, mapping, import). * @param string $message The error message. * @param array $context Additional error context. */ do_action( 'wc_migrator_error_occurred', 'mapping', $e->getMessage(), array( 'product_data' => $product_data, 'platform' => $this->parsed_args['platform'], ) ); WP_CLI::warning( sprintf( 'Error mapping product: %s', $e->getMessage() ) ); continue; } } if ( ! empty( $mapped_products ) ) { if ( $this->parsed_args['dry_run'] ) { $batch_results = $this->simulate_import_batch( $mapped_products ); } else { $batch_results = $this->product_importer->import_batch( $mapped_products, $source_data_batch ); } /** * Fires when a batch has been processed during migration. * * @since 10.3.0 * * @param array $batch_results Results from the batch import. * @param array $source_data Source platform data for the batch. * @param array $mapped_products Mapped WooCommerce data for the batch. */ do_action( 'wc_migrator_batch_processed', $batch_results, $source_data_batch, $mapped_products ); $this->log_batch_results( $batch_results ); $processed_count = $batch_results['stats']['successful']; if ( $processed_count > 0 && ! $this->parsed_args['dry_run'] ) { $current_count = get_option( 'wc_migrator_products_count', 0 ); update_option( 'wc_migrator_products_count', $current_count + $processed_count ); } } return $processed_count; } /** * Simulate the import process for dry-run mode. * * @param array $mapped_products Array of mapped product data. * @return array Simulated batch results matching real import format. */ private function simulate_import_batch( array $mapped_products ): array { $results = array(); $stats = array( 'successful' => 0, 'failed' => 0, 'skipped' => 0, ); foreach ( $mapped_products as $product_data ) { $product_name = $product_data['name'] ?? 'Unknown Product'; if ( empty( $product_data['name'] ) ) { $results[] = array( 'status' => 'error', 'message' => 'Product name is required', 'data' => $product_data, ); ++$stats['failed']; $this->simulate_stats_increment( 'errors_encountered' ); continue; } $existing_product_id = null; if ( ! empty( $product_data['sku'] ) ) { $existing_product_id = wc_get_product_id_by_sku( $product_data['sku'] ); } $would_skip = false; if ( $existing_product_id && $this->parsed_args['skip_existing'] ) { $would_skip = true; } if ( $would_skip ) { $results[] = array( 'status' => 'skipped', 'message' => "Product '{$product_name}' would be skipped (already exists)", 'data' => $product_data, ); ++$stats['skipped']; $this->simulate_stats_increment( 'products_skipped' ); } else { $results[] = array( 'status' => 'success', 'message' => "Product '{$product_name}' would be imported", 'data' => $product_data, ); ++$stats['successful']; if ( $existing_product_id ) { $this->simulate_stats_increment( 'products_updated' ); } else { $this->simulate_stats_increment( 'products_created' ); } if ( in_array( 'images', $this->fields_to_process, true ) && ! empty( $product_data['images'] ) ) { $image_count = is_array( $product_data['images'] ) ? count( $product_data['images'] ) : 1; for ( $i = 0; $i < $image_count; $i++ ) { $this->simulate_stats_increment( 'images_processed' ); } } } wc_get_logger()->info( "DRY RUN: Would import product '{$product_name}'", array( 'source' => 'wc-migrator' ) ); } return array( 'results' => $results, 'stats' => $stats, ); } /** * Simulate incrementing stats by using reflection to access private properties. * This ensures dry-run stats match what the real import would show. * * @param string $stat_key The stat key to increment. */ private function simulate_stats_increment( string $stat_key ): void { try { $reflection = new \ReflectionClass( $this->product_importer ); $stats_property = $reflection->getProperty( 'import_stats' ); $stats_property->setAccessible( true ); $current_stats = $stats_property->getValue( $this->product_importer ); if ( isset( $current_stats[ $stat_key ] ) ) { ++$current_stats[ $stat_key ]; $stats_property->setValue( $this->product_importer, $current_stats ); } } catch ( \ReflectionException $e ) { wc_get_logger()->warning( "DRY RUN: Could not update import stats for '{$stat_key}': " . $e->getMessage(), array( 'source' => 'wc-migrator' ) ); } } /** * Configure the injected product importer with options based on parsed arguments. */ private function configure_product_importer(): void { $import_options = array( 'skip_existing' => $this->parsed_args['skip_existing'] ?? false, 'update_existing' => ! ( $this->parsed_args['skip_existing'] ?? false ), 'import_images' => in_array( 'images', $this->fields_to_process, true ), 'skip_duplicate_images' => true, 'create_categories' => in_array( 'categories', $this->fields_to_process, true ), 'create_tags' => in_array( 'tags', $this->fields_to_process, true ), 'handle_variations' => in_array( 'attributes', $this->fields_to_process, true ), 'assign_default_category' => $this->parsed_args['assign_default_category'] ?? false, 'verbose' => $this->parsed_args['verbose'] ?? false, ); $this->product_importer->configure( $import_options ); if ( $this->parsed_args['verbose'] ?? false ) { $this->product_importer->set_progress_callback( array( $this, 'display_product_progress' ) ); } } /** * Display progress indicator for individual product imports. * * @param int $current_index Current product index (1-based). * @param int $total_count Total number of products in batch. * @param string $product_name Name of the product being processed. * @param array|null $result Import result (null when starting, array when finished). */ public function display_product_progress( int $current_index, int $total_count, string $product_name, ?array $result ): void { if ( null === $result ) { return; } $display_name = strlen( $product_name ) > 40 ? substr( $product_name, 0, 37 ) . '...' : $product_name; $status_char = '✓'; $status_color = '%G'; if ( 'error' === $result['status'] ) { $status_char = '✗'; $status_color = '%R'; } elseif ( 'success' === $result['status'] && 'skipped' === $result['action'] ) { $status_char = '−'; $status_color = '%Y'; } $progress = sprintf( '[%d/%d]', $current_index, $total_count ); if ( 1 === $current_index ) { WP_CLI::line( '' ); } WP_CLI::line( WP_CLI::colorize( sprintf( '%s%s%s %s %s', $status_color, $status_char, '%n', $progress, $display_name ) ) ); } /** * Log batch import results. * * @param array $batch_results Results from batch import. */ private function log_batch_results( array $batch_results ): void { $stats = $batch_results['stats']; // Only log failures and errors when verbose flag is set. if ( $this->parsed_args['verbose'] && $stats['failed'] > 0 ) { WP_CLI::warning( sprintf( '%d products failed to import', $stats['failed'] ) ); // Log first few errors for debugging. $error_count = 0; foreach ( $batch_results['results'] as $result ) { if ( 'error' === $result['status'] && $error_count < 3 ) { WP_CLI::warning( sprintf( 'Import error: %s', $result['message'] ) ); ++$error_count; } } } // Only log skipped products if there are many and verbose is enabled. if ( $this->parsed_args['verbose'] && $stats['skipped'] > 5 ) { WP_CLI::log( sprintf( 'Skipped %d existing products', $stats['skipped'] ) ); } } /** * Display final migration summary statistics. */ private function display_migration_summary(): void { if ( null === $this->product_importer ) { return; } $stats = $this->product_importer->get_import_stats(); WP_CLI::line( '' ); if ( $this->parsed_args['dry_run'] ) { WP_CLI::line( WP_CLI::colorize( '%YDry-Run Summary:%n' ) ); WP_CLI::line( sprintf( ' Products Would Be Created: %d', $stats['products_created'] ) ); WP_CLI::line( sprintf( ' Products Would Be Updated: %d', $stats['products_updated'] ) ); WP_CLI::line( sprintf( ' Products Would Be Skipped: %d', $stats['products_skipped'] ) ); WP_CLI::line( sprintf( ' Images Would Be Processed: %d', $stats['images_processed'] ) ); } else { WP_CLI::line( WP_CLI::colorize( '%YMigration Summary:%n' ) ); WP_CLI::line( sprintf( ' Products Created: %d', $stats['products_created'] ) ); WP_CLI::line( sprintf( ' Products Updated: %d', $stats['products_updated'] ) ); WP_CLI::line( sprintf( ' Products Skipped: %d', $stats['products_skipped'] ) ); WP_CLI::line( sprintf( ' Images Processed: %d', $stats['images_processed'] ) ); } if ( $stats['errors_encountered'] > 0 ) { if ( $this->parsed_args['dry_run'] ) { WP_CLI::line( WP_CLI::colorize( sprintf( ' %%RValidation Errors Found: %d%%n', $stats['errors_encountered'] ) ) ); } else { WP_CLI::line( WP_CLI::colorize( sprintf( ' %%RErrors Encountered: %d%%n', $stats['errors_encountered'] ) ) ); } } WP_CLI::line( '' ); } /** * Log session time metrics using session-specific data. * * @param array $final_stats Final migration statistics. */ private function log_session_time_metrics( array $final_stats ): void { $session_products = $final_stats['total_imported'] ?? 0; if ( empty( $session_products ) ) { return; } if ( empty( $this->session_start_time ) ) { return; } $session_duration_seconds = time() - $this->session_start_time; $platform = $this->parsed_args['platform']; $avg_time_per_product = $session_duration_seconds / $session_products; $session_time_formatted = human_time_diff( 0, $session_duration_seconds ); $avg_time_formatted = number_format( $avg_time_per_product, 2 ); $platform_display_name = $this->platform_registry->get_platform_display_name( $platform ); $metrics_message = sprintf( 'Session completed for %s: %d products in %s (avg: %s seconds per product)', $platform_display_name, $session_products, $session_time_formatted, $avg_time_formatted ); wc_get_logger()->info( $metrics_message, array( 'source' => 'wc-migrator' ) ); } /** * Display feedback survey link to collect user feedback. */ private function display_feedback_survey(): void { WP_CLI::line( '' ); WP_CLI::line( WP_CLI::colorize( '%GHelp us improve the WooCommerce Migrator!%n' ) ); WP_CLI::line( 'Please share your feedback about this migration experience:' ); WP_CLI::line( WP_CLI::colorize( '%Chttps://developer.woocommerce.com/migrator-feedback/%n' ) ); WP_CLI::line( '' ); } /** * Get user input from STDIN. Separate method for easier testing. * * @return string User input, trimmed and lowercased. */ 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']; } } } Migrator/Core/PlatformRegistry.php 0000777 00000023270 15252251042 0013257 0 ustar 00 <?php declare( strict_types=1 ); namespace Automattic\WooCommerce\Internal\CLI\Migrator\Core; use InvalidArgumentException; use Automattic\WooCommerce\Internal\CLI\Migrator\Interfaces\PlatformFetcherInterface; use Automattic\WooCommerce\Internal\CLI\Migrator\Interfaces\PlatformMapperInterface; use WP_CLI; /** * PlatformRegistry class. * * This class is responsible for loading and providing access to registered migration platforms. */ class PlatformRegistry { /** * An array to hold the configuration for all registered platforms. * * @var array */ private array $platforms = array(); /** * The credential manager instance. * * @var CredentialManager */ private CredentialManager $credential_manager; /** * Constructor. */ public function __construct() { $this->load_platforms(); } /** * Initialize the registry with dependencies. * * @internal * @param CredentialManager $credential_manager The credential manager. */ final public function init( CredentialManager $credential_manager ): void { $this->credential_manager = $credential_manager; } /** * Loads platforms discovered via a filter. * * It also validates that each registered platform provides both a fetcher and a mapper class. */ private function load_platforms(): void { /** * Filters the list of registered migration platforms. * * External platform plugins should hook into this filter to register themselves. * Each platform plugin is responsible for its own autoloading and initialization. * * @param array $platforms An associative array of platform configurations. * Each key is a unique platform ID (e.g., 'shopify'), and the value * is another array containing 'name', 'fetcher', and 'mapper' class names. * @since 1.0.0 */ $platforms = apply_filters( 'woocommerce_migrator_platforms', array() ); if ( ! is_array( $platforms ) ) { return; } foreach ( $platforms as $platform_id => $config ) { // Validate that required keys exist and have valid values. if ( isset( $config['fetcher'], $config['mapper'] ) && is_string( $config['fetcher'] ) && ! empty( $config['fetcher'] ) && is_string( $config['mapper'] ) && ! empty( $config['mapper'] ) ) { $this->platforms[ $platform_id ] = $config; } } } /** * Returns the entire array of registered platform configurations. * * @return array */ public function get_platforms(): array { return $this->platforms; } /** * Returns the configuration array for a single, specified platform ID. * * @param string $platform_id The ID of the platform (e.g., 'shopify'). * * @return array|null The platform configuration or null if not found. */ public function get_platform( string $platform_id ): ?array { return $this->platforms[ $platform_id ] ?? null; } /** * Retrieves and instantiates the fetcher class for a given platform. * * @param string $platform_id The ID of the platform. * * @return PlatformFetcherInterface An instance of the platform's fetcher class. * * @throws InvalidArgumentException If the platform is not found, fetcher class is invalid, or credentials are not configured. */ public function get_fetcher( string $platform_id ): PlatformFetcherInterface { $platform = $this->get_platform( $platform_id ); if ( ! $platform ) { throw new InvalidArgumentException( sprintf( /* translators: %s: Platform ID */ esc_html__( 'Platform %s not found.', 'woocommerce' ), esc_html( $platform_id ) ) ); } $fetcher_class = $platform['fetcher']; // Validate that fetcher class is a non-empty string. if ( ! is_string( $fetcher_class ) || empty( $fetcher_class ) ) { throw new InvalidArgumentException( sprintf( /* translators: %s: Platform ID */ esc_html__( 'Invalid fetcher class for platform %s. Fetcher must be a non-empty string.', 'woocommerce' ), esc_html( $platform_id ) ) ); } if ( ! class_exists( $fetcher_class ) ) { throw new InvalidArgumentException( sprintf( /* translators: %1$s: Platform ID, %2$s: Class name */ esc_html__( 'Invalid fetcher class for platform %1$s. Class %2$s does not exist.', 'woocommerce' ), esc_html( $platform_id ), esc_html( $fetcher_class ) ) ); } if ( ! in_array( PlatformFetcherInterface::class, class_implements( $fetcher_class ), true ) ) { throw new InvalidArgumentException( sprintf( /* translators: %1$s: Platform ID, %2$s: Class name, %3$s: Interface name */ esc_html__( 'Invalid fetcher class for platform %1$s. Class %2$s does not implement %3$s.', 'woocommerce' ), esc_html( $platform_id ), esc_html( $fetcher_class ), esc_html( PlatformFetcherInterface::class ) ) ); } // Get credentials from credential manager and pass to fetcher constructor. $credentials = $this->credential_manager->get_credentials( $platform_id ); if ( ! is_array( $credentials ) ) { throw new InvalidArgumentException( sprintf( /* translators: %s: platform ID */ 'No credentials found for platform "%s". Please configure credentials using: wp wc migrate setup', esc_html( $platform_id ) ) ); } return new $fetcher_class( $credentials ); } /** * Retrieves and instantiates the mapper class for a given platform. * * @param string $platform_id The ID of the platform. * @param array $args Optional arguments to pass to the mapper constructor. * * @return PlatformMapperInterface An instance of the platform's mapper class. * * @throws InvalidArgumentException If the platform is not found or the mapper class is invalid. */ public function get_mapper( string $platform_id, array $args = array() ): PlatformMapperInterface { $platform = $this->get_platform( $platform_id ); if ( ! $platform ) { throw new InvalidArgumentException( sprintf( /* translators: %s: Platform ID */ esc_html__( 'Platform %s not found.', 'woocommerce' ), esc_html( $platform_id ) ) ); } $mapper_class = $platform['mapper']; // Validate that mapper class is a non-empty string. if ( ! is_string( $mapper_class ) || empty( $mapper_class ) ) { throw new InvalidArgumentException( sprintf( /* translators: %s: Platform ID */ esc_html__( 'Invalid mapper class for platform %s. Mapper must be a non-empty string.', 'woocommerce' ), esc_html( $platform_id ) ) ); } if ( ! class_exists( $mapper_class ) ) { throw new InvalidArgumentException( sprintf( /* translators: %1$s: Platform ID, %2$s: Class name */ esc_html__( 'Invalid mapper class for platform %1$s. Class %2$s does not exist.', 'woocommerce' ), esc_html( $platform_id ), esc_html( $mapper_class ) ) ); } if ( ! in_array( PlatformMapperInterface::class, class_implements( $mapper_class ), true ) ) { throw new InvalidArgumentException( sprintf( /* translators: %1$s: Platform ID, %2$s: Class name, %3$s: Interface name */ esc_html__( 'Invalid mapper class for platform %1$s. Class %2$s does not implement %3$s.', 'woocommerce' ), esc_html( $platform_id ), esc_html( $mapper_class ), esc_html( PlatformMapperInterface::class ) ) ); } // If arguments are provided, instantiate manually to pass constructor args. // Otherwise, use the WooCommerce DI container for dependency injection. if ( ! empty( $args ) ) { return new $mapper_class( $args ); } else { $container = wc_get_container(); return $container->get( $mapper_class ); } } /** * Determines the platform to use from command arguments, with validation and fallback. * * @param array $assoc_args Associative arguments from the command. * @param string $default_platform The default platform to use if none specified. * * @return string The validated platform slug. */ public function resolve_platform( array $assoc_args, string $default_platform = 'shopify' ): string { $platform = $assoc_args['platform'] ?? null; if ( empty( $platform ) ) { $platform = $default_platform; $platform_display_name = $this->get_platform_display_name( $platform ); WP_CLI::log( "Platform not specified, using default: '{$platform_display_name}'." ); } // Validate the platform exists. if ( ! $this->get_platform( $platform ) ) { $available_platforms = array_keys( $this->get_platforms() ); if ( empty( $available_platforms ) ) { WP_CLI::error( 'No platforms are currently registered. Please ensure platform plugins are installed and activated.' ); } else { WP_CLI::error( sprintf( "Platform '%s' is not registered. Available platforms: %s", $platform, implode( ', ', $available_platforms ) ) ); } } return $platform; } /** * Get platform-specific credential fields for setup prompts. * * @param string $platform_slug The platform identifier. * * @return array Array of field_name => prompt_text pairs. */ public function get_platform_credential_fields( string $platform_slug ): array { $platform = $this->get_platform( $platform_slug ); if ( ! is_array( $platform ) ) { return array(); } $credentials = $platform['credentials'] ?? array(); return is_array( $credentials ) ? $credentials : array(); } /** * Gets the display name for a platform. * * @param string $platform_slug The platform identifier (e.g., 'shopify'). * * @return string The proper display name (e.g., 'Shopify'). */ public function get_platform_display_name( string $platform_slug ): string { $platform = $this->get_platform( $platform_slug ); if ( is_array( $platform ) && isset( $platform['name'] ) ) { return $platform['name']; } // Fallback to ucfirst if platform not found or no name configured. return ucfirst( $platform_slug ); } } Migrator/Core/CredentialManager.php 0000777 00000006451 15252251042 0013311 0 ustar 00 <?php declare( strict_types=1 ); namespace Automattic\WooCommerce\Internal\CLI\Migrator\Core; use WP_CLI; /** * Manages platform credentials. */ class CredentialManager { /** * Retrieves the stored credentials for a given platform. * * @param string $platform_slug The slug for the platform. * * @return array|null An associative array of credentials, or null if not found. */ public function get_credentials( string $platform_slug ): ?array { $option_name = "wc_migrator_credentials_{$platform_slug}"; $credentials_json = get_option( $option_name, false ); if ( ! $credentials_json ) { return null; } $credentials = json_decode( $credentials_json, true ); return is_array( $credentials ) ? $credentials : null; } /** * Checks if credentials exist for a given platform. * * @param string $platform_slug The slug for the platform. * * @return bool True if credentials exist, false otherwise. */ public function has_credentials( string $platform_slug ): bool { $credentials = $this->get_credentials( $platform_slug ); return ! empty( $credentials ); } /** * Prompts the user for credentials via the command line. * * @param array $fields An associative array of fields to prompt for. * * @return array The collected credentials. */ public function prompt_for_credentials( array $fields ): array { $credentials = array(); foreach ( $fields as $key => $prompt ) { $credentials[ $key ] = $this->readline( $prompt . ' ' ); } return $credentials; } /** * Saves credentials to the database for a given platform. * * @param string $platform_slug The slug for the platform. * @param array $credentials An associative array of credentials. */ public function save_credentials( string $platform_slug, array $credentials ): void { $option_name = "wc_migrator_credentials_{$platform_slug}"; update_option( $option_name, wp_json_encode( $credentials ) ); } /** * Deletes credentials from the database for a given platform. * * @param string $platform_slug The slug for the platform. */ public function delete_credentials( string $platform_slug ): void { $option_name = "wc_migrator_credentials_{$platform_slug}"; delete_option( $option_name ); } /** * Handles the interactive credential setup process for a platform. * * @param string $platform_slug The platform slug to set up credentials for. * @param array $required_fields An array of field_key => prompt_text for credentials to collect. * * @return void */ public function setup_credentials( string $platform_slug, array $required_fields ): void { if ( empty( $required_fields ) ) { WP_CLI::error( 'No credential fields specified for setup.' ); return; } WP_CLI::log( 'Configuring credentials for ' . ucfirst( $platform_slug ) . '...' ); $credentials = $this->prompt_for_credentials( $required_fields ); $this->save_credentials( $platform_slug, $credentials ); } /** * Reads a line from STDIN. * * A backward-compatible wrapper for WP_CLI::readline(). * * @param string $prompt The prompt to show to the user. * * @return string */ private function readline( string $prompt ): string { if ( method_exists( 'WP_CLI', 'readline' ) ) { return WP_CLI::readline( $prompt ); } WP_CLI::line( $prompt ); return trim( fgets( STDIN ) ); } } Migrator/Core/WooCommerceProductImporter.php 0000777 00000124066 15252251042 0015251 0 ustar 00 <?php /** * WooCommerce Product Importer * * @package Automattic\WooCommerce\Internal\CLI\Migrator\Core */ declare( strict_types=1 ); namespace Automattic\WooCommerce\Internal\CLI\Migrator\Core; use WC_Product; use WC_Product_Simple; use WC_Product_Variable; use WC_Product_Variation; use WP_Error; use Exception; use Automattic\WooCommerce\Utilities\FeaturesUtil; defined( 'ABSPATH' ) || exit; /** * WooCommerceProductImporter class. * * Handles the creation and updating of WooCommerce products from mapped data. * This class focuses on the actual product creation logic, following WordPress * coding standards and our established architecture patterns. * * @internal This class is part of the CLI Migrator feature and should not be used directly. */ class WooCommerceProductImporter { /** * Default timeout for image downloads in seconds. * * @var int */ private const DEFAULT_IMAGE_TIMEOUT = 10; /** * Maximum number of images to process per product. * * @var int */ private const MAX_IMAGES_PER_PRODUCT = 50; /** * Import options and configuration. * * @var array */ private array $import_options; /** * Progress callback function for per-product updates. * * @var callable|null */ private $progress_callback = null; /** * Statistics tracking for import operations. * * @var array */ private array $import_stats = array( 'products_created' => 0, 'products_updated' => 0, 'products_skipped' => 0, 'images_processed' => 0, 'errors_encountered' => 0, ); /** * Migration data including image and variation mappings for session persistence. * * @var array */ private array $migration_data = array( 'images_mapping' => array(), 'variations_mapping' => array(), ); /** * Mapping of original attribute names to taxonomy names for current product. * * @var array */ private array $current_attribute_mapping = array(); /** * Constructor - parameterless to support WooCommerce DI container. */ public function __construct() { $this->import_options = $this->get_default_options(); } /** * Configure the importer with options. * * @param array $options Import options and configuration. */ public function configure( array $options ): void { $this->import_options = array_merge( $this->import_options, $options ); } /** * Set progress callback for per-product import updates. * * @param callable|null $callback Function to call with progress updates. * Receives: (current_index, total_count, product_name, result). */ public function set_progress_callback( ?callable $callback ): void { $this->progress_callback = $callback; } /** * Import a single product from mapped data. * * @param array $product_data Mapped WooCommerce product data. * @param array $source_data Original source platform data for reference. * @return array Import result with status and details. */ public function import_product( array $product_data, array $source_data = array() ): array { $start_time = microtime( true ); $product_name = $product_data['name'] ?? 'Unknown Product'; $this->current_attribute_mapping = array(); try { wc_get_logger()->info( "Starting import for product: {$product_name}", array( 'source' => 'wc-migrator' ) ); $validation_result = $this->validate_product_data( $product_data ); if ( ! $validation_result['valid'] ) { wc_get_logger()->error( "Validation failed for product: {$product_name} - " . $validation_result['message'], array( 'source' => 'wc-migrator' ) ); return $this->create_error_result( 'validation_failed', $validation_result['message'], $product_data ); } $existing_product_id = $this->find_existing_product( $product_data, $source_data ); if ( $existing_product_id && $this->import_options['skip_existing'] ) { ++$this->import_stats['products_skipped']; return $this->create_success_result( 'skipped', $existing_product_id, 'Product already exists and skip_existing is enabled' ); } $product_type = $this->determine_product_type( $product_data ); $product = $this->get_or_create_product_object( $existing_product_id, $product_type ); if ( ! $product ) { return $this->create_error_result( 'product_creation_failed', 'Failed to create product object', $product_data ); } if ( $existing_product_id ) { $existing_migration_data = $product->get_meta( '_migration_data' ); if ( is_array( $existing_migration_data ) ) { $this->migration_data['images_mapping'] = $existing_migration_data['images_mapping'] ?? array(); $this->migration_data['variations_mapping'] = $existing_migration_data['variations_mapping'] ?? array(); } } $this->set_basic_product_properties( $product, $product_data ); $this->set_product_taxonomies( $product, $product_data ); $this->handle_product_images( $product, $product_data['images'] ?? array() ); wc_get_logger()->debug( "Processing {$product_type} product: {$product_name}", array( 'source' => 'wc-migrator' ) ); switch ( $product_type ) { case 'variable': $this->handle_variable_product( $product, $product_data ); break; case 'simple': default: $this->handle_simple_product( $product, $product_data ); break; } $product_id = $product->save(); if ( ! $product_id ) { return $this->create_error_result( 'save_failed', 'Failed to save product to database', $product_data ); } $this->handle_post_save_operations( $product_id, $product_data, $source_data ); if ( $existing_product_id ) { ++$this->import_stats['products_updated']; } else { ++$this->import_stats['products_created']; } $duration = microtime( true ) - $start_time; $action = $existing_product_id ? 'updated' : 'created'; wc_get_logger()->info( "Successfully {$action} product: {$product_name} (ID: {$product_id}) in {$duration}s", array( 'source' => 'wc-migrator' ) ); return $this->create_success_result( $action, $product_id, "Product {$action} successfully in {$duration}s" ); } catch ( Exception $e ) { ++$this->import_stats['errors_encountered']; $duration = microtime( true ) - $start_time; wc_get_logger()->error( "Exception importing product: {$product_name} after {$duration}s - " . $e->getMessage(), array( 'source' => 'wc-migrator', 'exception' => $e, ) ); return $this->create_error_result( 'exception', $e->getMessage(), $product_data ); } } /** * Import a batch of products. * * @param array $products_data Array of mapped product data. * @param array $source_data_batch Array of original source data for reference. * @return array Batch import results. */ public function import_batch( array $products_data, array $source_data_batch = array() ): array { $results = array(); $batch_stats = array( 'successful' => 0, 'failed' => 0, 'skipped' => 0, ); $total_count = count( $products_data ); foreach ( $products_data as $index => $product_data ) { $source_data = $source_data_batch[ $index ] ?? array(); $product_name = $product_data['name'] ?? 'Unknown Product'; $result = $this->import_product( $product_data, $source_data ); $results[] = $result; if ( 'success' === $result['status'] ) { if ( 'skipped' === $result['action'] ) { ++$batch_stats['skipped']; } else { ++$batch_stats['successful']; } } else { ++$batch_stats['failed']; } if ( $this->progress_callback ) { call_user_func( $this->progress_callback, $index + 1, $total_count, $product_name, $result ); } } return array( 'results' => $results, 'stats' => $batch_stats, ); } /** * Get current import statistics. * * @return array Import statistics. */ public function get_import_stats(): array { return $this->import_stats; } /** * Reset import statistics. */ public function reset_stats(): void { $this->import_stats = array( 'products_created' => 0, 'products_updated' => 0, 'products_skipped' => 0, 'images_processed' => 0, 'errors_encountered' => 0, ); } /** * Get default import options. * * @return array Default options. */ private function get_default_options(): array { return array( 'skip_existing' => false, 'update_existing' => true, 'import_images' => true, 'image_timeout' => self::DEFAULT_IMAGE_TIMEOUT, 'max_images_per_product' => self::MAX_IMAGES_PER_PRODUCT, 'skip_duplicate_images' => false, 'create_categories' => true, 'create_tags' => true, 'handle_variations' => true, 'assign_default_category' => false, 'dry_run' => false, ); } /** * Validate product data before import. * * @param array $product_data Product data to validate. * @return array Validation result. */ private function validate_product_data( array $product_data ): array { $required_fields = array( 'name' ); $missing_fields = array(); foreach ( $required_fields as $field ) { if ( empty( $product_data[ $field ] ) ) { $missing_fields[] = $field; } } if ( ! empty( $missing_fields ) ) { return array( 'valid' => false, 'message' => 'Missing required fields: ' . implode( ', ', $missing_fields ), ); } return array( 'valid' => true ); } /** * Find existing product by various identifiers. * * @param array $product_data Mapped product data. * @return int|null Existing product ID or null if not found. */ private function find_existing_product( array $product_data ): ?int { if ( ! empty( $product_data['original_product_id'] ) ) { $existing_posts = get_posts( array( 'post_type' => 'product', 'post_status' => 'any', // Find regardless of status. 'meta_key' => '_original_product_id', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key 'meta_value' => $product_data['original_product_id'], // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value 'fields' => 'ids', 'numberposts' => 1, ) ); if ( ! empty( $existing_posts ) ) { return (int) $existing_posts[0]; } } if ( ! empty( $product_data['sku'] ) ) { $product_id = wc_get_product_id_by_sku( $product_data['sku'] ); if ( $product_id ) { return $product_id; } } if ( ! empty( $product_data['slug'] ) ) { $post = get_page_by_path( $product_data['slug'], OBJECT, 'product' ); if ( $post ) { return $post->ID; } } return null; } /** * Determine product type from product data. * * @param array $product_data Product data. * @return string Product type. */ private function determine_product_type( array $product_data ): string { if ( isset( $product_data['is_variable'] ) ) { return $product_data['is_variable'] ? 'variable' : 'simple'; } if ( ! empty( $product_data['variations'] ) && count( $product_data['variations'] ) >= 1 ) { return 'variable'; } if ( ! empty( $product_data['attributes'] ) ) { foreach ( $product_data['attributes'] as $attribute ) { if ( ! empty( $attribute['is_variation'] ) || ! empty( $attribute['variation'] ) ) { return 'variable'; } } } return 'simple'; } /** * Get or create product object with proper type conversion handling. * * @param int|null $existing_product_id Existing product ID if updating. * @param string $required_type Required product type. * @return WC_Product|null Product object or null on failure. */ private function get_or_create_product_object( ?int $existing_product_id, string $required_type ): ?WC_Product { if ( ! $existing_product_id ) { return $this->create_product_object( $required_type ); } $existing_product = wc_get_product( $existing_product_id ); if ( ! $existing_product ) { return $this->create_product_object( $required_type ); } $current_type = $existing_product->get_type(); if ( $current_type === $required_type ) { return $existing_product; } wc_get_logger()->info( "Converting product ID {$existing_product_id} from {$current_type} to {$required_type}", array( 'source' => 'wc-migrator' ) ); switch ( $required_type ) { case 'variable': return new WC_Product_Variable( $existing_product_id ); case 'simple': default: return new WC_Product_Simple( $existing_product_id ); } } /** * Create appropriate product object based on type. * * @param string $product_type Product type. * @return WC_Product|null Product object or null on failure. */ private function create_product_object( string $product_type ): ?WC_Product { switch ( $product_type ) { case 'variable': return new WC_Product_Variable(); case 'simple': default: return new WC_Product_Simple(); } } /** * Set basic product properties common to all product types. * * @param WC_Product $product Product object. * @param array $product_data Product data. */ private function set_basic_product_properties( WC_Product $product, array $product_data ): void { $product->set_name( $product_data['name'] ); if ( ! empty( $product_data['slug'] ) ) { $product->set_slug( $product_data['slug'] ); } if ( ! empty( $product_data['description'] ) ) { $product->set_description( $product_data['description'] ); } if ( ! empty( $product_data['short_description'] ) ) { $product->set_short_description( $product_data['short_description'] ); } if ( ! empty( $product_data['status'] ) ) { $product->set_status( $product_data['status'] ); } if ( ! empty( $product_data['sku'] ) ) { $product->set_sku( $product_data['sku'] ); } if ( isset( $product_data['catalog_visibility'] ) ) { $product->set_catalog_visibility( $product_data['catalog_visibility'] ); } if ( ! empty( $product_data['date_created_gmt'] ) ) { $product->set_date_created( $product_data['date_created_gmt'] ); } if ( ! empty( $product_data['weight'] ) ) { $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 ) ) { $product->add_meta_data( $key, $value, true ); } } } if ( ! empty( $product_data['meta_data'] ) ) { foreach ( $product_data['meta_data'] as $meta ) { if ( ! empty( $meta['key'] ) ) { $product->add_meta_data( $meta['key'], $meta['value'] ?? '', true ); } } } } /** * Handle simple product specific data. * * @param WC_Product_Simple $product Simple product object. * @param array $product_data Product data. */ private function handle_simple_product( WC_Product_Simple $product, array $product_data ): void { if ( ! empty( $product_data['regular_price'] ) ) { $product->set_regular_price( $product_data['regular_price'] ); $product->set_price( $product_data['regular_price'] ); } if ( ! empty( $product_data['sale_price'] ) ) { $product->set_sale_price( $product_data['sale_price'] ); $product->set_price( $product_data['sale_price'] ); } if ( ! empty( $product_data['sku'] ) ) { add_filter( 'wc_product_has_unique_sku', '__return_false', 999 ); $product->set_sku( $product_data['sku'] ); remove_filter( 'wc_product_has_unique_sku', '__return_false', 999 ); } if ( isset( $product_data['manage_stock'] ) ) { $product->set_manage_stock( $product_data['manage_stock'] ); } if ( ! empty( $product_data['stock_quantity'] ) ) { $product->set_stock_quantity( (int) $product_data['stock_quantity'] ); } if ( ! empty( $product_data['stock_status'] ) ) { $product->set_stock_status( $product_data['stock_status'] ); } if ( array_key_exists( 'cost_of_goods', $product_data ) ) { $cogs_is_enabled = FeaturesUtil::feature_is_enabled( 'cost_of_goods_sold' ); if ( $cogs_is_enabled ) { $product->set_cogs_value( (float) $product_data['cost_of_goods'] ); } else { $this->set_cogs_value_direct( $product, (float) $product_data['cost_of_goods'] ); } } } /** * Handle variable product specific data. * * @param WC_Product_Variable $product Variable product object. * @param array $product_data Product data. */ private function handle_variable_product( WC_Product_Variable $product, array $product_data ): void { $product->set_sku( '' ); $product->set_regular_price( '' ); $product->set_sale_price( '' ); $product->set_manage_stock( false ); $product->set_weight( '' ); $product->set_stock_quantity( null ); if ( ! empty( $product_data['attributes'] ) ) { $this->setup_attributes( $product, $product_data['attributes'] ); } $product_id = $product->save(); if ( ! empty( $product_data['variations'] ) && $this->import_options['handle_variations'] ) { $this->sync_variations( $product, $product_data['variations'] ); } } /** * Set product attributes. * * @param WC_Product $product Product object. * @param array $attributes Attributes data. */ private function set_product_attributes( WC_Product $product, array $attributes ): void { $product_attributes = array(); foreach ( $attributes as $attribute_data ) { if ( empty( $attribute_data['name'] ) ) { continue; } $attribute = new \WC_Product_Attribute(); $attribute->set_name( $attribute_data['name'] ); $attribute->set_options( $attribute_data['options'] ?? array() ); $attribute->set_variation( $attribute_data['is_variation'] ?? $attribute_data['variation'] ?? false ); $attribute->set_visible( $attribute_data['is_visible'] ?? $attribute_data['visible'] ?? true ); $product_attributes[] = $attribute; } $product->set_attributes( $product_attributes ); } /** * Sets up product attributes for variable products with global taxonomy creation. * * @param WC_Product_Variable $product The variable product object. * @param array $attributes_data Standardized attribute data from mapper. */ private function setup_attributes( WC_Product_Variable $product, array $attributes_data ): void { $woo_attributes = array(); $this->current_attribute_mapping = array(); foreach ( $attributes_data as $attribute_info ) { $attr_name = $attribute_info['name'] ?? null; $attr_options = $attribute_info['options'] ?? array(); if ( empty( $attr_name ) || empty( $attr_options ) ) { continue; } $taxonomy_slug = sanitize_title( $attr_name ); $taxonomy_name = 'pa_' . $taxonomy_slug; $attribute_id = 0; if ( ! taxonomy_exists( $taxonomy_name ) ) { $attribute_id = wc_create_attribute( array( 'name' => $attr_name, 'slug' => $taxonomy_slug, 'type' => 'select', 'order_by' => 'menu_order', 'has_archives' => false, ) ); if ( is_wp_error( $attribute_id ) ) { wc_get_logger()->warning( "Failed to create attribute '{$attr_name}': " . $attribute_id->get_error_message(), array( 'source' => 'wc-migrator' ) ); continue; } register_taxonomy( $taxonomy_name, /** * Filters the object types associated with the attribute taxonomy. * * @since 10.2.0 * @param array $object_types Array of object types. */ apply_filters( 'woocommerce_taxonomy_objects_' . $taxonomy_name, array( 'product' ) ), /** * Filters the arguments for registering the attribute taxonomy. * * @since 10.2.0 * @param array $args Array of taxonomy registration arguments. */ apply_filters( 'woocommerce_taxonomy_args_' . $taxonomy_name, array( 'labels' => array( 'name' => $attr_name, ), 'hierarchical' => false, 'show_ui' => false, 'show_in_rest' => true, 'query_var' => true, 'rewrite' => false, 'public' => false, ) ) ); } else { $attribute_id = wc_attribute_taxonomy_id_by_name( $taxonomy_name ); } $term_ids = array(); $term_slugs = array(); foreach ( $attr_options as $value ) { $term_slug = sanitize_title( $value ); $term = get_term_by( 'slug', $term_slug, $taxonomy_name ); if ( ! $term ) { $term_result = wp_insert_term( $value, $taxonomy_name, array( 'slug' => $term_slug ) ); if ( is_wp_error( $term_result ) ) { wc_get_logger()->warning( "Failed to insert term '{$value}' (slug: {$term_slug}) into {$taxonomy_name}: " . $term_result->get_error_message(), array( 'source' => 'wc-migrator' ) ); continue; } $term_ids[] = $term_result['term_id']; $term_slugs[] = $term_slug; } else { $term_ids[] = $term->term_id; $term_slugs[] = $term->slug; } } $woo_attribute = new \WC_Product_Attribute(); $woo_attribute->set_name( $taxonomy_name ); $woo_attribute->set_id( $attribute_id ); $woo_attribute->set_options( $term_ids ); $woo_attribute->set_position( $attribute_info['position'] ?? 0 ); $woo_attribute->set_visible( $attribute_info['is_visible'] ?? true ); $woo_attribute->set_variation( $attribute_info['is_variation'] ?? true ); $woo_attributes[] = $woo_attribute; $this->current_attribute_mapping[ $attr_name ] = $taxonomy_name; } $product->set_attributes( $woo_attributes ); } /** * Creates or updates product variations with proper mapping and lookup. * * @param WC_Product_Variable $product The parent variable product. * @param array $variations_data Standardized variation data from mapper. */ private function sync_variations( WC_Product_Variable $product, array $variations_data ): void { $parent_product_id = $product->get_id(); $parent_original_id = $product->get_meta( '_original_product_id' ); $processed_variation_ids = array(); $variation_count = count( $variations_data ); wc_get_logger()->debug( "Syncing {$variation_count} variations for product ID {$parent_product_id}", array( 'source' => 'wc-migrator' ) ); $attribute_taxonomy_map = $this->current_attribute_mapping; // Build fallback mapping from product attributes if current mapping is empty. if ( empty( $attribute_taxonomy_map ) ) { $product_attributes = $product->get_attributes(); foreach ( $product_attributes as $taxonomy => $attribute_obj ) { if ( $attribute_obj->get_variation() ) { $attribute_label = wc_attribute_label( $taxonomy, $product ); // Store mapping with both original case and lowercase for case-insensitive lookup. $attribute_taxonomy_map[ $attribute_label ] = $taxonomy; $attribute_taxonomy_map[ strtolower( $attribute_label ) ] = $taxonomy; } } } foreach ( $variations_data as $var_data ) { $original_variant_id = $var_data['original_id'] ?? null; if ( ! $original_variant_id ) { wc_get_logger()->warning( 'Skipping variation: Missing original ID.', array( 'source' => 'wc-migrator' ) ); continue; } $variation_id = null; $variation = null; if ( isset( $this->migration_data['variations_mapping'][ $original_variant_id ] ) ) { $_variation_id = $this->migration_data['variations_mapping'][ $original_variant_id ]; $_variation = wc_get_product( $_variation_id ); if ( $_variation instanceof WC_Product_Variation && $_variation->get_parent_id() === $parent_product_id ) { $variation = $_variation; $variation_id = $_variation_id; } else { unset( $this->migration_data['variations_mapping'][ $original_variant_id ] ); } } if ( ! $variation ) { $query_args = array( 'post_parent' => $parent_product_id, 'post_type' => 'product_variation', 'numberposts' => 1, 'post_status' => 'any', 'meta_key' => '_original_variant_id', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key 'meta_value' => $original_variant_id, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value 'fields' => 'ids', ); $found_ids = get_posts( $query_args ); if ( ! empty( $found_ids ) ) { $variation_id = $found_ids[0]; $variation = wc_get_product( $variation_id ); if ( ! ( $variation instanceof WC_Product_Variation ) ) { wc_get_logger()->warning( "Found post ID {$variation_id} for original variant {$original_variant_id}, but it's not a WC_Product_Variation.", array( 'source' => 'wc-migrator' ) ); $variation = null; $variation_id = null; } } } if ( ! $variation ) { $variation = new WC_Product_Variation(); $variation->set_parent_id( $parent_product_id ); } $variation->set_status( 'publish' ); $variation->set_menu_order( $var_data['menu_order'] ?? 0 ); $variation->set_regular_price( $var_data['regular_price'] ?? '' ); $variation->set_sale_price( $var_data['sale_price'] ?? '' ); if ( ! empty( $var_data['sku'] ) ) { add_filter( 'wc_product_has_unique_sku', '__return_false', 999 ); $variation->set_sku( $var_data['sku'] ); remove_filter( 'wc_product_has_unique_sku', '__return_false', 999 ); } $variation->set_manage_stock( $var_data['manage_stock'] ?? false ); $variation->set_stock_quantity( $var_data['stock_quantity'] ?? null ); $variation->set_stock_status( $var_data['stock_status'] ?? 'instock' ); $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 ] ); } else { $variation->set_image_id( '' ); } $wc_variation_attributes = array(); if ( ! empty( $var_data['attributes'] ) && is_array( $var_data['attributes'] ) ) { foreach ( $var_data['attributes'] as $attr_name => $attr_value ) { if ( isset( $attribute_taxonomy_map[ $attr_name ] ) ) { $taxonomy = $attribute_taxonomy_map[ $attr_name ]; $term_slug = sanitize_title( $attr_value ); $normalized_attribute_name = wc_variation_attribute_name( $taxonomy ); $wc_variation_attributes[ $normalized_attribute_name ] = $term_slug; } else { wc_get_logger()->warning( "Attribute taxonomy mapping not found for option '{$attr_name}' while processing variation {$original_variant_id}.", array( 'source' => 'wc-migrator' ) ); } } } $variation->set_attributes( $wc_variation_attributes ); $variation->update_meta_data( '_original_variant_id', $original_variant_id ); if ( $parent_original_id ) { $variation->update_meta_data( '_original_product_id', $parent_original_id ); } $saved_variation_id = $variation->save(); if ( $saved_variation_id ) { $processed_variation_ids[] = $saved_variation_id; $this->migration_data['variations_mapping'][ $original_variant_id ] = $saved_variation_id; if ( ! empty( $var_data['cost_of_goods'] ) ) { update_post_meta( $saved_variation_id, '_cogs_total_value', (float) $var_data['cost_of_goods'] ); } } else { wc_get_logger()->error( "Failed to save variation for original variant {$original_variant_id}", array( 'source' => 'wc-migrator' ) ); } } WC_Product_Variable::sync( $parent_product_id ); $processed_count = count( $processed_variation_ids ); wc_get_logger()->debug( "Successfully synced {$processed_count}/{$variation_count} variations for product ID {$parent_product_id}", array( 'source' => 'wc-migrator' ) ); } /** * Create product variations (legacy method - keeping for backward compatibility). * * @param int $parent_id Parent product ID. * @param array $variations Variations data. */ private function create_product_variations( int $parent_id, array $variations ): void { $product = wc_get_product( $parent_id ); if ( $product instanceof WC_Product_Variable ) { $this->sync_variations( $product, $variations ); } } /** * Handle post-save operations like metadata and migration tracking. * * @param int $product_id Product ID. * @param array $product_data Product data. */ private function handle_post_save_operations( int $product_id, array $product_data ): void { if ( ! empty( $product_data['original_product_id'] ) ) { update_post_meta( $product_id, '_original_product_id', $product_data['original_product_id'] ); } if ( ! empty( $product_data['original_url'] ) ) { update_post_meta( $product_id, '_original_url', $product_data['original_url'] ); } update_post_meta( $product_id, '_migration_data', $this->migration_data ); if ( ! empty( $product_data['metafields'] ) ) { $this->update_seo_meta( $product_id, $product_data['metafields'], $product_data ); } } /** * Set product taxonomies (categories, tags, brand) before product save. * * @param WC_Product $product The product object. * @param array $product_data Standardized data containing taxonomies. */ private function set_product_taxonomies( WC_Product $product, array $product_data ): void { $product_id = $product->get_id(); if ( ! $product_id ) { $product_id = $product->save(); if ( ! $product_id ) { wc_get_logger()->warning( 'Could not save product to set taxonomies.', array( 'source' => 'wc-migrator' ) ); return; } } $taxonomies_to_set = array(); if ( isset( $product_data['categories'] ) && is_array( $product_data['categories'] ) && $this->import_options['create_categories'] ) { $term_ids = $this->get_or_create_terms( $product_data['categories'], 'product_cat' ); if ( ! empty( $term_ids ) ) { $taxonomies_to_set['product_cat'] = $term_ids; } elseif ( $this->import_options['assign_default_category'] ) { $default_cat_id = get_option( 'default_product_cat' ); if ( $default_cat_id ) { $taxonomies_to_set['product_cat'] = array( $default_cat_id ); wc_get_logger()->info( "Assigned default category (ID: {$default_cat_id}) to product with no categories", array( 'source' => 'wc-migrator' ) ); } } else { wc_get_logger()->debug( 'Product has no categories and assign_default_category is disabled', array( 'source' => 'wc-migrator' ) ); } } if ( isset( $product_data['tags'] ) && is_array( $product_data['tags'] ) && $this->import_options['create_tags'] ) { $term_ids = $this->get_or_create_terms( $product_data['tags'], 'product_tag' ); if ( ! empty( $term_ids ) ) { $taxonomies_to_set['product_tag'] = $term_ids; } } if ( ! empty( $product_data['brand']['name'] ) && taxonomy_exists( 'product_brand' ) ) { $brand_data = array( $product_data['brand'] ); $term_ids = $this->get_or_create_terms( $brand_data, 'product_brand' ); if ( ! empty( $term_ids ) ) { $taxonomies_to_set['product_brand'] = $term_ids; } } foreach ( $taxonomies_to_set as $taxonomy => $ids ) { wp_set_object_terms( $product_id, $ids, $taxonomy, false ); } } /** * Helper to get or create term IDs for a given taxonomy. * * @param array $terms_data Array of ['name' => ..., 'slug' => ...]. * @param string $taxonomy Taxonomy slug. * @return array Array of term IDs. */ private function get_or_create_terms( array $terms_data, string $taxonomy ): array { $term_ids = array(); foreach ( $terms_data as $term_info ) { $term_name = $term_info['name'] ?? null; $term_slug = $term_info['slug'] ?? sanitize_title( $term_name ); if ( empty( $term_name ) || empty( $term_slug ) ) { continue; } $term = get_term_by( 'slug', $term_slug, $taxonomy ); if ( ! $term ) { $term_result = wp_insert_term( $term_name, $taxonomy, array( 'slug' => $term_slug ) ); if ( is_wp_error( $term_result ) ) { wc_get_logger()->warning( "Failed to insert term '{$term_name}' (slug: {$term_slug}) into {$taxonomy}: " . $term_result->get_error_message(), array( 'source' => 'wc-migrator' ) ); continue; } $term_ids[] = $term_result['term_id']; } else { $term_ids[] = $term->term_id; } } return array_unique( $term_ids ); } /** * Handle product images using product object methods. * * @param WC_Product $product The product object. * @param array $images_data Standardized image data from mapper. */ private function handle_product_images( WC_Product $product, array $images_data ): void { if ( empty( $images_data ) ) { return; } $gallery_ids = array(); $featured_id = null; $product_id = $product->get_id(); $processed_count = 0; foreach ( $images_data as $index => $image ) { if ( $processed_count >= $this->import_options['max_images_per_product'] ) { break; } $original_id = $image['original_id'] ?? null; $image_url = $image['src'] ?? null; $image_alt = $image['alt'] ?? ''; $is_featured = $image['is_featured'] ?? ( 0 === $index ); if ( empty( $original_id ) || empty( $image_url ) ) { wc_get_logger()->warning( 'Skipping image: Missing original ID or URL.', array( 'source' => 'wc-migrator' ) ); continue; } if ( isset( $this->migration_data['images_mapping'][ $original_id ] ) && wp_attachment_is_image( $this->migration_data['images_mapping'][ $original_id ] ) ) { $attachment_id = $this->migration_data['images_mapping'][ $original_id ]; } else { if ( ! $product_id ) { $product_id = $product->save(); if ( ! $product_id ) { wc_get_logger()->warning( "Skipping image upload {$original_id}: Could not get product ID before sideloading.", array( 'source' => 'wc-migrator' ) ); continue; } } $start_time = microtime( true ); $image_desc = $image_alt ? $image_alt : $product->get_name(); $attachment_id = $this->import_image( $image_url, $image_alt, $product_id ); $duration = microtime( true ) - $start_time; if ( is_wp_error( $attachment_id ) ) { wc_get_logger()->error( "Error uploading {$image_url}: " . $attachment_id->get_error_message() . " (Duration: {$duration}s)", array( 'source' => 'wc-migrator' ) ); continue; } if ( ! $attachment_id ) { wc_get_logger()->warning( "Image upload failed for {$image_url} (Duration: {$duration}s)", array( 'source' => 'wc-migrator' ) ); continue; } $this->migration_data['images_mapping'][ $original_id ] = $attachment_id; if ( $image_alt ) { update_post_meta( $attachment_id, '_wp_attachment_image_alt', $image_alt ); } } if ( $is_featured ) { $featured_id = $attachment_id; } else { $gallery_ids[] = $attachment_id; } ++$processed_count; ++$this->import_stats['images_processed']; } if ( $featured_id ) { $product->set_image_id( $featured_id ); } if ( ! empty( $gallery_ids ) ) { $product->set_gallery_image_ids( array_unique( $gallery_ids ) ); } } /** * Import image from URL with mapping optimization. * * @param string $image_url Image URL. * @param string $alt_text Alt text for the image. * @param string|null $original_id Original platform image ID. * @param int $product_id Product ID for sideloading. * @return int|null Attachment ID or null on failure. */ private function import_image_with_mapping( string $image_url, string $alt_text = '', ?string $original_id = null, int $product_id = 0 ): ?int { if ( $original_id && isset( $this->migration_data['images_mapping'][ $original_id ] ) ) { $attachment_id = $this->migration_data['images_mapping'][ $original_id ]; if ( wp_attachment_is_image( $attachment_id ) ) { return $attachment_id; } else { unset( $this->migration_data['images_mapping'][ $original_id ] ); } } $start_time = microtime( true ); $attachment_id = $this->import_image( $image_url, $alt_text, $product_id ); $duration = microtime( true ) - $start_time; if ( $attachment_id && $original_id ) { $this->migration_data['images_mapping'][ $original_id ] = $attachment_id; } if ( $attachment_id ) { $message = sprintf( 'Image uploaded successfully in %.2fs: %s -> %d', $duration, $image_url, $attachment_id ); if ( $this->import_options['verbose'] ?? false ) { \WP_CLI::log( $message ); } wc_get_logger()->info( $message, array( 'source' => 'wc-migrator-images' ) ); } else { $message = sprintf( 'Image upload failed in %.2fs: %s', $duration, $image_url ); if ( $this->import_options['verbose'] ?? false ) { \WP_CLI::warning( $message ); } wc_get_logger()->error( $message, array( 'source' => 'wc-migrator-images' ) ); } return $attachment_id; } /** * Import image from URL. * * @param string $image_url Image URL. * @param string $alt_text Alt text for the image. * @param int $product_id Product ID for sideloading. * @return int|null Attachment ID or null on failure. */ private function import_image( string $image_url, string $alt_text = '', int $product_id = 0 ): ?int { if ( $this->import_options['dry_run'] ) { return null; } if ( ! $this->import_options['skip_duplicate_images'] ) { $existing_attachment = $this->get_attachment_by_url( $image_url ); if ( $existing_attachment ) { return $existing_attachment; } } require_once ABSPATH . 'wp-admin/includes/media.php'; require_once ABSPATH . 'wp-admin/includes/file.php'; require_once ABSPATH . 'wp-admin/includes/image.php'; add_filter( 'http_request_timeout', array( $this, 'set_image_download_timeout' ) ); add_filter( 'http_request_args', array( $this, 'optimize_http_request_args' ) ); add_filter( 'image_sideload_extensions', array( $this, 'add_avif_support_to_sideload' ) ); try { $attachment_id = media_sideload_image( $image_url, $product_id, null, 'id' ); if ( is_wp_error( $attachment_id ) ) { $message = sprintf( 'Image import failed for URL %s: %s', $image_url, $attachment_id->get_error_message() ); if ( $this->import_options['verbose'] ?? false ) { \WP_CLI::warning( $message ); } wc_get_logger()->error( $message, array( 'source' => 'wc-migrator-images' ) ); return null; } if ( $alt_text ) { update_post_meta( $attachment_id, '_wp_attachment_image_alt', $alt_text ); } return $attachment_id; } finally { remove_filter( 'http_request_timeout', array( $this, 'set_image_download_timeout' ) ); remove_filter( 'http_request_args', array( $this, 'optimize_http_request_args' ) ); remove_filter( 'image_sideload_extensions', array( $this, 'add_avif_support_to_sideload' ) ); } } /** * Set HTTP timeout for image downloads. * * @return int Modified timeout. */ public function set_image_download_timeout(): int { return $this->import_options['image_timeout']; } /** * Optimize HTTP request arguments for faster image downloads. * * @param array $args HTTP request arguments. * @return array Optimized arguments. */ public function optimize_http_request_args( array $args ): array { $args['redirection'] = 3; $args['timeout'] = $this->import_options['image_timeout'] ?? 30; return $args; } /** * Add AVIF support to image sideload extensions. * * @param array $allowed_extensions Array of allowed file extensions. * @return array Modified array with AVIF support. */ public function add_avif_support_to_sideload( array $allowed_extensions ): array { if ( ! in_array( 'avif', $allowed_extensions, true ) ) { $allowed_extensions[] = 'avif'; } return $allowed_extensions; } /** * Get existing attachment by URL. * * @param string $image_url Image URL. * @return int|null Attachment ID or null if not found. */ private function get_attachment_by_url( string $image_url ): ?int { global $wpdb; $basename = wp_basename( $image_url ); $attachment_id = $wpdb->get_var( $wpdb->prepare( "SELECT post_id FROM $wpdb->postmeta WHERE meta_key = '_wp_attached_file' AND meta_value LIKE %s", '%' . $wpdb->esc_like( $basename ) ) ); return $attachment_id ? (int) $attachment_id : null; } /** * Create success result array. * * @param string $action Action performed (created, updated, skipped). * @param int $product_id Product ID. * @param string $message Success message. * @return array Success result. */ private function create_success_result( string $action, int $product_id, string $message ): array { return array( 'status' => 'success', 'action' => $action, 'product_id' => $product_id, 'message' => $message, ); } /** * Updates SEO meta fields if Yoast SEO is active. * * @param int $product_id The product ID. * @param array $metafields Key-value array of metafields from standardized data. * @param array $product_data Full product data for fallbacks. */ private function update_seo_meta( int $product_id, array $metafields, array $product_data ): void { if ( ! defined( 'WPSEO_VERSION' ) ) { return; } $seo_title = $metafields['global_title_tag'] ?? null; $seo_description = $metafields['global_description_tag'] ?? null; $final_seo_title = $seo_title ? $seo_title : ( $product_data['name'] ?? '' ); $fallback_desc = $product_data['description'] ? $product_data['description'] : ( $product_data['short_description'] ?? '' ); $final_seo_description = $seo_description ? $seo_description : wp_strip_all_tags( $fallback_desc ); $current_title = get_post_meta( $product_id, '_yoast_wpseo_title', true ); if ( $current_title !== $final_seo_title && ! empty( $final_seo_title ) ) { update_post_meta( $product_id, '_yoast_wpseo_title', $final_seo_title ); } $current_desc = get_post_meta( $product_id, '_yoast_wpseo_metadesc', true ); if ( $current_desc !== $final_seo_description && ! empty( $final_seo_description ) ) { $truncated_desc = mb_substr( $final_seo_description, 0, 160 ); update_post_meta( $product_id, '_yoast_wpseo_metadesc', $truncated_desc ); } } /** * Set COGS value directly using meta data. * * @param WC_Product $product The product object. * @param float $cogs_value The COGS value to set. */ private function set_cogs_value_direct( WC_Product $product, float $cogs_value ): void { $product->update_meta_data( '_cogs_total_value', $cogs_value ); } /** * Create error result array. * * @param string $error_code Error code. * @param string $message Error message. * @param array $product_data Product data that failed. * @return array Error result. */ private function create_error_result( string $error_code, string $message, array $product_data ): array { return array( 'status' => 'error', 'error_code' => $error_code, 'message' => $message, 'product_data' => $product_data, ); } } Migrator/Core/MigratorTracker.php 0000777 00000027022 15252251042 0013041 0 ustar 00 <?php /** * Migrator Tracker * * @package Automattic\WooCommerce\Internal\CLI\Migrator\Core */ declare( strict_types=1 ); namespace Automattic\WooCommerce\Internal\CLI\Migrator\Core; defined( 'ABSPATH' ) || exit; /** * MigratorTracker class. * * Implements subscriber pattern to track comprehensive migration analytics * for integration with WC_Tracker telemetry system. * * @internal This class is part of the CLI Migrator feature and should not be used directly. */ class MigratorTracker { /** * Option name for storing migration analytics. */ private const OPTION_NAME = 'wc_migrator_analytics'; /** * Current migration session data. * * @var array */ private array $current_session = array(); /** * Constructor. */ public function __construct() { $this->init_hooks(); } /** * Initialize WordPress hooks. */ private function init_hooks(): void { add_action( 'wc_migrator_session_started', array( $this, 'on_session_started' ), 10, 2 ); add_action( 'wc_migrator_batch_processed', array( $this, 'on_batch_processed' ), 10, 3 ); add_action( 'wc_migrator_session_completed', array( $this, 'on_session_completed' ), 10, 2 ); } /** * Handle migration session start. * * @param string $platform Platform identifier (e.g., 'shopify'). * @param array $metadata Session metadata. */ public function on_session_started( string $platform, array $metadata ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed $this->current_session = array( 'platform' => $platform, 'started_at' => time(), 'products_total' => 0, 'products_attempted' => 0, 'products_successful' => 0, 'products_failed' => 0, 'products_skipped' => 0, 'product_types' => array(), 'total_time' => 0, 'is_dry_run' => $metadata['is_dry_run'] ?? false, ); } /** * Handle batch processing completion. * * @param array $batch_results Results from the batch import. * @param array $source_data Source platform data for the batch. * @param array $mapped_data Mapped WooCommerce data for the batch. */ public function on_batch_processed( array $batch_results, array $source_data, array $mapped_data ): void { if ( empty( $this->current_session ) ) { return; } // Track detailed statistics for better telemetry accuracy. $batch_stats = $batch_results['stats'] ?? array(); $this->current_session['products_attempted'] += count( $mapped_data ); $this->current_session['products_successful'] += $batch_stats['successful'] ?? 0; $this->current_session['products_failed'] += $batch_stats['failed'] ?? 0; $this->current_session['products_skipped'] += $batch_stats['skipped'] ?? 0; $this->track_product_types( $mapped_data, $batch_results ); } /** * Handle migration session completion. * * @param string $platform Platform identifier. * @param array $final_stats Final migration statistics. */ public function on_session_completed( string $platform, array $final_stats ): void { if ( empty( $this->current_session ) ) { // Log warning for debugging - session completed without active session. if ( function_exists( 'wc_get_logger' ) ) { wc_get_logger()->warning( 'Migration session completed event fired without active session.', array( 'source' => 'migrator_tracker' ) ); } return; } // Use consistent time() calls to avoid any timezone issues. $completion_time = time(); $this->current_session['total_time'] = $completion_time - $this->current_session['started_at']; $this->current_session['completed_at'] = $completion_time; $this->current_session['products_total'] = $final_stats['total_found'] ?? $this->current_session['products_attempted']; $this->save_session_data(); $this->current_session = array(); } /** * Track product types from mapped data and import results. * * Only count product types for successfully imported products to ensure * telemetry accuracy. * * @param array $mapped_data Array of mapped product data. * @param array $batch_results Results from the batch import. */ private function track_product_types( array $mapped_data, array $batch_results ): void { $successful_results = array_filter( $batch_results['results'] ?? array(), function ( $result ) { return 'success' === ( $result['status'] ?? '' ) && 'skipped' !== ( $result['action'] ?? '' ); } ); // Only track types for successfully imported products. foreach ( $successful_results as $index => $result ) { if ( ! isset( $mapped_data[ $index ] ) ) { continue; } $product = $mapped_data[ $index ]; $type = $product['type'] ?? 'simple'; if ( ! isset( $this->current_session['product_types'][ $type ] ) ) { $this->current_session['product_types'][ $type ] = 0; } ++$this->current_session['product_types'][ $type ]; } } /** * Save current session data to persistent storage. */ private function save_session_data(): void { $analytics = $this->get_stored_analytics(); $platform = $this->current_session['platform']; if ( ! isset( $analytics['platforms'][ $platform ] ) ) { $analytics['platforms'][ $platform ] = array( 'total_products_attempted' => 0, 'total_products_successful' => 0, 'total_products_failed' => 0, 'total_products_skipped' => 0, 'total_sessions' => 0, 'total_time' => 0, 'product_types' => array(), 'last_migration' => null, 'dry_run_sessions' => 0, ); } $platform_data = &$analytics['platforms'][ $platform ]; $products_attempted = $this->current_session['products_attempted'] ?? 0; $products_successful = $this->current_session['products_successful'] ?? 0; $products_failed = $this->current_session['products_failed'] ?? 0; $products_skipped = $this->current_session['products_skipped'] ?? 0; $total_time = $this->current_session['total_time'] ?? 0; $completed_at = $this->current_session['completed_at'] ?? time(); $product_types = $this->current_session['product_types'] ?? array(); $is_dry_run = $this->current_session['is_dry_run'] ?? false; // Update platform statistics. if ( ! $is_dry_run ) { $platform_data['total_products_attempted'] += $products_attempted; $platform_data['total_products_successful'] += $products_successful; $platform_data['total_products_failed'] += $products_failed; $platform_data['total_products_skipped'] += $products_skipped; $platform_data['last_migration'] = $completed_at; } else { ++$platform_data['dry_run_sessions']; } ++$platform_data['total_sessions']; $platform_data['total_time'] += $total_time; foreach ( $product_types as $type => $count ) { if ( ! isset( $platform_data['product_types'][ $type ] ) ) { $platform_data['product_types'][ $type ] = 0; } $platform_data['product_types'][ $type ] += $count; } if ( ! isset( $analytics['totals'] ) || ! is_array( $analytics['totals'] ) ) { $analytics['totals'] = array(); } // Only update global totals for non-dry-run sessions. if ( ! $is_dry_run ) { $analytics['totals']['products_attempted'] = ( $analytics['totals']['products_attempted'] ?? 0 ) + $products_attempted; $analytics['totals']['products_successful'] = ( $analytics['totals']['products_successful'] ?? 0 ) + $products_successful; $analytics['totals']['products_failed'] = ( $analytics['totals']['products_failed'] ?? 0 ) + $products_failed; $analytics['totals']['products_skipped'] = ( $analytics['totals']['products_skipped'] ?? 0 ) + $products_skipped; } $analytics['totals']['total_sessions'] = ( $analytics['totals']['total_sessions'] ?? 0 ) + 1; $analytics['totals']['total_migration_time'] = ( $analytics['totals']['total_migration_time'] ?? 0 ) + $total_time; $analytics['totals']['dry_run_sessions'] = ( $analytics['totals']['dry_run_sessions'] ?? 0 ) + ( $is_dry_run ? 1 : 0 ); $this->save_analytics( $analytics ); } /** * Get comprehensive migration data for WC_Tracker integration. * * @return array Formatted data for telemetry reporting. */ public function get_data(): array { $analytics = $this->get_stored_analytics(); $totals = $analytics['totals'] ?? array(); $data = array( 'products_attempted' => $totals['products_attempted'] ?? 0, 'products_successful' => $totals['products_successful'] ?? 0, 'products_failed' => $totals['products_failed'] ?? 0, 'products_skipped' => $totals['products_skipped'] ?? 0, 'total_migration_sessions' => $totals['total_sessions'] ?? 0, 'total_migration_time' => $totals['total_migration_time'] ?? 0, 'dry_run_sessions' => $totals['dry_run_sessions'] ?? 0, 'platforms_used' => array_keys( $analytics['platforms'] ?? array() ), 'platform_breakdown' => array(), 'success_rate' => $this->calculate_success_rate( $totals ), ); $platforms = $analytics['platforms'] ?? array(); foreach ( $platforms as $platform => $platform_data ) { $data['platform_breakdown'][ $platform ] = array( 'products_attempted' => $platform_data['total_products_attempted'] ?? 0, 'products_successful' => $platform_data['total_products_successful'] ?? 0, 'products_failed' => $platform_data['total_products_failed'] ?? 0, 'products_skipped' => $platform_data['total_products_skipped'] ?? 0, 'sessions_count' => $platform_data['total_sessions'] ?? 0, 'dry_run_sessions' => $platform_data['dry_run_sessions'] ?? 0, 'total_time' => $platform_data['total_time'] ?? 0, 'product_types' => $platform_data['product_types'] ?? array(), 'last_migration' => $platform_data['last_migration'] ?? null, 'success_rate' => $this->calculate_success_rate( $platform_data ), ); } return $data; } /** * Calculate success rate as a percentage. * * @param array $stats Statistics array containing attempted and successful counts. * @return float Success rate as a percentage (0-100). */ private function calculate_success_rate( array $stats ): float { $attempted = $stats['total_products_attempted'] ?? $stats['products_attempted'] ?? 0; $successful = $stats['total_products_successful'] ?? $stats['products_successful'] ?? 0; if ( 0 === $attempted ) { return 0.0; } return round( ( $successful / $attempted ) * 100, 2 ); } /** * Get stored analytics data with defaults. * * @return array Analytics data structure. */ private function get_stored_analytics(): array { $defaults = array( 'totals' => array( 'products_attempted' => 0, 'products_successful' => 0, 'products_failed' => 0, 'products_skipped' => 0, 'total_sessions' => 0, 'total_migration_time' => 0, 'dry_run_sessions' => 0, ), 'platforms' => array(), ); $stored = get_option( self::OPTION_NAME, array() ); return wp_parse_args( $stored, $defaults ); } /** * Save analytics data to WordPress options. * * @param array $analytics Analytics data to save. */ private function save_analytics( array $analytics ): void { if ( false === get_option( self::OPTION_NAME ) ) { add_option( self::OPTION_NAME, $analytics, '', 'no' ); } else { update_option( self::OPTION_NAME, $analytics, 'no' ); } } /** * Clear all stored analytics data. * Useful for development/testing or user privacy requests. */ public function clear_data(): void { delete_option( self::OPTION_NAME ); $this->current_session = array(); } } Migrator/Runner.php 0000777 00000003602 15252251042 0010320 0 ustar 00 <?php declare( strict_types=1 ); namespace Automattic\WooCommerce\Internal\CLI\Migrator; use Automattic\WooCommerce\Internal\CLI\Migrator\Commands\ProductsCommand; use Automattic\WooCommerce\Internal\CLI\Migrator\Commands\ResetCommand; use Automattic\WooCommerce\Internal\CLI\Migrator\Commands\SetupCommand; use Automattic\WooCommerce\Internal\CLI\Migrator\Commands\ListCommand; use Automattic\WooCommerce\Internal\CLI\Migrator\Platforms\Shopify\ShopifyPlatform; use WP_CLI; use WC_Product_Factory; /** * The main runner for the migrator. */ final class Runner { /** * Register the commands for the migrator. * * @return void */ public static function register_commands(): void { // Initialize built-in platforms. self::init_platforms(); $container = wc_get_container(); WP_CLI::add_command( 'wc migrate products', $container->get( ProductsCommand::class ), array( 'shortdesc' => 'Migrate products from a source platform to WooCommerce.', 'longdesc' => 'Migrate products from a source platform to WooCommerce. The migrator will fetch products from the source platform, map them to the WooCommerce product schema, and then import them into WooCommerce.', ) ); WP_CLI::add_command( 'wc migrate reset', $container->get( ResetCommand::class ), array( 'shortdesc' => 'Resets (deletes) the credentials for a given platform.', ) ); WP_CLI::add_command( 'wc migrate setup', $container->get( SetupCommand::class ), array( 'shortdesc' => 'Interactively sets up the credentials for a given platform.', ) ); WP_CLI::add_command( 'wc migrate list', $container->get( ListCommand::class ), array( 'shortdesc' => 'Lists all registered migration platforms.', ) ); } /** * Initialize built-in migration platforms. * * @return void */ private static function init_platforms(): void { ShopifyPlatform::init(); } } Migrator/Lib/ImportSession.php 0000777 00000045175 15252251042 0012406 0 ustar 00 <?php /** * !! Do not apply Woo-specific changes to this class !! * * This class is a part of the WordPress/php-toolkit project and is currently * duplicated between WordPress/php-toolkit and woocommerce/woocommerce: * * https://github.com/WordPress/php-toolkit/blob/trunk/components/DataLiberation/Importer/ImportSession.php * * https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/src/Internal/CLI/Migrator/Lib/ImportSession.php * * Apply all changes in both projects until Woo consumes php-toolkit as a * composer dependency. Generic changes belong to this class. Anything * Woo-specific should be implemented as an extension point. * * MODIFICATION: Made this class standalone by replacing external StreamImporter * and AttachmentDownloaderEvent dependencies with internal constants to eliminate * external imports and make the class fully self-contained. */ namespace Automattic\WooCommerce\Internal\CLI\Migrator\Lib; use WP_Query; use function get_all_post_meta_flat; use function is_wp_error; /** * Manages import session data in the WordPress database. * * Each import session is stored as a post of type 'import_session'. * Progress, stage, and other metadata are stored as post meta. */ class ImportSession { const POST_TYPE = 'import_session'; // Import stage constants - replaces StreamImporter dependencies const STAGE_INITIAL = 'initial'; const STAGE_FINISHED = 'finished'; // Import stages in processing order const STAGES_IN_ORDER = array( self::STAGE_INITIAL, 'indexing', 'preparing', 'importing', 'finalizing', self::STAGE_FINISHED, ); // Event type constants - replaces AttachmentDownloaderEvent dependencies const EVENT_SUCCESS = 'success'; const EVENT_ALREADY_EXISTS = 'already_exists'; const EVENT_FAILURE = 'failure'; /** * @TODO: Make it extendable * @TODO: Reuse the same entities list as WP_Stream_Importer */ const PROGRESS_ENTITIES = array( 'site_option', 'user', 'category', 'tag', 'term', 'post', 'post_meta', 'comment', 'comment_meta', ); const FRONTLOAD_STATUS_AWAITING_DOWNLOAD = 'awaiting_download'; const FRONTLOAD_STATUS_IGNORED = 'ignored'; const FRONTLOAD_STATUS_ERROR = 'error'; const FRONTLOAD_STATUS_SUCCEEDED = 'succeeded'; private $post_id; private $cached_stage; /** * Creates a new import session. * * @param array $args { * * @type string $data_source The data source (e.g. 'wxr_file', 'wxr_url', 'markdown_zip') * @type string $source_url Optional. URL of the source file for remote imports * @type int $attachment_id Optional. ID of the uploaded file attachment * @type string $file_name Optional. Original name of the uploaded file * } * @return ImportSession The created ImportSession instance. * @throws \Exception If the arguments are invalid. */ public static function create( $args ) { // Validate the required arguments for each data source. // @TODO: Leave it up to filters to make it extendable. switch ( $args['data_source'] ) { case 'wxr_file': if ( empty( $args['file_name'] ) ) { throw new \Exception( 'File name is required for WXR file imports' ); } break; case 'wxr_url': if ( empty( $args['source_url'] ) ) { throw new \Exception( 'Source URL is required for remote imports' ); } break; case 'markdown_zip': if ( empty( $args['file_name'] ) ) { throw new \Exception( 'File name is required for Markdown ZIP imports' ); } break; case 'local_directory': if ( empty( $args['file_name'] ) ) { throw new \Exception( 'Directory path is required for local directory imports' ); } break; } $post_id = wp_insert_post( array( 'post_type' => self::POST_TYPE, 'post_status' => 'publish', 'post_title' => sprintf( 'Import from %s - %s', $args['data_source'], $args['file_name'] ?? $args['source_url'] ?? 'Unknown source' ), 'meta_input' => array( 'data_source' => $args['data_source'], 'started_at' => time(), 'file_name' => $args['file_name'] ?? null, 'source_url' => $args['source_url'] ?? null, 'attachment_id' => $args['attachment_id'] ?? null, ), ), true ); if ( is_wp_error( $post_id ) ) { throw new \Exception( 'Error creating an import session: ' . $post_id->get_error_message() ); } if ( ! empty( $args['attachment_id'] ) ) { wp_update_post( array( 'ID' => $post_id, 'post_parent' => $args['attachment_id'], ) ); } return new self( $post_id ); } /** * Gets an existing import session by ID. * * @param int $post_id The import session post ID * * @return WP_Import_Model|null The import model instance or null if not found */ public static function by_id( $post_id ) { $post = get_post( $post_id ); if ( ! $post || $post->post_type !== self::POST_TYPE ) { return false; } return new self( $post_id ); } /** * Gets the most recent active import session. * * @return WP_Import_Session|null The most recent import or null if none found */ public static function get_active() { $posts = get_posts( array( 'post_type' => self::POST_TYPE, 'post_status' => array( 'publish' ), 'posts_per_page' => 1, 'orderby' => 'date', 'order' => 'DESC', 'meta_query' => array( // @TODO: This somehow makes $post empty. // array( // 'key' => 'current_stage', // 'value' => WP_Stream_Importer::STAGE_FINISHED, // 'compare' => '!=' // ) ), ) ); if ( empty( $posts ) ) { return false; } return new self( $posts[0]->ID ); } public function __construct( $post_id ) { $this->post_id = $post_id; } /** * Gets the import session ID. * * @return int The post ID */ public function get_id() { return $this->post_id; } public function get_metadata() { $cursor = $this->get_reentrancy_cursor(); return array( 'post_id' => $this->post_id, 'cursor' => $cursor ? $cursor : null, 'data_source' => get_post_meta( $this->post_id, 'data_source', true ), 'source_url' => get_post_meta( $this->post_id, 'source_url', true ), 'attachment_id' => get_post_meta( $this->post_id, 'attachment_id', true ), ); } public function get_data_source() { return get_post_meta( $this->post_id, 'data_source', true ); } public function get_human_readable_file_reference() { switch ( $this->get_data_source() ) { case 'wxr_file': case 'markdown_zip': return get_post_meta( $this->post_id, 'file_name', true ); case 'wxr_url': return get_post_meta( $this->post_id, 'source_url', true ); } return ''; } public function archive() { wp_update_post( array( 'ID' => $this->post_id, 'post_status' => 'archived', ) ); } /** * Gets the current progress information. * * @return array The progress data */ public function count_imported_entities() { $progress = array(); foreach ( self::PROGRESS_ENTITIES as $entity ) { $progress[] = array( 'label' => $entity, 'imported' => (int) get_post_meta( $this->post_id, 'imported_' . $entity, true ), 'total' => (int) get_post_meta( $this->post_id, 'total_' . $entity, true ), ); } return $progress; } public function count_all_imported_entities() { $counts = $this->count_imported_entities(); return array_sum( array_column( $counts, 'imported' ) ); } public function count_all_total_entities() { $counts = $this->count_imported_entities(); return array_sum( array_column( $counts, 'total' ) ); } public function count_remaining_entities() { $counts = $this->count_imported_entities(); return array_sum( array_column( $counts, 'total' ) ) - array_sum( array_column( $counts, 'imported' ) ); } /** * Cache of imported entity counts to avoid repeated database queries * * @var array */ private $cached_imported_counts = array(); /** * Updates the progress information. * * @param array $newly_imported_entities The new progress data with keys: posts, comments, terms, attachments, users */ public function bump_imported_entities_counts( $newly_imported_entities ) { foreach ( $newly_imported_entities as $field => $count ) { if ( ! in_array( $field, static::PROGRESS_ENTITIES, true ) ) { _doing_it_wrong( __METHOD__, 'Cannot bump imported entities count for unknown entity type: ' . $field, '1.0.0' ); continue; } // Get current count from cache or database if ( ! isset( $this->cached_imported_counts[ $field ] ) ) { $this->cached_imported_counts[ $field ] = (int) get_post_meta( $this->post_id, 'imported_' . $field, true ); } // Add new count to total $new_count = $this->cached_imported_counts[ $field ] + $count; // Update database and cache update_post_meta( $this->post_id, 'imported_' . $field, $new_count ); $this->cached_imported_counts[ $field ] = $new_count; /* @TODO run an atomic query instead: $sql = $wpdb->prepare( "INSERT INTO {$wpdb->postmeta} (post_id, meta_key, meta_value) VALUES (%d, %s, %d) ON DUPLICATE KEY UPDATE meta_value = meta_value + %d", $this->post_id, 'imported_' . $field, $count, $count ); $wpdb->query($sql); */ } } public function count_awaiting_frontloading_stubs() { global $wpdb; return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $wpdb->posts WHERE post_type = 'frontloading_stub' AND post_parent = %d AND post_status = %s", $this->post_id, self::FRONTLOAD_STATUS_AWAITING_DOWNLOAD ) ); } public function count_unfinished_frontloading_stubs() { global $wpdb; return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $wpdb->posts WHERE post_type = 'frontloading_stub' AND post_parent = %d AND post_status != %s AND post_status != %s", $this->post_id, self::FRONTLOAD_STATUS_SUCCEEDED, self::FRONTLOAD_STATUS_IGNORED ) ); } public function mark_frontloading_errors_as_ignored() { global $wpdb; $wpdb->update( $wpdb->posts, array( 'post_status' => self::FRONTLOAD_STATUS_IGNORED ), array( 'post_type' => 'frontloading_stub', // 'post_status !=' => self::FRONTLOAD_STATUS_SUCCEEDED, ) ); } public function get_frontloading_stubs( $options = array() ) { $query = new WP_Query( array( 'post_type' => 'frontloading_stub', 'post_status' => 'any', 'post_parent' => $this->post_id, 'posts_per_page' => $options['per_page'] ?? 25, 'paged' => $options['page'] ?? 1, 'orderby' => array( 'post_status' => array( self::FRONTLOAD_STATUS_ERROR => 0, self::FRONTLOAD_STATUS_AWAITING_DOWNLOAD => 1, 'any' => 2, ), 'ID' => 'ASC', ), ) ); if ( ! $query->have_posts() ) { return array(); } $posts = $query->posts; $ids = array_map( function ( $post ) { return $post->ID; }, $posts ); update_meta_cache( 'post', $ids ); foreach ( $posts as $post ) { $post->meta = get_all_post_meta_flat( $post->ID ); } return $posts; } public function get_total_number_of_entities() { $totals = array(); foreach ( static::PROGRESS_ENTITIES as $field ) { $totals[ $field ] = (int) get_post_meta( $this->post_id, 'total_' . $field, true ); } $totals['download'] = $this->get_total_number_of_assets(); return $totals; } public function get_total_number_of_assets() { global $wpdb; return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $wpdb->posts WHERE post_type = 'frontloading_stub' AND post_parent = %d", $this->post_id ) ); } public function get_frontloading_stub( $url ) { global $wpdb; $id = $wpdb->get_var( $wpdb->prepare( "SELECT p.ID FROM $wpdb->posts p INNER JOIN $wpdb->postmeta pm ON p.ID = pm.post_id WHERE p.post_type = 'frontloading_stub' AND p.post_parent = %d AND pm.meta_key = 'current_url' AND pm.meta_value = %s LIMIT 1", $this->post_id, $url ) ); return get_post( $id ); } /** * Creates placeholder attachments for the assets to be downloaded in the * frontloading stage. */ public function create_frontloading_stubs( $urls ) { global $wpdb; foreach ( $urls as $url => $_ ) { /** * Check if placeholder with this URL already exists * There's a race condition here – another insert may happen * between the check and the insert. * * @TODO: Explore solutions. A custom table with a UNIQUE constraint * may or may not be an option, depending on the performance impact * on 100GB+ VIP databases. */ $exists = $wpdb->get_var( $wpdb->prepare( "SELECT ID FROM $wpdb->posts WHERE post_type = 'frontloading_stub' AND post_parent = %d AND guid = %s LIMIT 1", $this->post_id, $url ) ); if ( $exists ) { continue; } $post_data = array( 'post_type' => 'frontloading_stub', 'post_parent' => $this->post_id, 'post_title' => basename( $url ), 'post_status' => self::FRONTLOAD_STATUS_AWAITING_DOWNLOAD, 'guid' => $url, 'meta_input' => array( 'original_url' => $url, 'current_url' => $url, 'attempts' => 0, 'last_error' => null, 'target_path' => '', ), ); $insertion_result = wp_insert_post( $post_data ); if ( is_wp_error( $insertion_result ) ) { throw new \Exception( 'Failed to insert frontloading placeholder' ); } } } /** * Sets the total number of entities to import for each type. * * @param array $totals The total number of entities for each type */ private $cached_totals = array(); public function bump_total_number_of_entities( $newly_indexed_entities ) { foreach ( $newly_indexed_entities as $field => $count ) { if ( ! in_array( $field, static::PROGRESS_ENTITIES, true ) ) { _doing_it_wrong( __METHOD__, 'Cannot set total number of entities for unknown entity type: ' . $field, '1.0.0' ); continue; } // Get current total from cache or database if ( ! isset( $this->cached_totals[ $field ] ) ) { $this->cached_totals[ $field ] = (int) get_post_meta( $this->post_id, 'total_' . $field, true ); } // Add new count to total $new_total = $this->cached_totals[ $field ] + $count; // Update database and cache update_post_meta( $this->post_id, 'total_' . $field, $new_total ); $this->cached_totals[ $field ] = $new_total; } } /** * Saves an array of [$url => ['received' => $downloaded_bytes, 'total' => $total_bytes | null]] * of the currently fetched files. The list is ephemeral and changes as we stream the data. There * will never be more than $concurrency_limit files in the list at any given time. */ public function bump_frontloading_progress( $frontloading_progress, $events = array() ) { update_post_meta( $this->post_id, 'frontloading_progress', $frontloading_progress ); foreach ( $events as $event ) { $url = $event->resource_id; $placeholder = $this->get_frontloading_stub( $url ); if ( ! $placeholder ) { _doing_it_wrong( __METHOD__, 'Frontloading placeholder post not found for URL: ' . $url, '1.0.0' ); continue; } update_post_meta( $placeholder->ID, 'last_error', $event->error ); $attempts = get_post_meta( $placeholder->ID, 'attempts', true ); $new_attempts = $attempts; $new_status = $placeholder->post_status; switch ( $event->type ) { case self::EVENT_SUCCESS: $new_status = self::FRONTLOAD_STATUS_SUCCEEDED; $new_attempts = $attempts + 1; break; case self::EVENT_ALREADY_EXISTS: $new_status = self::FRONTLOAD_STATUS_SUCCEEDED; break; case self::EVENT_FAILURE: $new_status = self::FRONTLOAD_STATUS_ERROR; $new_attempts = $attempts + 1; break; } if ( $new_attempts !== $attempts ) { update_post_meta( $placeholder->ID, 'attempts', $new_attempts ); } if ( $new_status !== $placeholder->post_status ) { wp_update_post( array( 'ID' => $placeholder->ID, 'post_status' => $new_status, ) ); } } } public function get_frontloading_progress() { $meta = get_post_meta( $this->post_id, 'frontloading_progress', true ); return $meta ? $meta : array(); } public function is_stage_completed( $stage ) { $current_stage = $this->get_stage(); $stage_index = array_search( $stage, self::STAGES_IN_ORDER, true ); $current_stage_index = array_search( $current_stage, self::STAGES_IN_ORDER, true ); return $current_stage_index > $stage_index; } /** * Gets the current import stage. * * @return string The current stage */ public function get_stage() { if ( ! isset( $this->cached_stage ) ) { $meta = get_post_meta( $this->post_id, 'current_stage', true ); $this->cached_stage = $meta ? $meta : self::STAGE_INITIAL; } return $this->cached_stage; } /** * Updates the current import stage. * * @param string $stage The new stage */ public function set_stage( $stage ) { if ( $stage === $this->get_stage() ) { return; } if ( self::STAGE_FINISHED === $stage ) { update_post_meta( $this->post_id, 'finished_at', time() ); } update_post_meta( $this->post_id, 'current_stage', $stage ); $this->cached_stage = $stage; } public function get_started_at() { return get_post_meta( $this->post_id, 'started_at', true ); } public function get_finished_at() { return get_post_meta( $this->post_id, 'finished_at', true ); } public function is_finished() { return ! empty( get_post_meta( $this->post_id, 'finished_at', true ) ); } /** * Gets the importer cursor for resuming imports. * * @return string|null The cursor data */ public function get_reentrancy_cursor() { return get_post_meta( $this->post_id, 'importer_cursor', true ); } /** * Updates the importer cursor. * * @param string $cursor The new cursor data */ public function set_reentrancy_cursor( $cursor ) { // WordPress, sadly, removes single slashes from the meta value and // requires an addslashes() call to preserve them. update_post_meta( $this->post_id, 'importer_cursor', addslashes( $cursor ) ); } /** * 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; } } Migrator/Platforms/Shopify/ShopifyClient.php 0000777 00000020002 15252251042 0015210 0 ustar 00 <?php /** * Shopify Client * * @package Automattic\WooCommerce\Internal\CLI\Migrator\Platforms\Shopify */ declare(strict_types=1); namespace Automattic\WooCommerce\Internal\CLI\Migrator\Platforms\Shopify; /** * Handles communication with the Shopify REST API. */ class ShopifyClient { /** * Platform credentials. * * @var array */ private array $credentials; /** * Constructor. * * @param array $credentials Platform credentials array. */ public function __construct( array $credentials ) { $this->credentials = $credentials; } /** * Makes a request to the Shopify REST API. * * @param string $path The API path (e.g., '/products/count.json'). * @param array $query_params Optional query parameters. * @param string $method HTTP method (GET, POST, PUT, DELETE). * @param array $body Request body for POST/PUT. * @return object|\WP_Error Decoded JSON response object or WP_Error on failure. */ public function rest_request( string $path, array $query_params = array(), string $method = 'GET', array $body = array() ) { $credentials = $this->get_credentials(); if ( is_wp_error( $credentials ) ) { return $credentials; } $rest_endpoint = $this->build_rest_url( $credentials['domain'], $path, $query_params ); $request_args = $this->build_request_args( $credentials['access_token'], $method, $body ); $response = wp_remote_request( $rest_endpoint, $request_args ); return $this->process_response( $response, $path ); } /** * Makes a request to the Shopify GraphQL API. * * @param string $query The GraphQL query string. * @param array $variables The variables for the query. * @return object|\WP_Error Decoded JSON response data or WP_Error on failure. */ public function graphql_request( string $query, array $variables = array() ) { $credentials = $this->get_credentials(); if ( is_wp_error( $credentials ) ) { return $credentials; } $graphql_endpoint = $this->build_graphql_url( $credentials['domain'] ); $request_args = $this->build_graphql_request_args( $credentials['access_token'], $query, $variables ); $response = wp_remote_request( $graphql_endpoint, $request_args ); return $this->process_graphql_response( $response ); } /** * Get Shopify API credentials. * * @return array|\WP_Error Array with 'domain' and 'access_token' keys, or WP_Error on failure. */ private function get_credentials() { if ( empty( $this->credentials['shop_url'] ) || empty( $this->credentials['access_token'] ) ) { return new \WP_Error( 'api_error', 'Shopify API credentials (shop_url, access_token) are not configured. Please run: wp wc migrate setup' ); } // Map the stored credential keys to the expected format. return array( 'domain' => $this->credentials['shop_url'], 'access_token' => $this->credentials['access_token'], ); } /** * Build the REST API URL. * * @param string $domain The Shopify domain. * @param string $path The API path. * @param array $query_params Query parameters. * @return string The complete API URL. */ private function build_rest_url( string $domain, string $path, array $query_params ): string { // Ensure the domain has the protocol. if ( ! preg_match( '~^https?://~i', $domain ) ) { $domain = 'https://' . $domain; } $shop_url = untrailingslashit( $domain ); // Use the latest stable API version. $api_version = '2025-04'; $rest_endpoint = "{$shop_url}/admin/api/{$api_version}{$path}"; if ( ! empty( $query_params ) ) { $rest_endpoint = add_query_arg( $query_params, $rest_endpoint ); } return $rest_endpoint; } /** * Build the request arguments. * * @param string $access_token The Shopify access token. * @param string $method HTTP method. * @param array $body Request body. * @return array Request arguments for wp_remote_request. */ private function build_request_args( string $access_token, string $method, array $body ): array { $request_args = array( 'method' => $method, 'headers' => array( 'Content-Type' => 'application/json', 'X-Shopify-Access-Token' => $access_token, ), 'timeout' => 60, ); if ( ! empty( $body ) && ( 'POST' === $method || 'PUT' === $method ) ) { $request_args['body'] = wp_json_encode( $body ); } return $request_args; } /** * Process the API response. * * @param array|WP_Error $response The HTTP response. * @param string $path The API path for error reporting. * @return object|\WP_Error Decoded response or WP_Error. */ private function process_response( $response, string $path ) { if ( is_wp_error( $response ) ) { return new \WP_Error( 'api_error', 'REST request failed: ' . $response->get_error_message() ); } $response_code = wp_remote_retrieve_response_code( $response ); $response_body = wp_remote_retrieve_body( $response ); if ( $response_code >= 300 ) { $error_details = json_decode( $response_body ); $error_message = isset( $error_details->errors ) ? wp_json_encode( $error_details->errors ) : $response_body; return new \WP_Error( 'api_error', "REST request to {$path} failed with status code {$response_code}: " . $error_message ); } $data = json_decode( $response_body ); if ( json_last_error() !== JSON_ERROR_NONE ) { return new \WP_Error( 'api_error', 'Failed to decode REST JSON response: ' . json_last_error_msg() ); } return $data; } /** * Build the GraphQL API URL. * * @param string $domain The Shopify domain. * @return string The complete GraphQL API URL. */ private function build_graphql_url( string $domain ): string { // Ensure the domain has the protocol. if ( ! preg_match( '~^https?://~i', $domain ) ) { $domain = 'https://' . $domain; } $shop_url = untrailingslashit( $domain ); // Use the same API version as REST. $api_version = '2025-04'; return "{$shop_url}/admin/api/{$api_version}/graphql.json"; } /** * Build the request arguments for GraphQL requests. * * @param string $access_token The Shopify access token. * @param string $query The GraphQL query. * @param array $variables The GraphQL variables. * @return array Request arguments for wp_remote_request. * * @phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed */ private function build_graphql_request_args( string $access_token, string $query, array $variables ): array { $request_body = compact( 'query', 'variables' ); return array( 'method' => 'POST', 'headers' => array( 'Content-Type' => 'application/json', 'X-Shopify-Access-Token' => $access_token, ), 'body' => wp_json_encode( $request_body ), 'timeout' => 60, ); } /** * Process the GraphQL API response. * * @param array|\WP_Error $response The HTTP response. * @return object|\WP_Error Decoded response data or WP_Error. */ private function process_graphql_response( $response ) { if ( is_wp_error( $response ) ) { return new \WP_Error( 'api_error', 'GraphQL request failed: ' . $response->get_error_message() ); } $response_code = wp_remote_retrieve_response_code( $response ); $response_body = wp_remote_retrieve_body( $response ); if ( $response_code >= 300 ) { $error_details = json_decode( $response_body ); $error_message = isset( $error_details->errors ) ? wp_json_encode( $error_details->errors ) : $response_body; return new \WP_Error( 'api_error', "GraphQL request failed with status code {$response_code}: " . $error_message ); } $data = json_decode( $response_body ); if ( json_last_error() !== JSON_ERROR_NONE ) { return new \WP_Error( 'api_error', 'Failed to decode GraphQL JSON response: ' . json_last_error_msg() ); } // Check for GraphQL-specific errors. if ( ! empty( $data->errors ) ) { return new \WP_Error( 'graphql_error', 'GraphQL API returned errors: ' . wp_json_encode( $data->errors ) ); } if ( empty( $data->data ) ) { return new \WP_Error( 'api_error', 'GraphQL response missing "data" field.' ); } return $data->data; } } Migrator/Platforms/Shopify/ShopifyMapper.php 0000777 00000066417 15252251042 0015242 0 ustar 00 <?php /** * Shopify Mapper * * @package Automattic\WooCommerce\Internal\CLI\Migrator\Platforms\Shopify */ declare( strict_types=1 ); namespace Automattic\WooCommerce\Internal\CLI\Migrator\Platforms\Shopify; use Automattic\WooCommerce\Internal\CLI\Migrator\Interfaces\PlatformMapperInterface; defined( 'ABSPATH' ) || exit; /** * ShopifyMapper class. * * This class is responsible for transforming raw Shopify product data * into a standardized format suitable for the WooCommerce Importer. * Maps comprehensive product data including variants, images, taxonomies, * and metadata from Shopify's GraphQL API response format. * * @internal This class is part of the CLI Migrator feature and should not be used directly. */ class ShopifyMapper implements PlatformMapperInterface { /** * Shopify weight unit to standard unit mapping. * * @var array */ private const WEIGHT_UNIT_MAP = array( 'GRAMS' => 'g', 'KILOGRAMS' => 'kg', 'POUNDS' => 'lb', 'OUNCES' => 'oz', ); /** * Weight conversion factors between units. * Structure: [from_unit][to_unit] = factor * * @var array */ private const WEIGHT_CONVERSION_FACTORS = array( 'kg' => array( 'kg' => 1, 'g' => 1000, 'lb' => 2.20462, 'oz' => 35.274, ), 'g' => array( 'kg' => 0.001, 'g' => 1, 'lb' => 0.00220462, 'oz' => 0.035274, ), 'lb' => array( 'kg' => 0.453592, 'g' => 453.592, 'lb' => 1, 'oz' => 16, ), 'oz' => array( 'kg' => 0.0283495, 'g' => 28.3495, 'lb' => 0.0625, 'oz' => 1, ), ); /** * Fields to process during mapping. * * @var array */ private $fields_to_process = array(); /** * Constructor. * * @param array $args Optional arguments including 'fields' array for selective processing. */ public function __construct( array $args = array() ) { $this->fields_to_process = $args['fields'] ?? $this->get_default_product_fields(); } /** * Maps raw Shopify product data to a standardized array format. * * @param object $shopify_product The raw Shopify product node from GraphQL. * @return array Standardized data array for WooCommerce_Product_Importer. */ public function map_product_data( object $shopify_product ): array { $is_variable = $this->is_variable_product( $shopify_product ); $wc_data = $this->map_basic_product_fields( $shopify_product, $is_variable ); // Map simple product data (for non-variable products). if ( ! $is_variable ) { $simple_data = $this->map_simple_product_data( $shopify_product ); $wc_data = array_merge( $wc_data, $simple_data ); } // Map product images. $wc_data['images'] = $this->map_product_images( $shopify_product ); // Map metafields and SEO data. $wc_data['metafields'] = $this->map_metafields( $shopify_product ); // Map variable product data (attributes and variations). $variable_data = $this->map_variable_product_data( $shopify_product, $is_variable ); $wc_data = array_merge( $wc_data, $variable_data ); return $wc_data; } /** * Checks if a product is a variable product. * * @param object $shopify_product The Shopify product data. * @return bool True if the product is a variable product, false otherwise. */ private function is_variable_product( object $shopify_product ): bool { return isset( $shopify_product->variants->edges ) && count( $shopify_product->variants->edges ) > 1; } /** * Converts the Shopify product status into WooCommerce product status. * * @param object $shopify_product The Shopify product data. * @return string The WooCommerce product status. */ private function get_woo_product_status( object $shopify_product ): string { $woo_product_status = 'draft'; if ( 'ACTIVE' === $shopify_product->status ) { $woo_product_status = 'publish'; } return $woo_product_status; } /** * Maps enhanced publication status fields from Shopify. * * @param object $shopify_product The Shopify product data. * @return array Enhanced status data. */ private function map_enhanced_status( object $shopify_product ): array { $status_data = array(); // Publication date. if ( property_exists( $shopify_product, 'publishedAt' ) && $shopify_product->publishedAt ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase. $status_data['date_published_gmt'] = $shopify_product->publishedAt; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase. } // Available for sale flag. if ( property_exists( $shopify_product, 'availableForSale' ) ) { $status_data['available_for_sale'] = $shopify_product->availableForSale; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase. } return $status_data; } /** * Maps product classification fields from Shopify. * * @param object $shopify_product The Shopify product data. * @return array Product classification data. */ private function map_product_classification( object $shopify_product ): array { $classification = array(); // Product type - check both camelCase and snake_case for compatibility. $product_type = null; if ( property_exists( $shopify_product, 'productType' ) && $shopify_product->productType ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase. $product_type = $shopify_product->productType; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase. } elseif ( property_exists( $shopify_product, 'product_type' ) && $shopify_product->product_type ) { $product_type = $shopify_product->product_type; } if ( $product_type ) { $classification['product_type'] = array( 'name' => $product_type, 'slug' => sanitize_title( $product_type ), ); } // Standard category. if ( property_exists( $shopify_product, 'category' ) && is_object( $shopify_product->category ) ) { $classification['standard_category'] = array( 'name' => $shopify_product->category->name ?? '', 'slug' => sanitize_title( $shopify_product->category->name ?? '' ), ); } // Gift card detection - check both camelCase and snake_case for compatibility. if ( property_exists( $shopify_product, 'isGiftCard' ) ) { $classification['is_gift_card'] = $shopify_product->isGiftCard; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase. } elseif ( property_exists( $shopify_product, 'is_gift_card' ) ) { $classification['is_gift_card'] = $shopify_product->is_gift_card; } if ( property_exists( $shopify_product, 'requiresSellingPlan' ) ) { $classification['requires_subscription'] = $shopify_product->requiresSellingPlan; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase. } elseif ( property_exists( $shopify_product, 'requires_selling_plan' ) ) { $classification['requires_subscription'] = $shopify_product->requires_selling_plan; } return $classification; } /** * Maps SEO fields from Shopify product data. * * @param object $shopify_product The Shopify product data. * @return array SEO metafields data. */ private function map_seo_fields( object $shopify_product ): array { $seo_data = array(); if ( property_exists( $shopify_product, 'seo' ) && is_object( $shopify_product->seo ) ) { if ( ! empty( $shopify_product->seo->title ) ) { $seo_data['global_title_tag'] = $shopify_product->seo->title; } if ( ! empty( $shopify_product->seo->description ) ) { $seo_data['global_description_tag'] = $shopify_product->seo->description; } } return $seo_data; } /** * Gets mapped WooCommerce product categories from Shopify collections. * * @param object $shopify_product The Shopify product data. * @return array Mapped category data. */ private function get_mapped_categories( object $shopify_product ): array { $categories = array(); if ( ! property_exists( $shopify_product, 'collections' ) || empty( $shopify_product->collections->edges ) ) { return $categories; } foreach ( $shopify_product->collections->edges as $collection_edge ) { $collection_node = $collection_edge->node; $categories[] = array( 'name' => wc_clean( $collection_node->title ), 'slug' => sanitize_title( $collection_node->handle ), ); } return $categories; } /** * Gets mapped WooCommerce product tags from Shopify tags. * * @param object $shopify_product The Shopify product data. * @return array Mapped tag data. */ private function get_mapped_tags( object $shopify_product ): array { $tags = array(); if ( empty( $shopify_product->tags ) ) { return $tags; } foreach ( $shopify_product->tags as $tag ) { $trimmed_tag = trim( $tag ); if ( ! empty( $trimmed_tag ) ) { $tags[] = array( 'name' => wc_clean( $trimmed_tag ), 'slug' => sanitize_title( $trimmed_tag ), ); } } return $tags; } /** * Converts weight based on Shopify weight unit to store's weight unit. * * @param float|null $weight The weight value from Shopify. * @param string|null $weight_unit The weight unit from Shopify. * @return float|null The converted weight, or null if input is invalid/zero. */ private function get_converted_weight( $weight, $weight_unit ): ?float { if ( null === $weight || null === $weight_unit || (float) $weight <= 0 ) { return null; } $shopify_unit_key = self::WEIGHT_UNIT_MAP[ $weight_unit ] ?? null; if ( ! $shopify_unit_key ) { return (float) $weight; } $store_weight_unit = get_option( 'woocommerce_weight_unit' ); if ( 'lbs' === $store_weight_unit ) { $store_weight_unit = 'lb'; } if ( $shopify_unit_key === $store_weight_unit ) { return (float) $weight; } // Use wc_get_weight for conversion if possible. if ( function_exists( 'wc_get_weight' ) ) { $converted = wc_get_weight( (float) $weight, $store_weight_unit, $shopify_unit_key ); return is_numeric( $converted ) ? (float) $converted : null; } // Fallback manual conversion using class constants. if ( ! isset( self::WEIGHT_CONVERSION_FACTORS[ $shopify_unit_key ][ $store_weight_unit ] ) ) { return (float) $weight; } return (float) $weight * self::WEIGHT_CONVERSION_FACTORS[ $shopify_unit_key ][ $store_weight_unit ]; } /** * Checks if a specific field should be processed based on constructor args. * * @param string $field_key The field key. * @return bool True if the field should be processed. */ private function should_process( string $field_key ): bool { if ( empty( $this->fields_to_process ) ) { return true; } return in_array( $field_key, $this->fields_to_process, true ); } /** * Maps basic product fields from Shopify to WooCommerce format. * * @param object $shopify_product The Shopify product data. * @param bool $is_variable Whether this is a variable product. * @return array Basic product field mappings. */ private function map_basic_product_fields( object $shopify_product, bool $is_variable ): array { $basic_data = array(); $basic_data['is_variable'] = $is_variable; $basic_data['original_product_id'] = ! empty( $shopify_product->id ) ? basename( $shopify_product->id ) : null; // Basic Product Fields. $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. // Enhanced date handling. if ( property_exists( $shopify_product, 'updatedAt' ) ) { $basic_data['date_modified_gmt'] = $shopify_product->updatedAt; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase. } // Catalog Visibility & Original URL. $basic_data['catalog_visibility'] = 'visible'; $basic_data['original_url'] = null; if ( property_exists( $shopify_product, 'onlineStoreUrl' ) ) { if ( null === $shopify_product->onlineStoreUrl ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase. $basic_data['catalog_visibility'] = 'hidden'; } else { $basic_data['original_url'] = $shopify_product->onlineStoreUrl; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase. } } $enhanced_status = $this->map_enhanced_status( $shopify_product ); $basic_data = array_merge( $basic_data, $enhanced_status ); // Taxonomies. $basic_data['categories'] = $this->get_mapped_categories( $shopify_product ); $basic_data['tags'] = $this->get_mapped_tags( $shopify_product ); // Enhanced product classification. $classification = $this->map_product_classification( $shopify_product ); $basic_data = array_merge( $basic_data, $classification ); // Brand (Vendor). $brand_name = $shopify_product->vendor ?? null; $basic_data['brand'] = $brand_name ? array( 'name' => wc_clean( $brand_name ), 'slug' => sanitize_title( $brand_name ), ) : null; return $basic_data; } /** * Maps simple product data (price, SKU, stock, weight) from Shopify variant. * * @param object $shopify_product The Shopify product data. * @return array Simple product data mappings. */ private function map_simple_product_data( object $shopify_product ): array { $simple_data = array(); if ( ! empty( $shopify_product->variants->edges ) ) { $variant_node = $shopify_product->variants->edges[0]->node; if ( $this->should_process( 'price' ) ) { if ( $variant_node->compareAtPrice && $variant_node->compareAtPrice > $variant_node->price ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase. $simple_data['sale_price'] = $variant_node->price; $simple_data['regular_price'] = $variant_node->compareAtPrice; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase. } else { $simple_data['sale_price'] = null; $simple_data['regular_price'] = $variant_node->price; } } if ( $this->should_process( 'sku' ) ) { $simple_data['sku'] = wc_clean( $variant_node->sku ); } if ( $this->should_process( 'stock' ) ) { $manage_stock = property_exists( $variant_node, 'inventoryItem' ) && $variant_node->inventoryItem->tracked; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase. $simple_data['manage_stock'] = $manage_stock; $stock_quantity = $variant_node->inventoryQuantity ?? 0; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase. $allow_oversell = $manage_stock && 'CONTINUE' === $variant_node->inventoryPolicy; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase. $simple_data['stock_status'] = ( $stock_quantity > 0 || $allow_oversell ) ? 'instock' : 'outofstock'; $simple_data['stock_quantity'] = $stock_quantity; } if ( $this->should_process( 'weight' ) ) { $weight_data = null; if ( property_exists( $variant_node, 'inventoryItem' ) && is_object( $variant_node->inventoryItem ) && // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase. property_exists( $variant_node->inventoryItem, 'measurement' ) && is_object( $variant_node->inventoryItem->measurement ) && // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase. property_exists( $variant_node->inventoryItem->measurement, 'weight' ) && is_object( $variant_node->inventoryItem->measurement->weight ) // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase. ) { $weight_data = $variant_node->inventoryItem->measurement->weight; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase. } $weight = $weight_data ? $weight_data->value : null; $weight_unit = $weight_data ? $weight_data->unit : null; $simple_data['weight'] = $this->get_converted_weight( $weight, $weight_unit ); } if ( property_exists( $variant_node, 'inventoryItem' ) && is_object( $variant_node->inventoryItem ) && // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase. property_exists( $variant_node->inventoryItem, 'unitCost' ) && is_object( $variant_node->inventoryItem->unitCost ) // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase. ) { $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 { $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; } return $simple_data; } /** * Maps variable product data (attributes and variations) from Shopify. * * @param object $shopify_product The Shopify product data. * @param bool $is_variable Whether this is a variable product. * @return array Variable product data mappings. */ private function map_variable_product_data( object $shopify_product, bool $is_variable ): array { $variable_data = array(); // Attributes (Variable Only). $variable_data['attributes'] = array(); if ( $is_variable && property_exists( $shopify_product, 'options' ) && ! empty( $shopify_product->options ) ) { foreach ( $shopify_product->options as $option ) { $variable_data['attributes'][] = array( 'name' => wc_clean( $option->name ), 'options' => array_map( 'wc_clean', $option->values ), 'position' => $option->position, 'is_visible' => true, 'is_variation' => true, ); } } // Variations (Variable Only). $variable_data['variations'] = array(); if ( $is_variable && property_exists( $shopify_product, 'variants' ) && ! empty( $shopify_product->variants->edges ) ) { foreach ( $shopify_product->variants->edges as $variant_edge ) { $variant_node = $variant_edge->node; $variation_data = array(); $variation_data['original_id'] = ! empty( $variant_node->id ) ? basename( $variant_node->id ) : null; if ( $this->should_process( 'price' ) ) { if ( $variant_node->compareAtPrice && (float) $variant_node->compareAtPrice > (float) $variant_node->price ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase. $variation_data['regular_price'] = $variant_node->compareAtPrice; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase. $variation_data['sale_price'] = $variant_node->price; } else { $variation_data['regular_price'] = $variant_node->price; $variation_data['sale_price'] = null; } } if ( $this->should_process( 'sku' ) ) { $variation_data['sku'] = wc_clean( $variant_node->sku ?? '' ); } if ( $this->should_process( 'stock' ) ) { $manage_stock = property_exists( $variant_node, 'inventoryItem' ) && $variant_node->inventoryItem->tracked; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase. $variation_data['manage_stock'] = $manage_stock; $stock_quantity = $variant_node->inventoryQuantity ?? 0; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase. $allow_oversell = $manage_stock && 'CONTINUE' === $variant_node->inventoryPolicy; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase. $variation_data['stock_status'] = ( $stock_quantity > 0 || $allow_oversell ) ? 'instock' : 'outofstock'; $variation_data['stock_quantity'] = $stock_quantity; } if ( $this->should_process( 'weight' ) ) { $weight_data = null; if ( property_exists( $variant_node, 'inventoryItem' ) && is_object( $variant_node->inventoryItem ) && // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase. property_exists( $variant_node->inventoryItem, 'measurement' ) && is_object( $variant_node->inventoryItem->measurement ) && // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase. property_exists( $variant_node->inventoryItem->measurement, 'weight' ) && is_object( $variant_node->inventoryItem->measurement->weight ) // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase. ) { $weight_data = $variant_node->inventoryItem->measurement->weight; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase. } $weight = $weight_data ? $weight_data->value : null; $weight_unit = $weight_data ? $weight_data->unit : null; $variation_data['weight'] = $this->get_converted_weight( $weight, $weight_unit ); } if ( property_exists( $variant_node, 'inventoryItem' ) && is_object( $variant_node->inventoryItem ) && // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase. property_exists( $variant_node->inventoryItem, 'unitCost' ) && is_object( $variant_node->inventoryItem->unitCost ) // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase. ) { $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'][ wc_clean( $selectedOption->name ) ] = wc_clean( $selectedOption->value ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase -- GraphQL uses camelCase. } } } if ( $this->should_process( 'images' ) ) { $variation_data['image_original_id'] = null; if ( ! empty( $variant_node->media->edges ) ) { $variant_media_node = $variant_node->media->edges[0]->node ?? null; if ( $variant_media_node && property_exists( $variant_media_node, 'image' ) && is_object( $variant_media_node->image ) && ! empty( $variant_media_node->id ) ) { $variation_data['image_original_id'] = $variant_media_node->id; } } } // Menu Order / Position. $variation_data['menu_order'] = $variant_node->position; $variable_data['variations'][] = $variation_data; } } return $variable_data; } /** * Maps product images from Shopify media data. * * @param object $shopify_product The Shopify product data. * @return array Product images data. */ private function map_product_images( object $shopify_product ): array { $images_data = array(); $featured_media_id = null; if ( ! empty( $shopify_product->featuredMedia ) && is_object( $shopify_product->featuredMedia ) && ! empty( $shopify_product->featuredMedia->id ) ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase. $featured_media_id = $shopify_product->featuredMedia->id; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL uses camelCase. } if ( ! empty( $shopify_product->media->edges ) ) { foreach ( $shopify_product->media->edges as $media_edge ) { $media_node = $media_edge->node; if ( property_exists( $media_node, 'image' ) && is_object( $media_node->image ) && ! empty( $media_node->id ) && ! empty( $media_node->image->url ) ) { $images_data[] = array( 'original_id' => $media_node->id, 'src' => $media_node->image->url, 'alt' => $media_node->image->altText ?? null, 'is_featured' => ( $media_node->id === $featured_media_id ), ); } } } return $images_data; } /** * Maps metafields and SEO data from Shopify product. * * @param object $shopify_product The Shopify product data. * @return array Metafields data. */ private function map_metafields( object $shopify_product ): array { $metafields_data = array(); if ( property_exists( $shopify_product, 'metafields' ) && ! empty( $shopify_product->metafields->edges ) ) { foreach ( $shopify_product->metafields->edges as $edge ) { $field_node = $edge->node; $key = sprintf( '%s_%s', $field_node->namespace, $field_node->key ); $metafields_data[ $key ] = $field_node->value; } } // Enhanced SEO mapping. $seo_data = $this->map_seo_fields( $shopify_product ); $metafields_data = array_merge( $metafields_data, $seo_data ); return $metafields_data; } /** * Gets the default product fields to process if not specified. * * @return array Default fields. */ private function get_default_product_fields(): array { return array( 'title', 'slug', 'description', 'short_description', 'status', 'date_created', 'catalog_visibility', 'category', 'tag', 'price', 'sku', 'stock', 'weight', 'brand', 'images', 'seo', 'attributes', ); } } Migrator/Platforms/Shopify/ShopifyPlatform.php 0000777 00000002472 15252251042 0015571 0 ustar 00 <?php /** * Shopify Platform Registration * * @package Automattic\WooCommerce\Internal\CLI\Migrator\Platforms\Shopify */ declare( strict_types=1 ); namespace Automattic\WooCommerce\Internal\CLI\Migrator\Platforms\Shopify; defined( 'ABSPATH' ) || exit; /** * ShopifyPlatform class. * * This class handles the registration of the Shopify platform with the * WooCommerce Migrator's platform registry system. */ class ShopifyPlatform { /** * Initializes the Shopify platform registration. * * @internal */ final public static function init(): void { add_filter( 'woocommerce_migrator_platforms', array( self::class, 'register_platform' ) ); } /** * Registers the Shopify platform with the migrator system. * * @param array $platforms Array of registered platforms. * * @return array Updated array of platforms including Shopify. */ public static function register_platform( array $platforms ): array { $platforms['shopify'] = array( 'name' => 'Shopify', 'description' => 'Import products and data from Shopify stores', 'fetcher' => ShopifyFetcher::class, 'mapper' => ShopifyMapper::class, 'credentials' => array( 'shop_url' => 'Enter shop URL (e.g., mystore.myshopify.com):', 'access_token' => 'Enter access token:', ), ); return $platforms; } } Migrator/Platforms/Shopify/ShopifyFetcher.php 0000777 00000022245 15252251042 0015365 0 ustar 00 <?php /** * Shopify Fetcher * * @package Automattic\WooCommerce\Internal\CLI\Migrator\Platforms\Shopify */ declare( strict_types=1 ); namespace Automattic\WooCommerce\Internal\CLI\Migrator\Platforms\Shopify; use Automattic\WooCommerce\Internal\CLI\Migrator\Interfaces\PlatformFetcherInterface; defined( 'ABSPATH' ) || exit; /** * ShopifyFetcher class. * * This class is responsible for fetching data from the Shopify platform. * Uses ShopifyClient for REST API communication and will be extended with * GraphQL API logic in future PRs. */ class ShopifyFetcher implements PlatformFetcherInterface { /** * Comprehensive GraphQL query for fetching Shopify products. * * This query fetches all necessary product data including variants, images, * collections, and metadata for migration to WooCommerce. */ const SHOPIFY_PRODUCT_QUERY = <<<'GRAPHQL' query GetShopifyProducts( $first: Int!, $after: String, $query: String, $variantsFirst: Int = 100 ) { products(first: $first, after: $after, query: $query) { edges { cursor node { id title handle descriptionHtml status createdAt vendor tags onlineStoreUrl options(first: 10) { id name position values } featuredMedia { ... on MediaImage { id image { url altText } } } media(first: 50) { edges { node { ... on MediaImage { id image { url altText } } } } } variants(first: $variantsFirst) { edges { node { id product { id } price compareAtPrice sku taxable inventoryPolicy inventoryQuantity position inventoryItem { tracked unitCost { amount currencyCode } measurement { weight { value unit } } } media(first: 1) { edges { node { ... on MediaImage { id image { url altText } } } } } selectedOptions { name value } } } } collections(first: 20) { edges { node { id handle title } } } metafields(first: 20, namespace: "global") { edges { node { namespace key value } } } } } pageInfo { hasNextPage } } } GRAPHQL; /** * The Shopify client instance. * * @var ShopifyClient */ private $shopify_client; /** * Platform credentials. * * @var array */ private array $credentials; /** * Constructor. * * @param array $credentials Platform credentials array. */ public function __construct( array $credentials ) { $this->credentials = $credentials; $this->shopify_client = new ShopifyClient( $credentials ); } /** * Fetches a batch of products from the Shopify GraphQL API. * * @param array $args Arguments for fetching. Supported keys: * - 'limit': Max number of items per batch (default: 50). * - 'after_cursor': Cursor for pagination (optional). * - 'query_filter': GraphQL query filter string (optional). * - 'variants_per_product': Max variants per product (default: 100). * * @return array An array containing: * 'items' => array Raw product edges fetched from Shopify. * 'cursor' => ?string The cursor for the next page, or null if no more pages. * 'has_next_page' => bool Indicates if there are more pages to fetch. */ public function fetch_batch( array $args ): array { $variables = $this->build_graphql_variables( $args ); $response_data = $this->shopify_client->graphql_request( self::SHOPIFY_PRODUCT_QUERY, $variables ); if ( is_wp_error( $response_data ) ) { \WP_CLI::warning( 'Failed to fetch products via GraphQL: ' . $response_data->get_error_message() ); return array( 'items' => array(), 'cursor' => null, 'has_next_page' => false, ); } if ( ! isset( $response_data->products->edges ) ) { \WP_CLI::warning( 'Invalid GraphQL response structure - missing products.edges field.' ); return array( 'items' => array(), 'cursor' => null, 'has_next_page' => false, ); } $items = $response_data->products->edges; $page_info = $response_data->products->pageInfo ?? null; $last_cursor = null; if ( ! empty( $items ) ) { $last_edge = end( $items ); $last_cursor = $last_edge->cursor ?? null; } return array( 'items' => $items, 'cursor' => $last_cursor, // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- GraphQL response property 'has_next_page' => $page_info ? $page_info->hasNextPage : false, ); } /** * Build GraphQL variables from fetch arguments. * * @param array $args The fetch arguments. * @return array The GraphQL variables. */ private function build_graphql_variables( array $args ): array { $variables = array( 'first' => $args['limit'] ?? 50, 'after' => $args['after_cursor'] ?? null, 'query' => $this->build_graphql_query_string( $args ), 'variantsFirst' => $args['variants_per_product'] ?? 100, ); // Remove null values to avoid GraphQL issues. return array_filter( $variables, function ( $value ) { return null !== $value && '' !== $value; } ); } /** * Build GraphQL query string from filter arguments. * * @param array $args Filter arguments. * @return string GraphQL query string. */ private function build_graphql_query_string( array $args ): string { $query_parts = array(); if ( isset( $args['status'] ) ) { $query_parts[] = 'status:' . strtoupper( $args['status'] ); } if ( isset( $args['product_type'] ) ) { $query_parts[] = 'product_type:"' . $args['product_type'] . '"'; } if ( isset( $args['vendor'] ) ) { $query_parts[] = 'vendor:"' . $args['vendor'] . '"'; } if ( isset( $args['handle'] ) ) { $query_parts[] = 'handle:' . $args['handle']; } if ( isset( $args['created_after'] ) ) { $query_parts[] = 'created_at:>=' . $args['created_after']; } if ( isset( $args['created_before'] ) ) { $query_parts[] = 'created_at:<=' . $args['created_before']; } if ( isset( $args['ids'] ) ) { $ids = is_array( $args['ids'] ) ? $args['ids'] : explode( ',', $args['ids'] ); $ids = array_filter( array_map( 'trim', $ids ) ); if ( ! empty( $ids ) ) { $formatted_ids = array_map( function ( $id ) { return 'gid://shopify/Product/' . $id; }, $ids ); $query_parts[] = 'id:(' . implode( ' OR ', $formatted_ids ) . ')'; } } return implode( ' AND ', $query_parts ); } /** * Fetches the total count of products from the Shopify REST API. * * @param array $args Arguments for filtering the count (e.g., status, date range). * * @return int The total count, or 0 on failure. */ public function fetch_total_count( array $args ): int { // Handle special case: if specific IDs are provided, count them directly. if ( isset( $args['ids'] ) ) { \WP_CLI::debug( 'Calculating total count based on provided product IDs.' ); $ids = is_array( $args['ids'] ) ? $args['ids'] : explode( ',', $args['ids'] ); return count( array_filter( $ids ) ); } $rest_api_path = '/products/count.json'; $query_params = $this->build_count_query_params( $args ); $response = $this->shopify_client->rest_request( $rest_api_path, $query_params ); if ( is_wp_error( $response ) ) { \WP_CLI::warning( 'Could not fetch total product count from Shopify REST API: ' . $response->get_error_message() ); return 0; } if ( ! isset( $response->count ) ) { \WP_CLI::warning( 'Unexpected response format from Shopify count API - missing count field.' ); return 0; } return (int) $response->count; } /** * Build query parameters for the count API request. * * @param array $args Filter arguments. * @return array Query parameters for the REST API. */ private function build_count_query_params( array $args ): array { $query_params = array(); // Map standard filter args to Shopify REST count query params. if ( isset( $args['status'] ) ) { $query_params['status'] = strtolower( $args['status'] ); // REST uses lowercase. } if ( isset( $args['created_at_min'] ) ) { $query_params['created_at_min'] = $args['created_at_min']; } if ( isset( $args['created_at_max'] ) ) { $query_params['created_at_max'] = $args['created_at_max']; } if ( isset( $args['updated_at_min'] ) ) { $query_params['updated_at_min'] = $args['updated_at_min']; } if ( isset( $args['updated_at_max'] ) ) { $query_params['updated_at_max'] = $args['updated_at_max']; } if ( isset( $args['vendor'] ) ) { $query_params['vendor'] = $args['vendor']; } if ( isset( $args['product_type'] ) ) { $query_params['product_type'] = $args['product_type']; } return $query_params; } } Migrator/Commands/ResetCommand.php 0000777 00000003702 15252251042 0013172 0 ustar 00 <?php declare( strict_types=1 ); namespace Automattic\WooCommerce\Internal\CLI\Migrator\Commands; use Automattic\WooCommerce\Internal\CLI\Migrator\Core\CredentialManager; use Automattic\WooCommerce\Internal\CLI\Migrator\Core\PlatformRegistry; use WP_CLI; /** * The command for resetting platform credentials. */ class ResetCommand { /** * The credential manager. * * @var CredentialManager */ private CredentialManager $credential_manager; /** * The platform registry. * * @var PlatformRegistry */ private PlatformRegistry $platform_registry; /** * Initialize the command with its dependencies. * * @param CredentialManager $credential_manager The credential manager. * @param PlatformRegistry $platform_registry The platform registry. * * @internal */ final public function init( CredentialManager $credential_manager, PlatformRegistry $platform_registry ): void { $this->credential_manager = $credential_manager; $this->platform_registry = $platform_registry; } /** * Resets (deletes) the credentials for a given platform. * * ## OPTIONS * * [--platform=<platform>] * : The platform to reset credentials for. Defaults to 'shopify'. * * ## EXAMPLES * * wp wc migrate reset * * @param array $args Positional arguments. * @param array $assoc_args Associative arguments. */ public function __invoke( array $args, array $assoc_args ) { // Resolve and validate the platform. $platform = $this->platform_registry->resolve_platform( $assoc_args ); $platform_display_name = $this->platform_registry->get_platform_display_name( $platform ); if ( ! $this->credential_manager->has_credentials( $platform ) ) { WP_CLI::warning( "No credentials found for '{$platform_display_name}' to reset." ); return; } $this->credential_manager->delete_credentials( $platform ); WP_CLI::success( "Credentials for the '{$platform_display_name}' platform have been cleared." ); } } Migrator/Commands/ProductsCommand.php 0000777 00000015265 15252251042 0013722 0 ustar 00 <?php declare( strict_types=1 ); namespace Automattic\WooCommerce\Internal\CLI\Migrator\Commands; use Automattic\WooCommerce\Internal\CLI\Migrator\Core\CredentialManager; use Automattic\WooCommerce\Internal\CLI\Migrator\Core\PlatformRegistry; use Automattic\WooCommerce\Internal\CLI\Migrator\Core\ProductsController; use WP_CLI; /** * The products command. */ final class ProductsCommand { /** * The credential manager. * * @var CredentialManager */ private CredentialManager $credential_manager; /** * The platform registry. * * @var PlatformRegistry */ private PlatformRegistry $platform_registry; /** * The products controller. * * @var ProductsController */ private ProductsController $products_controller; /** * Initialize the command with its dependencies. * * @param CredentialManager $credential_manager The credential manager. * @param PlatformRegistry $platform_registry The platform registry. * @param ProductsController $products_controller The products controller. * * @internal */ final public function init( CredentialManager $credential_manager, PlatformRegistry $platform_registry, ProductsController $products_controller ): void { // phpcs:ignore Generic.CodeAnalysis.UnnecessaryFinalModifier.Found -- Required by WooCommerce injection method rules $this->credential_manager = $credential_manager; $this->platform_registry = $platform_registry; $this->products_controller = $products_controller; } /** * The main execution logic for the command. * * [--platform=<platform>] * : The platform to migrate products from. * --- * default: shopify * --- * * [--count] * : Only fetch and display the total product count. * * [--limit=<limit>] * : Maximum number of products to migrate. * * [--status=<status>] * : Filter products by status (active, archived, draft). * * [--product-type=<product-type>] * : Filter products by type (for Shopify: any product type name, or 'single'/'variable' for WooCommerce equivalents). * * [--vendor=<vendor>] * : Filter products by vendor name. * * [--ids=<ids>] * : Comma-separated list of product IDs to migrate. * * [--batch-size=<size>] * : Number of products to process per batch (default: 20, max: 250). * * [--fields=<fields>] * : Comma-separated list of fields to migrate. * * [--exclude-fields=<fields>] * : Comma-separated list of fields to exclude from migration. * * [--resume] * : Resume from previous migration session without prompting. * * [--skip-existing] * : Skip products that already exist in WooCommerce. * * [--dry-run] * : Perform a dry run without creating products. * * [--verbose] * : Show detailed progress information including warnings and errors. * * [--assign-default-category] * : Assign WooCommerce default category to products that have no categories. * * ## EXAMPLES * * wp wc migrate products --count * wp wc migrate products --count --status=active * wp wc migrate products --count --product-type="T-Shirt" * wp wc migrate products --count --vendor="My Brand" * wp wc migrate products --limit=100 --batch-size=25 * wp wc migrate products --product-type="single" --status=active --limit=50 * wp wc migrate products --ids="123,456,789" * wp wc migrate products --fields=name,price,sku --resume * wp wc migrate products --verbose --limit=50 * wp wc migrate products --assign-default-category --limit=100 * * @param array $args The positional arguments. * @param array $assoc_args The associative arguments. * * @return void */ public function __invoke( array $args, array $assoc_args ): void { // Resolve and validate the platform. $platform = $this->platform_registry->resolve_platform( $assoc_args ); $platform_display_name = $this->platform_registry->get_platform_display_name( $platform ); if ( ! $this->credential_manager->has_credentials( $platform ) ) { WP_CLI::log( "Credentials for '{$platform_display_name}' not found. Let's set them up." ); // Get platform-specific credential fields and set them up. $required_fields = $this->platform_registry->get_platform_credential_fields( $platform ); if ( empty( $required_fields ) ) { WP_CLI::error( "The platform '{$platform_display_name}' does not have configured credential fields." ); return; } $this->credential_manager->setup_credentials( $platform, $required_fields ); WP_CLI::success( 'Credentials saved successfully. Please run the command again to begin the migration.' ); return; } // Handle count request if specified. if ( isset( $assoc_args['count'] ) ) { $this->handle_count_request( $platform, $platform_display_name, $assoc_args ); return; } // Delegate actual migration logic to ProductsController with resolved platform. $this->products_controller->migrate_products( $assoc_args, $platform ); } /** * Handle the count request. * * @param string $platform The platform name. * @param string $platform_display_name The platform display name. * @param array $assoc_args The associative arguments. */ private function handle_count_request( string $platform, string $platform_display_name, array $assoc_args ): void { WP_CLI::log( "Fetching product count from {$platform_display_name}..." ); $fetcher = $this->platform_registry->get_fetcher( $platform ); if ( ! $fetcher ) { WP_CLI::error( "Could not get fetcher for platform '{$platform_display_name}'" ); return; } // Build filter arguments. $filter_args = array(); if ( isset( $assoc_args['status'] ) ) { $filter_args['status'] = $assoc_args['status']; } if ( isset( $assoc_args['product-type'] ) ) { $filter_args['product_type'] = $assoc_args['product-type']; } if ( isset( $assoc_args['vendor'] ) ) { $filter_args['vendor'] = $assoc_args['vendor']; } if ( isset( $assoc_args['ids'] ) ) { $filter_args['ids'] = $assoc_args['ids']; } $count = $fetcher->fetch_total_count( $filter_args ); if ( 0 === $count ) { WP_CLI::log( 'No products found or unable to fetch count.' ); } else { $filters = array(); if ( isset( $assoc_args['status'] ) ) { $filters[] = "status '{$assoc_args['status']}'"; } if ( isset( $assoc_args['product-type'] ) ) { $filters[] = "type '{$assoc_args['product-type']}'"; } if ( isset( $assoc_args['vendor'] ) ) { $filters[] = "vendor '{$assoc_args['vendor']}'"; } if ( isset( $assoc_args['ids'] ) ) { $filters[] = "IDs '{$assoc_args['ids']}'"; } $filter_description = empty( $filters ) ? '' : ' with ' . implode( ', ', $filters ); WP_CLI::success( "Found {$count} products{$filter_description} on {$platform_display_name}." ); } } } Migrator/Commands/ListCommand.php 0000777 00000004022 15252251042 0013017 0 ustar 00 <?php declare( strict_types=1 ); namespace Automattic\WooCommerce\Internal\CLI\Migrator\Commands; use Automattic\WooCommerce\Internal\CLI\Migrator\Core\PlatformRegistry; use WP_CLI; /** * Lists all registered migration platforms. */ class ListCommand { /** * The platform registry. * * @var PlatformRegistry */ private PlatformRegistry $platform_registry; /** * Initialize the command with its dependencies. * * @param PlatformRegistry $platform_registry The platform registry. * * @internal */ final public function init( PlatformRegistry $platform_registry ): void { $this->platform_registry = $platform_registry; } /** * Lists all registered migration platforms. * * ## EXAMPLES * * $ wp wc migrate list * * @param array $args The positional arguments (unused). * @param array $assoc_args The associative arguments (unused). * * @return void */ public function __invoke( array $args, array $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed unset( $args, $assoc_args ); $platforms = $this->platform_registry->get_platforms(); if ( empty( $platforms ) ) { WP_CLI::line( 'No migration platforms are registered.' ); return; } $formatted_items = array(); $platform_count = count( $platforms ); $current_index = 0; foreach ( $platforms as $id => $details ) { $formatted_items[] = array( 'id' => $id, 'name' => $details['name'] ?? '', 'fetcher' => $details['fetcher'] ?? '', 'mapper' => $details['mapper'] ?? '', ); // Add separator row between platforms (but not after the last one). ++$current_index; if ( $current_index < $platform_count ) { $formatted_items[] = array( 'id' => str_repeat( '-', 20 ), 'name' => str_repeat( '-', 25 ), 'fetcher' => str_repeat( '-', 30 ), 'mapper' => str_repeat( '-', 30 ), ); } } WP_CLI\Utils\format_items( 'table', $formatted_items, array( 'id', 'name', 'fetcher', 'mapper' ) ); } } Migrator/Commands/SetupCommand.php 0000777 00000004061 15252251042 0013207 0 ustar 00 <?php declare( strict_types=1 ); namespace Automattic\WooCommerce\Internal\CLI\Migrator\Commands; use Automattic\WooCommerce\Internal\CLI\Migrator\Core\CredentialManager; use Automattic\WooCommerce\Internal\CLI\Migrator\Core\PlatformRegistry; use WP_CLI; /** * The command for interactively setting up platform credentials. */ class SetupCommand { /** * The credential manager. * * @var CredentialManager */ private CredentialManager $credential_manager; /** * The platform registry. * * @var PlatformRegistry */ private PlatformRegistry $platform_registry; /** * Initialize the command with its dependencies. * * @param CredentialManager $credential_manager The credential manager. * @param PlatformRegistry $platform_registry The platform registry. * * @internal */ final public function init( CredentialManager $credential_manager, PlatformRegistry $platform_registry ): void { $this->credential_manager = $credential_manager; $this->platform_registry = $platform_registry; } /** * Sets up the credentials for a given platform. * * ## OPTIONS * * [--platform=<platform>] * : The platform to set up credentials for. Defaults to 'shopify'. * * ## EXAMPLES * * wp wc migrate setup * * @param array $args Positional arguments. * @param array $assoc_args Associative arguments. */ public function __invoke( array $args, array $assoc_args ) { // Resolve and validate the platform. $platform = $this->platform_registry->resolve_platform( $assoc_args ); $platform_display_name = $this->platform_registry->get_platform_display_name( $platform ); // Get platform-specific credential fields and set them up. $required_fields = $this->platform_registry->get_platform_credential_fields( $platform ); if ( empty( $required_fields ) ) { WP_CLI::error( "The platform '{$platform_display_name}' does not have configured credential fields." ); } $this->credential_manager->setup_credentials( $platform, $required_fields ); WP_CLI::success( 'Credentials saved successfully.' ); } }
| ver. 1.6 |
Github
|
.
| PHP 8.1.34 | Генерация страницы: 0 |
proxy
|
phpinfo
|
Настройка