5 * MINUTE_IN_SECONDS, 'display' => 'Every 5 minutes (6rsh product sync)', ); } return $schedules; } /** * Keep exactly one recurring event scheduled. * * @return void */ public static function ensure_scheduled(): void { if ( ! wp_next_scheduled( self::HOOK ) ) { wp_schedule_event( time() + MINUTE_IN_SECONDS, self::SCHEDULE, self::HOOK ); } } /** * Remove the event (deactivation / kill switch). * * @return void */ public static function deactivate(): void { wp_clear_scheduled_hook( self::HOOK ); } // ── Queueing ──────────────────────────────────────────────────────────── /** * Queue a product/variation id. * * @param mixed $id Product id. * * @return void */ public static function queue_id( $id ): void { $id = absint( $id ); if ( ! $id ) { return; } $queue = self::get_list( self::OPT_QUEUE ); if ( count( $queue ) >= self::QUEUE_CAP ) { // A bulk edit of thousands of products: walking the catalogue again // is cheaper than tracking every id. update_option( self::OPT_CURSOR, 0, false ); update_option( self::OPT_QUEUE, array(), false ); return; } $queue[ $id ] = 1; update_option( self::OPT_QUEUE, $queue, false ); } /** * Queue from a hook that passes a WC_Product. * * @param mixed $product Product object. * * @return void */ public static function queue_product( $product ): void { if ( is_object( $product ) && method_exists( $product, 'get_id' ) ) { self::queue_id( $product->get_id() ); } } /** * Queue a deletion when a product is trashed or deleted. * * @param mixed $post_id Post id. * * @return void */ public static function queue_delete( $post_id ): void { $post_id = absint( $post_id ); $type = $post_id ? get_post_type( $post_id ) : ''; if ( 'product' !== $type && 'product_variation' !== $type ) { return; } $deletes = self::get_list( self::OPT_DELETES ); $deletes[ $post_id ] = 1; update_option( self::OPT_DELETES, $deletes, false ); $queue = self::get_list( self::OPT_QUEUE ); unset( $queue[ $post_id ] ); update_option( self::OPT_QUEUE, $queue, false ); } /** * Read an id => 1 option as an array. * * @param string $option Option name. * * @return array */ private static function get_list( string $option ): array { $v = get_option( $option, array() ); return is_array( $v ) ? $v : array(); } // ── The recurring run ─────────────────────────────────────────────────── /** * Push queued deletes, queued changes, then the next backfill step. * * @return void */ public static function run(): void { try { if ( ! self::enabled() || ! function_exists( 'wc_get_product' ) ) { return; } $api = SixRsh_API::instance(); if ( ! $api ) { return; } // 1 — deletions. $deletes = self::get_list( self::OPT_DELETES ); if ( $deletes ) { $ids = array_slice( array_keys( $deletes ), 0, self::BATCH ); $res = $api->delete_products( array_map( 'strval', $ids ) ); if ( ! empty( $res['success'] ) ) { update_option( self::OPT_DELETES, array_diff_key( $deletes, array_flip( $ids ) ), false ); } } $budget = self::MAX_PER_RUN; // 2 — changed products. $queue = self::get_list( self::OPT_QUEUE ); if ( $queue ) { $taken = array(); $rows = array(); foreach ( array_keys( $queue ) as $id ) { if ( count( $rows ) >= $budget ) { break; } $taken[] = $id; $rows = array_merge( $rows, self::rows_for( (int) $id ) ); } if ( self::push( $api, $rows ) ) { update_option( self::OPT_QUEUE, array_diff_key( self::get_list( self::OPT_QUEUE ), array_flip( $taken ) ), false ); $budget -= count( $rows ); } else { return; // Server unreachable: retry everything next run. } } // 3 — backfill the existing catalogue, oldest id first. $cursor = get_option( self::OPT_CURSOR, 0 ); if ( 'done' !== $cursor && $budget > 0 ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery $ids = $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type = 'product' AND post_status = 'publish' AND ID > %d ORDER BY ID ASC LIMIT %d", (int) $cursor, self::BACKFILL_STEP ) ); if ( ! $ids ) { update_option( self::OPT_CURSOR, 'done', false ); } else { $rows = array(); foreach ( $ids as $id ) { $rows = array_merge( $rows, self::rows_for( (int) $id ) ); } if ( self::push( $api, $rows ) ) { update_option( self::OPT_CURSOR, (int) end( $ids ), false ); } } } update_option( self::OPT_LAST_RUN, time(), false ); } catch ( \Throwable $e ) { if ( class_exists( 'SixRsh_Logger' ) ) { SixRsh_Logger::log( 'product_sync', '', '', 'failed', $e->getMessage() ); } } } /** * Send rows in server-sized batches. True when every batch was accepted * (an empty list counts as success). * * @param SixRsh_API $api Client. * @param array $rows Product rows. * * @return bool */ private static function push( $api, array $rows ): bool { foreach ( array_chunk( $rows, self::BATCH ) as $chunk ) { $res = $api->upsert_products( $chunk ); if ( empty( $res['success'] ) ) { return false; } } return true; } // ── Payload ───────────────────────────────────────────────────────────── /** * Rows for one product id: the product itself plus, for a variable * product, each enabled variation. A product that is no longer published * becomes a deletion instead. * * @param int $id Product or variation id. * * @return array */ public static function rows_for( int $id ): array { $product = wc_get_product( $id ); if ( ! $product ) { return array(); } if ( $product->is_type( 'variation' ) ) { $parent = wc_get_product( $product->get_parent_id() ); return ( $parent && 'publish' === $parent->get_status() && 'publish' === $product->get_status() ) ? array( self::row( $product, $parent ) ) : array(); } if ( 'publish' !== $product->get_status() ) { $deletes = self::get_list( self::OPT_DELETES ); $deletes[ $id ] = 1; update_option( self::OPT_DELETES, $deletes, false ); return array(); } $rows = array( self::row( $product, null ) ); if ( $product->is_type( 'variable' ) ) { foreach ( $product->get_children() as $child_id ) { $child = wc_get_product( $child_id ); if ( $child && 'publish' === $child->get_status() ) { $rows[] = self::row( $child, $product ); } } } return $rows; } /** * One API row. * * @param WC_Product $p Product or variation. * @param WC_Product|null $parent Parent for a variation. * * @return array */ public static function row( $p, $parent ): array { $source = $parent ? $parent : $p; $categories = array(); foreach ( (array) $source->get_category_ids() as $term_id ) { $term = get_term( $term_id, 'product_cat' ); if ( $term && ! is_wp_error( $term ) ) { $categories[] = html_entity_decode( $term->name, ENT_QUOTES, 'UTF-8' ); } } $attributes = array(); if ( $parent && method_exists( $p, 'get_variation_attributes' ) ) { foreach ( $p->get_variation_attributes() as $key => $value ) { $taxonomy = str_replace( 'attribute_', '', $key ); $label = $value; if ( taxonomy_exists( $taxonomy ) ) { $term = get_term_by( 'slug', $value, $taxonomy ); $label = $term ? $term->name : $value; } $attributes[ $key ] = (string) $label; } } $price = $p->get_price(); $regular = $p->get_regular_price(); $sale = $p->get_sale_price(); $image_id = $p->get_image_id() ? $p->get_image_id() : $source->get_image_id(); $modified = $p->get_date_modified(); return array( 'external_id' => (string) $p->get_id(), 'parent_external_id' => $parent ? (string) $parent->get_id() : null, 'type' => $p->get_type(), 'name' => html_entity_decode( wp_strip_all_tags( $p->get_name() ), ENT_QUOTES, 'UTF-8' ), 'sku' => $p->get_sku() ? (string) $p->get_sku() : null, 'price' => '' !== $price ? (float) $price : null, 'regular_price' => '' !== $regular ? (float) $regular : null, 'sale_price' => '' !== $sale ? (float) $sale : null, 'currency' => get_woocommerce_currency(), 'stock_status' => $p->get_stock_status(), 'stock_quantity' => $p->managing_stock() ? (int) $p->get_stock_quantity() : null, 'permalink' => $parent ? get_permalink( $parent->get_id() ) : get_permalink( $p->get_id() ), 'image_url' => $image_id ? wp_get_attachment_image_url( $image_id, 'woocommerce_thumbnail' ) : null, 'categories' => array_slice( $categories, 0, 20 ), 'attributes' => $attributes ? $attributes : null, 'short_description' => $parent ? null : mb_substr( trim( wp_strip_all_tags( $p->get_short_description() ) ), 0, 2000 ), 'status' => 'publish', 'updated_at' => $modified ? $modified->date( 'c' ) : null, ); } }