*/ public static function schema() { $samples = array(); foreach ( self::products_page( 1, 20 )['products'] as $product ) { $samples[] = self::entity( $product ); } return array( 'entity' => 'product', 'fields' => array( array( 'key' => 'productId', 'label' => 'Product ID', ), array( 'key' => 'sku', 'label' => 'SKU', ), array( 'key' => 'slug', 'label' => 'Slug', ), array( 'key' => 'title', 'label' => 'Title', ), ), 'sampleEntities' => $samples, ); } /** * Queue a sync soon (debounced: one pending sync at a time). * * @param string $reason Why (connect, settings, product, manual). * @param int $delay Seconds from now. * @return void */ public static function queue( $reason, $delay = 0 ) { if ( ! function_exists( 'as_schedule_single_action' ) || null === Connection::token() ) { return; } if ( as_has_scheduled_action( self::ACTION_SYNC, array(), self::GROUP ) ) { return; } as_schedule_single_action( time() + max( 0, (int) $delay ), self::ACTION_SYNC, array(), self::GROUP ); self::set_status( array( 'state' => 'queued', 'reason' => sanitize_key( $reason ), 'requestedAt' => time(), ) ); } /** * A product changed: re-sync in two minutes (bulk edits coalesce). * * @return void */ public function product_changed() { self::queue( 'product', 2 * MINUTE_IN_SECONDS ); } /** * A trashed product came back. * * @param int $post_id Post ID. * @return void */ public function maybe_product_untrashed( $post_id ) { if ( 'product' === get_post_type( $post_id ) ) { $this->product_changed(); } } /** * Keep one daily safety-net sync scheduled while connected. * * @return void */ public function ensure_daily() { if ( ! function_exists( 'as_has_scheduled_action' ) || null === Connection::token() ) { return; } if ( ! as_has_scheduled_action( self::ACTION_DAY, array(), self::GROUP ) ) { as_schedule_recurring_action( strtotime( 'tomorrow 03:00' ), DAY_IN_SECONDS, self::ACTION_DAY, array(), self::GROUP ); } } /** * Begin a sweep at page 1. * * @return void */ public function start_sweep() { delete_option( self::SWEEP ); self::set_status( array( 'state' => 'running', 'startedAt' => time(), 'error' => '', ) ); $this->run_page( 1 ); } /** * Push one page; chain the next. * * @param int $page 1-based page. * @return void */ public function run_page( $page ) { $page = max( 1, (int) $page ); $sweep = get_option( self::SWEEP, array() ); $sweep = is_array( $sweep ) ? $sweep : array(); $batch = self::products_page( $page, self::PAGE_SIZE ); $entities = array_map( array( __CLASS__, 'entity' ), $batch['products'] ); $final = $page >= $batch['pages'] || $page >= self::MAX_PAGES; $body = array( 'entities' => $entities, 'final' => $final, ); if ( $page > 1 && ! empty( $sweep['id'] ) ) { $body['sweepId'] = (string) $sweep['id']; } $result = Api::request( 'POST', '/catalog', $body, null, 60 ); if ( ! Api::ok( $result ) ) { delete_option( self::SWEEP ); self::set_status( array( 'state' => 'error', 'error' => Api::error_message( $result ), 'finishedAt' => time(), ) ); if ( ! is_wp_error( $result ) && 409 === $result['status'] ) { self::queue( 'retry', 10 * MINUTE_IN_SECONDS ); // Another sweep held the lock. } return; } $response = $result['body']; $unresolved = isset( $response['page']['unresolvedValues'] ) && is_array( $response['page']['unresolvedValues'] ) ? $response['page']['unresolvedValues'] : array(); $sample = array_slice( array_merge( (array) ( $sweep['sample'] ?? array() ), array_map( 'strval', $unresolved ) ), 0, 20 ); $products = (int) ( $sweep['products'] ?? 0 ) + count( $entities ); if ( ! $final ) { update_option( self::SWEEP, array( 'id' => isset( $response['sweepId'] ) ? sanitize_text_field( (string) $response['sweepId'] ) : '', 'products' => $products, 'sample' => $sample, ), false ); as_enqueue_async_action( self::ACTION_PAGE, array( $page + 1 ), self::GROUP ); return; } delete_option( self::SWEEP ); $totals = isset( $response['totals'] ) && is_array( $response['totals'] ) ? $response['totals'] : array(); self::set_status( array( 'state' => 'ok', 'finishedAt' => time(), 'products' => $products, 'compiled' => (int) ( $totals['compiled'] ?? 0 ), 'unresolved' => (int) ( $totals['unresolved'] ?? 0 ), 'deleted' => (int) ( $totals['deleted'] ?? 0 ), 'unresolvedSample' => $sample, 'error' => '', ) ); } /** * One page of published products. * * @param int $page 1-based page. * @param int $limit Page size. * @return array{products:\WC_Product[],pages:int} */ private static function products_page( $page, $limit ) { $result = wc_get_products( array( 'status' => 'publish', 'limit' => $limit, 'page' => $page, 'orderby' => 'ID', 'order' => 'ASC', 'paginate' => true, ) ); return array( 'products' => is_object( $result ) ? (array) $result->products : array(), 'pages' => is_object( $result ) ? max( 1, (int) $result->max_num_pages ) : 1, ); } /** * A product as Thridify context (identifiers only; empty fields omitted). * * @param \WC_Product $product Product. * @return array */ public static function entity( $product ) { $entity = array( 'productId' => (string) $product->get_id(), 'sku' => (string) $product->get_sku(), 'slug' => (string) $product->get_slug(), 'title' => wp_strip_all_tags( (string) $product->get_name() ), ); return array_filter( $entity, 'strlen' ); } /** * Last sync outcome. * * @return array */ public static function status() { $status = get_option( self::STATUS, array() ); return is_array( $status ) ? $status : array(); } /** * Merge into the status record. * * @param array $fields Fields. * @return void */ private static function set_status( array $fields ) { update_option( self::STATUS, array_merge( self::status(), $fields ), false ); } /** * Clear the status (after disconnect). * * @return void */ public static function reset_status() { delete_option( self::STATUS ); delete_option( self::SWEEP ); self::unschedule(); } /** * Cancel every scheduled sync. * * @return void */ public static function unschedule() { if ( function_exists( 'as_unschedule_all_actions' ) ) { as_unschedule_all_actions( self::ACTION_SYNC, array(), self::GROUP ); as_unschedule_all_actions( self::ACTION_DAY, array(), self::GROUP ); as_unschedule_all_actions( self::ACTION_PAGE, null, self::GROUP ); } } }