PluginProbe
AI Chatbot for WooCommerce & Live Chat – onWebChat / 3.9.1
AI Chatbot for WooCommerce & Live Chat – onWebChat v3.9.1
3.10.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 All 49 releases
onwebchat / includes / woocommerce-sync.php

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

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