| @@ -11,11 +11,30 @@ | ||
| 11 | 11 | class OnWebChat_WooCommerce_Sync { |
| 12 | 12 | |
| 13 | 13 | private $api_endpoint_prod = 'https://www.onwebchat.com/api/integrations/woocommerce'; |
| 14 | 14 | private $api_endpoint_dev = 'http://127.0.0.1:81/api/integrations/woocommerce'; |
| 15 | - private $max_description_length = 1000; | |
| 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; | |
| 16 | 23 | private $batch_size = 50; |
| 17 | 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; | |
| 18 | 37 | |
| 19 | 38 | /** |
| 20 | 39 | * Get the API endpoint based on testing mode |
| 21 | 40 | * @return string |
| @@ -26,8 +45,19 @@ | ||
| 26 | 45 | |
| 27 | 46 | public function __construct() { |
| 28 | 47 | // Read testing mode from global constant (defined in onwebchat.php) |
| 29 | 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 | + } | |
| 30 | 60 | // Initialize settings |
| 31 | 61 | add_action('admin_init', array($this, 'register_settings')); |
| 32 | 62 | |
| 33 | 63 | // Show authentication error notice globally (not just on WooCommerce tab) |
| @@ -35,9 +65,31 @@ | ||
| 35 | 65 | |
| 36 | 66 | // Product hooks - use WooCommerce hooks that fire AFTER meta data is saved |
| 37 | 67 | add_action('woocommerce_update_product', array($this, 'on_product_update'), 10, 1); |
| 38 | 68 | add_action('woocommerce_new_product', array($this, 'on_product_update'), 10, 1); |
| 39 | - | |
| 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 | + | |
| 40 | 92 | // Handle product deletion (both trash and permanent delete) |
| 41 | 93 | add_action('wp_trash_post', array($this, 'on_product_trash'), 10, 1); |
| 42 | 94 | add_action('before_delete_post', array($this, 'on_product_delete'), 10, 2); |
| 43 | 95 | |
| @@ -45,8 +97,17 @@ | ||
| 45 | 97 | add_action('onwebchat_wc_bulk_sync_batch', array($this, 'process_bulk_sync_batch')); |
| 46 | 98 | |
| 47 | 99 | // Admin AJAX handlers |
| 48 | 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')); | |
| 49 | 110 | add_action('wp_ajax_onwebchat_wc_regenerate_secret', array($this, 'ajax_regenerate_secret')); |
| 50 | 111 | add_action('wp_ajax_onwebchat_wc_reset_sync_status', array($this, 'ajax_reset_sync_status')); |
| 51 | 112 | add_action('wp_ajax_onwebchat_wc_connect', array($this, 'ajax_connect_woocommerce')); |
| 52 | 113 | add_action('wp_ajax_onwebchat_wc_manual_process_batch', array($this, 'ajax_manual_process_batch')); |
| @@ -63,10 +124,15 @@ | ||
| 63 | 124 | if (!current_user_can('manage_options')) { |
| 64 | 125 | wp_send_json_error('Insufficient permissions'); |
| 65 | 126 | } |
| 66 | 127 | |
| 67 | - $email = isset($_POST['email']) ? sanitize_email($_POST['email']) : ''; | |
| 68 | - $password = isset($_POST['password']) ? sanitize_text_field($_POST['password']) : ''; | |
| 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']) : ''; | |
| 69 | 135 | |
| 70 | 136 | if (empty($email) || empty($password)) { |
| 71 | 137 | wp_send_json_error('Email and password are required'); |
| 72 | 138 | } |
| @@ -119,8 +185,12 @@ | ||
| 119 | 185 | register_setting('onwebchat_wc_sync', 'onwebchat_wc_sync_mode'); |
| 120 | 186 | register_setting('onwebchat_wc_sync', 'onwebchat_wc_sync_secret'); |
| 121 | 187 | register_setting('onwebchat_wc_sync', 'onwebchat_wc_last_bulk_sync'); |
| 122 | 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'); | |
| 123 | 193 | } |
| 124 | 194 | |
| 125 | 195 | /** |
| 126 | 196 | * Hook: Product update (WooCommerce specific hook - fires AFTER all meta is saved) |
| @@ -145,15 +215,136 @@ | ||
| 145 | 215 | // Check if product category is excluded |
| 146 | 216 | if ($this->is_product_excluded($product)) { |
| 147 | 217 | return; |
| 148 | 218 | } |
| 149 | - | |
| 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 | + | |
| 150 | 229 | // Prepare and send product data |
| 151 | 230 | $product_data = $this->prepare_product_data($product); |
| 152 | 231 | $this->send_product_upsert($product_data, $product_id); |
| 153 | 232 | } |
| 154 | - | |
| 233 | + | |
| 155 | 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 | + /** | |
| 156 | 347 | * Hook: Product trash (when moved to trash) |
| 157 | 348 | */ |
| 158 | 349 | public function on_product_trash($post_id) { |
| 159 | 350 | // Check if it's a product |
| @@ -199,36 +390,334 @@ | ||
| 199 | 390 | if (in_array($cat_id, $excluded_categories)) { |
| 200 | 391 | return true; |
| 201 | 392 | } |
| 202 | 393 | } |
| 203 | - | |
| 394 | + | |
| 204 | 395 | return false; |
| 205 | 396 | } |
| 206 | - | |
| 397 | + | |
| 207 | 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 | + * Running a product sync is the merchant asking for their products in the | |
| 458 | + * chatbot, so it also switches automatic sync on: later edits, stock changes | |
| 459 | + * and images then reach the bot on their own. Before 3.10.0 the Sync button | |
| 460 | + * left the switch alone, so a store that never ticked it synced once and | |
| 461 | + * went stale without anyone noticing. The "off by removal" note (set when | |
| 462 | + * removing every category turned the switch off) has served its purpose. | |
| 463 | + */ | |
| 464 | + private function enable_auto_sync_for_run() { | |
| 465 | + delete_option('onwebchat_wc_sync_off_by_removal'); | |
| 466 | + | |
| 467 | + if (!get_option('onwebchat_wc_sync_enabled', false)) { | |
| 468 | + update_option('onwebchat_wc_sync_enabled', true); | |
| 469 | + } | |
| 470 | + } | |
| 471 | + | |
| 472 | + /** | |
| 473 | + * Persist the sync scope: the categories auto-sync covers, plus whether the | |
| 474 | + * scope is the whole catalogue. An empty array with $all false means the AI | |
| 475 | + * training data holds nothing (a fresh site, or one whose products were | |
| 476 | + * removed), so auto-sync has nothing to cover either. | |
| 477 | + */ | |
| 478 | + private function save_sync_scope($category_ids, $all) { | |
| 479 | + $clean = array(); | |
| 480 | + foreach ((array) $category_ids as $id) { | |
| 481 | + $id = (int) $id; | |
| 482 | + if ($id > 0) { | |
| 483 | + $clean[$id] = $id; | |
| 484 | + } | |
| 485 | + } | |
| 486 | + | |
| 487 | + update_option('onwebchat_wc_sync_categories', implode(',', array_values($clean))); | |
| 488 | + update_option('onwebchat_wc_sync_scope_all', $all ? '1' : '0'); | |
| 489 | + } | |
| 490 | + | |
| 491 | + /** | |
| 492 | + * Is the product within the current sync scope? | |
| 493 | + * With no categories saved it comes down to what the empty list means: the | |
| 494 | + * whole catalogue (any product qualifies) or nothing at all. The picker offers the whole | |
| 495 | + * category tree and selecting a category covers its whole subtree, so a | |
| 496 | + * product is in scope when any of its categories is a scoped category OR a | |
| 497 | + * descendant of one. This mirrors the bulk sync tax query | |
| 498 | + * (include_children = true). | |
| 499 | + */ | |
| 500 | + private function product_in_scope($product) { | |
| 501 | + $scope = $this->get_sync_scope(); | |
| 502 | + if (empty($scope)) { | |
| 503 | + return $this->is_scope_all(); | |
| 504 | + } | |
| 505 | + | |
| 506 | + foreach ($product->get_category_ids() as $cat_id) { | |
| 507 | + $cat_id = (int) $cat_id; | |
| 508 | + if (in_array($cat_id, $scope, true)) { | |
| 509 | + return true; | |
| 510 | + } | |
| 511 | + // Walk up to the root: a scoped ancestor puts the product in scope. | |
| 512 | + foreach (get_ancestors($cat_id, 'product_cat', 'taxonomy') as $ancestor_id) { | |
| 513 | + if (in_array((int) $ancestor_id, $scope, true)) { | |
| 514 | + return true; | |
| 515 | + } | |
| 516 | + } | |
| 517 | + } | |
| 518 | + | |
| 519 | + return false; | |
| 520 | + } | |
| 521 | + | |
| 522 | + /** | |
| 523 | + * Is this category already covered by the given scope? A scope covers a | |
| 524 | + * category when it holds the category itself or any of its ancestors, | |
| 525 | + * because selecting a category always includes its whole subtree. | |
| 526 | + */ | |
| 527 | + private function scope_covers($scope, $category_id) { | |
| 528 | + $category_id = (int) $category_id; | |
| 529 | + if (in_array($category_id, $scope, true)) { | |
| 530 | + return true; | |
| 531 | + } | |
| 532 | + | |
| 533 | + foreach (get_ancestors($category_id, 'product_cat', 'taxonomy') as $ancestor_id) { | |
| 534 | + if (in_array((int) $ancestor_id, $scope, true)) { | |
| 535 | + return true; | |
| 536 | + } | |
| 537 | + } | |
| 538 | + | |
| 539 | + return false; | |
| 540 | + } | |
| 541 | + | |
| 542 | + /** | |
| 543 | + * Which of the submitted categories are NOT yet covered by the saved scope. | |
| 544 | + * These are the only ones a sync has to push: everything already in scope is | |
| 545 | + * in the training data already. | |
| 546 | + */ | |
| 547 | + private function categories_added($submitted, $saved_scope) { | |
| 548 | + if (empty($saved_scope)) { | |
| 549 | + return array(); // whole catalogue already in scope, nothing is new | |
| 550 | + } | |
| 551 | + | |
| 552 | + $added = array(); | |
| 553 | + foreach ($submitted as $category_id) { | |
| 554 | + $category_id = (int) $category_id; | |
| 555 | + if ($category_id > 0 && !$this->scope_covers($saved_scope, $category_id)) { | |
| 556 | + $added[$category_id] = $category_id; | |
| 557 | + } | |
| 558 | + } | |
| 559 | + | |
| 560 | + return array_values($added); | |
| 561 | + } | |
| 562 | + | |
| 563 | + /** | |
| 564 | + * Which of the saved categories the merchant just unticked. Used to offer an | |
| 565 | + * explicit removal: unticking alone never drops anything (see | |
| 566 | + * ajax_scope_remove_start), because the saved scope only grows on sync. | |
| 567 | + */ | |
| 568 | + private function categories_removed($submitted, $saved_scope) { | |
| 569 | + if (empty($saved_scope)) { | |
| 570 | + return array(); | |
| 571 | + } | |
| 572 | + | |
| 573 | + $removed = array(); | |
| 574 | + foreach ($saved_scope as $category_id) { | |
| 575 | + $category_id = (int) $category_id; | |
| 576 | + if ($category_id > 0 && !$this->scope_covers($submitted, $category_id)) { | |
| 577 | + $removed[$category_id] = $category_id; | |
| 578 | + } | |
| 579 | + } | |
| 580 | + | |
| 581 | + return array_values($removed); | |
| 582 | + } | |
| 583 | + | |
| 584 | + /** | |
| 585 | + * tax_query for "products inside $terms but not inside $exclude", both | |
| 586 | + * including their subtrees. Empty $terms means the whole catalogue. | |
| 587 | + * Returns null when no restriction applies at all. | |
| 588 | + */ | |
| 589 | + private function build_scope_tax_query($terms, $exclude = array()) { | |
| 590 | + $clauses = array(); | |
| 591 | + | |
| 592 | + if (!empty($terms)) { | |
| 593 | + $clauses[] = array( | |
| 594 | + 'taxonomy' => 'product_cat', | |
| 595 | + 'field' => 'term_id', | |
| 596 | + 'terms' => array_map('intval', $terms), | |
| 597 | + 'include_children' => true, | |
| 598 | + ); | |
| 599 | + } | |
| 600 | + | |
| 601 | + if (!empty($exclude)) { | |
| 602 | + $clauses[] = array( | |
| 603 | + 'taxonomy' => 'product_cat', | |
| 604 | + 'field' => 'term_id', | |
| 605 | + 'terms' => array_map('intval', $exclude), | |
| 606 | + 'include_children' => true, | |
| 607 | + 'operator' => 'NOT IN', | |
| 608 | + ); | |
| 609 | + } | |
| 610 | + | |
| 611 | + if (empty($clauses)) { | |
| 612 | + return null; | |
| 613 | + } | |
| 614 | + | |
| 615 | + if (count($clauses) > 1) { | |
| 616 | + $clauses['relation'] = 'AND'; | |
| 617 | + } | |
| 618 | + | |
| 619 | + return $clauses; | |
| 620 | + } | |
| 621 | + | |
| 622 | + /** | |
| 623 | + * Count published products within the given scope (empty = whole catalogue), | |
| 624 | + * optionally excluding everything inside $exclude and its subtrees. | |
| 625 | + * Uses found_posts so we do not load every ID into memory. | |
| 626 | + */ | |
| 627 | + private function count_products_in_scope($category_ids, $exclude = array()) { | |
| 628 | + $args = array( | |
| 629 | + 'post_type' => 'product', | |
| 630 | + 'post_status' => 'publish', | |
| 631 | + 'posts_per_page' => 1, | |
| 632 | + 'fields' => 'ids', | |
| 633 | + 'no_found_rows' => false, | |
| 634 | + ); | |
| 635 | + | |
| 636 | + $tax_query = $this->build_scope_tax_query($category_ids, $exclude); | |
| 637 | + if ($tax_query !== null) { | |
| 638 | + $args['tax_query'] = $tax_query; | |
| 639 | + } | |
| 640 | + | |
| 641 | + $query = new WP_Query($args); | |
| 642 | + return (int) $query->found_posts; | |
| 643 | + } | |
| 644 | + | |
| 645 | + /** | |
| 646 | + * What the AI training data currently covers, for the settings screen: | |
| 647 | + * array(categories, products, whole_catalogue, nothing, known). Three | |
| 648 | + * states: the whole catalogue, the saved categories, or nothing synced yet. | |
| 649 | + * 'known' is false only on a site upgraded from an older version whose | |
| 650 | + * empty scope could mean either, and there the screen says nothing at all | |
| 651 | + * rather than something wrong. | |
| 652 | + */ | |
| 653 | + public function get_scope_summary() { | |
| 654 | + $scope = $this->get_sync_scope(); | |
| 655 | + $all = $this->is_scope_all(); | |
| 656 | + | |
| 657 | + return array( | |
| 658 | + 'categories' => count($scope), | |
| 659 | + 'products' => $all ? $this->count_products_in_scope(array()) : ($scope ? $this->count_products_in_scope($scope) : 0), | |
| 660 | + 'whole_catalogue' => $all, | |
| 661 | + 'nothing' => !$all && !$scope, | |
| 662 | + 'known' => $this->is_scope_known(), | |
| 663 | + ); | |
| 664 | + } | |
| 665 | + | |
| 666 | + /** | |
| 667 | + * Turn HTML entities into real characters. Product text is often stored | |
| 668 | + * double-encoded ("&quot;" for a quote), where a single pass still | |
| 669 | + * leaves """ in the text the bot is trained on, so decode until the | |
| 670 | + * string stops changing (3 passes is far more than any real content needs). | |
| 671 | + * Always call this AFTER strip_tags: decoding first could turn text like | |
| 672 | + * "price < 100 and > 50" into something strip_tags eats as a tag. | |
| 673 | + */ | |
| 674 | + private function decode_entities($value) { | |
| 675 | + $value = (string) $value; | |
| 676 | + | |
| 677 | + for ($i = 0; $i < 3; $i++) { | |
| 678 | + $decoded = html_entity_decode($value, ENT_QUOTES, 'UTF-8'); | |
| 679 | + if ($decoded === $value) { | |
| 680 | + break; | |
| 681 | + } | |
| 682 | + $value = $decoded; | |
| 683 | + } | |
| 684 | + | |
| 685 | + // html_entity_decode turns into a non-breaking space; make it a | |
| 686 | + // plain space so the text does not carry invisible oddities. | |
| 687 | + return str_replace("\xC2\xA0", ' ', $value); | |
| 688 | + } | |
| 689 | + | |
| 690 | + /** | |
| 208 | 691 | * Prepare product data for sync |
| 209 | 692 | */ |
| 210 | 693 | private function prepare_product_data($product) { |
| 211 | - $sync_mode = get_option('onwebchat_wc_sync_mode', 'short_fallback_full'); | |
| 694 | + $sync_mode = get_option('onwebchat_wc_sync_mode', 'short_plus_full'); | |
| 212 | 695 | |
| 213 | 696 | // Get description based on sync mode |
| 214 | 697 | $description = ''; |
| 215 | - $short_description = strip_tags($product->get_short_description()); | |
| 698 | + $short_description = $this->decode_entities(strip_tags($product->get_short_description())); | |
| 216 | 699 | |
| 217 | 700 | if ($sync_mode === 'short_only') { |
| 218 | 701 | $description = $short_description; |
| 702 | + } else if ($sync_mode === 'short_plus_full') { | |
| 703 | + // Send both texts: the short description first, then the full one. | |
| 704 | + $full_description = $this->decode_entities(strip_tags($product->get_description())); | |
| 705 | + $parts = array_filter(array(trim($short_description), trim($full_description))); | |
| 706 | + $description = implode("\n\n", $parts); | |
| 219 | 707 | } else if ($sync_mode === 'short_fallback_full') { |
| 220 | 708 | if (!empty($short_description)) { |
| 221 | 709 | $description = $short_description; |
| 222 | 710 | } else { |
| 223 | - // Fallback to first 60-80 words of full description | |
| 224 | - $full_description = strip_tags($product->get_description()); | |
| 225 | - $words = str_word_count($full_description, 2); | |
| 226 | - $word_array = array_keys($words); | |
| 711 | + // Fallback to the first 200 words of the full description. | |
| 712 | + // Split with a Unicode-aware regex: str_word_count() does not | |
| 713 | + // recognize non-latin (e.g. Greek) words, so the old word cut | |
| 714 | + // was unreliable on multibyte text. | |
| 715 | + $full_description = $this->decode_entities(strip_tags($product->get_description())); | |
| 716 | + $words = preg_split('/\s+/u', trim($full_description), -1, PREG_SPLIT_NO_EMPTY); | |
| 227 | 717 | |
| 228 | - if (count($word_array) > 80) { | |
| 229 | - $end_pos = $word_array[79]; | |
| 230 | - $description = substr($full_description, 0, $end_pos) . '...'; | |
| 718 | + if (is_array($words) && count($words) > 200) { | |
| 719 | + $description = implode(' ', array_slice($words, 0, 200)) . '...'; | |
| 231 | 720 | } else { |
| 232 | 721 | $description = $full_description; |
| 233 | 722 | } |
| 234 | 723 | } |
| @@ -233,11 +722,15 @@ | ||
| 233 | 722 | } |
| 234 | 723 | } |
| 235 | 724 | } |
| 236 | 725 | |
| 237 | - // Enforce max length | |
| 238 | - if (strlen($description) > $this->max_description_length) { | |
| 239 | - $description = substr($description, 0, $this->max_description_length) . '...'; | |
| 726 | + // Enforce max length by characters, not bytes: a byte-based substr() | |
| 727 | + // can cut a multibyte UTF-8 character (e.g. Greek text) in half. | |
| 728 | + $max_length = ($sync_mode === 'short_plus_full') | |
| 729 | + ? $this->max_description_length_combined | |
| 730 | + : $this->max_description_length; | |
| 731 | + if (mb_strlen($description, 'UTF-8') > $max_length) { | |
| 732 | + $description = mb_substr($description, 0, $max_length, 'UTF-8') . '...'; | |
| 240 | 733 | } |
| 241 | 734 | |
| 242 | 735 | $sku = $product->get_sku(); |
| 243 | 736 | $categories = $this->get_product_category_names($product); |
| @@ -246,9 +739,11 @@ | ||
| 246 | 739 | // Structured fields. The server rebuilds the embedding text from these, |
| 247 | 740 | // so there is no need to send a pre-formatted "text" blob. |
| 248 | 741 | $data = array( |
| 249 | 742 | 'product_id' => $product->get_id(), |
| 250 | - 'name' => $product->get_name(), | |
| 743 | + // Names are stored HTML-escaped ("Bags & Belts"), so decode them: the name is | |
| 744 | + // also the title of the product card the widget shows (3.10.0+). | |
| 745 | + 'name' => $this->decode_entities($product->get_name()), | |
| 251 | 746 | 'short_description' => trim($description), |
| 252 | 747 | 'url' => $url, |
| 253 | 748 | 'sku' => $sku, |
| 254 | 749 | 'categories' => $categories, |
| @@ -254,23 +749,39 @@ | ||
| 254 | 749 | 'categories' => $categories, |
| 255 | 750 | 'currency' => get_woocommerce_currency(), |
| 256 | 751 | ); |
| 257 | 752 | |
| 258 | - // Price (always sent). Variable products carry a min/max range. | |
| 259 | - $data['price'] = $product->get_price(); | |
| 753 | + // Price, as the customer sees it in the shop. get_price() returns the value | |
| 754 | + // as entered in admin, which excludes tax on shops that enter net prices but | |
| 755 | + // display gross ones, so the AI would quote a price the visitor never sees. | |
| 756 | + // wc_get_price_to_display() applies the shop's tax display settings. | |
| 757 | + $raw_price = $product->get_price(); | |
| 758 | + if ($raw_price !== '') { | |
| 759 | + $data['price'] = wc_get_price_to_display($product); | |
| 260 | 760 | |
| 761 | + // When the shop displays taxed prices, also send the untaxed price so the | |
| 762 | + // AI can quote both. | |
| 763 | + if (wc_tax_enabled()) { | |
| 764 | + $price_excl_tax = wc_get_price_excluding_tax($product); | |
| 765 | + if ((float) $price_excl_tax !== (float) $data['price']) { | |
| 766 | + $data['price_excl_tax'] = $price_excl_tax; | |
| 767 | + } | |
| 768 | + } | |
| 769 | + } | |
| 770 | + | |
| 261 | 771 | if ($product->is_type('variable')) { |
| 262 | - // Raw min/max prices, consistent with get_price() used for simple products. | |
| 263 | - $data['price_min'] = $product->get_variation_price('min', false); | |
| 264 | - $data['price_max'] = $product->get_variation_price('max', false); | |
| 772 | + // Display min/max prices, consistent with the display price used for | |
| 773 | + // simple products. | |
| 774 | + $data['price_min'] = $product->get_variation_price('min', true); | |
| 775 | + $data['price_max'] = $product->get_variation_price('max', true); | |
| 265 | 776 | } else { |
| 266 | 777 | $regular_price = $product->get_regular_price(); |
| 267 | 778 | if ($regular_price !== '') { |
| 268 | - $data['regular_price'] = $regular_price; | |
| 779 | + $data['regular_price'] = wc_get_price_to_display($product, array('price' => $regular_price)); | |
| 269 | 780 | } |
| 270 | 781 | // Only advertise a sale price while the sale is actually active. |
| 271 | - if ($product->is_on_sale()) { | |
| 272 | - $data['sale_price'] = $product->get_sale_price(); | |
| 782 | + if ($product->is_on_sale() && $product->get_sale_price() !== '') { | |
| 783 | + $data['sale_price'] = wc_get_price_to_display($product, array('price' => $product->get_sale_price())); | |
| 273 | 784 | } |
| 274 | 785 | } |
| 275 | 786 | |
| 276 | 787 | // Stock availability |
| @@ -306,10 +817,79 @@ | ||
| 306 | 817 | $data['rating'] = $rating; |
| 307 | 818 | $data['review_count'] = (int) $product->get_review_count(); |
| 308 | 819 | } |
| 309 | 820 | |
| 821 | + // Product thumbnail (3.10.0+): shown as a small product card under the chatbot's reply | |
| 822 | + // when it recommends this product. Always sent, '' when the product has no usable | |
| 823 | + // image: the server then clears the thumbnail it stored for an earlier sync. | |
| 824 | + $data['image'] = $this->get_product_image_url($product); | |
| 825 | + | |
| 310 | 826 | return $data; |
| 311 | 827 | } |
| 828 | + | |
| 829 | + /** | |
| 830 | + * Thumbnail URL for the product card the chat widget shows under a chatbot reply, or '' | |
| 831 | + * when the product has no usable image. | |
| 832 | + * | |
| 833 | + * - The WooCommerce catalogue thumbnail size (300px by default), so the widget never | |
| 834 | + * loads the full-size photo. WordPress falls back to the original file when that | |
| 835 | + * size was never generated. | |
| 836 | + * - Uploaded file names keep non-Latin letters (a Greek "κούπα.jpg" stays Greek in the | |
| 837 | + * URL) and WordPress returns them unencoded, so every byte outside printable ASCII | |
| 838 | + * is percent-encoded here: the widget, the dashboard and the server then all handle | |
| 839 | + * one plain ASCII URL. Already encoded parts (%CE%BA...) are left as they are. | |
| 840 | + * - Shops served over HTTPS get an HTTPS image link, otherwise the browser would block | |
| 841 | + * the picture on the shop page as mixed content. | |
| 842 | + * - Anything that is not an absolute http(s) URL, or is longer than the 1000 characters | |
| 843 | + * the server stores, is dropped (the product then syncs without a picture). | |
| 844 | + */ | |
| 845 | + private function get_product_image_url($product) { | |
| 846 | + $image_id = (int) $product->get_image_id(); | |
| 847 | + if ($image_id <= 0) { | |
| 848 | + return ''; | |
| 849 | + } | |
| 850 | + | |
| 851 | + $image_url = wp_get_attachment_image_url($image_id, 'woocommerce_thumbnail'); | |
| 852 | + if (!$image_url) { | |
| 853 | + $image_url = wp_get_attachment_image_url($image_id, 'thumbnail'); | |
| 854 | + } | |
| 855 | + if (!is_string($image_url)) { | |
| 856 | + return ''; | |
| 857 | + } | |
| 858 | + | |
| 859 | + $image_url = trim($image_url); | |
| 860 | + if ($image_url === '') { | |
| 861 | + return ''; | |
| 862 | + } | |
| 863 | + | |
| 864 | + $site_is_https = is_ssl() || (stripos(home_url('/'), 'https://') === 0); | |
| 865 | + | |
| 866 | + // Protocol-relative URL (some CDN plugins return "//cdn.example.com/..."). | |
| 867 | + if (substr($image_url, 0, 2) === '//') { | |
| 868 | + $image_url = ($site_is_https ? 'https:' : 'http:') . $image_url; | |
| 869 | + } | |
| 870 | + | |
| 871 | + if ($site_is_https && stripos($image_url, 'http://') === 0) { | |
| 872 | + $image_url = set_url_scheme($image_url, 'https'); | |
| 873 | + } | |
| 874 | + | |
| 875 | + // Percent-encode every byte outside printable ASCII (multibyte letters, spaces, | |
| 876 | + // control characters). No /u flag on purpose: each byte of a UTF-8 sequence is | |
| 877 | + // encoded separately, which is exactly the encoding a browser would apply. | |
| 878 | + $image_url = preg_replace_callback('/[^\x21-\x7E]/', function ($m) { | |
| 879 | + return rawurlencode($m[0]); | |
| 880 | + }, $image_url); | |
| 881 | + | |
| 882 | + if (!is_string($image_url) || !preg_match('#^https?://[^\s<>"\'\\\\]+$#i', $image_url)) { | |
| 883 | + return ''; | |
| 884 | + } | |
| 885 | + | |
| 886 | + if (strlen($image_url) > 1000) { | |
| 887 | + return ''; | |
| 888 | + } | |
| 889 | + | |
| 890 | + return $image_url; | |
| 891 | + } | |
| 312 | 892 | |
| 313 | 893 | /** |
| 314 | 894 | * Get product category names |
| 315 | 895 | */ |
| @@ -319,9 +899,9 @@ | ||
| 319 | 899 | |
| 320 | 900 | foreach ($category_ids as $cat_id) { |
| 321 | 901 | $term = get_term($cat_id, 'product_cat'); |
| 322 | 902 | if ($term && !is_wp_error($term)) { |
| 323 | - $categories[] = $term->name; | |
| 903 | + $categories[] = $this->decode_entities($term->name); | |
| 324 | 904 | } |
| 325 | 905 | } |
| 326 | 906 | |
| 327 | 907 | return $categories; |
| @@ -340,9 +920,9 @@ | ||
| 340 | 920 | } |
| 341 | 921 | |
| 342 | 922 | $terms = wp_get_post_terms($product->get_id(), $taxonomy, array('fields' => 'names')); |
| 343 | 923 | if (!is_wp_error($terms) && !empty($terms)) { |
| 344 | - return $terms[0]; | |
| 924 | + return $this->decode_entities($terms[0]); | |
| 345 | 925 | } |
| 346 | 926 | } |
| 347 | 927 | |
| 348 | 928 | return ''; |
| @@ -390,16 +970,21 @@ | ||
| 390 | 970 | if (is_wp_error($tags) || empty($tags)) { |
| 391 | 971 | return array(); |
| 392 | 972 | } |
| 393 | 973 | |
| 394 | - return $tags; | |
| 974 | + return array_map(array($this, 'decode_entities'), $tags); | |
| 395 | 975 | } |
| 396 | 976 | |
| 397 | 977 | /** |
| 398 | 978 | * Send batch of products to API (optimized) |
| 399 | - * @param array $products - Array of product data | |
| 979 | + * @param array $products - Array of product data | |
| 980 | + * @param int $sync_total - Total products in the current bulk run (0 = not a bulk run) | |
| 981 | + * @param int $sync_done - Products pushed so far in the run, including this batch | |
| 982 | + * | |
| 983 | + * When $sync_total is > 0 the batch is tagged with the run total/progress so | |
| 984 | + * the server can relay a live progress bar to open dashboards. | |
| 400 | 985 | */ |
| 401 | - private function send_product_batch($products) { | |
| 986 | + private function send_product_batch($products, $sync_total = 0, $sync_done = 0) { | |
| 402 | 987 | $chatId = get_option('onwebchat_plugin_option'); |
| 403 | 988 | $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : ''; |
| 404 | 989 | |
| 405 | 990 | if (empty($chatId)) { |
| @@ -426,8 +1011,16 @@ | ||
| 426 | 1011 | 'site_id' => $chatIdKey, |
| 427 | 1012 | 'site_url' => get_site_url(), |
| 428 | 1013 | 'products' => $products |
| 429 | 1014 | ); |
| 1015 | + | |
| 1016 | + // Tag bulk-run batches with the run total + progress so the server can | |
| 1017 | + // relay a live progress bar to open dashboards. Omitted for incremental | |
| 1018 | + // single-product syncs (which pass no total). | |
| 1019 | + if ((int) $sync_total > 0) { | |
| 1020 | + $payload['sync_total'] = (int) $sync_total; | |
| 1021 | + $payload['sync_done'] = min((int) $sync_done, (int) $sync_total); | |
| 1022 | + } | |
| 430 | 1023 | |
| 431 | 1024 | // Generate authentication headers (same as send_authenticated_request) |
| 432 | 1025 | $timestamp = time(); |
| 433 | 1026 | $nonce = base64_encode(random_bytes(16)); |
| @@ -439,9 +1032,12 @@ | ||
| 439 | 1032 | |
| 440 | 1033 | // Send request |
| 441 | 1034 | $request_args = array( |
| 442 | 1035 | 'method' => 'POST', |
| 443 | - 'timeout' => 30, // Longer timeout for batch operations | |
| 1036 | + // A batch whose descriptions are summarized for the first time costs | |
| 1037 | + // the server one model call per oversized product, so it needs far | |
| 1038 | + // more than the 30s that is plenty for every other endpoint. | |
| 1039 | + 'timeout' => 180, | |
| 444 | 1040 | 'headers' => array( |
| 445 | 1041 | 'Content-Type' => 'application/json', |
| 446 | 1042 | 'X-OWC-SiteId' => $chatIdKey, |
| 447 | 1043 | 'X-OWC-Timestamp' => $timestamp, |
| @@ -627,10 +1223,71 @@ | ||
| 627 | 1223 | ); |
| 628 | 1224 | |
| 629 | 1225 | $this->send_authenticated_request($endpoint, $payload, $product_id); |
| 630 | 1226 | } |
| 631 | - | |
| 1227 | + | |
| 632 | 1228 | /** |
| 1229 | + * Remove many products from the AI training data in one call. Used by the | |
| 1230 | + * scope-removal flow: one request per product would hit onWebChat's | |
| 1231 | + * product-sync rate limit on any real catalogue. | |
| 1232 | + * | |
| 1233 | + * @param array $product_ids | |
| 1234 | + * @return array {success, deleted, errors} | |
| 1235 | + */ | |
| 1236 | + private function send_products_delete_batch($product_ids) { | |
| 1237 | + $product_ids = array_values(array_unique(array_map('intval', (array) $product_ids))); | |
| 1238 | + if (empty($product_ids)) { | |
| 1239 | + return array('success' => true, 'deleted' => 0, 'errors' => 0); | |
| 1240 | + } | |
| 1241 | + | |
| 1242 | + $chatId = get_option('onwebchat_plugin_option'); | |
| 1243 | + $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : ''; | |
| 1244 | + | |
| 1245 | + if (empty($chatId)) { | |
| 1246 | + return array('success' => false, 'deleted' => 0, 'errors' => count($product_ids)); | |
| 1247 | + } | |
| 1248 | + | |
| 1249 | + $chatIdKey = explode('/', $chatId)[0]; | |
| 1250 | + | |
| 1251 | + $result = $this->send_authenticated_request($this->get_api_endpoint() . '/product/delete', array( | |
| 1252 | + 'site_id' => $chatIdKey, | |
| 1253 | + 'site_url' => get_site_url(), | |
| 1254 | + 'product_ids' => $product_ids, | |
| 1255 | + )); | |
| 1256 | + | |
| 1257 | + if (empty($result['success'])) { | |
| 1258 | + return array('success' => false, 'deleted' => 0, 'errors' => count($product_ids)); | |
| 1259 | + } | |
| 1260 | + | |
| 1261 | + return array('success' => true, 'deleted' => count($product_ids), 'errors' => 0); | |
| 1262 | + } | |
| 1263 | + | |
| 1264 | + /** | |
| 1265 | + * Send a lightweight availability update to onWebChat (no re-embed on the server). | |
| 1266 | + */ | |
| 1267 | + private function send_product_stock($product_id, $in_stock) { | |
| 1268 | + $chatId = get_option('onwebchat_plugin_option'); | |
| 1269 | + $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : ''; | |
| 1270 | + | |
| 1271 | + if (empty($chatId)) { | |
| 1272 | + return false; | |
| 1273 | + } | |
| 1274 | + | |
| 1275 | + // Extract key part (before first slash if present) | |
| 1276 | + $chatIdKey = explode('/', $chatId)[0]; | |
| 1277 | + | |
| 1278 | + $endpoint = $this->get_api_endpoint() . '/product/stock'; | |
| 1279 | + $payload = array( | |
| 1280 | + 'site_id' => $chatIdKey, // Use key part only | |
| 1281 | + 'site_url' => get_site_url(), | |
| 1282 | + 'product_id' => $product_id, | |
| 1283 | + 'in_stock' => (bool) $in_stock, | |
| 1284 | + ); | |
| 1285 | + | |
| 1286 | + $this->send_authenticated_request($endpoint, $payload, $product_id); | |
| 1287 | + } | |
| 1288 | + | |
| 1289 | + /** | |
| 633 | 1290 | * Get cached secret from local options |
| 634 | 1291 | * @param {bool} force_refresh - Not used (kept for compatibility), secret must be obtained via authenticated request |
| 635 | 1292 | */ |
| 636 | 1293 | private function get_secret($force_refresh = false) { |
| @@ -713,8 +1370,17 @@ | ||
| 713 | 1370 | error_log('onWebChat WooCommerce Sync - Success response but no secret provided'); |
| 714 | 1371 | return array('success' => false, 'error' => 'Server response missing secret'); |
| 715 | 1372 | } |
| 716 | 1373 | update_option('onwebchat_wc_sync_secret', $secret); |
| 1374 | + | |
| 1375 | + // Enable AI order-status lookup by default on connect and register our | |
| 1376 | + // callback URL with onWebChat (best effort; the merchant can toggle it off). | |
| 1377 | + update_option('onwebchat_wc_order_lookup_enabled', true); | |
| 1378 | + global $onwebchat_wc_orders; | |
| 1379 | + if (isset($onwebchat_wc_orders) && is_object($onwebchat_wc_orders)) { | |
| 1380 | + $onwebchat_wc_orders->push_order_lookup_config(true); | |
| 1381 | + } | |
| 1382 | + | |
| 717 | 1383 | return array('success' => true, 'secret' => $secret); |
| 718 | 1384 | } |
| 719 | 1385 | |
| 720 | 1386 | // Extract error message from various possible response formats |
| @@ -849,9 +1515,13 @@ | ||
| 849 | 1515 | * AJAX: Start bulk sync |
| 850 | 1516 | */ |
| 851 | 1517 | public function ajax_sync_existing_products() { |
| 852 | 1518 | check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce'); |
| 853 | - | |
| 1519 | + | |
| 1520 | + // This path can walk the whole catalogue in one request, and each batch | |
| 1521 | + // waits for the server (which may summarize descriptions with a model). | |
| 1522 | + @set_time_limit(0); | |
| 1523 | + | |
| 854 | 1524 | if (!current_user_can('manage_options')) { |
| 855 | 1525 | wp_send_json_error('Insufficient permissions'); |
| 856 | 1526 | } |
| 857 | 1527 | |
| @@ -861,42 +1531,105 @@ | ||
| 861 | 1531 | } |
| 862 | 1532 | |
| 863 | 1533 | // Rate limiting: prevent syncing more than once every 5 minutes |
| 864 | 1534 | $last_sync_time = get_option('onwebchat_wc_last_sync_start', 0); |
| 865 | - $cooldown_period = 5 * 60; // 5 minutes in seconds //also in the file woocommerce.php // 5 * 60 | |
| 1535 | + $cooldown_period = 30; // seconds; keep in step with admin/tabs/woocommerce.php | |
| 866 | 1536 | $time_since_last_sync = time() - $last_sync_time; |
| 867 | 1537 | |
| 868 | 1538 | if ($time_since_last_sync < $cooldown_period) { |
| 869 | - $wait_time = $cooldown_period - $time_since_last_sync; | |
| 870 | - $minutes = ceil($wait_time / 60); | |
| 871 | - wp_send_json_error('Please wait ' . $minutes . ' minute(s) before syncing again.'); | |
| 1539 | + $wait_time = max(1, $cooldown_period - $time_since_last_sync); | |
| 1540 | + wp_send_json_error('Please wait ' . $wait_time . ' second(s) before syncing again.'); | |
| 872 | 1541 | } |
| 873 | 1542 | |
| 1543 | + // Read the chosen sync scope (product_cat term IDs). Empty = whole catalogue. | |
| 1544 | + $category_ids = array(); | |
| 1545 | + if (isset($_POST['categories']) && $_POST['categories'] !== '') { | |
| 1546 | + foreach (explode(',', sanitize_text_field(wp_unslash($_POST['categories']))) as $id) { | |
| 1547 | + $id = (int) trim($id); | |
| 1548 | + if ($id > 0) { | |
| 1549 | + $category_ids[] = $id; | |
| 1550 | + } | |
| 1551 | + } | |
| 1552 | + } | |
| 1553 | + | |
| 1554 | + // Above the hard cap a category selection is required: refuse an | |
| 1555 | + // unrestricted "sync all" when the catalogue is larger than the cap. | |
| 1556 | + $published_total = $this->count_products_in_scope(array()); | |
| 1557 | + if (empty($category_ids) && $published_total > self::MAX_SYNC_PRODUCTS) { | |
| 1558 | + wp_send_json_error(sprintf( | |
| 1559 | + 'Your store has %s products, which is more than can be synced at once (%s). Please select specific categories to sync.', | |
| 1560 | + number_format_i18n($published_total), | |
| 1561 | + number_format_i18n(self::MAX_SYNC_PRODUCTS) | |
| 1562 | + )); | |
| 1563 | + } | |
| 1564 | + | |
| 1565 | + // The saved scope only ever GROWS on a sync. Ticking more categories adds | |
| 1566 | + // them to what the bot knows; unticking never silently drops products, | |
| 1567 | + // removal is its own explicit, confirmed action (ajax_scope_remove_*). | |
| 1568 | + // An empty selection means the whole catalogue, which covers everything, | |
| 1569 | + // so it clears the scope. | |
| 1570 | + // | |
| 1571 | + // $run_terms / $run_exclude are what THIS run pushes, which is not the | |
| 1572 | + // same as the scope: when categories are added to an existing scope only | |
| 1573 | + // the added ones are pushed, so adding one subcategory to a 10,000 | |
| 1574 | + // product scope no longer re-sends all 10,000. | |
| 1575 | + $saved_scope = $this->get_sync_scope(); | |
| 1576 | + | |
| 1577 | + if (empty($category_ids)) { | |
| 1578 | + // Nothing ticked: the whole catalogue is the scope. | |
| 1579 | + $new_scope = array(); | |
| 1580 | + $new_all = true; | |
| 1581 | + $run_terms = array(); // push everything | |
| 1582 | + $run_exclude = array(); | |
| 1583 | + } elseif (empty($saved_scope)) { | |
| 1584 | + // Nothing picked before (a fresh site, or one whose scope was | |
| 1585 | + // removed, or one that used to sync everything): the ticks become | |
| 1586 | + // the scope, so they are still ticked after a refresh and the | |
| 1587 | + // summary can name them. | |
| 1588 | + $new_scope = $category_ids; | |
| 1589 | + $new_all = false; | |
| 1590 | + $run_terms = $category_ids; | |
| 1591 | + $run_exclude = array(); | |
| 1592 | + } else { | |
| 1593 | + $added = $this->categories_added($category_ids, $saved_scope); | |
| 1594 | + $new_scope = array_values(array_unique(array_merge($saved_scope, $category_ids))); | |
| 1595 | + $new_all = false; | |
| 1596 | + | |
| 1597 | + if (!empty($added)) { | |
| 1598 | + $run_terms = $added; | |
| 1599 | + $run_exclude = $saved_scope; // already synced, skip it | |
| 1600 | + } else { | |
| 1601 | + // Nothing new was ticked, so the click means "refresh what I have". | |
| 1602 | + $run_terms = $new_scope; | |
| 1603 | + $run_exclude = array(); | |
| 1604 | + } | |
| 1605 | + } | |
| 1606 | + | |
| 1607 | + $this->save_sync_scope($new_scope, $new_all); | |
| 1608 | + $this->enable_auto_sync_for_run(); | |
| 1609 | + update_option('onwebchat_wc_bulk_run_terms', implode(',', array_map('intval', $run_terms))); | |
| 1610 | + update_option('onwebchat_wc_bulk_run_exclude', implode(',', array_map('intval', $run_exclude))); | |
| 1611 | + | |
| 874 | 1612 | // Store the current sync start time |
| 875 | 1613 | update_option('onwebchat_wc_last_sync_start', time()); |
| 876 | - | |
| 1614 | + | |
| 877 | 1615 | // Reset bulk sync progress |
| 878 | 1616 | update_option('onwebchat_wc_bulk_page', 0); |
| 879 | 1617 | update_option('onwebchat_wc_bulk_done', 0); |
| 880 | - | |
| 881 | - // Count total products | |
| 882 | - $args = array( | |
| 883 | - 'post_type' => 'product', | |
| 884 | - 'post_status' => 'publish', | |
| 885 | - 'posts_per_page' => -1, | |
| 886 | - 'fields' => 'ids', | |
| 887 | - ); | |
| 888 | - | |
| 889 | - $products = get_posts($args); | |
| 890 | - $total = count($products); | |
| 891 | - | |
| 1618 | + | |
| 1619 | + // Count the products THIS run will push, capped at the hard limit. | |
| 1620 | + $total = $this->count_products_in_scope($run_terms, $run_exclude); | |
| 1621 | + if ($total > self::MAX_SYNC_PRODUCTS) { | |
| 1622 | + $total = self::MAX_SYNC_PRODUCTS; | |
| 1623 | + } | |
| 1624 | + | |
| 892 | 1625 | update_option('onwebchat_wc_bulk_total', $total); |
| 893 | 1626 | update_option('onwebchat_wc_bulk_done', 0); // Initialize progress counter |
| 894 | 1627 | update_option('onwebchat_wc_bulk_in_progress', true); |
| 895 | - | |
| 1628 | + | |
| 896 | 1629 | // Process sync directly instead of using unreliable WP Cron |
| 897 | - $sync_result = $this->do_bulk_sync_all(); | |
| 898 | - | |
| 1630 | + $sync_result = $this->do_bulk_sync_all($category_ids); | |
| 1631 | + | |
| 899 | 1632 | wp_send_json_success(array( |
| 900 | 1633 | 'message' => 'Bulk sync completed', |
| 901 | 1634 | 'total' => $total, |
| 902 | 1635 | 'result' => $sync_result |
| @@ -903,16 +1636,244 @@ | ||
| 903 | 1636 | )); |
| 904 | 1637 | } |
| 905 | 1638 | |
| 906 | 1639 | /** |
| 907 | - * Process all products in bulk sync directly (not via cron) | |
| 1640 | + * AJAX: begin a client-driven bulk sync. | |
| 1641 | + * | |
| 1642 | + * Sets up the progress state and returns the total number of products to | |
| 1643 | + * sync. The browser then calls ajax_sync_next_batch() repeatedly (one page | |
| 1644 | + * per request) until the run reports it is complete. Because each request is | |
| 1645 | + * short, the whole sync no longer rides on a single request that outran the | |
| 1646 | + * web server timeout and reported a false failure while products kept | |
| 1647 | + * syncing. | |
| 908 | 1648 | */ |
| 909 | - private function do_bulk_sync_all() { | |
| 1649 | + public function ajax_start_bulk_sync() { | |
| 1650 | + check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce'); | |
| 1651 | + | |
| 1652 | + if (!current_user_can('manage_options')) { | |
| 1653 | + wp_send_json_error('Insufficient permissions'); | |
| 1654 | + } | |
| 1655 | + | |
| 1656 | + if (get_option('onwebchat_wc_bulk_in_progress', false)) { | |
| 1657 | + wp_send_json_error('A sync is already in progress. Please wait for it to complete.'); | |
| 1658 | + } | |
| 1659 | + | |
| 1660 | + // Read the chosen sync scope (product_cat term IDs). Empty = whole catalogue. | |
| 1661 | + $category_ids = array(); | |
| 1662 | + if (isset($_POST['categories']) && $_POST['categories'] !== '') { | |
| 1663 | + foreach (explode(',', sanitize_text_field(wp_unslash($_POST['categories']))) as $id) { | |
| 1664 | + $id = (int) trim($id); | |
| 1665 | + if ($id > 0) { | |
| 1666 | + $category_ids[] = $id; | |
| 1667 | + } | |
| 1668 | + } | |
| 1669 | + } | |
| 1670 | + | |
| 1671 | + // Above the hard cap a category selection is required: refuse an | |
| 1672 | + // unrestricted "sync all" when the catalogue is larger than the cap. | |
| 1673 | + $published_total = $this->count_products_in_scope(array()); | |
| 1674 | + if (empty($category_ids) && $published_total > self::MAX_SYNC_PRODUCTS) { | |
| 1675 | + wp_send_json_error(sprintf( | |
| 1676 | + 'Your store has %s products, which is more than can be synced at once (%s). Please select specific categories to sync.', | |
| 1677 | + number_format_i18n($published_total), | |
| 1678 | + number_format_i18n(self::MAX_SYNC_PRODUCTS) | |
| 1679 | + )); | |
| 1680 | + } | |
| 1681 | + | |
| 1682 | + // Remember the merchant's choice so ongoing auto-sync stays within it: | |
| 1683 | + // selected categories become the sync scope; an unrestricted "sync all" | |
| 1684 | + // puts the whole catalogue in scope. | |
| 1685 | + $this->save_sync_scope($category_ids, empty($category_ids)); | |
| 1686 | + $this->enable_auto_sync_for_run(); | |
| 1687 | + | |
| 1688 | + // Count total products within scope, capped at the hard limit. | |
| 1689 | + $total = $this->count_products_in_scope($category_ids); | |
| 1690 | + if ($total > self::MAX_SYNC_PRODUCTS) { | |
| 1691 | + $total = self::MAX_SYNC_PRODUCTS; | |
| 1692 | + } | |
| 1693 | + | |
| 1694 | + // Reset progress state for a fresh run. | |
| 1695 | + update_option('onwebchat_wc_last_sync_start', time()); | |
| 1696 | + update_option('onwebchat_wc_bulk_page', 0); | |
| 1697 | + update_option('onwebchat_wc_bulk_done', 0); | |
| 1698 | + update_option('onwebchat_wc_bulk_total', $total); | |
| 1699 | + update_option('onwebchat_wc_bulk_stats', array('created' => 0, 'updated' => 0, 'skipped' => 0, 'errors' => 0)); | |
| 1700 | + // Only enter the "in progress" state when there is actually something to | |
| 1701 | + // sync, so a 0-product start (empty scope) can't leave the store stuck at | |
| 1702 | + // "a sync is already in progress". | |
| 1703 | + update_option('onwebchat_wc_bulk_in_progress', $total > 0); | |
| 1704 | + | |
| 1705 | + wp_send_json_success(array('total' => $total, 'auto_sync_enabled' => true)); | |
| 1706 | + } | |
| 1707 | + | |
| 1708 | + /** | |
| 1709 | + * AJAX: process the next page of the in-progress bulk sync and report progress. | |
| 1710 | + */ | |
| 1711 | + public function ajax_sync_next_batch() { | |
| 1712 | + check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce'); | |
| 1713 | + | |
| 1714 | + // One page of products waits for the server, which may summarize long | |
| 1715 | + // descriptions with a model: that outlives a default max_execution_time. | |
| 1716 | + @set_time_limit(0); | |
| 1717 | + | |
| 1718 | + if (!current_user_can('manage_options')) { | |
| 1719 | + wp_send_json_error('Insufficient permissions'); | |
| 1720 | + } | |
| 1721 | + | |
| 1722 | + wp_send_json_success($this->sync_next_page()); | |
| 1723 | + } | |
| 1724 | + | |
| 1725 | + /** | |
| 1726 | + * Process exactly one page (batch_size products) of the in-progress bulk | |
| 1727 | + * sync, advancing the persisted progress. The browser calls this once per | |
| 1728 | + * request (via ajax_sync_next_batch) until it reports the run is complete, | |
| 1729 | + * so no single request has to stay open for the whole catalogue. | |
| 1730 | + * | |
| 1731 | + * Unlike do_bulk_sync_all()/the WP-Cron path this does NOT sleep and does | |
| 1732 | + * NOT schedule a follow-up cron event: the browser drives the loop. Stats | |
| 1733 | + * are accumulated in an option across pages so the completion notification | |
| 1734 | + * (which drives the dashboard notice) carries the full run totals. | |
| 1735 | + * | |
| 1736 | + * @return array Progress snapshot: in_progress, complete, done, total, stats. | |
| 1737 | + */ | |
| 1738 | + private function sync_next_page() { | |
| 1739 | + $total = (int) get_option('onwebchat_wc_bulk_total', 0); | |
| 1740 | + | |
| 1741 | + if (!get_option('onwebchat_wc_bulk_in_progress', false)) { | |
| 1742 | + return array( | |
| 1743 | + 'in_progress' => false, | |
| 1744 | + 'complete' => true, | |
| 1745 | + 'done' => (int) get_option('onwebchat_wc_bulk_done', 0), | |
| 1746 | + 'total' => $total, | |
| 1747 | + 'stats' => $this->get_bulk_stats(), | |
| 1748 | + ); | |
| 1749 | + } | |
| 1750 | + | |
| 1751 | + $page = (int) get_option('onwebchat_wc_bulk_page', 0); | |
| 1752 | + $done = (int) get_option('onwebchat_wc_bulk_done', 0); | |
| 1753 | + $stats = $this->get_bulk_stats(); | |
| 1754 | + | |
| 1755 | + $args = array( | |
| 1756 | + 'post_type' => 'product', | |
| 1757 | + 'post_status' => 'publish', | |
| 1758 | + 'posts_per_page' => $this->batch_size, | |
| 1759 | + 'paged' => $page + 1, | |
| 1760 | + 'orderby' => 'ID', | |
| 1761 | + 'order' => 'ASC', | |
| 1762 | + ); | |
| 1763 | + | |
| 1764 | + // Restrict to what THIS run pushes (see ajax_start_bulk_sync): the | |
| 1765 | + // categories being added, minus everything already synced. Falls back to | |
| 1766 | + // the saved scope for a run started before these options existed. | |
| 1767 | + $run_terms_raw = get_option('onwebchat_wc_bulk_run_terms', null); | |
| 1768 | + $run_terms = ($run_terms_raw === null) | |
| 1769 | + ? $this->get_sync_scope() | |
| 1770 | + : $this->parse_id_list($run_terms_raw); | |
| 1771 | + $run_exclude = $this->parse_id_list(get_option('onwebchat_wc_bulk_run_exclude', '')); | |
| 1772 | + | |
| 1773 | + $tax_query = $this->build_scope_tax_query($run_terms, $run_exclude); | |
| 1774 | + if ($tax_query !== null) { | |
| 1775 | + $args['tax_query'] = $tax_query; | |
| 1776 | + } | |
| 1777 | + | |
| 1778 | + $query = new WP_Query($args); | |
| 1779 | + $complete = false; | |
| 1780 | + | |
| 1781 | + if ($query->have_posts()) { | |
| 1782 | + $products_batch = array(); | |
| 1783 | + foreach ($query->posts as $post) { | |
| 1784 | + $product = wc_get_product($post->ID); | |
| 1785 | + if ($product && !$this->is_product_excluded($product)) { | |
| 1786 | + $products_batch[] = $this->prepare_product_data($product); | |
| 1787 | + } | |
| 1788 | + } | |
| 1789 | + | |
| 1790 | + $batch_done = 0; | |
| 1791 | + if (!empty($products_batch)) { | |
| 1792 | + // Tag with run total + running progress so the dashboard bar advances. | |
| 1793 | + $result = $this->send_product_batch($products_batch, $total, $done + count($products_batch)); | |
| 1794 | + if ($result && isset($result['stats'])) { | |
| 1795 | + $batch_done = (int) $result['stats']['created'] + (int) $result['stats']['updated'] + (int) $result['stats']['skipped']; | |
| 1796 | + $stats['created'] += (int) $result['stats']['created']; | |
| 1797 | + $stats['updated'] += (int) $result['stats']['updated']; | |
| 1798 | + $stats['skipped'] += (int) $result['stats']['skipped']; | |
| 1799 | + $stats['errors'] += (int) $result['stats']['errors']; | |
| 1800 | + } else { | |
| 1801 | + // Batch failed outright: count the products as errors so the | |
| 1802 | + // summary reflects reality rather than silently skipping them. | |
| 1803 | + $batch_done = count($products_batch); | |
| 1804 | + $stats['errors'] += count($products_batch); | |
| 1805 | + } | |
| 1806 | + } | |
| 1807 | + | |
| 1808 | + $done += $batch_done; | |
| 1809 | + $page += 1; | |
| 1810 | + | |
| 1811 | + update_option('onwebchat_wc_bulk_page', $page); | |
| 1812 | + update_option('onwebchat_wc_bulk_done', $done); | |
| 1813 | + update_option('onwebchat_wc_bulk_stats', $stats); | |
| 1814 | + | |
| 1815 | + // Stop once we have covered the counted total or reached the hard cap. | |
| 1816 | + if ($done >= $total || ($page * $this->batch_size) >= self::MAX_SYNC_PRODUCTS) { | |
| 1817 | + $complete = true; | |
| 1818 | + } | |
| 1819 | + } else { | |
| 1820 | + // No more products in scope. | |
| 1821 | + $complete = true; | |
| 1822 | + } | |
| 1823 | + | |
| 1824 | + wp_reset_postdata(); | |
| 1825 | + | |
| 1826 | + if ($complete) { | |
| 1827 | + $this->send_sync_completion_notification($stats); | |
| 1828 | + update_option('onwebchat_wc_bulk_in_progress', false); | |
| 1829 | + update_option('onwebchat_wc_last_bulk_sync', current_time('timestamp')); | |
| 1830 | + // Show 100% when the counted total was reached; otherwise leave the | |
| 1831 | + // real processed figure (e.g. an early empty page or the hard cap). | |
| 1832 | + if ($total > 0 && $done >= $total) { | |
| 1833 | + $done = $total; | |
| 1834 | + } | |
| 1835 | + update_option('onwebchat_wc_bulk_done', $done); | |
| 1836 | + } | |
| 1837 | + | |
| 1838 | + return array( | |
| 1839 | + 'in_progress' => !$complete, | |
| 1840 | + 'complete' => $complete, | |
| 1841 | + 'done' => $done, | |
| 1842 | + 'total' => $total, | |
| 1843 | + 'stats' => $stats, | |
| 1844 | + ); | |
| 1845 | + } | |
| 1846 | + | |
| 1847 | + /** | |
| 1848 | + * Read the accumulated bulk-sync stats option, normalised to the four keys. | |
| 1849 | + */ | |
| 1850 | + private function get_bulk_stats() { | |
| 1851 | + $stats = get_option('onwebchat_wc_bulk_stats', array()); | |
| 1852 | + if (!is_array($stats)) { | |
| 1853 | + $stats = array(); | |
| 1854 | + } | |
| 1855 | + return array( | |
| 1856 | + 'created' => isset($stats['created']) ? (int) $stats['created'] : 0, | |
| 1857 | + 'updated' => isset($stats['updated']) ? (int) $stats['updated'] : 0, | |
| 1858 | + 'skipped' => isset($stats['skipped']) ? (int) $stats['skipped'] : 0, | |
| 1859 | + 'errors' => isset($stats['errors']) ? (int) $stats['errors'] : 0, | |
| 1860 | + ); | |
| 1861 | + } | |
| 1862 | + | |
| 1863 | + /** | |
| 1864 | + * Process all products in bulk sync directly (not via cron). | |
| 1865 | + * | |
| 1866 | + * @param array $category_ids Sync scope (product_cat term IDs). Empty = whole catalogue. | |
| 1867 | + */ | |
| 1868 | + private function do_bulk_sync_all($category_ids = array()) { | |
| 910 | 1869 | $total = get_option('onwebchat_wc_bulk_total', 0); |
| 911 | 1870 | $page = 0; |
| 912 | 1871 | $total_done = 0; |
| 1872 | + $considered = 0; // products fetched so far, used to enforce the hard cap | |
| 913 | 1873 | $all_stats = array('created' => 0, 'updated' => 0, 'skipped' => 0, 'errors' => 0); |
| 914 | - | |
| 1874 | + $max = self::MAX_SYNC_PRODUCTS; | |
| 1875 | + | |
| 915 | 1876 | // Process all products in batches |
| 916 | 1877 | while (true) { |
| 917 | 1878 | $args = array( |
| 918 | 1879 | 'post_type' => 'product', |
| @@ -921,24 +1882,41 @@ | ||
| 921 | 1882 | 'paged' => $page + 1, |
| 922 | 1883 | 'orderby' => 'ID', |
| 923 | 1884 | 'order' => 'ASC', |
| 924 | 1885 | ); |
| 925 | - | |
| 1886 | + | |
| 1887 | + // Restrict to the chosen categories and their subtrees, to match the | |
| 1888 | + // per-product scope check and the counts shown in the picker. | |
| 1889 | + if (!empty($category_ids)) { | |
| 1890 | + $args['tax_query'] = array(array( | |
| 1891 | + 'taxonomy' => 'product_cat', | |
| 1892 | + 'field' => 'term_id', | |
| 1893 | + 'terms' => array_map('intval', $category_ids), | |
| 1894 | + 'include_children' => true, | |
| 1895 | + )); | |
| 1896 | + } | |
| 1897 | + | |
| 926 | 1898 | $query = new WP_Query($args); |
| 927 | - | |
| 1899 | + | |
| 928 | 1900 | if (!$query->have_posts()) { |
| 929 | 1901 | break; |
| 930 | 1902 | } |
| 931 | - | |
| 932 | - // Collect products in this batch | |
| 1903 | + | |
| 1904 | + // Collect products in this batch, honoring the hard cap. | |
| 933 | 1905 | $products_batch = array(); |
| 1906 | + $reached_cap = false; | |
| 934 | 1907 | foreach ($query->posts as $post) { |
| 1908 | + if ($considered >= $max) { | |
| 1909 | + $reached_cap = true; | |
| 1910 | + break; | |
| 1911 | + } | |
| 1912 | + $considered++; | |
| 935 | 1913 | $product = wc_get_product($post->ID); |
| 936 | 1914 | if ($product && !$this->is_product_excluded($product)) { |
| 937 | 1915 | $products_batch[] = $this->prepare_product_data($product); |
| 938 | 1916 | } |
| 939 | 1917 | } |
| 940 | - | |
| 1918 | + | |
| 941 | 1919 | // Send batch |
| 942 | 1920 | if (!empty($products_batch)) { |
| 943 | 1921 | $result = $this->send_product_batch($products_batch); |
| 944 | 1922 | if ($result && isset($result['stats'])) { |
| @@ -950,23 +1928,33 @@ | ||
| 950 | 1928 | } else { |
| 951 | 1929 | // Fallback |
| 952 | 1930 | $total_done += count($products_batch); |
| 953 | 1931 | } |
| 954 | - | |
| 1932 | + | |
| 955 | 1933 | // Update progress after each batch so AJAX polling can see it |
| 956 | 1934 | update_option('onwebchat_wc_bulk_done', $total_done); |
| 957 | - | |
| 958 | - // Wait 4 seconds before next batch | |
| 959 | - sleep(4); | |
| 1935 | + | |
| 1936 | + // Breathe between batches so a long run cannot walk into the | |
| 1937 | + // server's product-sync rate limit (150 requests per 5 minutes | |
| 1938 | + // per IP). One second is plenty: each batch already costs a | |
| 1939 | + // synchronous HTTP call of its own, so the real cycle time is | |
| 1940 | + // seconds even when nothing needs summarizing. | |
| 1941 | + sleep(1); | |
| 960 | 1942 | } |
| 961 | - | |
| 1943 | + | |
| 962 | 1944 | wp_reset_postdata(); |
| 963 | 1945 | $page++; |
| 964 | - | |
| 965 | - // Safety check - don't loop forever | |
| 966 | - if ($page > 100) { | |
| 1946 | + | |
| 1947 | + // Stop once the hard cap is reached. | |
| 1948 | + if ($reached_cap || $considered >= $max) { | |
| 967 | 1949 | break; |
| 968 | 1950 | } |
| 1951 | + | |
| 1952 | + // Safety check - don't loop forever. The cap allows up to | |
| 1953 | + // MAX_SYNC_PRODUCTS / batch_size batches, so keep a generous guard. | |
| 1954 | + if ($page > ($max / $this->batch_size) + 10) { | |
| 1955 | + break; | |
| 1956 | + } | |
| 969 | 1957 | } |
| 970 | 1958 | |
| 971 | 1959 | // Send completion notification to Angular dashboard with total stats |
| 972 | 1960 | $this->send_sync_completion_notification($all_stats); |
| @@ -998,9 +1986,9 @@ | ||
| 998 | 1986 | $done = get_option('onwebchat_wc_bulk_done', 0); |
| 999 | 1987 | $total = get_option('onwebchat_wc_bulk_total', 0); |
| 1000 | 1988 | |
| 1001 | 1989 | error_log('onWebChat WooCommerce Sync - Starting batch: page=' . $page . ', done=' . $done . ', total=' . $total); |
| 1002 | - | |
| 1990 | + | |
| 1003 | 1991 | // Get batch of products |
| 1004 | 1992 | $args = array( |
| 1005 | 1993 | 'post_type' => 'product', |
| 1006 | 1994 | 'post_status' => 'publish', |
| @@ -1008,9 +1996,21 @@ | ||
| 1008 | 1996 | 'paged' => $page + 1, |
| 1009 | 1997 | 'orderby' => 'ID', |
| 1010 | 1998 | 'order' => 'ASC', |
| 1011 | 1999 | ); |
| 1012 | - | |
| 2000 | + | |
| 2001 | + // Restrict to the saved sync scope and its subcategories, consistent | |
| 2002 | + // with the synchronous bulk sync path. | |
| 2003 | + $scope = $this->get_sync_scope(); | |
| 2004 | + if (!empty($scope)) { | |
| 2005 | + $args['tax_query'] = array(array( | |
| 2006 | + 'taxonomy' => 'product_cat', | |
| 2007 | + 'field' => 'term_id', | |
| 2008 | + 'terms' => array_map('intval', $scope), | |
| 2009 | + 'include_children' => true, | |
| 2010 | + )); | |
| 2011 | + } | |
| 2012 | + | |
| 1013 | 2013 | $query = new WP_Query($args); |
| 1014 | 2014 | |
| 1015 | 2015 | if ($query->have_posts()) { |
| 1016 | 2016 | // Collect all products in this batch |
| @@ -1168,8 +2168,199 @@ | ||
| 1168 | 2168 | 'total' => $total, |
| 1169 | 2169 | ); |
| 1170 | 2170 | } |
| 1171 | 2171 | |
| 2172 | + /** | |
| 2173 | + * AJAX: start removing categories from the AI training data. | |
| 2174 | + * | |
| 2175 | + * The counterpart of the additive sync scope: unticking a category never | |
| 2176 | + * removes anything by itself, the merchant has to ask for it here. Posts the | |
| 2177 | + * categories that should REMAIN ticked; whatever the saved scope holds on top | |
| 2178 | + * of that is what gets removed, together with its products, unless those | |
| 2179 | + * products also sit in a category that stays. | |
| 2180 | + */ | |
| 2181 | + public function ajax_scope_remove_start() { | |
| 2182 | + check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce'); | |
| 2183 | + | |
| 2184 | + if (!current_user_can('manage_options')) { | |
| 2185 | + wp_send_json_error('Insufficient permissions'); | |
| 2186 | + } | |
| 2187 | + | |
| 2188 | + if (get_option('onwebchat_wc_bulk_in_progress', false)) { | |
| 2189 | + wp_send_json_error('A sync is in progress. Please wait for it to finish.'); | |
| 2190 | + } | |
| 2191 | + | |
| 2192 | + @set_time_limit(0); | |
| 2193 | + | |
| 2194 | + $keep = array(); | |
| 2195 | + if (isset($_POST['categories']) && $_POST['categories'] !== '') { | |
| 2196 | + $keep = $this->parse_id_list(sanitize_text_field(wp_unslash($_POST['categories']))); | |
| 2197 | + } | |
| 2198 | + | |
| 2199 | + $saved_scope = $this->get_sync_scope(); | |
| 2200 | + if (empty($saved_scope)) { | |
| 2201 | + 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.'); | |
| 2202 | + } | |
| 2203 | + | |
| 2204 | + $removed = $this->categories_removed($keep, $saved_scope); | |
| 2205 | + if (empty($removed)) { | |
| 2206 | + wp_send_json_error('No synced categories were unticked, so there is nothing to remove.'); | |
| 2207 | + } | |
| 2208 | + | |
| 2209 | + // Products of the dropped categories that are not also in a category the | |
| 2210 | + // merchant keeps: a product in both stays in the training data. | |
| 2211 | + $total = $this->count_products_in_scope($removed, $keep); | |
| 2212 | + | |
| 2213 | + update_option('onwebchat_wc_remove_terms', implode(',', $removed)); | |
| 2214 | + update_option('onwebchat_wc_remove_keep', implode(',', $keep)); | |
| 2215 | + update_option('onwebchat_wc_remove_total', $total); | |
| 2216 | + update_option('onwebchat_wc_remove_done', 0); | |
| 2217 | + update_option('onwebchat_wc_remove_in_progress', true); | |
| 2218 | + | |
| 2219 | + $complete = ($total === 0); | |
| 2220 | + if ($complete) { | |
| 2221 | + $this->finish_scope_removal(); | |
| 2222 | + } | |
| 2223 | + | |
| 2224 | + wp_send_json_success(array( | |
| 2225 | + 'total' => $total, | |
| 2226 | + 'categories' => count($removed), | |
| 2227 | + 'done' => 0, | |
| 2228 | + 'complete' => $complete, | |
| 2229 | + // Removing everything also switches automatic product sync off, see | |
| 2230 | + // finish_scope_removal(); the UI says so before the merchant confirms. | |
| 2231 | + 'disables_sync' => empty($keep), | |
| 2232 | + )); | |
| 2233 | + } | |
| 2234 | + | |
| 2235 | + /** | |
| 2236 | + * AJAX: delete one page of products of the categories being removed. | |
| 2237 | + * The browser calls this until it reports complete, exactly like the sync. | |
| 2238 | + */ | |
| 2239 | + public function ajax_scope_remove_batch() { | |
| 2240 | + check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce'); | |
| 2241 | + | |
| 2242 | + if (!current_user_can('manage_options')) { | |
| 2243 | + wp_send_json_error('Insufficient permissions'); | |
| 2244 | + } | |
| 2245 | + | |
| 2246 | + @set_time_limit(0); | |
| 2247 | + | |
| 2248 | + if (!get_option('onwebchat_wc_remove_in_progress', false)) { | |
| 2249 | + wp_send_json_success(array( | |
| 2250 | + 'complete' => true, | |
| 2251 | + 'done' => (int) get_option('onwebchat_wc_remove_done', 0), | |
| 2252 | + 'total' => (int) get_option('onwebchat_wc_remove_total', 0), | |
| 2253 | + )); | |
| 2254 | + } | |
| 2255 | + | |
| 2256 | + $removed = $this->parse_id_list(get_option('onwebchat_wc_remove_terms', '')); | |
| 2257 | + $keep = $this->parse_id_list(get_option('onwebchat_wc_remove_keep', '')); | |
| 2258 | + $total = (int) get_option('onwebchat_wc_remove_total', 0); | |
| 2259 | + $done = (int) get_option('onwebchat_wc_remove_done', 0); | |
| 2260 | + | |
| 2261 | + // Never query without a category restriction. An empty $removed would make | |
| 2262 | + // build_scope_tax_query() return no clause at all, and this page would then | |
| 2263 | + // delete the first 200 products of the WHOLE catalogue from the training | |
| 2264 | + // data. That can only happen if the run state was lost half way (option | |
| 2265 | + // cleared, in-progress flag left behind), so treat it as "nothing to do". | |
| 2266 | + if (empty($removed)) { | |
| 2267 | + update_option('onwebchat_wc_remove_in_progress', false); | |
| 2268 | + delete_option('onwebchat_wc_remove_terms'); | |
| 2269 | + delete_option('onwebchat_wc_remove_keep'); | |
| 2270 | + | |
| 2271 | + wp_send_json_success(array( | |
| 2272 | + 'complete' => true, | |
| 2273 | + 'done' => $done, | |
| 2274 | + 'total' => $total, | |
| 2275 | + )); | |
| 2276 | + } | |
| 2277 | + | |
| 2278 | + $args = array( | |
| 2279 | + 'post_type' => 'product', | |
| 2280 | + 'post_status' => 'publish', | |
| 2281 | + 'posts_per_page' => self::REMOVE_PAGE_SIZE, | |
| 2282 | + 'orderby' => 'ID', | |
| 2283 | + 'order' => 'ASC', | |
| 2284 | + 'fields' => 'ids', | |
| 2285 | + // Deleting on the onWebChat side never changes this query, but the | |
| 2286 | + // rows already handled must be skipped, hence the offset. | |
| 2287 | + 'offset' => $done, | |
| 2288 | + ); | |
| 2289 | + | |
| 2290 | + $tax_query = $this->build_scope_tax_query($removed, $keep); | |
| 2291 | + if ($tax_query !== null) { | |
| 2292 | + $args['tax_query'] = $tax_query; | |
| 2293 | + } | |
| 2294 | + | |
| 2295 | + $query = new WP_Query($args); | |
| 2296 | + $ids = $query->posts; | |
| 2297 | + | |
| 2298 | + if (empty($ids)) { | |
| 2299 | + $this->finish_scope_removal(); | |
| 2300 | + return wp_send_json_success(array( | |
| 2301 | + 'complete' => true, | |
| 2302 | + 'done' => $done, | |
| 2303 | + 'total' => $total, | |
| 2304 | + )); | |
| 2305 | + } | |
| 2306 | + | |
| 2307 | + $result = $this->send_products_delete_batch($ids); | |
| 2308 | + if (empty($result['success'])) { | |
| 2309 | + wp_send_json_error('Could not remove the products from onWebChat. Please try again.'); | |
| 2310 | + } | |
| 2311 | + | |
| 2312 | + $done += count($ids); | |
| 2313 | + update_option('onwebchat_wc_remove_done', $done); | |
| 2314 | + | |
| 2315 | + $complete = ($done >= $total) || (count($ids) < self::REMOVE_PAGE_SIZE); | |
| 2316 | + if ($complete) { | |
| 2317 | + $this->finish_scope_removal(); | |
| 2318 | + } | |
| 2319 | + | |
| 2320 | + wp_send_json_success(array( | |
| 2321 | + 'complete' => $complete, | |
| 2322 | + 'done' => min($done, max($total, $done)), | |
| 2323 | + 'total' => max($total, $done), | |
| 2324 | + )); | |
| 2325 | + } | |
| 2326 | + | |
| 2327 | + /** | |
| 2328 | + * Close a removal run: the kept categories become the new sync scope, so | |
| 2329 | + * ongoing auto-sync stops covering what was just removed. | |
| 2330 | + * | |
| 2331 | + * @return bool | |
| 2332 | + */ | |
| 2333 | + private function finish_scope_removal() { | |
| 2334 | + $keep = $this->parse_id_list(get_option('onwebchat_wc_remove_keep', '')); | |
| 2335 | + $had_scope = (bool) $this->get_sync_scope(); | |
| 2336 | + | |
| 2337 | + // After a removal the scope is exactly what is kept, nothing implied: an | |
| 2338 | + // empty list here means the AI training data holds no products, not the | |
| 2339 | + // whole catalogue. Only when something was really removed, so a no-op | |
| 2340 | + // call on a site that syncs everything leaves its scope alone. | |
| 2341 | + if ($had_scope || !empty($keep)) { | |
| 2342 | + $this->save_sync_scope($keep, false); | |
| 2343 | + } | |
| 2344 | + | |
| 2345 | + // Nothing left ticked means the bot should hold no products at all. An | |
| 2346 | + // empty scope means "the whole catalogue", so leaving automatic sync on | |
| 2347 | + // would push every product straight back in on its next edit. | |
| 2348 | + // Only when a scope was actually being removed: a no-op call on a store | |
| 2349 | + // that already syncs its whole catalogue must never touch the toggle. | |
| 2350 | + if (empty($keep) && $had_scope) { | |
| 2351 | + update_option('onwebchat_wc_sync_enabled', false); | |
| 2352 | + // Note who turned it off, so the next bulk sync can turn it back on. | |
| 2353 | + update_option('onwebchat_wc_sync_off_by_removal', true); | |
| 2354 | + } | |
| 2355 | + | |
| 2356 | + update_option('onwebchat_wc_remove_in_progress', false); | |
| 2357 | + delete_option('onwebchat_wc_remove_terms'); | |
| 2358 | + delete_option('onwebchat_wc_remove_keep'); | |
| 2359 | + | |
| 2360 | + return true; | |
| 2361 | + } | |
| 2362 | + | |
| 1172 | 2363 | /** |
| 1173 | 2364 | * AJAX: Manually process batch (for debugging) |
| 1174 | 2365 | */ |
| 1175 | 2366 | public function ajax_manual_process_batch() { |