PluginProbe
Live Chat & AI Chatbot – onWebChat / 3.9.3
Live Chat & AI Chatbot – onWebChat v3.9.3
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.3, at includes/woocommerce-sync.php

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