PluginProbe
Live Chat & AI Chatbot – onWebChat / 3.9.0
Live Chat & AI Chatbot – onWebChat v3.9.0
3.9.2 3.9.3 3.9.1 3.9.0 3.8.4 3.8.2 3.8.1 3.8.0 3.7.2 3.7.1 3.7.0 3.6.0 3.5.5 trunk 1.0 1.0.1 1.0.10 1.0.11 1.0.12 1.0.13 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 All 48 releases
onwebchat / includes / woocommerce-sync.php

woocommerce-sync.php in Live Chat & AI Chatbot – onWebChat 3.9.0, at includes/woocommerce-sync.php

1,819 lines 72.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WooCommerce Product Sync Module
4 * Syncs WooCommerce products to onWebChat for AI bot training
5 */
6
7 if (!defined('ABSPATH')) {
8 exit; // Exit if accessed directly
9 }
10
11 class OnWebChat_WooCommerce_Sync {
12
13 private $api_endpoint_prod = 'https://www.onwebchat.com/api/integrations/woocommerce';
14 private $api_endpoint_dev = 'http://127.0.0.1:81/api/integrations/woocommerce';
15 private $max_description_length = 1500;
16 // Higher budget for the "short + full" mode: both descriptions together.
17 // Must stay below the server's MAX_PRODUCT_TEXT_LENGTH, which also holds
18 // the name, SKU, categories, options, price and URL.
19 private $max_description_length_combined = 3000;
20 private $batch_size = 50;
21 private $use_testing_mode;
22
23 // Large-catalogue sync scope.
24 // Stores with more than CATEGORY_SELECT_THRESHOLD published products get a
25 // category picker so the merchant can choose what to sync. The sync is
26 // hard-capped at MAX_SYNC_PRODUCTS so we never try to embed an unbounded
27 // catalogue. Above the cap a category selection is required.
28 const CATEGORY_SELECT_THRESHOLD = 2000;
29 const MAX_SYNC_PRODUCTS = 15000;
30
31 /**
32 * Get the API endpoint based on testing mode
33 * @return string
34 */
35 private function get_api_endpoint() {
36 return $this->use_testing_mode ? $this->api_endpoint_dev : $this->api_endpoint_prod;
37 }
38
39 public function __construct() {
40 // Read testing mode from global constant (defined in onwebchat.php)
41 $this->use_testing_mode = defined('ONWEBCHAT_WC_TESTING_MODE') ? ONWEBCHAT_WC_TESTING_MODE : false;
42
43 // One-time migration to the "short + full" default. The Description Mode
44 // selector was hidden in the UI before this version, so a stored
45 // 'short_fallback_full' was the hidden form field's value, never a real
46 // merchant choice. An explicit 'short_only' is left untouched.
47 if (!get_option('onwebchat_wc_desc_mode_migrated')) {
48 if (get_option('onwebchat_wc_sync_mode', 'short_plus_full') === 'short_fallback_full') {
49 update_option('onwebchat_wc_sync_mode', 'short_plus_full');
50 }
51 update_option('onwebchat_wc_desc_mode_migrated', 1);
52 }
53 // Initialize settings
54 add_action('admin_init', array($this, 'register_settings'));
55
56 // Show authentication error notice globally (not just on WooCommerce tab)
57 add_action('admin_notices', array($this, 'show_auth_error_notice'));
58
59 // Product hooks - use WooCommerce hooks that fire AFTER meta data is saved
60 add_action('woocommerce_update_product', array($this, 'on_product_update'), 10, 1);
61 add_action('woocommerce_new_product', array($this, 'on_product_update'), 10, 1);
62
63 // Lightweight availability hook: fires whenever a product's stock STATUS flips
64 // (including order-driven stock reductions that may not trigger a full product save).
65 // It pushes only the in/out-of-stock boolean to onWebChat, which updates it without
66 // re-embedding. Variations are intentionally not hooked: WooCommerce recomputes the
67 // parent product's stock status from its variations and fires this action for the
68 // parent, which is the entity synced to onWebChat.
69 add_action('woocommerce_product_set_stock_status', array($this, 'on_stock_status_change'), 10, 3);
70
71 // Scheduled sales: WooCommerce's daily wc_scheduled_sales cron flips sale
72 // prices via direct meta updates, NOT through a product save, so
73 // woocommerce_update_product never fires and the AI would keep quoting the
74 // pre-sale price. These two actions receive the affected product/variation
75 // IDs right after the cron applies or removes the sale prices.
76 add_action('wc_after_products_starting_sales', array($this, 'on_scheduled_sales'), 10, 1);
77 add_action('wc_after_products_ending_sales', array($this, 'on_scheduled_sales'), 10, 1);
78
79 // Variation price edits (the Variations tab saves via AJAX without always
80 // re-saving the parent post). Collect the parent IDs and sync each parent
81 // once on shutdown, so the synced price range follows variation changes.
82 add_action('woocommerce_update_product_variation', array($this, 'on_variation_update'), 10, 1);
83 add_action('woocommerce_save_product_variation', array($this, 'on_variation_update'), 10, 1);
84
85 // Handle product deletion (both trash and permanent delete)
86 add_action('wp_trash_post', array($this, 'on_product_trash'), 10, 1);
87 add_action('before_delete_post', array($this, 'on_product_delete'), 10, 2);
88
89 // Bulk sync via WP Cron
90 add_action('onwebchat_wc_bulk_sync_batch', array($this, 'process_bulk_sync_batch'));
91
92 // Admin AJAX handlers
93 add_action('wp_ajax_onwebchat_wc_sync_now', array($this, 'ajax_sync_existing_products'));
94 // Client-driven chunked bulk sync: the browser starts a run, then calls
95 // the batch action repeatedly (one page per request) until it completes.
96 // This replaces the single long request that timed out on large
97 // catalogues and reported a false "sync failed" while products kept
98 // syncing.
99 add_action('wp_ajax_onwebchat_wc_sync_start', array($this, 'ajax_start_bulk_sync'));
100 add_action('wp_ajax_onwebchat_wc_sync_batch', array($this, 'ajax_sync_next_batch'));
101 add_action('wp_ajax_onwebchat_wc_regenerate_secret', array($this, 'ajax_regenerate_secret'));
102 add_action('wp_ajax_onwebchat_wc_reset_sync_status', array($this, 'ajax_reset_sync_status'));
103 add_action('wp_ajax_onwebchat_wc_connect', array($this, 'ajax_connect_woocommerce'));
104 add_action('wp_ajax_onwebchat_wc_manual_process_batch', array($this, 'ajax_manual_process_batch'));
105 add_action('wp_ajax_onwebchat_wc_get_sync_status', array($this, 'ajax_get_sync_status'));
106 add_action('wp_ajax_onwebchat_wc_save_sync_enabled', array($this, 'ajax_save_sync_enabled'));
107 }
108
109 /**
110 * AJAX: Connect WooCommerce with authentication
111 */
112 public function ajax_connect_woocommerce() {
113 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
114
115 if (!current_user_can('manage_options')) {
116 wp_send_json_error('Insufficient permissions');
117 }
118
119 $email = isset($_POST['email']) ? sanitize_email($_POST['email']) : '';
120 $password = isset($_POST['password']) ? sanitize_text_field($_POST['password']) : '';
121
122 if (empty($email) || empty($password)) {
123 wp_send_json_error('Email and password are required');
124 }
125
126 $result = $this->request_secret_with_auth($email, $password);
127
128 if ($result['success']) {
129 // Clear any previous authentication errors
130 delete_transient('onwebchat_wc_auth_error');
131
132 wp_send_json_success(array(
133 'message' => 'WooCommerce sync connected successfully!'
134 ));
135 } else {
136 wp_send_json_error($result['error']);
137 }
138 }
139
140 /**
141 * Show authentication error notice globally across all admin pages
142 * (Hidden when already on WooCommerce tab since it has its own error message)
143 */
144 public function show_auth_error_notice() {
145 $auth_error = get_transient('onwebchat_wc_auth_error');
146
147 // Don't show if we're already on the WooCommerce tab (it has its own error message)
148 $is_woocommerce_tab = isset($_GET['page']) && $_GET['page'] === 'onwebchat_settings'
149 && isset($_GET['tab']) && $_GET['tab'] === 'woocommerce';
150
151 if ($auth_error && class_exists('WooCommerce') && !$is_woocommerce_tab) {
152 ?>
153 <div class="notice notice-error">
154 <p>
155 <strong>⚠️ onWebChat WooCommerce Sync Error:</strong>
156 <?php echo esc_html($auth_error); ?>
157 <a href="<?php echo esc_url(admin_url('admin.php?page=onwebchat_settings&tab=woocommerce')); ?>" class="button button-small" style="margin-left: 10px;">
158 Fix Authentication
159 </a>
160 </p>
161 </div>
162 <?php
163 }
164 }
165
166 /**
167 * Register WooCommerce sync settings
168 */
169 public function register_settings() {
170 register_setting('onwebchat_wc_sync', 'onwebchat_wc_sync_enabled');
171 register_setting('onwebchat_wc_sync', 'onwebchat_wc_sync_mode');
172 register_setting('onwebchat_wc_sync', 'onwebchat_wc_sync_secret');
173 register_setting('onwebchat_wc_sync', 'onwebchat_wc_last_bulk_sync');
174 register_setting('onwebchat_wc_sync', 'onwebchat_wc_excluded_categories');
175 // Persisted sync scope: comma-separated product_cat term IDs. Empty means
176 // the whole catalogue is in scope. Drives both bulk sync and ongoing
177 // per-product auto-sync.
178 register_setting('onwebchat_wc_sync', 'onwebchat_wc_sync_categories');
179 }
180
181 /**
182 * Hook: Product update (WooCommerce specific hook - fires AFTER all meta is saved)
183 */
184 public function on_product_update($product_id) {
185 // Check if sync is enabled
186 if (!get_option('onwebchat_wc_sync_enabled', false)) {
187 return;
188 }
189
190 // Get product object (at this point all meta data including SKU is already saved)
191 $product = wc_get_product($product_id);
192 if (!$product) {
193 return;
194 }
195
196 // Only sync published products
197 if ($product->get_status() !== 'publish') {
198 return;
199 }
200
201 // Check if product category is excluded
202 if ($this->is_product_excluded($product)) {
203 return;
204 }
205
206 // Respect the merchant's sync scope. When a category selection is active
207 // and this product belongs to none of the scoped categories, remove it
208 // (it may have been moved out of a scoped category after being synced)
209 // and stop. Deletes always remove, regardless of scope.
210 if (!$this->product_in_scope($product)) {
211 $this->send_product_delete($product_id);
212 return;
213 }
214
215 // Prepare and send product data
216 $product_data = $this->prepare_product_data($product);
217 $this->send_product_upsert($product_data, $product_id);
218 }
219
220 /**
221 * Hook: WooCommerce's scheduled-sales cron started or ended sales on these
222 * products. IDs can be simple products OR variations; variations are mapped to
223 * their parent (the entity synced to onWebChat) and each product is re-pushed
224 * once, so the AI immediately quotes the new (sale or regular) price.
225 *
226 * @param array $product_ids
227 */
228 public function on_scheduled_sales($product_ids) {
229 if (!get_option('onwebchat_wc_sync_enabled', false)) {
230 return;
231 }
232
233 $ids = array();
234 foreach ((array) $product_ids as $product_id) {
235 $product = wc_get_product($product_id);
236 if (!$product) {
237 continue;
238 }
239
240 $id = $product->is_type('variation') ? $product->get_parent_id() : $product->get_id();
241 if ($id > 0) {
242 $ids[$id] = $id; // de-duplicate
243 }
244 }
245
246 foreach ($ids as $id) {
247 $this->on_product_update($id);
248 }
249 }
250
251 /**
252 * Parent product IDs whose variations changed in this request; flushed once on
253 * shutdown so a save touching 30 variations pushes the parent a single time.
254 */
255 private $pending_variation_parents = array();
256
257 /**
258 * Hook: a variation was created/updated (Variations tab saves happen over AJAX
259 * and don't always re-save the parent post, so woocommerce_update_product may
260 * never fire). Queue the parent for one sync at the end of the request.
261 *
262 * @param int $variation_id
263 */
264 public function on_variation_update($variation_id) {
265 if (!get_option('onwebchat_wc_sync_enabled', false)) {
266 return;
267 }
268
269 $variation = wc_get_product($variation_id);
270 if (!$variation || !$variation->is_type('variation')) {
271 return;
272 }
273
274 $parent_id = (int) $variation->get_parent_id();
275 if ($parent_id <= 0) {
276 return;
277 }
278
279 if (empty($this->pending_variation_parents)) {
280 add_action('shutdown', array($this, 'flush_variation_parent_syncs'));
281 }
282
283 $this->pending_variation_parents[$parent_id] = $parent_id;
284 }
285
286 /**
287 * Shutdown: sync every parent whose variations changed in this request.
288 */
289 public function flush_variation_parent_syncs() {
290 $parent_ids = $this->pending_variation_parents;
291 $this->pending_variation_parents = array();
292
293 foreach ($parent_ids as $parent_id) {
294 $this->on_product_update($parent_id);
295 }
296 }
297
298 /**
299 * Hook: product stock STATUS changed (in stock / out of stock / on backorder).
300 * Pushes only the availability boolean to onWebChat (no re-embed). "onbackorder"
301 * is treated as available since the store still accepts orders.
302 *
303 * @param int $product_id
304 * @param string $status 'instock' | 'outofstock' | 'onbackorder'
305 * @param WC_Product|null $product
306 */
307 public function on_stock_status_change($product_id, $status, $product = null) {
308 if (!get_option('onwebchat_wc_sync_enabled', false)) {
309 return;
310 }
311
312 if (!$product || !is_object($product)) {
313 $product = wc_get_product($product_id);
314 }
315 if (!$product) {
316 return;
317 }
318
319 // Only products that would actually be synced: published, not excluded, in scope.
320 // Out-of-scope / excluded products are not in onWebChat, so there is nothing to update.
321 if ($product->get_status() !== 'publish') {
322 return;
323 }
324 if ($this->is_product_excluded($product) || !$this->product_in_scope($product)) {
325 return;
326 }
327
328 $in_stock = ($status !== 'outofstock');
329 $this->send_product_stock($product_id, $in_stock);
330 }
331
332 /**
333 * Hook: Product trash (when moved to trash)
334 */
335 public function on_product_trash($post_id) {
336 // Check if it's a product
337 if (get_post_type($post_id) !== 'product') {
338 return;
339 }
340
341 if (!get_option('onwebchat_wc_sync_enabled', false)) {
342 return;
343 }
344
345 // Send delete request when product is trashed
346 $this->send_product_delete($post_id);
347 }
348
349 /**
350 * Hook: Product permanent delete
351 */
352 public function on_product_delete($post_id, $post) {
353 if ($post->post_type !== 'product') {
354 return;
355 }
356
357 if (!get_option('onwebchat_wc_sync_enabled', false)) {
358 return;
359 }
360
361 // Send delete request when product is permanently deleted
362 $this->send_product_delete($post_id);
363 }
364
365 /**
366 * Check if product is in excluded categories
367 */
368 private function is_product_excluded($product) {
369 $excluded_categories = get_option('onwebchat_wc_excluded_categories', array());
370 if (empty($excluded_categories)) {
371 return false;
372 }
373
374 $product_categories = $product->get_category_ids();
375 foreach ($product_categories as $cat_id) {
376 if (in_array($cat_id, $excluded_categories)) {
377 return true;
378 }
379 }
380
381 return false;
382 }
383
384 /**
385 * Get the saved sync scope as an array of product_cat term IDs.
386 * An empty array means the whole catalogue is in scope.
387 */
388 private function get_sync_scope() {
389 $raw = (string) get_option('onwebchat_wc_sync_categories', '');
390 if ($raw === '') {
391 return array();
392 }
393
394 $ids = array();
395 foreach (explode(',', $raw) as $id) {
396 $id = (int) trim($id);
397 if ($id > 0) {
398 $ids[$id] = $id; // de-duplicate
399 }
400 }
401
402 return array_values($ids);
403 }
404
405 /**
406 * Persist the sync scope. Pass an empty array to clear it (whole catalogue).
407 */
408 private function save_sync_scope($category_ids) {
409 $clean = array();
410 foreach ((array) $category_ids as $id) {
411 $id = (int) $id;
412 if ($id > 0) {
413 $clean[$id] = $id;
414 }
415 }
416
417 update_option('onwebchat_wc_sync_categories', implode(',', array_values($clean)));
418 }
419
420 /**
421 * Is the product within the current sync scope?
422 * No scope set means everything is in scope. The picker only offers
423 * top-level categories, and selecting one covers its whole subtree, so a
424 * product is in scope when any of its categories is a scoped category OR a
425 * descendant of one. This mirrors the bulk sync tax query
426 * (include_children = true).
427 */
428 private function product_in_scope($product) {
429 $scope = $this->get_sync_scope();
430 if (empty($scope)) {
431 return true;
432 }
433
434 foreach ($product->get_category_ids() as $cat_id) {
435 $cat_id = (int) $cat_id;
436 if (in_array($cat_id, $scope, true)) {
437 return true;
438 }
439 // Walk up to the root: a scoped ancestor puts the product in scope.
440 foreach (get_ancestors($cat_id, 'product_cat', 'taxonomy') as $ancestor_id) {
441 if (in_array((int) $ancestor_id, $scope, true)) {
442 return true;
443 }
444 }
445 }
446
447 return false;
448 }
449
450 /**
451 * Count published products within the given scope (empty = whole catalogue).
452 * Uses found_posts so we do not load every ID into memory.
453 */
454 private function count_products_in_scope($category_ids) {
455 $args = array(
456 'post_type' => 'product',
457 'post_status' => 'publish',
458 'posts_per_page' => 1,
459 'fields' => 'ids',
460 'no_found_rows' => false,
461 );
462
463 if (!empty($category_ids)) {
464 $args['tax_query'] = array(array(
465 'taxonomy' => 'product_cat',
466 'field' => 'term_id',
467 'terms' => array_map('intval', $category_ids),
468 'include_children' => true,
469 ));
470 }
471
472 $query = new WP_Query($args);
473 return (int) $query->found_posts;
474 }
475
476 /**
477 * Prepare product data for sync
478 */
479 private function prepare_product_data($product) {
480 $sync_mode = get_option('onwebchat_wc_sync_mode', 'short_plus_full');
481
482 // Get description based on sync mode
483 $description = '';
484 $short_description = strip_tags($product->get_short_description());
485
486 if ($sync_mode === 'short_only') {
487 $description = $short_description;
488 } else if ($sync_mode === 'short_plus_full') {
489 // Send both texts: the short description first, then the full one.
490 $full_description = strip_tags($product->get_description());
491 $parts = array_filter(array(trim($short_description), trim($full_description)));
492 $description = implode("\n\n", $parts);
493 } else if ($sync_mode === 'short_fallback_full') {
494 if (!empty($short_description)) {
495 $description = $short_description;
496 } else {
497 // Fallback to the first 200 words of the full description.
498 // Split with a Unicode-aware regex: str_word_count() does not
499 // recognize non-latin (e.g. Greek) words, so the old word cut
500 // was unreliable on multibyte text.
501 $full_description = strip_tags($product->get_description());
502 $words = preg_split('/\s+/u', trim($full_description), -1, PREG_SPLIT_NO_EMPTY);
503
504 if (is_array($words) && count($words) > 200) {
505 $description = implode(' ', array_slice($words, 0, 200)) . '...';
506 } else {
507 $description = $full_description;
508 }
509 }
510 }
511
512 // Enforce max length by characters, not bytes: a byte-based substr()
513 // can cut a multibyte UTF-8 character (e.g. Greek text) in half.
514 $max_length = ($sync_mode === 'short_plus_full')
515 ? $this->max_description_length_combined
516 : $this->max_description_length;
517 if (mb_strlen($description, 'UTF-8') > $max_length) {
518 $description = mb_substr($description, 0, $max_length, 'UTF-8') . '...';
519 }
520
521 $sku = $product->get_sku();
522 $categories = $this->get_product_category_names($product);
523 $url = get_permalink($product->get_id());
524
525 // Structured fields. The server rebuilds the embedding text from these,
526 // so there is no need to send a pre-formatted "text" blob.
527 $data = array(
528 'product_id' => $product->get_id(),
529 'name' => $product->get_name(),
530 'short_description' => trim($description),
531 'url' => $url,
532 'sku' => $sku,
533 'categories' => $categories,
534 'currency' => get_woocommerce_currency(),
535 );
536
537 // Price, as the customer sees it in the shop. get_price() returns the value
538 // as entered in admin, which excludes tax on shops that enter net prices but
539 // display gross ones, so the AI would quote a price the visitor never sees.
540 // wc_get_price_to_display() applies the shop's tax display settings.
541 $raw_price = $product->get_price();
542 if ($raw_price !== '') {
543 $data['price'] = wc_get_price_to_display($product);
544
545 // When the shop displays taxed prices, also send the untaxed price so the
546 // AI can quote both.
547 if (wc_tax_enabled()) {
548 $price_excl_tax = wc_get_price_excluding_tax($product);
549 if ((float) $price_excl_tax !== (float) $data['price']) {
550 $data['price_excl_tax'] = $price_excl_tax;
551 }
552 }
553 }
554
555 if ($product->is_type('variable')) {
556 // Display min/max prices, consistent with the display price used for
557 // simple products.
558 $data['price_min'] = $product->get_variation_price('min', true);
559 $data['price_max'] = $product->get_variation_price('max', true);
560 } else {
561 $regular_price = $product->get_regular_price();
562 if ($regular_price !== '') {
563 $data['regular_price'] = wc_get_price_to_display($product, array('price' => $regular_price));
564 }
565 // Only advertise a sale price while the sale is actually active.
566 if ($product->is_on_sale() && $product->get_sale_price() !== '') {
567 $data['sale_price'] = wc_get_price_to_display($product, array('price' => $product->get_sale_price()));
568 }
569 }
570
571 // Stock availability
572 $data['in_stock'] = $product->is_in_stock();
573 if ($product->managing_stock()) {
574 $stock_qty = $product->get_stock_quantity();
575 if ($stock_qty !== null) {
576 $data['quantity'] = (int) $stock_qty;
577 }
578 }
579
580 // Brand (renders as "Brand:" on the server). Detect the common brand taxonomies.
581 $brand = $this->get_product_brand($product);
582 if (!empty($brand)) {
583 $data['manufacturer'] = $brand;
584 }
585
586 // Variation attributes / options (Color, Size, ...)
587 $attributes = $this->get_product_attributes($product);
588 if (!empty($attributes)) {
589 $data['attributes'] = $attributes;
590 }
591
592 // Tags
593 $tags = $this->get_product_tags($product);
594 if (!empty($tags)) {
595 $data['tags'] = $tags;
596 }
597
598 // Average rating and review count
599 $rating = (float) $product->get_average_rating();
600 if ($rating > 0) {
601 $data['rating'] = $rating;
602 $data['review_count'] = (int) $product->get_review_count();
603 }
604
605 return $data;
606 }
607
608 /**
609 * Get product category names
610 */
611 private function get_product_category_names($product) {
612 $categories = array();
613 $category_ids = $product->get_category_ids();
614
615 foreach ($category_ids as $cat_id) {
616 $term = get_term($cat_id, 'product_cat');
617 if ($term && !is_wp_error($term)) {
618 $categories[] = $term->name;
619 }
620 }
621
622 return $categories;
623 }
624
625 /**
626 * Get the product's brand name from whichever brand taxonomy is available.
627 * Supports WooCommerce 9.6+ native brands and the common brand plugins.
628 */
629 private function get_product_brand($product) {
630 $taxonomies = array('product_brand', 'pwb-brand', 'yith_product_brand', 'pa_brand');
631
632 foreach ($taxonomies as $taxonomy) {
633 if (!taxonomy_exists($taxonomy)) {
634 continue;
635 }
636
637 $terms = wp_get_post_terms($product->get_id(), $taxonomy, array('fields' => 'names'));
638 if (!is_wp_error($terms) && !empty($terms)) {
639 return $terms[0];
640 }
641 }
642
643 return '';
644 }
645
646 /**
647 * Get visible product attributes as an array of { name, options }.
648 * Works for both custom and taxonomy-based (global) attributes.
649 */
650 private function get_product_attributes($product) {
651 $result = array();
652
653 foreach ($product->get_attributes() as $attribute) {
654 if (!is_object($attribute) || !$attribute->get_visible()) {
655 continue;
656 }
657
658 $name = wc_attribute_label($attribute->get_name());
659
660 if ($attribute->is_taxonomy()) {
661 $options = wc_get_product_terms($product->get_id(), $attribute->get_name(), array('fields' => 'names'));
662 } else {
663 $options = $attribute->get_options();
664 }
665
666 $options = array_values(array_filter(array_map('trim', (array) $options)));
667
668 if (!empty($name) && !empty($options)) {
669 $result[] = array(
670 'name' => $name,
671 'options' => $options,
672 );
673 }
674 }
675
676 return $result;
677 }
678
679 /**
680 * Get product tag names.
681 */
682 private function get_product_tags($product) {
683 $tags = wp_get_post_terms($product->get_id(), 'product_tag', array('fields' => 'names'));
684
685 if (is_wp_error($tags) || empty($tags)) {
686 return array();
687 }
688
689 return $tags;
690 }
691
692 /**
693 * Send batch of products to API (optimized)
694 * @param array $products - Array of product data
695 * @param int $sync_total - Total products in the current bulk run (0 = not a bulk run)
696 * @param int $sync_done - Products pushed so far in the run, including this batch
697 *
698 * When $sync_total is > 0 the batch is tagged with the run total/progress so
699 * the server can relay a live progress bar to open dashboards.
700 */
701 private function send_product_batch($products, $sync_total = 0, $sync_done = 0) {
702 $chatId = get_option('onwebchat_plugin_option');
703 $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : '';
704
705 if (empty($chatId)) {
706 error_log('onWebChat WooCommerce Sync - Chat ID not configured');
707 return false;
708 }
709
710 // Ensure we have a secret (must be obtained via authenticated connection in WooCommerce settings)
711 $secret = $this->get_secret(false);
712 if (empty($secret)) {
713 error_log('onWebChat WooCommerce Sync - No secret configured. Please connect WooCommerce in the plugin settings.');
714 return array(
715 'success' => false,
716 'error' => 'No secret configured. Please connect WooCommerce integration.',
717 'needs_reconnect' => true
718 );
719 }
720
721 // Extract key part (before first slash if present)
722 $chatIdKey = explode('/', $chatId)[0];
723
724 $endpoint = $this->get_api_endpoint() . '/product/batch';
725 $payload = array(
726 'site_id' => $chatIdKey,
727 'site_url' => get_site_url(),
728 'products' => $products
729 );
730
731 // Tag bulk-run batches with the run total + progress so the server can
732 // relay a live progress bar to open dashboards. Omitted for incremental
733 // single-product syncs (which pass no total).
734 if ((int) $sync_total > 0) {
735 $payload['sync_total'] = (int) $sync_total;
736 $payload['sync_done'] = min((int) $sync_done, (int) $sync_total);
737 }
738
739 // Generate authentication headers (same as send_authenticated_request)
740 $timestamp = time();
741 $nonce = base64_encode(random_bytes(16));
742 $body_json = wp_json_encode($payload);
743
744 // Create signature: HMAC_SHA256(secret, site_id.timestamp.nonce.body)
745 $message = $chatIdKey . '.' . $timestamp . '.' . $nonce . '.' . $body_json;
746 $signature = hash_hmac('sha256', $message, $secret);
747
748 // Send request
749 $request_args = array(
750 'method' => 'POST',
751 'timeout' => 30, // Longer timeout for batch operations
752 'headers' => array(
753 'Content-Type' => 'application/json',
754 'X-OWC-SiteId' => $chatIdKey,
755 'X-OWC-Timestamp' => $timestamp,
756 'X-OWC-Nonce' => $nonce,
757 'X-OWC-Signature' => $signature,
758 ),
759 'body' => $body_json,
760 );
761
762 // Disable SSL verification for local dev server
763 if ($this->use_testing_mode) {
764 $request_args['sslverify'] = false;
765 }
766
767 $response = wp_remote_post($endpoint, $request_args);
768
769 if (is_wp_error($response)) {
770 error_log('onWebChat WooCommerce Sync - Batch sync error: ' . $response->get_error_message());
771 return array(
772 'success' => false,
773 'error' => 'Network error: ' . $response->get_error_message()
774 );
775 }
776
777 $response_code = wp_remote_retrieve_response_code($response);
778 $body = json_decode(wp_remote_retrieve_body($response), true);
779
780 // If authentication failed (401), the secret is invalid or out of sync
781 if ($response_code === 401) {
782 // Clear the invalid secret
783 delete_option('onwebchat_wc_sync_secret');
784
785 error_log('onWebChat WooCommerce Sync - Authentication failed (401): Secret is invalid or out of sync. Please reconnect WooCommerce in the plugin settings.');
786
787 // Store admin notice about authentication failure
788 set_transient('onwebchat_wc_auth_error', 'Authentication failed. Your WooCommerce secret is invalid or out of sync. Please reconnect WooCommerce integration in the plugin settings.', 3600);
789
790 return array(
791 'success' => false,
792 'error' => 'Authentication failed. Secret is invalid. Please reconnect WooCommerce integration.',
793 'needs_reconnect' => true
794 );
795 }
796
797 if ($response_code === 200 && isset($body['success']) && $body['success']) {
798 // Clear any previous auth errors on success
799 delete_transient('onwebchat_wc_auth_error');
800 return $body; // Return full response with stats
801 }
802
803 $error_msg = 'Batch sync failed';
804 if (isset($body['error'])) {
805 $error_msg .= ': ' . $body['error'];
806 }
807 error_log('onWebChat WooCommerce Sync - ' . $error_msg . ' - Response: ' . print_r($body, true));
808
809 return array(
810 'success' => false,
811 'error' => $error_msg
812 );
813 }
814
815 /**
816 * Send sync completion notification to server (triggers Angular modal)
817 */
818 private function send_sync_completion_notification($total_stats) {
819 $chatId = get_option('onwebchat_plugin_option');
820 $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : '';
821
822 if (empty($chatId)) {
823 error_log('onWebChat WooCommerce Sync - Chat ID not configured');
824 return false;
825 }
826
827 $secret = $this->get_secret(false);
828 if (empty($secret)) {
829 error_log('onWebChat WooCommerce Sync - No secret configured');
830 return false;
831 }
832
833 $chatIdKey = explode('/', $chatId)[0];
834
835 $endpoint = $this->get_api_endpoint() . '/product/sync-complete';
836 $payload = array(
837 'site_id' => $chatIdKey,
838 'stats' => $total_stats
839 );
840
841 // Generate authentication headers
842 $timestamp = time();
843 $nonce = base64_encode(random_bytes(16));
844 $body_json = wp_json_encode($payload);
845 $message = $chatIdKey . '.' . $timestamp . '.' . $nonce . '.' . $body_json;
846 $signature = hash_hmac('sha256', $message, $secret);
847
848 $request_args = array(
849 'method' => 'POST',
850 'timeout' => 10,
851 'headers' => array(
852 'Content-Type' => 'application/json',
853 'X-OWC-SiteId' => $chatIdKey,
854 'X-OWC-Timestamp' => $timestamp,
855 'X-OWC-Nonce' => $nonce,
856 'X-OWC-Signature' => $signature,
857 ),
858 'body' => $body_json,
859 );
860
861 if ($this->use_testing_mode) {
862 $request_args['sslverify'] = false;
863 }
864
865 $response = wp_remote_post($endpoint, $request_args);
866
867 if (is_wp_error($response)) {
868 error_log('onWebChat WooCommerce Sync - Completion notification failed: ' . $response->get_error_message());
869 return false;
870 }
871
872 $response_code = wp_remote_retrieve_response_code($response);
873
874 // If authentication failed (401), the secret is invalid or out of sync
875 if ($response_code === 401) {
876 delete_option('onwebchat_wc_sync_secret');
877 error_log('onWebChat WooCommerce Sync - Completion notification authentication failed (401): Secret is invalid. Please reconnect WooCommerce.');
878 set_transient('onwebchat_wc_auth_error', 'Authentication failed. Your WooCommerce secret is invalid or out of sync. Please reconnect WooCommerce integration in the plugin settings.', 3600);
879 return false;
880 }
881
882 if ($response_code >= 200 && $response_code < 300) {
883 error_log('onWebChat WooCommerce Sync - Completion notification sent successfully');
884 return true;
885 }
886
887 error_log('onWebChat WooCommerce Sync - Completion notification failed with code: ' . $response_code);
888 return false;
889 }
890
891 /**
892 * Send product upsert to server (uses batch endpoint with single product)
893 */
894 private function send_product_upsert($product_data, $product_id) {
895 // Use batch endpoint with single product
896 $result = $this->send_product_batch(array($product_data));
897
898 if ($result && isset($result['success']) && $result['success']) {
899 // Clear any previous errors
900 delete_post_meta($product_id, '_onwebchat_sync_error');
901 update_post_meta($product_id, '_onwebchat_last_sync', current_time('timestamp'));
902 return true;
903 } else {
904 // Log error if batch failed
905 $error_message = 'Failed to sync product';
906 if ($result && isset($result['error'])) {
907 $error_message = $result['error'];
908 } elseif (!$result) {
909 $error_message = 'Batch sync request failed';
910 }
911 $this->log_error($product_id, $error_message);
912 return false;
913 }
914 }
915
916 /**
917 * Send product delete to server
918 */
919 private function send_product_delete($product_id) {
920 $chatId = get_option('onwebchat_plugin_option');
921 $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : '';
922
923 if (empty($chatId)) {
924 return false;
925 }
926
927 // Extract key part (before first slash if present)
928 $chatIdKey = explode('/', $chatId)[0];
929
930 $endpoint = $this->get_api_endpoint() . '/product/delete';
931 $payload = array(
932 'site_id' => $chatIdKey, // Use key part only
933 'site_url' => get_site_url(),
934 'product_id' => $product_id
935 );
936
937 $this->send_authenticated_request($endpoint, $payload, $product_id);
938 }
939
940 /**
941 * Send a lightweight availability update to onWebChat (no re-embed on the server).
942 */
943 private function send_product_stock($product_id, $in_stock) {
944 $chatId = get_option('onwebchat_plugin_option');
945 $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : '';
946
947 if (empty($chatId)) {
948 return false;
949 }
950
951 // Extract key part (before first slash if present)
952 $chatIdKey = explode('/', $chatId)[0];
953
954 $endpoint = $this->get_api_endpoint() . '/product/stock';
955 $payload = array(
956 'site_id' => $chatIdKey, // Use key part only
957 'site_url' => get_site_url(),
958 'product_id' => $product_id,
959 'in_stock' => (bool) $in_stock,
960 );
961
962 $this->send_authenticated_request($endpoint, $payload, $product_id);
963 }
964
965 /**
966 * Get cached secret from local options
967 * @param {bool} force_refresh - Not used (kept for compatibility), secret must be obtained via authenticated request
968 */
969 private function get_secret($force_refresh = false) {
970 // Always return cached secret - never fetch automatically
971 // Secret must be obtained via authenticated request in WooCommerce settings
972 $secret = get_option('onwebchat_wc_sync_secret');
973
974 if (!empty($secret)) {
975 return $secret;
976 }
977
978 // No secret available - user must authenticate in WooCommerce settings
979 return null;
980 }
981
982 /**
983 * Request secret from server with authentication
984 * This is called when user clicks "Connect WooCommerce" with their password
985 *
986 * @param {string} email - User's onWebChat email
987 * @param {string} password - User's onWebChat password
988 * @return {array} - ['success' => bool, 'secret' => string, 'error' => string]
989 */
990 public function request_secret_with_auth($email, $password) {
991 $chatId = get_option('onwebchat_plugin_option');
992 $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : '';
993
994 if (empty($chatId)) {
995 return array('success' => false, 'error' => 'No Chat ID configured');
996 }
997
998 // Extract key part (before first slash if present)
999 $key = explode('/', $chatId)[0];
1000
1001 // Request secret from server with authentication
1002 $secret_endpoint = $this->get_api_endpoint() . '/secret';
1003
1004 $response = wp_remote_post($secret_endpoint, array(
1005 'timeout' => 15,
1006 'sslverify' => !$this->use_testing_mode,
1007 'headers' => array(
1008 'Content-Type' => 'application/json',
1009 ),
1010 'body' => wp_json_encode(array(
1011 'email' => $email,
1012 'password' => $password,
1013 'site_key' => $key,
1014 'version' => defined('ONWEBCHAT_PLUGIN_VERSION') ? ONWEBCHAT_PLUGIN_VERSION : '',
1015 )),
1016 ));
1017
1018 if (is_wp_error($response)) {
1019 $error_message = $response->get_error_message();
1020 error_log('onWebChat WooCommerce Sync - Connection error: ' . $error_message);
1021 return array('success' => false, 'error' => 'Connection failed: ' . $error_message);
1022 }
1023
1024 $status_code = wp_remote_retrieve_response_code($response);
1025 $response_body_raw = wp_remote_retrieve_body($response);
1026 $body = json_decode($response_body_raw, true);
1027
1028 // Log response for debugging. Redact the secret so it never lands in server/debug logs
1029 // (a successful response body contains the HMAC secret).
1030 $log_body = preg_replace('/("secret"\s*:\s*")[^"]*(")/i', '$1[REDACTED]$2', (string) $response_body_raw);
1031 error_log('onWebChat WooCommerce Sync - API response: Status=' . $status_code . ', Body=' . substr($log_body, 0, 500));
1032
1033 // Handle specific HTTP status codes
1034 if ($status_code === 401) {
1035 return array('success' => false, 'error' => 'Invalid email or password');
1036 }
1037
1038 if ($status_code === 403) {
1039 return array('success' => false, 'error' => 'You do not have access to this site');
1040 }
1041
1042 // Success case
1043 if ($status_code >= 200 && $status_code < 300 && isset($body['success']) && $body['success']) {
1044 $secret = isset($body['secret']) ? $body['secret'] : null;
1045 if (empty($secret)) {
1046 error_log('onWebChat WooCommerce Sync - Success response but no secret provided');
1047 return array('success' => false, 'error' => 'Server response missing secret');
1048 }
1049 update_option('onwebchat_wc_sync_secret', $secret);
1050
1051 // Enable AI order-status lookup by default on connect and register our
1052 // callback URL with onWebChat (best effort; the merchant can toggle it off).
1053 update_option('onwebchat_wc_order_lookup_enabled', true);
1054 global $onwebchat_wc_orders;
1055 if (isset($onwebchat_wc_orders) && is_object($onwebchat_wc_orders)) {
1056 $onwebchat_wc_orders->push_order_lookup_config(true);
1057 }
1058
1059 return array('success' => true, 'secret' => $secret);
1060 }
1061
1062 // Extract error message from various possible response formats
1063 $error_message = 'Unknown error';
1064
1065 if (is_array($body)) {
1066 // Try different possible error fields
1067 if (isset($body['error'])) {
1068 $error_message = is_string($body['error']) ? $body['error'] : json_encode($body['error']);
1069 } elseif (isset($body['message'])) {
1070 $error_message = is_string($body['message']) ? $body['message'] : json_encode($body['message']);
1071 } elseif (isset($body['errors']) && is_array($body['errors'])) {
1072 $error_message = implode(', ', $body['errors']);
1073 }
1074 } elseif (!empty($response_body_raw)) {
1075 // If body is not JSON or empty, use raw response (truncated)
1076 $error_message = 'Server returned: ' . substr(strip_tags($response_body_raw), 0, 200);
1077 }
1078
1079 // Defense in depth: the remote error is shown in the admin UI, so strip any markup here too
1080 // (the client also renders it as text). Prevents a malicious/MITM'd API response carrying HTML.
1081 $error_message = sanitize_text_field($error_message);
1082
1083 // Include status code in error message if not already included
1084 if ($status_code && strpos($error_message, 'HTTP') === false) {
1085 $error_message = 'HTTP ' . $status_code . ': ' . $error_message;
1086 }
1087
1088 error_log('onWebChat WooCommerce Sync - Connection failed: ' . $error_message);
1089 return array('success' => false, 'error' => $error_message);
1090 }
1091
1092 /**
1093 * Send authenticated request with HMAC signature
1094 */
1095 private function send_authenticated_request($endpoint, $payload, $product_id = null) {
1096 // Get cached secret (must be obtained via authenticated connection in WooCommerce settings)
1097 $secret = $this->get_secret(false);
1098
1099 if (empty($secret)) {
1100 if ($product_id) {
1101 $this->log_error($product_id, 'No secret configured. Please connect WooCommerce in the plugin settings.');
1102 }
1103 return array('success' => false, 'error' => 'Secret not available. Please connect WooCommerce in plugin settings.');
1104 }
1105
1106 $chatId = get_option('onwebchat_plugin_option');
1107 $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : '';
1108
1109 // Extract key part (before first slash if present) for consistency with server
1110 // e.g., "5f02c87b60726a4663b25463a424a034/1/1" -> "5f02c87b60726a4663b25463a424a034"
1111 $chatIdKey = explode('/', $chatId)[0];
1112
1113 // Generate authentication headers
1114 $timestamp = time();
1115 $nonce = base64_encode(random_bytes(16));
1116 $body_json = wp_json_encode($payload);
1117
1118 // Create signature: HMAC_SHA256(secret, site_id.timestamp.nonce.body)
1119 // IMPORTANT: Use the key part (not full chat_id) to match server-side verification
1120 $message = $chatIdKey . '.' . $timestamp . '.' . $nonce . '.' . $body_json;
1121 $signature = hash_hmac('sha256', $message, $secret);
1122
1123 // Send request
1124 $request_args = array(
1125 'method' => 'POST',
1126 'timeout' => 10,
1127 'headers' => array(
1128 'Content-Type' => 'application/json',
1129 'X-OWC-SiteId' => $chatIdKey, // Use key part only
1130 'X-OWC-Timestamp' => $timestamp,
1131 'X-OWC-Nonce' => $nonce,
1132 'X-OWC-Signature' => $signature,
1133 ),
1134 'body' => $body_json,
1135 );
1136
1137 // Disable SSL verification for local dev server
1138 if ($this->use_testing_mode) {
1139 $request_args['sslverify'] = false;
1140 }
1141
1142 $response = wp_remote_post($endpoint, $request_args);
1143
1144 // Handle response
1145 if (is_wp_error($response)) {
1146 $error_message = $response->get_error_message();
1147 if ($product_id) {
1148 $this->log_error($product_id, $error_message);
1149 }
1150 return array('success' => false, 'error' => $error_message);
1151 }
1152
1153 $status_code = wp_remote_retrieve_response_code($response);
1154
1155 // Success
1156 if ($status_code >= 200 && $status_code < 300) {
1157 return array('success' => true);
1158 }
1159
1160 // If authentication failed (401), the secret may be invalid
1161 if ($status_code === 401) {
1162 // Clear the invalid secret
1163 delete_option('onwebchat_wc_sync_secret');
1164
1165 if ($product_id) {
1166 $this->log_error($product_id, 'Authentication failed. Please reconnect WooCommerce in the plugin settings.');
1167 }
1168 return array('success' => false, 'error' => 'Authentication failed. Please reconnect WooCommerce in plugin settings.');
1169 }
1170
1171 // Error
1172 $error_body = wp_remote_retrieve_body($response);
1173 if ($product_id) {
1174 $this->log_error($product_id, "HTTP $status_code: $error_body");
1175 }
1176
1177 return array('success' => false, 'error' => "HTTP $status_code", 'status_code' => $status_code);
1178 }
1179
1180 /**
1181 * Log sync error to product meta
1182 */
1183 private function log_error($product_id, $error_message) {
1184 update_post_meta($product_id, '_onwebchat_sync_error', array(
1185 'message' => $error_message,
1186 'timestamp' => current_time('timestamp')
1187 ));
1188 }
1189
1190 /**
1191 * AJAX: Start bulk sync
1192 */
1193 public function ajax_sync_existing_products() {
1194 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
1195
1196 if (!current_user_can('manage_options')) {
1197 wp_send_json_error('Insufficient permissions');
1198 }
1199
1200 // Check if sync is already in progress
1201 if (get_option('onwebchat_wc_bulk_in_progress', false)) {
1202 wp_send_json_error('A sync is already in progress. Please wait for it to complete.');
1203 }
1204
1205 // Rate limiting: prevent syncing more than once every 5 minutes
1206 $last_sync_time = get_option('onwebchat_wc_last_sync_start', 0);
1207 $cooldown_period = 5 * 60; // 5 minutes in seconds //also in the file woocommerce.php // 5 * 60
1208 $time_since_last_sync = time() - $last_sync_time;
1209
1210 if ($time_since_last_sync < $cooldown_period) {
1211 $wait_time = $cooldown_period - $time_since_last_sync;
1212 $minutes = ceil($wait_time / 60);
1213 wp_send_json_error('Please wait ' . $minutes . ' minute(s) before syncing again.');
1214 }
1215
1216 // Read the chosen sync scope (product_cat term IDs). Empty = whole catalogue.
1217 $category_ids = array();
1218 if (isset($_POST['categories']) && $_POST['categories'] !== '') {
1219 foreach (explode(',', sanitize_text_field(wp_unslash($_POST['categories']))) as $id) {
1220 $id = (int) trim($id);
1221 if ($id > 0) {
1222 $category_ids[] = $id;
1223 }
1224 }
1225 }
1226
1227 // Above the hard cap a category selection is required: refuse an
1228 // unrestricted "sync all" when the catalogue is larger than the cap.
1229 $published_total = $this->count_products_in_scope(array());
1230 if (empty($category_ids) && $published_total > self::MAX_SYNC_PRODUCTS) {
1231 wp_send_json_error(sprintf(
1232 'Your store has %s products, which is more than can be synced at once (%s). Please select specific categories to sync.',
1233 number_format_i18n($published_total),
1234 number_format_i18n(self::MAX_SYNC_PRODUCTS)
1235 ));
1236 }
1237
1238 // Remember the merchant's choice so ongoing auto-sync stays within it:
1239 // selected categories become the sync scope; an unrestricted "sync all"
1240 // clears the scope (the whole catalogue is in scope again).
1241 $this->save_sync_scope($category_ids);
1242
1243 // Store the current sync start time
1244 update_option('onwebchat_wc_last_sync_start', time());
1245
1246 // Reset bulk sync progress
1247 update_option('onwebchat_wc_bulk_page', 0);
1248 update_option('onwebchat_wc_bulk_done', 0);
1249
1250 // Count total products within scope, capped at the hard limit.
1251 $total = $this->count_products_in_scope($category_ids);
1252 if ($total > self::MAX_SYNC_PRODUCTS) {
1253 $total = self::MAX_SYNC_PRODUCTS;
1254 }
1255
1256 update_option('onwebchat_wc_bulk_total', $total);
1257 update_option('onwebchat_wc_bulk_done', 0); // Initialize progress counter
1258 update_option('onwebchat_wc_bulk_in_progress', true);
1259
1260 // Process sync directly instead of using unreliable WP Cron
1261 $sync_result = $this->do_bulk_sync_all($category_ids);
1262
1263 wp_send_json_success(array(
1264 'message' => 'Bulk sync completed',
1265 'total' => $total,
1266 'result' => $sync_result
1267 ));
1268 }
1269
1270 /**
1271 * AJAX: begin a client-driven bulk sync.
1272 *
1273 * Sets up the progress state and returns the total number of products to
1274 * sync. The browser then calls ajax_sync_next_batch() repeatedly (one page
1275 * per request) until the run reports it is complete. Because each request is
1276 * short, the whole sync no longer rides on a single request that outran the
1277 * web server timeout and reported a false failure while products kept
1278 * syncing.
1279 */
1280 public function ajax_start_bulk_sync() {
1281 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
1282
1283 if (!current_user_can('manage_options')) {
1284 wp_send_json_error('Insufficient permissions');
1285 }
1286
1287 if (get_option('onwebchat_wc_bulk_in_progress', false)) {
1288 wp_send_json_error('A sync is already in progress. Please wait for it to complete.');
1289 }
1290
1291 // Read the chosen sync scope (product_cat term IDs). Empty = whole catalogue.
1292 $category_ids = array();
1293 if (isset($_POST['categories']) && $_POST['categories'] !== '') {
1294 foreach (explode(',', sanitize_text_field(wp_unslash($_POST['categories']))) as $id) {
1295 $id = (int) trim($id);
1296 if ($id > 0) {
1297 $category_ids[] = $id;
1298 }
1299 }
1300 }
1301
1302 // Above the hard cap a category selection is required: refuse an
1303 // unrestricted "sync all" when the catalogue is larger than the cap.
1304 $published_total = $this->count_products_in_scope(array());
1305 if (empty($category_ids) && $published_total > self::MAX_SYNC_PRODUCTS) {
1306 wp_send_json_error(sprintf(
1307 'Your store has %s products, which is more than can be synced at once (%s). Please select specific categories to sync.',
1308 number_format_i18n($published_total),
1309 number_format_i18n(self::MAX_SYNC_PRODUCTS)
1310 ));
1311 }
1312
1313 // Remember the merchant's choice so ongoing auto-sync stays within it:
1314 // selected categories become the sync scope; an unrestricted "sync all"
1315 // clears the scope (the whole catalogue is in scope again).
1316 $this->save_sync_scope($category_ids);
1317
1318 // Count total products within scope, capped at the hard limit.
1319 $total = $this->count_products_in_scope($category_ids);
1320 if ($total > self::MAX_SYNC_PRODUCTS) {
1321 $total = self::MAX_SYNC_PRODUCTS;
1322 }
1323
1324 // Reset progress state for a fresh run.
1325 update_option('onwebchat_wc_last_sync_start', time());
1326 update_option('onwebchat_wc_bulk_page', 0);
1327 update_option('onwebchat_wc_bulk_done', 0);
1328 update_option('onwebchat_wc_bulk_total', $total);
1329 update_option('onwebchat_wc_bulk_stats', array('created' => 0, 'updated' => 0, 'skipped' => 0, 'errors' => 0));
1330 // Only enter the "in progress" state when there is actually something to
1331 // sync, so a 0-product start (empty scope) can't leave the store stuck at
1332 // "a sync is already in progress".
1333 update_option('onwebchat_wc_bulk_in_progress', $total > 0);
1334
1335 wp_send_json_success(array('total' => $total));
1336 }
1337
1338 /**
1339 * AJAX: process the next page of the in-progress bulk sync and report progress.
1340 */
1341 public function ajax_sync_next_batch() {
1342 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
1343
1344 if (!current_user_can('manage_options')) {
1345 wp_send_json_error('Insufficient permissions');
1346 }
1347
1348 wp_send_json_success($this->sync_next_page());
1349 }
1350
1351 /**
1352 * Process exactly one page (batch_size products) of the in-progress bulk
1353 * sync, advancing the persisted progress. The browser calls this once per
1354 * request (via ajax_sync_next_batch) until it reports the run is complete,
1355 * so no single request has to stay open for the whole catalogue.
1356 *
1357 * Unlike do_bulk_sync_all()/the WP-Cron path this does NOT sleep and does
1358 * NOT schedule a follow-up cron event: the browser drives the loop. Stats
1359 * are accumulated in an option across pages so the completion notification
1360 * (which drives the dashboard notice) carries the full run totals.
1361 *
1362 * @return array Progress snapshot: in_progress, complete, done, total, stats.
1363 */
1364 private function sync_next_page() {
1365 $total = (int) get_option('onwebchat_wc_bulk_total', 0);
1366
1367 if (!get_option('onwebchat_wc_bulk_in_progress', false)) {
1368 return array(
1369 'in_progress' => false,
1370 'complete' => true,
1371 'done' => (int) get_option('onwebchat_wc_bulk_done', 0),
1372 'total' => $total,
1373 'stats' => $this->get_bulk_stats(),
1374 );
1375 }
1376
1377 $page = (int) get_option('onwebchat_wc_bulk_page', 0);
1378 $done = (int) get_option('onwebchat_wc_bulk_done', 0);
1379 $stats = $this->get_bulk_stats();
1380
1381 $args = array(
1382 'post_type' => 'product',
1383 'post_status' => 'publish',
1384 'posts_per_page' => $this->batch_size,
1385 'paged' => $page + 1,
1386 'orderby' => 'ID',
1387 'order' => 'ASC',
1388 );
1389
1390 // Restrict to the saved sync scope and its subcategories, consistent
1391 // with the per-product scope check and the counts shown in the picker.
1392 $scope = $this->get_sync_scope();
1393 if (!empty($scope)) {
1394 $args['tax_query'] = array(array(
1395 'taxonomy' => 'product_cat',
1396 'field' => 'term_id',
1397 'terms' => array_map('intval', $scope),
1398 'include_children' => true,
1399 ));
1400 }
1401
1402 $query = new WP_Query($args);
1403 $complete = false;
1404
1405 if ($query->have_posts()) {
1406 $products_batch = array();
1407 foreach ($query->posts as $post) {
1408 $product = wc_get_product($post->ID);
1409 if ($product && !$this->is_product_excluded($product)) {
1410 $products_batch[] = $this->prepare_product_data($product);
1411 }
1412 }
1413
1414 $batch_done = 0;
1415 if (!empty($products_batch)) {
1416 // Tag with run total + running progress so the dashboard bar advances.
1417 $result = $this->send_product_batch($products_batch, $total, $done + count($products_batch));
1418 if ($result && isset($result['stats'])) {
1419 $batch_done = (int) $result['stats']['created'] + (int) $result['stats']['updated'] + (int) $result['stats']['skipped'];
1420 $stats['created'] += (int) $result['stats']['created'];
1421 $stats['updated'] += (int) $result['stats']['updated'];
1422 $stats['skipped'] += (int) $result['stats']['skipped'];
1423 $stats['errors'] += (int) $result['stats']['errors'];
1424 } else {
1425 // Batch failed outright: count the products as errors so the
1426 // summary reflects reality rather than silently skipping them.
1427 $batch_done = count($products_batch);
1428 $stats['errors'] += count($products_batch);
1429 }
1430 }
1431
1432 $done += $batch_done;
1433 $page += 1;
1434
1435 update_option('onwebchat_wc_bulk_page', $page);
1436 update_option('onwebchat_wc_bulk_done', $done);
1437 update_option('onwebchat_wc_bulk_stats', $stats);
1438
1439 // Stop once we have covered the counted total or reached the hard cap.
1440 if ($done >= $total || ($page * $this->batch_size) >= self::MAX_SYNC_PRODUCTS) {
1441 $complete = true;
1442 }
1443 } else {
1444 // No more products in scope.
1445 $complete = true;
1446 }
1447
1448 wp_reset_postdata();
1449
1450 if ($complete) {
1451 $this->send_sync_completion_notification($stats);
1452 update_option('onwebchat_wc_bulk_in_progress', false);
1453 update_option('onwebchat_wc_last_bulk_sync', current_time('timestamp'));
1454 // Show 100% when the counted total was reached; otherwise leave the
1455 // real processed figure (e.g. an early empty page or the hard cap).
1456 if ($total > 0 && $done >= $total) {
1457 $done = $total;
1458 }
1459 update_option('onwebchat_wc_bulk_done', $done);
1460 }
1461
1462 return array(
1463 'in_progress' => !$complete,
1464 'complete' => $complete,
1465 'done' => $done,
1466 'total' => $total,
1467 'stats' => $stats,
1468 );
1469 }
1470
1471 /**
1472 * Read the accumulated bulk-sync stats option, normalised to the four keys.
1473 */
1474 private function get_bulk_stats() {
1475 $stats = get_option('onwebchat_wc_bulk_stats', array());
1476 if (!is_array($stats)) {
1477 $stats = array();
1478 }
1479 return array(
1480 'created' => isset($stats['created']) ? (int) $stats['created'] : 0,
1481 'updated' => isset($stats['updated']) ? (int) $stats['updated'] : 0,
1482 'skipped' => isset($stats['skipped']) ? (int) $stats['skipped'] : 0,
1483 'errors' => isset($stats['errors']) ? (int) $stats['errors'] : 0,
1484 );
1485 }
1486
1487 /**
1488 * Process all products in bulk sync directly (not via cron).
1489 *
1490 * @param array $category_ids Sync scope (product_cat term IDs). Empty = whole catalogue.
1491 */
1492 private function do_bulk_sync_all($category_ids = array()) {
1493 $total = get_option('onwebchat_wc_bulk_total', 0);
1494 $page = 0;
1495 $total_done = 0;
1496 $considered = 0; // products fetched so far, used to enforce the hard cap
1497 $all_stats = array('created' => 0, 'updated' => 0, 'skipped' => 0, 'errors' => 0);
1498 $max = self::MAX_SYNC_PRODUCTS;
1499
1500 // Process all products in batches
1501 while (true) {
1502 $args = array(
1503 'post_type' => 'product',
1504 'post_status' => 'publish',
1505 'posts_per_page' => $this->batch_size,
1506 'paged' => $page + 1,
1507 'orderby' => 'ID',
1508 'order' => 'ASC',
1509 );
1510
1511 // Restrict to the chosen top-level categories and their subtrees, to
1512 // match the per-product scope check and the counts shown in the picker.
1513 if (!empty($category_ids)) {
1514 $args['tax_query'] = array(array(
1515 'taxonomy' => 'product_cat',
1516 'field' => 'term_id',
1517 'terms' => array_map('intval', $category_ids),
1518 'include_children' => true,
1519 ));
1520 }
1521
1522 $query = new WP_Query($args);
1523
1524 if (!$query->have_posts()) {
1525 break;
1526 }
1527
1528 // Collect products in this batch, honoring the hard cap.
1529 $products_batch = array();
1530 $reached_cap = false;
1531 foreach ($query->posts as $post) {
1532 if ($considered >= $max) {
1533 $reached_cap = true;
1534 break;
1535 }
1536 $considered++;
1537 $product = wc_get_product($post->ID);
1538 if ($product && !$this->is_product_excluded($product)) {
1539 $products_batch[] = $this->prepare_product_data($product);
1540 }
1541 }
1542
1543 // Send batch
1544 if (!empty($products_batch)) {
1545 $result = $this->send_product_batch($products_batch);
1546 if ($result && isset($result['stats'])) {
1547 $total_done += $result['stats']['created'] + $result['stats']['updated'] + $result['stats']['skipped'];
1548 $all_stats['created'] += $result['stats']['created'];
1549 $all_stats['updated'] += $result['stats']['updated'];
1550 $all_stats['skipped'] += $result['stats']['skipped'];
1551 $all_stats['errors'] += $result['stats']['errors'];
1552 } else {
1553 // Fallback
1554 $total_done += count($products_batch);
1555 }
1556
1557 // Update progress after each batch so AJAX polling can see it
1558 update_option('onwebchat_wc_bulk_done', $total_done);
1559
1560 // Wait 4 seconds before next batch
1561 sleep(4);
1562 }
1563
1564 wp_reset_postdata();
1565 $page++;
1566
1567 // Stop once the hard cap is reached.
1568 if ($reached_cap || $considered >= $max) {
1569 break;
1570 }
1571
1572 // Safety check - don't loop forever. The cap allows up to
1573 // MAX_SYNC_PRODUCTS / batch_size batches, so keep a generous guard.
1574 if ($page > ($max / $this->batch_size) + 10) {
1575 break;
1576 }
1577 }
1578
1579 // Send completion notification to Angular dashboard with total stats
1580 $this->send_sync_completion_notification($all_stats);
1581
1582 // Mark sync as complete
1583 update_option('onwebchat_wc_bulk_in_progress', false);
1584 update_option('onwebchat_wc_bulk_done', $total_done);
1585 update_option('onwebchat_wc_last_bulk_sync', current_time('timestamp'));
1586
1587 return array(
1588 'done' => $total_done,
1589 'total' => $total,
1590 'stats' => $all_stats
1591 );
1592 }
1593
1594 /**
1595 * Process bulk sync batch (via WP Cron) - Uses batch API endpoint
1596 */
1597 public function process_bulk_sync_batch() {
1598 error_log('onWebChat WooCommerce Sync - process_bulk_sync_batch called');
1599
1600 if (!get_option('onwebchat_wc_bulk_in_progress', false)) {
1601 error_log('onWebChat WooCommerce Sync - Sync not in progress, exiting');
1602 return;
1603 }
1604
1605 $page = get_option('onwebchat_wc_bulk_page', 0);
1606 $done = get_option('onwebchat_wc_bulk_done', 0);
1607 $total = get_option('onwebchat_wc_bulk_total', 0);
1608
1609 error_log('onWebChat WooCommerce Sync - Starting batch: page=' . $page . ', done=' . $done . ', total=' . $total);
1610
1611 // Get batch of products
1612 $args = array(
1613 'post_type' => 'product',
1614 'post_status' => 'publish',
1615 'posts_per_page' => $this->batch_size,
1616 'paged' => $page + 1,
1617 'orderby' => 'ID',
1618 'order' => 'ASC',
1619 );
1620
1621 // Restrict to the saved sync scope and its subcategories, consistent
1622 // with the synchronous bulk sync path.
1623 $scope = $this->get_sync_scope();
1624 if (!empty($scope)) {
1625 $args['tax_query'] = array(array(
1626 'taxonomy' => 'product_cat',
1627 'field' => 'term_id',
1628 'terms' => array_map('intval', $scope),
1629 'include_children' => true,
1630 ));
1631 }
1632
1633 $query = new WP_Query($args);
1634
1635 if ($query->have_posts()) {
1636 // Collect all products in this batch
1637 $products_batch = array();
1638
1639 foreach ($query->posts as $post) {
1640 $product = wc_get_product($post->ID);
1641 if ($product && !$this->is_product_excluded($product)) {
1642 $products_batch[] = $this->prepare_product_data($product);
1643 }
1644 }
1645
1646 // Send entire batch in one request
1647 $batch_done = 0;
1648 if (!empty($products_batch)) {
1649 $result = $this->send_product_batch($products_batch);
1650 error_log('onWebChat WooCommerce Sync - Batch result: ' . print_r($result, true));
1651 if ($result && isset($result['stats'])) {
1652 // Count created + updated + skipped as "done"
1653 $batch_done = $result['stats']['created'] + $result['stats']['updated'] + $result['stats']['skipped'];
1654 error_log('onWebChat WooCommerce Sync - Batch done: ' . $batch_done);
1655 } else {
1656 // Fallback: assume all sent
1657 $batch_done = count($products_batch);
1658 error_log('onWebChat WooCommerce Sync - No stats in result, using fallback count: ' . $batch_done);
1659 }
1660 }
1661
1662 // Update progress
1663 $new_done = $done + $batch_done;
1664 error_log('onWebChat WooCommerce Sync - Progress update: done=' . $done . ' + batch_done=' . $batch_done . ' = new_done=' . $new_done . ' / total=' . $total);
1665 update_option('onwebchat_wc_bulk_page', $page + 1);
1666 update_option('onwebchat_wc_bulk_done', $new_done);
1667
1668 // Check if we've processed all products
1669 if ($new_done >= $total || !$query->have_posts()) {
1670 // Sync complete
1671 error_log('onWebChat WooCommerce Sync - Marking sync as complete');
1672 update_option('onwebchat_wc_bulk_in_progress', false);
1673 update_option('onwebchat_wc_last_bulk_sync', current_time('timestamp'));
1674 update_option('onwebchat_wc_bulk_done', $total); // Ensure it shows 100%
1675 } else {
1676 // Schedule next batch
1677 error_log('onWebChat WooCommerce Sync - Scheduling next batch in 60 seconds');
1678 wp_schedule_single_event(time() + 60, 'onwebchat_wc_bulk_sync_batch');
1679 }
1680 } else {
1681 // No more products - sync complete
1682 update_option('onwebchat_wc_bulk_in_progress', false);
1683 update_option('onwebchat_wc_last_bulk_sync', current_time('timestamp'));
1684 update_option('onwebchat_wc_bulk_done', $total); // Ensure it shows 100%
1685 }
1686
1687 wp_reset_postdata();
1688 }
1689
1690 /**
1691 * AJAX: Regenerate secret (fetch from server)
1692 */
1693 public function ajax_regenerate_secret() {
1694 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
1695
1696 if (!current_user_can('manage_options')) {
1697 wp_send_json_error('Insufficient permissions');
1698 }
1699
1700 // Clear cached secret - user will need to re-authenticate
1701 delete_option('onwebchat_wc_sync_secret');
1702
1703 // Clear any authentication error notices
1704 delete_transient('onwebchat_wc_auth_error');
1705
1706 wp_send_json_success(array(
1707 'message' => 'Secret cleared. Please reconnect WooCommerce with your credentials.',
1708 'needs_reconnect' => true
1709 ));
1710 }
1711
1712 /**
1713 * AJAX: Reset sync status
1714 */
1715 public function ajax_reset_sync_status() {
1716 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
1717
1718 if (!current_user_can('manage_options')) {
1719 wp_send_json_error('Insufficient permissions');
1720 }
1721
1722 $total = get_option('onwebchat_wc_bulk_total', 0);
1723
1724 // Mark sync as complete
1725 update_option('onwebchat_wc_bulk_in_progress', false);
1726 update_option('onwebchat_wc_bulk_done', $total);
1727 update_option('onwebchat_wc_last_bulk_sync', current_time('timestamp'));
1728
1729 wp_send_json_success(array(
1730 'message' => 'Sync status reset successfully'
1731 ));
1732 }
1733
1734 /**
1735 * AJAX handler to get current sync status
1736 */
1737 public function ajax_get_sync_status() {
1738 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
1739
1740 if (!current_user_can('manage_options')) {
1741 wp_send_json_error('Insufficient permissions');
1742 }
1743
1744 $in_progress = get_option('onwebchat_wc_bulk_in_progress', false);
1745 $done = get_option('onwebchat_wc_bulk_done', 0);
1746 $total = get_option('onwebchat_wc_bulk_total', 0);
1747
1748 wp_send_json_success(array(
1749 'in_progress' => $in_progress,
1750 'done' => $done,
1751 'total' => $total
1752 ));
1753 }
1754
1755 /**
1756 * AJAX handler to save sync enabled setting
1757 */
1758 public function ajax_save_sync_enabled() {
1759 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
1760
1761 if (!current_user_can('manage_options')) {
1762 wp_send_json_error('Insufficient permissions');
1763 }
1764
1765 $sync_enabled = isset($_POST['sync_enabled']) && $_POST['sync_enabled'] === '1';
1766
1767 update_option('onwebchat_wc_sync_enabled', $sync_enabled);
1768
1769 wp_send_json_success(array(
1770 'message' => $sync_enabled ? 'WooCommerce product sync enabled' : 'WooCommerce product sync disabled',
1771 'enabled' => $sync_enabled
1772 ));
1773 }
1774
1775 /**
1776 * Get sync status for admin display
1777 */
1778 public function get_sync_status() {
1779 $last_sync = get_option('onwebchat_wc_last_bulk_sync', 0);
1780 $in_progress = get_option('onwebchat_wc_bulk_in_progress', false);
1781 $done = get_option('onwebchat_wc_bulk_done', 0);
1782 $total = get_option('onwebchat_wc_bulk_total', 0);
1783
1784 return array(
1785 'last_sync' => $last_sync,
1786 'in_progress' => $in_progress,
1787 'done' => $done,
1788 'total' => $total,
1789 );
1790 }
1791
1792 /**
1793 * AJAX: Manually process batch (for debugging)
1794 */
1795 public function ajax_manual_process_batch() {
1796 check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
1797
1798 if (!current_user_can('manage_options')) {
1799 wp_send_json_error('Insufficient permissions');
1800 }
1801
1802 // Manually trigger the cron job
1803 error_log('onWebChat WooCommerce Sync - Manual batch process triggered via AJAX');
1804 $this->process_bulk_sync_batch();
1805
1806 // Return current status
1807 $status = $this->get_sync_status();
1808 wp_send_json_success(array(
1809 'message' => 'Batch processed',
1810 'status' => $status
1811 ));
1812 }
1813 }
1814
1815 // Initialize the sync module
1816 global $onwebchat_wc_sync;
1817 $onwebchat_wc_sync = new OnWebChat_WooCommerce_Sync();
1818
1819