settings = $settings; $this->reader = $reader; $this->http = $http; $this->progress = $progress; } public function sync(): SyncResult { $timestamp = time(); $apiUrl = $this->settings->apiUrl(); $apiKey = $this->settings->apiKey(); if ( $apiUrl === '' ) { return $this->fail( \__( 'API URL is not configured.', 'agent001-support-assistant-for-woocommerce' ), 0, $timestamp ); } if ( $apiKey === '' ) { return $this->fail( \__( 'API key is not configured.', 'agent001-support-assistant-for-woocommerce' ), 0, $timestamp ); } $categoryIds = $this->settings->syncCategories(); $batchSize = $this->batchSize(); $maxProducts = $this->maxProducts(); $total = $this->reader->count( $categoryIds ); if ( $this->progress !== null ) { $this->progress->setTotal( $total ); } if ( $total > $maxProducts || $total > $batchSize * self::MAX_BATCHES ) { return $this->fail( \__( 'The catalog exceeds the full synchronization limit. Select fewer product categories or increase the synchronization limit.', 'agent001-support-assistant-for-woocommerce' ), 0, $timestamp ); } $storeContext = $this->reader->readStoreContext(); $sessionId = $this->generateSessionId(); $afterId = 0; $sent = 0; $batchIdx = 0; do { // One extra row distinguishes a complete catalog from an exact-size batch. $batch = $this->reader->readBatchAfterId( $afterId, $batchSize + 1, $categoryIds ); $isLast = count( $batch ) <= $batchSize; $batch = array_slice( $batch, 0, $batchSize ); $nextCount = $sent + count( $batch ); if ( $nextCount > $maxProducts || ( ! $isLast && ( $nextCount >= $maxProducts || $batchIdx + 1 >= self::MAX_BATCHES ) ) ) { return $this->fail( \__( 'The catalog changed during synchronization and exceeded the synchronization limit. Please try again with fewer categories.', 'agent001-support-assistant-for-woocommerce' ), $sent, $timestamp ); } if ( $batch !== [] ) { $nextId = (int) end( $batch )['id']; if ( $nextId <= $afterId ) { return $this->fail( \__( 'Could not continue reading the product catalog. Please try again.', 'agent001-support-assistant-for-woocommerce' ), $sent, $timestamp ); } $afterId = $nextId; } $result = $this->postBatch( $apiUrl, $apiKey, $sessionId, $batchIdx, $isLast, $batch, $batchIdx === 0 ? $storeContext : [], $timestamp ); if ( ! $result->success ) { return $this->fail( $result->message, $sent, $timestamp ); } $sent += count( $batch ); if ( $this->progress !== null ) { $this->progress->tickReading( $sent ); } if ( $isLast ) { break; } } while ( true ); $message = sprintf( /* translators: %d: number of products sent for indexing */ \_n( 'Sent %d product for indexing.', 'Sent %d products for indexing.', $sent, 'agent001-support-assistant-for-woocommerce' ), $sent ); if ( $this->progress !== null ) { $this->progress->indexing( $sessionId, $sent ); } return new SyncResult( true, $message, $sent, $batchIdx, $timestamp ); } private function batchSize(): int { $size = (int) \apply_filters( 'agent001_sync_batch_size', self::DEFAULT_BATCH_SIZE ); return max( 1, min( $size, 5000 ) ); } private function maxProducts(): int { $max = (int) \apply_filters( 'agent001_sync_max_products', self::DEFAULT_MAX_PRODUCTS ); return max( 1, $max ); } private function maxBodyBytes(): int { return max( 65536, (int) \apply_filters( 'agent001_sync_max_body_bytes', self::DEFAULT_MAX_BODY_BYTES ) ); } private function generateSessionId(): string { try { return \bin2hex( \random_bytes( 16 ) ); } catch ( \Throwable $e ) { return \wp_generate_password( 32, false, false ); } } /** * @param array> $batch * @param array> $storeContext * @return object{success: bool, message: string} */ private function postBatch( string $apiUrl, string $apiKey, string $sessionId, int &$batchIndex, bool $isLast, array $batch, array $storeContext, int $timestamp ): object { $payload = [ 'site' => \home_url(), 'timestamp' => \gmdate( 'c', $timestamp ), 'version' => \defined( 'AGENT001_VERSION' ) ? (string) \AGENT001_VERSION : '', 'session_id' => $sessionId, 'batch_index' => $batchIndex, 'is_first' => $batchIndex === 0, 'is_last' => $isLast, 'products' => $batch, 'store_context' => $storeContext, ]; $body = \wp_json_encode( $payload ); if ( $body === false ) { return (object) [ 'success' => false, 'message' => \__( 'Failed to encode payload as JSON.', 'agent001-support-assistant-for-woocommerce' ), ]; } if ( strlen( $body ) > $this->maxBodyBytes() ) { if ( count( $batch ) <= 1 ) { return (object) [ 'success' => false, 'message' => \__( 'A product is too large to synchronize. Shorten its description or reduce the number of variations.', 'agent001-support-assistant-for-woocommerce' ) . ' ID: ' . (int) ( $batch[0]['id'] ?? 0 ), ]; } $half = intdiv( count( $batch ), 2 ); $first = $this->postBatch( $apiUrl, $apiKey, $sessionId, $batchIndex, false, array_slice( $batch, 0, $half ), $storeContext, $timestamp ); if ( ! $first->success ) { return $first; } return $this->postBatch( $apiUrl, $apiKey, $sessionId, $batchIndex, $isLast, array_slice( $batch, $half ), [], $timestamp ); } $result = $this->http->postJson( $apiUrl, [ 'Content-Type' => 'application/json', 'X-API-Key' => $apiKey, 'X-Agent001-Batch' => $batchIndex . '/' . ( $isLast ? 'final' : 'partial' ), 'X-Agent001-Session' => $sessionId, 'Accept' => 'application/json', 'User-Agent' => 'Agent001/' . ( \defined( 'AGENT001_VERSION' ) ? \AGENT001_VERSION : 'dev' ), ], $body ); if ( ! $result->isSuccess() ) { return (object) [ 'success' => false, 'message' => $result->error(), ]; } $batchIndex++; return (object) [ 'success' => true, 'message' => '' ]; } private function fail( string $message, int $rowCount, int $timestamp ): SyncResult { if ( $this->progress !== null ) { $this->progress->finish( false, $message, $rowCount ); } return new SyncResult( false, $message, $rowCount, 0, $timestamp ); } }