Changeset 3752558
- Timestamp:
- 10/18/2026 05:06:35 AM (4 weeks ago)
- Location:
- multi-store-sync-addon-for-flexstock
- Files:
-
- 142 added
- 8 deleted
- 26 edited
- 1 copied
-
tags/1.1.0 (copied) (copied from multi-store-sync-addon-for-flexstock/trunk )
-
tags/1.1.0/assets/build (deleted)
-
tags/1.1.0/assets/src (deleted)
-
tags/1.1.0/build (added)
-
tags/1.1.0/build/index-rtl.css (added)
-
tags/1.1.0/build/index.asset.php (added)
-
tags/1.1.0/build/index.css (added)
-
tags/1.1.0/build/index.js (added)
-
tags/1.1.0/includes/boot.php (modified) (1 diff)
-
tags/1.1.0/includes/classes/class-ajax.php (modified) (28 diffs)
-
tags/1.1.0/includes/classes/class-api.php (modified) (18 diffs)
-
tags/1.1.0/includes/classes/class-app.php (modified) (3 diffs)
-
tags/1.1.0/includes/classes/class-hooks.php (modified) (2 diffs)
-
tags/1.1.0/includes/classes/class-strings.php (added)
-
tags/1.1.0/includes/classes/export-functions.php (modified) (40 diffs)
-
tags/1.1.0/includes/classes/functions.php (modified) (33 diffs)
-
tags/1.1.0/includes/classes/import-function.php (modified) (46 diffs)
-
tags/1.1.0/includes/wppool/class-plugin.php (modified) (5 diffs)
-
tags/1.1.0/multi-store-sync-addon-for-flexstock.php (modified) (3 diffs)
-
tags/1.1.0/package.json (modified) (1 diff)
-
tags/1.1.0/readme.txt (modified) (6 diffs)
-
tags/1.1.0/src (added)
-
tags/1.1.0/src/App.jsx (added)
-
tags/1.1.0/src/Helpers.js (added)
-
tags/1.1.0/src/components (added)
-
tags/1.1.0/src/components/BackgroundSyncToast.jsx (added)
-
tags/1.1.0/src/components/ConfirmModal.jsx (added)
-
tags/1.1.0/src/components/DashboardHeader.jsx (added)
-
tags/1.1.0/src/components/DestinationView.jsx (added)
-
tags/1.1.0/src/components/FeedbackTopbar.jsx (added)
-
tags/1.1.0/src/components/ImportReport.jsx (added)
-
tags/1.1.0/src/components/KebabMenu.jsx (added)
-
tags/1.1.0/src/components/ManageView.jsx (added)
-
tags/1.1.0/src/components/ProOverlay.jsx (added)
-
tags/1.1.0/src/components/ServerMarkup.jsx (added)
-
tags/1.1.0/src/components/Sidebar.jsx (added)
-
tags/1.1.0/src/components/StoreCard.jsx (added)
-
tags/1.1.0/src/components/SyncProgressModal.jsx (added)
-
tags/1.1.0/src/components/Transition.jsx (added)
-
tags/1.1.0/src/components/connect (added)
-
tags/1.1.0/src/components/connect/ConnectStoreWizard.jsx (added)
-
tags/1.1.0/src/components/connect/StepPreview.jsx (added)
-
tags/1.1.0/src/components/connect/StepSheetTab.jsx (added)
-
tags/1.1.0/src/components/connect/StepVerify.jsx (added)
-
tags/1.1.0/src/components/token (added)
-
tags/1.1.0/src/components/token/AttributeFilterRows.jsx (added)
-
tags/1.1.0/src/components/token/CreateTokenWizard.jsx (added)
-
tags/1.1.0/src/components/token/EditTokenModal.jsx (added)
-
tags/1.1.0/src/components/token/FieldsDropdown.jsx (added)
-
tags/1.1.0/src/components/token/ShareDetails.jsx (added)
-
tags/1.1.0/src/components/token/ShareTokenModal.jsx (added)
-
tags/1.1.0/src/components/token/SkuCheckNotice.jsx (added)
-
tags/1.1.0/src/components/token/StepGenerate.jsx (added)
-
tags/1.1.0/src/components/token/StepPermissions.jsx (added)
-
tags/1.1.0/src/components/token/TermDropdown.jsx (added)
-
tags/1.1.0/src/components/token/TokenCard.jsx (added)
-
tags/1.1.0/src/components/token/TokenList.jsx (added)
-
tags/1.1.0/src/contexts (added)
-
tags/1.1.0/src/contexts/DashboardContext.jsx (added)
-
tags/1.1.0/src/hooks (added)
-
tags/1.1.0/src/hooks/useClickOutside.js (added)
-
tags/1.1.0/src/icons.js (added)
-
tags/1.1.0/src/index.js (added)
-
tags/1.1.0/src/js (added)
-
tags/1.1.0/src/js/clipboard.js (added)
-
tags/1.1.0/src/js/format.js (added)
-
tags/1.1.0/src/js/permissions.js (added)
-
tags/1.1.0/src/js/proText.js (added)
-
tags/1.1.0/src/js/show.js (added)
-
tags/1.1.0/src/js/toast.js (added)
-
tags/1.1.0/src/js/wppool.js (added)
-
tags/1.1.0/src/lib (added)
-
tags/1.1.0/src/lib/actions (added)
-
tags/1.1.0/src/lib/actions/connectActions.js (added)
-
tags/1.1.0/src/lib/actions/siteActions.js (added)
-
tags/1.1.0/src/lib/actions/syncActions.js (added)
-
tags/1.1.0/src/lib/actions/tokenActions.js (added)
-
tags/1.1.0/src/lib/ajax.js (added)
-
tags/1.1.0/src/lib/bgSyncStorage.js (added)
-
tags/1.1.0/src/lib/createStore.js (added)
-
tags/1.1.0/src/pages (added)
-
tags/1.1.0/src/pages/Dashboard.jsx (added)
-
tags/1.1.0/src/pages/License.jsx (added)
-
tags/1.1.0/src/pages/SetupWizard.jsx (added)
-
tags/1.1.0/src/scss (added)
-
tags/1.1.0/src/scss/admin.scss (added)
-
tags/1.1.0/templates (deleted)
-
tags/1.1.0/vendor/appsero/src/Client.php (modified) (2 diffs)
-
tags/1.1.0/vendor/appsero/src/License.php (deleted)
-
trunk/assets/build (deleted)
-
trunk/assets/src (deleted)
-
trunk/build (added)
-
trunk/build/index-rtl.css (added)
-
trunk/build/index.asset.php (added)
-
trunk/build/index.css (added)
-
trunk/build/index.js (added)
-
trunk/includes/boot.php (modified) (1 diff)
-
trunk/includes/classes/class-ajax.php (modified) (28 diffs)
-
trunk/includes/classes/class-api.php (modified) (18 diffs)
-
trunk/includes/classes/class-app.php (modified) (3 diffs)
-
trunk/includes/classes/class-hooks.php (modified) (2 diffs)
-
trunk/includes/classes/class-strings.php (added)
-
trunk/includes/classes/export-functions.php (modified) (40 diffs)
-
trunk/includes/classes/functions.php (modified) (33 diffs)
-
trunk/includes/classes/import-function.php (modified) (46 diffs)
-
trunk/includes/wppool/class-plugin.php (modified) (5 diffs)
-
trunk/multi-store-sync-addon-for-flexstock.php (modified) (3 diffs)
-
trunk/package.json (modified) (1 diff)
-
trunk/readme.txt (modified) (6 diffs)
-
trunk/src (added)
-
trunk/src/App.jsx (added)
-
trunk/src/Helpers.js (added)
-
trunk/src/components (added)
-
trunk/src/components/BackgroundSyncToast.jsx (added)
-
trunk/src/components/ConfirmModal.jsx (added)
-
trunk/src/components/DashboardHeader.jsx (added)
-
trunk/src/components/DestinationView.jsx (added)
-
trunk/src/components/FeedbackTopbar.jsx (added)
-
trunk/src/components/ImportReport.jsx (added)
-
trunk/src/components/KebabMenu.jsx (added)
-
trunk/src/components/ManageView.jsx (added)
-
trunk/src/components/ProOverlay.jsx (added)
-
trunk/src/components/ServerMarkup.jsx (added)
-
trunk/src/components/Sidebar.jsx (added)
-
trunk/src/components/StoreCard.jsx (added)
-
trunk/src/components/SyncProgressModal.jsx (added)
-
trunk/src/components/Transition.jsx (added)
-
trunk/src/components/connect (added)
-
trunk/src/components/connect/ConnectStoreWizard.jsx (added)
-
trunk/src/components/connect/StepPreview.jsx (added)
-
trunk/src/components/connect/StepSheetTab.jsx (added)
-
trunk/src/components/connect/StepVerify.jsx (added)
-
trunk/src/components/token (added)
-
trunk/src/components/token/AttributeFilterRows.jsx (added)
-
trunk/src/components/token/CreateTokenWizard.jsx (added)
-
trunk/src/components/token/EditTokenModal.jsx (added)
-
trunk/src/components/token/FieldsDropdown.jsx (added)
-
trunk/src/components/token/ShareDetails.jsx (added)
-
trunk/src/components/token/ShareTokenModal.jsx (added)
-
trunk/src/components/token/SkuCheckNotice.jsx (added)
-
trunk/src/components/token/StepGenerate.jsx (added)
-
trunk/src/components/token/StepPermissions.jsx (added)
-
trunk/src/components/token/TermDropdown.jsx (added)
-
trunk/src/components/token/TokenCard.jsx (added)
-
trunk/src/components/token/TokenList.jsx (added)
-
trunk/src/contexts (added)
-
trunk/src/contexts/DashboardContext.jsx (added)
-
trunk/src/hooks (added)
-
trunk/src/hooks/useClickOutside.js (added)
-
trunk/src/icons.js (added)
-
trunk/src/index.js (added)
-
trunk/src/js (added)
-
trunk/src/js/clipboard.js (added)
-
trunk/src/js/format.js (added)
-
trunk/src/js/permissions.js (added)
-
trunk/src/js/proText.js (added)
-
trunk/src/js/show.js (added)
-
trunk/src/js/toast.js (added)
-
trunk/src/js/wppool.js (added)
-
trunk/src/lib (added)
-
trunk/src/lib/actions (added)
-
trunk/src/lib/actions/connectActions.js (added)
-
trunk/src/lib/actions/siteActions.js (added)
-
trunk/src/lib/actions/syncActions.js (added)
-
trunk/src/lib/actions/tokenActions.js (added)
-
trunk/src/lib/ajax.js (added)
-
trunk/src/lib/bgSyncStorage.js (added)
-
trunk/src/lib/createStore.js (added)
-
trunk/src/pages (added)
-
trunk/src/pages/Dashboard.jsx (added)
-
trunk/src/pages/License.jsx (added)
-
trunk/src/pages/SetupWizard.jsx (added)
-
trunk/src/scss (added)
-
trunk/src/scss/admin.scss (added)
-
trunk/templates (deleted)
-
trunk/vendor/appsero/src/Client.php (modified) (2 diffs)
-
trunk/vendor/appsero/src/License.php (deleted)
Legend:
- Unmodified
- Added
- Removed
-
multi-store-sync-addon-for-flexstock/tags/1.1.0/includes/boot.php
r3609617 r3752558 17 17 require_once MULTISSAF_MULTI_STORE_INCLUDES . 'classes/import-function.php'; 18 18 require_once MULTISSAF_MULTI_STORE_INCLUDES . 'classes/export-functions.php'; 19 require_once MULTISSAF_MULTI_STORE_INCLUDES . 'classes/class-strings.php'; 19 20 // Loads app. 20 21 require_once MULTISSAF_MULTI_STORE_INCLUDES . 'classes/class-app.php'; -
multi-store-sync-addon-for-flexstock/tags/1.1.0/includes/classes/class-ajax.php
r3706977 r3752558 26 26 27 27 /** 28 * Is license active28 * App class 29 29 * 30 * @var bool30 * @var App 31 31 */ 32 32 public $app; 33 33 34 34 /** 35 * Contains license information.35 * Home URL reported by the source store in the last connection handshake. 36 36 * 37 * @var mixed38 */ 39 p ublic $license;37 * @var string 38 */ 39 private $handshake_source_url = ''; 40 40 41 41 /** … … 56 56 public function add_actions() { 57 57 add_action('wp_ajax_multissaf_multistore_setting', [$this, 'multissaf_save_multi_store_setting']); 58 add_action('wp_ajax_multissaf_request_for_connect', [$this, 'multissaf_request_for_connect']);59 58 add_action('wp_ajax_multissaf_site_remove', [$this, 'multissaf_site_remove']); 60 add_action('wp_ajax_multissaf_mode_change', [$this, 'multissaf_mode_change']);61 add_action('wp_ajax_multissaf_fetch_sku_product', [$this, 'multissaf_fetch_sku_product']);62 add_action('wp_ajax_multissaf_marge_sub_site', [$this, 'multissaf_marge_sub_site']);63 59 add_action('wp_ajax_multissaf_generate_token', [$this, 'multissaf_generate_token']); 64 60 add_action('wp_ajax_multissaf_save_token_settings', [$this, 'multissaf_save_token_settings']); … … 75 71 add_action('wp_ajax_multissaf_manual_import_products', [$this, 'multissaf_manual_import_products']); 76 72 add_action('wp_ajax_multissaf_update_last_sync_time', [$this, 'multissaf_update_last_sync_time']); 77 } 78 /** 79 * Fetches SKU product count with proper authentication and permission checks. 80 * 81 */ 82 public function multissaf_fetch_sku_product() { 83 if ( ! current_user_can('manage_options') ) { 84 $this->send_json(false, __('You do not have permission to do this', 'multi-store-sync-addon-for-flexstock')); 85 } 86 87 $body = $this->get_body(); 88 if ( ! isset($body->nonce) || ! wp_verify_nonce($body->nonce, 'ssgsw_nonce') ) { 89 $this->send_json(false, __('Invalid nonce', 'multi-store-sync-addon-for-flexstock')); 90 } 91 92 $product_count = multissaf_count_all_product_multi_store(); 93 $this->send_json(true, $product_count); 94 } 95 96 /** 97 * Handles the merging of a sub-site's settings and data. 98 * 99 * 100 * @return void Returns a JSON response on failure and proceeds with the logic on success. 101 */ 102 public function multissaf_marge_sub_site() { 103 if ( ! current_user_can('manage_options') ) { 104 $this->send_json(false, __('You do not have permission to do this', 'multi-store-sync-addon-for-flexstock')); 105 } 106 107 $body = $this->get_body(); 108 if ( ! isset($body->nonce) || ! wp_verify_nonce($body->nonce, 'ssgsw_nonce') ) { 109 $this->send_json(false, __('Invalid nonce', 'multi-store-sync-addon-for-flexstock')); 110 } 111 $update = multissaf_get_batch_products($body->offset, $body->batchSize); 112 113 $product_count = 0; 114 if ( is_array($update) && !empty($update)) { 115 $product_count = count($update); 116 } 117 118 $site_url = $body->site_url; 119 $api_url = $site_url .'/wp-json/ssgsw/v1/marge_with_parent'; 120 $token = $body->site_token; 121 $refarance_shop = home_url(); 122 $body = [ 123 'batch_info' => [ 124 'offset' => $body->offset, 125 'batchSize' => $body->batchSize, 126 'index_number' => $body->index_number 127 ], 128 'ssgsw_products' => $update, 129 'ssgsw_api' => true, 130 'refarance_shop' => $refarance_shop 131 ]; 132 133 $response = multissaf_send_api_get_request($api_url, $body, $token); 134 if ($response) { 135 if ($response->success) { 136 $modified_response = [ 137 'count' => $product_count 138 ]; 139 $this->send_json(true, $modified_response); 140 } 141 } else { 142 $this->send_json(false, "Faild to marge product with child."); 143 } 144 } 145 146 /** 147 * Remove site. 148 * 149 * This function handles the logic for removing a parent site from the stored options. 150 * * @return void 151 */ 152 public function multissaf_mode_change() { 153 $body = $this->get_body(); 154 155 if ( ! isset($body->nonce) || ! wp_verify_nonce($body->nonce, 'ssgsw_nonce') ) { 156 $this->send_json(false, __('Invalid nonce', 'multi-store-sync-addon-for-flexstock')); 157 } 158 159 // Check permission. 160 if ( ! current_user_can('manage_options') ) { 161 $this->send_json(false, __('You do not have permission to do this', 'multi-store-sync-addon-for-flexstock')); 162 } 163 164 if ( ! isset($body->options) ) { 165 $this->send_json(false, __('Options not set', 'multi-store-sync-addon-for-flexstock')); 166 } 167 168 $ssgsw_mode = $body->options[ 'ssgsw_mode' ] ?? false; 169 if ($ssgsw_mode) { 170 update_option('ssgsw_multi_store_mode_on', $ssgsw_mode); 171 $this->send_json(true, __('Options saved', 'multi-store-sync-addon-for-flexstock')); 172 } else { 173 $this->send_json(false, __('Options saved', 'multi-store-sync-addon-for-flexstock')); 174 } 73 add_action('wp_ajax_multissaf_repair_main_sheet', [$this, 'multissaf_repair_main_sheet']); 175 74 } 176 75 /** … … 326 225 327 226 /** 328 * Handles a request to connect a site.329 *330 * @param \WP_REST_Request $request The incoming REST API request object.331 * @return \WP_REST_Response|\WP_Error A WP_REST_Response object on success or a WP_Error object on failure.332 */333 public function multissaf_request_for_connect() {334 $body = $this->get_body();335 336 if ( ! isset($body->nonce) || ! wp_verify_nonce($body->nonce, 'ssgsw_nonce') ) {337 $this->send_json(false, __('Invalid nonce', 'multi-store-sync-addon-for-flexstock'));338 }339 340 // Check permission.341 if ( ! current_user_can('manage_options') ) {342 $this->send_json(false, __('You do not have permission to do this', 'multi-store-sync-addon-for-flexstock'));343 }344 345 //Get body from request346 if ( ! isset($body->options) ) {347 $this->send_json(false, __('Options not set', 'multi-store-sync-addon-for-flexstock'));348 }349 350 // 1 store by design to solve server resource.351 $existing_sites = get_option( 'ssgsw_multi_store_list', [] );352 if ( ! is_array( $existing_sites ) ) {353 $existing_sites = [];354 }355 356 357 $child_site = $body->options[ 'child_site' ] ?? false;358 $home_url = isset($child_site['url']) ? $child_site['url'] : '';359 $token = isset($child_site['token']) ? $child_site['token'] : '';360 $body = [361 'request_site' => [362 'url' => home_url(),363 'token' => get_option('ssgsw_token')364 ],365 'connection' => 'add_parent'366 ];367 if(!$home_url) {368 $this->send_json(false, __('Site URL not exits', 'multi-store-sync-addon-for-flexstock'));369 }370 if(!$token) {371 $this->send_json(false, __('Token not exits', 'multi-store-sync-addon-for-flexstock'));372 }373 $api_url = $home_url .'/wp-json/ssgsw/v1/connections';374 $response = multissaf_send_api_post_request($api_url, $body, $token);375 376 if($response) {377 $child_site['isConnected'] = true;378 multissaf_save_child_site($child_site);379 $this->send_json(true, __('Options saved', 'multi-store-sync-addon-for-flexstock'));380 } else {381 382 $this->send_json(false, __('Failed to connect', 'multi-store-sync-addon-for-flexstock'));383 }384 385 }386 /**387 227 * Saves multi-store settings. 388 228 * … … 426 266 } 427 267 } else { 428 //error_log('DEBUG: No settings found in options!');429 268 } 430 269 … … 507 346 wp_die(); 508 347 } 509 /** 510 * Gets the sanitized request body. 511 * Callers must verify nonce and user capabilities. 348 /** 349 * Gets the request body. 512 350 * 513 351 * @return object 514 352 */ 515 public function get_body() { 516 $inputs = []; 517 $raw_input = file_get_contents('php://input'); 518 if (!empty($raw_input)) { 519 $decoded = json_decode($raw_input, true); 520 if (is_array($decoded)) { 521 $inputs = $decoded; 522 } 523 } elseif (!empty($_POST)) { 524 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce and capabilities are validated by calling handler methods. 525 $inputs = wp_unslash($_POST); 526 } 527 528 return (object) $this->sanitize($inputs); 353 public function get_body() { //phpcs:ignore 354 $inputs = json_decode(file_get_contents('php://input'), true); 355 356 if ( is_array($inputs) ) { 357 return (object) $this->sanitize($inputs); 358 } 359 360 return (object) []; 529 361 } 530 362 /** … … 535 367 */ 536 368 public function sanitize( $array ) { //phpcs:ignore 537 if ( ! is_array($array) ) {538 return is_string($array) ? sanitize_text_field($array) : $array;539 }540 369 foreach ( $array as $key => $value ) { 541 370 if ( is_array($value) ) { 542 371 $array[ $key ] = $this->sanitize($value); 543 } else if ( is_string($value) ){372 } else { 544 373 $array[ $key ] = sanitize_text_field($value); 545 } else {546 $array[ $key ] = $value;547 374 } 548 375 } … … 566 393 $this->send_json(false, __('You do not have permission to do this', 'multi-store-sync-addon-for-flexstock')); 567 394 } 568 569 570 395 571 396 $token = ''; … … 621 446 } 622 447 448 // If not found, it means it's a NEW token (from Step 1) -> Create it now 623 449 if (!$updated) { 624 625 626 450 $new_token_data = [ 627 451 'token' => $target_token, … … 729 553 730 554 // 1. Product Scope 555 $include_type = isset($permissions->include_type) ? $permissions->include_type : 'all'; 731 556 $message .= '<div style="font-size:12px; text-transform:uppercase; color:#6b7280; font-weight:600; letter-spacing:0.5px; margin-bottom:12px;">' . __('Product Access', 'multi-store-sync-addon-for-flexstock') . '</div>'; 732 $message .= '<div style="display:inline-block; background-color:#dbeafe; color:#1e40af; padding:4px 12px; border-radius:12px; font-size:13px; font-weight:600; margin-bottom:20px;">' . __('All Products Included', 'multi-store-sync-addon-for-flexstock') . '</div>'; 557 558 if ($include_type === 'all') { 559 $message .= '<div style="display:inline-block; background-color:#dbeafe; color:#1e40af; padding:4px 12px; border-radius:12px; font-size:13px; font-weight:600; margin-bottom:20px;">' . __('All Products Included', 'multi-store-sync-addon-for-flexstock') . '</div>'; 560 } else { 561 /** 562 * Invite e-mail HTML for a token scope other than All Products. 563 * 564 * @param string $html Empty by default. 565 * @param object $permissions Token permissions from the request. 566 * @param string $include_type Normalized scope type. 567 */ 568 $message .= (string) apply_filters( 'multissaf_token_scope_email_html', '', $permissions, $include_type ); 569 } 733 570 734 571 // 2. Fields Layer … … 762 599 $message .= '</div>'; 763 600 } 601 602 // SKU Match Mode in email 603 $sku_match_mode_email = isset($permissions->sku_match_mode) ? $permissions->sku_match_mode : 'match'; 604 $message .= '<div style="margin-top:16px; padding-top:16px; border-top:1px solid #e5e7eb;">'; 605 $message .= '<div style="font-size:12px; text-transform:uppercase; color:#6b7280; font-weight:600; letter-spacing:0.5px; margin-bottom:8px;">' 606 . __('SKU matching mode', 'multi-store-sync-addon-for-flexstock') . '</div>'; 607 608 if ( $sku_match_mode_email === 'alias' ) { 609 $message .= '<div style="display:inline-block; background-color:#dbeafe; color:#1e40af; padding:4px 12px; border-radius:12px; font-size:13px; font-weight:600; margin-bottom:10px;">' 610 . __('SKU alias mapping', 'multi-store-sync-addon-for-flexstock') . '</div>'; 611 612 $sku_aliases_email = isset($permissions->sku_aliases) ? (array) $permissions->sku_aliases : []; 613 if ( ! empty( $sku_aliases_email ) ) { 614 $message .= '<table style="width:100%; font-size:12px; border-collapse:collapse; margin-top:6px;">'; 615 $message .= '<tr><th style="text-align:left; padding:4px 0; color:#6b7280; font-weight:600;">' 616 . __('Source SKU', 'multi-store-sync-addon-for-flexstock') 617 . '</th><th style="text-align:center; padding:4px 0; color:#6b7280;">→</th><th style="text-align:left; padding:4px 0; color:#6b7280; font-weight:600;">' 618 . __('Destination SKU', 'multi-store-sync-addon-for-flexstock') 619 . '</th></tr>'; 620 foreach ( $sku_aliases_email as $alias ) { 621 $src = isset( $alias->source ) ? esc_html( $alias->source ) : ''; 622 $dst = isset( $alias->destination ) ? esc_html( $alias->destination ) : ''; 623 if ( $src || $dst ) { 624 $message .= '<tr>' 625 . '<td style="padding:3px 0; color:#374151;">' . $src . '</td>' 626 . '<td style="text-align:center; color:#9ca3af;">→</td>' 627 . '<td style="padding:3px 0; color:#374151;">' . $dst . '</td>' 628 . '</tr>'; 629 } 630 } 631 $message .= '</table>'; 632 } 633 } else { 634 $message .= '<div style="display:inline-block; background-color:#dbeafe; color:#1e40af; padding:4px 12px; border-radius:12px; font-size:13px; font-weight:600;">' 635 . __('Match by SKU (exact)', 'multi-store-sync-addon-for-flexstock') . '</div>'; 636 } 637 $message .= '</div>'; 764 638 765 639 $message .= '</div>'; // End Details Box … … 878 752 879 753 public function multissaf_disconnect_token() { 880 $body = $this->get_body(); 754 755 if ( ! isset($_SERVER['CONTENT_TYPE']) || strpos($_SERVER['CONTENT_TYPE'], 'application/json') === false ) { 756 $body = (object) $_POST; 757 } else { 758 $body = json_decode(file_get_contents('php://input')); 759 } 881 760 882 761 if ( ! isset($body->nonce) || ! wp_verify_nonce($body->nonce, 'ssgsw_nonce') ) { 883 762 $this->send_json(false, __('Invalid nonce', 'multi-store-sync-addon-for-flexstock')); 884 }885 886 if ( ! current_user_can('manage_options') ) {887 $this->send_json(false, __('Permission denied', 'multi-store-sync-addon-for-flexstock'));888 763 } 889 764 … … 955 830 */ 956 831 private function get_sync_stats($store_url, $store_token) { 832 // Extend timeout only for this long-running remote call 833 if ( function_exists( 'set_time_limit' ) ) { 834 @set_time_limit( 120 ); 835 } 836 957 837 $child_access_data = multissaf_get_sub_product_count($store_url, $store_token, []); 958 838 $total_child_products = 0; … … 974 854 $child_permissions = $raw_permissions; 975 855 } 976 // Include all products. 977 $child_permissions['include_type'] = 'all'; 856 if ( ! isset( $child_permissions['include_type'] ) ) { 857 $child_permissions['include_type'] = 'all'; 858 } 978 859 $allowed_field_names = isset($data_array['field_names']) ? (array)$data_array['field_names'] : []; 979 860 } else if (is_numeric($child_access_data)) { … … 1096 977 */ 1097 978 public function multissaf_get_spreadsheet_tabs() { 1098 if ( ! current_user_can('manage_options') ) {1099 $this->send_json(false, __('You do not have permission to do this', 'multi-store-sync-addon-for-flexstock'));1100 }1101 1102 979 $body = $this->get_body(); 1103 980 if ( ! isset($body->nonce) || ! wp_verify_nonce($body->nonce, 'ssgsw_nonce') ) { … … 1183 1060 */ 1184 1061 public function multissaf_create_spreadsheet_tab() { 1185 if ( ! current_user_can('manage_options') ) {1186 $this->send_json(false, __('You do not have permission to do this', 'multi-store-sync-addon-for-flexstock'));1187 }1188 1189 1062 $body = $this->get_body(); 1190 1063 if ( ! isset($body->nonce) || ! wp_verify_nonce($body->nonce, 'ssgsw_nonce') ) { … … 1255 1128 */ 1256 1129 public function multissaf_get_sync_preview() { 1257 if ( ! current_user_can('manage_options') ) {1258 $this->send_json(false, __('You do not have permission to do this', 'multi-store-sync-addon-for-flexstock'));1259 }1260 1130 1261 1131 $body = $this->get_body(); … … 1276 1146 } 1277 1147 1148 /** 1149 * FlexStock live "store → sheet" handlers that write the main sheet on every 1150 * product/variation save. [ hook, method, priority, accepted args ]. 1151 */ 1152 private function base_live_sheet_handlers() { 1153 return [ 1154 [ 'woocommerce_after_product_object_save', 'after_save_product', 10, 2 ], 1155 [ 'woocommerce_save_product_variation', 'after_update_product_variation', 10, 2 ], 1156 [ 'woocommerce_product_set_stock', 'ssgs_woocommerce_product_set_stock', 10, 1 ], 1157 ]; 1158 } 1159 1160 /** 1161 * Temporarily detach FlexStock's live sheet handlers for the current request. 1162 * 1163 * @return array Handlers that were detached (pass to resume_base_live_sheet_sync()). 1164 */ 1165 private function pause_base_live_sheet_sync() { 1166 $paused = []; 1167 if ( ! class_exists( '\StockSyncWithGoogleSheetForWooCommerce\Hooks' ) || empty( \StockSyncWithGoogleSheetForWooCommerce\Hooks::$instance ) ) { 1168 return $paused; 1169 } 1170 $base = \StockSyncWithGoogleSheetForWooCommerce\Hooks::$instance; 1171 foreach ( $this->base_live_sheet_handlers() as $handler ) { 1172 list( $hook, $method, $priority ) = $handler; 1173 if ( has_action( $hook, [ $base, $method ] ) === $priority ) { 1174 remove_action( $hook, [ $base, $method ], $priority ); 1175 $paused[] = $handler; 1176 } 1177 } 1178 return $paused; 1179 } 1180 1181 /** 1182 * Re-attach handlers detached by pause_base_live_sheet_sync(). 1183 * 1184 * @param array $paused Handlers returned by pause_base_live_sheet_sync(). 1185 */ 1186 private function resume_base_live_sheet_sync( $paused ) { 1187 if ( empty( $paused ) ) { 1188 return; 1189 } 1190 $base = \StockSyncWithGoogleSheetForWooCommerce\Hooks::$instance; 1191 foreach ( $paused as $handler ) { 1192 list( $hook, $method, $priority, $args ) = $handler; 1193 add_action( $hook, [ $base, $method ], $priority, $args ); 1194 } 1195 } 1196 1278 1197 public function multissaf_manual_import_products() { 1279 if ( ! current_user_can('manage_options') ) {1280 $this->send_json(false, __('You do not have permission to do this', 'multi-store-sync-addon-for-flexstock'));1281 }1282 1198 1283 1199 $body = $this->get_body(); … … 1293 1209 if (!$store_url || !$store_token) { 1294 1210 $this->send_json(false, __('Store URL or Token missing', 'multi-store-sync-addon-for-flexstock')); 1211 } 1212 1213 // Extend timeout only immediately before this long-running remote call 1214 if ( function_exists( 'set_time_limit' ) ) { 1215 @set_time_limit( 120 ); 1295 1216 } 1296 1217 … … 1305 1226 ]; 1306 1227 1228 // Pass through the requested product count. 1229 if ( ! empty( $body->max_products ) ) { 1230 $payload['max_products'] = absint( $body->max_products ); 1231 } 1232 // Save the requested product count on the store (0 = all). 1233 if ( 0 === $offset && function_exists( 'multissaf_update_child_site' ) ) { 1234 multissaf_update_child_site( [ 'url' => $store_url ], [ 'max_products' => isset( $payload['max_products'] ) ? $payload['max_products'] : 0 ] ); 1235 } 1236 1307 1237 $response = multissaf_send_api_get_request($api_url, $payload, $store_token); 1308 1238 1309 1239 if ($response && isset($response->success) && $response->success && is_array($response->data)) { 1310 1240 $products = json_decode(json_encode($response->data), true); 1311 1241 1242 // Import at most the requested product count. 1243 if ( ! empty( $payload['max_products'] ) && is_array( $products ) ) { 1244 $products = array_slice( $products, 0, max( 0, $payload['max_products'] - $offset ) ); 1245 } 1246 // Products the source read on this page (incl. ones without SKU it did not send), 1247 $batch_info = isset( $response->batch_info ) ? (array) $response->batch_info : []; 1248 $scanned = isset( $batch_info['scanned'] ) ? absint( $batch_info['scanned'] ) : count( (array) $products ); 1249 if ( ! empty( $payload['max_products'] ) ) { 1250 $scanned = min( $scanned, max( 0, $payload['max_products'] - $offset ) ); 1251 } 1252 $batch_report = [ 1253 'scanned' => $scanned, 1254 'products_no_sku' => isset( $batch_info['products_no_sku'] ) ? absint( $batch_info['products_no_sku'] ) : 0, 1255 'variations_no_sku' => isset( $batch_info['variations_no_sku'] ) ? absint( $batch_info['variations_no_sku'] ) : 0, 1256 ]; 1257 // Variation SKUs the token lists without their parent SKU (skipped by the source). 1258 $scope_notices = isset( $batch_info['scope_notices'] ) ? json_decode( wp_json_encode( $batch_info['scope_notices'] ), true ) : []; 1259 if ( ! empty( $scope_notices['variation_skus_without_parent'] ) && is_array( $scope_notices['variation_skus_without_parent'] ) ) { 1260 $batch_report['variation_skus_without_parent'] = array_map( function ( $item ) { 1261 return [ 1262 'sku' => isset( $item['sku'] ) ? sanitize_text_field( $item['sku'] ) : '', 1263 'parent_sku' => isset( $item['parent_sku'] ) ? sanitize_text_field( $item['parent_sku'] ) : '', 1264 ]; 1265 }, $scope_notices['variation_skus_without_parent'] ); 1266 } 1267 1312 1268 if (!empty($products) && function_exists('multissaf_import_update_products')) { 1313 1269 1270 // One writer for the main sheet: the import itself queues these products for 1271 // the addon's main-tab writer (ssgsw_multistore_sheet_update). FlexStock's live 1272 // save handlers would write the same rows again from a separate index, which 1273 // duplicated rows in Sheet1, so they are paused while the import saves products. 1274 $live_sheet_handlers = $this->pause_base_live_sheet_sync(); 1314 1275 $import_result = multissaf_import_update_products($products, true, [], $store_url, true); 1276 $this->resume_base_live_sheet_sync( $live_sheet_handlers ); 1315 1277 1316 1278 $last_synced = ''; … … 1320 1282 } 1321 1283 1322 $this->send_json(true, [1284 $this->send_json(true, array_merge( [ 1323 1285 'count' => count($products), 1324 1286 'import_result' => $import_result, 1325 1287 'last_synced' => $last_synced 1326 ] );1327 } 1328 $this->send_json(true, ['count' => 0]);1288 ], $batch_report ) ); 1289 } 1290 $this->send_json(true, array_merge( ['count' => 0], $batch_report ) ); 1329 1291 } 1330 1292 $this->send_json(false, __('Failed to fetch products from child store', 'multi-store-sync-addon-for-flexstock')); … … 1335 1297 */ 1336 1298 public function multissaf_sync_batch_to_sheet() { 1337 if ( ! current_user_can('manage_options') ) {1338 $this->send_json(false, __('You do not have permission to do this', 'multi-store-sync-addon-for-flexstock'));1339 }1340 1341 1299 $body = $this->get_body(); 1342 1300 if ( ! isset($body->nonce) || ! wp_verify_nonce($body->nonce, 'ssgsw_nonce') ) { 1343 1301 $this->send_json(false, __('Invalid nonce', 'multi-store-sync-addon-for-flexstock')); 1302 } 1303 1304 // Extend timeout only immediately before this long-running batch sync operation 1305 if ( function_exists( 'set_time_limit' ) ) { 1306 @set_time_limit( 120 ); 1344 1307 } 1345 1308 … … 1373 1336 } 1374 1337 } 1375 1376 // Since Google Sheets API will natively reject invalid tab names during the update_values call, 1377 // we do not need to strictly block the sync here if get_sheet_tab() returned cached/stale data. 1378 // If it's cached, $tab_exists might be false even if the tab was just created. 1338 1379 1339 if (!$tab_exists && empty($tabs)) { 1380 1340 // Only block if we literally couldn't fetch ANY tabs (API error or permissions) … … 1386 1346 } 1387 1347 1388 // Fetch from Child — extend timeout only immediately before this long-running remote call 1389 if ( function_exists( 'set_time_limit' ) ) { 1390 @set_time_limit( 120 ); 1391 } 1348 // Fetch from Child 1392 1349 $api_url = trailingslashit($store_url) . 'wp-json/ssgsw/v1/retrive_child'; 1393 1350 $payload = [ … … 1399 1356 'access_token' => $store_token, 1400 1357 ]; 1358 // Same product count as the import, so the sheet gets the same products. 1359 if ( ! empty( $body->max_products ) ) { 1360 $payload['max_products'] = absint( $body->max_products ); 1361 } 1401 1362 $response = multissaf_send_api_get_request($api_url, $payload, $store_token); 1402 1363 $processed_count = 0; … … 1455 1416 $new_colmun = array_search($status_label, $headers_row); 1456 1417 1457 if (false !== $new_colmun ) {1418 if (false !== $new_colmun && function_exists('ssgsw_get_all_product_statuses')) { 1458 1419 $dropdown = ssgsw_get_all_product_statuses(); 1459 1420 $status_count = count($dropdown); … … 1504 1465 */ 1505 1466 public function multissaf_update_site_settings() { 1506 if ( ! current_user_can('manage_options') ) {1507 $this->send_json(false, __('You do not have permission to do this', 'multi-store-sync-addon-for-flexstock'));1508 }1509 1510 1467 $body = $this->get_body(); 1511 1468 if ( ! isset($body->nonce) || ! wp_verify_nonce($body->nonce, 'ssgsw_nonce') ) { … … 1555 1512 } 1556 1513 1557 if (function_exists('multissaf_update_child_site')) { 1558 // First try to update 1559 $updated = multissaf_update_child_site(['url' => $store_url], $settings); 1560 1561 if (!$updated && $store_token) { 1562 // IF NOT UPDATED, means it's newly verified. Add it to our local list. 1563 $child_site = [ 1564 'url' => $store_url, 1565 'token' => $store_token, 1566 'isConnected' => true, 1567 'tab_name' => $tab_name, 1568 'sheet_gid' => $sheet_gid, 1569 'permissions' => isset($settings['permissions']) ? $settings['permissions'] : null, 1570 'site_icon' => $site_icon, 1571 ]; 1572 if (function_exists('multissaf_save_child_site')) { 1573 multissaf_save_child_site($child_site); 1574 } 1575 1576 // Proceed to add parent on the remote site side and activate in one pass. 1577 $api_url = trailingslashit($store_url) . 'wp-json/ssgsw/v1/connections'; 1578 $current_db_mode = get_option('ssgsw_multi_store_mode_on'); 1579 $mode_param = ($current_db_mode === 'shop_to_sheet') ? 'manage' : 'sync'; 1580 1581 $payload = [ 1582 'request_site' => [ 1583 'url' => home_url(), 1584 'token' => get_option('ssgsw_token', '') 1585 ], 1586 'connection' => 'add_parent', 1587 'mode' => $mode_param 1588 ]; 1589 if (function_exists('multissaf_send_api_get_request')) { 1590 multissaf_send_api_get_request($api_url, $payload, $store_token); 1591 } 1592 1593 // Set active status on remote (fire-and-forget: non-blocking, we don't need the response) 1594 $status_payload = [ 1595 'request_site' => [ 1596 'url' => home_url(), 1597 'token' => get_option('ssgsw_token', '') 1598 ], 1599 'connection' => 'update_status', 1600 'status' => 'active', 1601 'mode' => $mode_param, 1602 ]; 1603 wp_remote_post( $api_url, [ 1604 'method' => 'POST', 1605 'headers' => [ 1606 'Content-Type' => 'application/json', 1607 'SSGSWKEY' => 'Bearer ' . $store_token, 1608 ], 1609 'body' => wp_json_encode( $status_payload ), 1610 'timeout' => 0.01, 1611 'blocking' => false, 1612 'sslverify' => apply_filters( 'ssgsw_multistore_ssl_verify', true ), 1613 ] ); 1614 1615 $this->send_json(true, ['message' => __('Settings updated and site saved.', 'multi-store-sync-addon-for-flexstock')]); 1616 } else if ($updated) { 1617 $this->send_json(true, ['message' => __('Settings updated', 'multi-store-sync-addon-for-flexstock')]); 1618 } else { 1619 $this->send_json(false, __('Site not found and token is missing to create it', 'multi-store-sync-addon-for-flexstock')); 1620 } 1621 } else { 1514 if ( ! function_exists( 'multissaf_update_child_site' ) || ! function_exists( 'multissaf_save_child_site' ) ) { 1622 1515 $this->send_json(false, __('Update function missing', 'multi-store-sync-addon-for-flexstock')); 1623 1516 } 1517 1518 // Find the store in our list (it stays there, disconnected, after a disconnect). 1519 $existing_site = null; 1520 $connected_sites = get_option( 'ssgsw_multi_store_list', [] ); 1521 if ( is_array( $connected_sites ) ) { 1522 foreach ( $connected_sites as $site ) { 1523 if ( isset( $site['url'] ) && rtrim( $site['url'], '/' ) === rtrim( $store_url, '/' ) ) { 1524 $existing_site = $site; 1525 break; 1526 } 1527 } 1528 } 1529 $was_connected = $existing_site && ! empty( $existing_site['isConnected'] ) && 'false' !== $existing_site['isConnected']; 1530 $reconnecting = $existing_site && ! $was_connected && ! empty( $settings['isConnected'] ); 1531 1532 if ( ! $existing_site && ! $store_token ) { 1533 $this->send_json(false, __('Site not found and token is missing to create it', 'multi-store-sync-addon-for-flexstock')); 1534 } 1535 1536 // A new or reconnected store must register this site on the source again, 1537 // otherwise the source keeps it disconnected and stops sending updates. 1538 // Product count for this store (0 = all); the source store keeps it on the token. 1539 $max_products = isset( $settings['max_products'] ) && '' !== $settings['max_products'] ? absint( $settings['max_products'] ) : null; 1540 1541 if ( ! $existing_site || $reconnecting ) { 1542 $handshake_token = $store_token ? $store_token : ( isset( $existing_site['token'] ) ? $existing_site['token'] : '' ); 1543 $handshake = $this->register_with_source_store( $store_url, $handshake_token, $max_products ); 1544 if ( true !== $handshake ) { 1545 $this->send_json( false, $handshake ); 1546 } 1547 if ( null !== $max_products ) { 1548 $settings['limit_sent'] = $max_products; 1549 } 1550 // The source's own address, used to recognise its updates (see multissaf_find_child_site()). 1551 if ( $this->handshake_source_url ) { 1552 $settings['source_url'] = $this->handshake_source_url; 1553 } 1554 } 1555 1556 // Send a changed product count to the source store. 1557 if ( $existing_site && ! $reconnecting && $was_connected && null !== $max_products 1558 && ( ! isset( $existing_site['limit_sent'] ) || absint( $existing_site['limit_sent'] ) !== $max_products ) 1559 && ! empty( $existing_site['token'] ) && function_exists( 'multissaf_send_api_get_request' ) ) { 1560 $limit_response = multissaf_send_api_get_request( trailingslashit( $store_url ) . 'wp-json/ssgsw/v1/connections', [ 1561 'request_site' => [ 'url' => home_url(), 'token' => get_option( 'ssgsw_token', '' ) ], 1562 'connection' => 'update_limit', 1563 'max_products' => $max_products, 1564 ], $existing_site['token'] ); 1565 if ( $limit_response && ! is_wp_error( $limit_response ) && ! empty( $limit_response->success ) ) { 1566 $settings['limit_sent'] = $max_products; 1567 } 1568 } 1569 1570 if ( $existing_site ) { 1571 multissaf_update_child_site( [ 'url' => $store_url ], $settings ); 1572 $this->send_json(true, ['message' => __('Settings updated', 'multi-store-sync-addon-for-flexstock')]); 1573 } 1574 1575 multissaf_save_child_site( [ 1576 'url' => $store_url, 1577 'token' => $store_token, 1578 'isConnected' => true, 1579 'tab_name' => $tab_name, 1580 'sheet_gid' => $sheet_gid, 1581 'permissions' => isset($settings['permissions']) ? $settings['permissions'] : null, 1582 'site_icon' => $site_icon, 1583 'source_url' => isset( $settings['source_url'] ) ? $settings['source_url'] : '', 1584 'max_products' => $max_products, 1585 'limit_sent' => isset( $settings['limit_sent'] ) ? $settings['limit_sent'] : null, 1586 ] ); 1587 $this->send_json(true, ['message' => __('Settings updated and site saved.', 'multi-store-sync-addon-for-flexstock')]); 1624 1588 } 1625 1589 1590 /** 1591 * Registers this site on the source store (add_parent, then update_status = active). 1592 * 1593 * @param string $store_url Source store URL. 1594 * @param string $store_token Token issued by the source store. 1595 * @param int|null $max_products Product count for this store (0 = all). 1596 * @return true|string True on success, otherwise an error message. 1597 */ 1598 private function register_with_source_store( $store_url, $store_token, $max_products = null ) { 1599 if ( ! $store_token || ! function_exists( 'multissaf_send_api_get_request' ) ) { 1600 return __( 'Store token missing', 'multi-store-sync-addon-for-flexstock' ); 1601 } 1602 1603 $api_url = trailingslashit( $store_url ) . 'wp-json/ssgsw/v1/connections'; 1604 $mode_param = ( get_option( 'ssgsw_multi_store_mode_on' ) === 'shop_to_sheet' ) ? 'manage' : 'sync'; 1605 $request_site = [ 1606 'url' => home_url(), 1607 'token' => get_option( 'ssgsw_token', '' ), 1608 ]; 1609 1610 $limit = null !== $max_products ? [ 'max_products' => absint( $max_products ) ] : []; 1611 $response = multissaf_send_api_get_request( $api_url, array_merge( [ 1612 'request_site' => $request_site, 1613 'connection' => 'add_parent', 1614 'mode' => $mode_param, 1615 ], $limit ), $store_token ); 1616 if ( ! $response || is_wp_error( $response ) || empty( $response->success ) ) { 1617 return ( $response && ! is_wp_error( $response ) && ! empty( $response->message ) ) 1618 ? $response->message 1619 : __( 'The source store did not accept the connection. Please check the URL and Token.', 'multi-store-sync-addon-for-flexstock' ); 1620 } 1621 $this->handshake_source_url = ( isset( $response->data->source_url ) && is_string( $response->data->source_url ) ) 1622 ? esc_url_raw( $response->data->source_url ) 1623 : ''; 1624 1625 $response = multissaf_send_api_get_request( $api_url, array_merge( [ 1626 'request_site' => $request_site, 1627 'connection' => 'update_status', 1628 'status' => 'active', 1629 'mode' => $mode_param, 1630 ], $limit ), $store_token ); 1631 if ( $response && ! is_wp_error( $response ) && isset( $response->success ) && ! $response->success && ! empty( $response->message ) ) { 1632 return $response->message; 1633 } 1634 1635 return true; 1636 } 1637 1638 /** 1639 * Repairs this site's main sheet (Sheet1) for the products imported from one 1640 * connected store: removes duplicate rows of those products, then re-writes them 1641 * through the addon's main-sheet writer (updates in place, appends missing rows). 1642 * Rows of other products on the sheet are not touched. 1643 */ 1644 public function multissaf_repair_main_sheet() { 1645 $body = $this->get_body(); 1646 if ( ! isset( $body->nonce ) || ! wp_verify_nonce( $body->nonce, 'ssgsw_nonce' ) ) { 1647 $this->send_json( false, __( 'Invalid nonce', 'multi-store-sync-addon-for-flexstock' ) ); 1648 } 1649 if ( ! current_user_can( 'manage_options' ) ) { 1650 $this->send_json( false, __( 'You do not have permission to perform this action.', 'multi-store-sync-addon-for-flexstock' ) ); 1651 } 1652 $store_url = isset( $body->store_url ) ? esc_url_raw( $body->store_url ) : ''; 1653 if ( ! $store_url || ! class_exists( '\StockSyncWithGoogleSheetForWooCommerce\Sheet' ) || ! function_exists( 'ssgsw_get_option' ) ) { 1654 $this->send_json( false, __( 'Store URL missing', 'multi-store-sync-addon-for-flexstock' ) ); 1655 } 1656 1657 // Products imported from this store (parents carry the remote-ID mapping) + their variations. 1658 global $wpdb; 1659 $keys = array_unique( [ 1660 '_ssgsw_remote_id_' . md5( $store_url ), 1661 '_ssgsw_remote_id_' . md5( trailingslashit( $store_url ) ), 1662 '_ssgsw_remote_id_' . md5( untrailingslashit( $store_url ) ), 1663 ] ); 1664 $placeholders = implode( ',', array_fill( 0, count( $keys ), '%s' ) ); 1665 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared 1666 $parent_ids = array_map( 'intval', $wpdb->get_col( $wpdb->prepare( 1667 "SELECT DISTINCT pm.post_id FROM {$wpdb->postmeta} pm 1668 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id AND p.post_type = 'product' AND p.post_status <> 'trash' 1669 WHERE pm.meta_key IN ($placeholders)", 1670 ...$keys 1671 ) ) ); 1672 if ( empty( $parent_ids ) ) { 1673 $this->send_json( true, [ 'duplicates_removed' => 0, 'products' => 0 ] ); 1674 } 1675 $parent_placeholders = implode( ',', array_fill( 0, count( $parent_ids ), '%d' ) ); 1676 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared 1677 $variation_ids = array_map( 'intval', $wpdb->get_col( $wpdb->prepare( 1678 "SELECT ID FROM {$wpdb->posts} WHERE post_type = 'product_variation' AND post_status <> 'trash' AND post_parent IN ($parent_placeholders)", 1679 ...$parent_ids 1680 ) ) ); 1681 $product_ids = array_merge( $parent_ids, $variation_ids ); 1682 $wanted = array_flip( $product_ids ); 1683 1684 // Read the main sheet fresh and find duplicate rows of those products (keep the first). 1685 $sheet_tab = ssgsw_get_option( 'sheet_tab' ); 1686 $sheet = new \StockSyncWithGoogleSheetForWooCommerce\Sheet(); 1687 $sheet->setTab( $sheet_tab ); 1688 $sheet->setSheetID( ssgsw_get_option( 'sheet_id' ) ); 1689 $rows = $sheet->get_formula_value( false, false, $sheet_tab ); 1690 1691 $seen = []; 1692 $duplicate = []; 1693 if ( is_array( $rows ) ) { 1694 foreach ( $rows as $index => $row ) { 1695 if ( 0 === $index || ! isset( $row[0] ) || ! is_numeric( $row[0] ) ) { 1696 continue; // header or non-product row 1697 } 1698 $id = (int) $row[0]; 1699 if ( ! isset( $wanted[ $id ] ) ) { 1700 continue; // not a product imported from this store 1701 } 1702 if ( isset( $seen[ $id ] ) ) { 1703 $duplicate[] = $index + 1; // sheet row number 1704 } else { 1705 $seen[ $id ] = true; 1706 } 1707 } 1708 } 1709 if ( ! empty( $duplicate ) ) { 1710 $sheet->delete_batch_rows( $duplicate ); 1711 } 1712 1713 // Products missing from the sheet may still carry the "row written" marker; clear it 1714 // so the writer appends them, then refresh all of them through the main-sheet writer. 1715 foreach ( $product_ids as $id ) { 1716 if ( ! isset( $seen[ $id ] ) ) { 1717 delete_post_meta( $id, '_ssgsw_main_sheet_row_written' ); 1718 } 1719 } 1720 delete_transient( apply_filters( 'multissaf_sheets_info_cache_key', 'multissaf_sheets_info_' . md5( ssgsw_get_option( 'spreadsheet_id' ) . '_' . $sheet_tab ) ) ); 1721 do_action( 'ssgsw_multistore_sheet_update', [ 'product_ids' => $product_ids ] ); 1722 1723 $this->send_json( true, [ 'duplicates_removed' => count( $duplicate ), 'products' => count( $parent_ids ) ] ); 1724 } 1725 1626 1726 public function multissaf_update_last_sync_time() { 1627 if ( ! current_user_can('manage_options') ) {1628 $this->send_json(false, __('You do not have permission to do this', 'multi-store-sync-addon-for-flexstock'));1629 }1630 1727 1631 1728 $body = $this->get_body(); -
multi-store-sync-addon-for-flexstock/tags/1.1.0/includes/classes/class-api.php
r3699976 r3752558 33 33 /** 34 34 * Current request permissions 35 * 35 36 * @var array 36 37 */ … … 68 69 'methods' => [ 'GET', 'POST' ], 69 70 'callback' => [ $this, 'verify_connections_callback' ], 70 'permission_callback' => [ $this, 'permission_callback' ],71 ],72 'marge_with_parent' => [73 'methods' => [ 'GET', 'POST' ],74 'callback' => [ $this, 'marge_with_parent_callback' ],75 71 'permission_callback' => [ $this, 'permission_callback' ], 76 72 ], … … 111 107 112 108 // Check if token is in request body or query params 113 if ( empty( $bearer) ) {109 if (empty($bearer) ) { 114 110 $bearer = $request->get_param('access_token'); 115 111 } … … 124 120 125 121 $is_valid = false; 126 // Check single token (legacy or main)122 // Check the site's own token 127 123 if (!empty($saved_token) && $bearer === $saved_token) { 128 124 $is_valid = true; 129 $this->current_permissions = [ ' scope' => 'all', 'fields' => [ 'all_fields' ] ];125 $this->current_permissions = [ 'include_type' => 'all', 'fields' => [ 'all_fields' ] ]; 130 126 } 131 127 … … 233 229 $token_data['main_store'] = $request_site['url']; 234 230 $token_data['status'] = 'pending'; 231 $token_updated = true; 232 } 233 // Product count chosen by the connecting store. 234 if (isset($params['max_products']) && trailingslashit($token_data['main_store']) === trailingslashit($request_site['url']) ) { 235 $token_data['max_products'] = absint($params['max_products']); 235 236 $token_updated = true; 236 237 } … … 400 401 if($status === 'active' && isset($request_site['url'])) { 401 402 $t_data['main_store'] = $request_site['url']; 403 if (isset($params['max_products']) ) { 404 $t_data['max_products'] = absint($params['max_products']); 405 } 402 406 } else { 403 407 unset($t_data['main_store']); … … 413 417 } 414 418 } 419 } 420 } 421 } else if ('update_limit' === $connection) { 422 // The store using this token sets how many products it takes (0 = all). 423 $bearer2 = isset($_SERVER['HTTP_AUTHORIZATION']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_AUTHORIZATION'])) : ''; 424 $bearer3 = isset($_SERVER['HTTP_SSGSWKEY']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_SSGSWKEY'])) : ''; 425 $bearer = str_replace('Bearer ', '', empty($bearer3) ? $bearer2 : $bearer3); 426 if ($bearer && $request_site && isset($params['max_products']) ) { 427 $child_tokens = get_option('ssgsw_child_tokens', []); 428 if (is_array($child_tokens) ) { 429 foreach ( $child_tokens as &$t_data ) { 430 if (isset($t_data['token']) && $bearer === $t_data['token'] 431 && ! empty($t_data['main_store']) && isset($request_site['url']) 432 && trailingslashit($t_data['main_store']) === trailingslashit($request_site['url']) 433 ) { 434 $t_data['max_products'] = absint($params['max_products']); 435 update_option('ssgsw_child_tokens', $child_tokens); 436 $is_successful = true; 437 break; 438 } 439 } 440 unset($t_data); 415 441 } 416 442 } … … 440 466 } 441 467 if ($is_successful) { 442 return $this->response(true, [ 468 return $this->response( 469 true, [ 443 470 'message' => 'Connection verified successfully.', 444 'permissions' => !empty($this->current_permissions['permissions']) ? $this->current_permissions['permissions'] : $this->current_permissions 445 ]); 471 'permissions' => !empty($this->current_permissions['permissions']) ? $this->current_permissions['permissions'] : $this->current_permissions, 472 // This site's own address: it is what we send as refarance_shop with updates, 473 // which can differ from the URL the other store used to connect. 474 'source_url' => home_url(), 475 ] 476 ); 446 477 } else { 447 478 return $this->response(false, 'Failed to perform the requested action.'); … … 458 489 { 459 490 $permissions = $this->current_permissions; 460 if ( empty( $permissions) ) {491 if (empty($permissions) ) { 461 492 $token = $request->get_param('access_token'); 462 493 463 if ( ! empty( $token) ) {494 if (! empty($token) ) { 464 495 $child_tokens = get_option('ssgsw_child_tokens', []); 465 if ( is_array( $child_tokens) ) {496 if (is_array($child_tokens) ) { 466 497 foreach ( $child_tokens as $token_data ) { 467 if ( isset( $token_data['token']) && $token === $token_data['token'] ) {468 $permissions = isset( $token_data['permissions']) ? $token_data['permissions'] : [];498 if (isset($token_data['token']) && $token === $token_data['token'] ) { 499 $permissions = isset($token_data['permissions']) ? $token_data['permissions'] : []; 469 500 break; 470 501 } … … 476 507 if (function_exists('multissaf_get_access_data_by_token')) { 477 508 $data = multissaf_get_access_data_by_token($permissions); 509 478 510 return $this->response(true, $data); 479 511 } … … 482 514 } 483 515 /** 484 * API Callback to merge data with a parent site.516 * API Callback to retrieve child site data. 485 517 * 486 518 * @param \WP_REST_Request $request The request object. 487 519 * @return \WP_REST_Response 488 520 */ 489 public function marge_with_parent_callback( \WP_REST_Request $request ){ 490 $products_data = $request->get_json_params(); 491 if (empty($products_data) ) { 492 return $this->response(false, 'No product data provided.'); 493 } 494 495 $reqest = multissaf_handle_product_import_api($products_data); 496 if(isset($reqest['success']) && $reqest['success']) { 497 return $this->response(true, 'Product data merged with parent successfully.'); 498 } else { 499 return $this->response(false, isset($reqest['message']) ? $reqest['message'] : 'Failed to merge product data.'); 500 } 501 } 502 503 /** 504 * API Callback to retrieve child site data. 505 * 506 * @param \WP_REST_Request $request The request object. 507 * @return \WP_REST_Response 508 */ 509 public function retrive_child_callback( \WP_REST_Request $request ){ 521 public function retrive_child_callback( \WP_REST_Request $request ) 522 { 510 523 $products_data = $request->get_json_params(); 511 524 … … 518 531 $product_ids = isset($products_data['product_ids']) ? $products_data['product_ids'] : []; 519 532 $product_skus = isset($products_data['product_skus']) ? $products_data['product_skus'] : []; 520 $access_token = isset($products_data['access_token']) ? $products_data['access_token'] : (isset($products_data['token_id']) ? $products_data['token_id'] : '');533 $access_token = isset($products_data['access_token']) ? $products_data['access_token'] : ''; 521 534 522 535 // Resolve permissions from token 523 if ( ! empty( $access_token) ) {524 $child_tokens = get_option( 'ssgsw_child_tokens', []);525 if ( is_array( $child_tokens) ) {536 if (! empty($access_token) ) { 537 $child_tokens = get_option('ssgsw_child_tokens', []); 538 if (is_array($child_tokens) ) { 526 539 foreach ( $child_tokens as $token_data ) { 527 if ( isset( $token_data['token']) && $access_token === $token_data['token'] ) {528 $this->current_permissions = isset( $token_data['permissions']) ? (array) $token_data['permissions'] : [];540 if (isset($token_data['token']) && $access_token === $token_data['token'] ) { 541 $this->current_permissions = isset($token_data['permissions']) ? (array) $token_data['permissions'] : []; 529 542 break; 530 543 } … … 533 546 } 534 547 535 // Normalize: old tokens stored full token_data as current_permissions (with nested 'permissions' key)548 // permission_callback stores the full token record; use its 'permissions' entry. 536 549 $permissions = $this->current_permissions; 537 if ( ! empty( $permissions['permissions'] ) && is_array( $permissions['permissions']) ) {550 if (! empty($permissions['permissions']) && is_array($permissions['permissions']) ) { 538 551 $permissions = $permissions['permissions']; 539 552 } 540 553 541 // Include all products.542 $permissions['include_type'] = 'all';543 554 if (! isset($permissions['include_type']) ) { 555 $permissions['include_type'] = 'all'; 556 } 544 557 545 558 … … 550 563 // Support fetching by SKU 551 564 // If product_skus is provided, convert SKUs to IDs first 552 if ( ! empty( $product_skus ) && is_array( $product_skus) ) {565 if (! empty($product_skus) && is_array($product_skus) ) { 553 566 global $wpdb; 554 $placeholders = implode( ',', array_fill( 0, count( $product_skus ), '%s' ));567 $placeholders = implode(',', array_fill(0, count($product_skus), '%s')); 555 568 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared 556 569 $product_ids = $wpdb->get_col( … … 563 576 ) 564 577 ); 565 $product_ids = array_map( 'intval', $product_ids);578 $product_ids = array_map('intval', $product_ids); 566 579 } 567 580 581 // Optional total product count for imports and offset-paged exports. 582 $max_products = isset($products_data['max_products']) ? absint($products_data['max_products']) : 0; 583 // The token's own product count applies as well. 584 $limit_token = $access_token; 585 if (! $limit_token ) { 586 $limit_token = isset($_SERVER['HTTP_SSGSWKEY']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_SSGSWKEY'])) : ( isset($_SERVER['HTTP_AUTHORIZATION']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_AUTHORIZATION'])) : '' ); 587 $limit_token = str_replace('Bearer ', '', $limit_token); 588 } 589 $token_limit = function_exists('multissaf_get_token_max_products') ? multissaf_get_token_max_products((string) $limit_token) : 0; 590 if ($token_limit && ( ! $max_products || $token_limit < $max_products ) ) { 591 $max_products = $token_limit; 592 } 593 if ($max_products > 0 && ( $import_request || empty($product_ids) ) ) { 594 $offset = absint($offset); 595 if ($offset >= $max_products ) { 596 return $this->response(true, []); 597 } 598 $batchSize = absint($batchSize); 599 $batchSize = $batchSize > 0 ? min($batchSize, $max_products - $offset) : $max_products - $offset; 600 } 601 568 602 if ($import_request) { 569 603 if (function_exists('multissaf_get_batch_products')) { 570 $data_divided = multissaf_get_batch_products($offset, $batchSize, $permissions); 604 $batch_info = null; 605 $data_divided = multissaf_get_batch_products($offset, $batchSize, $permissions, $batch_info); 606 // Notices about the token's scope (e.g. variation SKUs listed without their 607 // parent SKU), sent once with the first batch. 608 if (0 === absint($offset) ) { 609 $batch_info['scope_notices'] = (array) apply_filters('multissaf_token_scope_notices', [], $permissions); 610 } 611 // batch_info sits next to "data", so destinations that do not read it are unaffected. 612 return new \WP_REST_Response( 613 [ 614 'success' => true, 615 'data' => $data_divided, 616 'batch_info' => $batch_info, 617 ] 618 ); 571 619 } 572 620 } else { … … 592 640 * @return \WP_REST_Response 593 641 */ 594 public function update_single_callback( \WP_REST_Request $request ){ 642 public function update_single_callback( \WP_REST_Request $request ) 643 { 595 644 596 645 $product_data = $request->get_json_params(); … … 599 648 } 600 649 $store_role = get_option('ssgsw_multi_connect_mode_on'); 601 $sync_mode = get_option('ssgsw_multi_store_mode_on');650 $sync_mode = get_option('ssgsw_multi_store_mode_on'); 602 651 $all_target_ids = []; 603 652 604 if($sync_mode == 'shop_to_sheet') {605 if ( function_exists('multissaf_sync_to_specific_store_sheet') ) {653 if($sync_mode == 'shop_to_sheet') { 654 if (function_exists('multissaf_sync_to_specific_store_sheet') ) { 606 655 multissaf_sync_to_specific_store_sheet($product_data); 607 656 } 608 } else {657 } else { 609 658 if (isset($product_data['ssgsw_products'])) { 610 611 $results = multissaf_handle_product_import_api($product_data, false); 659 // New products from the store are created only within its product count. 660 $refrance_shop = isset($product_data['refarance_shop']) ? $product_data['refarance_shop'] : ''; 661 $create_limit = function_exists('multissaf_get_store_create_allowance') ? multissaf_get_store_create_allowance($refrance_shop) : null; 662 $results = multissaf_handle_product_import_api($product_data, false, $create_limit); 612 663 if (isset($results['data']['product_ids'])) { 613 664 $all_target_ids = $results['data']['product_ids']; 614 665 } else { 615 // error_log('[SSGSW DEBUG] Import Failed or returned no IDs. Results: ' . print_r($results, true));616 666 } 617 667 // Trigger local cron unconditionally so scheduled sheet updates fire promptly, … … 620 670 spawn_cron(); 621 671 } 622 }672 } 623 673 624 674 625 return $this->response(true, [ 675 return $this->response( 676 true, [ 626 677 'message' => 'Update processed.', 627 678 'target_ids' => $all_target_ids 628 ]); 679 ] 680 ); 629 681 } 630 682 -
multi-store-sync-addon-for-flexstock/tags/1.1.0/includes/classes/class-app.php
r3661535 r3752558 17 17 18 18 /** 19 * Checks base FlexStock plugin install/activation state and license validity 20 * for the multistore addon. 19 * Checks base FlexStock plugin install/activation state for the multistore addon. 21 20 * 22 21 * @package MultiStoreSyncAddonforFlexStock … … 25 24 { 26 25 27 /**28 * @var string Base plugin file path relative to WP_PLUGIN_DIR.29 */26 /** 27 * @var string Base plugin file path relative to WP_PLUGIN_DIR. 28 */ 30 29 public $free_version = 'stock-sync-with-google-sheet-for-woocommerce/stock-sync-with-google-sheet-for-woocommerce.php'; 31 30 … … 43 42 return false; 44 43 } 45 44 45 /** 46 * Checks if the plugin is activated 47 * 48 * @return bool 49 */ 50 public function is_plugin_activated() 51 { 52 if (is_plugin_active($this->free_version) ) { 53 return true; 54 } 55 56 return false; 57 } 58 46 59 } 47 60 } -
multi-store-sync-addon-for-flexstock/tags/1.1.0/includes/classes/class-hooks.php
r3706977 r3752558 5 5 * 6 6 * @package MultiStoreSyncAddonforFlexStock 7 * @since 1.0.07 * @since 1.0.0 8 8 */ 9 9 … … 14 14 15 15 // Exit if accessed directly. 16 defined( 'ABSPATH' ) || exit( 1 ); 17 18 if ( ! class_exists( '\MultiStoreSyncAddonforFlexStock\MULTISSAF_Hooks' ) ) { 19 20 /** 21 * Registers all multistore WP/WC hooks: menus, scripts, product sync, 22 * sheet writes, trash propagation, and token-filtered batch exports. 23 * 24 * @package MultiStoreSyncAddonforFlexStock 25 */ 26 final class MULTISSAF_Hooks { 27 28 /** 29 * Is license active 30 * 31 * @var bool 32 */ 33 public $app; 34 /** 35 * Is Ultimate active 36 * 37 * @var bool 38 */ 39 protected $is_ultimate_active; 40 41 /** 42 * Contains license information. 43 * 44 * @var mixed 45 */ 46 public $license; 47 48 /** 49 * Constructor 50 */ 51 public function __construct() { 52 $this->app = new App(); 53 $this->add_actions(); 54 55 } 56 57 58 /** 59 * Registers all actions and filters. 60 * Called from __construct(). Sets $is_ultimate_active flag. 61 * 62 * @return void 63 */ 64 public function add_actions() { 65 $this->is_ultimate_active = multissaf_is_plugin_ultimate_activated(); 66 register_activation_hook( MULTISSAF_MULTI_STORE, [ $this, 'activate' ]); 67 add_action( 'pre_current_active_plugins', [ $this, 'admin_notices' ]); 68 69 add_action('init', [ $this, 'init_appsero_client' ]); 70 add_action('admin_init', [ $this, 'redirect_after_active']); 71 add_filter('plugin_action_links_' . plugin_basename(MULTISSAF_MULTI_STORE), [ $this, 'add_plugin_action_links' ]); 72 add_action('ssgsw_before_log_menu', [ $this, 'set_menu']); 73 add_filter('ssgsw_options', [$this, 'ssgsw_options'], 10, 1); 74 add_filter('ssgsw_localized_script', [$this, 'ssgsw_localized_script'], 10, 1); 75 add_action('admin_enqueue_scripts', [$this, 'enqueue_scripts'], 9); 76 add_filter('ssgsw_sub_site_product_count', [$this, 'ssgsw_sub_site_product_count'], 10, 1); 77 add_filter( 'ssgsw_columns', [ $this, 'product_columns' ], 10, 1 ); 78 //add_filter( 'ssgsw_add_extra', [ $this, 'ssgsw_add_extra' ], 10, 3 ); 79 // add_filter('ssgsw_sku_checker_filter', [ $this, 'ssgsw_sync_only_products_with_sku'],10, 1 ); 80 add_filter('ssgsw_sync_mode', [$this, 'ssgsw_sync_mode'], 10, 1); 81 add_filter('ssgsw_domain_url', [$this, 'ssgsw_domain_url'], 10, 1); 82 add_action('ssgsw_collection_of_update_ids', [$this, 'ssgsw_collection_of_update_ids'], 10, 5); 83 add_action('ssgsw_multistore_product_update', [$this, 'ssgsw_multistore_product_update_func'], 10, 1); 84 85 add_action('ssgsw_multistore_sheet_update', [$this, 'ssgsw_multistore_sheet_update'], 10, 1); 86 87 add_action('ssgsw_multistore_sync_to_sheet', [ $this, 'ssgsw_multistore_sync_to_sheet'], 10, 1); 88 if ( ! $this->is_ultimate_active ) { 89 add_filter('ssgsw_format_columns_data', [$this, 'ssgsw_format_columns_data'], 10, 3); 90 } 91 add_filter('ssgsw_is_multistore_plugin_active', '__return_true'); 92 93 add_filter( 'ssgsw_multistore_token_filtered_batch', [ $this, 'ssgsw_token_filtered_batch' ], 10, 4 ); 94 add_action( 'trashed_post', [ $this, 'delete_child_tab_rows_on_trash' ], 20, 1 ); 95 96 // Direct sync handler (replaces cron scheduling) 97 add_action( 'ssgsw_multistore_product_update_direct', [ $this, 'ssgsw_multistore_product_update_direct' ], 10, 1 ); 98 99 // SKU update sync for multistore token filters 100 add_action( 'ssgsw_product_sku_changed', [ $this, 'update_token_sku_filters' ], 10, 3 ); 101 } 102 103 /** 104 * Update token configuration when a product SKU changes (no-op as specific SKU filtering is removed). 105 * 106 * @param int $product_id The product ID. 107 * @param string $old_sku The previous SKU. 108 * @param string $new_sku The new SKU. 109 */ 110 public function update_token_sku_filters( $product_id, $old_sku, $new_sku ) { 111 // Specific SKU filtering removed - all products sync is used. 112 return; 113 } 114 115 /** 116 * Deletes the matching row from every child tab in the destination 117 * spreadsheet when a product is trashed (locally or propagated). 118 * Uses SKU as the primary cross-site identifier, falls back to ID. 119 * Saves/restores $ssgsw_request_params around Sheet calls. 120 * 121 * Connected to: WP action 'trashed_post' (priority 20) 122 * 123 * @param int $post_id The trashed WP post ID. 124 * @return void 125 */ 126 public function delete_child_tab_rows_on_trash( $post_id ) { 127 128 if ( 'product' !== get_post_type( $post_id ) && 'product_variation' !== get_post_type( $post_id ) ) { 129 return; 130 } 131 132 if ( ! get_option( 'ssgsw_multi_store_active', false ) ) { 133 return; 134 } 135 136 // Only run on the destination/parent site (the one that has child tabs). 137 $store_role = get_option( 'ssgsw_multi_connect_mode_on', '' ); 138 if ( 'child' === $store_role ) { 139 return; // Source/child sites don't have child tabs. 140 } 141 142 $connected_children = get_option( 'ssgsw_multi_store_list', [] ); 143 if ( empty( $connected_children ) || ! is_array( $connected_children ) ) { 144 return; 145 } 146 147 $sku = get_post_meta( $post_id, '_sku', true ); 148 149 // Snapshot the global request params. We will temporarily override them per 150 // child-tab iteration so Sheet methods use the DESTINATION's spreadsheet — 151 // not whatever foreign body is in the active HTTP request (e.g. an AppScript 152 // request or a remote REST call from the source site). 153 global $ssgsw_request_params; 154 $saved_request_params = $ssgsw_request_params; 155 156 foreach ( $connected_children as $child ) { 157 $tab_name = isset( $child['tab_name'] ) ? $child['tab_name'] : ''; 158 $sheet_gid = isset( $child['sheet_gid'] ) ? $child['sheet_gid'] : ''; 159 $spreadsheet_id = isset( $child['spreadsheet_id'] ) ? $child['spreadsheet_id'] : ''; 160 161 // Fall back to the site's own spreadsheet ID if not stored per-child. 162 if ( empty( $spreadsheet_id ) ) { 163 $spreadsheet_id = get_option( SSGSW_PREFIX . 'spreadsheet_id', '' ); 164 } 165 166 if ( empty( $tab_name ) || empty( $spreadsheet_id ) ) { 167 continue; 168 } 169 170 if ( ! class_exists( '\StockSyncWithGoogleSheetForWooCommerce\Sheet' ) ) { 171 continue; 172 } 173 174 // Override request params to force Sheet methods onto THIS child tab's 175 // spreadsheet context (destination spreadsheet, child tab name/GID). 176 // access_token is omitted — Sheet::get_token() uses stored credentials. 177 $ssgsw_request_params = [ 178 'spreadsheet_id' => $spreadsheet_id, 179 'sheet_tab' => $tab_name, 180 'sheet_id' => $sheet_gid, 181 ]; 182 183 $sheet = new \StockSyncWithGoogleSheetForWooCommerce\Sheet(); 184 $sheet->setID( $spreadsheet_id ); 185 $sheet->setTab( $tab_name ); 186 if ( ! empty( $sheet_gid ) && method_exists( $sheet, 'setSheetID' ) ) { 187 $sheet->setSheetID( $sheet_gid ); 188 } 189 190 // Fetch the tab's current data. 191 // Pass $tab_name explicitly — belt-and-suspenders against any remaining 192 // body contamination (see 1-child-duplicate.md for root-cause details). 193 $tab_data = $sheet->get_formula_value( false, false, $tab_name, 'UNFORMATTED_VALUE' ); 194 if ( empty( $tab_data ) ) { 195 continue; 196 } 197 198 // Child tab rows contain SOURCE product IDs, not destination IDs. 199 // SKU is the reliable cross-site identifier — try it first. 200 $row_number = false; 201 if ( ! empty( $sku ) && function_exists( 'ssgsw_get_index_number_by_sku' ) ) { 202 $row_number = ssgsw_get_index_number_by_sku( $sku, $tab_data ); 203 } 204 205 // Fallback: scan column 0 for matching product ID. 206 if ( ! $row_number && function_exists( 'ssgsw_format_index_number' ) ) { 207 $tab_index = ssgsw_format_index_number( $tab_data ); 208 $row_number = function_exists( 'ssgsw_get_index_number_option' ) 209 ? ssgsw_get_index_number_option( $post_id, $tab_index ) 210 : false; 211 } 212 213 if ( $row_number ) { 214 // $sheet_gid passed explicitly so delete_single_row() uses it as 215 // priority-1 and does not fall back to body_info->sheet_id. 216 $sheet->delete_single_row( $row_number, $sheet_gid ); 217 } 218 } 219 220 // Restore request params so nothing downstream is affected. 221 $ssgsw_request_params = $saved_request_params; 222 } 223 224 /** 225 * Filter: intercepts the base plugin's batch export when a child token 226 * is present in the request. Returns permission-filtered product rows 227 * instead of the default unfiltered batch. 228 * 229 * Connected to: filter 'ssgsw_multistore_token_filtered_batch' 230 * 231 * @param false|array $default Default (no override). 232 * @param int $offset Batch offset. 233 * @param int $batch_size Batch size. 234 * @param int $index Sheet row index. 235 * @return false|array Filtered rows or false to fall back. 236 */ 237 public function ssgsw_token_filtered_batch( $default, $offset, $batch_size, $index ) { 238 239 // Resolve bearer token from the current request. 240 $bearer = ''; 241 if ( ! empty( $_SERVER['HTTP_SSGSWKEY'] ) ) { 242 $bearer = sanitize_text_field( wp_unslash( $_SERVER['HTTP_SSGSWKEY'] ) ); 243 } elseif ( ! empty( $_SERVER['HTTP_AUTHORIZATION'] ) ) { 244 $bearer = sanitize_text_field( wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] ) ); 245 } 246 $bearer = str_replace( 'Bearer ', '', $bearer ); 247 248 if ( empty( $bearer ) ) { 249 return false; 250 } 251 252 // Find the matching child token with its permissions. 253 $child_tokens = get_option( 'ssgsw_child_tokens', [] ); 254 if ( ! is_array( $child_tokens ) ) { 255 return false; 256 } 257 258 $permissions = null; 259 foreach ( $child_tokens as $token_data ) { 260 if ( isset( $token_data['token'] ) && $bearer === $token_data['token'] ) { 261 // Unwrap nested permissions structure. 262 $perms = isset( $token_data['permissions'] ) ? (array) $token_data['permissions'] : []; 263 if ( ! empty( $perms['permissions'] ) && is_array( $perms['permissions'] ) ) { 264 $perms = $perms['permissions']; 265 } 266 // Include all products. 267 $perms['include_type'] = 'all'; 268 $permissions = $perms; 269 break; 270 } 271 } 272 273 if ( $permissions === null ) { 274 return false; 275 } 276 277 // Use the token-aware batch function to fetch only filtered products. 278 if ( function_exists( 'multissaf_get_batch_products_by_token' ) ) { 279 $data = multissaf_get_batch_products_by_token( null, $offset, $batch_size, $permissions ); 280 if ( is_array( $data ) ) { 281 if ( $offset === 0 && ! empty( $data ) ) { 282 $first = reset( $data ); 283 // Header row contains strings (labels), not numeric IDs — detect by checking 284 if ( is_array( $first ) && isset( $first[0] ) && ! is_numeric( $first[0] ) ) { 285 array_shift( $data ); 286 } 287 } 288 289 return $data; 290 } 291 } 292 293 return false; 294 } 295 296 /** 297 * Format image and formula data 298 * 299 * @param array $format_data data. 300 * 301 * @return array formatted data. 302 */ 303 public function ssgsw_format_columns_data($formatted_data, $sheets_info, $formula) { 304 305 306 if ( $formula ) { 307 $sheet_header = isset($sheets_info[0]) ? $sheets_info[0] : []; 308 $check_id_exist = isset($sheets_info[0][0]) && 'ID' === $sheets_info[0][0] ? true : false; 309 $formatted_data = array_map(function ( $row ) use ( $sheets_info, $sheet_header, $check_id_exist ) { //phpcs:ignore 310 $desired_value = $row['ID']; 311 $found_array = null; 312 if ( is_array($sheets_info) && ! empty($sheets_info) ) { 313 foreach ( $sheets_info as $key => $sub_array ) { 314 if ( isset($sub_array[0]) && $sub_array[0] === $desired_value ) { 315 $found_array = $sub_array; 316 break; 317 } 318 } 319 } 320 321 if ( is_array($found_array) ) { 322 $i = 0; 323 if ( $check_id_exist ) { 324 $google_with_header = $this->set_sheet_header_with_data($sheet_header, $found_array); 325 $database_data = $this->ssgsw_compare_with_formula_value($desired_value); 326 $check_have_change = false; 327 if ( ! empty($database_data) ) { 328 $previous = []; 329 foreach ( $row as $key => $value ) { 330 $formation_key = $this->ssgsw_sheet_header_formating2($key); 331 if ( isset($google_with_header[ $key ]) && strpos($google_with_header[ $key ], '=') === 0 ) { 332 if ( 'product_image' !== $key ) { 333 $row[ $key ] = isset($google_with_header[ $key ]) ? $google_with_header[ $key ] : ''; 334 $new_data_set = isset($database_data[ $formation_key ]) ? $database_data[ $formation_key ] : ''; 335 $previous[0][ $formation_key ] = $new_data_set; 336 if ( $value !== $new_data_set ) { 337 $check_have_change = true; 338 } 339 } 340 } else { 341 $previous[0][ $formation_key ] = $row[ $key ]; 342 } 343 $i++; 344 } 345 if ( $check_have_change ) { 346 $product_object = new \StockSyncWithGoogleSheetForWooCommerce\Product(); 347 $product_object->bulk_update($previous, false, 1, false ); 348 } 349 } else { 350 foreach ( $row as $key => $value ) { 351 if ( isset($google_with_header[ $key ]) && strpos($google_with_header[ $key ], '=') === 0 ) { 352 if ( 'product_image' !== $key ) { 353 $row[ $key ] = $google_with_header[ $key ]; 354 } 355 } 356 $i++; 357 } 358 } 359 } else { 360 foreach ( $row as $key => $value ) { 361 if ( isset($found_array[ $i ]) && strpos($found_array[ $i ], '=') === 0 ) { 362 if ( 'product_image' !== $key ) { 363 $row[ $key ] = $found_array[ $i ]; 364 } 365 } 366 $i++; 367 } 368 } 369 } 370 if ( is_array($row) && array_key_exists('post_author', $row)) { 371 $row['post_author'] = $this->generate_shop_name($row['post_author']); 372 } 373 if ( is_array($row) && array_key_exists('ssgsw_product_url', $row)) { 374 $row['ssgsw_product_url'] = $this->generate_product_url($row); 375 } 376 if ( array_key_exists( 'product_image', $row) && ! empty($row['product_image']) ) { 377 $image_url_convart = $row['product_image']; 378 if ($image_url_convart) { 379 $base_url = wp_upload_dir()['baseurl']; 380 $absolute_url = trailingslashit($base_url) . ltrim($image_url_convart, '/'); 381 $row['product_image'] = '=IMAGE("' . $absolute_url . '")'; 382 } else { 383 $row['product_image'] = ''; 384 } 385 return array_values( (array) $row); 386 } else { 387 return array_values( (array) $row); 388 } 389 }, $formatted_data); 390 391 } else { 392 $formatted_data = array_map(function ( $row ) { //phpcs:ignore 393 if ( is_array($row) && array_key_exists('ssgsw_product_url', $row)) { 394 $row['ssgsw_product_url'] = $this->generate_product_url($row); 395 } 396 if ( is_array($row) && array_key_exists('post_author', $row)) { 397 $row['post_author'] = $this->generate_shop_name($row['post_author']); 398 } 399 if ( array_key_exists( 'product_image', $row) && ! empty($row['product_image']) ) { 400 $image_url_convart = $row['product_image']; 401 if ($image_url_convart) { 402 $base_url = wp_upload_dir()['baseurl']; 403 $absolute_url = trailingslashit($base_url) . ltrim($image_url_convart, '/'); 404 $row['product_image'] = '=IMAGE("' . $absolute_url . '")'; 405 } else { 406 $row['product_image'] = ''; 407 } 408 return array_values( (array) $row); 409 } else { 410 return array_values( (array) $row); 411 } 412 }, $formatted_data); 413 414 } 415 return $formatted_data; 416 } 417 /** 418 * Generate Product URL 419 */ 420 public function generate_product_url($row) { 421 $p_id = $row['ID']; 422 return get_permalink($p_id); 423 } 424 /** 425 * Generate Shop Name 426 */ 427 public function generate_shop_name($author_id) { 428 return get_the_author_meta( 'display_name', $author_id ); 429 } 430 /** 431 * SSGSW Sheet Header formating 432 * 433 * @param string $key Key of the data. 434 * 435 * @return string 436 */ 437 public function ssgsw_sheet_header_formating2( $key ) { 438 $header_info = [ 439 'ID' => 'ID', 440 'product_type' => 'Type', 441 'post_title' => 'name', 442 '_stock' => 'stock', 443 '_regular_price' => 'regular_price', 444 '_sale_price' => 'sale_price', 445 'product_image' => 'Image', 446 '_sku' => 'sku', 447 'total_sales' => 'sales', 448 '_product_attributes' => 'attributes', 449 'product_cat' => 'category', 450 'post_excerpt' => 'post_excerpt', 451 ]; 452 if ( array_key_exists($key, $header_info) ) { 453 return $header_info[ $key ]; 454 } else { 455 return $key; 456 } 457 } 458 /** 459 * Get products information with compare value 460 * 461 * @param int $product_id ID of product. 462 * 463 * @return mixed 464 */ 465 public function ssgsw_compare_with_formula_value( $product_id ) { 466 $product_info = $this->ssgsw_get_product_information_by_id($product_id); 467 if ( is_array($product_info) && ! empty($product_info) ) { 468 $product_information = isset($product_info[0]['product_information']) ? unserialize($product_info[0]['product_information']) : []; 469 if ( ! empty($product_information) ) { 470 return $product_information; 471 } 472 } 473 return []; 474 } 475 /** 476 * Get SSGSW product information 477 * 478 * @param int $product_id ID of product. 479 * 480 * @return mixed 481 */ 482 public function ssgsw_get_product_information_by_id( $product_id ) { 483 global $wpdb; 484 $product_id = sanitize_text_field( $product_id ); 485 $results = $wpdb->get_results( 486 $wpdb->prepare( 487 "SELECT * FROM {$wpdb->prefix}ssgsw_products WHERE product_id = %s", 488 $product_id 489 ), 490 ARRAY_A 491 ); 492 return $results; 493 } 494 /** 495 * Public function Sheet header with data. 496 * 497 * @param array $header_info header information. 498 * @param array $data data information. 499 * 500 * @return array 501 */ 502 public function set_sheet_header_with_data( $header_info = [], $data = [] ) { 503 $header_marge = []; 504 if ( is_array($header_info) && ! empty($header_info) ) { 505 foreach ( $header_info as $key => $value ) { 506 $header = $this->ssgsw_sheet_header_formating($value); 507 $header_marge[ $header ] = isset($data[ $key ]) ? $data[ $key ] : ''; 508 } 509 } 510 return $header_marge; 511 } 512 513 /** 514 * SSGSW Sheet Header formating 515 * 516 * @param string $key Key of the data. 517 * 518 * @return string 519 */ 520 public function ssgsw_sheet_header_formating( $key ) { 521 $header_info = [ 522 'ID' => 'ID', 523 'Type' => 'product_type', 524 'Name' => 'post_title', 525 'Stock' => '_stock', 526 'Regular price' => '_regular_price', 527 'Sale price' => '_sale_price', 528 'Image' => 'product_image', 529 'SKU' => '_sku', 530 'No of Sales' => 'total_sales', 531 'Attributes' => '_product_attributes', 532 'Categories' => 'product_cat', 533 'Short description' => 'post_excerpt', 534 ]; 535 if ( array_key_exists($key, $header_info) ) { 536 return $header_info[ $key ]; 537 } else { 538 return $key; 539 } 540 } 541 /** 542 * Syncs product data from a single store to multiple stores in batches. 543 * 544 * @param array $args An associative array containing: 545 */ 546 public function ssgsw_multistore_sync_to_sheet($args) { 547 $batch_size = 60; 548 $delay_seconds = 2; 549 $orgingal_ids = isset($args['product_ids']) ? $args['product_ids'] : []; 550 $site_url = isset($args['site_url']) ? $args['site_url'] : (isset($args['refarance_shop']) ? $args['refarance_shop'] : ''); 551 $token = isset($args['token']) ? $args['token'] : ''; 552 // Check if there are product IDs to process. 553 if (is_array($orgingal_ids) && !empty($orgingal_ids)) { 554 if (class_exists('\StockSyncWithGoogleSheetForWooCommerce\Sheet') && class_exists('\StockSyncWithGoogleSheetForWooCommerce\Column')) { 555 $new_sheets = new \StockSyncWithGoogleSheetForWooCommerce\Sheet(); 556 557 $spreadsheet_id = function_exists( 'ssgsw_get_option' ) ? ssgsw_get_option( 'spreadsheet_id' ) : ''; 558 $sheet_tab = function_exists( 'ssgsw_get_option' ) ? ssgsw_get_option( 'sheet_tab' ) : ''; 559 $cache_key = apply_filters( 560 'multissaf_sheets_info_cache_key', 561 'multissaf_sheets_info_' . md5( $spreadsheet_id . '_' . $sheet_tab ) 562 ); 563 564 $sheets_info = get_transient( $cache_key ); 565 if ( false === $sheets_info || empty( $sheets_info ) ) { 566 $sheets_info = $new_sheets->get_formula_value(); 567 if ( ! empty( $sheets_info ) ) { 568 set_transient( 569 $cache_key, 570 $sheets_info, 571 apply_filters( 'multissaf_sheets_info_ttl', 5 * MINUTE_IN_SECONDS ) 572 ); 573 } 574 } 575 576 if (empty($sheets_info)) { 577 $sheets_info = $new_sheets->get_formula_value(); 578 } 579 580 $columns = new \StockSyncWithGoogleSheetForWooCommerce\Column(); 581 $columns_info = $columns->get_columns_no_edit_remove(); 582 $sheet_header = isset($sheets_info[0]) ? $sheets_info[0] : []; 583 $key_with_headers = ssgsw_format_headers_with_sheet_header($sheet_header, $columns_info); 584 $keys = isset($key_with_headers[0]) ? $key_with_headers[0] : []; 585 $domain_url = apply_filters('ssgsw_domain_url', false); 586 587 // Get the current batch of product IDs. 588 $current_batch = array_slice($orgingal_ids, 0, $batch_size); 589 590 if (!empty($current_batch)) { 591 $get_batch = multissaf_batch_api_request( 592 0, 0, 0, $site_url, $token, $keys, $domain_url, $current_batch, $sheets_info 593 ); 594 595 // Process and update the Google Sheet with the fetched data. 596 $sheets_index = []; 597 if (function_exists('ssgsw_format_index_number')) { 598 $sheets_index = ssgsw_format_index_number($sheets_info); 599 } 600 601 if (is_array($get_batch) && !empty($get_batch)) { 602 603 $sync_batch_updates = []; 604 $sync_batch_appends = []; 605 606 foreach ($get_batch as $values) { 607 $values = (array) $values; 608 $product_id = isset($values[0]) ? $values[0] : false; 609 if ($product_id) { 610 $index_number_exists = false; 611 if (function_exists('multissaf_get_index_number_option_param')) { 612 $index_number_exists = multissaf_get_index_number_option_param($product_id, $sheets_index, $site_url); 613 } 614 615 if ($index_number_exists) { 616 $col_end = $new_sheets->getColumnLetter( count( $values ) ); 617 $range = $sheet_tab . '!A' . $index_number_exists . ':' . $col_end . $index_number_exists; 618 $sync_batch_updates[] = [ 619 'range' => $range, 620 'values' => [ array_values( $values ) ], 621 ]; 622 } else { 623 $sync_batch_appends[] = array_values( $values ); 624 } 625 } 626 } 627 628 // Send all updates in ONE API call 629 if ( ! empty( $sync_batch_updates ) ) { 630 $new_sheets->batch_update_rows( $sync_batch_updates ); 631 // Bust sheet cache after write 632 delete_transient( $cache_key ); 633 } 634 635 // Send all appends in ONE API call 636 if ( ! empty( $sync_batch_appends ) ) { 637 $new_sheets->append_batch_rows( $sync_batch_appends ); 638 // Bust sheet cache after write 639 delete_transient( $cache_key ); 640 } 641 } 642 } 643 644 // Slice the remaining products for the next batch. 645 $remaining_product_ids = array_slice($orgingal_ids, $batch_size); 646 647 // If there are more products to process, schedule a new event. 648 if (!empty($remaining_product_ids)) { 649 $next_args = $args; 650 $next_args['orgingal_ids'] = $remaining_product_ids; 651 652 // Clear any previous schedule for this task to prevent duplicates. 653 wp_clear_scheduled_hook('ssgsw_multistore_sync_to_sheet', [$next_args]); 654 655 // Schedule the next batch after the specified delay. 656 wp_schedule_single_event(time() + $delay_seconds, 'ssgsw_multistore_sync_to_sheet', [$next_args]); 657 } 658 } 659 } 660 } 661 662 /** 663 * Writes a batch of product IDs to the local main sheet via 664 * StockSyncWithGoogleSheetForWooCommerce\Sheet. Skips trashed products. 665 * Schedules continuation for batches > 60 products. 666 * 667 * Connected to: action 'ssgsw_multistore_sheet_update' 668 * 669 * @param array $args { 670 * @type int[] $product_ids IDs to write to sheet. 671 * } 672 * @return void 673 */ 674 public function ssgsw_multistore_sheet_update($args) { 675 if (!is_array($args) || empty($args)) { 676 return; 677 } 678 679 $product_ids = isset($args['product_ids']) ? (array) $args['product_ids'] : []; 680 681 // FIX: Defensive dedupe incoming batch to prevent processing duplicate IDs 682 $product_ids = array_unique( array_map( 'intval', $product_ids ) ); 683 684 // Sort the product IDs to ensure they are processed in a consistent order. 685 sort($product_ids); 686 $batch_size = 60; 687 $delay_seconds = 2; 688 689 // Get the product IDs for the current batch to process 690 $current_batch_ids = array_slice($product_ids, 0, $batch_size); 691 692 // If the current batch is empty, there's nothing to do 693 if (empty($current_batch_ids)) { 694 return; 695 } 696 697 // FIX: Add write lock to prevent concurrent sheet writes that cause duplicates 698 $write_lock_key = apply_filters( 699 'multissaf_sheet_write_lock_key', 700 'multissaf_sheet_write_lock' 701 ); 702 703 // Check if another write is in progress 704 if ( get_transient( $write_lock_key ) ) { 705 // Another batch is currently writing to the sheet 706 // Reschedule this batch for 3 seconds later to avoid race condition 707 /* error_log( sprintf( 708 '[MULTISTORE-SHEET] Write locked, rescheduling %d products for later', 709 count($product_ids) 710 ) ); */ 711 712 wp_schedule_single_event( 713 time() + 3, 714 'ssgsw_multistore_sheet_update', 715 [ $args ] 716 ); 717 return; 718 } 719 720 // Acquire lock (30 second TTL as safety in case of fatal error) 721 set_transient( $write_lock_key, time(), 30 ); 722 723 /* error_log( sprintf( 724 '[MULTISTORE-SHEET] Lock acquired, processing %d products', 725 count($current_batch_ids) 726 ) ); */ 727 728 // Initialize required classes and data 729 if (class_exists('\StockSyncWithGoogleSheetForWooCommerce\Sheet') && class_exists('\StockSyncWithGoogleSheetForWooCommerce\Column') && class_exists('\StockSyncWithGoogleSheetForWooCommerce\Product')) { 730 731 $new_sheet = new \StockSyncWithGoogleSheetForWooCommerce\Sheet(); 732 // Cache Sheet Formula Value 733 734 $spreadsheet_id = function_exists( 'ssgsw_get_option' ) ? ssgsw_get_option( 'spreadsheet_id' ) : ''; 735 $sheet_tab = function_exists( 'ssgsw_get_option' ) ? ssgsw_get_option( 'sheet_tab' ) : ''; 736 $cache_key = apply_filters( 737 'multissaf_sheets_info_cache_key', 738 'multissaf_sheets_info_' . md5( $spreadsheet_id . '_' . $sheet_tab ) 739 ); 740 741 $sheets_info = get_transient( $cache_key ); 742 if ( false === $sheets_info || empty( $sheets_info ) ) { 743 $sheets_info = $new_sheet->get_formula_value(); 744 if ( ! empty( $sheets_info ) ) { 745 set_transient( 746 $cache_key, 747 $sheets_info, 748 apply_filters( 'multissaf_sheets_info_ttl', 5 * MINUTE_IN_SECONDS ) 749 ); 750 } 751 } 752 753 $columns = new \StockSyncWithGoogleSheetForWooCommerce\Column(); 754 $keys_values = []; 755 if (function_exists('ssgsw_prepare_sheet_keys')) { 756 $keys_values = ssgsw_prepare_sheet_keys($sheets_info, $columns); 757 } 758 759 $products = new \StockSyncWithGoogleSheetForWooCommerce\Product(); 760 $formatted_data = $products->get_formatted_data($current_batch_ids, $sheets_info, false, 0, 0, ['product', 'product_variation'], false, $keys_values); 761 $sheets_index = []; 762 if (function_exists('ssgsw_format_index_number')) { 763 $sheets_index = ssgsw_format_index_number($sheets_info); 764 } 765 766 // Collect all row updates then batchUpdate 767 768 $batch_updates = []; 769 $batch_appends = []; 770 $newly_marked_ids = []; // FIX (Layer 3): Track which IDs we're about to append 771 772 foreach ($current_batch_ids as $key => $product_id) { 773 // Skip trashed or deleted products — their rows are already gone from the 774 // sheet and appending them would create infinite duplicate rows. 775 $post_status = get_post_status( $product_id ); 776 if ( ! $post_status || 'trash' === $post_status ) { 777 continue; 778 } 779 780 $formatted_key = array_search($product_id, $current_batch_ids); 781 $values = isset($formatted_data[$formatted_key]) ? $formatted_data[$formatted_key] : []; 782 if ($values) { 783 // FIX (Layer 3): Check our own authoritative marker first - takes priority 784 // over live-sheet lookup. If we've already successfully appended a row for 785 // this product before, we NEVER append again regardless of what the 786 // (possibly stale or mismatched) live index says. 787 $already_has_row = get_post_meta( $product_id, '_ssgsw_main_sheet_row_written', true ); 788 789 if (function_exists('ssgsw_get_index_number_option')) { 790 $index_number_exits = ssgsw_get_index_number_option($product_id, $sheets_index); 791 } else { 792 $index_number_exits = false; 793 } 794 795 if ($index_number_exits) { 796 $col_end = $new_sheet->getColumnLetter( count( $values ) ); 797 $range = $sheet_tab . '!A' . $index_number_exits . ':' . $col_end . $index_number_exits; 798 $batch_updates[] = [ 799 'range' => $range, 800 'values' => [ array_values( $values ) ], 801 ]; 802 // Backfill the marker in case it was missing (e.g. row existed from 803 // before this fix was deployed) so future runs are protected too. 804 if ( ! $already_has_row ) { 805 update_post_meta( $product_id, '_ssgsw_main_sheet_row_written', 1 ); 806 } 807 } elseif ( $already_has_row ) { 808 // FIX (Layer 3): Live index says "not found" but our own marker says we 809 // already wrote this one — trust our marker, skip entirely rather than 810 // appending a duplicate. (If the row was genuinely deleted from the 811 // sheet by a human, this product won't get a new row until the marker 812 // is manually cleared — safer than silently duplicating.) 813 continue; 814 } else { 815 $batch_appends[] = array_values( $values ); 816 $newly_marked_ids[] = $product_id; // mark only after successful write 817 } 818 } 819 } 820 821 // Send all updates in ONE API call 822 if ( ! empty( $batch_updates ) ) { 823 $result = $new_sheet->batch_update_rows( $batch_updates ); 824 if ( $result ) { 825 delete_transient( $cache_key ); 826 } 827 } 828 // Send all appends in ONE API call 829 if ( ! empty( $batch_appends ) ) { 830 $append_result = $new_sheet->append_batch_rows( $batch_appends ); 831 delete_transient( $cache_key ); 832 833 // FIX (Layer 3): Only mark as "has a row" if the append actually went through 834 if ( $append_result ) { 835 foreach ( $newly_marked_ids as $marked_id ) { 836 update_post_meta( $marked_id, '_ssgsw_main_sheet_row_written', 1 ); 837 } 838 } 839 } 840 841 } 842 843 // FIX: Release lock after writes complete to allow next batch to proceed 844 delete_transient( $write_lock_key ); 845 846 /* error_log( sprintf( 847 '[MULTISTORE-SHEET] Lock released, wrote %d updates, %d appends', 848 count($batch_updates), 849 count($batch_appends) 850 ) ); */ 851 852 // Now, schedule the next batch 853 $remaining_product_ids = array_slice($product_ids, $batch_size); 854 855 // If there are more products to process, schedule a new event 856 if (!empty($remaining_product_ids)) { 857 $next_args = $args; 858 $next_args['product_ids'] = $remaining_product_ids; 859 wp_clear_scheduled_hook('ssgsw_multistore_sheet_update', [$next_args]); 860 861 wp_schedule_single_event(time() + $delay_seconds, 'ssgsw_multistore_sheet_update', [$next_args]); 862 } 863 } 864 865 /** 866 * Direct sync dispatcher. If cron sync is disabled (feature flag 867 * 'enable_cron_sync' = false), calls ssgsw_multistore_product_update_func() 868 * inline. Otherwise fires the 'ssgsw_multistore_product_update' cron action. 869 * 870 * Connected to: action 'ssgsw_multistore_product_update_direct' 871 * 872 * @param array $args Same shape as ssgsw_multistore_product_update_func(). 873 * @return void 874 */ 875 public function ssgsw_multistore_product_update_direct( $args ) { 876 $product_ids = isset( $args['product_ids'] ) ? $args['product_ids'] : []; 877 $retry_count = isset( $args['retry_count'] ) ? intval( $args['retry_count'] ) : 0; 878 $sheet_sync = isset( $args['sheet_sync'] ) ? $args['sheet_sync'] : false; 879 $refrance_shop = isset( $args['refrance_shop'] ) ? $args['refrance_shop'] : false; 880 $site_offset = isset( $args['site_offset'] ) ? intval( $args['site_offset'] ) : 0; 881 $original_ids = isset( $args['original_ids'] ) ? $args['original_ids'] : []; 882 883 // Check if feature is enabled 884 if ( ! ssgsw_is_feature_enabled( 'enable_cron_sync' ) ) { 885 // Direct sync is enabled (cron is disabled) 886 887 // Max retry limit 888 $max_retries = ssgsw_is_feature_enabled( 'max_retry_count' ) ? ssgsw_get_feature_flags()['max_retry_count'] : 3; 889 890 if ( $retry_count >= $max_retries ) { 891 // Max retries reached, log and stop 892 return; 893 } 894 895 // Call the existing multistore product update function 896 $result = $this->ssgsw_multistore_product_update_func( $args ); 897 898 } else { 899 // Cron sync is enabled, use old behavior 900 do_action( 'ssgsw_multistore_product_update', $args ); 901 } 902 } 903 904 /** 905 * Handles the main cross-site product propagation loop. 906 * For each connected site, packages product data and POSTs to 907 * /wp-json/ssgsw/v1/update_by_ids. Schedules child-tab writes 908 * and sheet updates via WP-Cron on success. 909 * 910 * Connected to: action 'ssgsw_multistore_product_update' 911 * 912 * @param array $args { 913 * @type int[] $product_ids IDs to propagate. 914 * @type bool $sheet_sync Whether this is a sheet-sync-only pass. 915 * @type string $refrance_shop URL of the originating site (skip loop). 916 * @type int $site_offset Pagination offset within connected sites. 917 * @type int[] $original_ids Source IDs for child-tab SKU resolution. 918 * } 919 * @return void 920 */ 921 public function ssgsw_multistore_product_update_func($args) { 922 923 $product_ids = isset($args['product_ids']) ? $args['product_ids'] : []; 924 $sheet_sync = isset($args['sheet_sync']) ? $args['sheet_sync'] : false; 925 $refrance_shop = isset($args['refrance_shop']) ? $args['refrance_shop'] : false; 926 $site_offset = isset($args['site_offset']) ? intval($args['site_offset']) : 0; 927 $original_ids = isset($args['original_ids']) ? $args['original_ids'] : []; 928 929 // LOG: Entry point 930 /* error_log( sprintf( 931 '[MULTISTORE-SYNC] UPDATE_FUNC start | IDs: [%s] | Count: %d', 932 implode(',', $product_ids), 933 count($product_ids) 934 ) ); */ 935 936 // Drain pending ID queue into current batch 937 938 $pending_key = apply_filters( 'multissaf_pending_ids_key', 'multissaf_pending_product_ids' ); 939 $queued_ids = get_transient( $pending_key ); 940 if ( ! empty( $queued_ids ) && is_array( $queued_ids ) ) { 941 $product_ids = array_unique( array_merge( (array) $product_ids, $queued_ids ) ); 942 delete_transient( $pending_key ); 943 } 944 delete_transient( apply_filters( 'multissaf_sync_lock_key', 'multissaf_sync_scheduled_lock' ) ); 945 946 // Clear recovery queue on successful retry 947 $is_retry = ! empty( $args['is_retry'] ) ? true : false; 948 $retry_site = ! empty( $args['retry_site'] ) ? $args['retry_site'] : ''; 949 950 if ( $is_retry && $retry_site ) { 951 $failed_key = apply_filters( 952 'multissaf_failed_sync_key', 953 'multissaf_failed_sync_' . md5( $retry_site ) 954 ); 955 } 956 957 $site_batch_limit = 10; 958 959 $store_role = get_option('ssgsw_multi_connect_mode_on'); 960 $sync_mode = get_option('ssgsw_multi_store_mode_on'); 961 962 if ( ! is_array($product_ids) || empty($product_ids) ) return; 963 964 $batch_size = 55; 965 $delay_seconds = 5; 966 $current_batch = array_slice($product_ids, 0, $batch_size); 967 968 if (empty($current_batch)) return; 969 970 $all_sites = []; 971 if ('child' === $store_role) { 972 $all_sites = multissaf_get_connected_sites('ssgsw_multi_store_list_parent'); 973 } elseif ('main' === $store_role && 'multi_to_single' === $sync_mode) { 974 $all_sites = multissaf_get_connected_sites('ssgsw_multi_store_list'); 975 } 976 977 $current_sites_batch = array_slice($all_sites, $site_offset, $site_batch_limit); 978 $refrance_token = get_option('ssgsw_token'); 979 $refarance_shop_url = home_url(); 980 981 // LOG: Sites found 982 /* error_log( sprintf( 983 '[MULTISTORE-SYNC] Role: %s | Mode: %s | Sites found: %d | Batch: %d', 984 $store_role, 985 $sync_mode, 986 count($all_sites), 987 count($current_sites_batch) 988 ) ); */ 989 990 if ( ! empty($current_sites_batch) ) { 991 992 foreach ($current_sites_batch as $sites) { 993 $site_url = isset($sites['url']) ? $sites['url'] : ''; 994 $token = isset($sites['token']) ? $sites['token'] : ''; 995 996 // Default to TRUE (connected) if isConnected field is missing 997 // This fixes the bug where parent sites without isConnected were being skipped 998 $is_connected = isset($sites['isConnected']) ? $sites['isConnected'] : true; 999 1000 // Only skip if EXPLICITLY disconnected (0 or false) 1001 if ( is_bool($is_connected) && $is_connected === false ) { 1002 // Site is explicitly disconnected - skip and clean up 1003 $this->cleanup_site_cron_jobs($site_url); 1004 continue; 1005 } 1006 if ( is_numeric($is_connected) && intval($is_connected) === 0 ) { 1007 // Site is explicitly disconnected (0) - skip and clean up 1008 $this->cleanup_site_cron_jobs($site_url); 1009 continue; 1010 } 1011 1012 if ( ! $site_url || ! $token) { 1013 /* error_log( sprintf( 1014 '[MULTISTORE-SYNC] ⚠️ Site missing URL or token: %s', 1015 json_encode($sites) 1016 ) ); */ 1017 continue; 1018 } 1019 1020 // LOG: Sending attempt 1021 /* error_log( sprintf( 1022 '[MULTISTORE-SYNC] → Sending to: %s | Products: %d | isConnected: %s', 1023 $site_url, 1024 count($current_batch), 1025 var_export($is_connected, true) 1026 ) ); */ 1027 1028 // Validate token still matches current connection 1029 // Get fresh site data from option to ensure token hasn't changed 1030 $current_site_data = $this->get_current_site_data($site_url); 1031 if ( ! $current_site_data || $current_site_data['token'] !== $token ) { 1032 // Token mismatch - this cron job is from an old connection 1033 // Skip processing and clean up this stale cron job 1034 continue; 1035 } 1036 1037 $is_ref_shop = ( $refrance_shop && trailingslashit($site_url) === trailingslashit($refrance_shop) ); 1038 1039 $permissions = isset($sites['permissions']) ? (array) $sites['permissions'] : []; 1040 1041 // Extra permission mapping for child role 1042 if ('child' === $store_role) { 1043 $parents1 = get_option('ssgsw_child_tokens', []); 1044 if ( ! empty($parents1) ) { 1045 foreach ($parents1 as $p1) { 1046 $p1_main = isset($p1['main_store']) ? $p1['main_store'] : ''; 1047 if ( 1048 trailingslashit($p1_main) === trailingslashit($site_url) || 1049 ( isset($p1['url']) && trailingslashit($p1['url']) === trailingslashit($site_url) ) 1050 ) { 1051 $permissions = isset($p1['permissions']) ? $p1['permissions'] : $permissions; 1052 break; 1053 } 1054 } 1055 } 1056 } 1057 1058 $target_ids = []; 1059 1060 if ( ! $is_ref_shop ) { 1061 $get_row_data = multissaf_get_product_by_ids($current_batch, $permissions); 1062 1063 // If no products returned, all IDs in this batch are trashed/deleted. 1064 // Do not send to remote and do not schedule a retry — there is nothing to sync. 1065 if ( empty( $get_row_data ) ) { 1066 // Clean up any stale failed-sync option for this site so the retry 1067 // loop that caused infinite duplicates cannot continue. 1068 $stale_key = apply_filters( 'multissaf_failed_sync_key', 'multissaf_failed_sync_' . md5( $site_url ) ); 1069 delete_option( $stale_key ); 1070 delete_option( 'multissaf_failed_sync_meta_' . md5( $site_url ) ); 1071 continue; 1072 } 1073 1074 $skus = []; 1075 foreach ($get_row_data as $p) { 1076 if ( ! empty($p['sku'])) $skus[] = $p['sku']; 1077 } 1078 1079 $body = [ 1080 'ssgsw_products' => $get_row_data, 1081 'skus' => $skus, 1082 'refarance_shop' => $refarance_shop_url, 1083 'refrance_token' => $refrance_token, 1084 'orgingal_ids' => $product_ids, 1085 'sync_mode' => $sync_mode, 1086 'sheet_sync' => $sheet_sync, 1087 'manual_update' => true, 1088 ]; 1089 1090 $api_url = trailingslashit($site_url) . 'wp-json/ssgsw/v1/update_by_ids'; 1091 $response = multissaf_single_product_update($api_url, $body, $token); 1092 1093 if ($response && isset($response->data->target_ids) && ! empty($response->data->target_ids)) { 1094 $target_ids = $response->data->target_ids; 1095 1096 // LOG: Success 1097 /* error_log( sprintf( 1098 '[MULTISTORE-SYNC] ✅ Success | Site: %s | Targets: [%s]', 1099 $site_url, 1100 implode(',', $response->data->target_ids) 1101 ) ); */ 1102 1103 if ( $is_retry && $retry_site && trailingslashit( $site_url ) === trailingslashit( $retry_site ) ) { 1104 delete_option( $failed_key ); 1105 delete_option( 'multissaf_failed_sync_meta_' . md5( $retry_site ) ); 1106 } 1107 1108 } else { 1109 1110 // LOG: Failed 1111 /* error_log( sprintf( 1112 '[MULTISTORE-SYNC] ❌ Failed | Site: %s | Response: %s', 1113 $site_url, 1114 is_object($response) ? json_encode($response) : 'NULL' 1115 ) ); */ 1116 1117 $failed_key_site = apply_filters( 1118 'multissaf_failed_sync_key', 1119 'multissaf_failed_sync_' . md5( $site_url ) 1120 ); 1121 $failed_ids = get_option( $failed_key_site, [] ); 1122 1123 // Only retry IDs that still exist as published products. 1124 // Trashed/deleted products must never enter the retry queue — 1125 // they cause infinite append loops because the sheet row is gone. 1126 $retryable_ids = array_filter( (array) $current_batch, function( $id ) { 1127 $status = get_post_status( $id ); 1128 return $status && 'trash' !== $status; 1129 } ); 1130 1131 if ( empty( $retryable_ids ) ) { 1132 // Nothing to retry — clean up any stale data and move on. 1133 delete_option( $failed_key_site ); 1134 delete_option( 'multissaf_failed_sync_meta_' . md5( $site_url ) ); 1135 } else { 1136 $failed_ids = array_unique( array_merge( (array) $failed_ids, array_values( $retryable_ids ) ) ); 1137 update_option( $failed_key_site, $failed_ids, false ); 1138 1139 update_option( 'multissaf_failed_sync_meta_' . md5( $site_url ), [ 1140 'site_url' => $site_url, 1141 'failed_ids' => $failed_ids, 1142 'failed_at' => current_time( 'mysql' ), 1143 'retry_count' => 0, 1144 ], false ); 1145 1146 $retry_args = [[ 1147 'product_ids' => $failed_ids, 1148 'sheet_sync' => $sheet_sync, 1149 'refrance_shop' => $refrance_shop, 1150 'original_ids' => $original_ids, 1151 'is_retry' => true, 1152 'retry_site' => $site_url, 1153 ]]; 1154 $retry_delay = apply_filters( 'multissaf_retry_delay_seconds', 120 ); 1155 wp_clear_scheduled_hook( 'ssgsw_multistore_product_update', $retry_args ); 1156 wp_schedule_single_event( 1157 time() + $retry_delay, 1158 'ssgsw_multistore_product_update', 1159 $retry_args 1160 ); 1161 } 1162 } 1163 } else { 1164 $target_ids = ! empty($original_ids) ? $original_ids : $current_batch; 1165 } 1166 1167 $should_write_child_tabs = apply_filters( 1168 'multissaf_should_write_child_tabs', 1169 ( 'main' === $store_role && ! $sheet_sync && ! empty( $target_ids ) ), 1170 $store_role, 1171 $sheet_sync, 1172 $target_ids 1173 ); 1174 1175 if ( $should_write_child_tabs ) { 1176 // Extract SKUs from CURRENT_BATCH (Destination IDs), not target_ids (Source IDs) 1177 $target_skus = []; 1178 foreach ( $current_batch as $dest_product_id ) { 1179 $sku = get_post_meta( $dest_product_id, '_sku', true ); 1180 if ( ! empty( $sku ) ) { 1181 $target_skus[] = $sku; 1182 } 1183 } 1184 1185 if ( empty( $target_skus ) ) { 1186 //No SKUs found for Destination products, skipping Child Tab update 1187 continue; 1188 } 1189 1190 $retrieve_url = trailingslashit($site_url) . 'wp-json/ssgsw/v1/retrive_child'; 1191 $retrieve_body = [ 1192 'batch_info' => ['offset' => 0, 'batchSize' => 50], 1193 'product_skus' => $target_skus, // Use SKUs instead of IDs 1194 'access_token' => $token, 1195 'import_request' => false, 1196 ]; 1197 1198 //Calling retrive_child API with product_skus 1199 $retrieve_response = multissaf_single_product_update($retrieve_url, $retrieve_body, $token); 1200 $parent_sheet_data = []; 1201 1202 if ($retrieve_response && isset($retrieve_response->data)) { 1203 $parent_sheet_data = $retrieve_response->data; // Retrieved parent_sheet_data count 1204 } 1205 1206 if ( ! empty($parent_sheet_data) ) { 1207 $sheet_id = isset($sites['spreadsheet_id']) ? $sites['spreadsheet_id'] 1208 : (function_exists('ssgsw_get_option') ? ssgsw_get_option('spreadsheet_id') : ''); 1209 $tab_name = isset($sites['tab_name']) ? $sites['tab_name'] : ''; 1210 $sheet_gid = isset($sites['sheet_gid']) ? $sites['sheet_gid'] : ''; 1211 1212 if ( ! empty($sheet_id) && ! empty($tab_name) && class_exists('\StockSyncWithGoogleSheetForWooCommerce\Sheet') ) { 1213 $sheet = new \StockSyncWithGoogleSheetForWooCommerce\Sheet(); 1214 $sheet->setID($sheet_id); 1215 $sheet->setTab($tab_name); 1216 if ( ! empty($sheet_gid) && method_exists( $sheet, 'setSheetID' ) ) $sheet->setSheetID($sheet_gid); 1217 1218 // Always fetch fresh data for child tab updates to prevent duplicates 1219 $sheets_info = $sheet->get_formula_value( false, false, $tab_name, 'UNFORMATTED_VALUE' ); 1220 //Ftched Child Tab data, row count 1221 1222 if ( ! empty( $sheets_info ) ) { 1223 // Update cache AFTER fetching (for read-only operations) 1224 $child_cache_key = apply_filters( 1225 'multissaf_sheets_info_cache_key', 1226 'multissaf_sheets_info_' . md5( $sheet_id . '_' . $tab_name ) 1227 ); 1228 set_transient( 1229 $child_cache_key, 1230 $sheets_info, 1231 apply_filters( 'multissaf_sheets_info_ttl', 5 * MINUTE_IN_SECONDS ) 1232 ); 1233 } 1234 1235 if ($sheets_info) { 1236 // Build ID index from Child Tab - maps Product ID to Row Number 1237 $id_index = []; 1238 foreach ( $sheets_info as $row_num => $row_data ) { 1239 if ( $row_num == 0 ) continue; // Skip header 1240 if ( isset( $row_data[0] ) && is_numeric( $row_data[0] ) ) { 1241 $product_id = intval( $row_data[0] ); 1242 $id_index[ $product_id ] = $row_num + 1; // 1-based row index 1243 } 1244 } 1245 1246 $child_batch_updates = []; 1247 $child_batch_appends = []; 1248 1249 foreach ($parent_sheet_data as $data_idx => $values) { 1250 $values = (array) $values; 1251 1252 if (isset($values[0]) && ! is_numeric($values[0])) { 1253 // This is the header row — always write it to row 1 so the 1254 // child tab header stays in sync with the current field permissions. 1255 if ( $data_idx === 0 ) { 1256 $col_end = $sheet->getColumnLetter( count( $values ) ); 1257 $range = $tab_name . '!A1:' . $col_end . '1'; 1258 $child_batch_updates[] = [ 1259 'range' => $range, 1260 'values' => [ array_values( $values ) ], 1261 ]; 1262 } else { 1263 // error_log( "DEBUG: Skipping non-numeric ID at data_idx=$data_idx" ); 1264 } 1265 continue; 1266 } 1267 $product_id = isset($values[0]) ? intval($values[0]) : false; 1268 if ( ! $product_id) { 1269 continue; 1270 } 1271 1272 $row_idx = false; 1273 1274 // Find the row with this Product ID 1275 if ( isset( $id_index[ $product_id ] ) ) { 1276 $row_idx = $id_index[ $product_id ]; 1277 } else { 1278 // error_log( "Child Tab ID NOT FOUND: Product ID=$product_id (data_idx=$data_idx) - will append as new" ); 1279 } 1280 1281 // Update or append based on match 1282 if ($row_idx) { 1283 $col_end = $sheet->getColumnLetter( count( $values ) ); 1284 $range = $tab_name . '!A' . $row_idx . ':' . $col_end . $row_idx; 1285 $child_batch_updates[] = [ 1286 'range' => $range, 1287 'values' => [ array_values( $values ) ], 1288 ]; 1289 } else { 1290 // No ID match - append as new product 1291 $child_batch_appends[] = array_values( $values ); 1292 } 1293 } 1294 1295 // Send all updates in ONE API call 1296 if ( ! empty( $child_batch_updates ) ) { 1297 $sheet->batch_update_rows( $child_batch_updates ); 1298 delete_transient( $child_cache_key ); 1299 } 1300 if ( ! empty( $child_batch_appends ) ) { 1301 $sheet->append_batch_rows( $child_batch_appends ); 1302 delete_transient( $child_cache_key ); 1303 } 1304 1305 } 1306 } 1307 } 1308 } 1309 } 1310 } 1311 1312 if (count($all_sites) > ($site_offset + $site_batch_limit)) { 1313 // More sites left for the same product batch 1314 $next_args = $args; 1315 $next_args['site_offset'] = $site_offset + $site_batch_limit; 1316 wp_clear_scheduled_hook('ssgsw_multistore_product_update', [$next_args]); 1317 wp_schedule_single_event(time() + $delay_seconds, 'ssgsw_multistore_product_update', [$next_args]); 1318 1319 } else { 1320 1321 // Fix Duplicate Sheet Writes 1322 $should_write_main_tab = apply_filters( 1323 'multissaf_should_write_main_tab', 1324 ( $sheet_sync || empty( $current_sites_batch ) ), 1325 $sheet_sync, 1326 $current_sites_batch 1327 ); 1328 1329 if ( $should_write_main_tab ) { 1330 if (class_exists('\StockSyncWithGoogleSheetForWooCommerce\App')) { 1331 $new_app = new \StockSyncWithGoogleSheetForWooCommerce\App(); 1332 if ($new_app->is_plugin_ready_setup()) { 1333 $sheet_args = [['product_ids' => $current_batch]]; 1334 wp_clear_scheduled_hook('ssgsw_multistore_sheet_update', $sheet_args); 1335 wp_schedule_single_event(time() + 2, 'ssgsw_multistore_sheet_update', $sheet_args); 1336 } else { 1337 //error_log('[SSGSW DEBUG] MAIN STORE sheet setup NOT ready.'); 1338 } 1339 } else { 1340 //error_log('[SSGSW DEBUG] MAIN STORE App class missing.'); 1341 } 1342 } else { 1343 if (class_exists('\StockSyncWithGoogleSheetForWooCommerce\App')) { 1344 $new_app = new \StockSyncWithGoogleSheetForWooCommerce\App(); 1345 if ($new_app->is_plugin_ready_setup()) { 1346 $sheet_args = [['product_ids' => $current_batch]]; 1347 wp_clear_scheduled_hook('ssgsw_multistore_sheet_update', $sheet_args); 1348 wp_schedule_single_event(time() + 2, 'ssgsw_multistore_sheet_update', $sheet_args); 1349 } else { 1350 // error_log('[SSGSW DEBUG] MAIN STORE sheet setup NOT ready.'); 1351 } 1352 } 1353 } 1354 1355 // Move to next product batch 1356 $remaining_products = array_slice($product_ids, $batch_size); 1357 if ( ! empty($remaining_products)) { 1358 $next_args = $args; 1359 $next_args['product_ids'] = $remaining_products; 1360 $next_args['site_offset'] = 0; 1361 wp_clear_scheduled_hook('ssgsw_multistore_product_update', [$next_args]); 1362 wp_schedule_single_event(time() + $delay_seconds, 'ssgsw_multistore_product_update', [$next_args]); 1363 } 1364 } 1365 } 1366 1367 /** 1368 * Collects updated product IDs and queues/fires the propagation action. 1369 * Entry point called by the base plugin after any product save. 1370 * Guards against manual-import re-trigger via body flag. 1371 * 1372 * Connected to: action 'ssgsw_collection_of_update_ids' 1373 * 1374 * @param int[] $product_ids Products that changed. 1375 * @param bool $sheet_sync Pass through to update func. 1376 * @param string $refrance_shop Pass through to update func. 1377 * @param int[] $original_ids Pass through to update func. 1378 * @param bool $manual_update Allow firing even if body has manual_update flag. 1379 * @return void 1380 */ 1381 public function ssgsw_collection_of_update_ids($product_ids, $sheet_sync= false, $refrance_shop = '', $original_ids = [], $manual_update = false) { 1382 1383 // LOG: Entry point 1384 /* error_log( sprintf( 1385 '[MULTISTORE-SYNC] collection_of_update_ids | IDs: [%s] | Role: %s | SheetSync: %s | Ref: %s | Active: child=%s parent=%s', 1386 implode(',', $product_ids), 1387 get_option('ssgsw_multi_connect_mode_on', 'none'), 1388 $sheet_sync ? 'yes' : 'no', 1389 $refrance_shop ?: 'none', 1390 multissaf_chid_activity() ? 'Y' : 'N', 1391 multissaf_parent_activity() ? 'Y' : 'N' 1392 ) ); */ 1393 1394 $multissaf_body = multissaf_get_body(); 1395 $manual_import = isset($multissaf_body->manual_update) ? (int)$multissaf_body->manual_update : false; 1396 1397 if ( $manual_import && ! $manual_update ) { 1398 // error_log( '[MULTISTORE-SYNC] Blocked by manual_import guard' ); 1399 return; 1400 } 1401 1402 if(!$product_ids) { 1403 return; 1404 } 1405 $args = [ 1406 [ 1407 'product_ids' => $product_ids, 1408 'sheet_sync' => $sheet_sync, 1409 'refrance_shop' => $refrance_shop, 1410 'original_ids' => $original_ids, 1411 1412 ] 1413 ]; 1414 $child_active = multissaf_chid_activity(); 1415 $parent_active = multissaf_parent_activity(); 1416 1417 if ($child_active || $parent_active ) { 1418 1419 // Use direct sync instead of cron scheduling 1420 if ( ! ssgsw_is_feature_enabled( 'enable_cron_sync' ) ) { 1421 // Direct sync is enabled (cron is disabled) 1422 1423 $pending_key = apply_filters( 'multissaf_pending_ids_key', 'multissaf_pending_product_ids' ); 1424 $pending = get_transient( $pending_key ); 1425 $pending = is_array( $pending ) ? $pending : []; 1426 $pending = array_unique( array_merge( $pending, (array) $product_ids ) ); 1427 set_transient( $pending_key, $pending, apply_filters( 'multissaf_pending_ids_ttl', 30 ) ); 1428 1429 $lock_key = apply_filters( 'multissaf_sync_lock_key', 'multissaf_sync_scheduled_lock' ); 1430 if ( ! get_transient( $lock_key ) ) { 1431 set_transient( $lock_key, 1, 10 ); 1432 1433 // Call direct sync action instead of scheduling cron 1434 do_action( 'ssgsw_multistore_product_update_direct', [ 1435 'product_ids' => $pending, 1436 'sheet_sync' => $sheet_sync, 1437 'refrance_shop' => $refrance_shop, 1438 'original_ids' => $original_ids, 1439 'retry_count' => 0, // Start with 0 retries 1440 ] ); 1441 } 1442 } else { 1443 // Cron sync is enabled, use old behavior 1444 $pending_key = apply_filters( 'multissaf_pending_ids_key', 'multissaf_pending_product_ids' ); 1445 $pending = get_transient( $pending_key ); 1446 $pending = is_array( $pending ) ? $pending : []; 1447 $pending = array_unique( array_merge( $pending, (array) $product_ids ) ); 1448 set_transient( $pending_key, $pending, apply_filters( 'multissaf_pending_ids_ttl', 30 ) ); 1449 1450 $lock_key = apply_filters( 'multissaf_sync_lock_key', 'multissaf_sync_scheduled_lock' ); 1451 if ( ! get_transient( $lock_key ) ) { 1452 set_transient( $lock_key, 1, 10 ); 1453 wp_schedule_single_event( 1454 time() + apply_filters( 'multissaf_product_update_delay_seconds', 5 ), 1455 'ssgsw_multistore_product_update', 1456 [[ 1457 'product_ids' => $pending, 1458 'sheet_sync' => $sheet_sync, 1459 'refrance_shop' => $refrance_shop, 1460 'original_ids' => $original_ids, 1461 ]] 1462 ); 1463 } 1464 1465 } 1466 } 1467 1468 } 1469 1470 /** 1471 * Checks if the current domain should be allowed to perform a sync based on the multi-store sync mode and child activity. 1472 * 1473 * @param bool $domain_access The initial access status for the domain. This is typically set to true by default. 1474 * @return bool Returns true if the sync mode is "shop_to_sheet" and the child store is active. Otherwise, it returns the original value of $domain_access. 1475 */ 1476 public function ssgsw_domain_url($domain_access) { 1477 $sync_mode = get_option('ssgsw_multi_store_mode_on'); 1478 $activity = multissaf_chid_activity(); 1479 if ( 'shop_to_sheet' == $sync_mode && $activity) { 1480 return true; 1481 } 1482 return $domain_access; 1483 } 1484 1485 /** 1486 * Determines the active synchronization mode based on multi-store settings and activity. 1487 * @param string $sync_mode The initial synchronization mode value. 1488 * @return string The determined synchronization mode. 1489 */ 1490 public function ssgsw_sync_mode($sync_mode) { 1491 1492 $sync_mode = get_option('ssgsw_multi_store_mode_on'); 1493 $activity = multissaf_chid_activity(); 1494 if ( 'shop_to_sheet' == $sync_mode && $activity) { 1495 return 'shop_to_sheet'; 1496 } 1497 return $sync_mode; 1498 } 1499 1500 /** 1501 * Modifies the product query to sync only products with a SKU when in multi-store mode. 1502 * 1503 * @global wpdb $wpdb WordPress database object. 1504 * 1505 * @param string $query The original SQL query or query fragment passed to the filter. 1506 * @return string The modified query fragment, or the original query if the condition is not met. 1507 */ 1508 public function ssgsw_sync_only_products_with_sku($query) { 1509 1510 $sync_mode = get_option('ssgsw_multi_store_mode_on'); 1511 $other_check = multissaf_chid_activity(); 1512 1513 if ('multi_to_single' === $sync_mode && $other_check) { 1514 return true; 1515 } 1516 return $query; 1517 } 1518 1519 /** 1520 * Adds the home URL as an 'ssgsw_origin' key to the formatted data array. 1521 * 1522 * @param array $formated_data The array of data to which the origin URL will be added. 1523 * @return array The modified data array with the 'ssgsw_origin' key. 1524 */ 1525 public function ssgsw_add_extra($formated_data, $is_admin, $domain_chcker) { 1526 1527 if ($is_admin) { 1528 $sync_mode = get_option('ssgsw_multi_store_mode_on'); 1529 $other_check = multissaf_chid_activity(); 1530 if (('shop_to_sheet' === $sync_mode && $other_check) || $domain_chcker) { 1531 $formated_data['ssgsw_origin'] = wp_parse_url(home_url(), PHP_URL_HOST); 1532 return $formated_data; 1533 } 1534 } 1535 return $formated_data; 1536 } 1537 1538 /** 1539 * Adds a new custom 'Origin' column to the WooCommerce products list table. 1540 * 1541 * @param array $columns An associative array of existing product columns. 1542 * @return array An updated array of columns, including the new 'Origin' column. 1543 */ 1544 public function product_columns($columns) { 1545 1546 if (!function_exists('ssgsw_is_admin')) { 1547 return $columns; 1548 } 1549 if (!function_exists('ssgsw_check_action_domain')) { 1550 return $columns; 1551 } 1552 $is_admin_check = ssgsw_is_admin(); 1553 $domain_check = ssgsw_check_action_domain(); 1554 if ($is_admin_check) { 1555 $sync_mode = get_option('ssgsw_multi_store_mode_on'); 1556 $other_check = multissaf_chid_activity(); 1557 1558 if ( 'shop_to_sheet' === $sync_mode && $other_check ) { 1559 $ultimate_columns = [ 1560 'ssgsw_origin' => [ 1561 'label' => __('Store Name', 'multi-store-sync-addon-for-flexstock'), 1562 'type' => 'meta', 1563 'priority' => 9, 1564 ] 1565 ]; 1566 return array_merge( $columns, $ultimate_columns ); 1567 } 1568 } 1569 return $columns; 1570 } 1571 1572 /** 1573 * Filters the array of site product counts, allowing other plugins to 1574 * add data from connected sub-sites. 1575 * @param array $main_array The initial array of site data, containing the main site's information. 1576 * @return array The filtered array, now including data for connected sub-sites. 1577 */ 1578 public function ssgsw_sub_site_product_count($main_array = []) { 1579 // Retrieve the list of sub-sites from the option. 1580 $sub_site = multissaf_sub_site_url(); 1581 1582 // Filter out any entries that have empty URLs or tokens. 1583 $filtered_options = array_filter($sub_site, function($entry) { 1584 return !empty($entry['url']) && !empty($entry['token']); 1585 }); 1586 1587 // Re-index the array keys after filtering. 1588 $filtered_options = array_values($filtered_options); 1589 1590 // If there are valid sub-sites, loop through them and add their data to the main array. 1591 if (is_array($filtered_options) && !empty($filtered_options)) { 1592 foreach ($filtered_options as $site) { 1593 // Get the product count for the current sub-site. 1594 $site_url = $site['url']; 1595 $token = $site['token']; 1596 1597 // Get FILTERED product count from child using access_token 1598 $product_count = 0; 1599 if (function_exists('multissaf_get_sub_product_count')) { 1600 $count_data = multissaf_get_sub_product_count($site_url, $token, $site); 1601 if (is_array($count_data) && isset($count_data['count'])) { 1602 $product_count = intval($count_data['count']); 1603 } elseif (is_numeric($count_data)) { 1604 $product_count = intval($count_data); 1605 } 1606 } 1607 1608 // Append the sub-site's data to the main array. 1609 $main_array[] = [ 1610 'url' => $site_url, 1611 'token' => $token, 1612 'product' => $product_count, 1613 'sheet_tab' => $site['tab_name'], 1614 'sheet_id' => $site['sheet_gid'], 1615 ]; 1616 } 1617 } 1618 1619 // Return the final array containing data from the main site and all sub-sites. 1620 return $main_array; 1621 } 1622 1623 /** 1624 * Enqueues CSS styles for the plugin's admin page. 1625 * 1626 * @param string $hook The current admin page hook. 1627 * @return void 1628 */ 1629 public function enqueue_scripts($hook) { 1630 1631 if($hook == 'flexstock_page_ssgsw-multistore') { 1632 wp_enqueue_style('ssgsw-multi-store-css', MULTISSAF_MULTI_STORE_URL . '/assets/build/css/admin.min.css', [], time()); 1633 1634 $setup_complete = get_option('ssgsw_multi_store_setup_complete', false); 1635 1636 if ($setup_complete) { 1637 wp_enqueue_script('ssgsw-multi-store-dashboard', MULTISSAF_MULTI_STORE_URL . '/assets/build/js/admin.min.js', [], time(), true); 1638 1639 // Prepare data for dashboard 1640 $mode = get_option('ssgsw_multi_store_mode_on', 'multi_to_single'); 1641 $role = get_option('ssgsw_multi_connect_mode_on', 'main'); 1642 $js_sync_behavior = ($mode === 'shop_to_sheet') ? 'manage' : 'sync'; 1643 $js_store_role = ($role === 'child') ? 'child' : 'main'; 1644 1645 $available_fields = []; 1646 if (class_exists('\StockSyncWithGoogleSheetForWooCommerce\Column')) { 1647 $column_obj = new \StockSyncWithGoogleSheetForWooCommerce\Column(); 1648 $columns = $column_obj->get_columns(); 1649 foreach ($columns as $key => $data) { 1650 // Skip columns explicitly marked as non-sheet columns (column: false). 1651 if ( isset( $data['column'] ) && false === $data['column'] ) { 1652 continue; 1653 } 1654 $available_fields[] = [ 1655 'val' => $key, 1656 'label' => isset($data['label']) ? $data['label'] : $key 1657 ]; 1658 } 1659 } 1660 1661 $available_categories = []; 1662 $product_cats = get_terms(['taxonomy' => 'product_cat', 'hide_empty' => false]); 1663 if (!is_wp_error($product_cats)) { 1664 foreach ($product_cats as $cat) { 1665 $available_categories[] = [ 1666 'id' => $cat->term_id, 1667 'name' => $cat->name, 1668 'count' => $cat->count 1669 ]; 1670 } 1671 } 1672 1673 $available_tags = []; 1674 $product_tags = get_terms(['taxonomy' => 'product_tag', 'hide_empty' => false]); 1675 if (!is_wp_error($product_tags)) { 1676 foreach ($product_tags as $tag) { 1677 $available_tags[] = [ 1678 'id' => $tag->term_id, 1679 'name' => $tag->name, 1680 'count' => $tag->count 1681 ]; 1682 } 1683 } 1684 1685 $options = apply_filters('ssgsw_options', []); 1686 1687 wp_localize_script('ssgsw-multi-store-dashboard', 'multissaf_store_data', [ 1688 'syncMode' => $mode, 1689 'syncBehavior' => $js_sync_behavior, 1690 'storeRole' => $js_store_role, 1691 'availableFields' => $available_fields, 1692 'availableCategories' => $available_categories, 1693 'availableTags' => $available_tags, 1694 'spreadsheetId' => isset($options['spreadsheet_id']) ? $options['spreadsheet_id'] : '', 1695 'connectedSites' => isset($options['multi_store_list']) ? $options['multi_store_list'] : [], 1696 'connectedSitesParent' => isset($options['child_tokens']) ? $options['child_tokens'] : [], 1697 'homeUrl' => home_url(), 1698 'i18n' => [ 1699 'startingSync' => __('Starting sync...', 'multi-store-sync-addon-for-flexstock'), 1700 'exportingBatch' => __('Exporting batch starting at: ', 'multi-store-sync-addon-for-flexstock'), 1701 'syncing' => __('Syncing: ', 'multi-store-sync-addon-for-flexstock'), 1702 'syncComplete' => __('Sync Complete!', 'multi-store-sync-addon-for-flexstock'), 1703 'confirmReset' => __('Are you sure you want to switch modes? This will reset your current Multi-Store settings.', 'multi-store-sync-addon-for-flexstock'), 1704 'spreadsheetIdNotFound' => __('Master Spreadsheet ID not found. Please check your settings.', 'multi-store-sync-addon-for-flexstock'), 1705 'startingManualSync' => __('Starting manual sync...', 'multi-store-sync-addon-for-flexstock'), 1706 ] 1707 ]); 1708 1709 } else { 1710 wp_enqueue_script('ssgsw-multi-store-setting', MULTISSAF_MULTI_STORE_URL . '/assets/build/js/setting.min.js', [], time(), true); 1711 } 1712 } 1713 1714 } 1715 1716 /** 1717 * Localizes (passes) relevant script variables, specifically multi-store URLs, 1718 * to be used by front-end JavaScript 1719 * @since 1.0.0 (Example version) 1720 * 1721 * @param array $filters An array of data variables already set for localization. 1722 * @return array The updated array of localization data, including multi-store URLs. 1723 */ 1724 public function ssgsw_localized_script($filters) { 1725 $filters['multi_store_list'] = multissaf_sub_site_url(); 1726 $filters['multi_store_list_parent'] = multissaf_sub_site_url_parent(); 1727 1728 return $filters; 1729 } 1730 /** 1731 * Add setting options 1732 * 1733 * @return void 1734 */ 1735 public function ssgsw_options($options) { 1736 $options['multi_store_active'] = get_option('ssgsw_multi_store_active', true); 1737 $options['multi_store_list'] = multissaf_sub_site_url(); 1738 $options['multi_store_list_parent'] = multissaf_sub_site_url_parent(); 1739 $options['multi_store_mode_on'] = get_option('ssgsw_multi_store_mode_on', 'shop_to_sheet'); 1740 $options['multi_connect_mode_on'] = get_option('ssgsw_multi_connect_mode_on', 'child'); 1741 $options['child_tokens'] = get_option('ssgsw_child_tokens', []); 1742 $options['spreadsheet_id'] = function_exists('ssgsw_get_option') ? ssgsw_get_option('spreadsheet_id') : ''; 1743 1744 return $options; 1745 } 1746 1747 /** 1748 * Add vendor menu 1749 * 1750 * @return void 1751 */ 1752 public function set_menu() { 1753 add_submenu_page( 1754 'ssgsw-admin', 1755 __('Multi-Store Sync', 'multi-store-sync-addon-for-flexstock'), 1756 __('Multi-Store Sync', 'multi-store-sync-addon-for-flexstock'), 1757 'manage_options', 1758 'ssgsw-multistore', 1759 [ $this, 'render_vendor_page' ], 1760 99 1761 ); 1762 } 1763 1764 /** 1765 * Add vendor page 1766 * 1767 * @return void 1768 */ 1769 public function render_vendor_page() { 1770 $setup_complete = get_option('ssgsw_multi_store_setup_complete', false); 1771 if ($setup_complete) { 1772 $this->load_template('dashboard'); 1773 } else { 1774 $this->load_template('setting'); 1775 } 1776 } 1777 1778 /** 1779 * Add plugin action links callback. 1780 * 1781 * @param array $links Plugin action links. 1782 * @return array 1783 */ 1784 public function add_plugin_action_links( $links ) { 1785 1786 $links[] = '<a href="' . admin_url('admin.php?page=ssgsw-multistore') . '">' . __('Settings', 'multi-store-sync-addon-for-flexstock') . '</a>'; 1787 return $links; 1788 } 1789 1790 /** 1791 * Redirects the user to the add-ons page after plugin activation, 1792 * but only if the redirection hasn't been triggered before. 1793 * @return void 1794 */ 1795 public function redirect_after_active() { 1796 // Only run in wp-admin, never on frontend, REST, AJAX, or cron requests. 1797 if ( ! is_admin() || wp_doing_ajax() || ! current_user_can( 'manage_options' ) ) { 1798 return; 1799 } 1800 // Skip on bulk plugin activation. 1801 if ( isset( $_GET['activate-multi'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended 1802 return; 1803 } 1804 $ssgsw_triggered_from_ultimate = wp_validate_boolean( get_option( 'ssgsw_triggered_from_multistore' ) ); 1805 if ( ! $ssgsw_triggered_from_ultimate ) { 1806 update_option( 'ssgsw_triggered_from_multistore', true ); 1807 wp_safe_redirect( admin_url( 'admin.php?page=ssgsw-multistore' ) ); 1808 exit; 1809 } 1810 } 1811 1812 /** 1813 * Loads a template file with optional data injection 1814 * @param string $template The template file name (without extension) 1815 * @param array $data Associative array of data to expose to template (optional) 1816 * 1817 * @return void 1818 * 1819 * @throws Exception If template file cannot be found (when WP_DEBUG is true) 1820 */ 1821 public function load_template( $template, $data = [] ) { // phpcs:ignore 1822 if (!is_array($data)) { 1823 $data = []; 1824 } 1825 if ( file_exists( MULTISSAF_MULTI_STORE_TEMPLATES . $template . '.php' ) ) { 1826 include MULTISSAF_MULTI_STORE_TEMPLATES . $template . '.php'; 1827 } 1828 } 1829 1830 /** 1831 * Loads the appropriate template based on the plugin setup status and vendor integration. 1832 * 1833 * @return void 1834 */ 1835 1836 public function multi_store_template() { 1837 1838 } 1839 1840 /** 1841 * Activate plugin 1842 * 1843 * @return void 1844 */ 1845 public function activate() { 1846 delete_option( 'ssgsw_triggered_from_multistore' ); 1847 } 1848 1849 /** 1850 * Print admin notices 1851 * 1852 * @return void 1853 */ 1854 public function admin_notices() { 1855 1856 if ( ! current_user_can( 'activate_plugins' ) ) { 1857 return; 1858 } 1859 1860 // FlexStock is active and meets minimum version — no notice needed. 1861 if ( defined( 'SSGSW_VERSION' ) && version_compare( SSGSW_VERSION, '3.15.0', '>=' ) ) { 1862 return; 1863 } 1864 1865 $plugin_name = esc_html__( 'FlexStock Multi-Store', 'multi-store-sync-addon-for-flexstock' ); 1866 $plugin_url = $this->app->free_version; 1867 1868 $dependency = esc_html__( 'FlexStock', 'multi-store-sync-addon-for-flexstock' ); 1869 1870 // FlexStock is active but version is too old. 1871 if ( defined( 'SSGSW_VERSION' ) && version_compare( SSGSW_VERSION, '3.15.0', '<' ) ) { 1872 $message2 = wp_sprintf( '<strong>%s</strong> requires <strong>%s</strong> plugin version 3.15.0 or higher. Please update FlexStock.', esc_html( $plugin_name ), esc_html( $dependency ) ); 1873 echo wp_sprintf( '<div class="error"><p>%s</p></div>', wp_kses_post( $message2 ) ); 1874 return; 1875 } 1876 1877 // FlexStock is installed but not activated. 1878 if ( $this->app->is_plugin_installed() ) { 1879 $activation_url = wp_nonce_url( 'plugins.php?action=activate&plugin=' . esc_url( $plugin_url ) . '&plugin_status=all&paged=1&s', 'activate-plugin_' . esc_url( $plugin_url ) ); 1880 1881 $message = wp_sprintf( '<strong>%s</strong> requires <strong>%s</strong> plugin to be activated.', esc_html( $plugin_name ), esc_html( $dependency ) ); 1882 $button_text = esc_html__( 'Activate', 'multi-store-sync-addon-for-flexstock' ); 1883 } else { 1884 // FlexStock is not installed at all. 1885 $activation_url = wp_nonce_url( self_admin_url( 'update.php?action=install-plugin&plugin=stock-sync-with-google-sheet-for-woocommerce' ), 'install-plugin_woocommerce' ); 1886 $message = wp_sprintf( '<strong>%s</strong> requires <strong>%s</strong> plugin to be installed and activated.', esc_html( $plugin_name ), esc_html( $dependency ) ); 1887 $button_text = esc_html__( 'Install', 'multi-store-sync-addon-for-flexstock' ); 1888 } 1889 1890 echo wp_sprintf( '<div class="error"><p>%s</p></div>', wp_kses_post( $message ) ); 1891 } 1892 1893 /** 1894 * Initialize Appsero analytics client and WPPOOL plugin SDK. 1895 * 1896 * Integrates: 1897 * 1. Appsero Client for licensing & analytics 1898 * 2. WPPOOL SDK for premium upgrade popups 1899 * 1900 * @return void 1901 */ 1902 public function init_appsero_client() { 1903 $appsero_file = MULTISSAF_MULTI_STORE_PATH . 'vendor/appsero/src/Client.php'; 1904 if ( ! file_exists( $appsero_file ) ) { 1905 return; 1906 } 1907 1908 // Load Appsero Client 1909 if ( ! class_exists( '\MultiStoreSyncAddonForFlexStock\Appsero\Client' ) ) { 1910 require_once $appsero_file; 1911 } 1912 1913 if ( ! class_exists( '\MultiStoreSyncAddonForFlexStock\Appsero\Client' ) ) { 1914 return; 1915 } 1916 1917 $client = new \MultiStoreSyncAddonForFlexStock\Appsero\Client( 1918 '1772338d-40f8-4cb5-907b-432a609cd0f2', 1919 'Multi-Store Sync', 1920 MULTISSAF_MULTI_STORE 1921 ); 1922 1923 // Activate Appsero insights tracking 1924 $client->insights()->init(); 1925 1926 // Initialize WPPOOL SDK for premium popups 1927 if ( function_exists( 'wppool_plugin_init' ) ) { 1928 $default_image = MULTISSAF_MULTI_STORE_URL . '/includes/wppool/background-image.png'; 1929 1930 // Register plugin with SDK 1931 $multistore_plugin = wppool_plugin_init( 'multi_store_sync_addon_for_flexstock', $default_image ); 1932 1933 $promo_image = MULTISSAF_MULTI_STORE_URL . '/includes/wppool/special.png'; 1934 $promo_end = '2026-12-31 23:59:59'; 1935 $promo_start = '2026-11-15 00:00:00'; 1936 $promo_cta = esc_html__( 'Get Pro Now', 'multi-store-sync-addon-for-flexstock' ); 1937 $promo_link = 'https://wppool.dev/flexstock/addons/'; 1938 1939 if ( $multistore_plugin && is_object( $multistore_plugin ) && method_exists( $multistore_plugin, 'set_campaign' ) ) { 1940 $multistore_plugin->set_campaign( $promo_image, $promo_end, $promo_start, $promo_cta, $promo_link ); 1941 } 1942 1943 } 1944 } 1945 1946 /** 1947 * Get current site data from option by URL. 1948 * Used to validate token hasn't changed since cron was scheduled. 1949 * 1950 * @param string $site_url The site URL to look up. 1951 * @return array|false Site data array if found and connected, false otherwise. 1952 */ 1953 private function get_current_site_data($site_url) { 1954 if (empty($site_url)) { 1955 return false; 1956 } 1957 1958 $store_role = get_option('ssgsw_multi_connect_mode_on'); 1959 1960 if ('child' === $store_role) { 1961 $all_sites = get_option('ssgsw_multi_store_list_parent', []); 1962 } else { 1963 $all_sites = get_option('ssgsw_multi_store_list', []); 1964 } 1965 1966 if (!is_array($all_sites) || empty($all_sites)) { 1967 return false; 1968 } 1969 1970 $target_url = trailingslashit($site_url); 1971 1972 foreach ($all_sites as $site) { 1973 if (empty($site['url'])) { 1974 continue; 1975 } 1976 1977 if (trailingslashit($site['url']) === $target_url) { 1978 // Check if connected 1979 $is_connected = isset($site['isConnected']) ? $site['isConnected'] : false; 1980 if ($is_connected === true || $is_connected === 1 || $is_connected === '1') { 1981 return $site; 1982 } 1983 return false; // Found but disconnected 1984 } 1985 } 1986 1987 return false; // Not found 1988 } 1989 1990 /** 1991 * Clean up any stale cron jobs for a specific site URL. 1992 * Called when a site is found to be disconnected during cron execution. 1993 * 1994 * @param string $site_url The site URL to clean up cron jobs for. 1995 */ 1996 private function cleanup_site_cron_jobs($site_url) { 1997 if (empty($site_url)) { 1998 return; 1999 } 2000 2001 // Clear failed sync data 2002 $site_hash = md5($site_url); 2003 delete_option('multissaf_failed_sync_' . $site_hash); 2004 delete_option('multissaf_failed_sync_meta_' . $site_hash); 2005 2006 // Note: Cannot easily clear specific cron jobs here because they don't store site URL 2007 // This is why we need comprehensive cleanup on disconnect/delete 2008 } 2009 2010 } 2011 // Initialize. 2012 $multissaf_hook = new MULTISSAF_Hooks(); 16 defined('ABSPATH') || exit(1); 17 18 if (! class_exists('\MultiStoreSyncAddonforFlexStock\MULTISSAF_Hooks') ) { 19 20 /** 21 * Registers all multistore WP/WC hooks: menus, scripts, product sync, 22 * sheet writes, trash propagation, and token-filtered batch exports. 23 * 24 * @package MultiStoreSyncAddonforFlexStock 25 */ 26 final class MULTISSAF_Hooks 27 { 28 29 /** 30 * App class 31 * 32 * @var App 33 */ 34 public $app; 35 /** 36 * Is Ultimate active 37 * 38 * @var bool 39 */ 40 protected $is_ultimate_active; 41 42 /** 43 * Constructor 44 */ 45 public function __construct() 46 { 47 $this->app = new App(); 48 $this->add_actions(); 49 50 } 51 52 53 /** 54 * Registers all actions and filters. 55 * Called from __construct(). Sets $is_ultimate_active flag. 56 * 57 * @return void 58 */ 59 public function add_actions() 60 { 61 $this->is_ultimate_active = multissaf_is_plugin_ultimate_activated(); 62 register_activation_hook(MULTISSAF_MULTI_STORE, [ $this, 'activate' ]); 63 add_action('pre_current_active_plugins', [ $this, 'admin_notices' ]); 64 65 add_action('init', [ $this, 'init_appsero_client' ]); 66 add_action('init', [ $this, 'redirect_after_active'], 9999); 67 add_filter('plugin_action_links_' . plugin_basename(MULTISSAF_MULTI_STORE), [ $this, 'add_plugin_action_links' ]); 68 add_action('ssgsw_before_log_menu', [ $this, 'set_menu']); 69 add_filter('ssgsw_options', [$this, 'ssgsw_options'], 10, 1); 70 add_filter('ssgsw_localized_script', [$this, 'ssgsw_localized_script'], 10, 1); 71 add_action('admin_enqueue_scripts', [$this, 'enqueue_scripts'], 9); 72 add_filter('ssgsw_sub_site_product_count', [$this, 'ssgsw_sub_site_product_count'], 10, 1); 73 add_filter('ssgsw_columns', [ $this, 'product_columns' ], 10, 1); 74 add_filter('ssgsw_sync_mode', [$this, 'ssgsw_sync_mode'], 10, 1); 75 add_filter('ssgsw_domain_url', [$this, 'ssgsw_domain_url'], 10, 1); 76 add_action('ssgsw_collection_of_update_ids', [$this, 'ssgsw_collection_of_update_ids'], 10, 5); 77 add_action('ssgsw_multistore_product_update', [$this, 'ssgsw_multistore_product_update_func'], 10, 1); 78 79 add_action('ssgsw_multistore_sheet_update', [$this, 'ssgsw_multistore_sheet_update'], 10, 1); 80 81 add_action('ssgsw_multistore_sync_to_sheet', [ $this, 'ssgsw_multistore_sync_to_sheet'], 10, 1); 82 if (! $this->is_ultimate_active ) { 83 add_filter('ssgsw_format_columns_data', [$this, 'ssgsw_format_columns_data'], 10, 3); 84 } 85 add_filter('ssgsw_is_multistore_plugin_active', '__return_true'); 86 87 add_filter('ssgsw_multistore_token_filtered_batch', [ $this, 'ssgsw_token_filtered_batch' ], 10, 4); 88 add_action('trashed_post', [ $this, 'delete_child_tab_rows_on_trash' ], 20, 1); 89 90 // Direct sync handler (replaces cron scheduling) 91 add_action('ssgsw_multistore_product_update_direct', [ $this, 'ssgsw_multistore_product_update_direct' ], 10, 1); 92 } 93 94 /** 95 * Deletes the matching row from every child tab in the destination 96 * spreadsheet when a product is trashed (locally or propagated). 97 * Uses SKU as the primary cross-site identifier, falls back to ID. 98 * Saves/restores $ssgsw_request_params around Sheet calls. 99 * 100 * Connected to: WP action 'trashed_post' (priority 20) 101 * 102 * @param int $post_id The trashed WP post ID. 103 * @return void 104 */ 105 public function delete_child_tab_rows_on_trash( $post_id ) 106 { 107 108 if ('product' !== get_post_type($post_id) && 'product_variation' !== get_post_type($post_id) ) { 109 return; 110 } 111 112 if (! get_option('ssgsw_multi_store_active', false) ) { 113 return; 114 } 115 116 // Only run on the destination/parent site (the one that has child tabs). 117 $store_role = get_option('ssgsw_multi_connect_mode_on', ''); 118 if ('child' === $store_role ) { 119 return; // Source/child sites don't have child tabs. 120 } 121 122 $connected_children = get_option('ssgsw_multi_store_list', []); 123 if (empty($connected_children) || ! is_array($connected_children) ) { 124 return; 125 } 126 127 $sku = get_post_meta($post_id, '_sku', true); 128 129 // Snapshot the global request params. We will temporarily override them per 130 // child-tab iteration so Sheet methods use the DESTINATION's spreadsheet — 131 // not whatever foreign body is in the active HTTP request (e.g. an AppScript 132 // request or a remote REST call from the source site). 133 global $ssgsw_request_params; 134 $saved_request_params = $ssgsw_request_params; 135 136 foreach ( $connected_children as $child ) { 137 $tab_name = isset($child['tab_name']) ? $child['tab_name'] : ''; 138 $sheet_gid = isset($child['sheet_gid']) ? $child['sheet_gid'] : ''; 139 $spreadsheet_id = isset($child['spreadsheet_id']) ? $child['spreadsheet_id'] : ''; 140 141 // Fall back to the site's own spreadsheet ID if not stored per-child. 142 if (empty($spreadsheet_id) ) { 143 $spreadsheet_id = get_option(SSGSW_PREFIX . 'spreadsheet_id', ''); 144 } 145 146 if (empty($tab_name) || empty($spreadsheet_id) ) { 147 continue; 148 } 149 150 if (! class_exists('\StockSyncWithGoogleSheetForWooCommerce\Sheet') ) { 151 continue; 152 } 153 154 // Override request params to force Sheet methods onto THIS child tab's 155 // spreadsheet context (destination spreadsheet, child tab name/GID). 156 // access_token is omitted — Sheet::get_token() uses stored credentials. 157 $ssgsw_request_params = [ 158 'spreadsheet_id' => $spreadsheet_id, 159 'sheet_tab' => $tab_name, 160 'sheet_id' => $sheet_gid, 161 ]; 162 163 $sheet = new \StockSyncWithGoogleSheetForWooCommerce\Sheet(); 164 $sheet->setID($spreadsheet_id); 165 $sheet->setTab($tab_name); 166 if (! empty($sheet_gid) && method_exists($sheet, 'setSheetID') ) { 167 $sheet->setSheetID($sheet_gid); 168 } 169 170 // Fetch the tab's current data. 171 // Pass $tab_name explicitly — belt-and-suspenders against any remaining 172 // body contamination (see 1-child-duplicate.md for root-cause details). 173 $tab_data = $sheet->get_formula_value(false, false, $tab_name, 'UNFORMATTED_VALUE'); 174 if (empty($tab_data) ) { 175 continue; 176 } 177 178 // Child tab rows contain SOURCE product IDs, not destination IDs. 179 // SKU is the reliable cross-site identifier — try it first. 180 $row_number = false; 181 if (! empty($sku) && function_exists('ssgsw_get_index_number_by_sku') ) { 182 $row_number = ssgsw_get_index_number_by_sku($sku, $tab_data); 183 } 184 185 // Fallback: scan column 0 for matching product ID. 186 if (! $row_number && function_exists('ssgsw_format_index_number') ) { 187 $tab_index = ssgsw_format_index_number($tab_data); 188 $row_number = function_exists('ssgsw_get_index_number_option') 189 ? ssgsw_get_index_number_option($post_id, $tab_index) 190 : false; 191 } 192 193 if ($row_number ) { 194 // $sheet_gid passed explicitly so delete_single_row() uses it as 195 // priority-1 and does not fall back to body_info->sheet_id. 196 $sheet->delete_single_row($row_number, $sheet_gid); 197 } 198 } 199 200 // Restore request params so nothing downstream is affected. 201 $ssgsw_request_params = $saved_request_params; 202 } 203 204 /** 205 * Filter: intercepts the base plugin's batch export when a child token 206 * is present in the request. Returns permission-filtered product rows 207 * instead of the default unfiltered batch. 208 * 209 * Connected to: filter 'ssgsw_multistore_token_filtered_batch' 210 * 211 * @param false|array $default Default (no override). 212 * @param int $offset Batch offset. 213 * @param int $batch_size Batch size. 214 * @param int $index Sheet row index. 215 * @return false|array Filtered rows or false to fall back. 216 */ 217 public function ssgsw_token_filtered_batch( $default, $offset, $batch_size, $index ) 218 { 219 220 // Resolve bearer token from the current request. 221 $bearer = ''; 222 if (! empty($_SERVER['HTTP_SSGSWKEY']) ) { 223 $bearer = sanitize_text_field(wp_unslash($_SERVER['HTTP_SSGSWKEY'])); 224 } elseif (! empty($_SERVER['HTTP_AUTHORIZATION']) ) { 225 $bearer = sanitize_text_field(wp_unslash($_SERVER['HTTP_AUTHORIZATION'])); 226 } 227 $bearer = str_replace('Bearer ', '', $bearer); 228 229 if (empty($bearer) ) { 230 return false; 231 } 232 233 // Find the matching child token with its permissions. 234 $child_tokens = get_option('ssgsw_child_tokens', []); 235 if (! is_array($child_tokens) ) { 236 return false; 237 } 238 239 $permissions = null; 240 $limit = 0; 241 foreach ( $child_tokens as $token_data ) { 242 if (isset($token_data['token']) && $bearer === $token_data['token'] ) { 243 // Unwrap nested permissions structure. 244 $perms = isset($token_data['permissions']) ? (array) $token_data['permissions'] : []; 245 if (! empty($perms['permissions']) && is_array($perms['permissions']) ) { 246 $perms = $perms['permissions']; 247 } 248 if (! isset($perms['include_type']) ) { 249 $perms['include_type'] = 'all'; 250 } 251 $permissions = $perms; 252 $limit = function_exists('multissaf_get_token_max_products') ? multissaf_get_token_max_products($token_data) : 0; 253 break; 254 } 255 } 256 257 if ($permissions === null ) { 258 return false; 259 } 260 261 // Stay within the token's product count (0 = all); past it return an empty batch, 262 // not false (false would sync every product). 263 $fetch_size = $batch_size; 264 if (! empty($limit) ) { 265 if (absint($offset) >= $limit ) { 266 return []; 267 } 268 $fetch_size = absint($batch_size) > 0 ? min(absint($batch_size), $limit - absint($offset)) : $limit - absint($offset); 269 } 270 271 // Use the token-aware batch function to fetch only filtered products. 272 if (function_exists('multissaf_get_batch_products_by_token') ) { 273 $data = multissaf_get_batch_products_by_token(null, $offset, $fetch_size, $permissions); 274 if (is_array($data) ) { 275 if ($offset === 0 && ! empty($data) ) { 276 $first = reset($data); 277 // Header row contains strings (labels), not numeric IDs — detect by checking 278 if (is_array($first) && isset($first[0]) && ! is_numeric($first[0]) ) { 279 array_shift($data); 280 } 281 } 282 283 return $data; 284 } 285 } 286 287 return false; 288 } 289 290 /** 291 * Format image and formula data 292 * 293 * @param array $format_data data. 294 * 295 * @return array formatted data. 296 */ 297 public function ssgsw_format_columns_data($formatted_data, $sheets_info, $formula) 298 { 299 300 if ($this->app->is_plugin_activated()) { 301 if ($formula ) { 302 $sheet_header = isset($sheets_info[0]) ? $sheets_info[0] : []; 303 $check_id_exist = isset($sheets_info[0][0]) && 'ID' === $sheets_info[0][0] ? true : false; 304 $formatted_data = array_map(function ( $row ) use ( $sheets_info, $sheet_header, $check_id_exist ) { //phpcs:ignore 305 $desired_value = $row['ID']; 306 $found_array = null; 307 if (is_array($sheets_info) && ! empty($sheets_info) ) { 308 foreach ( $sheets_info as $key => $sub_array ) { 309 if (isset($sub_array[0]) && $sub_array[0] === $desired_value ) { 310 $found_array = $sub_array; 311 break; 312 } 313 } 314 } 315 316 if (is_array($found_array) ) { 317 $i = 0; 318 if ($check_id_exist ) { 319 $google_with_header = $this->set_sheet_header_with_data($sheet_header, $found_array); 320 $database_data = $this->ssgsw_compare_with_formula_value($desired_value); 321 $check_have_change = false; 322 if (! empty($database_data) ) { 323 $previous = []; 324 foreach ( $row as $key => $value ) { 325 $formation_key = $this->ssgsw_sheet_header_formating2($key); 326 if (isset($google_with_header[ $key ]) && strpos($google_with_header[ $key ], '=') === 0 ) { 327 if ('product_image' !== $key ) { 328 $row[ $key ] = isset($google_with_header[ $key ]) ? $google_with_header[ $key ] : ''; 329 $new_data_set = isset($database_data[ $formation_key ]) ? $database_data[ $formation_key ] : ''; 330 $previous[0][ $formation_key ] = $new_data_set; 331 if ($value !== $new_data_set ) { 332 $check_have_change = true; 333 } 334 } 335 } else { 336 $previous[0][ $formation_key ] = $row[ $key ]; 337 } 338 $i++; 339 } 340 if ($check_have_change ) { 341 $product_object = new \StockSyncWithGoogleSheetForWooCommerce\Product(); 342 $product_object->bulk_update($previous, false, 1, false); 343 } 344 } else { 345 foreach ( $row as $key => $value ) { 346 if (isset($google_with_header[ $key ]) && strpos($google_with_header[ $key ], '=') === 0 ) { 347 if ('product_image' !== $key ) { 348 $row[ $key ] = $google_with_header[ $key ]; 349 } 350 } 351 $i++; 352 } 353 } 354 } else { 355 foreach ( $row as $key => $value ) { 356 if (isset($found_array[ $i ]) && strpos($found_array[ $i ], '=') === 0 ) { 357 if ('product_image' !== $key ) { 358 $row[ $key ] = $found_array[ $i ]; 359 } 360 } 361 $i++; 362 } 363 } 364 } 365 if (is_array($row) && array_key_exists('post_author', $row)) { 366 $row['post_author'] = $this->generate_shop_name($row['post_author']); 367 } 368 if (is_array($row) && array_key_exists('ssgsw_product_url', $row)) { 369 $row['ssgsw_product_url'] = $this->generate_product_url($row); 370 } 371 if (array_key_exists('product_image', $row) && ! empty($row['product_image']) ) { 372 $image_url_convart = $row['product_image']; 373 if ($image_url_convart) { 374 $base_url = wp_upload_dir()['baseurl']; 375 $absolute_url = trailingslashit($base_url) . ltrim($image_url_convart, '/'); 376 $row['product_image'] = '=IMAGE("' . $absolute_url . '")'; 377 } else { 378 $row['product_image'] = ''; 379 } 380 return array_values((array) $row); 381 } else { 382 return array_values((array) $row); 383 } 384 }, $formatted_data 385 ); 386 387 } else { 388 $formatted_data = array_map(function ( $row ) { //phpcs:ignore 389 if (is_array($row) && array_key_exists('ssgsw_product_url', $row)) { 390 $row['ssgsw_product_url'] = $this->generate_product_url($row); 391 } 392 if (is_array($row) && array_key_exists('post_author', $row)) { 393 $row['post_author'] = $this->generate_shop_name($row['post_author']); 394 } 395 if (array_key_exists('product_image', $row) && ! empty($row['product_image']) ) { 396 $image_url_convart = $row['product_image']; 397 if ($image_url_convart) { 398 $base_url = wp_upload_dir()['baseurl']; 399 $absolute_url = trailingslashit($base_url) . ltrim($image_url_convart, '/'); 400 $row['product_image'] = '=IMAGE("' . $absolute_url . '")'; 401 } else { 402 $row['product_image'] = ''; 403 } 404 return array_values((array) $row); 405 } else { 406 return array_values((array) $row); 407 } 408 }, $formatted_data 409 ); 410 411 } 412 } 413 return $formatted_data; 414 } 415 /** 416 * Generate Product URL 417 */ 418 public function generate_product_url($row) 419 { 420 $p_id = $row['ID']; 421 return get_permalink($p_id); 422 } 423 /** 424 * Generate Shop Name 425 */ 426 public function generate_shop_name($author_id) 427 { 428 return get_the_author_meta('display_name', $author_id); 429 } 430 /** 431 * SSGSW Sheet Header formating 432 * 433 * @param string $key Key of the data. 434 * 435 * @return string 436 */ 437 public function ssgsw_sheet_header_formating2( $key ) 438 { 439 $header_info = [ 440 'ID' => 'ID', 441 'product_type' => 'Type', 442 'post_title' => 'name', 443 '_stock' => 'stock', 444 '_regular_price' => 'regular_price', 445 '_sale_price' => 'sale_price', 446 'product_image' => 'Image', 447 '_sku' => 'sku', 448 'total_sales' => 'sales', 449 '_product_attributes' => 'attributes', 450 'product_cat' => 'category', 451 'post_excerpt' => 'post_excerpt', 452 ]; 453 if (array_key_exists($key, $header_info) ) { 454 return $header_info[ $key ]; 455 } else { 456 return $key; 457 } 458 } 459 /** 460 * Get products information with compare value 461 * 462 * @param int $product_id ID of product. 463 * 464 * @return mixed 465 */ 466 public function ssgsw_compare_with_formula_value( $product_id ) 467 { 468 $product_info = $this->ssgsw_get_product_information_by_id($product_id); 469 if (is_array($product_info) && ! empty($product_info) ) { 470 $product_information = isset($product_info[0]['product_information']) ? unserialize($product_info[0]['product_information']) : []; 471 if (! empty($product_information) ) { 472 return $product_information; 473 } 474 } 475 return []; 476 } 477 /** 478 * Get SSGSW product information 479 * 480 * @param int $product_id ID of product. 481 * 482 * @return mixed 483 */ 484 public function ssgsw_get_product_information_by_id( $product_id ) 485 { 486 global $wpdb; 487 $product_id = sanitize_text_field($product_id); 488 $results = $wpdb->get_results( 489 $wpdb->prepare( 490 "SELECT * FROM {$wpdb->prefix}ssgsw_products WHERE product_id = %s", 491 $product_id 492 ), 493 ARRAY_A 494 ); 495 return $results; 496 } 497 /** 498 * Public function Sheet header with data. 499 * 500 * @param array $header_info header information. 501 * @param array $data data information. 502 * 503 * @return array 504 */ 505 public function set_sheet_header_with_data( $header_info = [], $data = [] ) 506 { 507 $header_marge = []; 508 if (is_array($header_info) && ! empty($header_info) ) { 509 foreach ( $header_info as $key => $value ) { 510 $header = $this->ssgsw_sheet_header_formating($value); 511 $header_marge[ $header ] = isset($data[ $key ]) ? $data[ $key ] : ''; 512 } 513 } 514 return $header_marge; 515 } 516 517 /** 518 * SSGSW Sheet Header formating 519 * 520 * @param string $key Key of the data. 521 * 522 * @return string 523 */ 524 public function ssgsw_sheet_header_formating( $key ) 525 { 526 $header_info = [ 527 'ID' => 'ID', 528 'Type' => 'product_type', 529 'Name' => 'post_title', 530 'Stock' => '_stock', 531 'Regular price' => '_regular_price', 532 'Sale price' => '_sale_price', 533 'Image' => 'product_image', 534 'SKU' => '_sku', 535 'No of Sales' => 'total_sales', 536 'Attributes' => '_product_attributes', 537 'Categories' => 'product_cat', 538 'Short description' => 'post_excerpt', 539 ]; 540 if (array_key_exists($key, $header_info) ) { 541 return $header_info[ $key ]; 542 } else { 543 return $key; 544 } 545 } 546 /** 547 * Syncs product data from a single store to multiple stores in batches. 548 * 549 * @param array $args An associative array containing: 550 */ 551 public function ssgsw_multistore_sync_to_sheet($args) 552 { 553 $batch_size = 60; 554 $delay_seconds = 2; 555 $orgingal_ids = isset($args['product_ids']) ? $args['product_ids'] : []; 556 $site_url = isset($args['site_url']) ? $args['site_url'] : (isset($args['refarance_shop']) ? $args['refarance_shop'] : ''); 557 $token = isset($args['token']) ? $args['token'] : ''; 558 // Check if there are product IDs to process. 559 if (is_array($orgingal_ids) && !empty($orgingal_ids)) { 560 if (class_exists('\StockSyncWithGoogleSheetForWooCommerce\Sheet') && class_exists('\StockSyncWithGoogleSheetForWooCommerce\Column')) { 561 $new_sheets = new \StockSyncWithGoogleSheetForWooCommerce\Sheet(); 562 563 $spreadsheet_id = function_exists('ssgsw_get_option') ? ssgsw_get_option('spreadsheet_id') : ''; 564 $sheet_tab = function_exists('ssgsw_get_option') ? ssgsw_get_option('sheet_tab') : ''; 565 $cache_key = apply_filters( 566 'multissaf_sheets_info_cache_key', 567 'multissaf_sheets_info_' . md5($spreadsheet_id . '_' . $sheet_tab) 568 ); 569 570 $sheets_info = get_transient($cache_key); 571 if (false === $sheets_info || empty($sheets_info) ) { 572 $sheets_info = $new_sheets->get_formula_value(); 573 if (! empty($sheets_info) ) { 574 set_transient( 575 $cache_key, 576 $sheets_info, 577 apply_filters('multissaf_sheets_info_ttl', 5 * MINUTE_IN_SECONDS) 578 ); 579 } 580 } 581 582 if (empty($sheets_info)) { 583 $sheets_info = $new_sheets->get_formula_value(); 584 } 585 586 $columns = new \StockSyncWithGoogleSheetForWooCommerce\Column(); 587 $columns_info = $columns->get_columns_no_edit_remove(); 588 $sheet_header = isset($sheets_info[0]) ? $sheets_info[0] : []; 589 $key_with_headers = multissaf_format_headers_with_sheet_header($sheet_header, $columns_info); 590 $keys = isset($key_with_headers[0]) ? $key_with_headers[0] : []; 591 $domain_url = apply_filters('ssgsw_domain_url', false); 592 593 // Get the current batch of product IDs. 594 $current_batch = array_slice($orgingal_ids, 0, $batch_size); 595 596 if (!empty($current_batch)) { 597 $get_batch = multissaf_batch_api_request( 598 0, 0, 0, $site_url, $token, $keys, $domain_url, $current_batch, $sheets_info 599 ); 600 601 // Process and update the Google Sheet with the fetched data. 602 $sheets_index = []; 603 if (function_exists('ssgsw_format_index_number')) { 604 $sheets_index = ssgsw_format_index_number($sheets_info); 605 } 606 607 if (is_array($get_batch) && !empty($get_batch)) { 608 609 $sync_batch_updates = []; 610 $sync_batch_appends = []; 611 612 foreach ($get_batch as $values) { 613 $values = (array) $values; 614 $product_id = isset($values[0]) ? $values[0] : false; 615 if ($product_id) { 616 $index_number_exists = false; 617 if (function_exists('multissaf_get_index_number_option_param')) { 618 $index_number_exists = multissaf_get_index_number_option_param($product_id, $sheets_index, $site_url); 619 } 620 621 if ($index_number_exists) { 622 $col_end = $new_sheets->getColumnLetter(count($values)); 623 $range = $sheet_tab . '!A' . $index_number_exists . ':' . $col_end . $index_number_exists; 624 $sync_batch_updates[] = [ 625 'range' => $range, 626 'values' => [ array_values($values) ], 627 ]; 628 } else { 629 $sync_batch_appends[] = array_values($values); 630 } 631 } 632 } 633 634 // Send all updates in ONE API call 635 if (! empty($sync_batch_updates) ) { 636 $new_sheets->batch_update_rows($sync_batch_updates); 637 // Bust sheet cache after write 638 delete_transient($cache_key); 639 } 640 641 // Send all appends in ONE API call 642 if (! empty($sync_batch_appends) ) { 643 $new_sheets->append_batch_rows($sync_batch_appends); 644 // Bust sheet cache after write 645 delete_transient($cache_key); 646 } 647 } 648 } 649 650 // Slice the remaining products for the next batch. 651 $remaining_product_ids = array_slice($orgingal_ids, $batch_size); 652 653 // If there are more products to process, schedule a new event. 654 if (!empty($remaining_product_ids)) { 655 $next_args = $args; 656 $next_args['orgingal_ids'] = $remaining_product_ids; 657 658 // Clear any previous schedule for this task to prevent duplicates. 659 wp_clear_scheduled_hook('ssgsw_multistore_sync_to_sheet', [$next_args]); 660 661 // Schedule the next batch after the specified delay. 662 wp_schedule_single_event(time() + $delay_seconds, 'ssgsw_multistore_sync_to_sheet', [$next_args]); 663 } 664 } 665 } 666 } 667 668 /** 669 * Writes a batch of product IDs to the local main sheet via 670 * StockSyncWithGoogleSheetForWooCommerce\Sheet. Skips trashed products. 671 * Schedules continuation for batches > 60 products. 672 * 673 * Connected to: action 'ssgsw_multistore_sheet_update' 674 * 675 * @param array $args { 676 * @type int[] $product_ids IDs to write to sheet. 677 * } 678 * @return void 679 */ 680 public function ssgsw_multistore_sheet_update($args) 681 { 682 if (!is_array($args) || empty($args)) { 683 return; 684 } 685 686 $product_ids = isset($args['product_ids']) ? (array) $args['product_ids'] : []; 687 688 // Remove duplicate IDs from the incoming batch. 689 $product_ids = array_unique(array_map('intval', $product_ids)); 690 691 // Sort the product IDs to ensure they are processed in a consistent order. 692 sort($product_ids); 693 $batch_size = 60; 694 $delay_seconds = 2; 695 696 // Get the product IDs for the current batch to process 697 $current_batch_ids = array_slice($product_ids, 0, $batch_size); 698 699 // If the current batch is empty, there's nothing to do 700 if (empty($current_batch_ids)) { 701 return; 702 } 703 704 // Write lock: prevents concurrent sheet writes that cause duplicates. 705 $write_lock_key = apply_filters( 706 'multissaf_sheet_write_lock_key', 707 'multissaf_sheet_write_lock' 708 ); 709 710 // Check if another write is in progress 711 if (get_transient($write_lock_key) ) { 712 // Another batch is currently writing to the sheet 713 // Reschedule this batch for 3 seconds later to avoid race condition 714 715 wp_schedule_single_event( 716 time() + 3, 717 'ssgsw_multistore_sheet_update', 718 [ $args ] 719 ); 720 return; 721 } 722 723 // Acquire lock (30 second TTL as safety in case of fatal error) 724 set_transient($write_lock_key, time(), 30); 725 726 727 // Initialize required classes and data 728 if (class_exists('\StockSyncWithGoogleSheetForWooCommerce\Sheet') && class_exists('\StockSyncWithGoogleSheetForWooCommerce\Column') && class_exists('\StockSyncWithGoogleSheetForWooCommerce\Product')) { 729 730 $new_sheet = new \StockSyncWithGoogleSheetForWooCommerce\Sheet(); 731 // Cache Sheet Formula Value 732 733 $spreadsheet_id = function_exists('ssgsw_get_option') ? ssgsw_get_option('spreadsheet_id') : ''; 734 $sheet_tab = function_exists('ssgsw_get_option') ? ssgsw_get_option('sheet_tab') : ''; 735 $cache_key = apply_filters( 736 'multissaf_sheets_info_cache_key', 737 'multissaf_sheets_info_' . md5($spreadsheet_id . '_' . $sheet_tab) 738 ); 739 740 $sheets_info = get_transient($cache_key); 741 if (false === $sheets_info || empty($sheets_info) ) { 742 $sheets_info = $new_sheet->get_formula_value(); 743 if (! empty($sheets_info) ) { 744 set_transient( 745 $cache_key, 746 $sheets_info, 747 apply_filters('multissaf_sheets_info_ttl', 5 * MINUTE_IN_SECONDS) 748 ); 749 } 750 } 751 752 $columns = new \StockSyncWithGoogleSheetForWooCommerce\Column(); 753 $keys_values = []; 754 if (function_exists('ssgsw_prepare_sheet_keys')) { 755 $keys_values = ssgsw_prepare_sheet_keys($sheets_info, $columns); 756 } 757 758 $products = new \StockSyncWithGoogleSheetForWooCommerce\Product(); 759 $formatted_data = $products->get_formatted_data($current_batch_ids, $sheets_info, false, 0, 0, ['product', 'product_variation'], false, $keys_values); 760 $sheets_index = []; 761 if (function_exists('ssgsw_format_index_number')) { 762 $sheets_index = ssgsw_format_index_number($sheets_info); 763 } 764 765 // Collect all row updates then batchUpdate 766 767 $batch_updates = []; 768 $batch_appends = []; 769 $newly_marked_ids = []; // IDs about to be appended. 770 771 foreach ($current_batch_ids as $key => $product_id) { 772 // Skip trashed or deleted products — their rows are already gone from the 773 // sheet and appending them would create infinite duplicate rows. 774 $post_status = get_post_status($product_id); 775 if (! $post_status || 'trash' === $post_status ) { 776 continue; 777 } 778 779 $formatted_key = array_search($product_id, $current_batch_ids); 780 $values = isset($formatted_data[$formatted_key]) ? $formatted_data[$formatted_key] : []; 781 if ($values) { 782 // Check this site's own row marker first; it takes priority. 783 // over live-sheet lookup. If we've already successfully appended a row for 784 // this product before, we NEVER append again regardless of what the 785 // (possibly stale or mismatched) live index says. 786 $already_has_row = get_post_meta($product_id, '_ssgsw_main_sheet_row_written', true); 787 788 if (function_exists('ssgsw_get_index_number_option')) { 789 $index_number_exits = ssgsw_get_index_number_option($product_id, $sheets_index); 790 } else { 791 $index_number_exits = false; 792 } 793 794 if ($index_number_exits) { 795 $col_end = $new_sheet->getColumnLetter(count($values)); 796 $range = $sheet_tab . '!A' . $index_number_exits . ':' . $col_end . $index_number_exits; 797 $batch_updates[] = [ 798 'range' => $range, 799 'values' => [ array_values($values) ], 800 ]; 801 // Backfill the marker if it was missing. 802 if (! $already_has_row ) { 803 update_post_meta($product_id, '_ssgsw_main_sheet_row_written', 1); 804 } 805 } elseif ($already_has_row ) { 806 // The marker says this row was already written: skip instead of appending a duplicate. 807 continue; 808 } else { 809 $batch_appends[] = array_values($values); 810 $newly_marked_ids[] = $product_id; // mark only after successful write 811 } 812 } 813 } 814 815 // Send all updates in ONE API call 816 if (! empty($batch_updates) ) { 817 $result = $new_sheet->batch_update_rows($batch_updates); 818 if ($result ) { 819 delete_transient($cache_key); 820 } 821 } 822 // Send all appends in ONE API call 823 if (! empty($batch_appends) ) { 824 $append_result = $new_sheet->append_batch_rows($batch_appends); 825 delete_transient($cache_key); 826 827 // Mark as written only if the append went through. 828 if ($append_result ) { 829 foreach ( $newly_marked_ids as $marked_id ) { 830 update_post_meta($marked_id, '_ssgsw_main_sheet_row_written', 1); 831 } 832 } 833 } 834 835 } 836 837 // Release the lock so the next batch can run. 838 delete_transient($write_lock_key); 839 840 841 // Now, schedule the next batch 842 $remaining_product_ids = array_slice($product_ids, $batch_size); 843 844 // If there are more products to process, schedule a new event 845 if (!empty($remaining_product_ids)) { 846 $next_args = $args; 847 $next_args['product_ids'] = $remaining_product_ids; 848 wp_clear_scheduled_hook('ssgsw_multistore_sheet_update', [$next_args]); 849 850 wp_schedule_single_event(time() + $delay_seconds, 'ssgsw_multistore_sheet_update', [$next_args]); 851 } 852 } 853 854 /** 855 * Direct sync dispatcher. If cron sync is disabled (feature flag 856 * 'enable_cron_sync' = false), calls ssgsw_multistore_product_update_func() 857 * inline. Otherwise fires the 'ssgsw_multistore_product_update' cron action. 858 * 859 * Connected to: action 'ssgsw_multistore_product_update_direct' 860 * 861 * @param array $args Same shape as ssgsw_multistore_product_update_func(). 862 * @return void 863 */ 864 public function ssgsw_multistore_product_update_direct( $args ) 865 { 866 $product_ids = isset($args['product_ids']) ? $args['product_ids'] : []; 867 $retry_count = isset($args['retry_count']) ? intval($args['retry_count']) : 0; 868 $sheet_sync = isset($args['sheet_sync']) ? $args['sheet_sync'] : false; 869 $refrance_shop = isset($args['refrance_shop']) ? $args['refrance_shop'] : false; 870 $site_offset = isset($args['site_offset']) ? intval($args['site_offset']) : 0; 871 $original_ids = isset($args['original_ids']) ? $args['original_ids'] : []; 872 873 // Check if feature is enabled 874 if (! multissaf_get_feature_flag('enable_cron_sync') ) { 875 // Direct sync is enabled (cron is disabled) 876 877 // Max retries 878 $max_retries = multissaf_get_feature_flag('max_retry_count') ? (int) multissaf_get_feature_flag('max_retry_count') : 3; 879 880 if ($retry_count >= $max_retries ) { 881 // Max retries reached, log and stop 882 return; 883 } 884 885 // Call the existing multistore product update function 886 $result = $this->ssgsw_multistore_product_update_func($args); 887 888 } else { 889 // Cron sync is enabled, use old behavior 890 do_action('ssgsw_multistore_product_update', $args); 891 } 892 } 893 894 /** 895 * Handles the main cross-site product propagation loop. 896 * For each connected site, packages product data and POSTs to 897 * /wp-json/ssgsw/v1/update_by_ids. Schedules child-tab writes 898 * and sheet updates via WP-Cron on success. 899 * 900 * Connected to: action 'ssgsw_multistore_product_update' 901 * 902 * @param array $args { 903 * @type int[] $product_ids IDs to propagate. 904 * @type bool $sheet_sync Whether this is a sheet-sync-only pass. 905 * @type string $refrance_shop URL of the originating site (skip loop). 906 * @type int $site_offset Pagination offset within connected sites. 907 * @type int[] $original_ids Source IDs for child-tab SKU resolution. 908 * } 909 * @return void 910 */ 911 public function ssgsw_multistore_product_update_func($args) 912 { 913 914 $product_ids = isset($args['product_ids']) ? $args['product_ids'] : []; 915 $sheet_sync = isset($args['sheet_sync']) ? $args['sheet_sync'] : false; 916 $refrance_shop = isset($args['refrance_shop']) ? $args['refrance_shop'] : false; 917 $site_offset = isset($args['site_offset']) ? intval($args['site_offset']) : 0; 918 $original_ids = isset($args['original_ids']) ? $args['original_ids'] : []; 919 920 // LOG: Entry point 921 922 // Drain pending ID queue into current batch 923 924 $pending_key = apply_filters('multissaf_pending_ids_key', 'multissaf_pending_product_ids'); 925 $queued_ids = get_transient($pending_key); 926 if (! empty($queued_ids) && is_array($queued_ids) ) { 927 $product_ids = array_unique(array_merge((array) $product_ids, $queued_ids)); 928 delete_transient($pending_key); 929 } 930 delete_transient(apply_filters('multissaf_sync_lock_key', 'multissaf_sync_scheduled_lock')); 931 932 // Clear recovery queue on successful retry 933 $is_retry = ! empty($args['is_retry']) ? true : false; 934 $retry_site = ! empty($args['retry_site']) ? $args['retry_site'] : ''; 935 936 if ($is_retry && $retry_site ) { 937 $failed_key = apply_filters( 938 'multissaf_failed_sync_key', 939 'multissaf_failed_sync_' . md5($retry_site) 940 ); 941 } 942 943 $site_batch_limit = 10; 944 945 $store_role = get_option('ssgsw_multi_connect_mode_on'); 946 $sync_mode = get_option('ssgsw_multi_store_mode_on'); 947 948 if (! is_array($product_ids) || empty($product_ids) ) { return; 949 } 950 951 $batch_size = 55; 952 $delay_seconds = 5; 953 $current_batch = array_slice($product_ids, 0, $batch_size); 954 955 if (empty($current_batch)) { return; 956 } 957 958 $all_sites = []; 959 if ('child' === $store_role) { 960 $all_sites = multissaf_get_connected_sites('ssgsw_multi_store_list_parent'); 961 } elseif ('main' === $store_role && 'multi_to_single' === $sync_mode) { 962 $all_sites = multissaf_get_connected_sites('ssgsw_multi_store_list'); 963 } 964 965 $current_sites_batch = array_slice($all_sites, $site_offset, $site_batch_limit); 966 $refrance_token = get_option('ssgsw_token'); 967 $refarance_shop_url = home_url(); 968 969 // LOG: Sites found 970 971 if (! empty($current_sites_batch) ) { 972 973 foreach ($current_sites_batch as $sites) { 974 $site_url = isset($sites['url']) ? $sites['url'] : ''; 975 $token = isset($sites['token']) ? $sites['token'] : ''; 976 977 // Default to TRUE (connected) if isConnected field is missing 978 $is_connected = isset($sites['isConnected']) ? $sites['isConnected'] : true; 979 980 // Only skip if EXPLICITLY disconnected (0 or false) 981 if (is_bool($is_connected) && $is_connected === false ) { 982 // Site is explicitly disconnected - skip and clean up 983 $this->cleanup_site_cron_jobs($site_url); 984 continue; 985 } 986 if (is_numeric($is_connected) && intval($is_connected) === 0 ) { 987 // Site is explicitly disconnected (0) - skip and clean up 988 $this->cleanup_site_cron_jobs($site_url); 989 continue; 990 } 991 992 if (! $site_url || ! $token) { 993 continue; 994 } 995 996 // LOG: Sending attempt 997 998 // Validate token still matches current connection 999 // Get fresh site data from option to ensure token hasn't changed 1000 $current_site_data = $this->get_current_site_data($site_url); 1001 if (! $current_site_data || $current_site_data['token'] !== $token ) { 1002 // Token mismatch - this cron job is from an old connection 1003 // Skip processing and clean up this stale cron job 1004 continue; 1005 } 1006 1007 $is_ref_shop = ( $refrance_shop && trailingslashit($site_url) === trailingslashit($refrance_shop) ); 1008 1009 $permissions = isset($sites['permissions']) ? (array) $sites['permissions'] : []; 1010 1011 // Extra permission mapping for child role 1012 $token_limit = 0; 1013 if ('child' === $store_role) { 1014 $parents1 = get_option('ssgsw_child_tokens', []); 1015 if (! empty($parents1) ) { 1016 foreach ($parents1 as $p1) { 1017 $p1_main = isset($p1['main_store']) ? $p1['main_store'] : ''; 1018 if (trailingslashit($p1_main) === trailingslashit($site_url) 1019 || ( isset($p1['url']) && trailingslashit($p1['url']) === trailingslashit($site_url) ) 1020 ) { 1021 $permissions = isset($p1['permissions']) ? $p1['permissions'] : $permissions; 1022 $token_limit = function_exists('multissaf_get_token_max_products') ? multissaf_get_token_max_products($p1) : 0; 1023 break; 1024 } 1025 } 1026 } 1027 } 1028 1029 $target_ids = []; 1030 1031 if (! $is_ref_shop ) { 1032 // On the source store, push only the products inside the token's scope 1033 // (filtered products / specific SKUs); other changes are not sent. 1034 $batch_ids = $current_batch; 1035 $apply_scope = false; 1036 if ('child' === $store_role ) { 1037 if (isset($permissions['permissions']) && is_array($permissions['permissions']) ) { 1038 $permissions = $permissions['permissions']; 1039 } 1040 $apply_scope = isset($permissions['include_type']) && 'all' !== $permissions['include_type']; 1041 if ($apply_scope && function_exists('multissaf_filter_ids_by_token_scope') ) { 1042 $batch_ids = multissaf_filter_ids_by_token_scope($current_batch, $permissions); 1043 } 1044 // Send only products within the token's product count. 1045 if ($token_limit && function_exists('multissaf_get_token_limited_ids') ) { 1046 $batch_ids = array_values(array_intersect(array_map('intval', (array) $batch_ids), multissaf_get_token_limited_ids($permissions, $token_limit))); 1047 } 1048 } 1049 $get_row_data = empty($batch_ids) ? [] : multissaf_get_product_by_ids($batch_ids, $permissions, $apply_scope); 1050 1051 // If no products returned, all IDs in this batch are trashed/deleted. 1052 // Do not send to remote and do not schedule a retry — there is nothing to sync. 1053 if (empty($get_row_data) ) { 1054 // Clean up any stale failed-sync option for this site so the retry 1055 // loop that caused infinite duplicates cannot continue. 1056 $stale_key = apply_filters('multissaf_failed_sync_key', 'multissaf_failed_sync_' . md5($site_url)); 1057 delete_option($stale_key); 1058 delete_option('multissaf_failed_sync_meta_' . md5($site_url)); 1059 continue; 1060 } 1061 1062 $skus = []; 1063 foreach ($get_row_data as $p) { 1064 if (! empty($p['sku'])) { $skus[] = $p['sku']; 1065 } 1066 } 1067 1068 $body = [ 1069 'ssgsw_products' => $get_row_data, 1070 'skus' => $skus, 1071 'refarance_shop' => $refarance_shop_url, 1072 'refrance_token' => $refrance_token, 1073 'orgingal_ids' => $product_ids, 1074 'sync_mode' => $sync_mode, 1075 'sheet_sync' => $sheet_sync, 1076 'manual_update' => true, 1077 ]; 1078 1079 $api_url = trailingslashit($site_url) . 'wp-json/ssgsw/v1/update_by_ids'; 1080 $response = multissaf_single_product_update($api_url, $body, $token); 1081 1082 if ($response && isset($response->data->target_ids) && ! empty($response->data->target_ids)) { 1083 $target_ids = $response->data->target_ids; 1084 1085 // LOG: Success 1086 1087 if ($is_retry && $retry_site && trailingslashit($site_url) === trailingslashit($retry_site) ) { 1088 delete_option($failed_key); 1089 delete_option('multissaf_failed_sync_meta_' . md5($retry_site)); 1090 } 1091 1092 } else { 1093 1094 // LOG: Failed 1095 1096 $failed_key_site = apply_filters( 1097 'multissaf_failed_sync_key', 1098 'multissaf_failed_sync_' . md5($site_url) 1099 ); 1100 $failed_ids = get_option($failed_key_site, []); 1101 1102 // Only retry IDs that still exist as published products. 1103 // Trashed/deleted products must never enter the retry queue — 1104 // they cause infinite append loops because the sheet row is gone. 1105 $retryable_ids = array_filter( 1106 (array) $current_batch, function ( $id ) { 1107 $status = get_post_status($id); 1108 return $status && 'trash' !== $status; 1109 } 1110 ); 1111 1112 if (empty($retryable_ids) ) { 1113 // Nothing to retry — clean up any stale data and move on. 1114 delete_option($failed_key_site); 1115 delete_option('multissaf_failed_sync_meta_' . md5($site_url)); 1116 } else { 1117 $failed_ids = array_unique(array_merge((array) $failed_ids, array_values($retryable_ids))); 1118 update_option($failed_key_site, $failed_ids, false); 1119 1120 update_option( 1121 'multissaf_failed_sync_meta_' . md5($site_url), [ 1122 'site_url' => $site_url, 1123 'failed_ids' => $failed_ids, 1124 'failed_at' => current_time('mysql'), 1125 'retry_count' => 0, 1126 ], false 1127 ); 1128 1129 $retry_args = [[ 1130 'product_ids' => $failed_ids, 1131 'sheet_sync' => $sheet_sync, 1132 'refrance_shop' => $refrance_shop, 1133 'original_ids' => $original_ids, 1134 'is_retry' => true, 1135 'retry_site' => $site_url, 1136 ]]; 1137 $retry_delay = apply_filters('multissaf_retry_delay_seconds', 120); 1138 wp_clear_scheduled_hook('ssgsw_multistore_product_update', $retry_args); 1139 wp_schedule_single_event( 1140 time() + $retry_delay, 1141 'ssgsw_multistore_product_update', 1142 $retry_args 1143 ); 1144 } 1145 } 1146 } else { 1147 $target_ids = ! empty($original_ids) ? $original_ids : $current_batch; 1148 } 1149 1150 $should_write_child_tabs = apply_filters( 1151 'multissaf_should_write_child_tabs', 1152 ( 'main' === $store_role && ! $sheet_sync && ! empty($target_ids) ), 1153 $store_role, 1154 $sheet_sync, 1155 $target_ids 1156 ); 1157 1158 if ($should_write_child_tabs ) { 1159 // Extract SKUs from CURRENT_BATCH (Destination IDs), not target_ids (Source IDs) 1160 $target_skus = []; 1161 foreach ( $current_batch as $dest_product_id ) { 1162 $sku = get_post_meta($dest_product_id, '_sku', true); 1163 if (! empty($sku) ) { 1164 $target_skus[] = $sku; 1165 } 1166 } 1167 1168 if (empty($target_skus) ) { 1169 //No SKUs found for Destination products, skipping Child Tab update 1170 continue; 1171 } 1172 1173 $retrieve_url = trailingslashit($site_url) . 'wp-json/ssgsw/v1/retrive_child'; 1174 $retrieve_body = [ 1175 'batch_info' => ['offset' => 0, 'batchSize' => 50], 1176 'product_skus' => $target_skus, // Use SKUs instead of IDs 1177 'access_token' => $token, 1178 'import_request' => false, 1179 ]; 1180 1181 //Calling retrive_child API with product_skus 1182 $retrieve_response = multissaf_single_product_update($retrieve_url, $retrieve_body, $token); 1183 $parent_sheet_data = []; 1184 1185 if ($retrieve_response && isset($retrieve_response->data)) { 1186 $parent_sheet_data = $retrieve_response->data; // Retrieved parent_sheet_data count 1187 } 1188 1189 if (! empty($parent_sheet_data) ) { 1190 $sheet_id = isset($sites['spreadsheet_id']) ? $sites['spreadsheet_id'] 1191 : (function_exists('ssgsw_get_option') ? ssgsw_get_option('spreadsheet_id') : ''); 1192 $tab_name = isset($sites['tab_name']) ? $sites['tab_name'] : ''; 1193 $sheet_gid = isset($sites['sheet_gid']) ? $sites['sheet_gid'] : ''; 1194 1195 if (! empty($sheet_id) && ! empty($tab_name) && class_exists('\StockSyncWithGoogleSheetForWooCommerce\Sheet') ) { 1196 $sheet = new \StockSyncWithGoogleSheetForWooCommerce\Sheet(); 1197 $sheet->setID($sheet_id); 1198 $sheet->setTab($tab_name); 1199 if (! empty($sheet_gid) && method_exists($sheet, 'setSheetID') ) { $sheet->setSheetID($sheet_gid); 1200 } 1201 1202 // Always fetch fresh data for child tab updates to prevent duplicates 1203 $sheets_info = $sheet->get_formula_value(false, false, $tab_name, 'UNFORMATTED_VALUE'); 1204 //Ftched Child Tab data, row count 1205 1206 if (! empty($sheets_info) ) { 1207 // Update cache AFTER fetching (for read-only operations) 1208 $child_cache_key = apply_filters( 1209 'multissaf_sheets_info_cache_key', 1210 'multissaf_sheets_info_' . md5($sheet_id . '_' . $tab_name) 1211 ); 1212 set_transient( 1213 $child_cache_key, 1214 $sheets_info, 1215 apply_filters('multissaf_sheets_info_ttl', 5 * MINUTE_IN_SECONDS) 1216 ); 1217 } 1218 1219 if ($sheets_info) { 1220 // Build ID index from Child Tab - maps Product ID to Row Number 1221 $id_index = []; 1222 foreach ( $sheets_info as $row_num => $row_data ) { 1223 if ($row_num == 0 ) { continue; // Skip header 1224 } 1225 if (isset($row_data[0]) && is_numeric($row_data[0]) ) { 1226 $product_id = intval($row_data[0]); 1227 $id_index[ $product_id ] = $row_num + 1; // 1-based row index 1228 } 1229 } 1230 1231 $child_batch_updates = []; 1232 $child_batch_appends = []; 1233 1234 foreach ($parent_sheet_data as $data_idx => $values) { 1235 $values = (array) $values; 1236 1237 if (isset($values[0]) && ! is_numeric($values[0])) { 1238 // This is the header row — always write it to row 1 so the 1239 // child tab header stays in sync with the current field permissions. 1240 if ($data_idx === 0 ) { 1241 $col_end = $sheet->getColumnLetter(count($values)); 1242 $range = $tab_name . '!A1:' . $col_end . '1'; 1243 $child_batch_updates[] = [ 1244 'range' => $range, 1245 'values' => [ array_values($values) ], 1246 ]; 1247 } else { 1248 } 1249 continue; 1250 } 1251 $product_id = isset($values[0]) ? intval($values[0]) : false; 1252 if (! $product_id) { 1253 continue; 1254 } 1255 1256 $row_idx = false; 1257 1258 // Find the row with this Product ID 1259 if (isset($id_index[ $product_id ]) ) { 1260 $row_idx = $id_index[ $product_id ]; 1261 } else { 1262 } 1263 1264 // Update or append based on match 1265 if ($row_idx) { 1266 $col_end = $sheet->getColumnLetter(count($values)); 1267 $range = $tab_name . '!A' . $row_idx . ':' . $col_end . $row_idx; 1268 $child_batch_updates[] = [ 1269 'range' => $range, 1270 'values' => [ array_values($values) ], 1271 ]; 1272 } else { 1273 // No ID match - append as new product 1274 $child_batch_appends[] = array_values($values); 1275 } 1276 } 1277 1278 // Send all updates in ONE API call 1279 if (! empty($child_batch_updates) ) { 1280 $sheet->batch_update_rows($child_batch_updates); 1281 delete_transient($child_cache_key); 1282 } 1283 if (! empty($child_batch_appends) ) { 1284 $sheet->append_batch_rows($child_batch_appends); 1285 delete_transient($child_cache_key); 1286 } 1287 1288 } 1289 } 1290 } 1291 } 1292 } 1293 } 1294 1295 if (count($all_sites) > ($site_offset + $site_batch_limit)) { 1296 // More sites left for the same product batch 1297 $next_args = $args; 1298 $next_args['site_offset'] = $site_offset + $site_batch_limit; 1299 wp_clear_scheduled_hook('ssgsw_multistore_product_update', [$next_args]); 1300 wp_schedule_single_event(time() + $delay_seconds, 'ssgsw_multistore_product_update', [$next_args]); 1301 1302 } else { 1303 1304 // Avoid duplicate sheet writes. 1305 $should_write_main_tab = apply_filters( 1306 'multissaf_should_write_main_tab', 1307 ( $sheet_sync || empty($current_sites_batch) ), 1308 $sheet_sync, 1309 $current_sites_batch 1310 ); 1311 1312 if ($should_write_main_tab ) { 1313 if (class_exists('\StockSyncWithGoogleSheetForWooCommerce\App')) { 1314 $new_app = new \StockSyncWithGoogleSheetForWooCommerce\App(); 1315 if ($new_app->is_plugin_ready_setup()) { 1316 $sheet_args = [['product_ids' => $current_batch]]; 1317 wp_clear_scheduled_hook('ssgsw_multistore_sheet_update', $sheet_args); 1318 wp_schedule_single_event(time() + 2, 'ssgsw_multistore_sheet_update', $sheet_args); 1319 } else { 1320 } 1321 } else { 1322 } 1323 } else { 1324 if (class_exists('\StockSyncWithGoogleSheetForWooCommerce\App')) { 1325 $new_app = new \StockSyncWithGoogleSheetForWooCommerce\App(); 1326 if ($new_app->is_plugin_ready_setup()) { 1327 $sheet_args = [['product_ids' => $current_batch]]; 1328 wp_clear_scheduled_hook('ssgsw_multistore_sheet_update', $sheet_args); 1329 wp_schedule_single_event(time() + 2, 'ssgsw_multistore_sheet_update', $sheet_args); 1330 } else { 1331 } 1332 } 1333 } 1334 1335 // Move to next product batch 1336 $remaining_products = array_slice($product_ids, $batch_size); 1337 if (! empty($remaining_products)) { 1338 $next_args = $args; 1339 $next_args['product_ids'] = $remaining_products; 1340 $next_args['site_offset'] = 0; 1341 wp_clear_scheduled_hook('ssgsw_multistore_product_update', [$next_args]); 1342 wp_schedule_single_event(time() + $delay_seconds, 'ssgsw_multistore_product_update', [$next_args]); 1343 } 1344 } 1345 } 1346 1347 /** 1348 * Collects updated product IDs and queues/fires the propagation action. 1349 * Entry point called by the base plugin after any product save. 1350 * Guards against manual-import re-trigger via body flag. 1351 * 1352 * Connected to: action 'ssgsw_collection_of_update_ids' 1353 * 1354 * @param int[] $product_ids Products that changed. 1355 * @param bool $sheet_sync Pass through to update func. 1356 * @param string $refrance_shop Pass through to update func. 1357 * @param int[] $original_ids Pass through to update func. 1358 * @param bool $manual_update Allow firing even if body has manual_update flag. 1359 * @return void 1360 */ 1361 public function ssgsw_collection_of_update_ids($product_ids, $sheet_sync= false, $refrance_shop = '', $original_ids = [], $manual_update = false) 1362 { 1363 1364 // LOG: Entry point 1365 1366 $multissaf_body = multissaf_get_body(); 1367 $manual_import = isset($multissaf_body->manual_update) ? (int)$multissaf_body->manual_update : false; 1368 1369 if ($manual_import && ! $manual_update ) { 1370 return; 1371 } 1372 1373 if(!$product_ids) { 1374 return; 1375 } 1376 $args = [ 1377 [ 1378 'product_ids' => $product_ids, 1379 'sheet_sync' => $sheet_sync, 1380 'refrance_shop' => $refrance_shop, 1381 'original_ids' => $original_ids, 1382 1383 ] 1384 ]; 1385 $child_active = multissaf_chid_activity(); 1386 $parent_active = multissaf_parent_activity(); 1387 1388 if ($child_active || $parent_active ) { 1389 1390 // Use direct sync instead of cron scheduling 1391 if (! multissaf_get_feature_flag('enable_cron_sync') ) { 1392 // Direct sync is enabled (cron is disabled) 1393 1394 $pending_key = apply_filters('multissaf_pending_ids_key', 'multissaf_pending_product_ids'); 1395 $pending = get_transient($pending_key); 1396 $pending = is_array($pending) ? $pending : []; 1397 $pending = array_unique(array_merge($pending, (array) $product_ids)); 1398 set_transient($pending_key, $pending, apply_filters('multissaf_pending_ids_ttl', 30)); 1399 1400 $lock_key = apply_filters('multissaf_sync_lock_key', 'multissaf_sync_scheduled_lock'); 1401 if (! get_transient($lock_key) ) { 1402 set_transient($lock_key, 1, 10); 1403 1404 // Call direct sync action instead of scheduling cron 1405 do_action( 1406 'ssgsw_multistore_product_update_direct', [ 1407 'product_ids' => $pending, 1408 'sheet_sync' => $sheet_sync, 1409 'refrance_shop' => $refrance_shop, 1410 'original_ids' => $original_ids, 1411 'retry_count' => 0, // Start with 0 retries 1412 ] 1413 ); 1414 } 1415 } else { 1416 // Cron sync is enabled, use old behavior 1417 $pending_key = apply_filters('multissaf_pending_ids_key', 'multissaf_pending_product_ids'); 1418 $pending = get_transient($pending_key); 1419 $pending = is_array($pending) ? $pending : []; 1420 $pending = array_unique(array_merge($pending, (array) $product_ids)); 1421 set_transient($pending_key, $pending, apply_filters('multissaf_pending_ids_ttl', 30)); 1422 1423 $lock_key = apply_filters('multissaf_sync_lock_key', 'multissaf_sync_scheduled_lock'); 1424 if (! get_transient($lock_key) ) { 1425 set_transient($lock_key, 1, 10); 1426 wp_schedule_single_event( 1427 time() + apply_filters('multissaf_product_update_delay_seconds', 5), 1428 'ssgsw_multistore_product_update', 1429 [[ 1430 'product_ids' => $pending, 1431 'sheet_sync' => $sheet_sync, 1432 'refrance_shop' => $refrance_shop, 1433 'original_ids' => $original_ids, 1434 ]] 1435 ); 1436 } 1437 1438 } 1439 } 1440 1441 } 1442 1443 /** 1444 * Checks if the current domain should be allowed to perform a sync based on the multi-store sync mode and child activity. 1445 * 1446 * @param bool $domain_access The initial access status for the domain. This is typically set to true by default. 1447 * @return bool Returns true if the sync mode is "shop_to_sheet" and the child store is active. Otherwise, it returns the original value of $domain_access. 1448 */ 1449 public function ssgsw_domain_url($domain_access) 1450 { 1451 $sync_mode = get_option('ssgsw_multi_store_mode_on'); 1452 $activity = multissaf_chid_activity(); 1453 if ('shop_to_sheet' == $sync_mode && $activity) { 1454 return true; 1455 } 1456 return $domain_access; 1457 } 1458 1459 /** 1460 * Determines the active synchronization mode based on multi-store settings and activity. 1461 * 1462 * @param string $sync_mode The initial synchronization mode value. 1463 * @return string The determined synchronization mode. 1464 */ 1465 public function ssgsw_sync_mode($sync_mode) 1466 { 1467 1468 $sync_mode = get_option('ssgsw_multi_store_mode_on'); 1469 $activity = multissaf_chid_activity(); 1470 if ('shop_to_sheet' == $sync_mode && $activity) { 1471 return 'shop_to_sheet'; 1472 } 1473 return $sync_mode; 1474 } 1475 1476 /** 1477 * Adds a new custom 'Origin' column to the WooCommerce products list table. 1478 * 1479 * @param array $columns An associative array of existing product columns. 1480 * @return array An updated array of columns, including the new 'Origin' column. 1481 */ 1482 public function product_columns($columns) 1483 { 1484 1485 if (!function_exists('ssgsw_is_admin')) { 1486 return $columns; 1487 } 1488 if (!function_exists('ssgsw_check_action_domain')) { 1489 return $columns; 1490 } 1491 $is_admin_check = ssgsw_is_admin(); 1492 $domain_check = ssgsw_check_action_domain(); 1493 if ($is_admin_check) { 1494 $sync_mode = get_option('ssgsw_multi_store_mode_on'); 1495 $other_check = multissaf_chid_activity(); 1496 1497 if ('shop_to_sheet' === $sync_mode && $other_check ) { 1498 $ultimate_columns = [ 1499 'ssgsw_origin' => [ 1500 'label' => __('Store Name', 'multi-store-sync-addon-for-flexstock'), 1501 'type' => 'meta', 1502 'priority' => 9, 1503 ] 1504 ]; 1505 return array_merge($columns, $ultimate_columns); 1506 } 1507 } 1508 return $columns; 1509 } 1510 1511 /** 1512 * Filters the array of site product counts, allowing other plugins to 1513 * add data from connected sub-sites. 1514 * 1515 * @param array $main_array The initial array of site data, containing the main site's information. 1516 * @return array The filtered array, now including data for connected sub-sites. 1517 */ 1518 public function ssgsw_sub_site_product_count($main_array = []) 1519 { 1520 // The admin "Sync on Google Sheet" (AJAX) syncs only this site's products; stores are 1521 // listed only for the Google Sheet script (REST), which routes each tab's edits. 1522 if (wp_doing_ajax() ) { 1523 return $main_array; 1524 } 1525 1526 // Retrieve the list of sub-sites from the option. 1527 $sub_site = multissaf_sub_site_url(); 1528 1529 // Filter out any entries that have empty URLs or tokens. 1530 $filtered_options = array_filter( 1531 $sub_site, function ($entry) { 1532 return !empty($entry['url']) && !empty($entry['token']); 1533 } 1534 ); 1535 1536 // Connected stores first: the sheet script uses the first store whose tab matches. 1537 $is_connected = function ( $entry ) { 1538 return ! empty($entry['isConnected']) && 'false' !== $entry['isConnected']; 1539 }; 1540 $filtered_options = array_merge( 1541 array_values(array_filter($filtered_options, $is_connected)), 1542 array_values( 1543 array_filter( 1544 $filtered_options, function ( $entry ) use ( $is_connected ) { 1545 return ! $is_connected($entry); 1546 } 1547 ) 1548 ) 1549 ); 1550 1551 // If there are valid sub-sites, loop through them and add their data to the main array. 1552 if (is_array($filtered_options) && !empty($filtered_options)) { 1553 foreach ($filtered_options as $site) { 1554 // Get the product count for the current sub-site. 1555 $site_url = $site['url']; 1556 $token = $site['token']; 1557 1558 // Get FILTERED product count from child using access_token (not for disconnected stores) 1559 $product_count = 0; 1560 if ($is_connected($site) && function_exists('multissaf_get_sub_product_count')) { 1561 $count_data = multissaf_get_sub_product_count($site_url, $token, $site); 1562 if (is_array($count_data) && isset($count_data['count'])) { 1563 $product_count = intval($count_data['count']); 1564 } elseif (is_numeric($count_data)) { 1565 $product_count = intval($count_data); 1566 } 1567 } 1568 1569 // Append the sub-site's data to the main array. 1570 $main_array[] = [ 1571 'url' => $site_url, 1572 'token' => $token, 1573 'product' => $product_count, 1574 'sheet_tab' => $site['tab_name'], 1575 'sheet_id' => $site['sheet_gid'], 1576 ]; 1577 } 1578 } 1579 1580 // Return the final array containing data from the main site and all sub-sites. 1581 return $main_array; 1582 } 1583 1584 /** 1585 * Enqueues the React admin app (build/index.js + build/index.css) on the plugin's admin page. 1586 * 1587 * @param string $hook The current admin page hook. 1588 * @return void 1589 */ 1590 public function enqueue_scripts($hook) 1591 { 1592 1593 if($hook == 'flexstock_page_ssgsw-multistore') { 1594 $asset_file = MULTISSAF_MULTI_STORE_PATH . 'build/index.asset.php'; 1595 $asset = file_exists($asset_file) ? include $asset_file : [ 'dependencies' => [], 'version' => MULTISSAF_MULTI_STORE_VERSION ]; 1596 1597 wp_enqueue_style('ssgsw-multi-store-css', MULTISSAF_MULTI_STORE_URL . '/build/index.css', [], $asset['version']); 1598 1599 // The base FlexStock admin script provides `ssgsw_script` (nonce, options), so load after it. 1600 wp_enqueue_script('multissaf-react', MULTISSAF_MULTI_STORE_URL . '/build/index.js', array_merge($asset['dependencies'], [ 'ssgsw-admin-js' ]), $asset['version'], true); 1601 1602 $setup_complete = get_option('ssgsw_multi_store_setup_complete', false); 1603 1604 // Data used by both the setup wizard and the dashboard. 1605 $localized_data = [ 1606 'page' => $setup_complete ? 'dashboard' : 'setup', 1607 'pluginUrl' => MULTISSAF_MULTI_STORE_URL, 1608 'hasLicenseTab' => function_exists('ssgsw_initialize_appsero_client_multistore'), 1609 'strings' => Strings::get(), 1610 ]; 1611 1612 if ($setup_complete) { 1613 // Prepare data for dashboard 1614 $mode = get_option('ssgsw_multi_store_mode_on', 'multi_to_single'); 1615 $role = get_option('ssgsw_multi_connect_mode_on', 'main'); 1616 $js_sync_behavior = ($mode === 'shop_to_sheet') ? 'manage' : 'sync'; 1617 $js_store_role = ($role === 'child') ? 'child' : 'main'; 1618 1619 $available_fields = []; 1620 if (class_exists('\StockSyncWithGoogleSheetForWooCommerce\Column')) { 1621 $column_obj = new \StockSyncWithGoogleSheetForWooCommerce\Column(); 1622 $columns = $column_obj->get_columns(); 1623 foreach ($columns as $key => $data) { 1624 // Skip columns explicitly marked as non-sheet columns (column: false). 1625 if (isset($data['column']) && false === $data['column'] ) { 1626 continue; 1627 } 1628 $available_fields[] = [ 1629 'val' => $key, 1630 'label' => isset($data['label']) ? $data['label'] : $key 1631 ]; 1632 } 1633 } 1634 1635 $available_categories = []; 1636 $product_cats = get_terms(['taxonomy' => 'product_cat', 'hide_empty' => false]); 1637 if (!is_wp_error($product_cats)) { 1638 foreach ($product_cats as $cat) { 1639 $available_categories[] = [ 1640 'id' => $cat->term_id, 1641 'name' => $cat->name, 1642 'count' => $cat->count 1643 ]; 1644 } 1645 } 1646 1647 $available_tags = []; 1648 $product_tags = get_terms(['taxonomy' => 'product_tag', 'hide_empty' => false]); 1649 if (!is_wp_error($product_tags)) { 1650 foreach ($product_tags as $tag) { 1651 $available_tags[] = [ 1652 'id' => $tag->term_id, 1653 'name' => $tag->name, 1654 'count' => $tag->count 1655 ]; 1656 } 1657 } 1658 1659 // Build available WooCommerce attributes with their terms 1660 $available_attributes = []; 1661 if (function_exists('wc_get_attribute_taxonomies') ) { 1662 $attribute_taxonomies = wc_get_attribute_taxonomies(); 1663 foreach ( $attribute_taxonomies as $tax ) { 1664 $taxonomy = wc_attribute_taxonomy_name($tax->attribute_name); 1665 $terms = get_terms([ 'taxonomy' => $taxonomy, 'hide_empty' => false ]); 1666 $term_list = []; 1667 if (! is_wp_error($terms) ) { 1668 foreach ( $terms as $term ) { 1669 $term_list[] = [ 'slug' => $term->slug, 'name' => $term->name ]; 1670 } 1671 } 1672 $available_attributes[] = [ 1673 'taxonomy' => $taxonomy, 1674 'label' => $tax->attribute_label, 1675 'terms' => $term_list, 1676 ]; 1677 } 1678 } 1679 1680 $options = apply_filters('ssgsw_options', []); 1681 1682 // Token and spreadsheet state used by the React UI. 1683 $is_multistore_active = function_exists('ssgsw_multistore_license_active') ? ssgsw_multistore_license_active() : false; 1684 $existing_tokens = get_option('ssgsw_child_tokens', []); 1685 $spreadsheet_id = function_exists('ssgsw_get_option') ? ssgsw_get_option('spreadsheet_id') : ''; 1686 1687 $localized_data = array_merge( 1688 $localized_data, [ 1689 'isMultistoreLicenseActive' => (bool) $is_multistore_active, 1690 'tokenCount' => is_array($existing_tokens) ? count($existing_tokens) : 0, 1691 'hasSpreadsheet' => ! empty($spreadsheet_id), 1692 'flexstockSetupUrl' => admin_url('admin.php?page=ssgsw-admin'), 1693 'syncMode' => $mode, 1694 'syncBehavior' => $js_sync_behavior, 1695 'storeRole' => $js_store_role, 1696 'availableFields' => $available_fields, 1697 'availableCategories' => $available_categories, 1698 'availableTags' => $available_tags, 1699 'availableAttributes' => $available_attributes, 1700 'spreadsheetId' => isset($options['spreadsheet_id']) ? $options['spreadsheet_id'] : '', 1701 'connectedSites' => isset($options['multi_store_list']) ? $options['multi_store_list'] : [], 1702 'connectedSitesParent' => isset($options['child_tokens']) ? $options['child_tokens'] : [], 1703 'homeUrl' => home_url(), 1704 ] 1705 ); 1706 } 1707 1708 wp_localize_script('multissaf-react', 'multissaf_store_data', $localized_data); 1709 } 1710 1711 } 1712 1713 /** 1714 * Localizes (passes) relevant script variables, specifically multi-store URLs, 1715 * to be used by front-end JavaScript 1716 * 1717 * @since 1.0.0 (Example version) 1718 * 1719 * @param array $filters An array of data variables already set for localization. 1720 * @return array The updated array of localization data, including multi-store URLs. 1721 */ 1722 public function ssgsw_localized_script($filters) 1723 { 1724 $filters['multi_store_list'] = multissaf_sub_site_url(); 1725 $filters['multi_store_list_parent'] = multissaf_sub_site_url_parent(); 1726 1727 return $filters; 1728 } 1729 /** 1730 * Add setting options 1731 * 1732 * @return void 1733 */ 1734 public function ssgsw_options($options) 1735 { 1736 $options['multi_store_active'] = get_option('ssgsw_multi_store_active', true); 1737 $options['multi_store_list'] = multissaf_sub_site_url(); 1738 $options['multi_store_list_parent'] = multissaf_sub_site_url_parent(); 1739 $options['multi_store_mode_on'] = get_option('ssgsw_multi_store_mode_on', 'shop_to_sheet'); 1740 $options['multi_connect_mode_on'] = get_option('ssgsw_multi_connect_mode_on', 'child'); 1741 $options['child_tokens'] = get_option('ssgsw_child_tokens', []); 1742 $options['spreadsheet_id'] = function_exists('ssgsw_get_option') ? ssgsw_get_option('spreadsheet_id') : ''; 1743 1744 return $options; 1745 } 1746 1747 /** 1748 * Add vendor menu 1749 * 1750 * @return void 1751 */ 1752 public function set_menu() 1753 { 1754 add_submenu_page( 1755 'ssgsw-admin', 1756 __('Multi-Store Sync', 'multi-store-sync-addon-for-flexstock'), 1757 __('Multi-Store Sync', 'multi-store-sync-addon-for-flexstock'), 1758 'manage_options', 1759 'ssgsw-multistore', 1760 [ $this, 'render_vendor_page' ], 1761 99 1762 ); 1763 } 1764 1765 /** 1766 * Render the admin page: the React app mount point (src/, built to build/index.js). 1767 * 1768 * Markup printed by other plugins goes into hidden holders that React moves into place. 1769 * 1770 * @return void 1771 */ 1772 public function render_vendor_page() 1773 { 1774 $setup_complete = get_option('ssgsw_multi_store_setup_complete', false); 1775 ?> 1776 <div id="multissaf_srv_appscript" hidden> 1777 <?php 1778 if (function_exists('ssgsw')) { 1779 ssgsw()->load_template('dashboard/appscript'); 1780 } 1781 ?> 1782 </div> 1783 <div id="multissaf_srv_license" hidden> 1784 <?php 1785 if (function_exists('ssgsw_initialize_appsero_client_multistore')) { 1786 ssgsw_initialize_appsero_client_multistore(); 1787 } 1788 ?> 1789 </div> 1790 <?php if ($setup_complete) : ?> 1791 <div id="multissaf_app" data-page="dashboard" class="addons-container ssgsw-wrapper"> 1792 <div class="mss-flex-center" style="position:absolute; top:0; left:0; width:100%; height:100%; background:#f1f5f9; z-index:99999; flex-direction:column;"> 1793 <span class="dashicons dashicons-update mss-spin" style="font-size: 40px; width:40px; height:40px; color: #4F46E5;"></span> 1794 <p class="mss-mt-0"><?php esc_html_e('Loading Dashboard...', 'multi-store-sync-addon-for-flexstock'); ?></p> 1795 </div> 1796 </div> 1797 <?php else : ?> 1798 <div id="multissaf_app" data-page="setup" class="addons-container ssgsw-wrapper" style="position:relative; min-height:400px;"> 1799 <style> 1800 @keyframes ssgsw-spin { 100% { transform: rotate(360deg); } } 1801 </style> 1802 <div style="position:absolute; top:0; left:0; width:100%; height:100%; background:#f1f5f9; z-index:99999; display:flex; flex-direction:column; align-items:center; justify-content:center;"> 1803 <span class="dashicons dashicons-update" style="font-size: 40px; width:40px; height:40px; color: #6366f1; animation: ssgsw-spin 1.5s linear infinite;"></span> 1804 <p style="margin-top:16px; color:#6b7280; font-weight:600; font-size:14px;"><?php esc_html_e('Loading Dashboard...', 'multi-store-sync-addon-for-flexstock'); ?></p> 1805 </div> 1806 </div> 1807 <?php endif; ?> 1808 <?php 1809 } 1810 1811 /** 1812 * Add plugin action links callback. 1813 * 1814 * @param array $links Plugin action links. 1815 * @return array 1816 */ 1817 public function add_plugin_action_links( $links ) 1818 { 1819 if($this->app->is_plugin_activated()) { 1820 $links[] = '<a href="' . admin_url('admin.php?page=ssgsw-multistore') . '">' . __('Settings', 'multi-store-sync-addon-for-flexstock') . '</a>'; 1821 } 1822 return $links; 1823 } 1824 1825 /** 1826 * Redirects the user to the add-ons page after plugin activation, 1827 * but only if the redirection hasn't been triggered before. 1828 * 1829 * @return void 1830 */ 1831 public function redirect_after_active() 1832 { 1833 // Only run in wp-admin, never on frontend, REST, AJAX, or cron requests. 1834 if (! is_admin() || wp_doing_ajax() || ! current_user_can('manage_options') ) { 1835 return; 1836 } 1837 // Skip on bulk plugin activation. 1838 if (isset($_GET['activate-multi']) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended 1839 return; 1840 } 1841 1842 if($this->app->is_plugin_activated()) { 1843 $ssgsw_triggered_from_ultimate = wp_validate_boolean(get_option('ssgsw_triggered_from_multistore')); 1844 if (! $ssgsw_triggered_from_ultimate ) { 1845 update_option('ssgsw_triggered_from_multistore', true); 1846 wp_safe_redirect(admin_url('admin.php?page=ssgsw-multistore')); 1847 exit; 1848 } 1849 } 1850 } 1851 1852 /** 1853 * Activate plugin 1854 * 1855 * @return void 1856 */ 1857 public function activate() 1858 { 1859 delete_option('ssgsw_triggered_from_multistore'); 1860 } 1861 1862 /** 1863 * Print admin notices 1864 * 1865 * @return void 1866 */ 1867 public function admin_notices() 1868 { 1869 if ($this->app->is_plugin_activated() ) { 1870 return; 1871 } 1872 1873 if (! current_user_can('activate_plugins') ) { 1874 return; 1875 } 1876 $plugin_name = esc_html__('FlexStock Multi-Store', 'multi-store-sync-addon-for-flexstock'); 1877 $plugin_url = $this->app->free_version; 1878 1879 $dependency = esc_html__('FlexStock', 'multi-store-sync-addon-for-flexstock'); 1880 1881 if (defined('SSGSW_VERSION') && SSGSW_VERSION < '3.15.0') { 1882 $message2 = wp_sprintf('<strong>%s</strong> requires <strong>%s</strong> plugin to be activated version number up to 3.15.0', esc_html($plugin_name), esc_html($dependency)); 1883 echo wp_sprintf('<div class="error"><p>%s</p></div>', wp_kses_post($message2)); 1884 } 1885 1886 if ($this->app->is_plugin_installed() ) { 1887 $activation_url = wp_nonce_url('plugins.php?action=activate&plugin=' . esc_url($plugin_url) . '&plugin_status=all&paged=1&s', 'activate-plugin_' . esc_url($plugin_url)); 1888 1889 $message = wp_sprintf('<strong>%s</strong> requires <strong>%s</strong> plugin to be activated.', esc_html($plugin_name), esc_html($dependency)); 1890 $button_text = esc_html__('Activate', 'multi-store-sync-addon-for-flexstock'); 1891 } else { 1892 $activation_url = wp_nonce_url(self_admin_url('update.php?action=install-plugin&plugin=stock-sync-with-google-sheet-for-woocommerce'), 'install-plugin_woocommerce'); 1893 $message = wp_sprintf('<strong>%s</strong> requires <strong>%s</strong> plugin to be installed and activated.', esc_html($plugin_name), esc_html($dependency)); 1894 $button_text = esc_html__('Install', 'multi-store-sync-addon-for-flexstock'); 1895 } 1896 1897 echo wp_sprintf('<div class="error"><p>%s</p></div>', wp_kses_post($message)); 1898 } 1899 1900 /** 1901 * Initialize the Appsero opt-in analytics client and the WPPOOL plugin SDK. 1902 * 1903 * @return void 1904 */ 1905 public function init_appsero_client() 1906 { 1907 $appsero_file = MULTISSAF_MULTI_STORE_PATH . 'vendor/appsero/src/Client.php'; 1908 if (! file_exists($appsero_file) ) { 1909 return; 1910 } 1911 1912 // Load Appsero Client 1913 if (! class_exists('\MultiStoreSyncAddonForFlexStock\Appsero\Client') ) { 1914 include_once $appsero_file; 1915 } 1916 1917 if (! class_exists('\MultiStoreSyncAddonForFlexStock\Appsero\Client') ) { 1918 return; 1919 } 1920 1921 $client = new \MultiStoreSyncAddonForFlexStock\Appsero\Client( 1922 '1772338d-40f8-4cb5-907b-432a609cd0f2', 1923 'Multi-Store Sync', 1924 MULTISSAF_MULTI_STORE 1925 ); 1926 1927 // Activate Appsero insights tracking 1928 $client->insights()->init(); 1929 1930 // Initialize the WPPOOL plugin SDK. 1931 if (function_exists('wppool_plugin_init') ) { 1932 $default_image = MULTISSAF_MULTI_STORE_URL . '/includes/wppool/background-image.png'; 1933 1934 // Register plugin with SDK 1935 $multistore_plugin = wppool_plugin_init('multi_store_sync_addon_for_flexstock', $default_image); 1936 1937 $promo_image = MULTISSAF_MULTI_STORE_URL . '/includes/wppool/special.png'; 1938 $promo_end = '2026-12-31 23:59:59'; 1939 $promo_start = '2026-11-15 00:00:00'; 1940 $promo_cta = esc_html__('Get Pro Now', 'multi-store-sync-addon-for-flexstock'); 1941 $promo_link = 'https://wppool.dev/flexstock/addons/'; 1942 1943 if ($multistore_plugin && is_object($multistore_plugin) && method_exists($multistore_plugin, 'set_campaign') ) { 1944 $multistore_plugin->set_campaign($promo_image, $promo_end, $promo_start, $promo_cta, $promo_link); 1945 } 1946 1947 } 1948 } 1949 1950 1951 /** 1952 * Get current site data from option by URL. 1953 * Used to validate token hasn't changed since cron was scheduled. 1954 * 1955 * @param string $site_url The site URL to look up. 1956 * @return array|false Site data array if found and connected, false otherwise. 1957 */ 1958 private function get_current_site_data($site_url) 1959 { 1960 if (empty($site_url)) { 1961 return false; 1962 } 1963 1964 $store_role = get_option('ssgsw_multi_connect_mode_on'); 1965 1966 if ('child' === $store_role) { 1967 $all_sites = get_option('ssgsw_multi_store_list_parent', []); 1968 } else { 1969 $all_sites = get_option('ssgsw_multi_store_list', []); 1970 } 1971 1972 if (!is_array($all_sites) || empty($all_sites)) { 1973 return false; 1974 } 1975 1976 $target_url = trailingslashit($site_url); 1977 1978 foreach ($all_sites as $site) { 1979 if (empty($site['url'])) { 1980 continue; 1981 } 1982 1983 if (trailingslashit($site['url']) === $target_url) { 1984 // Check if connected 1985 $is_connected = isset($site['isConnected']) ? $site['isConnected'] : false; 1986 if ($is_connected === true || $is_connected === 1 || $is_connected === '1') { 1987 return $site; 1988 } 1989 return false; // Found but disconnected 1990 } 1991 } 1992 1993 return false; // Not found 1994 } 1995 1996 /** 1997 * Clean up any stale cron jobs for a specific site URL. 1998 * Called when a site is found to be disconnected during cron execution. 1999 * 2000 * @param string $site_url The site URL to clean up cron jobs for. 2001 */ 2002 private function cleanup_site_cron_jobs($site_url) 2003 { 2004 if (empty($site_url)) { 2005 return; 2006 } 2007 2008 // Clear failed sync data 2009 $site_hash = md5($site_url); 2010 delete_option('multissaf_failed_sync_' . $site_hash); 2011 delete_option('multissaf_failed_sync_meta_' . $site_hash); 2012 2013 // Note: Cannot easily clear specific cron jobs here because they don't store site URL 2014 // This is why we need comprehensive cleanup on disconnect/delete 2015 } 2016 2017 } 2018 // Initialize. 2019 $multissaf_hook = new MULTISSAF_Hooks(); 2013 2020 } -
multi-store-sync-addon-for-flexstock/tags/1.1.0/includes/classes/export-functions.php
r3706977 r3752558 9 9 * @return array 10 10 */ 11 if ( ! function_exists( 'multissaf_get_all_columns' ) ) { 12 function multissaf_get_all_columns() { 13 defined( 'ABSPATH' ) || exit; 11 if (! function_exists('multissaf_get_all_columns') ) { 12 function multissaf_get_all_columns() 13 { 14 defined('ABSPATH') || exit; 14 15 $columns = [ 15 16 'ID' => [ … … 101 102 * @return array 102 103 */ 103 if ( ! function_exists( 'multissaf_get_column_keys' ) ) { 104 function multissaf_get_column_keys() { 105 defined( 'ABSPATH' ) || exit; 104 if (! function_exists('multissaf_get_column_keys') ) { 105 function multissaf_get_column_keys() 106 { 107 defined('ABSPATH') || exit; 106 108 107 109 $columns = multissaf_get_all_columns(); … … 113 115 ); 114 116 115 $keys = array_keys($columns);116 117 return $keys;117 $keys = array_keys($columns); 118 119 return $keys; 118 120 } 119 121 } … … 123 125 * @return array 124 126 */ 125 if ( ! function_exists( 'multissaf_get_queryable_columns' ) ) { 126 function multissaf_get_queryable_columns() { 127 defined( 'ABSPATH' ) || exit; 127 if (! function_exists('multissaf_get_queryable_columns') ) { 128 function multissaf_get_queryable_columns() 129 { 130 defined('ABSPATH') || exit; 128 131 129 132 $columns = multissaf_get_all_columns(); … … 133 136 } 134 137 ); 135 return $columns;138 return $columns; 136 139 } 137 140 } … … 142 145 * @return array 143 146 */ 144 if ( ! function_exists( 'multissaf_get_queryable_fields' ) ) { 145 function multissaf_get_queryable_fields() { 146 defined( 'ABSPATH' ) || exit; 147 if (! function_exists('multissaf_get_queryable_fields') ) { 148 function multissaf_get_queryable_fields() 149 { 150 defined('ABSPATH') || exit; 147 151 148 152 $columns = multissaf_get_queryable_columns(); … … 154 158 ); 155 159 156 return array_keys($columns);160 return array_keys($columns); 157 161 } 158 162 } … … 163 167 * @return array 164 168 */ 165 if ( ! function_exists( 'multissaf_multistore_get_queryable_metas' ) ) { 166 function multissaf_multistore_get_queryable_metas() { 167 defined( 'ABSPATH' ) || exit; 169 if (! function_exists('multissaf_multistore_get_queryable_metas') ) { 170 function multissaf_multistore_get_queryable_metas() 171 { 172 defined('ABSPATH') || exit; 168 173 169 174 $columns = multissaf_get_queryable_columns(); … … 175 180 ); 176 181 177 return apply_filters('ssgsw_multistore_queryable_metas', $columns);182 return apply_filters('ssgsw_multistore_queryable_metas', $columns); 178 183 } 179 184 } … … 184 189 * @return array 185 190 */ 186 if ( ! function_exists( 'multissaf_multistore_get_queryable_taxonomies' ) ) { 187 function multissaf_multistore_get_queryable_taxonomies() { 188 defined( 'ABSPATH' ) || exit; 191 if (! function_exists('multissaf_multistore_get_queryable_taxonomies') ) { 192 function multissaf_multistore_get_queryable_taxonomies() 193 { 194 defined('ABSPATH') || exit; 189 195 190 196 $columns = multissaf_get_queryable_columns(); … … 195 201 ); 196 202 197 return array_keys($columns); 198 } 199 } 200 201 if ( ! function_exists( 'multissaf_get_queryable_relations' ) ) { 202 function multissaf_get_queryable_relations() { 203 defined( 'ABSPATH' ) || exit; 204 205 $columns = multissaf_get_queryable_columns(); 206 $columns = array_filter( 207 $columns, function ( $column ) { 208 return 'relation' === $column['type']; 209 } 210 ); 211 212 return array_keys($columns); 213 } 214 } 215 216 if ( ! function_exists( 'multissaf_get_product_attribute_keys' ) ) { 217 function multissaf_get_product_attribute_keys() { 218 defined( 'ABSPATH' ) || exit; 203 return array_keys($columns); 204 } 205 } 206 207 if (! function_exists('multissaf_get_product_attribute_keys') ) { 208 function multissaf_get_product_attribute_keys() 209 { 210 defined('ABSPATH') || exit; 219 211 220 212 global $wpdb; … … 230 222 ); 231 223 232 // Ensure results are returned as an array 233 return is_array($results) && !empty($results) ? $results : []; 234 } 235 } 236 237 if ( ! function_exists( 'multissaf_get_raw_data' ) ) { 238 function multissaf_get_raw_data( ?array $ids = null, $offset = 0, $batch_size = 0, array $product_type = [ 'product', 'product_variation' ] ) { 239 defined( 'ABSPATH' ) || exit; 224 // Ensure results are returned as an array 225 return is_array($results) && !empty($results) ? $results : []; 226 } 227 } 228 229 if (! function_exists('multissaf_get_raw_data') ) { 230 function multissaf_get_raw_data( ?array $ids = null, $offset = 0, $batch_size = 0, array $product_type = [ 'product', 'product_variation' ] ) 231 { 232 defined('ABSPATH') || exit; 240 233 241 234 global $wpdb; 235 $main_table = $wpdb->prefix . 'posts'; 242 236 243 237 $fields = multissaf_get_queryable_fields(); 244 245 $main_table = $wpdb->prefix . 'posts';246 238 247 239 $query = 'SELECT '; … … 254 246 ); 255 247 256 //From post meta table257 $post_meta_table = $wpdb->prefix . 'postmeta';258 $meta_fields = multissaf_multistore_get_queryable_metas();248 //From post meta table 249 $post_meta_table = $wpdb->prefix . 'postmeta'; 250 $meta_fields = multissaf_multistore_get_queryable_metas(); 259 251 if ($meta_fields && count($meta_fields) > 0 ) { 260 252 $query .= ', '; … … 268 260 if(is_array($attribute_keys) && !empty($attribute_keys)) { 269 261 foreach ($attribute_keys as $attribute_key) { 270 // Sanitize the meta_key for safe use as a SQL identifier/alias. 271 // attribute_ keys are internal WooCommerce postmeta keys (e.g. attribute_pa_color) 272 // so sanitize_key() is the right tool — strips anything non-alphanumeric/underscore/dash. 273 $safe_meta_key = sanitize_key( $attribute_key->meta_key ); 274 $safe_meta_alias = sanitize_key( $attribute_key->meta_key ); 275 if ( empty( $safe_meta_key ) ) { 276 continue; 277 } 278 $escaped_meta_key = $wpdb->prepare( '%s', $safe_meta_key ); 279 $meta_query[] = "(SELECT {$post_meta_table}.meta_value FROM {$post_meta_table} WHERE {$post_meta_table}.post_id = {$main_table}.ID AND {$post_meta_table}.meta_key = $escaped_meta_key LIMIT 1) AS `{$safe_meta_alias}`"; 262 $escaped_meta_key = $wpdb->prepare('%s', $attribute_key->meta_key); 263 $meta_query[] = "(SELECT {$post_meta_table}.meta_value FROM {$post_meta_table} WHERE {$post_meta_table}.post_id = {$main_table}.ID AND {$post_meta_table}.meta_key = $escaped_meta_key LIMIT 1) AS $escaped_meta_key"; 280 264 } 281 265 } … … 284 268 } 285 269 286 //From taxonomy table287 $taxonomy_fields = multissaf_multistore_get_queryable_taxonomies();288 289 // Whitelist taxonomy fields against registered product taxonomies before SQL interpolation.290 if ( ! empty( $taxonomy_fields )) {291 $valid_product_taxonomies = get_object_taxonomies( 'product', 'names');270 //From taxonomy table 271 $taxonomy_fields = multissaf_multistore_get_queryable_taxonomies(); 272 273 // Whitelist taxonomy fields against registered product taxonomies before SQL interpolation. 274 if ($taxonomy_fields && count($taxonomy_fields) > 0 ) { 275 $valid_product_taxonomies = get_object_taxonomies('product', 'names'); 292 276 $taxonomy_fields = array_filter( 293 277 $taxonomy_fields, 294 278 function ( $field ) use ( $valid_product_taxonomies ) { 295 return in_array( $field, $valid_product_taxonomies, true);279 return in_array($field, $valid_product_taxonomies, true); 296 280 } 297 281 ); … … 308 292 ); 309 293 310 311 } 312 $query .= " From $main_table"; 313 // Includes variations — use $wpdb->prepare() for the full IN clause. 314 $type_placeholders = implode( ', ', array_fill( 0, count( $product_type ), '%s' ) ); 315 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared 316 $query .= $wpdb->prepare( " WHERE {$main_table}.post_type IN ($type_placeholders)", $product_type ); 317 318 $query .= " AND $main_table.post_status != 'trash'"; 319 $query .= " AND EXISTS ( SELECT 1 FROM {$wpdb->postmeta} WHERE post_id = $main_table.ID AND meta_key = '_sku' AND meta_value IS NOT NULL AND meta_value != '' )"; 294 295 } 296 297 // Use proper $wpdb->prepare() with placeholders for the full IN clause. 298 $type_placeholders = implode(', ', array_fill(0, count($product_type), '%s')); 299 300 $query .= " From $main_table"; 301 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared 302 $query .= $wpdb->prepare(" WHERE {$main_table}.post_type IN ($type_placeholders)", $product_type); 303 304 $query .= " AND $main_table.post_status != 'trash'"; 305 $query .= " AND EXISTS ( SELECT 1 FROM {$wpdb->postmeta} WHERE post_id = $main_table.ID AND meta_key = '_sku' AND meta_value IS NOT NULL AND meta_value != '' )"; 320 306 321 307 if ($ids && count($ids) > 0 ) { 322 308 // Cast all IDs to integers to prevent SQL injection. 323 $safe_ids = array_filter( array_map( 'absint', (array) $ids ));324 if ( ! empty( $safe_ids) ) {325 $id_placeholders = implode( ', ', array_fill( 0, count( $safe_ids ), '%d' ));309 $safe_ids = array_filter(array_map('absint', (array) $ids)); 310 if (! empty($safe_ids) ) { 311 $id_placeholders = implode(', ', array_fill(0, count($safe_ids), '%d')); 326 312 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared 327 $query .= $wpdb->prepare( " AND {$main_table}.ID IN ($id_placeholders)", $safe_ids ); 328 } 329 } 330 // Avoid duplicating IDs. 331 $query .= " GROUP BY $main_table.ID "; 332 333 // Order and Order By — whitelist column names, never interpolate raw filter values. 334 $allowed_columns = [ 'post_date', 'post_modified', 'post_title', 'ID', 'menu_order' ]; 335 $order_by_raw = apply_filters( 'multissaf_product_order_by', 'post_date' ); 336 $order_raw = apply_filters( 'multissaf_product_order', 'ASC' ); 337 $order_by = in_array( $order_by_raw, $allowed_columns, true ) ? $order_by_raw : 'post_date'; 338 $order = strtoupper( $order_raw ) === 'DESC' ? 'DESC' : 'ASC'; 339 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared 340 $query .= " ORDER BY {$main_table}.{$order_by} {$order}"; 313 $query .= $wpdb->prepare(" AND {$main_table}.ID IN ($id_placeholders)", $safe_ids); 314 } 315 } 316 // Avoid duplicating IDs. 317 $query .= " GROUP BY $main_table.ID "; 318 319 // Order and Order By — whitelist column names, never interpolate raw filter values. 320 $allowed_columns = [ 'post_date', 'post_modified', 'post_title', 'ID', 'menu_order' ]; 321 $order_by_raw = apply_filters('multissaf_product_order_by', 'post_date'); 322 $order_raw = apply_filters('multissaf_product_order', 'ASC'); 323 $order_by = in_array($order_by_raw, $allowed_columns, true) ? $order_by_raw : 'post_date'; 324 $order = strtoupper($order_raw) === 'DESC' ? 'DESC' : 'ASC'; 325 326 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared 327 $query .= " ORDER BY {$main_table}.{$order_by} {$order}"; 341 328 if($batch_size) { 342 329 $query .= $wpdb->prepare(' LIMIT %d OFFSET %d', $batch_size, $offset); 343 330 } 344 331 345 // phpcs:ignore346 $results = $wpdb->get_results( $query ); // db call ok; no-cache ok.332 // phpcs:ignore 333 $results = $wpdb->get_results( $query ); // db call ok; no-cache ok. 347 334 if ($wpdb->last_error ) { 348 335 return $wpdb->last_error; 349 336 } 350 337 351 return $results; 352 } 353 } 354 355 356 if ( ! function_exists( 'multissaf_get_formatted_data' ) ) { 357 function multissaf_get_formatted_data( $ids = null, $offset = 0, $batch_size = 0, array $product_type = [ 'product', 'product_variation' ], $keys = [] ) { 358 defined( 'ABSPATH' ) || exit; 338 return $results; 339 } 340 } 341 342 343 if (! function_exists('multissaf_get_formatted_data') ) { 344 function multissaf_get_formatted_data( $ids = null, $offset = 0, $batch_size = 0, array $product_type = [ 'product', 'product_variation' ], $keys = [] ) 345 { 346 defined('ABSPATH') || exit; 359 347 360 348 if (is_array($keys) && empty($keys)) { … … 382 370 } 383 371 384 // FIX: Fetch categories/tags/brands for ALL product types (not just variations)372 // Categories, tags and brands for all product types. 385 373 // Determine which post ID to use for fetching taxonomy terms 386 374 $term_source_id = $row->ID; … … 388 376 389 377 // For variations: terms live on the parent product, not the variation post itself 390 if ( 'variation' === $product_type ) {391 $parent_id = wp_get_post_parent_id( $row->ID);392 if ( $parent_id ) {378 if ('variation' === $product_type ) { 379 $parent_id = wp_get_post_parent_id($row->ID); 380 if ($parent_id ) { 393 381 $term_source_id = $parent_id; 394 382 } … … 396 384 397 385 // Get categories - for all product types 398 $categories = wp_get_post_terms( $term_source_id, 'product_cat');386 $categories = wp_get_post_terms($term_source_id, 'product_cat'); 399 387 $formatted_row['categories'] = []; 400 if ( ! is_wp_error( $categories ) && ! empty( $categories) ) {388 if (! is_wp_error($categories) && ! empty($categories) ) { 401 389 foreach ( $categories as $category ) { 402 390 // Skip the default WooCommerce 'uncategorized' term 403 if ( 'uncategorized' === $category->slug ) {391 if ('uncategorized' === $category->slug ) { 404 392 continue; 405 393 } … … 412 400 413 401 // Get tags - for all product types 414 $tags = wp_get_post_terms( $term_source_id, 'product_tag');402 $tags = wp_get_post_terms($term_source_id, 'product_tag'); 415 403 $formatted_row['tags'] = []; 416 if ( ! is_wp_error( $tags ) && ! empty( $tags) ) {404 if (! is_wp_error($tags) && ! empty($tags) ) { 417 405 foreach ( $tags as $tag ) { 418 406 $formatted_row['tags'][] = [ … … 424 412 425 413 // Get brands - for all product types 426 $brands = wp_get_post_terms( $term_source_id, 'product_brand');414 $brands = wp_get_post_terms($term_source_id, 'product_brand'); 427 415 $formatted_row['brands'] = []; 428 if ( ! is_wp_error( $brands ) && ! empty( $brands) ) {416 if (! is_wp_error($brands) && ! empty($brands) ) { 429 417 foreach ( $brands as $brand ) { 430 418 $formatted_row['brands'][] = [ … … 446 434 ); 447 435 448 return $formatted_data; 449 } 450 } 451 452 if ( ! function_exists( 'multissaf_get_formatted_data_with_permissions' ) ) { 453 function multissaf_get_formatted_data_with_permissions( $ids = null, $offset = 0, $batch_size = 0, array $product_type = [ 'product', 'product_variation' ], $permissions = [] ) { 454 defined( 'ABSPATH' ) || exit; 455 456 $allowed_fields = isset( $permissions['fields'] ) ? (array) $permissions['fields'] : [ 'all_fields' ]; 457 $allow_all = in_array( 'all_fields', $allowed_fields ) || empty( $allowed_fields ); 436 return $formatted_data; 437 } 438 } 439 440 if (! function_exists('multissaf_get_formatted_data_with_permissions') ) { 441 function multissaf_get_formatted_data_with_permissions( $ids = null, $offset = 0, $batch_size = 0, array $product_type = [ 'product', 'product_variation' ], $permissions = [] ) 442 { 443 defined('ABSPATH') || exit; 444 445 $allowed_fields = isset($permissions['fields']) ? (array) $permissions['fields'] : [ 'all_fields' ]; 446 $allow_all = in_array('all_fields', $allowed_fields) || empty($allowed_fields); 458 447 459 448 $keys = multissaf_get_column_keys(); … … 476 465 foreach ( $keys as $key ) { 477 466 // Always allow ID, product_type, and _product_attributes 478 if ( ! $allow_all && ! in_array( $key, $allowed_fields ) && ! in_array( $key, ['ID', 'product_type', '_product_attributes']) ) {467 if (! $allow_all && ! in_array($key, $allowed_fields) && ! in_array($key, ['ID', 'product_type', '_product_attributes']) ) { 479 468 continue; 480 469 } … … 488 477 489 478 // For variations: terms live on the parent product, not the variation post itself 490 if ( 'variation' === $product_type ) {491 $parent_id = wp_get_post_parent_id( $row->ID);492 if ( $parent_id ) {479 if ('variation' === $product_type ) { 480 $parent_id = wp_get_post_parent_id($row->ID); 481 if ($parent_id ) { 493 482 $term_source_id = $parent_id; 494 483 } … … 496 485 497 486 // Get categories (ALWAYS - critical for product structure) 498 $categories = wp_get_post_terms( $term_source_id, 'product_cat');487 $categories = wp_get_post_terms($term_source_id, 'product_cat'); 499 488 $formatted_row['categories'] = []; 500 if ( ! is_wp_error( $categories ) && ! empty( $categories) ) {489 if (! is_wp_error($categories) && ! empty($categories) ) { 501 490 foreach ( $categories as $category ) { 502 if ( 'uncategorized' === $category->slug ) {491 if ('uncategorized' === $category->slug ) { 503 492 continue; 504 493 } … … 511 500 512 501 // Get tags (ALWAYS - critical for product structure) 513 $tags = wp_get_post_terms( $term_source_id, 'product_tag');502 $tags = wp_get_post_terms($term_source_id, 'product_tag'); 514 503 $formatted_row['tags'] = []; 515 if ( ! is_wp_error( $tags ) && ! empty( $tags) ) {504 if (! is_wp_error($tags) && ! empty($tags) ) { 516 505 foreach ( $tags as $tag ) { 517 506 $formatted_row['tags'][] = [ … … 523 512 524 513 // Get brands (ALWAYS - critical for product structure) 525 $brands = wp_get_post_terms( $term_source_id, 'product_brand');514 $brands = wp_get_post_terms($term_source_id, 'product_brand'); 526 515 $formatted_row['brands'] = []; 527 if ( ! is_wp_error( $brands ) && ! empty( $brands) ) {516 if (! is_wp_error($brands) && ! empty($brands) ) { 528 517 foreach ( $brands as $brand ) { 529 518 $formatted_row['brands'][] = [ … … 535 524 536 525 // Get all custom meta data (only if allowed) 537 if ( $allow_all || in_array( '_custom_fields', $allowed_fields ) || in_array( 'custom_meta', $allowed_fields) ) {526 if ($allow_all || in_array('_custom_fields', $allowed_fields) || in_array('custom_meta', $allowed_fields) ) { 538 527 $woo_meta_keys_to_skip = [ 539 528 'chosen_product_cat', '_sku', '_price', '_regular_price', '_sale_price', '_stock', '_stock_status', … … 550 539 ); 551 540 552 return $formatted_data; 553 } 554 } 555 556 if ( ! function_exists( 'multissaf_get_product_by_ids' ) ) { 557 function multissaf_get_product_by_ids( $prduct_ids, $permissions = [] ) { 558 defined( 'ABSPATH' ) || exit; 559 560 if ( ! empty( $permissions ) && isset( $permissions['fields'] ) ) { 541 return $formatted_data; 542 } 543 } 544 545 if (! function_exists('multissaf_get_product_by_ids') ) { 546 /** 547 * @param int[] $prduct_ids Product IDs. 548 * @param array $permissions Token permissions (fields, and scope when $apply_scope). 549 * @param bool $apply_scope Keep only the variations inside the token's scope. 550 */ 551 function multissaf_get_product_by_ids( $prduct_ids, $permissions = [], $apply_scope = false ) 552 { 553 defined('ABSPATH') || exit; 554 555 if (! empty($permissions) && isset($permissions['fields']) ) { 561 556 $formatted_data = multissaf_get_formatted_data_with_permissions($prduct_ids, 0, 0, ['product'], $permissions); 562 557 } else { … … 573 568 574 569 $child_ids = multissaf_get_children_id($product_id); 570 if ($apply_scope && function_exists('multissaf_filter_ids_by_token_scope') ) { 571 $child_ids = multissaf_filter_ids_by_token_scope($child_ids, $permissions); 572 } 575 573 if (!empty($child_ids)) { 576 574 // Fetch and format variation data 577 if ( ! empty( $permissions ) && isset( $permissions['fields']) ) {575 if (! empty($permissions) && isset($permissions['fields']) ) { 578 576 $formatted_data_variation = multissaf_get_formatted_data_with_permissions($child_ids, 0, 0, ['product_variation'], $permissions); 579 577 } else { … … 594 592 } 595 593 596 if ( ! function_exists( 'multissaf_get_batch_products' ) ) { 597 function multissaf_get_batch_products( $offset, $batch_size, $permissions = [] ) { 598 defined( 'ABSPATH' ) || exit; 594 if (! function_exists('multissaf_count_ids_without_sku') ) { 595 /** 596 * Counts the given products or variations (not trashed) that have no SKU of their own. 597 * They are not sent for import, since products are matched by SKU. 598 * 599 * @param int[] $ids Product or variation IDs. 600 * @param string $post_type 'product' or 'product_variation'. 601 * @return int 602 */ 603 function multissaf_count_ids_without_sku( $ids, $post_type ) 604 { 605 defined('ABSPATH') || exit; 606 607 $ids = array_values(array_filter(array_map('absint', (array) $ids))); 608 if (empty($ids) ) { 609 return 0; 610 } 611 612 global $wpdb; 613 $placeholders = implode(',', array_fill(0, count($ids), '%d')); 614 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared 615 return (int) $wpdb->get_var( 616 $wpdb->prepare( 617 "SELECT COUNT(*) FROM {$wpdb->posts} p 618 WHERE p.ID IN ($placeholders) AND p.post_type = %s AND p.post_status <> 'trash' 619 AND NOT EXISTS ( SELECT 1 FROM {$wpdb->postmeta} pm WHERE pm.post_id = p.ID AND pm.meta_key = '_sku' AND pm.meta_value <> '' )", 620 array_merge($ids, [ $post_type ]) 621 ) 622 ); 623 } 624 } 625 626 if (! function_exists('multissaf_get_batch_products') ) { 627 /** 628 * @param int $offset 629 * @param int $batch_size 630 * @param array $permissions 631 * @param array|null $batch_info Filled with: scanned (parent products read on this page, to 632 * advance the offset), products_no_sku, variations_no_sku. 633 */ 634 function multissaf_get_batch_products( $offset, $batch_size, $permissions = [], &$batch_info = null ) 635 { 636 defined('ABSPATH') || exit; 637 638 $batch_info = [ 'scanned' => 0, 'products_no_sku' => 0, 'variations_no_sku' => 0 ]; 599 639 600 640 // Unwrap nested permissions if necessary. 601 if ( isset( $permissions['permissions'] ) && is_array( $permissions['permissions']) ) {641 if (isset($permissions['permissions']) && is_array($permissions['permissions']) ) { 602 642 $permissions = $permissions['permissions']; 603 643 } 604 644 605 645 $prduct_ids = null; 606 $requested_variation_ids = []; 607 608 if ( ! empty( $permissions ) && function_exists( 'multissaf_get_raw_data_by_token' ) ) { 609 610 $raw_filtered = multissaf_get_raw_data_by_token( null, $offset, $batch_size, ['product'], $permissions);611 612 if ( empty( $raw_filtered) ) {646 647 if (! empty($permissions) && function_exists('multissaf_get_raw_data_by_token') ) { 648 649 // Paged fetch of the parent products inside the token's scope. 650 $raw_filtered = multissaf_get_raw_data_by_token(null, $offset, $batch_size, ['product'], $permissions); 651 652 if (empty($raw_filtered) ) { 613 653 return []; 614 654 } 615 655 $filtered_ids = []; 616 656 foreach ( $raw_filtered as $raw_product ) { 617 if ( isset( $raw_product->ID) ) {657 if (isset($raw_product->ID) ) { 618 658 $filtered_ids[] = $raw_product->ID; 619 659 } 620 660 } 621 661 $prduct_ids = $filtered_ids; 662 $batch_info['scanned'] = count($filtered_ids); 663 $batch_info['products_no_sku'] = multissaf_count_ids_without_sku($filtered_ids, 'product'); 622 664 623 665 // Since we already paginated at the raw-data level, fetch exact IDs. … … 626 668 } 627 669 628 if ( ! empty( $permissions ) && isset( $permissions['fields']) ) {670 if (! empty($permissions) && isset($permissions['fields']) ) { 629 671 $formatted_data = multissaf_get_formatted_data_with_permissions($prduct_ids, $offset, $batch_size, ['product'], $permissions); 630 672 } else { … … 633 675 634 676 $new_format_store = []; 677 678 if (null === $prduct_ids ) { 679 // Without permissions the page itself only holds products with a SKU. 680 $batch_info['scanned'] = is_array($formatted_data) ? count($formatted_data) : 0; 681 } 635 682 636 683 if (is_array($formatted_data) && !empty($formatted_data)) { … … 642 689 // Get child IDs of the variable product 643 690 $child_ids = multissaf_get_children_id($product_id); 644 // If specific variation SKUs were requested, restrict to only those.645 if ( ! empty( $requested_variation_ids) ) {646 $child_ids = array_values( array_intersect( (array) $child_ids, $requested_variation_ids ));691 // Keep only the variations inside the token's scope. 692 if (! empty($permissions) ) { 693 $child_ids = multissaf_filter_ids_by_token_scope($child_ids, $permissions); 647 694 } 648 695 if (!empty($child_ids)) { 696 $batch_info['variations_no_sku'] += multissaf_count_ids_without_sku($child_ids, 'product_variation'); 649 697 // Fetch and format variation data 650 if ( ! empty( $permissions ) && isset( $permissions['fields']) ) {698 if (! empty($permissions) && isset($permissions['fields']) ) { 651 699 $formatted_data_variation = multissaf_get_formatted_data_with_permissions($child_ids, 0, 0, ['product_variation'], $permissions); 652 700 } else { … … 668 716 } 669 717 670 if ( ! function_exists( 'multissaf_get_children_id' ) ) { 671 function multissaf_get_children_id( $product_id ) { 672 defined( 'ABSPATH' ) || exit; 718 if (! function_exists('multissaf_get_children_id') ) { 719 function multissaf_get_children_id( $product_id ) 720 { 721 defined('ABSPATH') || exit; 673 722 674 723 global $wpdb; … … 688 737 } 689 738 690 if ( ! function_exists( 'multissaf_get_column_callback' ) ) { 691 function multissaf_get_column_callback( $value, $key, $row ) { 692 693 defined( 'ABSPATH' ) || exit; 739 if (! function_exists('multissaf_get_column_callback') ) { 740 function multissaf_get_column_callback( $value, $key, $row ) 741 { 742 743 defined('ABSPATH') || exit; 694 744 695 745 if ('_stock' === $key ) { … … 835 885 836 886 837 if ( ! function_exists( 'multissaf_get_product_type' ) ) { 838 function multissaf_get_product_type( $product_type = 'simple' ) { 839 defined( 'ABSPATH' ) || exit; 887 if (! function_exists('multissaf_get_product_type') ) { 888 function multissaf_get_product_type( $product_type = 'simple' ) 889 { 890 defined('ABSPATH') || exit; 840 891 841 892 $product_type = multissaf_get_first_term($product_type); 842 893 $types = [ 843 'simple' => __('simple', 'multi-store-sync-addon-for-flexstock'),844 'variable' => __('variable', 'multi-store-sync-addon-for-flexstock'),845 'grouped' => __('grouped', 'multi-store-sync-addon-for-flexstock'),846 'external' => __('external', 'multi-store-sync-addon-for-flexstock'),894 'simple' => __('simple', 'multi-store-sync-addon-for-flexstock'), 895 'variable' => __('variable', 'multi-store-sync-addon-for-flexstock'), 896 'grouped' => __('grouped', 'multi-store-sync-addon-for-flexstock'), 897 'external' => __('external', 'multi-store-sync-addon-for-flexstock'), 847 898 ]; 848 899 … … 852 903 853 904 854 if ( ! function_exists( 'multissaf_get_first_term' ) ) { 855 function multissaf_get_first_term( $categories = '0' ) { 856 defined( 'ABSPATH' ) || exit; 905 if (! function_exists('multissaf_get_first_term') ) { 906 function multissaf_get_first_term( $categories = '0' ) 907 { 908 defined('ABSPATH') || exit; 857 909 858 910 if ($categories !== null) {//phpcs:ignore … … 867 919 868 920 869 if ( ! function_exists( 'multissaf_colleciton_of_change_key' ) ) { 870 function multissaf_colleciton_of_change_key( $key ) { 871 defined( 'ABSPATH' ) || exit; 921 if (! function_exists('multissaf_colleciton_of_change_key') ) { 922 function multissaf_colleciton_of_change_key( $key ) 923 { 924 defined('ABSPATH') || exit; 872 925 873 926 $change_key = [ 874 'ID' => 'id',875 'post_title' => 'title',876 'post_content' => 'description',877 'post_excerpt' => 'short_description',878 'product_type' => 'type',879 '_price' => 'price',880 '_sku' => 'sku',881 '_stock' => 'stock_quantity',882 '_stock_status' => 'stock_status',883 'post_status' => 'status',884 '_regular_price' => 'regular_price',885 '_sale_price' =>'sale_price',886 '_product_attributes' => 'attributes',887 '_thumbnail_id' => 'featured_image_url',888 '_product_image_gallery' => 'gallery_image_urls'927 'ID' => 'id', 928 'post_title' => 'title', 929 'post_content' => 'description', 930 'post_excerpt' => 'short_description', 931 'product_type' => 'type', 932 '_price' => 'price', 933 '_sku' => 'sku', 934 '_stock' => 'stock_quantity', 935 '_stock_status' => 'stock_status', 936 'post_status' => 'status', 937 '_regular_price' => 'regular_price', 938 '_sale_price' =>'sale_price', 939 '_product_attributes' => 'attributes', 940 '_thumbnail_id' => 'featured_image_url', 941 '_product_image_gallery' => 'gallery_image_urls' 889 942 ]; 890 943 return isset($change_key[$key]) ? $change_key[$key] : $key; … … 893 946 894 947 895 if ( ! function_exists( 'multissaf_get_variation_attributes_simple' ) ) { 896 function multissaf_get_variation_attributes_simple( $variation_id ) { 897 defined( 'ABSPATH' ) || exit; 948 if (! function_exists('multissaf_get_variation_attributes_simple') ) { 949 function multissaf_get_variation_attributes_simple( $variation_id ) 950 { 951 defined('ABSPATH') || exit; 898 952 899 953 global $wpdb; -
multi-store-sync-addon-for-flexstock/tags/1.1.0/includes/classes/functions.php
r3706977 r3752558 86 86 /** 87 87 * Returns child sites from ssgsw_multi_store_list with valid url+token. 88 * Free plugin: limited to 1 entry by design (no license check) 89 * Pro plugin: can return all via 'ssgsw_multistore_access' filter 88 * Only entries with both a url and a token are returned. 90 89 * 91 90 * @return array[] Each entry: {url, token, tab_name, sheet_gid, isConnected, ...} … … 96 95 $all_option = get_option( 'ssgsw_multi_store_list' ); 97 96 97 98 98 if ( ! $all_option ) { 99 99 return []; … … 106 106 ); 107 107 $filtered_options = array_values( $filtered_options ); 108 109 if ( empty( $filtered_options ) ) {110 return [];111 }112 108 113 109 return $filtered_options; … … 168 164 169 165 /** 166 * SQL conditions for a token's scope: '' for all products; other scopes come from the 167 * 'multissaf_token_scope_sql' filter, and match no products when it is not handled. 168 * 169 * @param array $permissions Token permissions (include_type and the scope settings). 170 * @param string $table Table name or alias of wp_posts in the outer query. 171 * @return string SQL starting with " AND", or '' for all products. 172 */ 173 if ( ! function_exists( 'multissaf_get_token_scope_sql' ) ) { 174 function multissaf_get_token_scope_sql( $permissions, $table ) { 175 $permissions = (array) $permissions; 176 $include_type = isset( $permissions['include_type'] ) ? $permissions['include_type'] : 'all'; 177 178 if ( $include_type === 'all' ) { 179 return ''; 180 } 181 182 /** 183 * SQL conditions for a token scope other than All Products. 184 * 185 * @param string|null $sql null when not handled; otherwise SQL starting with " AND", 186 * built with $wpdb->prepare(). 187 * @param array $permissions Token permissions. 188 * @param string $table Table name or alias of wp_posts in the outer query. 189 * @param string $include_type Normalized scope type. 190 */ 191 $sql = apply_filters( 'multissaf_token_scope_sql', null, $permissions, $table, $include_type ); 192 193 return is_string( $sql ) ? $sql : ' AND 1=0'; 194 } 195 } 196 197 /** 198 * Keeps only the IDs that fall inside a token's scope, in their original order. 199 * 200 * @param int[] $ids Product or variation IDs. 201 * @param array $permissions Token permissions. 202 * @return int[] 203 */ 204 if ( ! function_exists( 'multissaf_filter_ids_by_token_scope' ) ) { 205 function multissaf_filter_ids_by_token_scope( $ids, $permissions ) { 206 global $wpdb; 207 208 $ids = array_values( array_filter( array_map( 'absint', (array) $ids ) ) ); 209 if ( empty( $ids ) ) { 210 return []; 211 } 212 213 $scope_sql = multissaf_get_token_scope_sql( $permissions, 'p' ); 214 if ( '' === $scope_sql ) { 215 return $ids; 216 } 217 218 $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) ); 219 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $scope_sql is built with $wpdb->prepare(). 220 $in_scope = $wpdb->get_col( $wpdb->prepare( "SELECT p.ID FROM {$wpdb->posts} p WHERE p.ID IN ($placeholders)", $ids ) . $scope_sql ); 221 $in_scope = array_map( 'intval', (array) $in_scope ); 222 223 return array_values( array_intersect( $ids, $in_scope ) ); 224 } 225 } 226 227 /** 170 228 * Token-permission-aware raw data query. Extends multissaf_get_raw_data() 171 * with field selection (all products included).229 * with the token scope from multissaf_get_token_scope_sql(). 172 230 * Uses Column class to resolve allowed fields. 173 231 * 174 * Connected from: multissaf_get_ formatted_data_by_token(),175 * multissaf_get_batch_products _by_token()232 * Connected from: multissaf_get_batch_products_by_token(), 233 * multissaf_get_batch_products() 176 234 * 177 235 * @param int[]|null $ids … … 179 237 * @param int $batch_size 180 238 * @param string[] $product_types 181 * @param array $parmision_filds Resolved permissions (include_type, cats, tags, fields).239 * @param array $parmision_filds Resolved permissions (include_type, cats, tags, skus, fields, attribute_filters). 182 240 * @return object[]|string 183 241 */ … … 276 334 } 277 335 278 // All products included in sync permissions (no category/tag/SKU restrictions). 336 // Product scope from the token (all / by_category / by_sku). 337 $query .= multissaf_get_token_scope_sql( $parmision_filds, $main_table ); 279 338 280 339 // Always exclude trashed products from token-filtered exports. … … 300 359 $order = strtoupper( $order_raw ) === 'DESC' ? 'DESC' : 'ASC'; 301 360 302 // FIX (Layer 1): Add ID as secondary sort key to guarantee deterministic pagination. 303 // When products share the same post_date, MySQL doesn't guarantee stable order across 304 // separate queries. Adding ID (primary key) ensures the same offset always returns the 305 // same row, preventing products from appearing at multiple offsets or being skipped entirely. 361 // ID as secondary sort key keeps paging stable when products share a post_date. 306 362 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared 307 363 $query .= " ORDER BY {$main_table}.{$order_by} {$order}, {$main_table}.ID ASC"; … … 320 376 } 321 377 322 if ( ! function_exists( 'multissaf_get_formatted_data_by_token' ) ) {323 function multissaf_get_formatted_data_by_token( $ids = null, $offset = 0, $batch_size = 0, array $product_type = [ 'product', 'product_variation' ], $keys = [], $parmision_filds = [] ) {324 defined( 'ABSPATH' ) || exit;325 326 // Identify allowed fields from permissions327 $allowed_fields = isset( $parmision_filds['fields'] ) ? (array) $parmision_filds['fields'] : ( is_array( $parmision_filds ) && ! empty( $parmision_filds ) && ! isset( $parmision_filds['scope'] ) && ! isset( $parmision_filds['include_type'] ) ? $parmision_filds : [ 'all_fields' ] );328 $allow_all = in_array( 'all_fields', $allowed_fields ) || empty( $allowed_fields );329 330 if (is_array($keys) && empty($keys)) {331 $column_obj = class_exists('\StockSyncWithGoogleSheetForWooCommerce\Column') ? new \StockSyncWithGoogleSheetForWooCommerce\Column() : null;332 $keys = $column_obj ? $column_obj->get_column_keys() : [];333 }334 335 336 // Merge permission fields into keys if any are missing (avoid adding 'all_fields' as a literal key)337 if ( ! empty( $allowed_fields ) && ! in_array( 'all_fields', $allowed_fields ) ) {338 $keys = array_unique( array_merge( $keys, $allowed_fields ) );339 }340 341 // Create sanitized keys for property access without mutating original keys342 $sanitized_keys = [];343 foreach ( $keys as $key ) {344 $sanitized_keys[ $key ] = str_replace([ '-', ' ', ',', '.', '?' ], '_', $key);345 }346 347 348 $raw_data = multissaf_get_raw_data_by_token($ids, $offset, $batch_size, $product_type, $parmision_filds);349 350 if (! is_array($raw_data) ) {351 return false;352 }353 if (! $raw_data || (is_array($raw_data) && count($raw_data) === 0) ) {354 return false;355 }356 357 $formatted_data = array_map(358 function ( $row ) use ( $keys, $sanitized_keys, $allowed_fields, $allow_all ) {359 $formatted_row = [];360 361 foreach ( $keys as $key ) {362 // Check if field is allowed (always allow ID)363 if ( ! $allow_all && ! in_array( $key, $allowed_fields ) && 'ID' !== $key ) {364 continue;365 }366 367 if ( '_stock_status' === $key ) {368 continue;369 }370 371 $s_key = $sanitized_keys[ $key ];372 $cell_value = apply_filters( 'ssgs_get_column', $row->$s_key, $key, $row );373 374 // If the value is an array (like nested attributes), return an empty string375 $formatted_row[ $key ] = is_array($cell_value) ? '' : $cell_value;376 }377 return array_values($formatted_row);378 }, $raw_data379 );380 return $formatted_data;381 }382 }383 384 378 /** 385 379 * Builds the flat sheet-row format (array of values, no keys) for a … … 399 393 function multissaf_get_batch_products_by_token( $ids = null, $offset = 0, $batch_size = 0, $parmision_filds = [] ) { 400 394 401 402 395 // When explicit variation IDs are requested, fetch their parents and 396 // keep only those variations in the child-fetch loop below. 403 397 $requested_variation_ids = []; 404 398 … … 449 443 $all_raw_data[] = $parent_row; 450 444 451 // Check if variable (product_type field will have term_id:name pattern from the query)445 // Check if variable (product_type is a comma list of "taxonomy:slug" for all the product's terms) 452 446 $is_variable = false; 453 447 if (isset($parent_row->product_type)) { 454 $parts = explode(':', $parent_row->product_type); 455 $type_name = isset($parts[1]) ? strtolower($parts[1]) : ''; 456 if ('variable' === $type_name) { 457 $is_variable = true; 458 } 448 $is_variable = in_array( 'product_type:variable', explode( ',', strtolower( $parent_row->product_type ) ), true ); 459 449 } 460 450 … … 476 466 } 477 467 478 // Resolve keys first (same logic as formatted_data_by_token)479 $allowed_fields = isset( $parmision_filds['fields'] ) ? (array) $parmision_filds['fields'] : ( is_array( $parmision_filds ) && ! empty( $parmision_filds ) && ! isset( $parmision_filds['scope'] ) && ! isset( $parmision_filds['include_type'] ) ? $parmision_filds : [ 'all_fields' ] );468 // Resolve allowed field keys from the permissions 469 $allowed_fields = isset( $parmision_filds['fields'] ) ? (array) $parmision_filds['fields'] : [ 'all_fields' ]; 480 470 $column_obj = class_exists('\StockSyncWithGoogleSheetForWooCommerce\Column') ? new \StockSyncWithGoogleSheetForWooCommerce\Column() : null; 481 471 $keys = $column_obj ? $column_obj->get_column_keys() : []; … … 729 719 /** 730 720 * Saves or upserts a child site entry into ssgsw_multi_store_list. 731 * Blocks adding a 2nd site when unlicensed (ssgsw_multistore_access = false). 732 * 733 * Connected from: MULTISSAF_AJAX::multissaf_request_for_connect(), 734 * MULTISSAF_AJAX::multissaf_update_site_settings() 721 * 722 * Connected from: MULTISSAF_AJAX::multissaf_update_site_settings() 735 723 * 736 724 * @param array $data Site data including 'url' (required), 'token', 'tab_name', etc. 737 * @return bool True on success, false if blocked orurl missing.725 * @return bool True on success, false if url missing. 738 726 */ 739 727 if ( ! function_exists( 'multissaf_save_child_site' ) ) { … … 769 757 770 758 if ( ! $updated ) { 771 772 759 // Add as new 773 760 $existing_sites[] = $data; … … 796 783 defined( 'ABSPATH' ) || exit; 797 784 798 // Ensure isConnected is set when parent is first saved785 // A completed add_parent handshake is an active connection. 799 786 if ( ! isset( $data['isConnected'] ) ) { 800 $data['isConnected'] = true; // Completed add_parent handshake = active connection801 } 802 803 $exits_array = multissaf_sub_site_url_parent();804 805 $ exits_array[] = $data;806 // Serialize each sub-array into a unique string.807 $serialized_array = array_map( 'serialize', $exits_array );808 809 // Use array_unique to remove duplicate serialized strings.810 $unique_serialized_array = array_unique( $serialized_array);811 812 // Unserialize the unique strings back into arrays.813 $unique_array = array_map( 'unserialize', $unique_serialized_array );814 815 // Re-index the array to have a clean, sequential order.816 $cleaned_array = array_values( $unique_array ); 817 // used in verify_connections_callback in class-api and multissaf_site_remove in class-ajax 818 update_option( 'ssgsw_multi_store_list_parent', $cleaned_array);787 $data['isConnected'] = true; 788 } 789 790 // One entry per parent URL: a reconnect replaces the old (disconnected) entry 791 // instead of adding a second one next to it. 792 $parent_url = isset( $data['url'] ) ? untrailingslashit( $data['url'] ) : ''; 793 $parents = []; 794 foreach ( (array) multissaf_sub_site_url_parent() as $site ) { 795 if ( $parent_url && isset( $site['url'] ) && untrailingslashit( $site['url'] ) === $parent_url ) { 796 $data = array_merge( $site, $data ); 797 unset( $data['status'] ); 798 continue; 799 } 800 $parents[] = $site; 801 } 802 $parents[] = $data; 803 804 // used in verify_connections_callback in class-api and multissaf_site_remove in class-ajax 805 update_option( 'ssgsw_multi_store_list_parent', array_values( $parents ) ); 819 806 } 820 807 } … … 822 809 /** 823 810 * Returns parent sites from ssgsw_multi_store_list_parent. 824 * Free plugin: limited to 1 parent by design (no license check)825 * Pro plugin: can return all via 'ssgsw_multistore_access' filter826 811 * 827 812 * @return array[] Each entry: {url, token} … … 835 820 return []; 836 821 } 837 838 839 822 840 823 return $all_option; … … 842 825 } 843 826 844 845 if ( ! function_exists( 'multissaf_multi_store_license_check' ) ) {846 function multissaf_multi_store_license_check() {847 defined( 'ABSPATH' ) || exit;848 global $ssgsw_multistore;849 850 if ( ! $ssgsw_multistore ) {851 return false;852 }853 854 return $ssgsw_multistore->is_valid();855 }856 }857 827 858 828 … … 860 830 function multissaf_get_body() { 861 831 defined( 'ABSPATH' ) || exit; 862 $raw_input = file_get_contents( 'php://input' ); 863 if ( ! empty( $raw_input ) ) { 864 $inputs = json_decode( $raw_input, true ); 865 if ( is_array( $inputs ) ) { 866 return (object) multissaf_sanitize( $inputs ); 867 } 868 } 869 870 return (object) multissaf_sanitize( $_POST ); 832 $inputs = json_decode( file_get_contents( 'php://input' ), true ); 833 834 if ( is_array( $inputs ) ) { 835 return (object) multissaf_sanitize( $inputs ); 836 } 837 838 return (object) []; 871 839 } 872 840 } … … 875 843 function multissaf_sanitize( $array ) { //phpcs:ignore 876 844 defined( 'ABSPATH' ) || exit; 877 if ( ! is_array( $array ) ) {878 return is_string( $array ) ? sanitize_text_field( $array ) : $array;879 }880 845 foreach ( $array as $key => $value ) { 881 846 if ( is_array( $value ) ) { 882 847 $array[ $key ] = multissaf_sanitize( $value ); 883 } else if ( is_string( $value ) ){848 } else { 884 849 $array[ $key ] = sanitize_text_field( $value ); 885 } else {886 $array[ $key ] = $value;887 850 } 888 851 } … … 992 955 993 956 994 if ( ! function_exists( 'multissaf_get_product_data_batch' ) ) {995 function multissaf_get_product_data_batch( $offset, $limit ) {996 defined( 'ABSPATH' ) || exit;997 998 $args = array(999 'post_type' => array('product'),1000 'posts_per_page' => $limit,1001 'offset' => $offset,1002 'post_status' => 'any',1003 'meta_query' => array(1004 array(1005 'key' => '_sku',1006 'compare' => 'EXISTS',1007 ),1008 ),1009 );1010 1011 $products = new WP_Query($args);1012 $data = [];1013 1014 if ($products->have_posts()) {1015 foreach ($products->posts as $product_post) {1016 $product = wc_get_product($product_post->ID);1017 1018 if (!$product) {1019 continue;1020 }1021 1022 // Get product data1023 $product_data = [1024 'id' => $product->get_id(),1025 'title' => $product->get_name(),1026 'description' => $product->get_description(),1027 'short_description' => $product->get_short_description(),1028 'type' => $product->get_type(),1029 'sku' => $product->get_sku(),1030 'status' => $product->get_status(),1031 'regular_price' => $product->get_regular_price(),1032 'sale_price' => $product->get_sale_price(),1033 'stock_quantity' => $product->get_stock_quantity(),1034 'stock_status' => $product->get_stock_status(),1035 ];1036 1037 // Get featured image URL1038 $image_id = $product->get_image_id();1039 $product_data['featured_image_url'] = $image_id ? wp_get_attachment_url($image_id) : '';1040 1041 // Get gallery image URLs1042 $gallery_ids = $product->get_gallery_image_ids();1043 $product_data['gallery_image_urls'] = array_map('wp_get_attachment_url', $gallery_ids);1044 1045 // Get categories & tags1046 $categories = wp_get_post_terms($product->get_id(), 'product_cat');1047 $product_data['categories'] = [];1048 foreach ($categories as $category) {1049 $product_data['categories'][] = [1050 'name' => $category->name,1051 'slug' => $category->slug,1052 ];1053 }1054 1055 $tags = wp_get_post_terms($product->get_id(), 'product_tag');1056 $product_data['tags'] = [];1057 foreach ($tags as $tag) {1058 $product_data['tags'][] = [1059 'name' => $tag->name,1060 'slug' => $tag->slug,1061 ];1062 }1063 $brands = wp_get_post_terms($product->get_id(), 'product_brand'); // You may need to change 'product_brand' to your actual brand taxonomy slug1064 $product_data['brands'] = [];1065 foreach ($brands as $brand) {1066 $product_data['brands'][] = [1067 'name' => $brand->name,1068 'slug' => $brand->slug,1069 ];1070 }1071 // Get attributes1072 $product_data['attributes'] = [];1073 1074 $product_id = $product->get_id();1075 1076 // First: Try to get from _product_attributes post meta1077 $product_attributes = get_post_meta($product_id, '_product_attributes', true);1078 $product_attributes = maybe_unserialize($product_attributes);1079 1080 if (!empty($product_attributes) && is_array($product_attributes)) {1081 foreach ($product_attributes as $attribute_name => $attribute) {1082 $attribute_label = wc_attribute_label($attribute_name);1083 $is_variations = isset($attribute['is_variation']) ? $attribute['is_variation'] : false;1084 $is_visible = isset($attribute['is_visible']) ? $attribute['is_visible'] : false;1085 $attribute_value = '';1086 1087 if (!empty($attribute['is_taxonomy'])) {1088 // Handle global attribute (taxonomy)1089 $terms = wp_get_post_terms($product_id, $attribute_name, ['fields' => 'names']);1090 if (!is_wp_error($terms) && !empty($terms)) {1091 $attribute_value = implode(' | ', $terms);1092 }1093 } elseif (!empty($attribute['value'])) {1094 // Handle custom attribute (non-taxonomy)1095 $options = array_map('trim', explode('|', $attribute['value']));1096 $attribute_value = implode(' | ', $options);1097 }1098 1099 if (!empty($attribute_value)) {1100 $product_data['attributes'][] = [1101 'slug' => $attribute_name,1102 'name' => $attribute_label,1103 'value' => $attribute_value,1104 'is_variations' => $is_variations,1105 'is_visible' => $is_visible1106 ];1107 }1108 }1109 }1110 1111 $woo_meta_keys_to_skip = [1112 'chosen_product_cat',1113 '_sku',1114 '_price',1115 '_regular_price',1116 '_sale_price',1117 '_stock',1118 '_stock_status',1119 '_thumbnail_id',1120 '_product_image_gallery',1121 '_upsell_ids',1122 '_crosssell_ids',1123 '_featured',1124 ];1125 1126 // Get all custom meta data1127 $all_meta = get_post_meta($product->get_id());1128 1129 $product_data['custom_meta'] = array_diff_key($all_meta, array_flip($woo_meta_keys_to_skip));1130 1131 // Get variations (if a variable product)1132 $product_data['variations'] = [];1133 if ($product->is_type('variable')) {1134 foreach ($product->get_children() as $child_id) {1135 $variation = wc_get_product($child_id);1136 $variation_image_id = $variation->get_image_id();1137 $variation_image_url = $variation_image_id ? wp_get_attachment_url($variation_image_id) : '';1138 1139 $all_variation_meta = get_post_meta($variation->get_id());1140 $variation_custom_meta = array_diff_key($all_variation_meta, array_flip($woo_meta_keys_to_skip));1141 1142 $product_data['variations'][] = [1143 'id' => $variation->get_id(),1144 'sku' => $variation->get_sku(),1145 'regular_price' => $variation->get_regular_price(),1146 'sale_price' => $variation->get_sale_price(),1147 'price' => $variation->get_price(),1148 'stock_quantity' => $variation->get_stock_quantity(),1149 'stock_status' => $variation->get_stock_status(),1150 'attributes' => $variation->get_attributes(),1151 'image_url' => $variation_image_url,1152 'custom_meta' => $variation_custom_meta,1153 ];1154 }1155 }1156 1157 $data[] = $product_data;1158 }1159 }1160 1161 // Memory cleanup - Add these lines here.1162 wp_reset_postdata();1163 1164 // Store data before unsetting variables.1165 $result = $data;1166 1167 // Clean up memory.1168 unset( $products, $data, $product_post, $product, $all_meta, $product_attributes );1169 1170 return $result;1171 }1172 }1173 1174 957 if ( ! function_exists( 'multissaf_get_sub_product_count' ) ) { 1175 958 function multissaf_get_sub_product_count( $url, $token, $site ) { … … 1241 1024 $data = multissaf_get_access_data_by_token( $matched_token_data ); 1242 1025 if ( is_array( $data ) && isset( $data['count'] ) ) { 1243 return intval( $data['count'] ); 1026 // Stay within the token's product count (0 = all). 1027 $limit = multissaf_get_token_max_products( $matched_token_data ); 1028 return $limit ? min( intval( $data['count'] ), $limit ) : intval( $data['count'] ); 1244 1029 } 1245 1030 } … … 1264 1049 function multissaf_single_product_update( $api_url, $body_data, $bearer_token ) { 1265 1050 1266 // No global time limit — single product API call is bounded by wp_remote_post timeout. 1051 // Extend timeout only immediately before this HTTP request 1052 if ( function_exists( 'set_time_limit' ) ) { 1053 @set_time_limit( 120 ); 1054 } 1055 1267 1056 // Ensure the token and body data are not empty. 1268 1057 if ( empty( $api_url ) || empty( $bearer_token ) ) { … … 1310 1099 // Check for a WordPress error. 1311 1100 if ( is_wp_error( $response ) ) { 1312 return false; // Re turn the WP_Error object.1101 return false; // Request failed. 1313 1102 } 1314 1103 // Get the HTTP status code from the response. … … 1359 1148 // Check for a WordPress error. 1360 1149 if ( is_wp_error( $response ) ) { 1361 return false; // Re turn the WP_Error object.1150 return false; // Request failed. 1362 1151 } 1363 1152 // Get the HTTP status code from the response. … … 1409 1198 // Check for a WordPress error. 1410 1199 if ( is_wp_error( $response ) ) { 1411 return false; // Re turn the WP_Error object.1200 return false; // Request failed. 1412 1201 } 1413 1202 // Get the HTTP status code from the response. … … 1584 1373 } 1585 1374 1586 // Optionally apply the limit.1375 // Allow the count to be filtered. 1587 1376 if ( $product_count > 100 ) { 1588 1377 $limit = apply_filters( 'ssgsw_product_limit', 100 ); … … 1593 1382 1594 1383 return $product_count; 1595 }1596 }1597 1598 if ( ! function_exists( 'multissaf_get_parent_product_ids' ) ) {1599 function multissaf_get_parent_product_ids( array $product_ids ): array {1600 defined( 'ABSPATH' ) || exit;1601 if ( empty( $product_ids ) ) {1602 return [];1603 }1604 1605 global $wpdb;1606 1607 // Sanitize the product IDs to prevent SQL injection.1608 $sanitized_ids = array_map( 'intval', $product_ids );1609 $placeholders = implode( ',', array_fill( 0, count( $sanitized_ids ), '%d' ) );1610 1611 // SQL query to find only product IDs with post_type 'product'.1612 $parent_products = $wpdb->get_col(1613 $wpdb->prepare(1614 // SQL Query String as the first argument.1615 "SELECT ID FROM {$wpdb->posts} WHERE ID IN ({$placeholders}) AND post_type = 'product'",1616 // Splat operator (...) passes array elements as individual arguments.1617 ...$sanitized_ids1618 )1619 );1620 $unique_parent_ids = array_unique( array_map( 'intval', $parent_products ) );1621 1622 // Sort the IDs for consistent processing.1623 sort( $unique_parent_ids );1624 1625 return $unique_parent_ids;1626 1384 } 1627 1385 } … … 1660 1418 } 1661 1419 return false; 1420 } 1421 } 1422 1423 /** 1424 * FlexStock's ssgsw_format_headers_with_sheet_header() (sheet headers first, then the other 1425 * column labels, mapped to column keys), with the same logic for FlexStock versions without it. 1426 * 1427 * @param string[] $sheet_headers Current sheet header row. 1428 * @param array $colmuns FlexStock columns (key => ['label' => ...]). 1429 * @return array [ column keys, header labels ] 1430 */ 1431 if ( ! function_exists( 'multissaf_format_headers_with_sheet_header' ) ) { 1432 function multissaf_format_headers_with_sheet_header( $sheet_headers, $colmuns ) { 1433 if ( function_exists( 'ssgsw_format_headers_with_sheet_header' ) ) { 1434 return ssgsw_format_headers_with_sheet_header( $sheet_headers, $colmuns ); 1435 } 1436 $format_column = array_flip( array_map( function ( $value ) { 1437 return $value['label']; 1438 }, $colmuns ) ); 1439 $formatted_data = []; 1440 $column_lookup = $format_column; 1441 foreach ( (array) $sheet_headers as $header ) { 1442 if ( isset( $column_lookup[ $header ] ) ) { 1443 $formatted_data[] = $header; 1444 unset( $column_lookup[ $header ] ); 1445 } 1446 } 1447 $formatted_data = array_merge( $formatted_data, array_keys( $column_lookup ) ); 1448 $keys = []; 1449 foreach ( $formatted_data as $key => $value ) { 1450 $keys[ $key ] = str_replace( [ ' ', ',', '.', '?' ], '_', $format_column[ $value ] ); 1451 } 1452 return [ $keys, $formatted_data ]; 1453 } 1454 } 1455 1456 /** 1457 * Reads a FlexStock feature flag. Uses FlexStock's own flags when the installed version 1458 * has them; older FlexStock versions do not, so the same defaults apply. 1459 * 1460 * @param string $feature Flag name, e.g. 'enable_cron_sync' or 'max_retry_count'. 1461 * @return mixed Flag value, or false when unknown. 1462 */ 1463 if ( ! function_exists( 'multissaf_get_feature_flag' ) ) { 1464 function multissaf_get_feature_flag( $feature ) { 1465 if ( function_exists( 'ssgsw_get_feature_flags' ) ) { 1466 $flags = ssgsw_get_feature_flags(); 1467 } else { 1468 $flags = array_merge( 1469 [ 1470 'enable_cron_sync' => false, // Direct sync instead of cron. 1471 'max_retry_count' => 3, 1472 ], 1473 (array) get_option( 'ssgsw_feature_flags', [] ) 1474 ); 1475 } 1476 return isset( $flags[ $feature ] ) ? $flags[ $feature ] : false; 1477 } 1478 } 1479 1480 /** 1481 * Product count set on a token by the store using it (0 = all products). 1482 * 1483 * @param array|string $token_data Token record from ssgsw_child_tokens, or the token string. 1484 * @return int 1485 */ 1486 if ( ! function_exists( 'multissaf_get_token_max_products' ) ) { 1487 function multissaf_get_token_max_products( $token_data ) { 1488 if ( is_string( $token_data ) ) { 1489 $token = $token_data; 1490 $token_data = null; 1491 foreach ( (array) get_option( 'ssgsw_child_tokens', [] ) as $entry ) { 1492 if ( isset( $entry['token'] ) && '' !== $token && $token === $entry['token'] ) { 1493 $token_data = $entry; 1494 break; 1495 } 1496 } 1497 } 1498 return ( is_array( $token_data ) && ! empty( $token_data['max_products'] ) ) ? absint( $token_data['max_products'] ) : 0; 1499 } 1500 } 1501 1502 /** 1503 * The first N parent products in a token's scope, in the same order as the import. 1504 * 1505 * @param array $permissions Token permissions. 1506 * @param int $limit Number of products. 1507 * @return int[] 1508 */ 1509 if ( ! function_exists( 'multissaf_get_token_limited_ids' ) ) { 1510 function multissaf_get_token_limited_ids( $permissions, $limit ) { 1511 global $wpdb; 1512 $limit = absint( $limit ); 1513 if ( ! $limit ) { 1514 return []; 1515 } 1516 $allowed_columns = [ 'post_date', 'post_modified', 'post_title', 'ID', 'menu_order' ]; 1517 $order_by_raw = apply_filters( 'multissaf_product_order_by', 'post_date' ); 1518 $order_raw = apply_filters( 'multissaf_product_order', 'ASC' ); 1519 $order_by = in_array( $order_by_raw, $allowed_columns, true ) ? $order_by_raw : 'post_date'; 1520 $order = strtoupper( $order_raw ) === 'DESC' ? 'DESC' : 'ASC'; 1521 $table = $wpdb->posts; 1522 1523 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- scope SQL is built with $wpdb->prepare(). 1524 $ids = $wpdb->get_col( 1525 "SELECT $table.ID FROM $table WHERE $table.post_type = 'product' AND $table.post_status = 'publish'" 1526 . multissaf_get_token_scope_sql( $permissions, $table ) 1527 . " ORDER BY $table.$order_by $order, $table.ID ASC" 1528 . $wpdb->prepare( ' LIMIT %d', $limit ) 1529 ); 1530 return array_map( 'intval', (array) $ids ); 1531 } 1532 } 1533 1534 /** 1535 * Finds a connected store (destination side) by URL. Matches the URL used to connect 1536 * it or the store's own home URL (`source_url`, saved during the connection handshake), 1537 * compared without scheme, "www." and trailing slash. 1538 * 1539 * @param string $url Store URL, e.g. refarance_shop from an update. 1540 * @return array|null The store entry from ssgsw_multi_store_list. 1541 */ 1542 if ( ! function_exists( 'multissaf_find_child_site' ) ) { 1543 function multissaf_find_child_site( $url ) { 1544 $normalize = function ( $value ) { 1545 $value = strtolower( untrailingslashit( trim( (string) $value ) ) ); 1546 return preg_replace( '#^(https?://)?(www\.)?#', '', $value ); 1547 }; 1548 $target = $normalize( $url ); 1549 if ( '' === $target ) { 1550 return null; 1551 } 1552 foreach ( (array) get_option( 'ssgsw_multi_store_list', [] ) as $site ) { 1553 foreach ( [ 'url', 'source_url' ] as $key ) { 1554 if ( ! empty( $site[ $key ] ) && $normalize( $site[ $key ] ) === $target ) { 1555 return $site; 1556 } 1557 } 1558 } 1559 return null; 1560 } 1561 } 1562 1563 /** 1564 * How many new products a store's updates may still create here (null = any number). 1565 * 1566 * Connected from: API::update_single_callback() 1567 * 1568 * @param string $store_url The store's URL as sent with its updates (its home URL). 1569 * @return int|null Remaining count, or null for any number. 1570 */ 1571 if ( ! function_exists( 'multissaf_get_store_create_allowance' ) ) { 1572 function multissaf_get_store_create_allowance( $store_url ) { 1573 defined( 'ABSPATH' ) || exit; 1574 1575 $site = multissaf_find_child_site( $store_url ); 1576 if ( ! $site || ! isset( $site['max_products'] ) || '' === $site['max_products'] || null === $site['max_products'] ) { 1577 return 0; 1578 } 1579 $cap = absint( $site['max_products'] ); 1580 if ( 0 === $cap ) { 1581 return null; 1582 } 1583 1584 // Products imported from this store carry the store's remote-ID meta. 1585 $keys = []; 1586 foreach ( array_filter( [ $site['url'], isset( $site['source_url'] ) ? $site['source_url'] : '', $store_url ] ) as $url ) { 1587 foreach ( [ $url, trailingslashit( $url ), untrailingslashit( $url ) ] as $variant ) { 1588 $keys[] = '_ssgsw_remote_id_' . md5( $variant ); 1589 } 1590 } 1591 $keys = array_values( array_unique( $keys ) ); 1592 1593 global $wpdb; 1594 $placeholders = implode( ',', array_fill( 0, count( $keys ), '%s' ) ); 1595 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared 1596 $imported = (int) $wpdb->get_var( $wpdb->prepare( 1597 "SELECT COUNT(DISTINCT pm.post_id) FROM {$wpdb->postmeta} pm 1598 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id AND p.post_type = 'product' 1599 WHERE pm.meta_key IN ($placeholders)", 1600 ...$keys 1601 ) ); 1602 1603 return max( 0, $cap - $imported ); 1662 1604 } 1663 1605 } … … 1691 1633 } 1692 1634 1693 $join = ''; 1694 $where = $wpdb->prepare( " WHERE p.post_type IN (%s) AND p.post_status = %s", 'product', 'publish' ); 1695 $selected_terms = []; 1696 1635 // Same scope clauses as the product fetch, so the count matches what is imported or synced. 1636 $join = ''; 1637 $where = $wpdb->prepare( " WHERE p.post_type IN (%s) AND p.post_status = %s", 'product', 'publish' ); 1638 $scope_permissions = isset( $permissions['permissions'] ) ? (array) $permissions['permissions'] : (array) $permissions; 1639 $where .= multissaf_get_token_scope_sql( $scope_permissions, 'p' ); 1697 1640 1698 1641 $sql = "SELECT COUNT(DISTINCT p.ID) FROM {$wpdb->posts} p $join $where"; … … 1706 1649 1707 1650 // Field Logic 1708 $fields = isset( $ permissions['permissions']['fields'] ) ? (array) $permissions['permissions']['fields'] : [ 'all_fields' ];1651 $fields = isset( $scope_permissions['fields'] ) ? (array) $scope_permissions['fields'] : [ 'all_fields' ]; 1709 1652 $field_names = []; 1710 1653 … … 1726 1669 } 1727 1670 1728 1729 1671 return [ 1730 1672 'count' => $count, … … 1732 1674 'permissions' => $permissions, 1733 1675 'field_names' => array_values( array_filter( $field_names ) ), 1734 'selected_terms' => $selected_terms, 1735 ]; 1736 } 1737 } 1738 1739 if ( ! function_exists( 'multissaf_get_sample_sync_data' ) ) { 1740 function multissaf_get_sample_sync_data() { 1741 1742 return [ 1743 'ssgsw_products' => [ 1744 [ 1745 'id' => 192, 1746 'type' => 'simple', 1747 'title' => 'Hello22c334', 1748 'stock_quantity' => null, 1749 'stock_status' => 'instock', 1750 'regular_price' => 3314, 1751 'price' => 311, 1752 'sale_price' => 311, 1753 'featured_image_url' => '', 1754 'gallery_image_urls' => [], 1755 'status' => 'publish', 1756 'sku' => 'testcase', 1757 'attributes' => [], 1758 'short_description' => '', 1759 'description' => '', 1760 'custom_meta' => [ 1761 '_edit_last' => [ '1' ], 1762 'total_sales' => [ '0' ], 1763 '_tax_status' => [ 'taxable' ], 1764 '_tax_class' => [ '' ], 1765 '_manage_stock' => [ 'no' ], 1766 '_backorders' => [ 'no' ], 1767 '_sold_individually' => [ 'no' ], 1768 '_virtual' => [ 'no' ], 1769 '_downloadable' => [ 'no' ], 1770 '_download_limit' => [ '-1' ], 1771 '_download_expiry' => [ '-1' ], 1772 '_wc_average_rating' => [ '0' ], 1773 '_wc_review_count' => [ '0' ], 1774 '_product_version' => [ '10.5.2' ], 1775 '_per_product_admin_commission' => [ '' ], 1776 '_per_product_admin_commission_type' => [ 'fixed' ], 1777 '_per_product_admin_additional_fee' => [ '' ], 1778 ], 1779 ], 1780 ], 1781 'skus' => [ 'testcase' ], 1782 'refarance_shop' => 'http://store1.test', 1783 'refrance_token' => '421eb65e670f2e7ec717c3014a52c6c1ae210a47321540ab951f77b7aacf5f22', 1784 'orgingal_ids' => [ 192 ], 1785 'sync_mode' => 'multi_to_single', 1786 'sheet_sync' => '', 1676 'selected_terms' => [], 1787 1677 ]; 1788 1678 } … … 1995 1885 // Filter to only connected sites 1996 1886 $connected_sites = array_filter( $all_sites, function( $site ) { 1997 // FIX: Default to TRUE (connected) if isConnected field is missing 1998 // This fixes the bug where parent sites saved without isConnected field 1999 // were being filtered out, causing cross-site sync to fail 1887 // Treat a missing isConnected as connected. 2000 1888 $is_connected = isset($site['isConnected']) ? $site['isConnected'] : true; 2001 1889 -
multi-store-sync-addon-for-flexstock/tags/1.1.0/includes/classes/import-function.php
r3624473 r3752558 4 4 5 5 /** 6 * Imports or updates a batch of products on the local WooCommerce store 7 * from a structured product array (as sent by multissaf_get_product_by_ids). 8 * Matches by SKU (exact or alias). Creates missing products; updates existing. 6 * Imports or updates products on this store, matched by SKU; missing products are created. 9 7 * 10 8 * Connected from: API::update_single_callback(), 11 * API::marge_with_parent_callback(),12 9 * MULTISSAF_AJAX::multissaf_manual_import_products() 13 10 * 14 * @param array $products_data Array of product arrays with keys: sku, title,11 * @param array $products_data Array of product arrays with keys: sku, title, 15 12 * stock_quantity, regular_price, etc. 16 * @param bool $sheet_sync Pass-through flag for downstream sheet update. 17 * @param int[] $original_ids Source IDs for sheet write scheduling. 18 * @param string $refrance_shop Source site URL. 19 * @param bool $manual_update Whether triggered by admin manual action. 13 * @param bool $sheet_sync Pass-through flag for downstream sheet update. 14 * @param int[] $original_ids Source IDs for sheet write scheduling. 15 * @param string $refrance_shop Source site URL. 16 * @param bool $manual_update Whether triggered by admin manual action. 17 * @param int|null $create_limit How many new products may be created (null = any number). 20 18 * @return array {success, created_count, updated_count, failed_count, messages, details} 21 19 */ 22 if ( ! function_exists( 'multissaf_import_update_products' ) ) { 23 function multissaf_import_update_products( $products_data, $sheet_sync = false, $original_ids = [], $refrance_shop = '', $manual_update = false ) { 24 defined( 'ABSPATH' ) || exit; 20 if (! function_exists('multissaf_save_meta_value') ) { 21 /** 22 * Saves an imported meta value through FlexStock when available (it also handles 23 * serialized values); older FlexStock versions lack that helper. 24 * 25 * @param int $id Product or variation ID. 26 * @param string $meta_key Meta key. 27 * @param mixed $value Meta value (may be serialized). 28 * @return mixed 29 */ 30 function multissaf_save_meta_value( $id, $meta_key, $value ) 31 { 32 if (function_exists('ssgsw_meta_field_value_save') ) { 33 return ssgsw_meta_field_value_save($id, $meta_key, $value); 34 } 35 if (empty($id) || ! is_numeric($id) ) { 36 return false; 37 } 38 return update_post_meta($id, $meta_key, maybe_unserialize($value)); 39 } 40 } 41 42 if (! function_exists('multissaf_import_update_products') ) { 43 function multissaf_import_update_products( $products_data, $sheet_sync = false, $original_ids = [], $refrance_shop = '', $manual_update = false, $create_limit = null ) 44 { 45 defined('ABSPATH') || exit; 25 46 26 47 if (empty($products_data) || !is_array($products_data)) { … … 43 64 44 65 // Defer term and comment counting for performance 45 wp_defer_term_counting( true);46 wp_defer_comment_counting( true);47 48 $skus_to_lookup = array_filter( array_column( $products_data, 'sku' ));49 $skus_to_lookup = apply_filters( 'multissaf_import_skus_to_lookup', $skus_to_lookup);66 wp_defer_term_counting(true); 67 wp_defer_comment_counting(true); 68 69 $skus_to_lookup = array_filter(array_column($products_data, 'sku')); 70 $skus_to_lookup = apply_filters('multissaf_import_skus_to_lookup', $skus_to_lookup); 50 71 $sku_map = []; 51 72 52 if ( ! empty( $skus_to_lookup) ) {73 if (! empty($skus_to_lookup) ) { 53 74 global $wpdb; 54 $placeholders = implode( ',', array_fill( 0, count( $skus_to_lookup ), '%s' ));75 $placeholders = implode(',', array_fill(0, count($skus_to_lookup), '%s')); 55 76 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared 56 77 $rows = $wpdb->get_results( … … 79 100 } 80 101 81 $existing_product_id = isset( $sku_map[ $sku ]) ? $sku_map[ $sku ] : 0;102 $existing_product_id = isset($sku_map[ $sku ]) ? $sku_map[ $sku ] : 0; 82 103 83 104 // Tier 2: Match by old SKU 84 if ( !$existing_product_id && isset($product_data['custom_meta']['_ssgsw_old_sku_temp']) ) {105 if (!$existing_product_id && isset($product_data['custom_meta']['_ssgsw_old_sku_temp']) ) { 85 106 $old_sku = $product_data['custom_meta']['_ssgsw_old_sku_temp']; 86 if ( is_array( $old_sku) ) {107 if (is_array($old_sku) ) { 87 108 $old_sku = $old_sku[0]; 88 109 } 89 if ( ! empty( $old_sku ) ) { 90 $existing_product_id = isset( $sku_map[ $old_sku ] ) ? $sku_map[ $old_sku ] : wc_get_product_id_by_sku( $old_sku ); 91 } 110 if (! empty($old_sku) ) { 111 $existing_product_id = isset($sku_map[ $old_sku ]) ? $sku_map[ $old_sku ] : wc_get_product_id_by_sku($old_sku); 112 } 113 } 114 115 // Top-level products may only match parent product posts, never variations. 116 if ($existing_product_id && 'product' !== get_post_type($existing_product_id) ) { 117 $existing_product_id = 0; 92 118 } 93 119 94 120 // Tier 3: Match by mapped remote ID 95 if ( !$existing_product_id && !empty($refrance_shop) && !empty($product_data['id']) ) {121 if (!$existing_product_id && !empty($refrance_shop) && !empty($product_data['id']) ) { 96 122 $remote_meta_key = '_ssgsw_remote_id_' . md5($refrance_shop); 97 123 global $wpdb; 98 $found_id = $wpdb->get_var($wpdb->prepare( 99 "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = %s AND meta_value = %s LIMIT 1", 100 $remote_meta_key, 101 absint($product_data['id']) 102 )); 103 if ( $found_id ) { 124 $found_id = $wpdb->get_var( 125 $wpdb->prepare( 126 "SELECT pm.post_id FROM {$wpdb->postmeta} pm 127 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id AND p.post_type = 'product' 128 WHERE pm.meta_key = %s AND pm.meta_value = %s LIMIT 1", 129 $remote_meta_key, 130 absint($product_data['id']) 131 ) 132 ); 133 if ($found_id ) { 104 134 $existing_product_id = (int) $found_id; 105 135 } 106 136 } 107 137 108 // Tier 4: Match by raw ID (cloned sites fallback) 109 if ( !$existing_product_id && !empty($product_data['id']) ) { 110 $check_id = absint($product_data['id']); 111 $post = get_post($check_id); 112 if ($post && in_array($post->post_type, ['product', 'product_variation'])) { 113 $existing_product_id = $check_id; 114 } 115 } 116 117 if ( isset( $product_data['status'] ) && 'trash' === strtolower( $product_data['status'] ) ) { 118 if ( $existing_product_id ) { 119 if ( 'trash' !== get_post_status( $existing_product_id ) ) { 120 wp_trash_post( $existing_product_id ); 138 // No raw post-ID matching: post IDs are not shared between sites, so matching 139 // on them overwrote unrelated destination products and variations. 140 141 if (isset($product_data['status']) && 'trash' === strtolower($product_data['status']) ) { 142 if ($existing_product_id ) { 143 if ('trash' !== get_post_status($existing_product_id) ) { 144 wp_trash_post($existing_product_id); 121 145 } 122 146 $product_ids[] = $existing_product_id; … … 134 158 135 159 if ($existing_product_id) { 136 if ( 'trash' === get_post_status( $existing_product_id ) && isset( $product_data['status'] ) && 'trash' !== strtolower( $product_data['status']) ) {137 wp_untrash_post( $existing_product_id);160 if ('trash' === get_post_status($existing_product_id) && isset($product_data['status']) && 'trash' !== strtolower($product_data['status']) ) { 161 wp_untrash_post($existing_product_id); 138 162 } 139 163 // Update existing product … … 141 165 142 166 if ($result['success']) { 143 if ( !empty($refrance_shop) && !empty($product_data['id']) ) {167 if (!empty($refrance_shop) && !empty($product_data['id']) ) { 144 168 update_post_meta($existing_product_id, '_ssgsw_remote_id_' . md5($refrance_shop), absint($product_data['id'])); 145 169 } … … 161 185 } else { 162 186 $store_role = get_option('ssgsw_multi_connect_mode_on'); 163 if ( 'child' === $store_role ) { 187 if ('child' === $store_role ) { 188 continue; 189 } 190 if (null !== $create_limit && $create_limit <= 0 ) { 191 $results['messages'][] = "Not created (product is not on this site): $sku"; 164 192 continue; 165 193 } … … 167 195 $result = multissaf_create_new_product($product_data); 168 196 if ($result['success']) { 169 if ( !empty($refrance_shop) && !empty($product_data['id']) ) { 197 if (null !== $create_limit ) { 198 $create_limit--; 199 } 200 if (!empty($refrance_shop) && !empty($product_data['id']) ) { 170 201 update_post_meta($result['product_id'], '_ssgsw_remote_id_' . md5($refrance_shop), absint($product_data['id'])); 171 202 } … … 199 230 200 231 // Resume term and comment counting 201 wp_defer_term_counting( false);202 wp_defer_comment_counting( false);232 wp_defer_term_counting(false); 233 wp_defer_comment_counting(false); 203 234 204 235 return $results; … … 207 238 208 239 209 if ( ! function_exists( 'multissaf_update_existing_product' ) ) { 210 function multissaf_update_existing_product( $product_id, $product_data ) { 211 defined( 'ABSPATH' ) || exit; 240 if (! function_exists('multissaf_update_existing_product') ) { 241 function multissaf_update_existing_product( $product_id, $product_data ) 242 { 243 defined('ABSPATH') || exit; 212 244 213 245 try { … … 239 271 if (isset($product_data['regular_price'])) { 240 272 $new_regular_price = floatval($product_data['regular_price']); 241 // $current_regular_price = floatval($product->get_regular_price());242 273 $current_regular_price = floatval(get_post_meta($product_id, '_regular_price', true)); 243 274 if (abs($new_regular_price - $current_regular_price) > 0.0001) { … … 261 292 $stock_status_provided = isset($product_data['stock_status']); 262 293 263 if ( $stock_quantity_provided || $stock_status_provided ) {294 if ($stock_quantity_provided || $stock_status_provided ) { 264 295 $current_quantity = $product->get_stock_quantity(); 265 296 $current_status = $product->get_stock_status(); … … 270 301 271 302 // If quantity is provided and numeric, derive status from it 272 if ( $stock_quantity_provided && is_numeric($product_data['stock_quantity']) ) {303 if ($stock_quantity_provided && is_numeric($product_data['stock_quantity']) ) { 273 304 $new_quantity = absint($product_data['stock_quantity']); 274 305 275 if ( $new_quantity > 0 ) {306 if ($new_quantity > 0 ) { 276 307 $new_status = 'instock'; 277 308 } else { 278 309 // Check backorders 279 310 $backorders = $product->get_backorders(); 280 if ( $backorders && $backorders !== 'no' ) {311 if ($backorders && $backorders !== 'no' ) { 281 312 $new_status = 'onbackorder'; 282 313 } else { … … 286 317 } 287 318 // If only status is provided (non-numeric), derive quantity from it 288 else if ( $stock_status_provided && !is_numeric($product_data['stock_status']) ) {319 else if ($stock_status_provided && !is_numeric($product_data['stock_status']) ) { 289 320 // Normalize the status string 290 $stock_normalized = strtolower( preg_replace( '/\s+/', '', trim( $product_data['stock_status'] ) ));291 $stock_normalized = preg_replace( '/\s*\(\d+\)\s*$/', '', $stock_normalized); // Remove any quantity in parentheses292 293 if ( $stock_normalized === 'outofstock' ) {321 $stock_normalized = strtolower(preg_replace('/\s+/', '', trim($product_data['stock_status']))); 322 $stock_normalized = preg_replace('/\s*\(\d+\)\s*$/', '', $stock_normalized); // Remove any quantity in parentheses 323 324 if ($stock_normalized === 'outofstock' ) { 294 325 $new_quantity = 0; 295 } else if ( $stock_normalized === 'instock' ) {326 } else if ($stock_normalized === 'instock' ) { 296 327 // Keep current quantity if positive, otherwise set to 1 297 $new_quantity = max( 1, $current_quantity);298 } else if ( $stock_normalized === 'onbackorder' ) {328 $new_quantity = max(1, $current_quantity); 329 } else if ($stock_normalized === 'onbackorder' ) { 299 330 $new_quantity = 0; 300 331 } … … 302 333 303 334 // Apply atomic updates - derive and set all three together 304 $product->set_manage_stock( true);305 $product->set_stock_quantity( $new_quantity);306 $product->set_stock_status( $new_status);335 $product->set_manage_stock(true); 336 $product->set_stock_quantity($new_quantity); 337 $product->set_stock_status($new_status); 307 338 308 339 $updated_fields[] = 'stock_quantity'; … … 317 348 318 349 // Update SKU — only when it changes and there is no conflict on another product. 319 if ( isset( $product_data['sku']) && '' !== $product_data['sku'] ) {350 if (isset($product_data['sku']) && '' !== $product_data['sku'] ) { 320 351 $new_sku = $product_data['sku']; 321 352 $current_sku = $product->get_sku(); 322 if ( $new_sku !== $current_sku ) {323 $sku_conflict_id = wc_get_product_id_by_sku( $new_sku);324 if ( ! $sku_conflict_id || (int) $sku_conflict_id === (int) $product_id ) {325 $product->set_sku( $new_sku);353 if ($new_sku !== $current_sku ) { 354 $sku_conflict_id = wc_get_product_id_by_sku($new_sku); 355 if (! $sku_conflict_id || (int) $sku_conflict_id === (int) $product_id ) { 356 $product->set_sku($new_sku); 326 357 $updated_fields[] = 'sku'; 327 358 } 328 359 } 329 360 } 330 331 /* if (!empty($updated_fields)) {332 $product->save();333 } */334 361 335 362 if (!empty($updated_fields)) { … … 339 366 340 367 // If the product was just trashed via the parent's REST API push, 341 if ( isset( $product_data['status']) && 'trash' === $product_data['status'] ) {368 if (isset($product_data['status']) && 'trash' === $product_data['status'] ) { 342 369 wp_schedule_single_event( 343 370 time() + 3, … … 347 374 } 348 375 } catch ( \Throwable $e ) { 349 error_log( '[MultiStore] Product save failed for product ' . $product_id . ': ' . $e->getMessage() ); 376 error_log('[MultiStore] Product save failed for product ' . $product_id . ': ' . $e->getMessage()); 377 return [ 'success' => false, 'message' => 'Product save failed: ' . $e->getMessage(), 'product_ids' => [] ]; 350 378 } 351 379 } … … 354 382 if (isset($product_data['custom_meta']) && is_array($product_data['custom_meta'])) { 355 383 foreach ($product_data['custom_meta'] as $meta_key => $meta_value) { 356 $transport_only_keys = [ '_ssgsw_old_sku_temp' ]; 357 if ( in_array( $meta_key, $transport_only_keys, true ) ) { 384 $transport_only_keys = [ '_ssgsw_old_sku_temp', '_ssgsw_main_sheet_row_written' ]; 385 // Site-local markers (sheet row written, remote-ID mappings) never copy across sites. 386 if (in_array($meta_key, $transport_only_keys, true) || 0 === strpos($meta_key, '_ssgsw_remote_id_') ) { 358 387 continue; 359 388 } … … 367 396 } 368 397 if ($current_meta !== $meta_value) { 369 ssgsw_meta_field_value_save($product_id, $meta_key, $meta_value);398 multissaf_save_meta_value($product_id, $meta_key, $meta_value); 370 399 $updated_fields[] = "meta: $meta_key"; 371 400 } … … 459 488 $variation_attrs = []; 460 489 foreach ( $product_data['attributes'] as $attr ) { 461 $slug = isset( $attr['slug'] ) ? sanitize_title( $attr['slug']) : '';462 $value = isset( $attr['value']) ? (string) $attr['value'] : '';490 $slug = isset($attr['slug']) ? sanitize_title($attr['slug']) : ''; 491 $value = isset($attr['value']) ? (string) $attr['value'] : ''; 463 492 // WC variations store ONE selected value per attribute, keyed by taxonomy 464 if ( ! empty( $slug) ) {493 if (! empty($slug) ) { 465 494 $variation_attrs[ $slug ] = $value; 466 495 } 467 496 } 468 $product->set_attributes( $variation_attrs);497 $product->set_attributes($variation_attrs); 469 498 $updated_fields[] = 'attributes'; 470 499 } else { … … 524 553 * @return array Result with success status, message and product_id 525 554 */ 526 if ( ! function_exists( 'multissaf_create_new_product' ) ) { 527 function multissaf_create_new_product( $product_data ) { 528 defined( 'ABSPATH' ) || exit; 555 if (! function_exists('multissaf_create_new_product') ) { 556 function multissaf_create_new_product( $product_data ) 557 { 558 defined('ABSPATH') || exit; 529 559 try { 530 560 $product_type = isset($product_data['type']) ? $product_data['type'] : 'simple'; … … 566 596 $stock_status_provided = isset($product_data['stock_status']); 567 597 568 if ( $stock_quantity_provided || $stock_status_provided ) {598 if ($stock_quantity_provided || $stock_status_provided ) { 569 599 $new_quantity = 0; 570 600 $new_status = 'instock'; 571 601 572 602 // If quantity is provided and numeric, derive status from it 573 if ( $stock_quantity_provided && is_numeric($product_data['stock_quantity']) ) {603 if ($stock_quantity_provided && is_numeric($product_data['stock_quantity']) ) { 574 604 $new_quantity = absint($product_data['stock_quantity']); 575 605 576 if ( $new_quantity > 0 ) {606 if ($new_quantity > 0 ) { 577 607 $new_status = 'instock'; 578 608 } else { … … 581 611 } 582 612 // If only status is provided 583 else if ( $stock_status_provided && !is_numeric($product_data['stock_status']) ) {584 $stock_normalized = strtolower( preg_replace( '/\s+/', '', trim( $product_data['stock_status'] ) ));585 $stock_normalized = preg_replace( '/\s*\(\d+\)\s*$/', '', $stock_normalized);613 else if ($stock_status_provided && !is_numeric($product_data['stock_status']) ) { 614 $stock_normalized = strtolower(preg_replace('/\s+/', '', trim($product_data['stock_status']))); 615 $stock_normalized = preg_replace('/\s*\(\d+\)\s*$/', '', $stock_normalized); 586 616 587 617 $valid_statuses = ['instock', 'outofstock', 'onbackorder']; 588 if ( in_array( $stock_normalized, $valid_statuses, true) ) {618 if (in_array($stock_normalized, $valid_statuses, true) ) { 589 619 $new_status = $stock_normalized; 590 if ( $stock_normalized === 'outofstock' || $stock_normalized === 'onbackorder' ) {620 if ($stock_normalized === 'outofstock' || $stock_normalized === 'onbackorder' ) { 591 621 $new_quantity = 0; 592 622 } else { … … 597 627 598 628 // Apply atomic stock update 599 $product->set_manage_stock( true);600 $product->set_stock_quantity( $new_quantity);601 $product->set_stock_status( $new_status);629 $product->set_manage_stock(true); 630 $product->set_stock_quantity($new_quantity); 631 $product->set_stock_status($new_status); 602 632 } 603 633 … … 620 650 if (isset($product_data['custom_meta']) && is_array($product_data['custom_meta'])) { 621 651 foreach ($product_data['custom_meta'] as $meta_key => $meta_value) { 622 $transport_only_keys = [ '_ssgsw_old_sku_temp' ]; 623 if ( in_array( $meta_key, $transport_only_keys, true ) ) { 652 $transport_only_keys = [ '_ssgsw_old_sku_temp', '_ssgsw_main_sheet_row_written' ]; 653 // Site-local markers (sheet row written, remote-ID mappings) never copy across sites. 654 if (in_array($meta_key, $transport_only_keys, true) || 0 === strpos($meta_key, '_ssgsw_remote_id_') ) { 624 655 continue; 625 656 } … … 630 661 $current_meta = get_post_meta($product_id, $meta_key, true); 631 662 if ($current_meta !== $meta_value) { 632 ssgsw_meta_field_value_save($product_id, $meta_key, $meta_value);663 multissaf_save_meta_value($product_id, $meta_key, $meta_value); 633 664 634 665 } … … 733 764 * @return array List of updated variations 734 765 */ 735 if ( ! function_exists( 'multissaf_create_update_product_variations' ) ) { 736 function multissaf_create_update_product_variations( $product_id, $variations_data ) { 737 defined( 'ABSPATH' ) || exit; 766 if (! function_exists('multissaf_create_update_product_variations') ) { 767 function multissaf_create_update_product_variations( $product_id, $variations_data ) 768 { 769 defined('ABSPATH') || exit; 738 770 $updated_variations = []; 739 771 foreach ($variations_data as $variation_data) { … … 751 783 } else { 752 784 $store_role = get_option('ssgsw_multi_connect_mode_on'); 753 if ( 'child' === $store_role ) {785 if ('child' === $store_role ) { 754 786 continue; 755 787 } … … 767 799 } 768 800 769 if ( ! function_exists( 'multissaf_create_product_variations' ) ) { 770 function multissaf_create_product_variations( $product_id, $variation_data ) { 771 defined( 'ABSPATH' ) || exit; 801 if (! function_exists('multissaf_create_product_variations') ) { 802 function multissaf_create_product_variations( $product_id, $variation_data ) 803 { 804 defined('ABSPATH') || exit; 772 805 773 806 $variation = new WC_Product_Variation(); … … 815 848 $current_meta = get_post_meta($variation_id, $meta_key, true); 816 849 if ($current_meta !== $meta_value) { 817 ssgsw_meta_field_value_save($variation_id, $meta_key, $meta_value);850 multissaf_save_meta_value($variation_id, $meta_key, $meta_value); 818 851 } 819 852 } … … 827 860 } 828 861 829 if ( ! function_exists( 'multissaf_update_variation' ) ) { 830 function multissaf_update_variation( $variation, $variation_data ) { 831 defined( 'ABSPATH' ) || exit; 862 if (! function_exists('multissaf_update_variation') ) { 863 function multissaf_update_variation( $variation, $variation_data ) 864 { 865 defined('ABSPATH') || exit; 832 866 833 867 $updated_fields = []; … … 879 913 880 914 // Variations trashed via API push — after_save_product is blocked 881 if ( isset( $variation_data['status']) && 'trash' === $variation_data['status'] ) {882 if ( class_exists('\StockSyncWithGoogleSheetForWooCommerce\Product') ) {915 if (isset($variation_data['status']) && 'trash' === $variation_data['status'] ) { 916 if (class_exists('\StockSyncWithGoogleSheetForWooCommerce\Product') ) { 883 917 $sheet_product = new \StockSyncWithGoogleSheetForWooCommerce\Product(); 884 $sheet_product->batch_update_delete_and_append( $variation->get_id());918 $sheet_product->batch_update_delete_and_append($variation->get_id()); 885 919 } 886 920 } … … 899 933 } 900 934 if ($current_meta !== $meta_value) { 901 ssgsw_meta_field_value_save($variation->get_id(), $meta_key, $meta_value);935 multissaf_save_meta_value($variation->get_id(), $meta_key, $meta_value); 902 936 $updated_fields[] = "meta: $meta_key"; 903 937 } … … 912 946 } 913 947 914 if ( ! function_exists( 'multissaf_manage_product_attributes' ) ) { 915 function multissaf_manage_product_attributes( WC_Product $product, array $attributes_data ) { 916 defined( 'ABSPATH' ) || exit; 948 if (! function_exists('multissaf_manage_product_attributes') ) { 949 function multissaf_manage_product_attributes( WC_Product $product, array $attributes_data ) 950 { 951 defined('ABSPATH') || exit; 917 952 if (!$product || empty($attributes_data)) { 918 953 return false; … … 966 1001 $term = get_term_by('slug', $term_slug, $slug); 967 1002 968 // Fall back to name lookup for backward compatibility1003 // Fall back to a name lookup for terms whose slug differs from the name 969 1004 if (!$term) { 970 1005 $term = get_term_by('name', $term_name, $slug); … … 1011 1046 1012 1047 // Flush WC attribute lookup cache to prevent stale key warnings 1013 if ( class_exists( '\Automattic\WooCommerce\Internal\ProductAttributesLookup\LookupDataStore') ) {1048 if (class_exists('\Automattic\WooCommerce\Internal\ProductAttributesLookup\LookupDataStore') ) { 1014 1049 try { 1015 1050 wc_get_container() 1016 ->get( \Automattic\WooCommerce\Internal\ProductAttributesLookup\LookupDataStore::class)1017 ->on_product_changed( $product->get_id());1051 ->get(\Automattic\WooCommerce\Internal\ProductAttributesLookup\LookupDataStore::class) 1052 ->on_product_changed($product->get_id()); 1018 1053 } catch ( \Exception $e ) { 1019 1054 error_log('[MultiStore] Failed to rebuild lookup data after save: ' . $e->getMessage()); … … 1028 1063 } 1029 1064 1030 if ( ! function_exists( 'multissaf_update_product_featured_image' ) ) { 1031 function multissaf_update_product_featured_image( $product_id, $image_url ) { 1032 defined( 'ABSPATH' ) || exit; 1033 1034 $image_url = function_exists('ssgsw_normalize_sheet_image_url') ? ssgsw_normalize_sheet_image_url( $image_url ) : $image_url; 1065 if (! function_exists('multissaf_update_product_featured_image') ) { 1066 function multissaf_update_product_featured_image( $product_id, $image_url ) 1067 { 1068 defined('ABSPATH') || exit; 1069 1070 $image_url = function_exists('ssgsw_normalize_sheet_image_url') ? ssgsw_normalize_sheet_image_url($image_url) : $image_url; 1035 1071 if (empty($image_url) || ! filter_var($image_url, FILTER_VALIDATE_URL) ) { 1036 1072 return false; … … 1059 1095 } 1060 1096 1061 if ( ! function_exists( 'multissaf_set_product_image_from_url' ) ) { 1062 function multissaf_set_product_image_from_url( $product_id, $image_url, $gallary = false ) { 1063 defined( 'ABSPATH' ) || exit; 1097 if (! function_exists('multissaf_set_product_image_from_url') ) { 1098 function multissaf_set_product_image_from_url( $product_id, $image_url, $gallary = false ) 1099 { 1100 defined('ABSPATH') || exit; 1064 1101 1065 1102 $image_data = function_exists('ssgsw_safe_remote_get_image') ? ssgsw_safe_remote_get_image($image_url) : wp_remote_get($image_url); … … 1098 1135 } 1099 1136 1100 if ( ! function_exists( 'multissaf_get_attachment_by_original_image_url' ) ) { 1101 function multissaf_get_attachment_by_original_image_url( $original_image_url ) { 1102 defined( 'ABSPATH' ) || exit; 1137 if (! function_exists('multissaf_get_attachment_by_original_image_url') ) { 1138 function multissaf_get_attachment_by_original_image_url( $original_image_url ) 1139 { 1140 defined('ABSPATH') || exit; 1103 1141 $args = array( 1104 1142 'post_type' => 'attachment', … … 1121 1159 } 1122 1160 1123 if ( ! function_exists( 'multissaf_get_attachment_id_by_url' ) ) { 1124 function multissaf_get_attachment_id_by_url( $image_url ) { 1125 defined( 'ABSPATH' ) || exit; 1161 if (! function_exists('multissaf_get_attachment_id_by_url') ) { 1162 function multissaf_get_attachment_id_by_url( $image_url ) 1163 { 1164 defined('ABSPATH') || exit; 1126 1165 $attachment_id = attachment_url_to_postid($image_url); 1127 1166 return $attachment_id; … … 1129 1168 } 1130 1169 1131 if ( ! function_exists( 'multissaf_update_product_gallery' ) ) { 1132 function multissaf_update_product_gallery( $product, $gallery_urls ) { 1133 defined( 'ABSPATH' ) || exit; 1170 if (! function_exists('multissaf_update_product_gallery') ) { 1171 function multissaf_update_product_gallery( $product, $gallery_urls ) 1172 { 1173 defined('ABSPATH') || exit; 1134 1174 $product_id = $product->get_id(); 1135 1175 if (!$product) { … … 1167 1207 $product->save(); 1168 1208 } catch ( \Throwable $e ) { 1169 error_log( '[MultiStore] Gallery save failed for product ' . $product->get_id() . ': ' . $e->getMessage());1209 error_log('[MultiStore] Gallery save failed for product ' . $product->get_id() . ': ' . $e->getMessage()); 1170 1210 return false; 1171 1211 } … … 1179 1219 1180 1220 1181 if ( ! function_exists( 'multissaf_set_product_gallery' ) ) { 1182 function multissaf_set_product_gallery( $product_id, $gallery_urls ) { 1183 defined( 'ABSPATH' ) || exit; 1221 if (! function_exists('multissaf_set_product_gallery') ) { 1222 function multissaf_set_product_gallery( $product_id, $gallery_urls ) 1223 { 1224 defined('ABSPATH') || exit; 1184 1225 1185 1226 if (empty($gallery_urls) || !is_array($gallery_urls)) { return; … … 1204 1245 } 1205 1246 1206 if ( ! function_exists( 'multissaf_import_image_from_url' ) ) { 1207 function multissaf_import_image_from_url( $image_url, $product_id ) { 1208 defined( 'ABSPATH' ) || exit; 1247 if (! function_exists('multissaf_import_image_from_url') ) { 1248 function multissaf_import_image_from_url( $image_url, $product_id ) 1249 { 1250 defined('ABSPATH') || exit; 1209 1251 1210 1252 if (empty($image_url) || ! filter_var($image_url, FILTER_VALIDATE_URL) ) { … … 1224 1266 } 1225 1267 1226 if ( ! function_exists( 'multissaf_handle_product_import_api' ) ) { 1227 function multissaf_handle_product_import_api( $request_body, $sheet_sync = false ) { 1228 defined( 'ABSPATH' ) || exit; 1268 if (! function_exists('multissaf_handle_product_import_api') ) { 1269 function multissaf_handle_product_import_api( $request_body, $sheet_sync = false, $create_limit = null ) 1270 { 1271 defined('ABSPATH') || exit; 1229 1272 if (!isset($request_body['ssgsw_products']) || empty($request_body['ssgsw_products'])) { 1230 1273 return [ … … 1239 1282 $refrance_shop = isset($request_body['refarance_shop']) ? $request_body['refarance_shop'] : ''; 1240 1283 // Process the import/update 1241 $results = multissaf_import_update_products($products_data, $sheet_sync, $original_ids, $refrance_shop, true );1284 $results = multissaf_import_update_products($products_data, $sheet_sync, $original_ids, $refrance_shop, true, $create_limit); 1242 1285 1243 1286 return [ -
multi-store-sync-addon-for-flexstock/tags/1.1.0/includes/wppool/class-plugin.php
r3639255 r3752558 10 10 11 11 // Exit if accessed directly. 12 defined( 'ABSPATH') || exit();12 defined('ABSPATH') || exit(); 13 13 14 14 /** 15 15 * Plugin Class 16 16 */ 17 if ( ! class_exists( 'WPPOOL_Plugin' ) ) { 18 /** 19 * Handles all the WPPOOL Plugin related functionalities, promotions, etc. 20 * 21 * @version 3.0.0 22 */ 23 class WPPOOL_Plugin { 24 /** 25 * Contains instance of Plugin. 26 * 27 * @since 3.0.0 28 * @var self 29 */ 30 private static $instance = null; 31 32 /** 33 * Returns instance of Plugin. 34 * 35 * @since 3.0.0 36 * @return self 37 */ 38 public static function get_instance() { 39 if ( is_null( self::$instance ) ) { 40 self::$instance = new self(); 41 } 42 43 return self::$instance; 44 } 45 46 /** 47 * Contains sdk version. 48 * 49 * @var string 50 */ 51 public $sdk_version = '3.2.1'; 52 53 /** 54 * Contains all public plugins from WPPOOL. 55 * 56 * @var array 57 */ 58 public $plugins = [ 59 'wp_dark_mode' => [ 60 'list_id' => 20, 61 'button_link' => 'https://go.wppool.dev/LaSV', 62 'button_text' => 'Get Premium', 63 'color' => '#FF631A', 64 'demo_link' => 'https://go.wppool.dev/bjxy', 65 'demo_text' => 'Try a FREE demo', 66 ], 67 'sheets_to_wp_table_live_sync' => [ 68 'list_id' => 21, 69 'button_link' => 'https://go.wppool.dev/Rimc', 70 'button_text' => 'Get Premium', 71 'color' => '#1AD26E', 72 'demo_link' => 'https://go.wppool.dev/Yjcr', 73 'demo_text' => 'Try a FREE demo', 74 ], 75 'easy_video_reviews' => [ 76 'list_id' => 22, 77 'button_text' => 'Get Premium', 78 'color' => '#0288FD', 79 'demo_link' => 'https://go.wppool.dev/VjWZ', 80 'demo_text' => 'Try a FREE demo', 81 ], 82 'webinar_and_video_conference_with_jitsi_meet' => [ 83 'list_id' => 23, 84 'button_link' => 'https://go.wppool.dev/8iQC', 85 'button_text' => 'Get Premium', 86 'color' => '#1D5AE4', 87 'demo_link' => 'https://go.wppool.dev/ajve', 88 'demo_text' => 'Try a FREE demo', 89 ], 90 'zero_bs_accounting' => [ 91 'list_id' => 24, 92 'button_link' => 'https://go.wppool.dev/zbs', 93 'button_text' => 'Get Premium', 94 ], 95 'stock_sync_with_google_sheet_for_woocommerce' => [ 96 'list_id' => 46, 97 'button_link' => 'https://go.wppool.dev/dr8d', 98 'button_text' => 'Get Premium', 99 'color' => '#8F5CCB', 100 'demo_link' => 'https://go.wppool.dev/kjbW', 101 'demo_text' => 'Try a FREE demo', 102 ], 103 'stock_notifier_for_woocommerce' => [ 104 'list_id' => 47, 105 'button_link' => 'https://go.wppool.dev/hiE1', 106 'button_text' => 'Get Premium', 107 'color' => '#3FC250', 108 ], 109 'chat_widgets_for_multivendor_marketplaces' => [ 110 'list_id' => 26, 111 'button_link' => 'https://go.wppool.dev/EiRM', 112 'button_text' => 'Get Premium', 113 'color' => '#CC22FF', 114 ], 115 'omg_chat_widget' => [ 116 'list_id' => 26, 117 'button_link' => 'https://go.wppool.dev/EiRM', 118 'button_text' => 'Get Premium', 119 'color' => '#CC22FF', 120 ], 121 'social_contact_form' => [ 122 'list_id' => 49, 123 'button_link' => 'https://go.wppool.dev/2rc7', 124 'button_text' => 'Get Premium', 125 'color' => '#DC4FF3', 126 ], 127 'elementor_speed_optimizer' => [ 128 'list_id' => 54, 129 'button_link' => 'https://go.wppool.dev/cyVx', 130 'button_text' => 'Get Premium', 131 'color' => '#C91170', 132 ], 133 'easy_email_integration' => [ 134 'list_id' => 55, 135 'button_text' => 'Get Premium', 136 ], 137 'easy_cloudflare_trunstile' => [ 138 'list_id' => 56, 139 'button_link' => 'https://go.wppool.dev/easycloudflare', 140 'button_text' => 'Get Premium', 141 ], 142 'order_sync_with_google_sheets_for_woocommerce' => [ 143 'list_id' => 65, 144 'button_link' => 'https://lnk.wppool.dev/9uh9khK', 145 'button_text' => 'Get Premium', 146 'color' => '#6621ba', 147 'demo_link' => 'https://lnk.wppool.dev/SzdnSCP', 148 'demo_text' => 'Try a FREE demo', 149 ], 150 'echo_rewards' => [ 151 'list_id' => 70, 152 'button_link' => 'https://go.wppool.dev/Bg5L', 153 'button_text' => 'Get Premium', 154 'color' => '#6A32F8', 155 'demo_link' => 'https://go.wppool.dev/7jm2', 156 'demo_text' => 'Try a FREE demo', 157 ], 158 'multi_store_sync_addon_for_flexstock' => [ 159 'list_id' => 92, 160 'button_link' => 'https://lnk.wppool.dev/8wPJ7Qj', 161 'button_text' => 'Get Premium', 162 'color' => '#8F5CCB', 163 'demo_link' => 'https://lnk.wppool.dev/8wPJ7Qj', 164 'demo_text' => 'Try a FREE demo', 165 ], 166 ]; 167 168 /** 169 * FluentCRM Tags ID 170 * 171 * @var array 172 */ 173 public $tags = [ 174 'free' => 11, 175 'paid' => 12, 176 'pro' => 13, 177 'cancelled' => 23, 178 ]; 179 180 /** 181 * Contains CRM server endpoint. This is where we will send user data. 182 * 183 * @var string 184 */ 185 public $crm_endpoint = 'https://fluent.wppool.dev/wp-json/contact/sync'; 186 187 /** 188 * Contains CRM access token. This is used to authenticate the request. 189 * 190 * @var string 191 */ 192 public $crm_access_token = '66E6D9A59A5A948B'; 193 194 /** 195 * Contains plugin id. 196 * 197 * @var string 198 * @example wp_dark_mode 199 */ 200 public $plugin_id = 'wp_dark_mode'; 201 202 /** 203 * Temporarily stores user data. Like email, name, etc. Later it will be sent to CRM. 204 * 205 * @var array 206 */ 207 public $user_data = []; 208 209 /** 210 * Returns the plugin list. This is used to get the plugin name, list id, etc. 211 * 212 * @return array 213 */ 214 public function get_plugins() { 215 return apply_filters( 'wppool_plugins', $this->plugins ); 216 } 217 218 /** 219 * Returns current plugin. 220 * 221 * @return mixed 222 */ 223 public function get_current_plugin() { 224 $plugins = $this->get_plugins(); 225 226 return isset( $plugins[ $this->plugin_id ] ) ? $plugins[ $this->plugin_id ] : null; 227 } 228 229 /** 230 * Temporarily stores custom tags. 231 * 232 * @var array 233 */ 234 235 protected $custom_tags = []; 236 237 /** 238 * Returns the tags. 239 * 240 * @return array 241 */ 242 public function get_tags() { 243 return apply_filters( 'wppool_tags', array_merge( $this->tags, $this->custom_tags ) ); 244 } 245 /** 246 * Temporarily stores custom lists. 247 * 248 * @var array 249 */ 250 protected $custom_lists = []; 251 252 /** 253 * Returns the lists. 254 * 255 * @return array 256 */ 257 public function get_list_id() { 258 $plugin = $this->get_current_plugin(); 259 260 if ( ! $plugin ) { 261 return $this->custom_lists; 262 } 263 264 return array_merge( $this->custom_lists, [ $plugin['list_id'] ] ); 265 } 266 267 /** 268 * Constructor. 269 * 270 * @param string $product_slug The product plugin_id. 271 * @param array $user_data User data. 272 */ 273 public function __construct( 274 $product_slug = 'wp_dark_mode', 275 $user_data = [] 276 ) { 277 $this->plugin_id = $this->slugify( $product_slug ); 278 $this->user_data = is_array( $user_data ) ? $user_data : []; 279 } 280 281 /** 282 * Returns the slugged plugin_id from the plugin name or title. 283 * 284 * @param string $string The plugin_id. 285 * @return string 286 */ 287 public function slugify( $string = '' ) { 288 $string = sanitize_title( $string ); 289 $string = str_replace( '-', '_', $string ); 290 291 return $string; 292 } 293 294 /** 295 * Initialize the SDK and add hooks. 296 * 297 * @return void 298 */ 299 public static function init_plugin_sdk() { 300 $instance = new self(); 301 302 add_action( 'admin_enqueue_scripts', [ $instance, 'enqueue_scripts' ] ); 303 add_action( 'admin_footer', [ $instance, 'load_popup_template' ] ); 304 305 // Elementor support for popup. 306 add_action( 'elementor/editor/after_enqueue_scripts', [ $instance, 'enqueue_scripts' ] ); 307 add_action( 'elementor/editor/header', [ $instance, 'load_popup_template' ] ); 308 } 309 310 /** 311 * Get Default Popup Background Image. 312 * 313 * @return string 314 */ 315 public function get_image_url() { 316 return apply_filters( 'wppool_popup_image', ( file_exists( __DIR__ . '/background-image.png' ) ? plugin_dir_url( __FILE__ ) . '/background-image.png' : '' ), $this->plugin_id ); 317 } 318 319 /** 320 * Loads popup template. 321 * 322 * @return void 323 */ 324 public function load_popup_template() { 325 ?> 326 <div class="_wppool-popup" id="_wppool-popup" style="display: none;" data-plugin="wp_dark_mode" tabindex="1"> 327 <div class="_wppool-popup-overlay"></div> 328 <div class="_wppool-popup-modal"> 329 <!-- close --> 330 <div class="_wppool-popup-modal-close"> × </div> 331 <!-- content section --> 332 <div class="_wppool-popup-modal-footer"> 333 <!-- countdown --> 334 <div class="_wppool-popup-countdown" style="display: none"> 335 <span class="_wppool-popup-countdown-text"> 336 <?php echo esc_html__( 'Deal Ends In', 'multi-store-sync-addon-for-flexstock' ); ?> 337 </span> 338 <div class="_wppool-popup-countdown-time"> 339 <div> 340 <span data-counter="days"> 341 <?php echo esc_html__( '00', 'multi-store-sync-addon-for-flexstock' ); ?> 342 </span> 343 <span> 344 <?php echo esc_html__( 'Days', 'multi-store-sync-addon-for-flexstock' ); ?> 345 </span> 346 </div> 347 <span>:</span> 348 <div> 349 <span data-counter="hours"> 350 <?php echo esc_html__( '00', 'multi-store-sync-addon-for-flexstock' ); ?> 351 </span> 352 <span> 353 <?php echo esc_html__( 'Hours', 'multi-store-sync-addon-for-flexstock' ); ?> 354 </span> 355 </div> 356 <span>:</span> 357 <div> 358 <span data-counter="minutes"> 359 <?php echo esc_html__( '00', 'multi-store-sync-addon-for-flexstock' ); ?> 360 </span> 361 <span> 362 <?php echo esc_html__( 'Minutes', 'multi-store-sync-addon-for-flexstock' ); ?> 363 </span> 364 </div> 365 <span>:</span> 366 <div> 367 <span data-counter="seconds"> 368 <?php echo esc_html__( '00', 'multi-store-sync-addon-for-flexstock' ); ?> 369 </span> 370 <span> 371 <?php echo esc_html__( 'Seconds', 'multi-store-sync-addon-for-flexstock' ); ?> 372 </span> 373 </div> 374 </div> 375 </div> 376 <!-- button --> 377 <a class="_wppool-popup-button"> 378 <?php echo esc_html__( 'Upgrade to Pro', 'multi-store-sync-addon-for-flexstock' ); ?> 379 </a> 380 381 <a target="_blank" class="_wppool-popup-demo-link" href=""> 382 <?php echo esc_html__( 'Try a free demo', 'multi-store-sync-addon-for-flexstock' ); ?> 383 </a> 384 </div> 385 </div> 386 </div> 387 <?php 388 } 389 390 /** 391 * Get inline scripts. 392 * 393 * @return string 394 */ 395 public function get_inline_scripts() { 396 return '(function() { 17 if (! class_exists('WPPOOL_Plugin') ) { 18 /** 19 * Handles all the WPPOOL Plugin related functionalities, promotions, etc. 20 * 21 * @version 3.0.0 22 */ 23 class WPPOOL_Plugin 24 { 25 /** 26 * Contains instance of Plugin. 27 * 28 * @since 3.0.0 29 * @var self 30 */ 31 private static $instance = null; 32 33 /** 34 * Returns instance of Plugin. 35 * 36 * @since 3.0.0 37 * @return self 38 */ 39 public static function get_instance() 40 { 41 if (is_null(self::$instance) ) { 42 self::$instance = new self(); 43 } 44 45 return self::$instance; 46 } 47 48 /** 49 * Contains sdk version. 50 * 51 * @var string 52 */ 53 public $sdk_version = '3.2.1'; 54 55 /** 56 * Contains all public plugins from WPPOOL. 57 * 58 * @var array 59 */ 60 public $plugins = [ 61 'wp_dark_mode' => [ 62 'list_id' => 20, 63 'button_link' => 'https://go.wppool.dev/LaSV', 64 'button_text' => 'Get Premium', 65 'color' => '#FF631A', 66 'demo_link' => 'https://go.wppool.dev/bjxy', 67 'demo_text' => 'Try a FREE demo', 68 ], 69 'sheets_to_wp_table_live_sync' => [ 70 'list_id' => 21, 71 'button_link' => 'https://go.wppool.dev/Rimc', 72 'button_text' => 'Get Premium', 73 'color' => '#1AD26E', 74 'demo_link' => 'https://go.wppool.dev/Yjcr', 75 'demo_text' => 'Try a FREE demo', 76 ], 77 'easy_video_reviews' => [ 78 'list_id' => 22, 79 'button_text' => 'Get Premium', 80 'color' => '#0288FD', 81 'demo_link' => 'https://go.wppool.dev/VjWZ', 82 'demo_text' => 'Try a FREE demo', 83 ], 84 'webinar_and_video_conference_with_jitsi_meet' => [ 85 'list_id' => 23, 86 'button_link' => 'https://go.wppool.dev/8iQC', 87 'button_text' => 'Get Premium', 88 'color' => '#1D5AE4', 89 'demo_link' => 'https://go.wppool.dev/ajve', 90 'demo_text' => 'Try a FREE demo', 91 ], 92 'zero_bs_accounting' => [ 93 'list_id' => 24, 94 'button_link' => 'https://go.wppool.dev/zbs', 95 'button_text' => 'Get Premium', 96 ], 97 'stock_sync_with_google_sheet_for_woocommerce' => [ 98 'list_id' => 46, 99 'button_link' => 'https://go.wppool.dev/dr8d', 100 'button_text' => 'Get Premium', 101 'color' => '#8F5CCB', 102 'demo_link' => 'https://go.wppool.dev/kjbW', 103 'demo_text' => 'Try a FREE demo', 104 ], 105 'stock_notifier_for_woocommerce' => [ 106 'list_id' => 47, 107 'button_link' => 'https://go.wppool.dev/hiE1', 108 'button_text' => 'Get Premium', 109 'color' => '#3FC250', 110 ], 111 'chat_widgets_for_multivendor_marketplaces' => [ 112 'list_id' => 26, 113 'button_link' => 'https://go.wppool.dev/EiRM', 114 'button_text' => 'Get Premium', 115 'color' => '#CC22FF', 116 ], 117 'omg_chat_widget' => [ 118 'list_id' => 26, 119 'button_link' => 'https://go.wppool.dev/EiRM', 120 'button_text' => 'Get Premium', 121 'color' => '#CC22FF', 122 ], 123 'social_contact_form' => [ 124 'list_id' => 49, 125 'button_link' => 'https://go.wppool.dev/2rc7', 126 'button_text' => 'Get Premium', 127 'color' => '#DC4FF3', 128 ], 129 'elementor_speed_optimizer' => [ 130 'list_id' => 54, 131 'button_link' => 'https://go.wppool.dev/cyVx', 132 'button_text' => 'Get Premium', 133 'color' => '#C91170', 134 ], 135 'easy_email_integration' => [ 136 'list_id' => 55, 137 'button_text' => 'Get Premium', 138 ], 139 'easy_cloudflare_trunstile' => [ 140 'list_id' => 56, 141 'button_link' => 'https://go.wppool.dev/easycloudflare', 142 'button_text' => 'Get Premium', 143 ], 144 'order_sync_with_google_sheets_for_woocommerce' => [ 145 'list_id' => 65, 146 'button_link' => 'https://lnk.wppool.dev/9uh9khK', 147 'button_text' => 'Get Premium', 148 'color' => '#6621ba', 149 'demo_link' => 'https://lnk.wppool.dev/SzdnSCP', 150 'demo_text' => 'Try a FREE demo', 151 ], 152 'echo_rewards' => [ 153 'list_id' => 70, 154 'button_link' => 'https://go.wppool.dev/Bg5L', 155 'button_text' => 'Get Premium', 156 'color' => '#6A32F8', 157 'demo_link' => 'https://go.wppool.dev/7jm2', 158 'demo_text' => 'Try a FREE demo', 159 ], 160 'multi_store_sync_addon_for_flexstock' => [ 161 'list_id' => 92, 162 'button_link' => 'https://lnk.wppool.dev/8wPJ7Qj', 163 'button_text' => 'Get Premium', 164 'color' => '#8F5CCB', 165 'demo_link' => 'https://lnk.wppool.dev/8wPJ7Qj', 166 'demo_text' => 'Try a FREE demo', 167 ], 168 ]; 169 170 /** 171 * FluentCRM Tags ID 172 * 173 * @var array 174 */ 175 public $tags = [ 176 'free' => 11, 177 'paid' => 12, 178 'pro' => 13, 179 'cancelled' => 23, 180 ]; 181 182 /** 183 * Contains CRM server endpoint. This is where we will send user data. 184 * 185 * @var string 186 */ 187 public $crm_endpoint = 'https://fluent.wppool.dev/wp-json/contact/sync'; 188 189 /** 190 * Contains CRM access token. This is used to authenticate the request. 191 * 192 * @var string 193 */ 194 public $crm_access_token = '66E6D9A59A5A948B'; 195 196 /** 197 * Contains plugin id. 198 * 199 * @var string 200 * @example wp_dark_mode 201 */ 202 public $plugin_id = 'wp_dark_mode'; 203 204 /** 205 * Temporarily stores user data. Like email, name, etc. Later it will be sent to CRM. 206 * 207 * @var array 208 */ 209 public $user_data = []; 210 211 /** 212 * Returns the plugin list. This is used to get the plugin name, list id, etc. 213 * 214 * @return array 215 */ 216 public function get_plugins() 217 { 218 return apply_filters('wppool_plugins', $this->plugins); 219 } 220 221 /** 222 * Returns current plugin. 223 * 224 * @return mixed 225 */ 226 public function get_current_plugin() 227 { 228 $plugins = $this->get_plugins(); 229 230 return isset($plugins[ $this->plugin_id ]) ? $plugins[ $this->plugin_id ] : null; 231 } 232 233 /** 234 * Temporarily stores custom tags. 235 * 236 * @var array 237 */ 238 239 protected $custom_tags = []; 240 241 /** 242 * Returns the tags. 243 * 244 * @return array 245 */ 246 public function get_tags() 247 { 248 return apply_filters('wppool_tags', array_merge($this->tags, $this->custom_tags)); 249 } 250 /** 251 * Temporarily stores custom lists. 252 * 253 * @var array 254 */ 255 protected $custom_lists = []; 256 257 /** 258 * Returns the lists. 259 * 260 * @return array 261 */ 262 public function get_list_id() 263 { 264 $plugin = $this->get_current_plugin(); 265 266 if (! $plugin ) { 267 return $this->custom_lists; 268 } 269 270 return array_merge($this->custom_lists, [ $plugin['list_id'] ]); 271 } 272 273 /** 274 * Constructor. 275 * 276 * @param string $product_slug The product plugin_id. 277 * @param array $user_data User data. 278 */ 279 public function __construct( 280 $product_slug = 'wp_dark_mode', 281 $user_data = [] 282 ) { 283 $this->plugin_id = $this->slugify($product_slug); 284 $this->user_data = is_array($user_data) ? $user_data : []; 285 } 286 287 /** 288 * Returns the slugged plugin_id from the plugin name or title. 289 * 290 * @param string $string The plugin_id. 291 * @return string 292 */ 293 public function slugify( $string = '' ) 294 { 295 $string = sanitize_title($string); 296 $string = str_replace('-', '_', $string); 297 298 return $string; 299 } 300 301 /** 302 * Initialize the SDK and add hooks. 303 * 304 * @return void 305 */ 306 public static function init_plugin_sdk() 307 { 308 $instance = new self(); 309 310 add_action('admin_enqueue_scripts', [ $instance, 'enqueue_scripts' ]); 311 add_action('admin_footer', [ $instance, 'load_popup_template' ]); 312 313 // Elementor support for popup. 314 add_action('elementor/editor/after_enqueue_scripts', [ $instance, 'enqueue_scripts' ]); 315 add_action('elementor/editor/header', [ $instance, 'load_popup_template' ]); 316 } 317 318 /** 319 * Get Default Popup Background Image. 320 * 321 * @return string 322 */ 323 public function get_image_url() 324 { 325 return apply_filters('wppool_popup_image', ( file_exists(__DIR__ . '/background-image.png') ? plugin_dir_url(__FILE__) . '/background-image.png' : '' ), $this->plugin_id); 326 } 327 328 /** 329 * Loads popup template. 330 * 331 * @return void 332 */ 333 public function load_popup_template() 334 { 335 ?> 336 <div class="_wppool-popup" id="_wppool-popup" style="display: none;" data-plugin="wp_dark_mode" tabindex="1"> 337 <div class="_wppool-popup-overlay"></div> 338 <div class="_wppool-popup-modal"> 339 <!-- close --> 340 <div class="_wppool-popup-modal-close"> × </div> 341 <!-- content section --> 342 <div class="_wppool-popup-modal-footer"> 343 <!-- countdown --> 344 <div class="_wppool-popup-countdown" style="display: none"> 345 <span class="_wppool-popup-countdown-text"> 346 <?php echo esc_html__('Deal Ends In', 'multi-store-sync-addon-for-flexstock'); ?> 347 </span> 348 <div class="_wppool-popup-countdown-time"> 349 <div> 350 <span data-counter="days"> 351 <?php echo esc_html__('00', 'multi-store-sync-addon-for-flexstock'); ?> 352 </span> 353 <span> 354 <?php echo esc_html__('Days', 'multi-store-sync-addon-for-flexstock'); ?> 355 </span> 356 </div> 357 <span>:</span> 358 <div> 359 <span data-counter="hours"> 360 <?php echo esc_html__('00', 'multi-store-sync-addon-for-flexstock'); ?> 361 </span> 362 <span> 363 <?php echo esc_html__('Hours', 'multi-store-sync-addon-for-flexstock'); ?> 364 </span> 365 </div> 366 <span>:</span> 367 <div> 368 <span data-counter="minutes"> 369 <?php echo esc_html__('00', 'multi-store-sync-addon-for-flexstock'); ?> 370 </span> 371 <span> 372 <?php echo esc_html__('Minutes', 'multi-store-sync-addon-for-flexstock'); ?> 373 </span> 374 </div> 375 <span>:</span> 376 <div> 377 <span data-counter="seconds"> 378 <?php echo esc_html__('00', 'multi-store-sync-addon-for-flexstock'); ?> 379 </span> 380 <span> 381 <?php echo esc_html__('Seconds', 'multi-store-sync-addon-for-flexstock'); ?> 382 </span> 383 </div> 384 </div> 385 </div> 386 <!-- button --> 387 <a class="_wppool-popup-button"> 388 <?php echo esc_html__('Upgrade to Pro', 'multi-store-sync-addon-for-flexstock'); ?> 389 </a> 390 391 <a target="_blank" class="_wppool-popup-demo-link" href=""> 392 <?php echo esc_html__('Try a free demo', 'multi-store-sync-addon-for-flexstock'); ?> 393 </a> 394 </div> 395 </div> 396 </div> 397 <?php 398 } 399 400 /** 401 * Get inline scripts. 402 * 403 * @return string 404 */ 405 public function get_inline_scripts() 406 { 407 return '(function() { 397 408 398 409 if (typeof(WPPOOL) !== "undefined") { … … 626 637 627 638 // Check if the image url is valid image url online. 628 const fallback_image_url = "' . esc_url( $this->get_image_url()) . '";639 const fallback_image_url = "' . esc_url($this->get_image_url()) . '"; 629 640 630 641 if (data.background_image && data.background_image.length > 0) { … … 800 811 801 812 })(jQuery)'; 802 } 803 804 /** 805 * Get inline styles. 806 * 807 * @return string 808 */ 809 public function get_inline_styles() { 810 $css = 811 ':root { 813 } 814 815 /** 816 * Get inline styles. 817 * 818 * @return string 819 */ 820 public function get_inline_styles() 821 { 822 $css = 823 ':root { 812 824 --wppool-popup-color: #FF631A; 813 825 } … … 848 860 max-height: 600px !important; 849 861 color: white; 850 background: #222 url(' . esc_url( $this->get_image_url()) . ') no-repeat center center;862 background: #222 url(' . esc_url($this->get_image_url()) . ') no-repeat center center; 851 863 background-position: center; 852 864 background-size: cover; … … 1014 1026 '; 1015 1027 1016 return apply_filters( 'wppool_inline_styles', $css ); 1017 } 1018 1019 /** 1020 * Enqueues scripts. 1021 * 1022 * @return void 1023 */ 1024 public function enqueue_scripts() { 1025 wp_register_script( 'wppool-plugins', '', [], time(), true ); 1026 1027 // Localize script. 1028 wp_localize_script( 'wppool-plugins', 'WPPOOL_Plugins', [ 1029 'plugins' => $this->get_plugins(), 1030 'debug' => defined( 'WP_DEBUG' ) && WP_DEBUG, 1031 ] ); 1032 1033 wp_enqueue_script( 'wppool-plugins' ); 1034 1035 // Enqueue inline scripts. 1036 wp_add_inline_script( 'wppool-plugins', $this->get_inline_scripts() ); 1037 1038 // Enqueue inline styles. 1039 wp_register_style( 'wppool-plugins', '', [], time() ); 1040 wp_enqueue_style( 'wppool-plugins' ); 1041 wp_add_inline_style( 'wppool-plugins', $this->get_inline_styles() ); 1042 } 1043 1044 /** 1045 * Adds Appsero tracker integration hooks. 1046 * 1047 * @return void 1048 */ 1049 public function listen_appsero() { 1050 $hook_name = 1051 str_replace( '_', '-', $this->plugin_id ) . '_tracker_optin'; 1052 1053 add_action( 1054 $hook_name, 1055 [ $this, 'plugin_tracker_optin_callback' ], 1056 10, 1057 1 1058 ); 1059 } 1060 1061 /** 1062 * Appsero for older support 1063 * 1064 * @return void 1065 */ 1066 public function appsero() { 1067 $this->listen_appsero(); 1068 } 1069 1070 /** 1071 * Callback for Appsero tracker integration hooks. 1072 * 1073 * @param array $data The data. 1074 * @return void 1075 */ 1076 public function plugin_tracker_optin_callback( $data = [] ) { 1077 $this->user_data = [ 1078 'email' => $data['admin_email'], 1079 'first_name' => $data['first_name'], 1080 'last_name' => $data['last_name'], 1081 ]; 1082 1083 // Subscribe to CRM. 1084 $this->subscribe(); 1085 } 1086 1087 /** 1088 * Sets custom tags IDs. 1089 * set tag 1090 * 1091 * @param int|array $tag_id The tag IDs. 1092 * @return self 1093 */ 1094 public function set_tag( $tag_id = null ) { 1095 if ( $tag_id ) { 1096 $this->custom_tags = array_merge( 1097 $this->custom_tags, 1098 is_array( $tag_id ) ? $tag_id : [ $tag_id ] 1099 ); 1100 } 1101 1102 return $this; 1103 } 1104 1105 /** 1106 * Sets custom list IDs. 1107 * 1108 * @param int|array $list_id The list IDs. 1109 * @return self 1110 */ 1111 public function set_list( $list_id = null ) { 1112 if ( $list_id ) { 1113 $this->custom_lists = array_merge( 1114 $this->custom_lists, 1115 is_array( $list_id ) ? $list_id : [ $list_id ] 1116 ); 1117 } 1118 1119 return $this; 1120 } 1121 1122 /** 1123 * Removes custom tag IDs. 1124 * 1125 * @param int|array $tag_id The tag IDs. 1126 * @return self 1127 */ 1128 public function remove_tag( $tag_id = null ) { 1129 if ( $tag_id ) { 1130 $this->custom_tags = array_diff( 1131 $this->custom_tags, 1132 is_array( $tag_id ) ? $tag_id : [ $tag_id ] 1133 ); 1134 } 1135 1136 return $this; 1137 } 1138 1139 /** 1140 * Removes custom list IDs. 1141 * 1142 * @param int|array $list_id The list id. 1143 * @return self 1144 */ 1145 public function remove_list( $list_id = null ) { 1146 if ( $list_id ) { 1147 $this->custom_lists = array_diff( 1148 $this->custom_lists, 1149 is_array( $list_id ) ? $list_id : [ $list_id ] 1150 ); 1151 } 1152 1153 return $this; 1154 } 1155 1156 /** 1157 * Returns tag ID by tag name. 1158 * 1159 * @param string $tag The tag. 1160 * @return array 1161 */ 1162 public function get_tag_id( $tag = '' ) { 1163 $tags = $this->get_tags(); 1164 1165 return isset( $tags[ $tag ] ) ? $tags[ $tag ] : []; 1166 } 1167 1168 /** 1169 * Get current list 1170 * 1171 * @return array 1172 */ 1173 public function get_current_list_id() { 1174 $plugin = $this->get_current_plugin(); 1175 1176 return $plugin ? $plugin['list_id'] : []; 1177 } 1178 1179 /** 1180 * Send data to CRM. 1181 * Data WILL BE ONLY SENT IF user allowed to send data from Admin Notice. 1182 * 1183 * @param mixed $data The data to process. 1184 * @throws \Exception If email is not valid. 1185 * @return mixed 1186 */ 1187 protected function sent_to_fluent_server( $data = [] ) { 1188 // Check if email isset and email is valid. 1189 if ( ! isset( $data['email'] ) || ! is_email( $data['email'] ) ) { 1190 throw new \Exception( 'Email is not valid' ); 1191 } 1192 1193 $data = isset( $data ) && $data ? $data : $this->user_data; 1194 1195 $payload = [ 1196 'headers' => [ 1197 'Authorization' => 'Bearer ' . $this->crm_access_token, 1198 ], 1199 'body' => $data, 1200 ]; 1201 1202 $response = wp_remote_post( $this->crm_endpoint, $payload ); 1203 1204 if ( is_wp_error( $response ) ) { 1205 throw new \Exception( esc_html( $response->get_error_message() ) ); 1206 } 1207 1208 $response = json_decode( wp_remote_retrieve_body( $response ), true ); 1209 1210 return $response; 1211 } 1212 1213 /** 1214 * Subscribes to list and tag. 1215 * Data WILL BE ONLY SENT IF user allowed to send data from Admin Notice. 1216 * 1217 * @param string $tag The tag. 1218 * @return mixed 1219 */ 1220 public function subscribe( $tag = 'free' ) { 1221 $data = array_merge( $this->user_data, [ 1222 'tags' => [ $this->get_tag_id( $tag ) ], 1223 'lists' => $this->get_list_id(), 1224 ] ); 1225 1226 return $this->sent_to_fluent_server( $data ); 1227 } 1228 1229 /** 1230 * Unsubscribes from list 1231 * Data WILL BE ONLY SENT IF user allowed to send data from Admin Notice. 1232 * 1233 * @return array 1234 */ 1235 public function unsubscribe_plugin() { 1236 $data = array_merge( $this->user_data, [ 1237 'remove_lists' => $this->get_list_id(), 1238 ] ); 1239 1240 return $this->sent_to_fluent_server( $data ); 1241 } 1242 1243 /** 1244 * Unsubscribes from tag 1245 * Data WILL BE ONLY SENT IF user allowed to send data from Admin Notice. 1246 * 1247 * @param string $tag The tag name. 1248 * @return array 1249 */ 1250 public function unsubscribe_tag( $tag = 'free' ) { 1251 $data = array_merge( $this->user_data, [ 1252 'remove_tags' => $this->get_tag_id( $tag ), 1253 ] ); 1254 1255 return $this->sent_to_fluent_server( $data ); 1256 } 1257 1258 /** 1259 * Unsubscribe from the CRM 1260 * 1261 * @return array 1262 */ 1263 public function unsubscribe() { 1264 $data = array_merge( $this->user_data, [ 'status' => 'unsubscribed' ] ); 1265 1266 return $this->sent_to_fluent_server( $data ); 1267 } 1268 1269 /** 1270 * Returns the current plugin image 1271 * 1272 * @return string 1273 */ 1274 public function get_plugin_image() { 1275 return plugin_dir_url( __FILE__ ) . 'background-image.png'; 1276 } 1277 1278 /** 1279 * Init Plugin 1280 * 1281 * @param string $plugin_id The plugin id. 1282 * @param string $image_url The image url. 1283 * @return self 1284 */ 1285 public static function init( $plugin_id = 'wp_dark_mode', $image_url = null ) { 1286 $instance = new self( $plugin_id ); 1287 1288 // Add plugin image. 1289 add_filter( 'wppool_plugins', function ( $plugins ) use ( $instance, $image_url ) { 1290 $plugins[ $instance->plugin_id ]['background_image'] = isset( $image_url ) ? $image_url : $instance->get_plugin_image(); 1291 1292 return $plugins; 1293 } ); 1294 1295 // Trigger appsero. 1296 $instance->appsero(); 1297 1298 return $instance; 1299 } 1300 1301 /** 1302 * Set campaign details. 1303 * 1304 * @param string $image_url The image URL for the campaign. 1305 * @param string $to End date for the campaign. 1306 * @param string $from Start date for the campaign. 1307 * @param string $button_text The custom CTA text for the button. 1308 * @return mixed 1309 */ 1310 public function set_campaign( $image_url = null, $to = null, $from = null, $button_text = 'Get Premium', $button_link = 'https://go.wppool.dev/8aCD' ) { 1311 // Bailout if image url is not valid. 1312 if ( ! $image_url ) { 1313 return $this; 1314 } 1315 1316 // Set default times if not provided. 1317 $from_time = $from ? strtotime( $from ) : strtotime( 'now' ); 1318 $to_time = $to ? strtotime( $to ) : strtotime( '+2 weeks' ); 1319 $current_time = strtotime( 'now' ); 1320 1321 // If current time is not between from and to date, return. 1322 if ( $current_time < $from_time || $current_time > $to_time ) { 1323 return $this; 1324 } 1325 1326 // Modify the plugin data to include the campaign image, date range, and button text. 1327 add_filter( 'wppool_plugins', function ( $plugins ) use ( $image_url, $to, $from, $button_text, $button_link ) { 1328 $plugins[ $this->plugin_id ]['background_image'] = $image_url; 1329 $plugins[ $this->plugin_id ]['from'] = $from; 1330 $plugins[ $this->plugin_id ]['to'] = $to; 1331 $plugins[ $this->plugin_id ]['button_text'] = $button_text; 1332 $plugins[ $this->plugin_id ]['button_link'] = $button_link; 1333 1334 return $plugins; 1335 } ); 1336 } 1337 } 1338 1339 1340 // Bypass Appsero Local. 1341 add_filter( 'appsero_is_local', '__return_false' ); 1342 1343 // Instantiate the class after plugins loaded. 1344 add_action( 'plugins_loaded', [ '\WPPOOL_Plugin', 'init_plugin_sdk' ] ); 1345 1346 /** 1347 * If WPPOOL_Plugin function does not exists. 1348 * 1349 * @param string $plugin_id The plugin id. 1350 * @param string $image_url The image url. 1351 * @return mixed 1352 */ 1353 function wppool_plugin_init( $plugin_id = 'wp_dark_mode', $image_url = null ) { 1354 return WPPOOL_Plugin::init( $plugin_id, $image_url ); 1355 } 1028 return apply_filters('wppool_inline_styles', $css); 1029 } 1030 1031 /** 1032 * Enqueues scripts. 1033 * 1034 * @return void 1035 */ 1036 public function enqueue_scripts() 1037 { 1038 wp_register_script('wppool-plugins', '', [], time(), true); 1039 1040 // Localize script. 1041 wp_localize_script( 1042 'wppool-plugins', 'WPPOOL_Plugins', [ 1043 'plugins' => $this->get_plugins(), 1044 'debug' => defined('WP_DEBUG') && WP_DEBUG, 1045 ] 1046 ); 1047 1048 wp_enqueue_script('wppool-plugins'); 1049 1050 // Enqueue inline scripts. 1051 wp_add_inline_script('wppool-plugins', $this->get_inline_scripts()); 1052 1053 // Enqueue inline styles. 1054 wp_register_style('wppool-plugins', '', [], time()); 1055 wp_enqueue_style('wppool-plugins'); 1056 wp_add_inline_style('wppool-plugins', $this->get_inline_styles()); 1057 } 1058 1059 /** 1060 * Adds Appsero tracker integration hooks. 1061 * 1062 * @return void 1063 */ 1064 public function listen_appsero() 1065 { 1066 $hook_name = 1067 str_replace('_', '-', $this->plugin_id) . '_tracker_optin'; 1068 1069 add_action( 1070 $hook_name, 1071 [ $this, 'plugin_tracker_optin_callback' ], 1072 10, 1073 1 1074 ); 1075 } 1076 1077 /** 1078 * Appsero for older support 1079 * 1080 * @return void 1081 */ 1082 public function appsero() 1083 { 1084 $this->listen_appsero(); 1085 } 1086 1087 /** 1088 * Callback for Appsero tracker integration hooks. 1089 * 1090 * @param array $data The data. 1091 * @return void 1092 */ 1093 public function plugin_tracker_optin_callback( $data = [] ) 1094 { 1095 $this->user_data = [ 1096 'email' => $data['admin_email'], 1097 'first_name' => $data['first_name'], 1098 'last_name' => $data['last_name'], 1099 ]; 1100 1101 // Subscribe to CRM. 1102 $this->subscribe(); 1103 } 1104 1105 /** 1106 * Sets custom tags IDs. 1107 * set tag 1108 * 1109 * @param int|array $tag_id The tag IDs. 1110 * @return self 1111 */ 1112 public function set_tag( $tag_id = null ) 1113 { 1114 if ($tag_id ) { 1115 $this->custom_tags = array_merge( 1116 $this->custom_tags, 1117 is_array($tag_id) ? $tag_id : [ $tag_id ] 1118 ); 1119 } 1120 1121 return $this; 1122 } 1123 1124 /** 1125 * Sets custom list IDs. 1126 * 1127 * @param int|array $list_id The list IDs. 1128 * @return self 1129 */ 1130 public function set_list( $list_id = null ) 1131 { 1132 if ($list_id ) { 1133 $this->custom_lists = array_merge( 1134 $this->custom_lists, 1135 is_array($list_id) ? $list_id : [ $list_id ] 1136 ); 1137 } 1138 1139 return $this; 1140 } 1141 1142 /** 1143 * Removes custom tag IDs. 1144 * 1145 * @param int|array $tag_id The tag IDs. 1146 * @return self 1147 */ 1148 public function remove_tag( $tag_id = null ) 1149 { 1150 if ($tag_id ) { 1151 $this->custom_tags = array_diff( 1152 $this->custom_tags, 1153 is_array($tag_id) ? $tag_id : [ $tag_id ] 1154 ); 1155 } 1156 1157 return $this; 1158 } 1159 1160 /** 1161 * Removes custom list IDs. 1162 * 1163 * @param int|array $list_id The list id. 1164 * @return self 1165 */ 1166 public function remove_list( $list_id = null ) 1167 { 1168 if ($list_id ) { 1169 $this->custom_lists = array_diff( 1170 $this->custom_lists, 1171 is_array($list_id) ? $list_id : [ $list_id ] 1172 ); 1173 } 1174 1175 return $this; 1176 } 1177 1178 /** 1179 * Returns tag ID by tag name. 1180 * 1181 * @param string $tag The tag. 1182 * @return array 1183 */ 1184 public function get_tag_id( $tag = '' ) 1185 { 1186 $tags = $this->get_tags(); 1187 1188 return isset($tags[ $tag ]) ? $tags[ $tag ] : []; 1189 } 1190 1191 /** 1192 * Get current list 1193 * 1194 * @return array 1195 */ 1196 public function get_current_list_id() 1197 { 1198 $plugin = $this->get_current_plugin(); 1199 1200 return $plugin ? $plugin['list_id'] : []; 1201 } 1202 1203 /** 1204 * Send data to CRM. 1205 * Data WILL BE ONLY SENT IF user allowed to send data from Admin Notice. 1206 * 1207 * @param mixed $data The data to process. 1208 * @throws \Exception If email is not valid. 1209 * @return mixed 1210 */ 1211 protected function sent_to_fluent_server( $data = [] ) 1212 { 1213 // Check if email isset and email is valid. 1214 if (! isset($data['email']) || ! is_email($data['email']) ) { 1215 throw new \Exception('Email is not valid'); 1216 } 1217 1218 $data = isset($data) && $data ? $data : $this->user_data; 1219 1220 $payload = [ 1221 'headers' => [ 1222 'Authorization' => 'Bearer ' . $this->crm_access_token, 1223 ], 1224 'body' => $data, 1225 ]; 1226 1227 $response = wp_remote_post($this->crm_endpoint, $payload); 1228 1229 if (is_wp_error($response) ) { 1230 throw new \Exception(esc_html($response->get_error_message())); 1231 } 1232 1233 $response = json_decode(wp_remote_retrieve_body($response), true); 1234 1235 return $response; 1236 } 1237 1238 /** 1239 * Subscribes to list and tag. 1240 * Data WILL BE ONLY SENT IF user allowed to send data from Admin Notice. 1241 * 1242 * @param string $tag The tag. 1243 * @return mixed 1244 */ 1245 public function subscribe( $tag = 'free' ) 1246 { 1247 $data = array_merge( 1248 $this->user_data, [ 1249 'tags' => [ $this->get_tag_id($tag) ], 1250 'lists' => $this->get_list_id(), 1251 ] 1252 ); 1253 1254 return $this->sent_to_fluent_server($data); 1255 } 1256 1257 /** 1258 * Unsubscribes from list 1259 * Data WILL BE ONLY SENT IF user allowed to send data from Admin Notice. 1260 * 1261 * @return array 1262 */ 1263 public function unsubscribe_plugin() 1264 { 1265 $data = array_merge( 1266 $this->user_data, [ 1267 'remove_lists' => $this->get_list_id(), 1268 ] 1269 ); 1270 1271 return $this->sent_to_fluent_server($data); 1272 } 1273 1274 /** 1275 * Unsubscribes from tag 1276 * Data WILL BE ONLY SENT IF user allowed to send data from Admin Notice. 1277 * 1278 * @param string $tag The tag name. 1279 * @return array 1280 */ 1281 public function unsubscribe_tag( $tag = 'free' ) 1282 { 1283 $data = array_merge( 1284 $this->user_data, [ 1285 'remove_tags' => $this->get_tag_id($tag), 1286 ] 1287 ); 1288 1289 return $this->sent_to_fluent_server($data); 1290 } 1291 1292 /** 1293 * Unsubscribe from the CRM 1294 * 1295 * @return array 1296 */ 1297 public function unsubscribe() 1298 { 1299 $data = array_merge($this->user_data, [ 'status' => 'unsubscribed' ]); 1300 1301 return $this->sent_to_fluent_server($data); 1302 } 1303 1304 /** 1305 * Returns the current plugin image 1306 * 1307 * @return string 1308 */ 1309 public function get_plugin_image() 1310 { 1311 return plugin_dir_url(__FILE__) . 'background-image.png'; 1312 } 1313 1314 /** 1315 * Init Plugin 1316 * 1317 * @param string $plugin_id The plugin id. 1318 * @param string $image_url The image url. 1319 * @return self 1320 */ 1321 public static function init( $plugin_id = 'wp_dark_mode', $image_url = null ) 1322 { 1323 $instance = new self($plugin_id); 1324 1325 // Add plugin image. 1326 add_filter( 1327 'wppool_plugins', function ( $plugins ) use ( $instance, $image_url ) { 1328 $plugins[ $instance->plugin_id ]['background_image'] = isset($image_url) ? $image_url : $instance->get_plugin_image(); 1329 1330 return $plugins; 1331 } 1332 ); 1333 1334 // Trigger appsero. 1335 $instance->appsero(); 1336 1337 return $instance; 1338 } 1339 1340 /** 1341 * Set campaign details. 1342 * 1343 * @param string $image_url The image URL for the campaign. 1344 * @param string $to End date for the campaign. 1345 * @param string $from Start date for the campaign. 1346 * @param string $button_text The custom CTA text for the button. 1347 * @return mixed 1348 */ 1349 public function set_campaign( $image_url = null, $to = null, $from = null, $button_text = 'Get Premium', $button_link = 'https://go.wppool.dev/8aCD' ) 1350 { 1351 // Bailout if image url is not valid. 1352 if (! $image_url ) { 1353 return $this; 1354 } 1355 1356 // Set default times if not provided. 1357 $from_time = $from ? strtotime($from) : strtotime('now'); 1358 $to_time = $to ? strtotime($to) : strtotime('+2 weeks'); 1359 $current_time = strtotime('now'); 1360 1361 // If current time is not between from and to date, return. 1362 if ($current_time < $from_time || $current_time > $to_time ) { 1363 return $this; 1364 } 1365 1366 // Modify the plugin data to include the campaign image, date range, and button text. 1367 add_filter( 1368 'wppool_plugins', function ( $plugins ) use ( $image_url, $to, $from, $button_text, $button_link ) { 1369 $plugins[ $this->plugin_id ]['background_image'] = $image_url; 1370 $plugins[ $this->plugin_id ]['from'] = $from; 1371 $plugins[ $this->plugin_id ]['to'] = $to; 1372 $plugins[ $this->plugin_id ]['button_text'] = $button_text; 1373 $plugins[ $this->plugin_id ]['button_link'] = $button_link; 1374 1375 return $plugins; 1376 } 1377 ); 1378 } 1379 } 1380 1381 1382 // Bypass Appsero Local. 1383 add_filter('appsero_is_local', '__return_false'); 1384 1385 // Instantiate the class after plugins loaded. 1386 add_action('plugins_loaded', [ '\WPPOOL_Plugin', 'init_plugin_sdk' ]); 1387 1388 /** 1389 * If WPPOOL_Plugin function does not exists. 1390 * 1391 * @param string $plugin_id The plugin id. 1392 * @param string $image_url The image url. 1393 * @return mixed 1394 */ 1395 function wppool_plugin_init( $plugin_id = 'wp_dark_mode', $image_url = null ) 1396 { 1397 return WPPOOL_Plugin::init($plugin_id, $image_url); 1398 } 1356 1399 } -
multi-store-sync-addon-for-flexstock/tags/1.1.0/multi-store-sync-addon-for-flexstock.php
r3706977 r3752558 4 4 * Plugin URI: https://wppool.dev/flexstock/addons/multi-store-sync-for-flexstock/ 5 5 * Description: Sync stock with Google Sheet for WooCommerce Multi-Store 6 * Version: 1.0.7 6 * Version: 1.1.0 7 * Requires at least: 7.0 8 * Requires PHP: 7.4 7 9 * Author: WPPOOL 8 10 * Author URI: https://www.wppool.dev/ … … 22 24 define('MULTISSAF_MULTI_STORE_PATH', plugin_dir_path(MULTISSAF_MULTI_STORE)); 23 25 define('MULTISSAF_MULTI_STORE_URL', plugins_url('', __FILE__)); 24 define('MULTISSAF_MULTI_STORE_TEMPLATES', MULTISSAF_MULTI_STORE_PATH . 'templates/'); 25 define('MULTISSAF_MULTI_STORE_VERSION', '1.0.7'); 26 define('MULTISSAF_MULTI_STORE_VERSION', '1.1.0'); 26 27 /** 27 28 * Loading base file … … 32 33 } 33 34 34 /**35 * Manipulating the plugin code WILL NOT ALLOW you to use the premium features.36 * Please download the free version of the plugin from https://wordpress.org/plugins/stock-sync-with-google-sheet-for-woocommerce/37 * Powered by WPPOOL38 * https://wppool.dev/39 */40 35 41 36 42 -
multi-store-sync-addon-for-flexstock/tags/1.1.0/package.json
r3699976 r3752558 1 1 { 2 2 "name": "multi-store-sync-addon-for-flexstock", 3 "version": "1.0. 0",3 "version": "1.0.7", 4 4 "description": "Multi-Store Sync Addon for FlexStock", 5 "main": " index.js",5 "main": "src/index.js", 6 6 "scripts": { 7 "start": "npx webpack --watch", 8 "webpack": "npx webpack --mode production", 9 "sass": "npx sass ./assets/src/css/admin.scss ./assets/build/css/admin.min.css --style=compressed --watch", 10 "sass:build": "npx sass ./assets/src/css/admin.scss ./assets/build/css/admin.min.css --style=compressed", 11 "build": "npm run webpack && npm run sass:build" 7 "start": "wp-scripts start", 8 "build": "wp-scripts build", 9 "translate": "wp i18n make-pot ./ languages/multi-store-sync-addon-for-flexstock.pot --exclude=node_modules,src,build" 12 10 }, 13 11 "keywords": [], 14 "author": " sabbirsam",15 "license": " ISC",12 "author": "WPPOOL", 13 "license": "GPL-2.0-or-later", 16 14 "devDependencies": { 17 "@babel/core": "^7.17.12",18 15 "@babel/plugin-syntax-dynamic-import": "^7.8.3", 19 "@ babel/preset-env": "^7.17.10",20 "alpinejs": "^3.12.2",21 "babel-loader": "^8.2.5",22 " sass": "^1.69.5",23 " webpack": "^5.87.0",24 " webpack-cli": "^4.10.0"16 "@wordpress/scripts": "^31.5.1" 17 }, 18 "dependencies": { 19 "react": "^18.3.1", 20 "react-dom": "^18.3.1", 21 "react-router-dom": "^6.30.1" 25 22 } 26 23 } -
multi-store-sync-addon-for-flexstock/tags/1.1.0/readme.txt
r3706977 r3752558 2 2 Contributors: wppool, devsabbirahmed 3 3 Tags: woocommerce multi store, google sheets, inventory management, product sync, stock management 4 Requires at least: 5.44 Requires at least: 7.0 5 5 Tested up to: 7.1 6 Requires PHP: 5.67 Stable tag: 1. 0.76 Requires PHP: 7.4 7 Stable tag: 1.1.0 8 8 License: GPLv2 or later 9 9 License URI: http://www.gnu.org/licenses/gpl-2.0.html … … 150 150 Share products using the workflow that fits your business: 151 151 * All Products 152 * Products by Category 153 * Products by Tag 152 * Products by Category (Pro) 153 * Products by Tag (Pro) 154 154 * Products by Attribute (Pro) 155 155 * Specific Products by SKU (Pro) … … 174 174 175 175 * **All Products:** Share every published product with connected WooCommerce stores. Available in Free. 176 * **Filtered Products :** Share only products that match specific conditions. Filter by **Category** or **Tag** in Free, and unlock **Attribute** filtering in Pro for more advanced product sharing.176 * **Filtered Products (Pro):** Share only products that match specific conditions, filtered by **Category**, **Tag** or **Attribute**. 177 177 178 178 * **Specific Products (Pro):** Share only selected products using their SKUs. Perfect for syncing specific catalogs, featured products, seasonal collections, or dealer-exclusive inventory. … … 193 193 * Unlimited product variations 194 194 * All Products mode 195 * Filter Products mode 195 196 === Pro === 197 198 Adds: 199 200 * Filtered Products mode 196 201 * Category Filter 197 202 * Tag Filter 198 199 === Pro === 200 201 Unlocks: 202 203 * Advanced Attribute Filter 203 * Attribute Filter 204 204 * Specific SKU Sharing 205 205 … … 302 302 303 303 The following compiled files have human-readable sources in the repository: 304 - ` assets/build/js/admin.min.js` → source in `assets/src/js/dashboard.js`305 - ` assets/build/js/setting.min.js` → source in `assets/src/js/setting.js`304 - `build/index.js` → React source in `src/` (entry: `src/index.js`) 305 - `build/index.css` → source in `src/scss/admin.scss` 306 306 307 307 Build instructions: 308 308 309 1. Run `npm start` in the root to install dependencies.309 1. Run `npm install` in the root to install dependencies. 310 310 2. Run `npm run build` to compile the JavaScript and CSS assets. 311 3. The compiled files are output to ` assets/build/`311 3. The compiled files are output to `build/` 312 312 313 313 == Frequently Asked Questions == … … 432 432 8. Synchronization status 433 433 434 = 1.1.0 = 435 * **Improvement:** Admin dashboard rebuilt with React 436 * **Fix:** Imports no longer update the wrong product when a product ID exists on both stores 437 * **Fix:** Product counts now match the products imported or synced 438 * **Fix:** Variations are included when syncing products to Google Sheets 439 434 440 = 1.0.7 = 435 441 * **Enhancement:** Activation redirect no longer affects front-end requests -
multi-store-sync-addon-for-flexstock/tags/1.1.0/vendor/appsero/src/Client.php
r3699976 r3752558 89 89 90 90 /** 91 * The Object of License Class92 *93 * @var object94 */95 private $license;96 97 /**98 91 * Initialize the class 99 92 * … … 128 121 129 122 return $this->insights; 130 }131 132 /**133 * Initialize plugin/theme updater134 *135 * @return void136 */137 public function updater() {138 // do not show update notice on ajax request and rest api request139 if ( wp_doing_ajax() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {140 return;141 }142 143 // show deprecated notice144 _deprecated_function( __CLASS__ . '::updater', '2.0', '\MultiStoreSyncAddonForFlexStock\Appsero\Updater::init($client);, for more details please visit: https://appsero.com/docs/appsero-developers-guide/appsero-client/appsero-sdk-updater-changes/' );145 146 // initialize the new updater147 if ( method_exists( '\MultiStoreSyncAddonForFlexStock\Appsero\Updater', 'init' ) ) {148 \MultiStoreSyncAddonForFlexStock\Appsero\Updater::init( $this );149 }150 }151 152 /**153 * Initialize license checker154 *155 * @return Appsero\License156 */157 public function license() {158 if ( ! class_exists( __NAMESPACE__ . '\License' ) ) {159 require_once __DIR__ . '/License.php';160 }161 162 // if already instantiated, return the cached one163 if ( $this->license ) {164 return $this->license;165 }166 167 $this->license = new License( $this );168 169 return $this->license;170 123 } 171 124 -
multi-store-sync-addon-for-flexstock/trunk/includes/boot.php
r3609617 r3752558 17 17 require_once MULTISSAF_MULTI_STORE_INCLUDES . 'classes/import-function.php'; 18 18 require_once MULTISSAF_MULTI_STORE_INCLUDES . 'classes/export-functions.php'; 19 require_once MULTISSAF_MULTI_STORE_INCLUDES . 'classes/class-strings.php'; 19 20 // Loads app. 20 21 require_once MULTISSAF_MULTI_STORE_INCLUDES . 'classes/class-app.php'; -
multi-store-sync-addon-for-flexstock/trunk/includes/classes/class-ajax.php
r3706977 r3752558 26 26 27 27 /** 28 * Is license active28 * App class 29 29 * 30 * @var bool30 * @var App 31 31 */ 32 32 public $app; 33 33 34 34 /** 35 * Contains license information.35 * Home URL reported by the source store in the last connection handshake. 36 36 * 37 * @var mixed38 */ 39 p ublic $license;37 * @var string 38 */ 39 private $handshake_source_url = ''; 40 40 41 41 /** … … 56 56 public function add_actions() { 57 57 add_action('wp_ajax_multissaf_multistore_setting', [$this, 'multissaf_save_multi_store_setting']); 58 add_action('wp_ajax_multissaf_request_for_connect', [$this, 'multissaf_request_for_connect']);59 58 add_action('wp_ajax_multissaf_site_remove', [$this, 'multissaf_site_remove']); 60 add_action('wp_ajax_multissaf_mode_change', [$this, 'multissaf_mode_change']);61 add_action('wp_ajax_multissaf_fetch_sku_product', [$this, 'multissaf_fetch_sku_product']);62 add_action('wp_ajax_multissaf_marge_sub_site', [$this, 'multissaf_marge_sub_site']);63 59 add_action('wp_ajax_multissaf_generate_token', [$this, 'multissaf_generate_token']); 64 60 add_action('wp_ajax_multissaf_save_token_settings', [$this, 'multissaf_save_token_settings']); … … 75 71 add_action('wp_ajax_multissaf_manual_import_products', [$this, 'multissaf_manual_import_products']); 76 72 add_action('wp_ajax_multissaf_update_last_sync_time', [$this, 'multissaf_update_last_sync_time']); 77 } 78 /** 79 * Fetches SKU product count with proper authentication and permission checks. 80 * 81 */ 82 public function multissaf_fetch_sku_product() { 83 if ( ! current_user_can('manage_options') ) { 84 $this->send_json(false, __('You do not have permission to do this', 'multi-store-sync-addon-for-flexstock')); 85 } 86 87 $body = $this->get_body(); 88 if ( ! isset($body->nonce) || ! wp_verify_nonce($body->nonce, 'ssgsw_nonce') ) { 89 $this->send_json(false, __('Invalid nonce', 'multi-store-sync-addon-for-flexstock')); 90 } 91 92 $product_count = multissaf_count_all_product_multi_store(); 93 $this->send_json(true, $product_count); 94 } 95 96 /** 97 * Handles the merging of a sub-site's settings and data. 98 * 99 * 100 * @return void Returns a JSON response on failure and proceeds with the logic on success. 101 */ 102 public function multissaf_marge_sub_site() { 103 if ( ! current_user_can('manage_options') ) { 104 $this->send_json(false, __('You do not have permission to do this', 'multi-store-sync-addon-for-flexstock')); 105 } 106 107 $body = $this->get_body(); 108 if ( ! isset($body->nonce) || ! wp_verify_nonce($body->nonce, 'ssgsw_nonce') ) { 109 $this->send_json(false, __('Invalid nonce', 'multi-store-sync-addon-for-flexstock')); 110 } 111 $update = multissaf_get_batch_products($body->offset, $body->batchSize); 112 113 $product_count = 0; 114 if ( is_array($update) && !empty($update)) { 115 $product_count = count($update); 116 } 117 118 $site_url = $body->site_url; 119 $api_url = $site_url .'/wp-json/ssgsw/v1/marge_with_parent'; 120 $token = $body->site_token; 121 $refarance_shop = home_url(); 122 $body = [ 123 'batch_info' => [ 124 'offset' => $body->offset, 125 'batchSize' => $body->batchSize, 126 'index_number' => $body->index_number 127 ], 128 'ssgsw_products' => $update, 129 'ssgsw_api' => true, 130 'refarance_shop' => $refarance_shop 131 ]; 132 133 $response = multissaf_send_api_get_request($api_url, $body, $token); 134 if ($response) { 135 if ($response->success) { 136 $modified_response = [ 137 'count' => $product_count 138 ]; 139 $this->send_json(true, $modified_response); 140 } 141 } else { 142 $this->send_json(false, "Faild to marge product with child."); 143 } 144 } 145 146 /** 147 * Remove site. 148 * 149 * This function handles the logic for removing a parent site from the stored options. 150 * * @return void 151 */ 152 public function multissaf_mode_change() { 153 $body = $this->get_body(); 154 155 if ( ! isset($body->nonce) || ! wp_verify_nonce($body->nonce, 'ssgsw_nonce') ) { 156 $this->send_json(false, __('Invalid nonce', 'multi-store-sync-addon-for-flexstock')); 157 } 158 159 // Check permission. 160 if ( ! current_user_can('manage_options') ) { 161 $this->send_json(false, __('You do not have permission to do this', 'multi-store-sync-addon-for-flexstock')); 162 } 163 164 if ( ! isset($body->options) ) { 165 $this->send_json(false, __('Options not set', 'multi-store-sync-addon-for-flexstock')); 166 } 167 168 $ssgsw_mode = $body->options[ 'ssgsw_mode' ] ?? false; 169 if ($ssgsw_mode) { 170 update_option('ssgsw_multi_store_mode_on', $ssgsw_mode); 171 $this->send_json(true, __('Options saved', 'multi-store-sync-addon-for-flexstock')); 172 } else { 173 $this->send_json(false, __('Options saved', 'multi-store-sync-addon-for-flexstock')); 174 } 73 add_action('wp_ajax_multissaf_repair_main_sheet', [$this, 'multissaf_repair_main_sheet']); 175 74 } 176 75 /** … … 326 225 327 226 /** 328 * Handles a request to connect a site.329 *330 * @param \WP_REST_Request $request The incoming REST API request object.331 * @return \WP_REST_Response|\WP_Error A WP_REST_Response object on success or a WP_Error object on failure.332 */333 public function multissaf_request_for_connect() {334 $body = $this->get_body();335 336 if ( ! isset($body->nonce) || ! wp_verify_nonce($body->nonce, 'ssgsw_nonce') ) {337 $this->send_json(false, __('Invalid nonce', 'multi-store-sync-addon-for-flexstock'));338 }339 340 // Check permission.341 if ( ! current_user_can('manage_options') ) {342 $this->send_json(false, __('You do not have permission to do this', 'multi-store-sync-addon-for-flexstock'));343 }344 345 //Get body from request346 if ( ! isset($body->options) ) {347 $this->send_json(false, __('Options not set', 'multi-store-sync-addon-for-flexstock'));348 }349 350 // 1 store by design to solve server resource.351 $existing_sites = get_option( 'ssgsw_multi_store_list', [] );352 if ( ! is_array( $existing_sites ) ) {353 $existing_sites = [];354 }355 356 357 $child_site = $body->options[ 'child_site' ] ?? false;358 $home_url = isset($child_site['url']) ? $child_site['url'] : '';359 $token = isset($child_site['token']) ? $child_site['token'] : '';360 $body = [361 'request_site' => [362 'url' => home_url(),363 'token' => get_option('ssgsw_token')364 ],365 'connection' => 'add_parent'366 ];367 if(!$home_url) {368 $this->send_json(false, __('Site URL not exits', 'multi-store-sync-addon-for-flexstock'));369 }370 if(!$token) {371 $this->send_json(false, __('Token not exits', 'multi-store-sync-addon-for-flexstock'));372 }373 $api_url = $home_url .'/wp-json/ssgsw/v1/connections';374 $response = multissaf_send_api_post_request($api_url, $body, $token);375 376 if($response) {377 $child_site['isConnected'] = true;378 multissaf_save_child_site($child_site);379 $this->send_json(true, __('Options saved', 'multi-store-sync-addon-for-flexstock'));380 } else {381 382 $this->send_json(false, __('Failed to connect', 'multi-store-sync-addon-for-flexstock'));383 }384 385 }386 /**387 227 * Saves multi-store settings. 388 228 * … … 426 266 } 427 267 } else { 428 //error_log('DEBUG: No settings found in options!');429 268 } 430 269 … … 507 346 wp_die(); 508 347 } 509 /** 510 * Gets the sanitized request body. 511 * Callers must verify nonce and user capabilities. 348 /** 349 * Gets the request body. 512 350 * 513 351 * @return object 514 352 */ 515 public function get_body() { 516 $inputs = []; 517 $raw_input = file_get_contents('php://input'); 518 if (!empty($raw_input)) { 519 $decoded = json_decode($raw_input, true); 520 if (is_array($decoded)) { 521 $inputs = $decoded; 522 } 523 } elseif (!empty($_POST)) { 524 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce and capabilities are validated by calling handler methods. 525 $inputs = wp_unslash($_POST); 526 } 527 528 return (object) $this->sanitize($inputs); 353 public function get_body() { //phpcs:ignore 354 $inputs = json_decode(file_get_contents('php://input'), true); 355 356 if ( is_array($inputs) ) { 357 return (object) $this->sanitize($inputs); 358 } 359 360 return (object) []; 529 361 } 530 362 /** … … 535 367 */ 536 368 public function sanitize( $array ) { //phpcs:ignore 537 if ( ! is_array($array) ) {538 return is_string($array) ? sanitize_text_field($array) : $array;539 }540 369 foreach ( $array as $key => $value ) { 541 370 if ( is_array($value) ) { 542 371 $array[ $key ] = $this->sanitize($value); 543 } else if ( is_string($value) ){372 } else { 544 373 $array[ $key ] = sanitize_text_field($value); 545 } else {546 $array[ $key ] = $value;547 374 } 548 375 } … … 566 393 $this->send_json(false, __('You do not have permission to do this', 'multi-store-sync-addon-for-flexstock')); 567 394 } 568 569 570 395 571 396 $token = ''; … … 621 446 } 622 447 448 // If not found, it means it's a NEW token (from Step 1) -> Create it now 623 449 if (!$updated) { 624 625 626 450 $new_token_data = [ 627 451 'token' => $target_token, … … 729 553 730 554 // 1. Product Scope 555 $include_type = isset($permissions->include_type) ? $permissions->include_type : 'all'; 731 556 $message .= '<div style="font-size:12px; text-transform:uppercase; color:#6b7280; font-weight:600; letter-spacing:0.5px; margin-bottom:12px;">' . __('Product Access', 'multi-store-sync-addon-for-flexstock') . '</div>'; 732 $message .= '<div style="display:inline-block; background-color:#dbeafe; color:#1e40af; padding:4px 12px; border-radius:12px; font-size:13px; font-weight:600; margin-bottom:20px;">' . __('All Products Included', 'multi-store-sync-addon-for-flexstock') . '</div>'; 557 558 if ($include_type === 'all') { 559 $message .= '<div style="display:inline-block; background-color:#dbeafe; color:#1e40af; padding:4px 12px; border-radius:12px; font-size:13px; font-weight:600; margin-bottom:20px;">' . __('All Products Included', 'multi-store-sync-addon-for-flexstock') . '</div>'; 560 } else { 561 /** 562 * Invite e-mail HTML for a token scope other than All Products. 563 * 564 * @param string $html Empty by default. 565 * @param object $permissions Token permissions from the request. 566 * @param string $include_type Normalized scope type. 567 */ 568 $message .= (string) apply_filters( 'multissaf_token_scope_email_html', '', $permissions, $include_type ); 569 } 733 570 734 571 // 2. Fields Layer … … 762 599 $message .= '</div>'; 763 600 } 601 602 // SKU Match Mode in email 603 $sku_match_mode_email = isset($permissions->sku_match_mode) ? $permissions->sku_match_mode : 'match'; 604 $message .= '<div style="margin-top:16px; padding-top:16px; border-top:1px solid #e5e7eb;">'; 605 $message .= '<div style="font-size:12px; text-transform:uppercase; color:#6b7280; font-weight:600; letter-spacing:0.5px; margin-bottom:8px;">' 606 . __('SKU matching mode', 'multi-store-sync-addon-for-flexstock') . '</div>'; 607 608 if ( $sku_match_mode_email === 'alias' ) { 609 $message .= '<div style="display:inline-block; background-color:#dbeafe; color:#1e40af; padding:4px 12px; border-radius:12px; font-size:13px; font-weight:600; margin-bottom:10px;">' 610 . __('SKU alias mapping', 'multi-store-sync-addon-for-flexstock') . '</div>'; 611 612 $sku_aliases_email = isset($permissions->sku_aliases) ? (array) $permissions->sku_aliases : []; 613 if ( ! empty( $sku_aliases_email ) ) { 614 $message .= '<table style="width:100%; font-size:12px; border-collapse:collapse; margin-top:6px;">'; 615 $message .= '<tr><th style="text-align:left; padding:4px 0; color:#6b7280; font-weight:600;">' 616 . __('Source SKU', 'multi-store-sync-addon-for-flexstock') 617 . '</th><th style="text-align:center; padding:4px 0; color:#6b7280;">→</th><th style="text-align:left; padding:4px 0; color:#6b7280; font-weight:600;">' 618 . __('Destination SKU', 'multi-store-sync-addon-for-flexstock') 619 . '</th></tr>'; 620 foreach ( $sku_aliases_email as $alias ) { 621 $src = isset( $alias->source ) ? esc_html( $alias->source ) : ''; 622 $dst = isset( $alias->destination ) ? esc_html( $alias->destination ) : ''; 623 if ( $src || $dst ) { 624 $message .= '<tr>' 625 . '<td style="padding:3px 0; color:#374151;">' . $src . '</td>' 626 . '<td style="text-align:center; color:#9ca3af;">→</td>' 627 . '<td style="padding:3px 0; color:#374151;">' . $dst . '</td>' 628 . '</tr>'; 629 } 630 } 631 $message .= '</table>'; 632 } 633 } else { 634 $message .= '<div style="display:inline-block; background-color:#dbeafe; color:#1e40af; padding:4px 12px; border-radius:12px; font-size:13px; font-weight:600;">' 635 . __('Match by SKU (exact)', 'multi-store-sync-addon-for-flexstock') . '</div>'; 636 } 637 $message .= '</div>'; 764 638 765 639 $message .= '</div>'; // End Details Box … … 878 752 879 753 public function multissaf_disconnect_token() { 880 $body = $this->get_body(); 754 755 if ( ! isset($_SERVER['CONTENT_TYPE']) || strpos($_SERVER['CONTENT_TYPE'], 'application/json') === false ) { 756 $body = (object) $_POST; 757 } else { 758 $body = json_decode(file_get_contents('php://input')); 759 } 881 760 882 761 if ( ! isset($body->nonce) || ! wp_verify_nonce($body->nonce, 'ssgsw_nonce') ) { 883 762 $this->send_json(false, __('Invalid nonce', 'multi-store-sync-addon-for-flexstock')); 884 }885 886 if ( ! current_user_can('manage_options') ) {887 $this->send_json(false, __('Permission denied', 'multi-store-sync-addon-for-flexstock'));888 763 } 889 764 … … 955 830 */ 956 831 private function get_sync_stats($store_url, $store_token) { 832 // Extend timeout only for this long-running remote call 833 if ( function_exists( 'set_time_limit' ) ) { 834 @set_time_limit( 120 ); 835 } 836 957 837 $child_access_data = multissaf_get_sub_product_count($store_url, $store_token, []); 958 838 $total_child_products = 0; … … 974 854 $child_permissions = $raw_permissions; 975 855 } 976 // Include all products. 977 $child_permissions['include_type'] = 'all'; 856 if ( ! isset( $child_permissions['include_type'] ) ) { 857 $child_permissions['include_type'] = 'all'; 858 } 978 859 $allowed_field_names = isset($data_array['field_names']) ? (array)$data_array['field_names'] : []; 979 860 } else if (is_numeric($child_access_data)) { … … 1096 977 */ 1097 978 public function multissaf_get_spreadsheet_tabs() { 1098 if ( ! current_user_can('manage_options') ) {1099 $this->send_json(false, __('You do not have permission to do this', 'multi-store-sync-addon-for-flexstock'));1100 }1101 1102 979 $body = $this->get_body(); 1103 980 if ( ! isset($body->nonce) || ! wp_verify_nonce($body->nonce, 'ssgsw_nonce') ) { … … 1183 1060 */ 1184 1061 public function multissaf_create_spreadsheet_tab() { 1185 if ( ! current_user_can('manage_options') ) {1186 $this->send_json(false, __('You do not have permission to do this', 'multi-store-sync-addon-for-flexstock'));1187 }1188 1189 1062 $body = $this->get_body(); 1190 1063 if ( ! isset($body->nonce) || ! wp_verify_nonce($body->nonce, 'ssgsw_nonce') ) { … … 1255 1128 */ 1256 1129 public function multissaf_get_sync_preview() { 1257 if ( ! current_user_can('manage_options') ) {1258 $this->send_json(false, __('You do not have permission to do this', 'multi-store-sync-addon-for-flexstock'));1259 }1260 1130 1261 1131 $body = $this->get_body(); … … 1276 1146 } 1277 1147 1148 /** 1149 * FlexStock live "store → sheet" handlers that write the main sheet on every 1150 * product/variation save. [ hook, method, priority, accepted args ]. 1151 */ 1152 private function base_live_sheet_handlers() { 1153 return [ 1154 [ 'woocommerce_after_product_object_save', 'after_save_product', 10, 2 ], 1155 [ 'woocommerce_save_product_variation', 'after_update_product_variation', 10, 2 ], 1156 [ 'woocommerce_product_set_stock', 'ssgs_woocommerce_product_set_stock', 10, 1 ], 1157 ]; 1158 } 1159 1160 /** 1161 * Temporarily detach FlexStock's live sheet handlers for the current request. 1162 * 1163 * @return array Handlers that were detached (pass to resume_base_live_sheet_sync()). 1164 */ 1165 private function pause_base_live_sheet_sync() { 1166 $paused = []; 1167 if ( ! class_exists( '\StockSyncWithGoogleSheetForWooCommerce\Hooks' ) || empty( \StockSyncWithGoogleSheetForWooCommerce\Hooks::$instance ) ) { 1168 return $paused; 1169 } 1170 $base = \StockSyncWithGoogleSheetForWooCommerce\Hooks::$instance; 1171 foreach ( $this->base_live_sheet_handlers() as $handler ) { 1172 list( $hook, $method, $priority ) = $handler; 1173 if ( has_action( $hook, [ $base, $method ] ) === $priority ) { 1174 remove_action( $hook, [ $base, $method ], $priority ); 1175 $paused[] = $handler; 1176 } 1177 } 1178 return $paused; 1179 } 1180 1181 /** 1182 * Re-attach handlers detached by pause_base_live_sheet_sync(). 1183 * 1184 * @param array $paused Handlers returned by pause_base_live_sheet_sync(). 1185 */ 1186 private function resume_base_live_sheet_sync( $paused ) { 1187 if ( empty( $paused ) ) { 1188 return; 1189 } 1190 $base = \StockSyncWithGoogleSheetForWooCommerce\Hooks::$instance; 1191 foreach ( $paused as $handler ) { 1192 list( $hook, $method, $priority, $args ) = $handler; 1193 add_action( $hook, [ $base, $method ], $priority, $args ); 1194 } 1195 } 1196 1278 1197 public function multissaf_manual_import_products() { 1279 if ( ! current_user_can('manage_options') ) {1280 $this->send_json(false, __('You do not have permission to do this', 'multi-store-sync-addon-for-flexstock'));1281 }1282 1198 1283 1199 $body = $this->get_body(); … … 1293 1209 if (!$store_url || !$store_token) { 1294 1210 $this->send_json(false, __('Store URL or Token missing', 'multi-store-sync-addon-for-flexstock')); 1211 } 1212 1213 // Extend timeout only immediately before this long-running remote call 1214 if ( function_exists( 'set_time_limit' ) ) { 1215 @set_time_limit( 120 ); 1295 1216 } 1296 1217 … … 1305 1226 ]; 1306 1227 1228 // Pass through the requested product count. 1229 if ( ! empty( $body->max_products ) ) { 1230 $payload['max_products'] = absint( $body->max_products ); 1231 } 1232 // Save the requested product count on the store (0 = all). 1233 if ( 0 === $offset && function_exists( 'multissaf_update_child_site' ) ) { 1234 multissaf_update_child_site( [ 'url' => $store_url ], [ 'max_products' => isset( $payload['max_products'] ) ? $payload['max_products'] : 0 ] ); 1235 } 1236 1307 1237 $response = multissaf_send_api_get_request($api_url, $payload, $store_token); 1308 1238 1309 1239 if ($response && isset($response->success) && $response->success && is_array($response->data)) { 1310 1240 $products = json_decode(json_encode($response->data), true); 1311 1241 1242 // Import at most the requested product count. 1243 if ( ! empty( $payload['max_products'] ) && is_array( $products ) ) { 1244 $products = array_slice( $products, 0, max( 0, $payload['max_products'] - $offset ) ); 1245 } 1246 // Products the source read on this page (incl. ones without SKU it did not send), 1247 $batch_info = isset( $response->batch_info ) ? (array) $response->batch_info : []; 1248 $scanned = isset( $batch_info['scanned'] ) ? absint( $batch_info['scanned'] ) : count( (array) $products ); 1249 if ( ! empty( $payload['max_products'] ) ) { 1250 $scanned = min( $scanned, max( 0, $payload['max_products'] - $offset ) ); 1251 } 1252 $batch_report = [ 1253 'scanned' => $scanned, 1254 'products_no_sku' => isset( $batch_info['products_no_sku'] ) ? absint( $batch_info['products_no_sku'] ) : 0, 1255 'variations_no_sku' => isset( $batch_info['variations_no_sku'] ) ? absint( $batch_info['variations_no_sku'] ) : 0, 1256 ]; 1257 // Variation SKUs the token lists without their parent SKU (skipped by the source). 1258 $scope_notices = isset( $batch_info['scope_notices'] ) ? json_decode( wp_json_encode( $batch_info['scope_notices'] ), true ) : []; 1259 if ( ! empty( $scope_notices['variation_skus_without_parent'] ) && is_array( $scope_notices['variation_skus_without_parent'] ) ) { 1260 $batch_report['variation_skus_without_parent'] = array_map( function ( $item ) { 1261 return [ 1262 'sku' => isset( $item['sku'] ) ? sanitize_text_field( $item['sku'] ) : '', 1263 'parent_sku' => isset( $item['parent_sku'] ) ? sanitize_text_field( $item['parent_sku'] ) : '', 1264 ]; 1265 }, $scope_notices['variation_skus_without_parent'] ); 1266 } 1267 1312 1268 if (!empty($products) && function_exists('multissaf_import_update_products')) { 1313 1269 1270 // One writer for the main sheet: the import itself queues these products for 1271 // the addon's main-tab writer (ssgsw_multistore_sheet_update). FlexStock's live 1272 // save handlers would write the same rows again from a separate index, which 1273 // duplicated rows in Sheet1, so they are paused while the import saves products. 1274 $live_sheet_handlers = $this->pause_base_live_sheet_sync(); 1314 1275 $import_result = multissaf_import_update_products($products, true, [], $store_url, true); 1276 $this->resume_base_live_sheet_sync( $live_sheet_handlers ); 1315 1277 1316 1278 $last_synced = ''; … … 1320 1282 } 1321 1283 1322 $this->send_json(true, [1284 $this->send_json(true, array_merge( [ 1323 1285 'count' => count($products), 1324 1286 'import_result' => $import_result, 1325 1287 'last_synced' => $last_synced 1326 ] );1327 } 1328 $this->send_json(true, ['count' => 0]);1288 ], $batch_report ) ); 1289 } 1290 $this->send_json(true, array_merge( ['count' => 0], $batch_report ) ); 1329 1291 } 1330 1292 $this->send_json(false, __('Failed to fetch products from child store', 'multi-store-sync-addon-for-flexstock')); … … 1335 1297 */ 1336 1298 public function multissaf_sync_batch_to_sheet() { 1337 if ( ! current_user_can('manage_options') ) {1338 $this->send_json(false, __('You do not have permission to do this', 'multi-store-sync-addon-for-flexstock'));1339 }1340 1341 1299 $body = $this->get_body(); 1342 1300 if ( ! isset($body->nonce) || ! wp_verify_nonce($body->nonce, 'ssgsw_nonce') ) { 1343 1301 $this->send_json(false, __('Invalid nonce', 'multi-store-sync-addon-for-flexstock')); 1302 } 1303 1304 // Extend timeout only immediately before this long-running batch sync operation 1305 if ( function_exists( 'set_time_limit' ) ) { 1306 @set_time_limit( 120 ); 1344 1307 } 1345 1308 … … 1373 1336 } 1374 1337 } 1375 1376 // Since Google Sheets API will natively reject invalid tab names during the update_values call, 1377 // we do not need to strictly block the sync here if get_sheet_tab() returned cached/stale data. 1378 // If it's cached, $tab_exists might be false even if the tab was just created. 1338 1379 1339 if (!$tab_exists && empty($tabs)) { 1380 1340 // Only block if we literally couldn't fetch ANY tabs (API error or permissions) … … 1386 1346 } 1387 1347 1388 // Fetch from Child — extend timeout only immediately before this long-running remote call 1389 if ( function_exists( 'set_time_limit' ) ) { 1390 @set_time_limit( 120 ); 1391 } 1348 // Fetch from Child 1392 1349 $api_url = trailingslashit($store_url) . 'wp-json/ssgsw/v1/retrive_child'; 1393 1350 $payload = [ … … 1399 1356 'access_token' => $store_token, 1400 1357 ]; 1358 // Same product count as the import, so the sheet gets the same products. 1359 if ( ! empty( $body->max_products ) ) { 1360 $payload['max_products'] = absint( $body->max_products ); 1361 } 1401 1362 $response = multissaf_send_api_get_request($api_url, $payload, $store_token); 1402 1363 $processed_count = 0; … … 1455 1416 $new_colmun = array_search($status_label, $headers_row); 1456 1417 1457 if (false !== $new_colmun ) {1418 if (false !== $new_colmun && function_exists('ssgsw_get_all_product_statuses')) { 1458 1419 $dropdown = ssgsw_get_all_product_statuses(); 1459 1420 $status_count = count($dropdown); … … 1504 1465 */ 1505 1466 public function multissaf_update_site_settings() { 1506 if ( ! current_user_can('manage_options') ) {1507 $this->send_json(false, __('You do not have permission to do this', 'multi-store-sync-addon-for-flexstock'));1508 }1509 1510 1467 $body = $this->get_body(); 1511 1468 if ( ! isset($body->nonce) || ! wp_verify_nonce($body->nonce, 'ssgsw_nonce') ) { … … 1555 1512 } 1556 1513 1557 if (function_exists('multissaf_update_child_site')) { 1558 // First try to update 1559 $updated = multissaf_update_child_site(['url' => $store_url], $settings); 1560 1561 if (!$updated && $store_token) { 1562 // IF NOT UPDATED, means it's newly verified. Add it to our local list. 1563 $child_site = [ 1564 'url' => $store_url, 1565 'token' => $store_token, 1566 'isConnected' => true, 1567 'tab_name' => $tab_name, 1568 'sheet_gid' => $sheet_gid, 1569 'permissions' => isset($settings['permissions']) ? $settings['permissions'] : null, 1570 'site_icon' => $site_icon, 1571 ]; 1572 if (function_exists('multissaf_save_child_site')) { 1573 multissaf_save_child_site($child_site); 1574 } 1575 1576 // Proceed to add parent on the remote site side and activate in one pass. 1577 $api_url = trailingslashit($store_url) . 'wp-json/ssgsw/v1/connections'; 1578 $current_db_mode = get_option('ssgsw_multi_store_mode_on'); 1579 $mode_param = ($current_db_mode === 'shop_to_sheet') ? 'manage' : 'sync'; 1580 1581 $payload = [ 1582 'request_site' => [ 1583 'url' => home_url(), 1584 'token' => get_option('ssgsw_token', '') 1585 ], 1586 'connection' => 'add_parent', 1587 'mode' => $mode_param 1588 ]; 1589 if (function_exists('multissaf_send_api_get_request')) { 1590 multissaf_send_api_get_request($api_url, $payload, $store_token); 1591 } 1592 1593 // Set active status on remote (fire-and-forget: non-blocking, we don't need the response) 1594 $status_payload = [ 1595 'request_site' => [ 1596 'url' => home_url(), 1597 'token' => get_option('ssgsw_token', '') 1598 ], 1599 'connection' => 'update_status', 1600 'status' => 'active', 1601 'mode' => $mode_param, 1602 ]; 1603 wp_remote_post( $api_url, [ 1604 'method' => 'POST', 1605 'headers' => [ 1606 'Content-Type' => 'application/json', 1607 'SSGSWKEY' => 'Bearer ' . $store_token, 1608 ], 1609 'body' => wp_json_encode( $status_payload ), 1610 'timeout' => 0.01, 1611 'blocking' => false, 1612 'sslverify' => apply_filters( 'ssgsw_multistore_ssl_verify', true ), 1613 ] ); 1614 1615 $this->send_json(true, ['message' => __('Settings updated and site saved.', 'multi-store-sync-addon-for-flexstock')]); 1616 } else if ($updated) { 1617 $this->send_json(true, ['message' => __('Settings updated', 'multi-store-sync-addon-for-flexstock')]); 1618 } else { 1619 $this->send_json(false, __('Site not found and token is missing to create it', 'multi-store-sync-addon-for-flexstock')); 1620 } 1621 } else { 1514 if ( ! function_exists( 'multissaf_update_child_site' ) || ! function_exists( 'multissaf_save_child_site' ) ) { 1622 1515 $this->send_json(false, __('Update function missing', 'multi-store-sync-addon-for-flexstock')); 1623 1516 } 1517 1518 // Find the store in our list (it stays there, disconnected, after a disconnect). 1519 $existing_site = null; 1520 $connected_sites = get_option( 'ssgsw_multi_store_list', [] ); 1521 if ( is_array( $connected_sites ) ) { 1522 foreach ( $connected_sites as $site ) { 1523 if ( isset( $site['url'] ) && rtrim( $site['url'], '/' ) === rtrim( $store_url, '/' ) ) { 1524 $existing_site = $site; 1525 break; 1526 } 1527 } 1528 } 1529 $was_connected = $existing_site && ! empty( $existing_site['isConnected'] ) && 'false' !== $existing_site['isConnected']; 1530 $reconnecting = $existing_site && ! $was_connected && ! empty( $settings['isConnected'] ); 1531 1532 if ( ! $existing_site && ! $store_token ) { 1533 $this->send_json(false, __('Site not found and token is missing to create it', 'multi-store-sync-addon-for-flexstock')); 1534 } 1535 1536 // A new or reconnected store must register this site on the source again, 1537 // otherwise the source keeps it disconnected and stops sending updates. 1538 // Product count for this store (0 = all); the source store keeps it on the token. 1539 $max_products = isset( $settings['max_products'] ) && '' !== $settings['max_products'] ? absint( $settings['max_products'] ) : null; 1540 1541 if ( ! $existing_site || $reconnecting ) { 1542 $handshake_token = $store_token ? $store_token : ( isset( $existing_site['token'] ) ? $existing_site['token'] : '' ); 1543 $handshake = $this->register_with_source_store( $store_url, $handshake_token, $max_products ); 1544 if ( true !== $handshake ) { 1545 $this->send_json( false, $handshake ); 1546 } 1547 if ( null !== $max_products ) { 1548 $settings['limit_sent'] = $max_products; 1549 } 1550 // The source's own address, used to recognise its updates (see multissaf_find_child_site()). 1551 if ( $this->handshake_source_url ) { 1552 $settings['source_url'] = $this->handshake_source_url; 1553 } 1554 } 1555 1556 // Send a changed product count to the source store. 1557 if ( $existing_site && ! $reconnecting && $was_connected && null !== $max_products 1558 && ( ! isset( $existing_site['limit_sent'] ) || absint( $existing_site['limit_sent'] ) !== $max_products ) 1559 && ! empty( $existing_site['token'] ) && function_exists( 'multissaf_send_api_get_request' ) ) { 1560 $limit_response = multissaf_send_api_get_request( trailingslashit( $store_url ) . 'wp-json/ssgsw/v1/connections', [ 1561 'request_site' => [ 'url' => home_url(), 'token' => get_option( 'ssgsw_token', '' ) ], 1562 'connection' => 'update_limit', 1563 'max_products' => $max_products, 1564 ], $existing_site['token'] ); 1565 if ( $limit_response && ! is_wp_error( $limit_response ) && ! empty( $limit_response->success ) ) { 1566 $settings['limit_sent'] = $max_products; 1567 } 1568 } 1569 1570 if ( $existing_site ) { 1571 multissaf_update_child_site( [ 'url' => $store_url ], $settings ); 1572 $this->send_json(true, ['message' => __('Settings updated', 'multi-store-sync-addon-for-flexstock')]); 1573 } 1574 1575 multissaf_save_child_site( [ 1576 'url' => $store_url, 1577 'token' => $store_token, 1578 'isConnected' => true, 1579 'tab_name' => $tab_name, 1580 'sheet_gid' => $sheet_gid, 1581 'permissions' => isset($settings['permissions']) ? $settings['permissions'] : null, 1582 'site_icon' => $site_icon, 1583 'source_url' => isset( $settings['source_url'] ) ? $settings['source_url'] : '', 1584 'max_products' => $max_products, 1585 'limit_sent' => isset( $settings['limit_sent'] ) ? $settings['limit_sent'] : null, 1586 ] ); 1587 $this->send_json(true, ['message' => __('Settings updated and site saved.', 'multi-store-sync-addon-for-flexstock')]); 1624 1588 } 1625 1589 1590 /** 1591 * Registers this site on the source store (add_parent, then update_status = active). 1592 * 1593 * @param string $store_url Source store URL. 1594 * @param string $store_token Token issued by the source store. 1595 * @param int|null $max_products Product count for this store (0 = all). 1596 * @return true|string True on success, otherwise an error message. 1597 */ 1598 private function register_with_source_store( $store_url, $store_token, $max_products = null ) { 1599 if ( ! $store_token || ! function_exists( 'multissaf_send_api_get_request' ) ) { 1600 return __( 'Store token missing', 'multi-store-sync-addon-for-flexstock' ); 1601 } 1602 1603 $api_url = trailingslashit( $store_url ) . 'wp-json/ssgsw/v1/connections'; 1604 $mode_param = ( get_option( 'ssgsw_multi_store_mode_on' ) === 'shop_to_sheet' ) ? 'manage' : 'sync'; 1605 $request_site = [ 1606 'url' => home_url(), 1607 'token' => get_option( 'ssgsw_token', '' ), 1608 ]; 1609 1610 $limit = null !== $max_products ? [ 'max_products' => absint( $max_products ) ] : []; 1611 $response = multissaf_send_api_get_request( $api_url, array_merge( [ 1612 'request_site' => $request_site, 1613 'connection' => 'add_parent', 1614 'mode' => $mode_param, 1615 ], $limit ), $store_token ); 1616 if ( ! $response || is_wp_error( $response ) || empty( $response->success ) ) { 1617 return ( $response && ! is_wp_error( $response ) && ! empty( $response->message ) ) 1618 ? $response->message 1619 : __( 'The source store did not accept the connection. Please check the URL and Token.', 'multi-store-sync-addon-for-flexstock' ); 1620 } 1621 $this->handshake_source_url = ( isset( $response->data->source_url ) && is_string( $response->data->source_url ) ) 1622 ? esc_url_raw( $response->data->source_url ) 1623 : ''; 1624 1625 $response = multissaf_send_api_get_request( $api_url, array_merge( [ 1626 'request_site' => $request_site, 1627 'connection' => 'update_status', 1628 'status' => 'active', 1629 'mode' => $mode_param, 1630 ], $limit ), $store_token ); 1631 if ( $response && ! is_wp_error( $response ) && isset( $response->success ) && ! $response->success && ! empty( $response->message ) ) { 1632 return $response->message; 1633 } 1634 1635 return true; 1636 } 1637 1638 /** 1639 * Repairs this site's main sheet (Sheet1) for the products imported from one 1640 * connected store: removes duplicate rows of those products, then re-writes them 1641 * through the addon's main-sheet writer (updates in place, appends missing rows). 1642 * Rows of other products on the sheet are not touched. 1643 */ 1644 public function multissaf_repair_main_sheet() { 1645 $body = $this->get_body(); 1646 if ( ! isset( $body->nonce ) || ! wp_verify_nonce( $body->nonce, 'ssgsw_nonce' ) ) { 1647 $this->send_json( false, __( 'Invalid nonce', 'multi-store-sync-addon-for-flexstock' ) ); 1648 } 1649 if ( ! current_user_can( 'manage_options' ) ) { 1650 $this->send_json( false, __( 'You do not have permission to perform this action.', 'multi-store-sync-addon-for-flexstock' ) ); 1651 } 1652 $store_url = isset( $body->store_url ) ? esc_url_raw( $body->store_url ) : ''; 1653 if ( ! $store_url || ! class_exists( '\StockSyncWithGoogleSheetForWooCommerce\Sheet' ) || ! function_exists( 'ssgsw_get_option' ) ) { 1654 $this->send_json( false, __( 'Store URL missing', 'multi-store-sync-addon-for-flexstock' ) ); 1655 } 1656 1657 // Products imported from this store (parents carry the remote-ID mapping) + their variations. 1658 global $wpdb; 1659 $keys = array_unique( [ 1660 '_ssgsw_remote_id_' . md5( $store_url ), 1661 '_ssgsw_remote_id_' . md5( trailingslashit( $store_url ) ), 1662 '_ssgsw_remote_id_' . md5( untrailingslashit( $store_url ) ), 1663 ] ); 1664 $placeholders = implode( ',', array_fill( 0, count( $keys ), '%s' ) ); 1665 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared 1666 $parent_ids = array_map( 'intval', $wpdb->get_col( $wpdb->prepare( 1667 "SELECT DISTINCT pm.post_id FROM {$wpdb->postmeta} pm 1668 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id AND p.post_type = 'product' AND p.post_status <> 'trash' 1669 WHERE pm.meta_key IN ($placeholders)", 1670 ...$keys 1671 ) ) ); 1672 if ( empty( $parent_ids ) ) { 1673 $this->send_json( true, [ 'duplicates_removed' => 0, 'products' => 0 ] ); 1674 } 1675 $parent_placeholders = implode( ',', array_fill( 0, count( $parent_ids ), '%d' ) ); 1676 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared 1677 $variation_ids = array_map( 'intval', $wpdb->get_col( $wpdb->prepare( 1678 "SELECT ID FROM {$wpdb->posts} WHERE post_type = 'product_variation' AND post_status <> 'trash' AND post_parent IN ($parent_placeholders)", 1679 ...$parent_ids 1680 ) ) ); 1681 $product_ids = array_merge( $parent_ids, $variation_ids ); 1682 $wanted = array_flip( $product_ids ); 1683 1684 // Read the main sheet fresh and find duplicate rows of those products (keep the first). 1685 $sheet_tab = ssgsw_get_option( 'sheet_tab' ); 1686 $sheet = new \StockSyncWithGoogleSheetForWooCommerce\Sheet(); 1687 $sheet->setTab( $sheet_tab ); 1688 $sheet->setSheetID( ssgsw_get_option( 'sheet_id' ) ); 1689 $rows = $sheet->get_formula_value( false, false, $sheet_tab ); 1690 1691 $seen = []; 1692 $duplicate = []; 1693 if ( is_array( $rows ) ) { 1694 foreach ( $rows as $index => $row ) { 1695 if ( 0 === $index || ! isset( $row[0] ) || ! is_numeric( $row[0] ) ) { 1696 continue; // header or non-product row 1697 } 1698 $id = (int) $row[0]; 1699 if ( ! isset( $wanted[ $id ] ) ) { 1700 continue; // not a product imported from this store 1701 } 1702 if ( isset( $seen[ $id ] ) ) { 1703 $duplicate[] = $index + 1; // sheet row number 1704 } else { 1705 $seen[ $id ] = true; 1706 } 1707 } 1708 } 1709 if ( ! empty( $duplicate ) ) { 1710 $sheet->delete_batch_rows( $duplicate ); 1711 } 1712 1713 // Products missing from the sheet may still carry the "row written" marker; clear it 1714 // so the writer appends them, then refresh all of them through the main-sheet writer. 1715 foreach ( $product_ids as $id ) { 1716 if ( ! isset( $seen[ $id ] ) ) { 1717 delete_post_meta( $id, '_ssgsw_main_sheet_row_written' ); 1718 } 1719 } 1720 delete_transient( apply_filters( 'multissaf_sheets_info_cache_key', 'multissaf_sheets_info_' . md5( ssgsw_get_option( 'spreadsheet_id' ) . '_' . $sheet_tab ) ) ); 1721 do_action( 'ssgsw_multistore_sheet_update', [ 'product_ids' => $product_ids ] ); 1722 1723 $this->send_json( true, [ 'duplicates_removed' => count( $duplicate ), 'products' => count( $parent_ids ) ] ); 1724 } 1725 1626 1726 public function multissaf_update_last_sync_time() { 1627 if ( ! current_user_can('manage_options') ) {1628 $this->send_json(false, __('You do not have permission to do this', 'multi-store-sync-addon-for-flexstock'));1629 }1630 1727 1631 1728 $body = $this->get_body(); -
multi-store-sync-addon-for-flexstock/trunk/includes/classes/class-api.php
r3699976 r3752558 33 33 /** 34 34 * Current request permissions 35 * 35 36 * @var array 36 37 */ … … 68 69 'methods' => [ 'GET', 'POST' ], 69 70 'callback' => [ $this, 'verify_connections_callback' ], 70 'permission_callback' => [ $this, 'permission_callback' ],71 ],72 'marge_with_parent' => [73 'methods' => [ 'GET', 'POST' ],74 'callback' => [ $this, 'marge_with_parent_callback' ],75 71 'permission_callback' => [ $this, 'permission_callback' ], 76 72 ], … … 111 107 112 108 // Check if token is in request body or query params 113 if ( empty( $bearer) ) {109 if (empty($bearer) ) { 114 110 $bearer = $request->get_param('access_token'); 115 111 } … … 124 120 125 121 $is_valid = false; 126 // Check single token (legacy or main)122 // Check the site's own token 127 123 if (!empty($saved_token) && $bearer === $saved_token) { 128 124 $is_valid = true; 129 $this->current_permissions = [ ' scope' => 'all', 'fields' => [ 'all_fields' ] ];125 $this->current_permissions = [ 'include_type' => 'all', 'fields' => [ 'all_fields' ] ]; 130 126 } 131 127 … … 233 229 $token_data['main_store'] = $request_site['url']; 234 230 $token_data['status'] = 'pending'; 231 $token_updated = true; 232 } 233 // Product count chosen by the connecting store. 234 if (isset($params['max_products']) && trailingslashit($token_data['main_store']) === trailingslashit($request_site['url']) ) { 235 $token_data['max_products'] = absint($params['max_products']); 235 236 $token_updated = true; 236 237 } … … 400 401 if($status === 'active' && isset($request_site['url'])) { 401 402 $t_data['main_store'] = $request_site['url']; 403 if (isset($params['max_products']) ) { 404 $t_data['max_products'] = absint($params['max_products']); 405 } 402 406 } else { 403 407 unset($t_data['main_store']); … … 413 417 } 414 418 } 419 } 420 } 421 } else if ('update_limit' === $connection) { 422 // The store using this token sets how many products it takes (0 = all). 423 $bearer2 = isset($_SERVER['HTTP_AUTHORIZATION']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_AUTHORIZATION'])) : ''; 424 $bearer3 = isset($_SERVER['HTTP_SSGSWKEY']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_SSGSWKEY'])) : ''; 425 $bearer = str_replace('Bearer ', '', empty($bearer3) ? $bearer2 : $bearer3); 426 if ($bearer && $request_site && isset($params['max_products']) ) { 427 $child_tokens = get_option('ssgsw_child_tokens', []); 428 if (is_array($child_tokens) ) { 429 foreach ( $child_tokens as &$t_data ) { 430 if (isset($t_data['token']) && $bearer === $t_data['token'] 431 && ! empty($t_data['main_store']) && isset($request_site['url']) 432 && trailingslashit($t_data['main_store']) === trailingslashit($request_site['url']) 433 ) { 434 $t_data['max_products'] = absint($params['max_products']); 435 update_option('ssgsw_child_tokens', $child_tokens); 436 $is_successful = true; 437 break; 438 } 439 } 440 unset($t_data); 415 441 } 416 442 } … … 440 466 } 441 467 if ($is_successful) { 442 return $this->response(true, [ 468 return $this->response( 469 true, [ 443 470 'message' => 'Connection verified successfully.', 444 'permissions' => !empty($this->current_permissions['permissions']) ? $this->current_permissions['permissions'] : $this->current_permissions 445 ]); 471 'permissions' => !empty($this->current_permissions['permissions']) ? $this->current_permissions['permissions'] : $this->current_permissions, 472 // This site's own address: it is what we send as refarance_shop with updates, 473 // which can differ from the URL the other store used to connect. 474 'source_url' => home_url(), 475 ] 476 ); 446 477 } else { 447 478 return $this->response(false, 'Failed to perform the requested action.'); … … 458 489 { 459 490 $permissions = $this->current_permissions; 460 if ( empty( $permissions) ) {491 if (empty($permissions) ) { 461 492 $token = $request->get_param('access_token'); 462 493 463 if ( ! empty( $token) ) {494 if (! empty($token) ) { 464 495 $child_tokens = get_option('ssgsw_child_tokens', []); 465 if ( is_array( $child_tokens) ) {496 if (is_array($child_tokens) ) { 466 497 foreach ( $child_tokens as $token_data ) { 467 if ( isset( $token_data['token']) && $token === $token_data['token'] ) {468 $permissions = isset( $token_data['permissions']) ? $token_data['permissions'] : [];498 if (isset($token_data['token']) && $token === $token_data['token'] ) { 499 $permissions = isset($token_data['permissions']) ? $token_data['permissions'] : []; 469 500 break; 470 501 } … … 476 507 if (function_exists('multissaf_get_access_data_by_token')) { 477 508 $data = multissaf_get_access_data_by_token($permissions); 509 478 510 return $this->response(true, $data); 479 511 } … … 482 514 } 483 515 /** 484 * API Callback to merge data with a parent site.516 * API Callback to retrieve child site data. 485 517 * 486 518 * @param \WP_REST_Request $request The request object. 487 519 * @return \WP_REST_Response 488 520 */ 489 public function marge_with_parent_callback( \WP_REST_Request $request ){ 490 $products_data = $request->get_json_params(); 491 if (empty($products_data) ) { 492 return $this->response(false, 'No product data provided.'); 493 } 494 495 $reqest = multissaf_handle_product_import_api($products_data); 496 if(isset($reqest['success']) && $reqest['success']) { 497 return $this->response(true, 'Product data merged with parent successfully.'); 498 } else { 499 return $this->response(false, isset($reqest['message']) ? $reqest['message'] : 'Failed to merge product data.'); 500 } 501 } 502 503 /** 504 * API Callback to retrieve child site data. 505 * 506 * @param \WP_REST_Request $request The request object. 507 * @return \WP_REST_Response 508 */ 509 public function retrive_child_callback( \WP_REST_Request $request ){ 521 public function retrive_child_callback( \WP_REST_Request $request ) 522 { 510 523 $products_data = $request->get_json_params(); 511 524 … … 518 531 $product_ids = isset($products_data['product_ids']) ? $products_data['product_ids'] : []; 519 532 $product_skus = isset($products_data['product_skus']) ? $products_data['product_skus'] : []; 520 $access_token = isset($products_data['access_token']) ? $products_data['access_token'] : (isset($products_data['token_id']) ? $products_data['token_id'] : '');533 $access_token = isset($products_data['access_token']) ? $products_data['access_token'] : ''; 521 534 522 535 // Resolve permissions from token 523 if ( ! empty( $access_token) ) {524 $child_tokens = get_option( 'ssgsw_child_tokens', []);525 if ( is_array( $child_tokens) ) {536 if (! empty($access_token) ) { 537 $child_tokens = get_option('ssgsw_child_tokens', []); 538 if (is_array($child_tokens) ) { 526 539 foreach ( $child_tokens as $token_data ) { 527 if ( isset( $token_data['token']) && $access_token === $token_data['token'] ) {528 $this->current_permissions = isset( $token_data['permissions']) ? (array) $token_data['permissions'] : [];540 if (isset($token_data['token']) && $access_token === $token_data['token'] ) { 541 $this->current_permissions = isset($token_data['permissions']) ? (array) $token_data['permissions'] : []; 529 542 break; 530 543 } … … 533 546 } 534 547 535 // Normalize: old tokens stored full token_data as current_permissions (with nested 'permissions' key)548 // permission_callback stores the full token record; use its 'permissions' entry. 536 549 $permissions = $this->current_permissions; 537 if ( ! empty( $permissions['permissions'] ) && is_array( $permissions['permissions']) ) {550 if (! empty($permissions['permissions']) && is_array($permissions['permissions']) ) { 538 551 $permissions = $permissions['permissions']; 539 552 } 540 553 541 // Include all products.542 $permissions['include_type'] = 'all';543 554 if (! isset($permissions['include_type']) ) { 555 $permissions['include_type'] = 'all'; 556 } 544 557 545 558 … … 550 563 // Support fetching by SKU 551 564 // If product_skus is provided, convert SKUs to IDs first 552 if ( ! empty( $product_skus ) && is_array( $product_skus) ) {565 if (! empty($product_skus) && is_array($product_skus) ) { 553 566 global $wpdb; 554 $placeholders = implode( ',', array_fill( 0, count( $product_skus ), '%s' ));567 $placeholders = implode(',', array_fill(0, count($product_skus), '%s')); 555 568 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared 556 569 $product_ids = $wpdb->get_col( … … 563 576 ) 564 577 ); 565 $product_ids = array_map( 'intval', $product_ids);578 $product_ids = array_map('intval', $product_ids); 566 579 } 567 580 581 // Optional total product count for imports and offset-paged exports. 582 $max_products = isset($products_data['max_products']) ? absint($products_data['max_products']) : 0; 583 // The token's own product count applies as well. 584 $limit_token = $access_token; 585 if (! $limit_token ) { 586 $limit_token = isset($_SERVER['HTTP_SSGSWKEY']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_SSGSWKEY'])) : ( isset($_SERVER['HTTP_AUTHORIZATION']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_AUTHORIZATION'])) : '' ); 587 $limit_token = str_replace('Bearer ', '', $limit_token); 588 } 589 $token_limit = function_exists('multissaf_get_token_max_products') ? multissaf_get_token_max_products((string) $limit_token) : 0; 590 if ($token_limit && ( ! $max_products || $token_limit < $max_products ) ) { 591 $max_products = $token_limit; 592 } 593 if ($max_products > 0 && ( $import_request || empty($product_ids) ) ) { 594 $offset = absint($offset); 595 if ($offset >= $max_products ) { 596 return $this->response(true, []); 597 } 598 $batchSize = absint($batchSize); 599 $batchSize = $batchSize > 0 ? min($batchSize, $max_products - $offset) : $max_products - $offset; 600 } 601 568 602 if ($import_request) { 569 603 if (function_exists('multissaf_get_batch_products')) { 570 $data_divided = multissaf_get_batch_products($offset, $batchSize, $permissions); 604 $batch_info = null; 605 $data_divided = multissaf_get_batch_products($offset, $batchSize, $permissions, $batch_info); 606 // Notices about the token's scope (e.g. variation SKUs listed without their 607 // parent SKU), sent once with the first batch. 608 if (0 === absint($offset) ) { 609 $batch_info['scope_notices'] = (array) apply_filters('multissaf_token_scope_notices', [], $permissions); 610 } 611 // batch_info sits next to "data", so destinations that do not read it are unaffected. 612 return new \WP_REST_Response( 613 [ 614 'success' => true, 615 'data' => $data_divided, 616 'batch_info' => $batch_info, 617 ] 618 ); 571 619 } 572 620 } else { … … 592 640 * @return \WP_REST_Response 593 641 */ 594 public function update_single_callback( \WP_REST_Request $request ){ 642 public function update_single_callback( \WP_REST_Request $request ) 643 { 595 644 596 645 $product_data = $request->get_json_params(); … … 599 648 } 600 649 $store_role = get_option('ssgsw_multi_connect_mode_on'); 601 $sync_mode = get_option('ssgsw_multi_store_mode_on');650 $sync_mode = get_option('ssgsw_multi_store_mode_on'); 602 651 $all_target_ids = []; 603 652 604 if($sync_mode == 'shop_to_sheet') {605 if ( function_exists('multissaf_sync_to_specific_store_sheet') ) {653 if($sync_mode == 'shop_to_sheet') { 654 if (function_exists('multissaf_sync_to_specific_store_sheet') ) { 606 655 multissaf_sync_to_specific_store_sheet($product_data); 607 656 } 608 } else {657 } else { 609 658 if (isset($product_data['ssgsw_products'])) { 610 611 $results = multissaf_handle_product_import_api($product_data, false); 659 // New products from the store are created only within its product count. 660 $refrance_shop = isset($product_data['refarance_shop']) ? $product_data['refarance_shop'] : ''; 661 $create_limit = function_exists('multissaf_get_store_create_allowance') ? multissaf_get_store_create_allowance($refrance_shop) : null; 662 $results = multissaf_handle_product_import_api($product_data, false, $create_limit); 612 663 if (isset($results['data']['product_ids'])) { 613 664 $all_target_ids = $results['data']['product_ids']; 614 665 } else { 615 // error_log('[SSGSW DEBUG] Import Failed or returned no IDs. Results: ' . print_r($results, true));616 666 } 617 667 // Trigger local cron unconditionally so scheduled sheet updates fire promptly, … … 620 670 spawn_cron(); 621 671 } 622 }672 } 623 673 624 674 625 return $this->response(true, [ 675 return $this->response( 676 true, [ 626 677 'message' => 'Update processed.', 627 678 'target_ids' => $all_target_ids 628 ]); 679 ] 680 ); 629 681 } 630 682 -
multi-store-sync-addon-for-flexstock/trunk/includes/classes/class-app.php
r3661535 r3752558 17 17 18 18 /** 19 * Checks base FlexStock plugin install/activation state and license validity 20 * for the multistore addon. 19 * Checks base FlexStock plugin install/activation state for the multistore addon. 21 20 * 22 21 * @package MultiStoreSyncAddonforFlexStock … … 25 24 { 26 25 27 /**28 * @var string Base plugin file path relative to WP_PLUGIN_DIR.29 */26 /** 27 * @var string Base plugin file path relative to WP_PLUGIN_DIR. 28 */ 30 29 public $free_version = 'stock-sync-with-google-sheet-for-woocommerce/stock-sync-with-google-sheet-for-woocommerce.php'; 31 30 … … 43 42 return false; 44 43 } 45 44 45 /** 46 * Checks if the plugin is activated 47 * 48 * @return bool 49 */ 50 public function is_plugin_activated() 51 { 52 if (is_plugin_active($this->free_version) ) { 53 return true; 54 } 55 56 return false; 57 } 58 46 59 } 47 60 } -
multi-store-sync-addon-for-flexstock/trunk/includes/classes/class-hooks.php
r3706977 r3752558 5 5 * 6 6 * @package MultiStoreSyncAddonforFlexStock 7 * @since 1.0.07 * @since 1.0.0 8 8 */ 9 9 … … 14 14 15 15 // Exit if accessed directly. 16 defined( 'ABSPATH' ) || exit( 1 ); 17 18 if ( ! class_exists( '\MultiStoreSyncAddonforFlexStock\MULTISSAF_Hooks' ) ) { 19 20 /** 21 * Registers all multistore WP/WC hooks: menus, scripts, product sync, 22 * sheet writes, trash propagation, and token-filtered batch exports. 23 * 24 * @package MultiStoreSyncAddonforFlexStock 25 */ 26 final class MULTISSAF_Hooks { 27 28 /** 29 * Is license active 30 * 31 * @var bool 32 */ 33 public $app; 34 /** 35 * Is Ultimate active 36 * 37 * @var bool 38 */ 39 protected $is_ultimate_active; 40 41 /** 42 * Contains license information. 43 * 44 * @var mixed 45 */ 46 public $license; 47 48 /** 49 * Constructor 50 */ 51 public function __construct() { 52 $this->app = new App(); 53 $this->add_actions(); 54 55 } 56 57 58 /** 59 * Registers all actions and filters. 60 * Called from __construct(). Sets $is_ultimate_active flag. 61 * 62 * @return void 63 */ 64 public function add_actions() { 65 $this->is_ultimate_active = multissaf_is_plugin_ultimate_activated(); 66 register_activation_hook( MULTISSAF_MULTI_STORE, [ $this, 'activate' ]); 67 add_action( 'pre_current_active_plugins', [ $this, 'admin_notices' ]); 68 69 add_action('init', [ $this, 'init_appsero_client' ]); 70 add_action('admin_init', [ $this, 'redirect_after_active']); 71 add_filter('plugin_action_links_' . plugin_basename(MULTISSAF_MULTI_STORE), [ $this, 'add_plugin_action_links' ]); 72 add_action('ssgsw_before_log_menu', [ $this, 'set_menu']); 73 add_filter('ssgsw_options', [$this, 'ssgsw_options'], 10, 1); 74 add_filter('ssgsw_localized_script', [$this, 'ssgsw_localized_script'], 10, 1); 75 add_action('admin_enqueue_scripts', [$this, 'enqueue_scripts'], 9); 76 add_filter('ssgsw_sub_site_product_count', [$this, 'ssgsw_sub_site_product_count'], 10, 1); 77 add_filter( 'ssgsw_columns', [ $this, 'product_columns' ], 10, 1 ); 78 //add_filter( 'ssgsw_add_extra', [ $this, 'ssgsw_add_extra' ], 10, 3 ); 79 // add_filter('ssgsw_sku_checker_filter', [ $this, 'ssgsw_sync_only_products_with_sku'],10, 1 ); 80 add_filter('ssgsw_sync_mode', [$this, 'ssgsw_sync_mode'], 10, 1); 81 add_filter('ssgsw_domain_url', [$this, 'ssgsw_domain_url'], 10, 1); 82 add_action('ssgsw_collection_of_update_ids', [$this, 'ssgsw_collection_of_update_ids'], 10, 5); 83 add_action('ssgsw_multistore_product_update', [$this, 'ssgsw_multistore_product_update_func'], 10, 1); 84 85 add_action('ssgsw_multistore_sheet_update', [$this, 'ssgsw_multistore_sheet_update'], 10, 1); 86 87 add_action('ssgsw_multistore_sync_to_sheet', [ $this, 'ssgsw_multistore_sync_to_sheet'], 10, 1); 88 if ( ! $this->is_ultimate_active ) { 89 add_filter('ssgsw_format_columns_data', [$this, 'ssgsw_format_columns_data'], 10, 3); 90 } 91 add_filter('ssgsw_is_multistore_plugin_active', '__return_true'); 92 93 add_filter( 'ssgsw_multistore_token_filtered_batch', [ $this, 'ssgsw_token_filtered_batch' ], 10, 4 ); 94 add_action( 'trashed_post', [ $this, 'delete_child_tab_rows_on_trash' ], 20, 1 ); 95 96 // Direct sync handler (replaces cron scheduling) 97 add_action( 'ssgsw_multistore_product_update_direct', [ $this, 'ssgsw_multistore_product_update_direct' ], 10, 1 ); 98 99 // SKU update sync for multistore token filters 100 add_action( 'ssgsw_product_sku_changed', [ $this, 'update_token_sku_filters' ], 10, 3 ); 101 } 102 103 /** 104 * Update token configuration when a product SKU changes (no-op as specific SKU filtering is removed). 105 * 106 * @param int $product_id The product ID. 107 * @param string $old_sku The previous SKU. 108 * @param string $new_sku The new SKU. 109 */ 110 public function update_token_sku_filters( $product_id, $old_sku, $new_sku ) { 111 // Specific SKU filtering removed - all products sync is used. 112 return; 113 } 114 115 /** 116 * Deletes the matching row from every child tab in the destination 117 * spreadsheet when a product is trashed (locally or propagated). 118 * Uses SKU as the primary cross-site identifier, falls back to ID. 119 * Saves/restores $ssgsw_request_params around Sheet calls. 120 * 121 * Connected to: WP action 'trashed_post' (priority 20) 122 * 123 * @param int $post_id The trashed WP post ID. 124 * @return void 125 */ 126 public function delete_child_tab_rows_on_trash( $post_id ) { 127 128 if ( 'product' !== get_post_type( $post_id ) && 'product_variation' !== get_post_type( $post_id ) ) { 129 return; 130 } 131 132 if ( ! get_option( 'ssgsw_multi_store_active', false ) ) { 133 return; 134 } 135 136 // Only run on the destination/parent site (the one that has child tabs). 137 $store_role = get_option( 'ssgsw_multi_connect_mode_on', '' ); 138 if ( 'child' === $store_role ) { 139 return; // Source/child sites don't have child tabs. 140 } 141 142 $connected_children = get_option( 'ssgsw_multi_store_list', [] ); 143 if ( empty( $connected_children ) || ! is_array( $connected_children ) ) { 144 return; 145 } 146 147 $sku = get_post_meta( $post_id, '_sku', true ); 148 149 // Snapshot the global request params. We will temporarily override them per 150 // child-tab iteration so Sheet methods use the DESTINATION's spreadsheet — 151 // not whatever foreign body is in the active HTTP request (e.g. an AppScript 152 // request or a remote REST call from the source site). 153 global $ssgsw_request_params; 154 $saved_request_params = $ssgsw_request_params; 155 156 foreach ( $connected_children as $child ) { 157 $tab_name = isset( $child['tab_name'] ) ? $child['tab_name'] : ''; 158 $sheet_gid = isset( $child['sheet_gid'] ) ? $child['sheet_gid'] : ''; 159 $spreadsheet_id = isset( $child['spreadsheet_id'] ) ? $child['spreadsheet_id'] : ''; 160 161 // Fall back to the site's own spreadsheet ID if not stored per-child. 162 if ( empty( $spreadsheet_id ) ) { 163 $spreadsheet_id = get_option( SSGSW_PREFIX . 'spreadsheet_id', '' ); 164 } 165 166 if ( empty( $tab_name ) || empty( $spreadsheet_id ) ) { 167 continue; 168 } 169 170 if ( ! class_exists( '\StockSyncWithGoogleSheetForWooCommerce\Sheet' ) ) { 171 continue; 172 } 173 174 // Override request params to force Sheet methods onto THIS child tab's 175 // spreadsheet context (destination spreadsheet, child tab name/GID). 176 // access_token is omitted — Sheet::get_token() uses stored credentials. 177 $ssgsw_request_params = [ 178 'spreadsheet_id' => $spreadsheet_id, 179 'sheet_tab' => $tab_name, 180 'sheet_id' => $sheet_gid, 181 ]; 182 183 $sheet = new \StockSyncWithGoogleSheetForWooCommerce\Sheet(); 184 $sheet->setID( $spreadsheet_id ); 185 $sheet->setTab( $tab_name ); 186 if ( ! empty( $sheet_gid ) && method_exists( $sheet, 'setSheetID' ) ) { 187 $sheet->setSheetID( $sheet_gid ); 188 } 189 190 // Fetch the tab's current data. 191 // Pass $tab_name explicitly — belt-and-suspenders against any remaining 192 // body contamination (see 1-child-duplicate.md for root-cause details). 193 $tab_data = $sheet->get_formula_value( false, false, $tab_name, 'UNFORMATTED_VALUE' ); 194 if ( empty( $tab_data ) ) { 195 continue; 196 } 197 198 // Child tab rows contain SOURCE product IDs, not destination IDs. 199 // SKU is the reliable cross-site identifier — try it first. 200 $row_number = false; 201 if ( ! empty( $sku ) && function_exists( 'ssgsw_get_index_number_by_sku' ) ) { 202 $row_number = ssgsw_get_index_number_by_sku( $sku, $tab_data ); 203 } 204 205 // Fallback: scan column 0 for matching product ID. 206 if ( ! $row_number && function_exists( 'ssgsw_format_index_number' ) ) { 207 $tab_index = ssgsw_format_index_number( $tab_data ); 208 $row_number = function_exists( 'ssgsw_get_index_number_option' ) 209 ? ssgsw_get_index_number_option( $post_id, $tab_index ) 210 : false; 211 } 212 213 if ( $row_number ) { 214 // $sheet_gid passed explicitly so delete_single_row() uses it as 215 // priority-1 and does not fall back to body_info->sheet_id. 216 $sheet->delete_single_row( $row_number, $sheet_gid ); 217 } 218 } 219 220 // Restore request params so nothing downstream is affected. 221 $ssgsw_request_params = $saved_request_params; 222 } 223 224 /** 225 * Filter: intercepts the base plugin's batch export when a child token 226 * is present in the request. Returns permission-filtered product rows 227 * instead of the default unfiltered batch. 228 * 229 * Connected to: filter 'ssgsw_multistore_token_filtered_batch' 230 * 231 * @param false|array $default Default (no override). 232 * @param int $offset Batch offset. 233 * @param int $batch_size Batch size. 234 * @param int $index Sheet row index. 235 * @return false|array Filtered rows or false to fall back. 236 */ 237 public function ssgsw_token_filtered_batch( $default, $offset, $batch_size, $index ) { 238 239 // Resolve bearer token from the current request. 240 $bearer = ''; 241 if ( ! empty( $_SERVER['HTTP_SSGSWKEY'] ) ) { 242 $bearer = sanitize_text_field( wp_unslash( $_SERVER['HTTP_SSGSWKEY'] ) ); 243 } elseif ( ! empty( $_SERVER['HTTP_AUTHORIZATION'] ) ) { 244 $bearer = sanitize_text_field( wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] ) ); 245 } 246 $bearer = str_replace( 'Bearer ', '', $bearer ); 247 248 if ( empty( $bearer ) ) { 249 return false; 250 } 251 252 // Find the matching child token with its permissions. 253 $child_tokens = get_option( 'ssgsw_child_tokens', [] ); 254 if ( ! is_array( $child_tokens ) ) { 255 return false; 256 } 257 258 $permissions = null; 259 foreach ( $child_tokens as $token_data ) { 260 if ( isset( $token_data['token'] ) && $bearer === $token_data['token'] ) { 261 // Unwrap nested permissions structure. 262 $perms = isset( $token_data['permissions'] ) ? (array) $token_data['permissions'] : []; 263 if ( ! empty( $perms['permissions'] ) && is_array( $perms['permissions'] ) ) { 264 $perms = $perms['permissions']; 265 } 266 // Include all products. 267 $perms['include_type'] = 'all'; 268 $permissions = $perms; 269 break; 270 } 271 } 272 273 if ( $permissions === null ) { 274 return false; 275 } 276 277 // Use the token-aware batch function to fetch only filtered products. 278 if ( function_exists( 'multissaf_get_batch_products_by_token' ) ) { 279 $data = multissaf_get_batch_products_by_token( null, $offset, $batch_size, $permissions ); 280 if ( is_array( $data ) ) { 281 if ( $offset === 0 && ! empty( $data ) ) { 282 $first = reset( $data ); 283 // Header row contains strings (labels), not numeric IDs — detect by checking 284 if ( is_array( $first ) && isset( $first[0] ) && ! is_numeric( $first[0] ) ) { 285 array_shift( $data ); 286 } 287 } 288 289 return $data; 290 } 291 } 292 293 return false; 294 } 295 296 /** 297 * Format image and formula data 298 * 299 * @param array $format_data data. 300 * 301 * @return array formatted data. 302 */ 303 public function ssgsw_format_columns_data($formatted_data, $sheets_info, $formula) { 304 305 306 if ( $formula ) { 307 $sheet_header = isset($sheets_info[0]) ? $sheets_info[0] : []; 308 $check_id_exist = isset($sheets_info[0][0]) && 'ID' === $sheets_info[0][0] ? true : false; 309 $formatted_data = array_map(function ( $row ) use ( $sheets_info, $sheet_header, $check_id_exist ) { //phpcs:ignore 310 $desired_value = $row['ID']; 311 $found_array = null; 312 if ( is_array($sheets_info) && ! empty($sheets_info) ) { 313 foreach ( $sheets_info as $key => $sub_array ) { 314 if ( isset($sub_array[0]) && $sub_array[0] === $desired_value ) { 315 $found_array = $sub_array; 316 break; 317 } 318 } 319 } 320 321 if ( is_array($found_array) ) { 322 $i = 0; 323 if ( $check_id_exist ) { 324 $google_with_header = $this->set_sheet_header_with_data($sheet_header, $found_array); 325 $database_data = $this->ssgsw_compare_with_formula_value($desired_value); 326 $check_have_change = false; 327 if ( ! empty($database_data) ) { 328 $previous = []; 329 foreach ( $row as $key => $value ) { 330 $formation_key = $this->ssgsw_sheet_header_formating2($key); 331 if ( isset($google_with_header[ $key ]) && strpos($google_with_header[ $key ], '=') === 0 ) { 332 if ( 'product_image' !== $key ) { 333 $row[ $key ] = isset($google_with_header[ $key ]) ? $google_with_header[ $key ] : ''; 334 $new_data_set = isset($database_data[ $formation_key ]) ? $database_data[ $formation_key ] : ''; 335 $previous[0][ $formation_key ] = $new_data_set; 336 if ( $value !== $new_data_set ) { 337 $check_have_change = true; 338 } 339 } 340 } else { 341 $previous[0][ $formation_key ] = $row[ $key ]; 342 } 343 $i++; 344 } 345 if ( $check_have_change ) { 346 $product_object = new \StockSyncWithGoogleSheetForWooCommerce\Product(); 347 $product_object->bulk_update($previous, false, 1, false ); 348 } 349 } else { 350 foreach ( $row as $key => $value ) { 351 if ( isset($google_with_header[ $key ]) && strpos($google_with_header[ $key ], '=') === 0 ) { 352 if ( 'product_image' !== $key ) { 353 $row[ $key ] = $google_with_header[ $key ]; 354 } 355 } 356 $i++; 357 } 358 } 359 } else { 360 foreach ( $row as $key => $value ) { 361 if ( isset($found_array[ $i ]) && strpos($found_array[ $i ], '=') === 0 ) { 362 if ( 'product_image' !== $key ) { 363 $row[ $key ] = $found_array[ $i ]; 364 } 365 } 366 $i++; 367 } 368 } 369 } 370 if ( is_array($row) && array_key_exists('post_author', $row)) { 371 $row['post_author'] = $this->generate_shop_name($row['post_author']); 372 } 373 if ( is_array($row) && array_key_exists('ssgsw_product_url', $row)) { 374 $row['ssgsw_product_url'] = $this->generate_product_url($row); 375 } 376 if ( array_key_exists( 'product_image', $row) && ! empty($row['product_image']) ) { 377 $image_url_convart = $row['product_image']; 378 if ($image_url_convart) { 379 $base_url = wp_upload_dir()['baseurl']; 380 $absolute_url = trailingslashit($base_url) . ltrim($image_url_convart, '/'); 381 $row['product_image'] = '=IMAGE("' . $absolute_url . '")'; 382 } else { 383 $row['product_image'] = ''; 384 } 385 return array_values( (array) $row); 386 } else { 387 return array_values( (array) $row); 388 } 389 }, $formatted_data); 390 391 } else { 392 $formatted_data = array_map(function ( $row ) { //phpcs:ignore 393 if ( is_array($row) && array_key_exists('ssgsw_product_url', $row)) { 394 $row['ssgsw_product_url'] = $this->generate_product_url($row); 395 } 396 if ( is_array($row) && array_key_exists('post_author', $row)) { 397 $row['post_author'] = $this->generate_shop_name($row['post_author']); 398 } 399 if ( array_key_exists( 'product_image', $row) && ! empty($row['product_image']) ) { 400 $image_url_convart = $row['product_image']; 401 if ($image_url_convart) { 402 $base_url = wp_upload_dir()['baseurl']; 403 $absolute_url = trailingslashit($base_url) . ltrim($image_url_convart, '/'); 404 $row['product_image'] = '=IMAGE("' . $absolute_url . '")'; 405 } else { 406 $row['product_image'] = ''; 407 } 408 return array_values( (array) $row); 409 } else { 410 return array_values( (array) $row); 411 } 412 }, $formatted_data); 413 414 } 415 return $formatted_data; 416 } 417 /** 418 * Generate Product URL 419 */ 420 public function generate_product_url($row) { 421 $p_id = $row['ID']; 422 return get_permalink($p_id); 423 } 424 /** 425 * Generate Shop Name 426 */ 427 public function generate_shop_name($author_id) { 428 return get_the_author_meta( 'display_name', $author_id ); 429 } 430 /** 431 * SSGSW Sheet Header formating 432 * 433 * @param string $key Key of the data. 434 * 435 * @return string 436 */ 437 public function ssgsw_sheet_header_formating2( $key ) { 438 $header_info = [ 439 'ID' => 'ID', 440 'product_type' => 'Type', 441 'post_title' => 'name', 442 '_stock' => 'stock', 443 '_regular_price' => 'regular_price', 444 '_sale_price' => 'sale_price', 445 'product_image' => 'Image', 446 '_sku' => 'sku', 447 'total_sales' => 'sales', 448 '_product_attributes' => 'attributes', 449 'product_cat' => 'category', 450 'post_excerpt' => 'post_excerpt', 451 ]; 452 if ( array_key_exists($key, $header_info) ) { 453 return $header_info[ $key ]; 454 } else { 455 return $key; 456 } 457 } 458 /** 459 * Get products information with compare value 460 * 461 * @param int $product_id ID of product. 462 * 463 * @return mixed 464 */ 465 public function ssgsw_compare_with_formula_value( $product_id ) { 466 $product_info = $this->ssgsw_get_product_information_by_id($product_id); 467 if ( is_array($product_info) && ! empty($product_info) ) { 468 $product_information = isset($product_info[0]['product_information']) ? unserialize($product_info[0]['product_information']) : []; 469 if ( ! empty($product_information) ) { 470 return $product_information; 471 } 472 } 473 return []; 474 } 475 /** 476 * Get SSGSW product information 477 * 478 * @param int $product_id ID of product. 479 * 480 * @return mixed 481 */ 482 public function ssgsw_get_product_information_by_id( $product_id ) { 483 global $wpdb; 484 $product_id = sanitize_text_field( $product_id ); 485 $results = $wpdb->get_results( 486 $wpdb->prepare( 487 "SELECT * FROM {$wpdb->prefix}ssgsw_products WHERE product_id = %s", 488 $product_id 489 ), 490 ARRAY_A 491 ); 492 return $results; 493 } 494 /** 495 * Public function Sheet header with data. 496 * 497 * @param array $header_info header information. 498 * @param array $data data information. 499 * 500 * @return array 501 */ 502 public function set_sheet_header_with_data( $header_info = [], $data = [] ) { 503 $header_marge = []; 504 if ( is_array($header_info) && ! empty($header_info) ) { 505 foreach ( $header_info as $key => $value ) { 506 $header = $this->ssgsw_sheet_header_formating($value); 507 $header_marge[ $header ] = isset($data[ $key ]) ? $data[ $key ] : ''; 508 } 509 } 510 return $header_marge; 511 } 512 513 /** 514 * SSGSW Sheet Header formating 515 * 516 * @param string $key Key of the data. 517 * 518 * @return string 519 */ 520 public function ssgsw_sheet_header_formating( $key ) { 521 $header_info = [ 522 'ID' => 'ID', 523 'Type' => 'product_type', 524 'Name' => 'post_title', 525 'Stock' => '_stock', 526 'Regular price' => '_regular_price', 527 'Sale price' => '_sale_price', 528 'Image' => 'product_image', 529 'SKU' => '_sku', 530 'No of Sales' => 'total_sales', 531 'Attributes' => '_product_attributes', 532 'Categories' => 'product_cat', 533 'Short description' => 'post_excerpt', 534 ]; 535 if ( array_key_exists($key, $header_info) ) { 536 return $header_info[ $key ]; 537 } else { 538 return $key; 539 } 540 } 541 /** 542 * Syncs product data from a single store to multiple stores in batches. 543 * 544 * @param array $args An associative array containing: 545 */ 546 public function ssgsw_multistore_sync_to_sheet($args) { 547 $batch_size = 60; 548 $delay_seconds = 2; 549 $orgingal_ids = isset($args['product_ids']) ? $args['product_ids'] : []; 550 $site_url = isset($args['site_url']) ? $args['site_url'] : (isset($args['refarance_shop']) ? $args['refarance_shop'] : ''); 551 $token = isset($args['token']) ? $args['token'] : ''; 552 // Check if there are product IDs to process. 553 if (is_array($orgingal_ids) && !empty($orgingal_ids)) { 554 if (class_exists('\StockSyncWithGoogleSheetForWooCommerce\Sheet') && class_exists('\StockSyncWithGoogleSheetForWooCommerce\Column')) { 555 $new_sheets = new \StockSyncWithGoogleSheetForWooCommerce\Sheet(); 556 557 $spreadsheet_id = function_exists( 'ssgsw_get_option' ) ? ssgsw_get_option( 'spreadsheet_id' ) : ''; 558 $sheet_tab = function_exists( 'ssgsw_get_option' ) ? ssgsw_get_option( 'sheet_tab' ) : ''; 559 $cache_key = apply_filters( 560 'multissaf_sheets_info_cache_key', 561 'multissaf_sheets_info_' . md5( $spreadsheet_id . '_' . $sheet_tab ) 562 ); 563 564 $sheets_info = get_transient( $cache_key ); 565 if ( false === $sheets_info || empty( $sheets_info ) ) { 566 $sheets_info = $new_sheets->get_formula_value(); 567 if ( ! empty( $sheets_info ) ) { 568 set_transient( 569 $cache_key, 570 $sheets_info, 571 apply_filters( 'multissaf_sheets_info_ttl', 5 * MINUTE_IN_SECONDS ) 572 ); 573 } 574 } 575 576 if (empty($sheets_info)) { 577 $sheets_info = $new_sheets->get_formula_value(); 578 } 579 580 $columns = new \StockSyncWithGoogleSheetForWooCommerce\Column(); 581 $columns_info = $columns->get_columns_no_edit_remove(); 582 $sheet_header = isset($sheets_info[0]) ? $sheets_info[0] : []; 583 $key_with_headers = ssgsw_format_headers_with_sheet_header($sheet_header, $columns_info); 584 $keys = isset($key_with_headers[0]) ? $key_with_headers[0] : []; 585 $domain_url = apply_filters('ssgsw_domain_url', false); 586 587 // Get the current batch of product IDs. 588 $current_batch = array_slice($orgingal_ids, 0, $batch_size); 589 590 if (!empty($current_batch)) { 591 $get_batch = multissaf_batch_api_request( 592 0, 0, 0, $site_url, $token, $keys, $domain_url, $current_batch, $sheets_info 593 ); 594 595 // Process and update the Google Sheet with the fetched data. 596 $sheets_index = []; 597 if (function_exists('ssgsw_format_index_number')) { 598 $sheets_index = ssgsw_format_index_number($sheets_info); 599 } 600 601 if (is_array($get_batch) && !empty($get_batch)) { 602 603 $sync_batch_updates = []; 604 $sync_batch_appends = []; 605 606 foreach ($get_batch as $values) { 607 $values = (array) $values; 608 $product_id = isset($values[0]) ? $values[0] : false; 609 if ($product_id) { 610 $index_number_exists = false; 611 if (function_exists('multissaf_get_index_number_option_param')) { 612 $index_number_exists = multissaf_get_index_number_option_param($product_id, $sheets_index, $site_url); 613 } 614 615 if ($index_number_exists) { 616 $col_end = $new_sheets->getColumnLetter( count( $values ) ); 617 $range = $sheet_tab . '!A' . $index_number_exists . ':' . $col_end . $index_number_exists; 618 $sync_batch_updates[] = [ 619 'range' => $range, 620 'values' => [ array_values( $values ) ], 621 ]; 622 } else { 623 $sync_batch_appends[] = array_values( $values ); 624 } 625 } 626 } 627 628 // Send all updates in ONE API call 629 if ( ! empty( $sync_batch_updates ) ) { 630 $new_sheets->batch_update_rows( $sync_batch_updates ); 631 // Bust sheet cache after write 632 delete_transient( $cache_key ); 633 } 634 635 // Send all appends in ONE API call 636 if ( ! empty( $sync_batch_appends ) ) { 637 $new_sheets->append_batch_rows( $sync_batch_appends ); 638 // Bust sheet cache after write 639 delete_transient( $cache_key ); 640 } 641 } 642 } 643 644 // Slice the remaining products for the next batch. 645 $remaining_product_ids = array_slice($orgingal_ids, $batch_size); 646 647 // If there are more products to process, schedule a new event. 648 if (!empty($remaining_product_ids)) { 649 $next_args = $args; 650 $next_args['orgingal_ids'] = $remaining_product_ids; 651 652 // Clear any previous schedule for this task to prevent duplicates. 653 wp_clear_scheduled_hook('ssgsw_multistore_sync_to_sheet', [$next_args]); 654 655 // Schedule the next batch after the specified delay. 656 wp_schedule_single_event(time() + $delay_seconds, 'ssgsw_multistore_sync_to_sheet', [$next_args]); 657 } 658 } 659 } 660 } 661 662 /** 663 * Writes a batch of product IDs to the local main sheet via 664 * StockSyncWithGoogleSheetForWooCommerce\Sheet. Skips trashed products. 665 * Schedules continuation for batches > 60 products. 666 * 667 * Connected to: action 'ssgsw_multistore_sheet_update' 668 * 669 * @param array $args { 670 * @type int[] $product_ids IDs to write to sheet. 671 * } 672 * @return void 673 */ 674 public function ssgsw_multistore_sheet_update($args) { 675 if (!is_array($args) || empty($args)) { 676 return; 677 } 678 679 $product_ids = isset($args['product_ids']) ? (array) $args['product_ids'] : []; 680 681 // FIX: Defensive dedupe incoming batch to prevent processing duplicate IDs 682 $product_ids = array_unique( array_map( 'intval', $product_ids ) ); 683 684 // Sort the product IDs to ensure they are processed in a consistent order. 685 sort($product_ids); 686 $batch_size = 60; 687 $delay_seconds = 2; 688 689 // Get the product IDs for the current batch to process 690 $current_batch_ids = array_slice($product_ids, 0, $batch_size); 691 692 // If the current batch is empty, there's nothing to do 693 if (empty($current_batch_ids)) { 694 return; 695 } 696 697 // FIX: Add write lock to prevent concurrent sheet writes that cause duplicates 698 $write_lock_key = apply_filters( 699 'multissaf_sheet_write_lock_key', 700 'multissaf_sheet_write_lock' 701 ); 702 703 // Check if another write is in progress 704 if ( get_transient( $write_lock_key ) ) { 705 // Another batch is currently writing to the sheet 706 // Reschedule this batch for 3 seconds later to avoid race condition 707 /* error_log( sprintf( 708 '[MULTISTORE-SHEET] Write locked, rescheduling %d products for later', 709 count($product_ids) 710 ) ); */ 711 712 wp_schedule_single_event( 713 time() + 3, 714 'ssgsw_multistore_sheet_update', 715 [ $args ] 716 ); 717 return; 718 } 719 720 // Acquire lock (30 second TTL as safety in case of fatal error) 721 set_transient( $write_lock_key, time(), 30 ); 722 723 /* error_log( sprintf( 724 '[MULTISTORE-SHEET] Lock acquired, processing %d products', 725 count($current_batch_ids) 726 ) ); */ 727 728 // Initialize required classes and data 729 if (class_exists('\StockSyncWithGoogleSheetForWooCommerce\Sheet') && class_exists('\StockSyncWithGoogleSheetForWooCommerce\Column') && class_exists('\StockSyncWithGoogleSheetForWooCommerce\Product')) { 730 731 $new_sheet = new \StockSyncWithGoogleSheetForWooCommerce\Sheet(); 732 // Cache Sheet Formula Value 733 734 $spreadsheet_id = function_exists( 'ssgsw_get_option' ) ? ssgsw_get_option( 'spreadsheet_id' ) : ''; 735 $sheet_tab = function_exists( 'ssgsw_get_option' ) ? ssgsw_get_option( 'sheet_tab' ) : ''; 736 $cache_key = apply_filters( 737 'multissaf_sheets_info_cache_key', 738 'multissaf_sheets_info_' . md5( $spreadsheet_id . '_' . $sheet_tab ) 739 ); 740 741 $sheets_info = get_transient( $cache_key ); 742 if ( false === $sheets_info || empty( $sheets_info ) ) { 743 $sheets_info = $new_sheet->get_formula_value(); 744 if ( ! empty( $sheets_info ) ) { 745 set_transient( 746 $cache_key, 747 $sheets_info, 748 apply_filters( 'multissaf_sheets_info_ttl', 5 * MINUTE_IN_SECONDS ) 749 ); 750 } 751 } 752 753 $columns = new \StockSyncWithGoogleSheetForWooCommerce\Column(); 754 $keys_values = []; 755 if (function_exists('ssgsw_prepare_sheet_keys')) { 756 $keys_values = ssgsw_prepare_sheet_keys($sheets_info, $columns); 757 } 758 759 $products = new \StockSyncWithGoogleSheetForWooCommerce\Product(); 760 $formatted_data = $products->get_formatted_data($current_batch_ids, $sheets_info, false, 0, 0, ['product', 'product_variation'], false, $keys_values); 761 $sheets_index = []; 762 if (function_exists('ssgsw_format_index_number')) { 763 $sheets_index = ssgsw_format_index_number($sheets_info); 764 } 765 766 // Collect all row updates then batchUpdate 767 768 $batch_updates = []; 769 $batch_appends = []; 770 $newly_marked_ids = []; // FIX (Layer 3): Track which IDs we're about to append 771 772 foreach ($current_batch_ids as $key => $product_id) { 773 // Skip trashed or deleted products — their rows are already gone from the 774 // sheet and appending them would create infinite duplicate rows. 775 $post_status = get_post_status( $product_id ); 776 if ( ! $post_status || 'trash' === $post_status ) { 777 continue; 778 } 779 780 $formatted_key = array_search($product_id, $current_batch_ids); 781 $values = isset($formatted_data[$formatted_key]) ? $formatted_data[$formatted_key] : []; 782 if ($values) { 783 // FIX (Layer 3): Check our own authoritative marker first - takes priority 784 // over live-sheet lookup. If we've already successfully appended a row for 785 // this product before, we NEVER append again regardless of what the 786 // (possibly stale or mismatched) live index says. 787 $already_has_row = get_post_meta( $product_id, '_ssgsw_main_sheet_row_written', true ); 788 789 if (function_exists('ssgsw_get_index_number_option')) { 790 $index_number_exits = ssgsw_get_index_number_option($product_id, $sheets_index); 791 } else { 792 $index_number_exits = false; 793 } 794 795 if ($index_number_exits) { 796 $col_end = $new_sheet->getColumnLetter( count( $values ) ); 797 $range = $sheet_tab . '!A' . $index_number_exits . ':' . $col_end . $index_number_exits; 798 $batch_updates[] = [ 799 'range' => $range, 800 'values' => [ array_values( $values ) ], 801 ]; 802 // Backfill the marker in case it was missing (e.g. row existed from 803 // before this fix was deployed) so future runs are protected too. 804 if ( ! $already_has_row ) { 805 update_post_meta( $product_id, '_ssgsw_main_sheet_row_written', 1 ); 806 } 807 } elseif ( $already_has_row ) { 808 // FIX (Layer 3): Live index says "not found" but our own marker says we 809 // already wrote this one — trust our marker, skip entirely rather than 810 // appending a duplicate. (If the row was genuinely deleted from the 811 // sheet by a human, this product won't get a new row until the marker 812 // is manually cleared — safer than silently duplicating.) 813 continue; 814 } else { 815 $batch_appends[] = array_values( $values ); 816 $newly_marked_ids[] = $product_id; // mark only after successful write 817 } 818 } 819 } 820 821 // Send all updates in ONE API call 822 if ( ! empty( $batch_updates ) ) { 823 $result = $new_sheet->batch_update_rows( $batch_updates ); 824 if ( $result ) { 825 delete_transient( $cache_key ); 826 } 827 } 828 // Send all appends in ONE API call 829 if ( ! empty( $batch_appends ) ) { 830 $append_result = $new_sheet->append_batch_rows( $batch_appends ); 831 delete_transient( $cache_key ); 832 833 // FIX (Layer 3): Only mark as "has a row" if the append actually went through 834 if ( $append_result ) { 835 foreach ( $newly_marked_ids as $marked_id ) { 836 update_post_meta( $marked_id, '_ssgsw_main_sheet_row_written', 1 ); 837 } 838 } 839 } 840 841 } 842 843 // FIX: Release lock after writes complete to allow next batch to proceed 844 delete_transient( $write_lock_key ); 845 846 /* error_log( sprintf( 847 '[MULTISTORE-SHEET] Lock released, wrote %d updates, %d appends', 848 count($batch_updates), 849 count($batch_appends) 850 ) ); */ 851 852 // Now, schedule the next batch 853 $remaining_product_ids = array_slice($product_ids, $batch_size); 854 855 // If there are more products to process, schedule a new event 856 if (!empty($remaining_product_ids)) { 857 $next_args = $args; 858 $next_args['product_ids'] = $remaining_product_ids; 859 wp_clear_scheduled_hook('ssgsw_multistore_sheet_update', [$next_args]); 860 861 wp_schedule_single_event(time() + $delay_seconds, 'ssgsw_multistore_sheet_update', [$next_args]); 862 } 863 } 864 865 /** 866 * Direct sync dispatcher. If cron sync is disabled (feature flag 867 * 'enable_cron_sync' = false), calls ssgsw_multistore_product_update_func() 868 * inline. Otherwise fires the 'ssgsw_multistore_product_update' cron action. 869 * 870 * Connected to: action 'ssgsw_multistore_product_update_direct' 871 * 872 * @param array $args Same shape as ssgsw_multistore_product_update_func(). 873 * @return void 874 */ 875 public function ssgsw_multistore_product_update_direct( $args ) { 876 $product_ids = isset( $args['product_ids'] ) ? $args['product_ids'] : []; 877 $retry_count = isset( $args['retry_count'] ) ? intval( $args['retry_count'] ) : 0; 878 $sheet_sync = isset( $args['sheet_sync'] ) ? $args['sheet_sync'] : false; 879 $refrance_shop = isset( $args['refrance_shop'] ) ? $args['refrance_shop'] : false; 880 $site_offset = isset( $args['site_offset'] ) ? intval( $args['site_offset'] ) : 0; 881 $original_ids = isset( $args['original_ids'] ) ? $args['original_ids'] : []; 882 883 // Check if feature is enabled 884 if ( ! ssgsw_is_feature_enabled( 'enable_cron_sync' ) ) { 885 // Direct sync is enabled (cron is disabled) 886 887 // Max retry limit 888 $max_retries = ssgsw_is_feature_enabled( 'max_retry_count' ) ? ssgsw_get_feature_flags()['max_retry_count'] : 3; 889 890 if ( $retry_count >= $max_retries ) { 891 // Max retries reached, log and stop 892 return; 893 } 894 895 // Call the existing multistore product update function 896 $result = $this->ssgsw_multistore_product_update_func( $args ); 897 898 } else { 899 // Cron sync is enabled, use old behavior 900 do_action( 'ssgsw_multistore_product_update', $args ); 901 } 902 } 903 904 /** 905 * Handles the main cross-site product propagation loop. 906 * For each connected site, packages product data and POSTs to 907 * /wp-json/ssgsw/v1/update_by_ids. Schedules child-tab writes 908 * and sheet updates via WP-Cron on success. 909 * 910 * Connected to: action 'ssgsw_multistore_product_update' 911 * 912 * @param array $args { 913 * @type int[] $product_ids IDs to propagate. 914 * @type bool $sheet_sync Whether this is a sheet-sync-only pass. 915 * @type string $refrance_shop URL of the originating site (skip loop). 916 * @type int $site_offset Pagination offset within connected sites. 917 * @type int[] $original_ids Source IDs for child-tab SKU resolution. 918 * } 919 * @return void 920 */ 921 public function ssgsw_multistore_product_update_func($args) { 922 923 $product_ids = isset($args['product_ids']) ? $args['product_ids'] : []; 924 $sheet_sync = isset($args['sheet_sync']) ? $args['sheet_sync'] : false; 925 $refrance_shop = isset($args['refrance_shop']) ? $args['refrance_shop'] : false; 926 $site_offset = isset($args['site_offset']) ? intval($args['site_offset']) : 0; 927 $original_ids = isset($args['original_ids']) ? $args['original_ids'] : []; 928 929 // LOG: Entry point 930 /* error_log( sprintf( 931 '[MULTISTORE-SYNC] UPDATE_FUNC start | IDs: [%s] | Count: %d', 932 implode(',', $product_ids), 933 count($product_ids) 934 ) ); */ 935 936 // Drain pending ID queue into current batch 937 938 $pending_key = apply_filters( 'multissaf_pending_ids_key', 'multissaf_pending_product_ids' ); 939 $queued_ids = get_transient( $pending_key ); 940 if ( ! empty( $queued_ids ) && is_array( $queued_ids ) ) { 941 $product_ids = array_unique( array_merge( (array) $product_ids, $queued_ids ) ); 942 delete_transient( $pending_key ); 943 } 944 delete_transient( apply_filters( 'multissaf_sync_lock_key', 'multissaf_sync_scheduled_lock' ) ); 945 946 // Clear recovery queue on successful retry 947 $is_retry = ! empty( $args['is_retry'] ) ? true : false; 948 $retry_site = ! empty( $args['retry_site'] ) ? $args['retry_site'] : ''; 949 950 if ( $is_retry && $retry_site ) { 951 $failed_key = apply_filters( 952 'multissaf_failed_sync_key', 953 'multissaf_failed_sync_' . md5( $retry_site ) 954 ); 955 } 956 957 $site_batch_limit = 10; 958 959 $store_role = get_option('ssgsw_multi_connect_mode_on'); 960 $sync_mode = get_option('ssgsw_multi_store_mode_on'); 961 962 if ( ! is_array($product_ids) || empty($product_ids) ) return; 963 964 $batch_size = 55; 965 $delay_seconds = 5; 966 $current_batch = array_slice($product_ids, 0, $batch_size); 967 968 if (empty($current_batch)) return; 969 970 $all_sites = []; 971 if ('child' === $store_role) { 972 $all_sites = multissaf_get_connected_sites('ssgsw_multi_store_list_parent'); 973 } elseif ('main' === $store_role && 'multi_to_single' === $sync_mode) { 974 $all_sites = multissaf_get_connected_sites('ssgsw_multi_store_list'); 975 } 976 977 $current_sites_batch = array_slice($all_sites, $site_offset, $site_batch_limit); 978 $refrance_token = get_option('ssgsw_token'); 979 $refarance_shop_url = home_url(); 980 981 // LOG: Sites found 982 /* error_log( sprintf( 983 '[MULTISTORE-SYNC] Role: %s | Mode: %s | Sites found: %d | Batch: %d', 984 $store_role, 985 $sync_mode, 986 count($all_sites), 987 count($current_sites_batch) 988 ) ); */ 989 990 if ( ! empty($current_sites_batch) ) { 991 992 foreach ($current_sites_batch as $sites) { 993 $site_url = isset($sites['url']) ? $sites['url'] : ''; 994 $token = isset($sites['token']) ? $sites['token'] : ''; 995 996 // Default to TRUE (connected) if isConnected field is missing 997 // This fixes the bug where parent sites without isConnected were being skipped 998 $is_connected = isset($sites['isConnected']) ? $sites['isConnected'] : true; 999 1000 // Only skip if EXPLICITLY disconnected (0 or false) 1001 if ( is_bool($is_connected) && $is_connected === false ) { 1002 // Site is explicitly disconnected - skip and clean up 1003 $this->cleanup_site_cron_jobs($site_url); 1004 continue; 1005 } 1006 if ( is_numeric($is_connected) && intval($is_connected) === 0 ) { 1007 // Site is explicitly disconnected (0) - skip and clean up 1008 $this->cleanup_site_cron_jobs($site_url); 1009 continue; 1010 } 1011 1012 if ( ! $site_url || ! $token) { 1013 /* error_log( sprintf( 1014 '[MULTISTORE-SYNC] ⚠️ Site missing URL or token: %s', 1015 json_encode($sites) 1016 ) ); */ 1017 continue; 1018 } 1019 1020 // LOG: Sending attempt 1021 /* error_log( sprintf( 1022 '[MULTISTORE-SYNC] → Sending to: %s | Products: %d | isConnected: %s', 1023 $site_url, 1024 count($current_batch), 1025 var_export($is_connected, true) 1026 ) ); */ 1027 1028 // Validate token still matches current connection 1029 // Get fresh site data from option to ensure token hasn't changed 1030 $current_site_data = $this->get_current_site_data($site_url); 1031 if ( ! $current_site_data || $current_site_data['token'] !== $token ) { 1032 // Token mismatch - this cron job is from an old connection 1033 // Skip processing and clean up this stale cron job 1034 continue; 1035 } 1036 1037 $is_ref_shop = ( $refrance_shop && trailingslashit($site_url) === trailingslashit($refrance_shop) ); 1038 1039 $permissions = isset($sites['permissions']) ? (array) $sites['permissions'] : []; 1040 1041 // Extra permission mapping for child role 1042 if ('child' === $store_role) { 1043 $parents1 = get_option('ssgsw_child_tokens', []); 1044 if ( ! empty($parents1) ) { 1045 foreach ($parents1 as $p1) { 1046 $p1_main = isset($p1['main_store']) ? $p1['main_store'] : ''; 1047 if ( 1048 trailingslashit($p1_main) === trailingslashit($site_url) || 1049 ( isset($p1['url']) && trailingslashit($p1['url']) === trailingslashit($site_url) ) 1050 ) { 1051 $permissions = isset($p1['permissions']) ? $p1['permissions'] : $permissions; 1052 break; 1053 } 1054 } 1055 } 1056 } 1057 1058 $target_ids = []; 1059 1060 if ( ! $is_ref_shop ) { 1061 $get_row_data = multissaf_get_product_by_ids($current_batch, $permissions); 1062 1063 // If no products returned, all IDs in this batch are trashed/deleted. 1064 // Do not send to remote and do not schedule a retry — there is nothing to sync. 1065 if ( empty( $get_row_data ) ) { 1066 // Clean up any stale failed-sync option for this site so the retry 1067 // loop that caused infinite duplicates cannot continue. 1068 $stale_key = apply_filters( 'multissaf_failed_sync_key', 'multissaf_failed_sync_' . md5( $site_url ) ); 1069 delete_option( $stale_key ); 1070 delete_option( 'multissaf_failed_sync_meta_' . md5( $site_url ) ); 1071 continue; 1072 } 1073 1074 $skus = []; 1075 foreach ($get_row_data as $p) { 1076 if ( ! empty($p['sku'])) $skus[] = $p['sku']; 1077 } 1078 1079 $body = [ 1080 'ssgsw_products' => $get_row_data, 1081 'skus' => $skus, 1082 'refarance_shop' => $refarance_shop_url, 1083 'refrance_token' => $refrance_token, 1084 'orgingal_ids' => $product_ids, 1085 'sync_mode' => $sync_mode, 1086 'sheet_sync' => $sheet_sync, 1087 'manual_update' => true, 1088 ]; 1089 1090 $api_url = trailingslashit($site_url) . 'wp-json/ssgsw/v1/update_by_ids'; 1091 $response = multissaf_single_product_update($api_url, $body, $token); 1092 1093 if ($response && isset($response->data->target_ids) && ! empty($response->data->target_ids)) { 1094 $target_ids = $response->data->target_ids; 1095 1096 // LOG: Success 1097 /* error_log( sprintf( 1098 '[MULTISTORE-SYNC] ✅ Success | Site: %s | Targets: [%s]', 1099 $site_url, 1100 implode(',', $response->data->target_ids) 1101 ) ); */ 1102 1103 if ( $is_retry && $retry_site && trailingslashit( $site_url ) === trailingslashit( $retry_site ) ) { 1104 delete_option( $failed_key ); 1105 delete_option( 'multissaf_failed_sync_meta_' . md5( $retry_site ) ); 1106 } 1107 1108 } else { 1109 1110 // LOG: Failed 1111 /* error_log( sprintf( 1112 '[MULTISTORE-SYNC] ❌ Failed | Site: %s | Response: %s', 1113 $site_url, 1114 is_object($response) ? json_encode($response) : 'NULL' 1115 ) ); */ 1116 1117 $failed_key_site = apply_filters( 1118 'multissaf_failed_sync_key', 1119 'multissaf_failed_sync_' . md5( $site_url ) 1120 ); 1121 $failed_ids = get_option( $failed_key_site, [] ); 1122 1123 // Only retry IDs that still exist as published products. 1124 // Trashed/deleted products must never enter the retry queue — 1125 // they cause infinite append loops because the sheet row is gone. 1126 $retryable_ids = array_filter( (array) $current_batch, function( $id ) { 1127 $status = get_post_status( $id ); 1128 return $status && 'trash' !== $status; 1129 } ); 1130 1131 if ( empty( $retryable_ids ) ) { 1132 // Nothing to retry — clean up any stale data and move on. 1133 delete_option( $failed_key_site ); 1134 delete_option( 'multissaf_failed_sync_meta_' . md5( $site_url ) ); 1135 } else { 1136 $failed_ids = array_unique( array_merge( (array) $failed_ids, array_values( $retryable_ids ) ) ); 1137 update_option( $failed_key_site, $failed_ids, false ); 1138 1139 update_option( 'multissaf_failed_sync_meta_' . md5( $site_url ), [ 1140 'site_url' => $site_url, 1141 'failed_ids' => $failed_ids, 1142 'failed_at' => current_time( 'mysql' ), 1143 'retry_count' => 0, 1144 ], false ); 1145 1146 $retry_args = [[ 1147 'product_ids' => $failed_ids, 1148 'sheet_sync' => $sheet_sync, 1149 'refrance_shop' => $refrance_shop, 1150 'original_ids' => $original_ids, 1151 'is_retry' => true, 1152 'retry_site' => $site_url, 1153 ]]; 1154 $retry_delay = apply_filters( 'multissaf_retry_delay_seconds', 120 ); 1155 wp_clear_scheduled_hook( 'ssgsw_multistore_product_update', $retry_args ); 1156 wp_schedule_single_event( 1157 time() + $retry_delay, 1158 'ssgsw_multistore_product_update', 1159 $retry_args 1160 ); 1161 } 1162 } 1163 } else { 1164 $target_ids = ! empty($original_ids) ? $original_ids : $current_batch; 1165 } 1166 1167 $should_write_child_tabs = apply_filters( 1168 'multissaf_should_write_child_tabs', 1169 ( 'main' === $store_role && ! $sheet_sync && ! empty( $target_ids ) ), 1170 $store_role, 1171 $sheet_sync, 1172 $target_ids 1173 ); 1174 1175 if ( $should_write_child_tabs ) { 1176 // Extract SKUs from CURRENT_BATCH (Destination IDs), not target_ids (Source IDs) 1177 $target_skus = []; 1178 foreach ( $current_batch as $dest_product_id ) { 1179 $sku = get_post_meta( $dest_product_id, '_sku', true ); 1180 if ( ! empty( $sku ) ) { 1181 $target_skus[] = $sku; 1182 } 1183 } 1184 1185 if ( empty( $target_skus ) ) { 1186 //No SKUs found for Destination products, skipping Child Tab update 1187 continue; 1188 } 1189 1190 $retrieve_url = trailingslashit($site_url) . 'wp-json/ssgsw/v1/retrive_child'; 1191 $retrieve_body = [ 1192 'batch_info' => ['offset' => 0, 'batchSize' => 50], 1193 'product_skus' => $target_skus, // Use SKUs instead of IDs 1194 'access_token' => $token, 1195 'import_request' => false, 1196 ]; 1197 1198 //Calling retrive_child API with product_skus 1199 $retrieve_response = multissaf_single_product_update($retrieve_url, $retrieve_body, $token); 1200 $parent_sheet_data = []; 1201 1202 if ($retrieve_response && isset($retrieve_response->data)) { 1203 $parent_sheet_data = $retrieve_response->data; // Retrieved parent_sheet_data count 1204 } 1205 1206 if ( ! empty($parent_sheet_data) ) { 1207 $sheet_id = isset($sites['spreadsheet_id']) ? $sites['spreadsheet_id'] 1208 : (function_exists('ssgsw_get_option') ? ssgsw_get_option('spreadsheet_id') : ''); 1209 $tab_name = isset($sites['tab_name']) ? $sites['tab_name'] : ''; 1210 $sheet_gid = isset($sites['sheet_gid']) ? $sites['sheet_gid'] : ''; 1211 1212 if ( ! empty($sheet_id) && ! empty($tab_name) && class_exists('\StockSyncWithGoogleSheetForWooCommerce\Sheet') ) { 1213 $sheet = new \StockSyncWithGoogleSheetForWooCommerce\Sheet(); 1214 $sheet->setID($sheet_id); 1215 $sheet->setTab($tab_name); 1216 if ( ! empty($sheet_gid) && method_exists( $sheet, 'setSheetID' ) ) $sheet->setSheetID($sheet_gid); 1217 1218 // Always fetch fresh data for child tab updates to prevent duplicates 1219 $sheets_info = $sheet->get_formula_value( false, false, $tab_name, 'UNFORMATTED_VALUE' ); 1220 //Ftched Child Tab data, row count 1221 1222 if ( ! empty( $sheets_info ) ) { 1223 // Update cache AFTER fetching (for read-only operations) 1224 $child_cache_key = apply_filters( 1225 'multissaf_sheets_info_cache_key', 1226 'multissaf_sheets_info_' . md5( $sheet_id . '_' . $tab_name ) 1227 ); 1228 set_transient( 1229 $child_cache_key, 1230 $sheets_info, 1231 apply_filters( 'multissaf_sheets_info_ttl', 5 * MINUTE_IN_SECONDS ) 1232 ); 1233 } 1234 1235 if ($sheets_info) { 1236 // Build ID index from Child Tab - maps Product ID to Row Number 1237 $id_index = []; 1238 foreach ( $sheets_info as $row_num => $row_data ) { 1239 if ( $row_num == 0 ) continue; // Skip header 1240 if ( isset( $row_data[0] ) && is_numeric( $row_data[0] ) ) { 1241 $product_id = intval( $row_data[0] ); 1242 $id_index[ $product_id ] = $row_num + 1; // 1-based row index 1243 } 1244 } 1245 1246 $child_batch_updates = []; 1247 $child_batch_appends = []; 1248 1249 foreach ($parent_sheet_data as $data_idx => $values) { 1250 $values = (array) $values; 1251 1252 if (isset($values[0]) && ! is_numeric($values[0])) { 1253 // This is the header row — always write it to row 1 so the 1254 // child tab header stays in sync with the current field permissions. 1255 if ( $data_idx === 0 ) { 1256 $col_end = $sheet->getColumnLetter( count( $values ) ); 1257 $range = $tab_name . '!A1:' . $col_end . '1'; 1258 $child_batch_updates[] = [ 1259 'range' => $range, 1260 'values' => [ array_values( $values ) ], 1261 ]; 1262 } else { 1263 // error_log( "DEBUG: Skipping non-numeric ID at data_idx=$data_idx" ); 1264 } 1265 continue; 1266 } 1267 $product_id = isset($values[0]) ? intval($values[0]) : false; 1268 if ( ! $product_id) { 1269 continue; 1270 } 1271 1272 $row_idx = false; 1273 1274 // Find the row with this Product ID 1275 if ( isset( $id_index[ $product_id ] ) ) { 1276 $row_idx = $id_index[ $product_id ]; 1277 } else { 1278 // error_log( "Child Tab ID NOT FOUND: Product ID=$product_id (data_idx=$data_idx) - will append as new" ); 1279 } 1280 1281 // Update or append based on match 1282 if ($row_idx) { 1283 $col_end = $sheet->getColumnLetter( count( $values ) ); 1284 $range = $tab_name . '!A' . $row_idx . ':' . $col_end . $row_idx; 1285 $child_batch_updates[] = [ 1286 'range' => $range, 1287 'values' => [ array_values( $values ) ], 1288 ]; 1289 } else { 1290 // No ID match - append as new product 1291 $child_batch_appends[] = array_values( $values ); 1292 } 1293 } 1294 1295 // Send all updates in ONE API call 1296 if ( ! empty( $child_batch_updates ) ) { 1297 $sheet->batch_update_rows( $child_batch_updates ); 1298 delete_transient( $child_cache_key ); 1299 } 1300 if ( ! empty( $child_batch_appends ) ) { 1301 $sheet->append_batch_rows( $child_batch_appends ); 1302 delete_transient( $child_cache_key ); 1303 } 1304 1305 } 1306 } 1307 } 1308 } 1309 } 1310 } 1311 1312 if (count($all_sites) > ($site_offset + $site_batch_limit)) { 1313 // More sites left for the same product batch 1314 $next_args = $args; 1315 $next_args['site_offset'] = $site_offset + $site_batch_limit; 1316 wp_clear_scheduled_hook('ssgsw_multistore_product_update', [$next_args]); 1317 wp_schedule_single_event(time() + $delay_seconds, 'ssgsw_multistore_product_update', [$next_args]); 1318 1319 } else { 1320 1321 // Fix Duplicate Sheet Writes 1322 $should_write_main_tab = apply_filters( 1323 'multissaf_should_write_main_tab', 1324 ( $sheet_sync || empty( $current_sites_batch ) ), 1325 $sheet_sync, 1326 $current_sites_batch 1327 ); 1328 1329 if ( $should_write_main_tab ) { 1330 if (class_exists('\StockSyncWithGoogleSheetForWooCommerce\App')) { 1331 $new_app = new \StockSyncWithGoogleSheetForWooCommerce\App(); 1332 if ($new_app->is_plugin_ready_setup()) { 1333 $sheet_args = [['product_ids' => $current_batch]]; 1334 wp_clear_scheduled_hook('ssgsw_multistore_sheet_update', $sheet_args); 1335 wp_schedule_single_event(time() + 2, 'ssgsw_multistore_sheet_update', $sheet_args); 1336 } else { 1337 //error_log('[SSGSW DEBUG] MAIN STORE sheet setup NOT ready.'); 1338 } 1339 } else { 1340 //error_log('[SSGSW DEBUG] MAIN STORE App class missing.'); 1341 } 1342 } else { 1343 if (class_exists('\StockSyncWithGoogleSheetForWooCommerce\App')) { 1344 $new_app = new \StockSyncWithGoogleSheetForWooCommerce\App(); 1345 if ($new_app->is_plugin_ready_setup()) { 1346 $sheet_args = [['product_ids' => $current_batch]]; 1347 wp_clear_scheduled_hook('ssgsw_multistore_sheet_update', $sheet_args); 1348 wp_schedule_single_event(time() + 2, 'ssgsw_multistore_sheet_update', $sheet_args); 1349 } else { 1350 // error_log('[SSGSW DEBUG] MAIN STORE sheet setup NOT ready.'); 1351 } 1352 } 1353 } 1354 1355 // Move to next product batch 1356 $remaining_products = array_slice($product_ids, $batch_size); 1357 if ( ! empty($remaining_products)) { 1358 $next_args = $args; 1359 $next_args['product_ids'] = $remaining_products; 1360 $next_args['site_offset'] = 0; 1361 wp_clear_scheduled_hook('ssgsw_multistore_product_update', [$next_args]); 1362 wp_schedule_single_event(time() + $delay_seconds, 'ssgsw_multistore_product_update', [$next_args]); 1363 } 1364 } 1365 } 1366 1367 /** 1368 * Collects updated product IDs and queues/fires the propagation action. 1369 * Entry point called by the base plugin after any product save. 1370 * Guards against manual-import re-trigger via body flag. 1371 * 1372 * Connected to: action 'ssgsw_collection_of_update_ids' 1373 * 1374 * @param int[] $product_ids Products that changed. 1375 * @param bool $sheet_sync Pass through to update func. 1376 * @param string $refrance_shop Pass through to update func. 1377 * @param int[] $original_ids Pass through to update func. 1378 * @param bool $manual_update Allow firing even if body has manual_update flag. 1379 * @return void 1380 */ 1381 public function ssgsw_collection_of_update_ids($product_ids, $sheet_sync= false, $refrance_shop = '', $original_ids = [], $manual_update = false) { 1382 1383 // LOG: Entry point 1384 /* error_log( sprintf( 1385 '[MULTISTORE-SYNC] collection_of_update_ids | IDs: [%s] | Role: %s | SheetSync: %s | Ref: %s | Active: child=%s parent=%s', 1386 implode(',', $product_ids), 1387 get_option('ssgsw_multi_connect_mode_on', 'none'), 1388 $sheet_sync ? 'yes' : 'no', 1389 $refrance_shop ?: 'none', 1390 multissaf_chid_activity() ? 'Y' : 'N', 1391 multissaf_parent_activity() ? 'Y' : 'N' 1392 ) ); */ 1393 1394 $multissaf_body = multissaf_get_body(); 1395 $manual_import = isset($multissaf_body->manual_update) ? (int)$multissaf_body->manual_update : false; 1396 1397 if ( $manual_import && ! $manual_update ) { 1398 // error_log( '[MULTISTORE-SYNC] Blocked by manual_import guard' ); 1399 return; 1400 } 1401 1402 if(!$product_ids) { 1403 return; 1404 } 1405 $args = [ 1406 [ 1407 'product_ids' => $product_ids, 1408 'sheet_sync' => $sheet_sync, 1409 'refrance_shop' => $refrance_shop, 1410 'original_ids' => $original_ids, 1411 1412 ] 1413 ]; 1414 $child_active = multissaf_chid_activity(); 1415 $parent_active = multissaf_parent_activity(); 1416 1417 if ($child_active || $parent_active ) { 1418 1419 // Use direct sync instead of cron scheduling 1420 if ( ! ssgsw_is_feature_enabled( 'enable_cron_sync' ) ) { 1421 // Direct sync is enabled (cron is disabled) 1422 1423 $pending_key = apply_filters( 'multissaf_pending_ids_key', 'multissaf_pending_product_ids' ); 1424 $pending = get_transient( $pending_key ); 1425 $pending = is_array( $pending ) ? $pending : []; 1426 $pending = array_unique( array_merge( $pending, (array) $product_ids ) ); 1427 set_transient( $pending_key, $pending, apply_filters( 'multissaf_pending_ids_ttl', 30 ) ); 1428 1429 $lock_key = apply_filters( 'multissaf_sync_lock_key', 'multissaf_sync_scheduled_lock' ); 1430 if ( ! get_transient( $lock_key ) ) { 1431 set_transient( $lock_key, 1, 10 ); 1432 1433 // Call direct sync action instead of scheduling cron 1434 do_action( 'ssgsw_multistore_product_update_direct', [ 1435 'product_ids' => $pending, 1436 'sheet_sync' => $sheet_sync, 1437 'refrance_shop' => $refrance_shop, 1438 'original_ids' => $original_ids, 1439 'retry_count' => 0, // Start with 0 retries 1440 ] ); 1441 } 1442 } else { 1443 // Cron sync is enabled, use old behavior 1444 $pending_key = apply_filters( 'multissaf_pending_ids_key', 'multissaf_pending_product_ids' ); 1445 $pending = get_transient( $pending_key ); 1446 $pending = is_array( $pending ) ? $pending : []; 1447 $pending = array_unique( array_merge( $pending, (array) $product_ids ) ); 1448 set_transient( $pending_key, $pending, apply_filters( 'multissaf_pending_ids_ttl', 30 ) ); 1449 1450 $lock_key = apply_filters( 'multissaf_sync_lock_key', 'multissaf_sync_scheduled_lock' ); 1451 if ( ! get_transient( $lock_key ) ) { 1452 set_transient( $lock_key, 1, 10 ); 1453 wp_schedule_single_event( 1454 time() + apply_filters( 'multissaf_product_update_delay_seconds', 5 ), 1455 'ssgsw_multistore_product_update', 1456 [[ 1457 'product_ids' => $pending, 1458 'sheet_sync' => $sheet_sync, 1459 'refrance_shop' => $refrance_shop, 1460 'original_ids' => $original_ids, 1461 ]] 1462 ); 1463 } 1464 1465 } 1466 } 1467 1468 } 1469 1470 /** 1471 * Checks if the current domain should be allowed to perform a sync based on the multi-store sync mode and child activity. 1472 * 1473 * @param bool $domain_access The initial access status for the domain. This is typically set to true by default. 1474 * @return bool Returns true if the sync mode is "shop_to_sheet" and the child store is active. Otherwise, it returns the original value of $domain_access. 1475 */ 1476 public function ssgsw_domain_url($domain_access) { 1477 $sync_mode = get_option('ssgsw_multi_store_mode_on'); 1478 $activity = multissaf_chid_activity(); 1479 if ( 'shop_to_sheet' == $sync_mode && $activity) { 1480 return true; 1481 } 1482 return $domain_access; 1483 } 1484 1485 /** 1486 * Determines the active synchronization mode based on multi-store settings and activity. 1487 * @param string $sync_mode The initial synchronization mode value. 1488 * @return string The determined synchronization mode. 1489 */ 1490 public function ssgsw_sync_mode($sync_mode) { 1491 1492 $sync_mode = get_option('ssgsw_multi_store_mode_on'); 1493 $activity = multissaf_chid_activity(); 1494 if ( 'shop_to_sheet' == $sync_mode && $activity) { 1495 return 'shop_to_sheet'; 1496 } 1497 return $sync_mode; 1498 } 1499 1500 /** 1501 * Modifies the product query to sync only products with a SKU when in multi-store mode. 1502 * 1503 * @global wpdb $wpdb WordPress database object. 1504 * 1505 * @param string $query The original SQL query or query fragment passed to the filter. 1506 * @return string The modified query fragment, or the original query if the condition is not met. 1507 */ 1508 public function ssgsw_sync_only_products_with_sku($query) { 1509 1510 $sync_mode = get_option('ssgsw_multi_store_mode_on'); 1511 $other_check = multissaf_chid_activity(); 1512 1513 if ('multi_to_single' === $sync_mode && $other_check) { 1514 return true; 1515 } 1516 return $query; 1517 } 1518 1519 /** 1520 * Adds the home URL as an 'ssgsw_origin' key to the formatted data array. 1521 * 1522 * @param array $formated_data The array of data to which the origin URL will be added. 1523 * @return array The modified data array with the 'ssgsw_origin' key. 1524 */ 1525 public function ssgsw_add_extra($formated_data, $is_admin, $domain_chcker) { 1526 1527 if ($is_admin) { 1528 $sync_mode = get_option('ssgsw_multi_store_mode_on'); 1529 $other_check = multissaf_chid_activity(); 1530 if (('shop_to_sheet' === $sync_mode && $other_check) || $domain_chcker) { 1531 $formated_data['ssgsw_origin'] = wp_parse_url(home_url(), PHP_URL_HOST); 1532 return $formated_data; 1533 } 1534 } 1535 return $formated_data; 1536 } 1537 1538 /** 1539 * Adds a new custom 'Origin' column to the WooCommerce products list table. 1540 * 1541 * @param array $columns An associative array of existing product columns. 1542 * @return array An updated array of columns, including the new 'Origin' column. 1543 */ 1544 public function product_columns($columns) { 1545 1546 if (!function_exists('ssgsw_is_admin')) { 1547 return $columns; 1548 } 1549 if (!function_exists('ssgsw_check_action_domain')) { 1550 return $columns; 1551 } 1552 $is_admin_check = ssgsw_is_admin(); 1553 $domain_check = ssgsw_check_action_domain(); 1554 if ($is_admin_check) { 1555 $sync_mode = get_option('ssgsw_multi_store_mode_on'); 1556 $other_check = multissaf_chid_activity(); 1557 1558 if ( 'shop_to_sheet' === $sync_mode && $other_check ) { 1559 $ultimate_columns = [ 1560 'ssgsw_origin' => [ 1561 'label' => __('Store Name', 'multi-store-sync-addon-for-flexstock'), 1562 'type' => 'meta', 1563 'priority' => 9, 1564 ] 1565 ]; 1566 return array_merge( $columns, $ultimate_columns ); 1567 } 1568 } 1569 return $columns; 1570 } 1571 1572 /** 1573 * Filters the array of site product counts, allowing other plugins to 1574 * add data from connected sub-sites. 1575 * @param array $main_array The initial array of site data, containing the main site's information. 1576 * @return array The filtered array, now including data for connected sub-sites. 1577 */ 1578 public function ssgsw_sub_site_product_count($main_array = []) { 1579 // Retrieve the list of sub-sites from the option. 1580 $sub_site = multissaf_sub_site_url(); 1581 1582 // Filter out any entries that have empty URLs or tokens. 1583 $filtered_options = array_filter($sub_site, function($entry) { 1584 return !empty($entry['url']) && !empty($entry['token']); 1585 }); 1586 1587 // Re-index the array keys after filtering. 1588 $filtered_options = array_values($filtered_options); 1589 1590 // If there are valid sub-sites, loop through them and add their data to the main array. 1591 if (is_array($filtered_options) && !empty($filtered_options)) { 1592 foreach ($filtered_options as $site) { 1593 // Get the product count for the current sub-site. 1594 $site_url = $site['url']; 1595 $token = $site['token']; 1596 1597 // Get FILTERED product count from child using access_token 1598 $product_count = 0; 1599 if (function_exists('multissaf_get_sub_product_count')) { 1600 $count_data = multissaf_get_sub_product_count($site_url, $token, $site); 1601 if (is_array($count_data) && isset($count_data['count'])) { 1602 $product_count = intval($count_data['count']); 1603 } elseif (is_numeric($count_data)) { 1604 $product_count = intval($count_data); 1605 } 1606 } 1607 1608 // Append the sub-site's data to the main array. 1609 $main_array[] = [ 1610 'url' => $site_url, 1611 'token' => $token, 1612 'product' => $product_count, 1613 'sheet_tab' => $site['tab_name'], 1614 'sheet_id' => $site['sheet_gid'], 1615 ]; 1616 } 1617 } 1618 1619 // Return the final array containing data from the main site and all sub-sites. 1620 return $main_array; 1621 } 1622 1623 /** 1624 * Enqueues CSS styles for the plugin's admin page. 1625 * 1626 * @param string $hook The current admin page hook. 1627 * @return void 1628 */ 1629 public function enqueue_scripts($hook) { 1630 1631 if($hook == 'flexstock_page_ssgsw-multistore') { 1632 wp_enqueue_style('ssgsw-multi-store-css', MULTISSAF_MULTI_STORE_URL . '/assets/build/css/admin.min.css', [], time()); 1633 1634 $setup_complete = get_option('ssgsw_multi_store_setup_complete', false); 1635 1636 if ($setup_complete) { 1637 wp_enqueue_script('ssgsw-multi-store-dashboard', MULTISSAF_MULTI_STORE_URL . '/assets/build/js/admin.min.js', [], time(), true); 1638 1639 // Prepare data for dashboard 1640 $mode = get_option('ssgsw_multi_store_mode_on', 'multi_to_single'); 1641 $role = get_option('ssgsw_multi_connect_mode_on', 'main'); 1642 $js_sync_behavior = ($mode === 'shop_to_sheet') ? 'manage' : 'sync'; 1643 $js_store_role = ($role === 'child') ? 'child' : 'main'; 1644 1645 $available_fields = []; 1646 if (class_exists('\StockSyncWithGoogleSheetForWooCommerce\Column')) { 1647 $column_obj = new \StockSyncWithGoogleSheetForWooCommerce\Column(); 1648 $columns = $column_obj->get_columns(); 1649 foreach ($columns as $key => $data) { 1650 // Skip columns explicitly marked as non-sheet columns (column: false). 1651 if ( isset( $data['column'] ) && false === $data['column'] ) { 1652 continue; 1653 } 1654 $available_fields[] = [ 1655 'val' => $key, 1656 'label' => isset($data['label']) ? $data['label'] : $key 1657 ]; 1658 } 1659 } 1660 1661 $available_categories = []; 1662 $product_cats = get_terms(['taxonomy' => 'product_cat', 'hide_empty' => false]); 1663 if (!is_wp_error($product_cats)) { 1664 foreach ($product_cats as $cat) { 1665 $available_categories[] = [ 1666 'id' => $cat->term_id, 1667 'name' => $cat->name, 1668 'count' => $cat->count 1669 ]; 1670 } 1671 } 1672 1673 $available_tags = []; 1674 $product_tags = get_terms(['taxonomy' => 'product_tag', 'hide_empty' => false]); 1675 if (!is_wp_error($product_tags)) { 1676 foreach ($product_tags as $tag) { 1677 $available_tags[] = [ 1678 'id' => $tag->term_id, 1679 'name' => $tag->name, 1680 'count' => $tag->count 1681 ]; 1682 } 1683 } 1684 1685 $options = apply_filters('ssgsw_options', []); 1686 1687 wp_localize_script('ssgsw-multi-store-dashboard', 'multissaf_store_data', [ 1688 'syncMode' => $mode, 1689 'syncBehavior' => $js_sync_behavior, 1690 'storeRole' => $js_store_role, 1691 'availableFields' => $available_fields, 1692 'availableCategories' => $available_categories, 1693 'availableTags' => $available_tags, 1694 'spreadsheetId' => isset($options['spreadsheet_id']) ? $options['spreadsheet_id'] : '', 1695 'connectedSites' => isset($options['multi_store_list']) ? $options['multi_store_list'] : [], 1696 'connectedSitesParent' => isset($options['child_tokens']) ? $options['child_tokens'] : [], 1697 'homeUrl' => home_url(), 1698 'i18n' => [ 1699 'startingSync' => __('Starting sync...', 'multi-store-sync-addon-for-flexstock'), 1700 'exportingBatch' => __('Exporting batch starting at: ', 'multi-store-sync-addon-for-flexstock'), 1701 'syncing' => __('Syncing: ', 'multi-store-sync-addon-for-flexstock'), 1702 'syncComplete' => __('Sync Complete!', 'multi-store-sync-addon-for-flexstock'), 1703 'confirmReset' => __('Are you sure you want to switch modes? This will reset your current Multi-Store settings.', 'multi-store-sync-addon-for-flexstock'), 1704 'spreadsheetIdNotFound' => __('Master Spreadsheet ID not found. Please check your settings.', 'multi-store-sync-addon-for-flexstock'), 1705 'startingManualSync' => __('Starting manual sync...', 'multi-store-sync-addon-for-flexstock'), 1706 ] 1707 ]); 1708 1709 } else { 1710 wp_enqueue_script('ssgsw-multi-store-setting', MULTISSAF_MULTI_STORE_URL . '/assets/build/js/setting.min.js', [], time(), true); 1711 } 1712 } 1713 1714 } 1715 1716 /** 1717 * Localizes (passes) relevant script variables, specifically multi-store URLs, 1718 * to be used by front-end JavaScript 1719 * @since 1.0.0 (Example version) 1720 * 1721 * @param array $filters An array of data variables already set for localization. 1722 * @return array The updated array of localization data, including multi-store URLs. 1723 */ 1724 public function ssgsw_localized_script($filters) { 1725 $filters['multi_store_list'] = multissaf_sub_site_url(); 1726 $filters['multi_store_list_parent'] = multissaf_sub_site_url_parent(); 1727 1728 return $filters; 1729 } 1730 /** 1731 * Add setting options 1732 * 1733 * @return void 1734 */ 1735 public function ssgsw_options($options) { 1736 $options['multi_store_active'] = get_option('ssgsw_multi_store_active', true); 1737 $options['multi_store_list'] = multissaf_sub_site_url(); 1738 $options['multi_store_list_parent'] = multissaf_sub_site_url_parent(); 1739 $options['multi_store_mode_on'] = get_option('ssgsw_multi_store_mode_on', 'shop_to_sheet'); 1740 $options['multi_connect_mode_on'] = get_option('ssgsw_multi_connect_mode_on', 'child'); 1741 $options['child_tokens'] = get_option('ssgsw_child_tokens', []); 1742 $options['spreadsheet_id'] = function_exists('ssgsw_get_option') ? ssgsw_get_option('spreadsheet_id') : ''; 1743 1744 return $options; 1745 } 1746 1747 /** 1748 * Add vendor menu 1749 * 1750 * @return void 1751 */ 1752 public function set_menu() { 1753 add_submenu_page( 1754 'ssgsw-admin', 1755 __('Multi-Store Sync', 'multi-store-sync-addon-for-flexstock'), 1756 __('Multi-Store Sync', 'multi-store-sync-addon-for-flexstock'), 1757 'manage_options', 1758 'ssgsw-multistore', 1759 [ $this, 'render_vendor_page' ], 1760 99 1761 ); 1762 } 1763 1764 /** 1765 * Add vendor page 1766 * 1767 * @return void 1768 */ 1769 public function render_vendor_page() { 1770 $setup_complete = get_option('ssgsw_multi_store_setup_complete', false); 1771 if ($setup_complete) { 1772 $this->load_template('dashboard'); 1773 } else { 1774 $this->load_template('setting'); 1775 } 1776 } 1777 1778 /** 1779 * Add plugin action links callback. 1780 * 1781 * @param array $links Plugin action links. 1782 * @return array 1783 */ 1784 public function add_plugin_action_links( $links ) { 1785 1786 $links[] = '<a href="' . admin_url('admin.php?page=ssgsw-multistore') . '">' . __('Settings', 'multi-store-sync-addon-for-flexstock') . '</a>'; 1787 return $links; 1788 } 1789 1790 /** 1791 * Redirects the user to the add-ons page after plugin activation, 1792 * but only if the redirection hasn't been triggered before. 1793 * @return void 1794 */ 1795 public function redirect_after_active() { 1796 // Only run in wp-admin, never on frontend, REST, AJAX, or cron requests. 1797 if ( ! is_admin() || wp_doing_ajax() || ! current_user_can( 'manage_options' ) ) { 1798 return; 1799 } 1800 // Skip on bulk plugin activation. 1801 if ( isset( $_GET['activate-multi'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended 1802 return; 1803 } 1804 $ssgsw_triggered_from_ultimate = wp_validate_boolean( get_option( 'ssgsw_triggered_from_multistore' ) ); 1805 if ( ! $ssgsw_triggered_from_ultimate ) { 1806 update_option( 'ssgsw_triggered_from_multistore', true ); 1807 wp_safe_redirect( admin_url( 'admin.php?page=ssgsw-multistore' ) ); 1808 exit; 1809 } 1810 } 1811 1812 /** 1813 * Loads a template file with optional data injection 1814 * @param string $template The template file name (without extension) 1815 * @param array $data Associative array of data to expose to template (optional) 1816 * 1817 * @return void 1818 * 1819 * @throws Exception If template file cannot be found (when WP_DEBUG is true) 1820 */ 1821 public function load_template( $template, $data = [] ) { // phpcs:ignore 1822 if (!is_array($data)) { 1823 $data = []; 1824 } 1825 if ( file_exists( MULTISSAF_MULTI_STORE_TEMPLATES . $template . '.php' ) ) { 1826 include MULTISSAF_MULTI_STORE_TEMPLATES . $template . '.php'; 1827 } 1828 } 1829 1830 /** 1831 * Loads the appropriate template based on the plugin setup status and vendor integration. 1832 * 1833 * @return void 1834 */ 1835 1836 public function multi_store_template() { 1837 1838 } 1839 1840 /** 1841 * Activate plugin 1842 * 1843 * @return void 1844 */ 1845 public function activate() { 1846 delete_option( 'ssgsw_triggered_from_multistore' ); 1847 } 1848 1849 /** 1850 * Print admin notices 1851 * 1852 * @return void 1853 */ 1854 public function admin_notices() { 1855 1856 if ( ! current_user_can( 'activate_plugins' ) ) { 1857 return; 1858 } 1859 1860 // FlexStock is active and meets minimum version — no notice needed. 1861 if ( defined( 'SSGSW_VERSION' ) && version_compare( SSGSW_VERSION, '3.15.0', '>=' ) ) { 1862 return; 1863 } 1864 1865 $plugin_name = esc_html__( 'FlexStock Multi-Store', 'multi-store-sync-addon-for-flexstock' ); 1866 $plugin_url = $this->app->free_version; 1867 1868 $dependency = esc_html__( 'FlexStock', 'multi-store-sync-addon-for-flexstock' ); 1869 1870 // FlexStock is active but version is too old. 1871 if ( defined( 'SSGSW_VERSION' ) && version_compare( SSGSW_VERSION, '3.15.0', '<' ) ) { 1872 $message2 = wp_sprintf( '<strong>%s</strong> requires <strong>%s</strong> plugin version 3.15.0 or higher. Please update FlexStock.', esc_html( $plugin_name ), esc_html( $dependency ) ); 1873 echo wp_sprintf( '<div class="error"><p>%s</p></div>', wp_kses_post( $message2 ) ); 1874 return; 1875 } 1876 1877 // FlexStock is installed but not activated. 1878 if ( $this->app->is_plugin_installed() ) { 1879 $activation_url = wp_nonce_url( 'plugins.php?action=activate&plugin=' . esc_url( $plugin_url ) . '&plugin_status=all&paged=1&s', 'activate-plugin_' . esc_url( $plugin_url ) ); 1880 1881 $message = wp_sprintf( '<strong>%s</strong> requires <strong>%s</strong> plugin to be activated.', esc_html( $plugin_name ), esc_html( $dependency ) ); 1882 $button_text = esc_html__( 'Activate', 'multi-store-sync-addon-for-flexstock' ); 1883 } else { 1884 // FlexStock is not installed at all. 1885 $activation_url = wp_nonce_url( self_admin_url( 'update.php?action=install-plugin&plugin=stock-sync-with-google-sheet-for-woocommerce' ), 'install-plugin_woocommerce' ); 1886 $message = wp_sprintf( '<strong>%s</strong> requires <strong>%s</strong> plugin to be installed and activated.', esc_html( $plugin_name ), esc_html( $dependency ) ); 1887 $button_text = esc_html__( 'Install', 'multi-store-sync-addon-for-flexstock' ); 1888 } 1889 1890 echo wp_sprintf( '<div class="error"><p>%s</p></div>', wp_kses_post( $message ) ); 1891 } 1892 1893 /** 1894 * Initialize Appsero analytics client and WPPOOL plugin SDK. 1895 * 1896 * Integrates: 1897 * 1. Appsero Client for licensing & analytics 1898 * 2. WPPOOL SDK for premium upgrade popups 1899 * 1900 * @return void 1901 */ 1902 public function init_appsero_client() { 1903 $appsero_file = MULTISSAF_MULTI_STORE_PATH . 'vendor/appsero/src/Client.php'; 1904 if ( ! file_exists( $appsero_file ) ) { 1905 return; 1906 } 1907 1908 // Load Appsero Client 1909 if ( ! class_exists( '\MultiStoreSyncAddonForFlexStock\Appsero\Client' ) ) { 1910 require_once $appsero_file; 1911 } 1912 1913 if ( ! class_exists( '\MultiStoreSyncAddonForFlexStock\Appsero\Client' ) ) { 1914 return; 1915 } 1916 1917 $client = new \MultiStoreSyncAddonForFlexStock\Appsero\Client( 1918 '1772338d-40f8-4cb5-907b-432a609cd0f2', 1919 'Multi-Store Sync', 1920 MULTISSAF_MULTI_STORE 1921 ); 1922 1923 // Activate Appsero insights tracking 1924 $client->insights()->init(); 1925 1926 // Initialize WPPOOL SDK for premium popups 1927 if ( function_exists( 'wppool_plugin_init' ) ) { 1928 $default_image = MULTISSAF_MULTI_STORE_URL . '/includes/wppool/background-image.png'; 1929 1930 // Register plugin with SDK 1931 $multistore_plugin = wppool_plugin_init( 'multi_store_sync_addon_for_flexstock', $default_image ); 1932 1933 $promo_image = MULTISSAF_MULTI_STORE_URL . '/includes/wppool/special.png'; 1934 $promo_end = '2026-12-31 23:59:59'; 1935 $promo_start = '2026-11-15 00:00:00'; 1936 $promo_cta = esc_html__( 'Get Pro Now', 'multi-store-sync-addon-for-flexstock' ); 1937 $promo_link = 'https://wppool.dev/flexstock/addons/'; 1938 1939 if ( $multistore_plugin && is_object( $multistore_plugin ) && method_exists( $multistore_plugin, 'set_campaign' ) ) { 1940 $multistore_plugin->set_campaign( $promo_image, $promo_end, $promo_start, $promo_cta, $promo_link ); 1941 } 1942 1943 } 1944 } 1945 1946 /** 1947 * Get current site data from option by URL. 1948 * Used to validate token hasn't changed since cron was scheduled. 1949 * 1950 * @param string $site_url The site URL to look up. 1951 * @return array|false Site data array if found and connected, false otherwise. 1952 */ 1953 private function get_current_site_data($site_url) { 1954 if (empty($site_url)) { 1955 return false; 1956 } 1957 1958 $store_role = get_option('ssgsw_multi_connect_mode_on'); 1959 1960 if ('child' === $store_role) { 1961 $all_sites = get_option('ssgsw_multi_store_list_parent', []); 1962 } else { 1963 $all_sites = get_option('ssgsw_multi_store_list', []); 1964 } 1965 1966 if (!is_array($all_sites) || empty($all_sites)) { 1967 return false; 1968 } 1969 1970 $target_url = trailingslashit($site_url); 1971 1972 foreach ($all_sites as $site) { 1973 if (empty($site['url'])) { 1974 continue; 1975 } 1976 1977 if (trailingslashit($site['url']) === $target_url) { 1978 // Check if connected 1979 $is_connected = isset($site['isConnected']) ? $site['isConnected'] : false; 1980 if ($is_connected === true || $is_connected === 1 || $is_connected === '1') { 1981 return $site; 1982 } 1983 return false; // Found but disconnected 1984 } 1985 } 1986 1987 return false; // Not found 1988 } 1989 1990 /** 1991 * Clean up any stale cron jobs for a specific site URL. 1992 * Called when a site is found to be disconnected during cron execution. 1993 * 1994 * @param string $site_url The site URL to clean up cron jobs for. 1995 */ 1996 private function cleanup_site_cron_jobs($site_url) { 1997 if (empty($site_url)) { 1998 return; 1999 } 2000 2001 // Clear failed sync data 2002 $site_hash = md5($site_url); 2003 delete_option('multissaf_failed_sync_' . $site_hash); 2004 delete_option('multissaf_failed_sync_meta_' . $site_hash); 2005 2006 // Note: Cannot easily clear specific cron jobs here because they don't store site URL 2007 // This is why we need comprehensive cleanup on disconnect/delete 2008 } 2009 2010 } 2011 // Initialize. 2012 $multissaf_hook = new MULTISSAF_Hooks(); 16 defined('ABSPATH') || exit(1); 17 18 if (! class_exists('\MultiStoreSyncAddonforFlexStock\MULTISSAF_Hooks') ) { 19 20 /** 21 * Registers all multistore WP/WC hooks: menus, scripts, product sync, 22 * sheet writes, trash propagation, and token-filtered batch exports. 23 * 24 * @package MultiStoreSyncAddonforFlexStock 25 */ 26 final class MULTISSAF_Hooks 27 { 28 29 /** 30 * App class 31 * 32 * @var App 33 */ 34 public $app; 35 /** 36 * Is Ultimate active 37 * 38 * @var bool 39 */ 40 protected $is_ultimate_active; 41 42 /** 43 * Constructor 44 */ 45 public function __construct() 46 { 47 $this->app = new App(); 48 $this->add_actions(); 49 50 } 51 52 53 /** 54 * Registers all actions and filters. 55 * Called from __construct(). Sets $is_ultimate_active flag. 56 * 57 * @return void 58 */ 59 public function add_actions() 60 { 61 $this->is_ultimate_active = multissaf_is_plugin_ultimate_activated(); 62 register_activation_hook(MULTISSAF_MULTI_STORE, [ $this, 'activate' ]); 63 add_action('pre_current_active_plugins', [ $this, 'admin_notices' ]); 64 65 add_action('init', [ $this, 'init_appsero_client' ]); 66 add_action('init', [ $this, 'redirect_after_active'], 9999); 67 add_filter('plugin_action_links_' . plugin_basename(MULTISSAF_MULTI_STORE), [ $this, 'add_plugin_action_links' ]); 68 add_action('ssgsw_before_log_menu', [ $this, 'set_menu']); 69 add_filter('ssgsw_options', [$this, 'ssgsw_options'], 10, 1); 70 add_filter('ssgsw_localized_script', [$this, 'ssgsw_localized_script'], 10, 1); 71 add_action('admin_enqueue_scripts', [$this, 'enqueue_scripts'], 9); 72 add_filter('ssgsw_sub_site_product_count', [$this, 'ssgsw_sub_site_product_count'], 10, 1); 73 add_filter('ssgsw_columns', [ $this, 'product_columns' ], 10, 1); 74 add_filter('ssgsw_sync_mode', [$this, 'ssgsw_sync_mode'], 10, 1); 75 add_filter('ssgsw_domain_url', [$this, 'ssgsw_domain_url'], 10, 1); 76 add_action('ssgsw_collection_of_update_ids', [$this, 'ssgsw_collection_of_update_ids'], 10, 5); 77 add_action('ssgsw_multistore_product_update', [$this, 'ssgsw_multistore_product_update_func'], 10, 1); 78 79 add_action('ssgsw_multistore_sheet_update', [$this, 'ssgsw_multistore_sheet_update'], 10, 1); 80 81 add_action('ssgsw_multistore_sync_to_sheet', [ $this, 'ssgsw_multistore_sync_to_sheet'], 10, 1); 82 if (! $this->is_ultimate_active ) { 83 add_filter('ssgsw_format_columns_data', [$this, 'ssgsw_format_columns_data'], 10, 3); 84 } 85 add_filter('ssgsw_is_multistore_plugin_active', '__return_true'); 86 87 add_filter('ssgsw_multistore_token_filtered_batch', [ $this, 'ssgsw_token_filtered_batch' ], 10, 4); 88 add_action('trashed_post', [ $this, 'delete_child_tab_rows_on_trash' ], 20, 1); 89 90 // Direct sync handler (replaces cron scheduling) 91 add_action('ssgsw_multistore_product_update_direct', [ $this, 'ssgsw_multistore_product_update_direct' ], 10, 1); 92 } 93 94 /** 95 * Deletes the matching row from every child tab in the destination 96 * spreadsheet when a product is trashed (locally or propagated). 97 * Uses SKU as the primary cross-site identifier, falls back to ID. 98 * Saves/restores $ssgsw_request_params around Sheet calls. 99 * 100 * Connected to: WP action 'trashed_post' (priority 20) 101 * 102 * @param int $post_id The trashed WP post ID. 103 * @return void 104 */ 105 public function delete_child_tab_rows_on_trash( $post_id ) 106 { 107 108 if ('product' !== get_post_type($post_id) && 'product_variation' !== get_post_type($post_id) ) { 109 return; 110 } 111 112 if (! get_option('ssgsw_multi_store_active', false) ) { 113 return; 114 } 115 116 // Only run on the destination/parent site (the one that has child tabs). 117 $store_role = get_option('ssgsw_multi_connect_mode_on', ''); 118 if ('child' === $store_role ) { 119 return; // Source/child sites don't have child tabs. 120 } 121 122 $connected_children = get_option('ssgsw_multi_store_list', []); 123 if (empty($connected_children) || ! is_array($connected_children) ) { 124 return; 125 } 126 127 $sku = get_post_meta($post_id, '_sku', true); 128 129 // Snapshot the global request params. We will temporarily override them per 130 // child-tab iteration so Sheet methods use the DESTINATION's spreadsheet — 131 // not whatever foreign body is in the active HTTP request (e.g. an AppScript 132 // request or a remote REST call from the source site). 133 global $ssgsw_request_params; 134 $saved_request_params = $ssgsw_request_params; 135 136 foreach ( $connected_children as $child ) { 137 $tab_name = isset($child['tab_name']) ? $child['tab_name'] : ''; 138 $sheet_gid = isset($child['sheet_gid']) ? $child['sheet_gid'] : ''; 139 $spreadsheet_id = isset($child['spreadsheet_id']) ? $child['spreadsheet_id'] : ''; 140 141 // Fall back to the site's own spreadsheet ID if not stored per-child. 142 if (empty($spreadsheet_id) ) { 143 $spreadsheet_id = get_option(SSGSW_PREFIX . 'spreadsheet_id', ''); 144 } 145 146 if (empty($tab_name) || empty($spreadsheet_id) ) { 147 continue; 148 } 149 150 if (! class_exists('\StockSyncWithGoogleSheetForWooCommerce\Sheet') ) { 151 continue; 152 } 153 154 // Override request params to force Sheet methods onto THIS child tab's 155 // spreadsheet context (destination spreadsheet, child tab name/GID). 156 // access_token is omitted — Sheet::get_token() uses stored credentials. 157 $ssgsw_request_params = [ 158 'spreadsheet_id' => $spreadsheet_id, 159 'sheet_tab' => $tab_name, 160 'sheet_id' => $sheet_gid, 161 ]; 162 163 $sheet = new \StockSyncWithGoogleSheetForWooCommerce\Sheet(); 164 $sheet->setID($spreadsheet_id); 165 $sheet->setTab($tab_name); 166 if (! empty($sheet_gid) && method_exists($sheet, 'setSheetID') ) { 167 $sheet->setSheetID($sheet_gid); 168 } 169 170 // Fetch the tab's current data. 171 // Pass $tab_name explicitly — belt-and-suspenders against any remaining 172 // body contamination (see 1-child-duplicate.md for root-cause details). 173 $tab_data = $sheet->get_formula_value(false, false, $tab_name, 'UNFORMATTED_VALUE'); 174 if (empty($tab_data) ) { 175 continue; 176 } 177 178 // Child tab rows contain SOURCE product IDs, not destination IDs. 179 // SKU is the reliable cross-site identifier — try it first. 180 $row_number = false; 181 if (! empty($sku) && function_exists('ssgsw_get_index_number_by_sku') ) { 182 $row_number = ssgsw_get_index_number_by_sku($sku, $tab_data); 183 } 184 185 // Fallback: scan column 0 for matching product ID. 186 if (! $row_number && function_exists('ssgsw_format_index_number') ) { 187 $tab_index = ssgsw_format_index_number($tab_data); 188 $row_number = function_exists('ssgsw_get_index_number_option') 189 ? ssgsw_get_index_number_option($post_id, $tab_index) 190 : false; 191 } 192 193 if ($row_number ) { 194 // $sheet_gid passed explicitly so delete_single_row() uses it as 195 // priority-1 and does not fall back to body_info->sheet_id. 196 $sheet->delete_single_row($row_number, $sheet_gid); 197 } 198 } 199 200 // Restore request params so nothing downstream is affected. 201 $ssgsw_request_params = $saved_request_params; 202 } 203 204 /** 205 * Filter: intercepts the base plugin's batch export when a child token 206 * is present in the request. Returns permission-filtered product rows 207 * instead of the default unfiltered batch. 208 * 209 * Connected to: filter 'ssgsw_multistore_token_filtered_batch' 210 * 211 * @param false|array $default Default (no override). 212 * @param int $offset Batch offset. 213 * @param int $batch_size Batch size. 214 * @param int $index Sheet row index. 215 * @return false|array Filtered rows or false to fall back. 216 */ 217 public function ssgsw_token_filtered_batch( $default, $offset, $batch_size, $index ) 218 { 219 220 // Resolve bearer token from the current request. 221 $bearer = ''; 222 if (! empty($_SERVER['HTTP_SSGSWKEY']) ) { 223 $bearer = sanitize_text_field(wp_unslash($_SERVER['HTTP_SSGSWKEY'])); 224 } elseif (! empty($_SERVER['HTTP_AUTHORIZATION']) ) { 225 $bearer = sanitize_text_field(wp_unslash($_SERVER['HTTP_AUTHORIZATION'])); 226 } 227 $bearer = str_replace('Bearer ', '', $bearer); 228 229 if (empty($bearer) ) { 230 return false; 231 } 232 233 // Find the matching child token with its permissions. 234 $child_tokens = get_option('ssgsw_child_tokens', []); 235 if (! is_array($child_tokens) ) { 236 return false; 237 } 238 239 $permissions = null; 240 $limit = 0; 241 foreach ( $child_tokens as $token_data ) { 242 if (isset($token_data['token']) && $bearer === $token_data['token'] ) { 243 // Unwrap nested permissions structure. 244 $perms = isset($token_data['permissions']) ? (array) $token_data['permissions'] : []; 245 if (! empty($perms['permissions']) && is_array($perms['permissions']) ) { 246 $perms = $perms['permissions']; 247 } 248 if (! isset($perms['include_type']) ) { 249 $perms['include_type'] = 'all'; 250 } 251 $permissions = $perms; 252 $limit = function_exists('multissaf_get_token_max_products') ? multissaf_get_token_max_products($token_data) : 0; 253 break; 254 } 255 } 256 257 if ($permissions === null ) { 258 return false; 259 } 260 261 // Stay within the token's product count (0 = all); past it return an empty batch, 262 // not false (false would sync every product). 263 $fetch_size = $batch_size; 264 if (! empty($limit) ) { 265 if (absint($offset) >= $limit ) { 266 return []; 267 } 268 $fetch_size = absint($batch_size) > 0 ? min(absint($batch_size), $limit - absint($offset)) : $limit - absint($offset); 269 } 270 271 // Use the token-aware batch function to fetch only filtered products. 272 if (function_exists('multissaf_get_batch_products_by_token') ) { 273 $data = multissaf_get_batch_products_by_token(null, $offset, $fetch_size, $permissions); 274 if (is_array($data) ) { 275 if ($offset === 0 && ! empty($data) ) { 276 $first = reset($data); 277 // Header row contains strings (labels), not numeric IDs — detect by checking 278 if (is_array($first) && isset($first[0]) && ! is_numeric($first[0]) ) { 279 array_shift($data); 280 } 281 } 282 283 return $data; 284 } 285 } 286 287 return false; 288 } 289 290 /** 291 * Format image and formula data 292 * 293 * @param array $format_data data. 294 * 295 * @return array formatted data. 296 */ 297 public function ssgsw_format_columns_data($formatted_data, $sheets_info, $formula) 298 { 299 300 if ($this->app->is_plugin_activated()) { 301 if ($formula ) { 302 $sheet_header = isset($sheets_info[0]) ? $sheets_info[0] : []; 303 $check_id_exist = isset($sheets_info[0][0]) && 'ID' === $sheets_info[0][0] ? true : false; 304 $formatted_data = array_map(function ( $row ) use ( $sheets_info, $sheet_header, $check_id_exist ) { //phpcs:ignore 305 $desired_value = $row['ID']; 306 $found_array = null; 307 if (is_array($sheets_info) && ! empty($sheets_info) ) { 308 foreach ( $sheets_info as $key => $sub_array ) { 309 if (isset($sub_array[0]) && $sub_array[0] === $desired_value ) { 310 $found_array = $sub_array; 311 break; 312 } 313 } 314 } 315 316 if (is_array($found_array) ) { 317 $i = 0; 318 if ($check_id_exist ) { 319 $google_with_header = $this->set_sheet_header_with_data($sheet_header, $found_array); 320 $database_data = $this->ssgsw_compare_with_formula_value($desired_value); 321 $check_have_change = false; 322 if (! empty($database_data) ) { 323 $previous = []; 324 foreach ( $row as $key => $value ) { 325 $formation_key = $this->ssgsw_sheet_header_formating2($key); 326 if (isset($google_with_header[ $key ]) && strpos($google_with_header[ $key ], '=') === 0 ) { 327 if ('product_image' !== $key ) { 328 $row[ $key ] = isset($google_with_header[ $key ]) ? $google_with_header[ $key ] : ''; 329 $new_data_set = isset($database_data[ $formation_key ]) ? $database_data[ $formation_key ] : ''; 330 $previous[0][ $formation_key ] = $new_data_set; 331 if ($value !== $new_data_set ) { 332 $check_have_change = true; 333 } 334 } 335 } else { 336 $previous[0][ $formation_key ] = $row[ $key ]; 337 } 338 $i++; 339 } 340 if ($check_have_change ) { 341 $product_object = new \StockSyncWithGoogleSheetForWooCommerce\Product(); 342 $product_object->bulk_update($previous, false, 1, false); 343 } 344 } else { 345 foreach ( $row as $key => $value ) { 346 if (isset($google_with_header[ $key ]) && strpos($google_with_header[ $key ], '=') === 0 ) { 347 if ('product_image' !== $key ) { 348 $row[ $key ] = $google_with_header[ $key ]; 349 } 350 } 351 $i++; 352 } 353 } 354 } else { 355 foreach ( $row as $key => $value ) { 356 if (isset($found_array[ $i ]) && strpos($found_array[ $i ], '=') === 0 ) { 357 if ('product_image' !== $key ) { 358 $row[ $key ] = $found_array[ $i ]; 359 } 360 } 361 $i++; 362 } 363 } 364 } 365 if (is_array($row) && array_key_exists('post_author', $row)) { 366 $row['post_author'] = $this->generate_shop_name($row['post_author']); 367 } 368 if (is_array($row) && array_key_exists('ssgsw_product_url', $row)) { 369 $row['ssgsw_product_url'] = $this->generate_product_url($row); 370 } 371 if (array_key_exists('product_image', $row) && ! empty($row['product_image']) ) { 372 $image_url_convart = $row['product_image']; 373 if ($image_url_convart) { 374 $base_url = wp_upload_dir()['baseurl']; 375 $absolute_url = trailingslashit($base_url) . ltrim($image_url_convart, '/'); 376 $row['product_image'] = '=IMAGE("' . $absolute_url . '")'; 377 } else { 378 $row['product_image'] = ''; 379 } 380 return array_values((array) $row); 381 } else { 382 return array_values((array) $row); 383 } 384 }, $formatted_data 385 ); 386 387 } else { 388 $formatted_data = array_map(function ( $row ) { //phpcs:ignore 389 if (is_array($row) && array_key_exists('ssgsw_product_url', $row)) { 390 $row['ssgsw_product_url'] = $this->generate_product_url($row); 391 } 392 if (is_array($row) && array_key_exists('post_author', $row)) { 393 $row['post_author'] = $this->generate_shop_name($row['post_author']); 394 } 395 if (array_key_exists('product_image', $row) && ! empty($row['product_image']) ) { 396 $image_url_convart = $row['product_image']; 397 if ($image_url_convart) { 398 $base_url = wp_upload_dir()['baseurl']; 399 $absolute_url = trailingslashit($base_url) . ltrim($image_url_convart, '/'); 400 $row['product_image'] = '=IMAGE("' . $absolute_url . '")'; 401 } else { 402 $row['product_image'] = ''; 403 } 404 return array_values((array) $row); 405 } else { 406 return array_values((array) $row); 407 } 408 }, $formatted_data 409 ); 410 411 } 412 } 413 return $formatted_data; 414 } 415 /** 416 * Generate Product URL 417 */ 418 public function generate_product_url($row) 419 { 420 $p_id = $row['ID']; 421 return get_permalink($p_id); 422 } 423 /** 424 * Generate Shop Name 425 */ 426 public function generate_shop_name($author_id) 427 { 428 return get_the_author_meta('display_name', $author_id); 429 } 430 /** 431 * SSGSW Sheet Header formating 432 * 433 * @param string $key Key of the data. 434 * 435 * @return string 436 */ 437 public function ssgsw_sheet_header_formating2( $key ) 438 { 439 $header_info = [ 440 'ID' => 'ID', 441 'product_type' => 'Type', 442 'post_title' => 'name', 443 '_stock' => 'stock', 444 '_regular_price' => 'regular_price', 445 '_sale_price' => 'sale_price', 446 'product_image' => 'Image', 447 '_sku' => 'sku', 448 'total_sales' => 'sales', 449 '_product_attributes' => 'attributes', 450 'product_cat' => 'category', 451 'post_excerpt' => 'post_excerpt', 452 ]; 453 if (array_key_exists($key, $header_info) ) { 454 return $header_info[ $key ]; 455 } else { 456 return $key; 457 } 458 } 459 /** 460 * Get products information with compare value 461 * 462 * @param int $product_id ID of product. 463 * 464 * @return mixed 465 */ 466 public function ssgsw_compare_with_formula_value( $product_id ) 467 { 468 $product_info = $this->ssgsw_get_product_information_by_id($product_id); 469 if (is_array($product_info) && ! empty($product_info) ) { 470 $product_information = isset($product_info[0]['product_information']) ? unserialize($product_info[0]['product_information']) : []; 471 if (! empty($product_information) ) { 472 return $product_information; 473 } 474 } 475 return []; 476 } 477 /** 478 * Get SSGSW product information 479 * 480 * @param int $product_id ID of product. 481 * 482 * @return mixed 483 */ 484 public function ssgsw_get_product_information_by_id( $product_id ) 485 { 486 global $wpdb; 487 $product_id = sanitize_text_field($product_id); 488 $results = $wpdb->get_results( 489 $wpdb->prepare( 490 "SELECT * FROM {$wpdb->prefix}ssgsw_products WHERE product_id = %s", 491 $product_id 492 ), 493 ARRAY_A 494 ); 495 return $results; 496 } 497 /** 498 * Public function Sheet header with data. 499 * 500 * @param array $header_info header information. 501 * @param array $data data information. 502 * 503 * @return array 504 */ 505 public function set_sheet_header_with_data( $header_info = [], $data = [] ) 506 { 507 $header_marge = []; 508 if (is_array($header_info) && ! empty($header_info) ) { 509 foreach ( $header_info as $key => $value ) { 510 $header = $this->ssgsw_sheet_header_formating($value); 511 $header_marge[ $header ] = isset($data[ $key ]) ? $data[ $key ] : ''; 512 } 513 } 514 return $header_marge; 515 } 516 517 /** 518 * SSGSW Sheet Header formating 519 * 520 * @param string $key Key of the data. 521 * 522 * @return string 523 */ 524 public function ssgsw_sheet_header_formating( $key ) 525 { 526 $header_info = [ 527 'ID' => 'ID', 528 'Type' => 'product_type', 529 'Name' => 'post_title', 530 'Stock' => '_stock', 531 'Regular price' => '_regular_price', 532 'Sale price' => '_sale_price', 533 'Image' => 'product_image', 534 'SKU' => '_sku', 535 'No of Sales' => 'total_sales', 536 'Attributes' => '_product_attributes', 537 'Categories' => 'product_cat', 538 'Short description' => 'post_excerpt', 539 ]; 540 if (array_key_exists($key, $header_info) ) { 541 return $header_info[ $key ]; 542 } else { 543 return $key; 544 } 545 } 546 /** 547 * Syncs product data from a single store to multiple stores in batches. 548 * 549 * @param array $args An associative array containing: 550 */ 551 public function ssgsw_multistore_sync_to_sheet($args) 552 { 553 $batch_size = 60; 554 $delay_seconds = 2; 555 $orgingal_ids = isset($args['product_ids']) ? $args['product_ids'] : []; 556 $site_url = isset($args['site_url']) ? $args['site_url'] : (isset($args['refarance_shop']) ? $args['refarance_shop'] : ''); 557 $token = isset($args['token']) ? $args['token'] : ''; 558 // Check if there are product IDs to process. 559 if (is_array($orgingal_ids) && !empty($orgingal_ids)) { 560 if (class_exists('\StockSyncWithGoogleSheetForWooCommerce\Sheet') && class_exists('\StockSyncWithGoogleSheetForWooCommerce\Column')) { 561 $new_sheets = new \StockSyncWithGoogleSheetForWooCommerce\Sheet(); 562 563 $spreadsheet_id = function_exists('ssgsw_get_option') ? ssgsw_get_option('spreadsheet_id') : ''; 564 $sheet_tab = function_exists('ssgsw_get_option') ? ssgsw_get_option('sheet_tab') : ''; 565 $cache_key = apply_filters( 566 'multissaf_sheets_info_cache_key', 567 'multissaf_sheets_info_' . md5($spreadsheet_id . '_' . $sheet_tab) 568 ); 569 570 $sheets_info = get_transient($cache_key); 571 if (false === $sheets_info || empty($sheets_info) ) { 572 $sheets_info = $new_sheets->get_formula_value(); 573 if (! empty($sheets_info) ) { 574 set_transient( 575 $cache_key, 576 $sheets_info, 577 apply_filters('multissaf_sheets_info_ttl', 5 * MINUTE_IN_SECONDS) 578 ); 579 } 580 } 581 582 if (empty($sheets_info)) { 583 $sheets_info = $new_sheets->get_formula_value(); 584 } 585 586 $columns = new \StockSyncWithGoogleSheetForWooCommerce\Column(); 587 $columns_info = $columns->get_columns_no_edit_remove(); 588 $sheet_header = isset($sheets_info[0]) ? $sheets_info[0] : []; 589 $key_with_headers = multissaf_format_headers_with_sheet_header($sheet_header, $columns_info); 590 $keys = isset($key_with_headers[0]) ? $key_with_headers[0] : []; 591 $domain_url = apply_filters('ssgsw_domain_url', false); 592 593 // Get the current batch of product IDs. 594 $current_batch = array_slice($orgingal_ids, 0, $batch_size); 595 596 if (!empty($current_batch)) { 597 $get_batch = multissaf_batch_api_request( 598 0, 0, 0, $site_url, $token, $keys, $domain_url, $current_batch, $sheets_info 599 ); 600 601 // Process and update the Google Sheet with the fetched data. 602 $sheets_index = []; 603 if (function_exists('ssgsw_format_index_number')) { 604 $sheets_index = ssgsw_format_index_number($sheets_info); 605 } 606 607 if (is_array($get_batch) && !empty($get_batch)) { 608 609 $sync_batch_updates = []; 610 $sync_batch_appends = []; 611 612 foreach ($get_batch as $values) { 613 $values = (array) $values; 614 $product_id = isset($values[0]) ? $values[0] : false; 615 if ($product_id) { 616 $index_number_exists = false; 617 if (function_exists('multissaf_get_index_number_option_param')) { 618 $index_number_exists = multissaf_get_index_number_option_param($product_id, $sheets_index, $site_url); 619 } 620 621 if ($index_number_exists) { 622 $col_end = $new_sheets->getColumnLetter(count($values)); 623 $range = $sheet_tab . '!A' . $index_number_exists . ':' . $col_end . $index_number_exists; 624 $sync_batch_updates[] = [ 625 'range' => $range, 626 'values' => [ array_values($values) ], 627 ]; 628 } else { 629 $sync_batch_appends[] = array_values($values); 630 } 631 } 632 } 633 634 // Send all updates in ONE API call 635 if (! empty($sync_batch_updates) ) { 636 $new_sheets->batch_update_rows($sync_batch_updates); 637 // Bust sheet cache after write 638 delete_transient($cache_key); 639 } 640 641 // Send all appends in ONE API call 642 if (! empty($sync_batch_appends) ) { 643 $new_sheets->append_batch_rows($sync_batch_appends); 644 // Bust sheet cache after write 645 delete_transient($cache_key); 646 } 647 } 648 } 649 650 // Slice the remaining products for the next batch. 651 $remaining_product_ids = array_slice($orgingal_ids, $batch_size); 652 653 // If there are more products to process, schedule a new event. 654 if (!empty($remaining_product_ids)) { 655 $next_args = $args; 656 $next_args['orgingal_ids'] = $remaining_product_ids; 657 658 // Clear any previous schedule for this task to prevent duplicates. 659 wp_clear_scheduled_hook('ssgsw_multistore_sync_to_sheet', [$next_args]); 660 661 // Schedule the next batch after the specified delay. 662 wp_schedule_single_event(time() + $delay_seconds, 'ssgsw_multistore_sync_to_sheet', [$next_args]); 663 } 664 } 665 } 666 } 667 668 /** 669 * Writes a batch of product IDs to the local main sheet via 670 * StockSyncWithGoogleSheetForWooCommerce\Sheet. Skips trashed products. 671 * Schedules continuation for batches > 60 products. 672 * 673 * Connected to: action 'ssgsw_multistore_sheet_update' 674 * 675 * @param array $args { 676 * @type int[] $product_ids IDs to write to sheet. 677 * } 678 * @return void 679 */ 680 public function ssgsw_multistore_sheet_update($args) 681 { 682 if (!is_array($args) || empty($args)) { 683 return; 684 } 685 686 $product_ids = isset($args['product_ids']) ? (array) $args['product_ids'] : []; 687 688 // Remove duplicate IDs from the incoming batch. 689 $product_ids = array_unique(array_map('intval', $product_ids)); 690 691 // Sort the product IDs to ensure they are processed in a consistent order. 692 sort($product_ids); 693 $batch_size = 60; 694 $delay_seconds = 2; 695 696 // Get the product IDs for the current batch to process 697 $current_batch_ids = array_slice($product_ids, 0, $batch_size); 698 699 // If the current batch is empty, there's nothing to do 700 if (empty($current_batch_ids)) { 701 return; 702 } 703 704 // Write lock: prevents concurrent sheet writes that cause duplicates. 705 $write_lock_key = apply_filters( 706 'multissaf_sheet_write_lock_key', 707 'multissaf_sheet_write_lock' 708 ); 709 710 // Check if another write is in progress 711 if (get_transient($write_lock_key) ) { 712 // Another batch is currently writing to the sheet 713 // Reschedule this batch for 3 seconds later to avoid race condition 714 715 wp_schedule_single_event( 716 time() + 3, 717 'ssgsw_multistore_sheet_update', 718 [ $args ] 719 ); 720 return; 721 } 722 723 // Acquire lock (30 second TTL as safety in case of fatal error) 724 set_transient($write_lock_key, time(), 30); 725 726 727 // Initialize required classes and data 728 if (class_exists('\StockSyncWithGoogleSheetForWooCommerce\Sheet') && class_exists('\StockSyncWithGoogleSheetForWooCommerce\Column') && class_exists('\StockSyncWithGoogleSheetForWooCommerce\Product')) { 729 730 $new_sheet = new \StockSyncWithGoogleSheetForWooCommerce\Sheet(); 731 // Cache Sheet Formula Value 732 733 $spreadsheet_id = function_exists('ssgsw_get_option') ? ssgsw_get_option('spreadsheet_id') : ''; 734 $sheet_tab = function_exists('ssgsw_get_option') ? ssgsw_get_option('sheet_tab') : ''; 735 $cache_key = apply_filters( 736 'multissaf_sheets_info_cache_key', 737 'multissaf_sheets_info_' . md5($spreadsheet_id . '_' . $sheet_tab) 738 ); 739 740 $sheets_info = get_transient($cache_key); 741 if (false === $sheets_info || empty($sheets_info) ) { 742 $sheets_info = $new_sheet->get_formula_value(); 743 if (! empty($sheets_info) ) { 744 set_transient( 745 $cache_key, 746 $sheets_info, 747 apply_filters('multissaf_sheets_info_ttl', 5 * MINUTE_IN_SECONDS) 748 ); 749 } 750 } 751 752 $columns = new \StockSyncWithGoogleSheetForWooCommerce\Column(); 753 $keys_values = []; 754 if (function_exists('ssgsw_prepare_sheet_keys')) { 755 $keys_values = ssgsw_prepare_sheet_keys($sheets_info, $columns); 756 } 757 758 $products = new \StockSyncWithGoogleSheetForWooCommerce\Product(); 759 $formatted_data = $products->get_formatted_data($current_batch_ids, $sheets_info, false, 0, 0, ['product', 'product_variation'], false, $keys_values); 760 $sheets_index = []; 761 if (function_exists('ssgsw_format_index_number')) { 762 $sheets_index = ssgsw_format_index_number($sheets_info); 763 } 764 765 // Collect all row updates then batchUpdate 766 767 $batch_updates = []; 768 $batch_appends = []; 769 $newly_marked_ids = []; // IDs about to be appended. 770 771 foreach ($current_batch_ids as $key => $product_id) { 772 // Skip trashed or deleted products — their rows are already gone from the 773 // sheet and appending them would create infinite duplicate rows. 774 $post_status = get_post_status($product_id); 775 if (! $post_status || 'trash' === $post_status ) { 776 continue; 777 } 778 779 $formatted_key = array_search($product_id, $current_batch_ids); 780 $values = isset($formatted_data[$formatted_key]) ? $formatted_data[$formatted_key] : []; 781 if ($values) { 782 // Check this site's own row marker first; it takes priority. 783 // over live-sheet lookup. If we've already successfully appended a row for 784 // this product before, we NEVER append again regardless of what the 785 // (possibly stale or mismatched) live index says. 786 $already_has_row = get_post_meta($product_id, '_ssgsw_main_sheet_row_written', true); 787 788 if (function_exists('ssgsw_get_index_number_option')) { 789 $index_number_exits = ssgsw_get_index_number_option($product_id, $sheets_index); 790 } else { 791 $index_number_exits = false; 792 } 793 794 if ($index_number_exits) { 795 $col_end = $new_sheet->getColumnLetter(count($values)); 796 $range = $sheet_tab . '!A' . $index_number_exits . ':' . $col_end . $index_number_exits; 797 $batch_updates[] = [ 798 'range' => $range, 799 'values' => [ array_values($values) ], 800 ]; 801 // Backfill the marker if it was missing. 802 if (! $already_has_row ) { 803 update_post_meta($product_id, '_ssgsw_main_sheet_row_written', 1); 804 } 805 } elseif ($already_has_row ) { 806 // The marker says this row was already written: skip instead of appending a duplicate. 807 continue; 808 } else { 809 $batch_appends[] = array_values($values); 810 $newly_marked_ids[] = $product_id; // mark only after successful write 811 } 812 } 813 } 814 815 // Send all updates in ONE API call 816 if (! empty($batch_updates) ) { 817 $result = $new_sheet->batch_update_rows($batch_updates); 818 if ($result ) { 819 delete_transient($cache_key); 820 } 821 } 822 // Send all appends in ONE API call 823 if (! empty($batch_appends) ) { 824 $append_result = $new_sheet->append_batch_rows($batch_appends); 825 delete_transient($cache_key); 826 827 // Mark as written only if the append went through. 828 if ($append_result ) { 829 foreach ( $newly_marked_ids as $marked_id ) { 830 update_post_meta($marked_id, '_ssgsw_main_sheet_row_written', 1); 831 } 832 } 833 } 834 835 } 836 837 // Release the lock so the next batch can run. 838 delete_transient($write_lock_key); 839 840 841 // Now, schedule the next batch 842 $remaining_product_ids = array_slice($product_ids, $batch_size); 843 844 // If there are more products to process, schedule a new event 845 if (!empty($remaining_product_ids)) { 846 $next_args = $args; 847 $next_args['product_ids'] = $remaining_product_ids; 848 wp_clear_scheduled_hook('ssgsw_multistore_sheet_update', [$next_args]); 849 850 wp_schedule_single_event(time() + $delay_seconds, 'ssgsw_multistore_sheet_update', [$next_args]); 851 } 852 } 853 854 /** 855 * Direct sync dispatcher. If cron sync is disabled (feature flag 856 * 'enable_cron_sync' = false), calls ssgsw_multistore_product_update_func() 857 * inline. Otherwise fires the 'ssgsw_multistore_product_update' cron action. 858 * 859 * Connected to: action 'ssgsw_multistore_product_update_direct' 860 * 861 * @param array $args Same shape as ssgsw_multistore_product_update_func(). 862 * @return void 863 */ 864 public function ssgsw_multistore_product_update_direct( $args ) 865 { 866 $product_ids = isset($args['product_ids']) ? $args['product_ids'] : []; 867 $retry_count = isset($args['retry_count']) ? intval($args['retry_count']) : 0; 868 $sheet_sync = isset($args['sheet_sync']) ? $args['sheet_sync'] : false; 869 $refrance_shop = isset($args['refrance_shop']) ? $args['refrance_shop'] : false; 870 $site_offset = isset($args['site_offset']) ? intval($args['site_offset']) : 0; 871 $original_ids = isset($args['original_ids']) ? $args['original_ids'] : []; 872 873 // Check if feature is enabled 874 if (! multissaf_get_feature_flag('enable_cron_sync') ) { 875 // Direct sync is enabled (cron is disabled) 876 877 // Max retries 878 $max_retries = multissaf_get_feature_flag('max_retry_count') ? (int) multissaf_get_feature_flag('max_retry_count') : 3; 879 880 if ($retry_count >= $max_retries ) { 881 // Max retries reached, log and stop 882 return; 883 } 884 885 // Call the existing multistore product update function 886 $result = $this->ssgsw_multistore_product_update_func($args); 887 888 } else { 889 // Cron sync is enabled, use old behavior 890 do_action('ssgsw_multistore_product_update', $args); 891 } 892 } 893 894 /** 895 * Handles the main cross-site product propagation loop. 896 * For each connected site, packages product data and POSTs to 897 * /wp-json/ssgsw/v1/update_by_ids. Schedules child-tab writes 898 * and sheet updates via WP-Cron on success. 899 * 900 * Connected to: action 'ssgsw_multistore_product_update' 901 * 902 * @param array $args { 903 * @type int[] $product_ids IDs to propagate. 904 * @type bool $sheet_sync Whether this is a sheet-sync-only pass. 905 * @type string $refrance_shop URL of the originating site (skip loop). 906 * @type int $site_offset Pagination offset within connected sites. 907 * @type int[] $original_ids Source IDs for child-tab SKU resolution. 908 * } 909 * @return void 910 */ 911 public function ssgsw_multistore_product_update_func($args) 912 { 913 914 $product_ids = isset($args['product_ids']) ? $args['product_ids'] : []; 915 $sheet_sync = isset($args['sheet_sync']) ? $args['sheet_sync'] : false; 916 $refrance_shop = isset($args['refrance_shop']) ? $args['refrance_shop'] : false; 917 $site_offset = isset($args['site_offset']) ? intval($args['site_offset']) : 0; 918 $original_ids = isset($args['original_ids']) ? $args['original_ids'] : []; 919 920 // LOG: Entry point 921 922 // Drain pending ID queue into current batch 923 924 $pending_key = apply_filters('multissaf_pending_ids_key', 'multissaf_pending_product_ids'); 925 $queued_ids = get_transient($pending_key); 926 if (! empty($queued_ids) && is_array($queued_ids) ) { 927 $product_ids = array_unique(array_merge((array) $product_ids, $queued_ids)); 928 delete_transient($pending_key); 929 } 930 delete_transient(apply_filters('multissaf_sync_lock_key', 'multissaf_sync_scheduled_lock')); 931 932 // Clear recovery queue on successful retry 933 $is_retry = ! empty($args['is_retry']) ? true : false; 934 $retry_site = ! empty($args['retry_site']) ? $args['retry_site'] : ''; 935 936 if ($is_retry && $retry_site ) { 937 $failed_key = apply_filters( 938 'multissaf_failed_sync_key', 939 'multissaf_failed_sync_' . md5($retry_site) 940 ); 941 } 942 943 $site_batch_limit = 10; 944 945 $store_role = get_option('ssgsw_multi_connect_mode_on'); 946 $sync_mode = get_option('ssgsw_multi_store_mode_on'); 947 948 if (! is_array($product_ids) || empty($product_ids) ) { return; 949 } 950 951 $batch_size = 55; 952 $delay_seconds = 5; 953 $current_batch = array_slice($product_ids, 0, $batch_size); 954 955 if (empty($current_batch)) { return; 956 } 957 958 $all_sites = []; 959 if ('child' === $store_role) { 960 $all_sites = multissaf_get_connected_sites('ssgsw_multi_store_list_parent'); 961 } elseif ('main' === $store_role && 'multi_to_single' === $sync_mode) { 962 $all_sites = multissaf_get_connected_sites('ssgsw_multi_store_list'); 963 } 964 965 $current_sites_batch = array_slice($all_sites, $site_offset, $site_batch_limit); 966 $refrance_token = get_option('ssgsw_token'); 967 $refarance_shop_url = home_url(); 968 969 // LOG: Sites found 970 971 if (! empty($current_sites_batch) ) { 972 973 foreach ($current_sites_batch as $sites) { 974 $site_url = isset($sites['url']) ? $sites['url'] : ''; 975 $token = isset($sites['token']) ? $sites['token'] : ''; 976 977 // Default to TRUE (connected) if isConnected field is missing 978 $is_connected = isset($sites['isConnected']) ? $sites['isConnected'] : true; 979 980 // Only skip if EXPLICITLY disconnected (0 or false) 981 if (is_bool($is_connected) && $is_connected === false ) { 982 // Site is explicitly disconnected - skip and clean up 983 $this->cleanup_site_cron_jobs($site_url); 984 continue; 985 } 986 if (is_numeric($is_connected) && intval($is_connected) === 0 ) { 987 // Site is explicitly disconnected (0) - skip and clean up 988 $this->cleanup_site_cron_jobs($site_url); 989 continue; 990 } 991 992 if (! $site_url || ! $token) { 993 continue; 994 } 995 996 // LOG: Sending attempt 997 998 // Validate token still matches current connection 999 // Get fresh site data from option to ensure token hasn't changed 1000 $current_site_data = $this->get_current_site_data($site_url); 1001 if (! $current_site_data || $current_site_data['token'] !== $token ) { 1002 // Token mismatch - this cron job is from an old connection 1003 // Skip processing and clean up this stale cron job 1004 continue; 1005 } 1006 1007 $is_ref_shop = ( $refrance_shop && trailingslashit($site_url) === trailingslashit($refrance_shop) ); 1008 1009 $permissions = isset($sites['permissions']) ? (array) $sites['permissions'] : []; 1010 1011 // Extra permission mapping for child role 1012 $token_limit = 0; 1013 if ('child' === $store_role) { 1014 $parents1 = get_option('ssgsw_child_tokens', []); 1015 if (! empty($parents1) ) { 1016 foreach ($parents1 as $p1) { 1017 $p1_main = isset($p1['main_store']) ? $p1['main_store'] : ''; 1018 if (trailingslashit($p1_main) === trailingslashit($site_url) 1019 || ( isset($p1['url']) && trailingslashit($p1['url']) === trailingslashit($site_url) ) 1020 ) { 1021 $permissions = isset($p1['permissions']) ? $p1['permissions'] : $permissions; 1022 $token_limit = function_exists('multissaf_get_token_max_products') ? multissaf_get_token_max_products($p1) : 0; 1023 break; 1024 } 1025 } 1026 } 1027 } 1028 1029 $target_ids = []; 1030 1031 if (! $is_ref_shop ) { 1032 // On the source store, push only the products inside the token's scope 1033 // (filtered products / specific SKUs); other changes are not sent. 1034 $batch_ids = $current_batch; 1035 $apply_scope = false; 1036 if ('child' === $store_role ) { 1037 if (isset($permissions['permissions']) && is_array($permissions['permissions']) ) { 1038 $permissions = $permissions['permissions']; 1039 } 1040 $apply_scope = isset($permissions['include_type']) && 'all' !== $permissions['include_type']; 1041 if ($apply_scope && function_exists('multissaf_filter_ids_by_token_scope') ) { 1042 $batch_ids = multissaf_filter_ids_by_token_scope($current_batch, $permissions); 1043 } 1044 // Send only products within the token's product count. 1045 if ($token_limit && function_exists('multissaf_get_token_limited_ids') ) { 1046 $batch_ids = array_values(array_intersect(array_map('intval', (array) $batch_ids), multissaf_get_token_limited_ids($permissions, $token_limit))); 1047 } 1048 } 1049 $get_row_data = empty($batch_ids) ? [] : multissaf_get_product_by_ids($batch_ids, $permissions, $apply_scope); 1050 1051 // If no products returned, all IDs in this batch are trashed/deleted. 1052 // Do not send to remote and do not schedule a retry — there is nothing to sync. 1053 if (empty($get_row_data) ) { 1054 // Clean up any stale failed-sync option for this site so the retry 1055 // loop that caused infinite duplicates cannot continue. 1056 $stale_key = apply_filters('multissaf_failed_sync_key', 'multissaf_failed_sync_' . md5($site_url)); 1057 delete_option($stale_key); 1058 delete_option('multissaf_failed_sync_meta_' . md5($site_url)); 1059 continue; 1060 } 1061 1062 $skus = []; 1063 foreach ($get_row_data as $p) { 1064 if (! empty($p['sku'])) { $skus[] = $p['sku']; 1065 } 1066 } 1067 1068 $body = [ 1069 'ssgsw_products' => $get_row_data, 1070 'skus' => $skus, 1071 'refarance_shop' => $refarance_shop_url, 1072 'refrance_token' => $refrance_token, 1073 'orgingal_ids' => $product_ids, 1074 'sync_mode' => $sync_mode, 1075 'sheet_sync' => $sheet_sync, 1076 'manual_update' => true, 1077 ]; 1078 1079 $api_url = trailingslashit($site_url) . 'wp-json/ssgsw/v1/update_by_ids'; 1080 $response = multissaf_single_product_update($api_url, $body, $token); 1081 1082 if ($response && isset($response->data->target_ids) && ! empty($response->data->target_ids)) { 1083 $target_ids = $response->data->target_ids; 1084 1085 // LOG: Success 1086 1087 if ($is_retry && $retry_site && trailingslashit($site_url) === trailingslashit($retry_site) ) { 1088 delete_option($failed_key); 1089 delete_option('multissaf_failed_sync_meta_' . md5($retry_site)); 1090 } 1091 1092 } else { 1093 1094 // LOG: Failed 1095 1096 $failed_key_site = apply_filters( 1097 'multissaf_failed_sync_key', 1098 'multissaf_failed_sync_' . md5($site_url) 1099 ); 1100 $failed_ids = get_option($failed_key_site, []); 1101 1102 // Only retry IDs that still exist as published products. 1103 // Trashed/deleted products must never enter the retry queue — 1104 // they cause infinite append loops because the sheet row is gone. 1105 $retryable_ids = array_filter( 1106 (array) $current_batch, function ( $id ) { 1107 $status = get_post_status($id); 1108 return $status && 'trash' !== $status; 1109 } 1110 ); 1111 1112 if (empty($retryable_ids) ) { 1113 // Nothing to retry — clean up any stale data and move on. 1114 delete_option($failed_key_site); 1115 delete_option('multissaf_failed_sync_meta_' . md5($site_url)); 1116 } else { 1117 $failed_ids = array_unique(array_merge((array) $failed_ids, array_values($retryable_ids))); 1118 update_option($failed_key_site, $failed_ids, false); 1119 1120 update_option( 1121 'multissaf_failed_sync_meta_' . md5($site_url), [ 1122 'site_url' => $site_url, 1123 'failed_ids' => $failed_ids, 1124 'failed_at' => current_time('mysql'), 1125 'retry_count' => 0, 1126 ], false 1127 ); 1128 1129 $retry_args = [[ 1130 'product_ids' => $failed_ids, 1131 'sheet_sync' => $sheet_sync, 1132 'refrance_shop' => $refrance_shop, 1133 'original_ids' => $original_ids, 1134 'is_retry' => true, 1135 'retry_site' => $site_url, 1136 ]]; 1137 $retry_delay = apply_filters('multissaf_retry_delay_seconds', 120); 1138 wp_clear_scheduled_hook('ssgsw_multistore_product_update', $retry_args); 1139 wp_schedule_single_event( 1140 time() + $retry_delay, 1141 'ssgsw_multistore_product_update', 1142 $retry_args 1143 ); 1144 } 1145 } 1146 } else { 1147 $target_ids = ! empty($original_ids) ? $original_ids : $current_batch; 1148 } 1149 1150 $should_write_child_tabs = apply_filters( 1151 'multissaf_should_write_child_tabs', 1152 ( 'main' === $store_role && ! $sheet_sync && ! empty($target_ids) ), 1153 $store_role, 1154 $sheet_sync, 1155 $target_ids 1156 ); 1157 1158 if ($should_write_child_tabs ) { 1159 // Extract SKUs from CURRENT_BATCH (Destination IDs), not target_ids (Source IDs) 1160 $target_skus = []; 1161 foreach ( $current_batch as $dest_product_id ) { 1162 $sku = get_post_meta($dest_product_id, '_sku', true); 1163 if (! empty($sku) ) { 1164 $target_skus[] = $sku; 1165 } 1166 } 1167 1168 if (empty($target_skus) ) { 1169 //No SKUs found for Destination products, skipping Child Tab update 1170 continue; 1171 } 1172 1173 $retrieve_url = trailingslashit($site_url) . 'wp-json/ssgsw/v1/retrive_child'; 1174 $retrieve_body = [ 1175 'batch_info' => ['offset' => 0, 'batchSize' => 50], 1176 'product_skus' => $target_skus, // Use SKUs instead of IDs 1177 'access_token' => $token, 1178 'import_request' => false, 1179 ]; 1180 1181 //Calling retrive_child API with product_skus 1182 $retrieve_response = multissaf_single_product_update($retrieve_url, $retrieve_body, $token); 1183 $parent_sheet_data = []; 1184 1185 if ($retrieve_response && isset($retrieve_response->data)) { 1186 $parent_sheet_data = $retrieve_response->data; // Retrieved parent_sheet_data count 1187 } 1188 1189 if (! empty($parent_sheet_data) ) { 1190 $sheet_id = isset($sites['spreadsheet_id']) ? $sites['spreadsheet_id'] 1191 : (function_exists('ssgsw_get_option') ? ssgsw_get_option('spreadsheet_id') : ''); 1192 $tab_name = isset($sites['tab_name']) ? $sites['tab_name'] : ''; 1193 $sheet_gid = isset($sites['sheet_gid']) ? $sites['sheet_gid'] : ''; 1194 1195 if (! empty($sheet_id) && ! empty($tab_name) && class_exists('\StockSyncWithGoogleSheetForWooCommerce\Sheet') ) { 1196 $sheet = new \StockSyncWithGoogleSheetForWooCommerce\Sheet(); 1197 $sheet->setID($sheet_id); 1198 $sheet->setTab($tab_name); 1199 if (! empty($sheet_gid) && method_exists($sheet, 'setSheetID') ) { $sheet->setSheetID($sheet_gid); 1200 } 1201 1202 // Always fetch fresh data for child tab updates to prevent duplicates 1203 $sheets_info = $sheet->get_formula_value(false, false, $tab_name, 'UNFORMATTED_VALUE'); 1204 //Ftched Child Tab data, row count 1205 1206 if (! empty($sheets_info) ) { 1207 // Update cache AFTER fetching (for read-only operations) 1208 $child_cache_key = apply_filters( 1209 'multissaf_sheets_info_cache_key', 1210 'multissaf_sheets_info_' . md5($sheet_id . '_' . $tab_name) 1211 ); 1212 set_transient( 1213 $child_cache_key, 1214 $sheets_info, 1215 apply_filters('multissaf_sheets_info_ttl', 5 * MINUTE_IN_SECONDS) 1216 ); 1217 } 1218 1219 if ($sheets_info) { 1220 // Build ID index from Child Tab - maps Product ID to Row Number 1221 $id_index = []; 1222 foreach ( $sheets_info as $row_num => $row_data ) { 1223 if ($row_num == 0 ) { continue; // Skip header 1224 } 1225 if (isset($row_data[0]) && is_numeric($row_data[0]) ) { 1226 $product_id = intval($row_data[0]); 1227 $id_index[ $product_id ] = $row_num + 1; // 1-based row index 1228 } 1229 } 1230 1231 $child_batch_updates = []; 1232 $child_batch_appends = []; 1233 1234 foreach ($parent_sheet_data as $data_idx => $values) { 1235 $values = (array) $values; 1236 1237 if (isset($values[0]) && ! is_numeric($values[0])) { 1238 // This is the header row — always write it to row 1 so the 1239 // child tab header stays in sync with the current field permissions. 1240 if ($data_idx === 0 ) { 1241 $col_end = $sheet->getColumnLetter(count($values)); 1242 $range = $tab_name . '!A1:' . $col_end . '1'; 1243 $child_batch_updates[] = [ 1244 'range' => $range, 1245 'values' => [ array_values($values) ], 1246 ]; 1247 } else { 1248 } 1249 continue; 1250 } 1251 $product_id = isset($values[0]) ? intval($values[0]) : false; 1252 if (! $product_id) { 1253 continue; 1254 } 1255 1256 $row_idx = false; 1257 1258 // Find the row with this Product ID 1259 if (isset($id_index[ $product_id ]) ) { 1260 $row_idx = $id_index[ $product_id ]; 1261 } else { 1262 } 1263 1264 // Update or append based on match 1265 if ($row_idx) { 1266 $col_end = $sheet->getColumnLetter(count($values)); 1267 $range = $tab_name . '!A' . $row_idx . ':' . $col_end . $row_idx; 1268 $child_batch_updates[] = [ 1269 'range' => $range, 1270 'values' => [ array_values($values) ], 1271 ]; 1272 } else { 1273 // No ID match - append as new product 1274 $child_batch_appends[] = array_values($values); 1275 } 1276 } 1277 1278 // Send all updates in ONE API call 1279 if (! empty($child_batch_updates) ) { 1280 $sheet->batch_update_rows($child_batch_updates); 1281 delete_transient($child_cache_key); 1282 } 1283 if (! empty($child_batch_appends) ) { 1284 $sheet->append_batch_rows($child_batch_appends); 1285 delete_transient($child_cache_key); 1286 } 1287 1288 } 1289 } 1290 } 1291 } 1292 } 1293 } 1294 1295 if (count($all_sites) > ($site_offset + $site_batch_limit)) { 1296 // More sites left for the same product batch 1297 $next_args = $args; 1298 $next_args['site_offset'] = $site_offset + $site_batch_limit; 1299 wp_clear_scheduled_hook('ssgsw_multistore_product_update', [$next_args]); 1300 wp_schedule_single_event(time() + $delay_seconds, 'ssgsw_multistore_product_update', [$next_args]); 1301 1302 } else { 1303 1304 // Avoid duplicate sheet writes. 1305 $should_write_main_tab = apply_filters( 1306 'multissaf_should_write_main_tab', 1307 ( $sheet_sync || empty($current_sites_batch) ), 1308 $sheet_sync, 1309 $current_sites_batch 1310 ); 1311 1312 if ($should_write_main_tab ) { 1313 if (class_exists('\StockSyncWithGoogleSheetForWooCommerce\App')) { 1314 $new_app = new \StockSyncWithGoogleSheetForWooCommerce\App(); 1315 if ($new_app->is_plugin_ready_setup()) { 1316 $sheet_args = [['product_ids' => $current_batch]]; 1317 wp_clear_scheduled_hook('ssgsw_multistore_sheet_update', $sheet_args); 1318 wp_schedule_single_event(time() + 2, 'ssgsw_multistore_sheet_update', $sheet_args); 1319 } else { 1320 } 1321 } else { 1322 } 1323 } else { 1324 if (class_exists('\StockSyncWithGoogleSheetForWooCommerce\App')) { 1325 $new_app = new \StockSyncWithGoogleSheetForWooCommerce\App(); 1326 if ($new_app->is_plugin_ready_setup()) { 1327 $sheet_args = [['product_ids' => $current_batch]]; 1328 wp_clear_scheduled_hook('ssgsw_multistore_sheet_update', $sheet_args); 1329 wp_schedule_single_event(time() + 2, 'ssgsw_multistore_sheet_update', $sheet_args); 1330 } else { 1331 } 1332 } 1333 } 1334 1335 // Move to next product batch 1336 $remaining_products = array_slice($product_ids, $batch_size); 1337 if (! empty($remaining_products)) { 1338 $next_args = $args; 1339 $next_args['product_ids'] = $remaining_products; 1340 $next_args['site_offset'] = 0; 1341 wp_clear_scheduled_hook('ssgsw_multistore_product_update', [$next_args]); 1342 wp_schedule_single_event(time() + $delay_seconds, 'ssgsw_multistore_product_update', [$next_args]); 1343 } 1344 } 1345 } 1346 1347 /** 1348 * Collects updated product IDs and queues/fires the propagation action. 1349 * Entry point called by the base plugin after any product save. 1350 * Guards against manual-import re-trigger via body flag. 1351 * 1352 * Connected to: action 'ssgsw_collection_of_update_ids' 1353 * 1354 * @param int[] $product_ids Products that changed. 1355 * @param bool $sheet_sync Pass through to update func. 1356 * @param string $refrance_shop Pass through to update func. 1357 * @param int[] $original_ids Pass through to update func. 1358 * @param bool $manual_update Allow firing even if body has manual_update flag. 1359 * @return void 1360 */ 1361 public function ssgsw_collection_of_update_ids($product_ids, $sheet_sync= false, $refrance_shop = '', $original_ids = [], $manual_update = false) 1362 { 1363 1364 // LOG: Entry point 1365 1366 $multissaf_body = multissaf_get_body(); 1367 $manual_import = isset($multissaf_body->manual_update) ? (int)$multissaf_body->manual_update : false; 1368 1369 if ($manual_import && ! $manual_update ) { 1370 return; 1371 } 1372 1373 if(!$product_ids) { 1374 return; 1375 } 1376 $args = [ 1377 [ 1378 'product_ids' => $product_ids, 1379 'sheet_sync' => $sheet_sync, 1380 'refrance_shop' => $refrance_shop, 1381 'original_ids' => $original_ids, 1382 1383 ] 1384 ]; 1385 $child_active = multissaf_chid_activity(); 1386 $parent_active = multissaf_parent_activity(); 1387 1388 if ($child_active || $parent_active ) { 1389 1390 // Use direct sync instead of cron scheduling 1391 if (! multissaf_get_feature_flag('enable_cron_sync') ) { 1392 // Direct sync is enabled (cron is disabled) 1393 1394 $pending_key = apply_filters('multissaf_pending_ids_key', 'multissaf_pending_product_ids'); 1395 $pending = get_transient($pending_key); 1396 $pending = is_array($pending) ? $pending : []; 1397 $pending = array_unique(array_merge($pending, (array) $product_ids)); 1398 set_transient($pending_key, $pending, apply_filters('multissaf_pending_ids_ttl', 30)); 1399 1400 $lock_key = apply_filters('multissaf_sync_lock_key', 'multissaf_sync_scheduled_lock'); 1401 if (! get_transient($lock_key) ) { 1402 set_transient($lock_key, 1, 10); 1403 1404 // Call direct sync action instead of scheduling cron 1405 do_action( 1406 'ssgsw_multistore_product_update_direct', [ 1407 'product_ids' => $pending, 1408 'sheet_sync' => $sheet_sync, 1409 'refrance_shop' => $refrance_shop, 1410 'original_ids' => $original_ids, 1411 'retry_count' => 0, // Start with 0 retries 1412 ] 1413 ); 1414 } 1415 } else { 1416 // Cron sync is enabled, use old behavior 1417 $pending_key = apply_filters('multissaf_pending_ids_key', 'multissaf_pending_product_ids'); 1418 $pending = get_transient($pending_key); 1419 $pending = is_array($pending) ? $pending : []; 1420 $pending = array_unique(array_merge($pending, (array) $product_ids)); 1421 set_transient($pending_key, $pending, apply_filters('multissaf_pending_ids_ttl', 30)); 1422 1423 $lock_key = apply_filters('multissaf_sync_lock_key', 'multissaf_sync_scheduled_lock'); 1424 if (! get_transient($lock_key) ) { 1425 set_transient($lock_key, 1, 10); 1426 wp_schedule_single_event( 1427 time() + apply_filters('multissaf_product_update_delay_seconds', 5), 1428 'ssgsw_multistore_product_update', 1429 [[ 1430 'product_ids' => $pending, 1431 'sheet_sync' => $sheet_sync, 1432 'refrance_shop' => $refrance_shop, 1433 'original_ids' => $original_ids, 1434 ]] 1435 ); 1436 } 1437 1438 } 1439 } 1440 1441 } 1442 1443 /** 1444 * Checks if the current domain should be allowed to perform a sync based on the multi-store sync mode and child activity. 1445 * 1446 * @param bool $domain_access The initial access status for the domain. This is typically set to true by default. 1447 * @return bool Returns true if the sync mode is "shop_to_sheet" and the child store is active. Otherwise, it returns the original value of $domain_access. 1448 */ 1449 public function ssgsw_domain_url($domain_access) 1450 { 1451 $sync_mode = get_option('ssgsw_multi_store_mode_on'); 1452 $activity = multissaf_chid_activity(); 1453 if ('shop_to_sheet' == $sync_mode && $activity) { 1454 return true; 1455 } 1456 return $domain_access; 1457 } 1458 1459 /** 1460 * Determines the active synchronization mode based on multi-store settings and activity. 1461 * 1462 * @param string $sync_mode The initial synchronization mode value. 1463 * @return string The determined synchronization mode. 1464 */ 1465 public function ssgsw_sync_mode($sync_mode) 1466 { 1467 1468 $sync_mode = get_option('ssgsw_multi_store_mode_on'); 1469 $activity = multissaf_chid_activity(); 1470 if ('shop_to_sheet' == $sync_mode && $activity) { 1471 return 'shop_to_sheet'; 1472 } 1473 return $sync_mode; 1474 } 1475 1476 /** 1477 * Adds a new custom 'Origin' column to the WooCommerce products list table. 1478 * 1479 * @param array $columns An associative array of existing product columns. 1480 * @return array An updated array of columns, including the new 'Origin' column. 1481 */ 1482 public function product_columns($columns) 1483 { 1484 1485 if (!function_exists('ssgsw_is_admin')) { 1486 return $columns; 1487 } 1488 if (!function_exists('ssgsw_check_action_domain')) { 1489 return $columns; 1490 } 1491 $is_admin_check = ssgsw_is_admin(); 1492 $domain_check = ssgsw_check_action_domain(); 1493 if ($is_admin_check) { 1494 $sync_mode = get_option('ssgsw_multi_store_mode_on'); 1495 $other_check = multissaf_chid_activity(); 1496 1497 if ('shop_to_sheet' === $sync_mode && $other_check ) { 1498 $ultimate_columns = [ 1499 'ssgsw_origin' => [ 1500 'label' => __('Store Name', 'multi-store-sync-addon-for-flexstock'), 1501 'type' => 'meta', 1502 'priority' => 9, 1503 ] 1504 ]; 1505 return array_merge($columns, $ultimate_columns); 1506 } 1507 } 1508 return $columns; 1509 } 1510 1511 /** 1512 * Filters the array of site product counts, allowing other plugins to 1513 * add data from connected sub-sites. 1514 * 1515 * @param array $main_array The initial array of site data, containing the main site's information. 1516 * @return array The filtered array, now including data for connected sub-sites. 1517 */ 1518 public function ssgsw_sub_site_product_count($main_array = []) 1519 { 1520 // The admin "Sync on Google Sheet" (AJAX) syncs only this site's products; stores are 1521 // listed only for the Google Sheet script (REST), which routes each tab's edits. 1522 if (wp_doing_ajax() ) { 1523 return $main_array; 1524 } 1525 1526 // Retrieve the list of sub-sites from the option. 1527 $sub_site = multissaf_sub_site_url(); 1528 1529 // Filter out any entries that have empty URLs or tokens. 1530 $filtered_options = array_filter( 1531 $sub_site, function ($entry) { 1532 return !empty($entry['url']) && !empty($entry['token']); 1533 } 1534 ); 1535 1536 // Connected stores first: the sheet script uses the first store whose tab matches. 1537 $is_connected = function ( $entry ) { 1538 return ! empty($entry['isConnected']) && 'false' !== $entry['isConnected']; 1539 }; 1540 $filtered_options = array_merge( 1541 array_values(array_filter($filtered_options, $is_connected)), 1542 array_values( 1543 array_filter( 1544 $filtered_options, function ( $entry ) use ( $is_connected ) { 1545 return ! $is_connected($entry); 1546 } 1547 ) 1548 ) 1549 ); 1550 1551 // If there are valid sub-sites, loop through them and add their data to the main array. 1552 if (is_array($filtered_options) && !empty($filtered_options)) { 1553 foreach ($filtered_options as $site) { 1554 // Get the product count for the current sub-site. 1555 $site_url = $site['url']; 1556 $token = $site['token']; 1557 1558 // Get FILTERED product count from child using access_token (not for disconnected stores) 1559 $product_count = 0; 1560 if ($is_connected($site) && function_exists('multissaf_get_sub_product_count')) { 1561 $count_data = multissaf_get_sub_product_count($site_url, $token, $site); 1562 if (is_array($count_data) && isset($count_data['count'])) { 1563 $product_count = intval($count_data['count']); 1564 } elseif (is_numeric($count_data)) { 1565 $product_count = intval($count_data); 1566 } 1567 } 1568 1569 // Append the sub-site's data to the main array. 1570 $main_array[] = [ 1571 'url' => $site_url, 1572 'token' => $token, 1573 'product' => $product_count, 1574 'sheet_tab' => $site['tab_name'], 1575 'sheet_id' => $site['sheet_gid'], 1576 ]; 1577 } 1578 } 1579 1580 // Return the final array containing data from the main site and all sub-sites. 1581 return $main_array; 1582 } 1583 1584 /** 1585 * Enqueues the React admin app (build/index.js + build/index.css) on the plugin's admin page. 1586 * 1587 * @param string $hook The current admin page hook. 1588 * @return void 1589 */ 1590 public function enqueue_scripts($hook) 1591 { 1592 1593 if($hook == 'flexstock_page_ssgsw-multistore') { 1594 $asset_file = MULTISSAF_MULTI_STORE_PATH . 'build/index.asset.php'; 1595 $asset = file_exists($asset_file) ? include $asset_file : [ 'dependencies' => [], 'version' => MULTISSAF_MULTI_STORE_VERSION ]; 1596 1597 wp_enqueue_style('ssgsw-multi-store-css', MULTISSAF_MULTI_STORE_URL . '/build/index.css', [], $asset['version']); 1598 1599 // The base FlexStock admin script provides `ssgsw_script` (nonce, options), so load after it. 1600 wp_enqueue_script('multissaf-react', MULTISSAF_MULTI_STORE_URL . '/build/index.js', array_merge($asset['dependencies'], [ 'ssgsw-admin-js' ]), $asset['version'], true); 1601 1602 $setup_complete = get_option('ssgsw_multi_store_setup_complete', false); 1603 1604 // Data used by both the setup wizard and the dashboard. 1605 $localized_data = [ 1606 'page' => $setup_complete ? 'dashboard' : 'setup', 1607 'pluginUrl' => MULTISSAF_MULTI_STORE_URL, 1608 'hasLicenseTab' => function_exists('ssgsw_initialize_appsero_client_multistore'), 1609 'strings' => Strings::get(), 1610 ]; 1611 1612 if ($setup_complete) { 1613 // Prepare data for dashboard 1614 $mode = get_option('ssgsw_multi_store_mode_on', 'multi_to_single'); 1615 $role = get_option('ssgsw_multi_connect_mode_on', 'main'); 1616 $js_sync_behavior = ($mode === 'shop_to_sheet') ? 'manage' : 'sync'; 1617 $js_store_role = ($role === 'child') ? 'child' : 'main'; 1618 1619 $available_fields = []; 1620 if (class_exists('\StockSyncWithGoogleSheetForWooCommerce\Column')) { 1621 $column_obj = new \StockSyncWithGoogleSheetForWooCommerce\Column(); 1622 $columns = $column_obj->get_columns(); 1623 foreach ($columns as $key => $data) { 1624 // Skip columns explicitly marked as non-sheet columns (column: false). 1625 if (isset($data['column']) && false === $data['column'] ) { 1626 continue; 1627 } 1628 $available_fields[] = [ 1629 'val' => $key, 1630 'label' => isset($data['label']) ? $data['label'] : $key 1631 ]; 1632 } 1633 } 1634 1635 $available_categories = []; 1636 $product_cats = get_terms(['taxonomy' => 'product_cat', 'hide_empty' => false]); 1637 if (!is_wp_error($product_cats)) { 1638 foreach ($product_cats as $cat) { 1639 $available_categories[] = [ 1640 'id' => $cat->term_id, 1641 'name' => $cat->name, 1642 'count' => $cat->count 1643 ]; 1644 } 1645 } 1646 1647 $available_tags = []; 1648 $product_tags = get_terms(['taxonomy' => 'product_tag', 'hide_empty' => false]); 1649 if (!is_wp_error($product_tags)) { 1650 foreach ($product_tags as $tag) { 1651 $available_tags[] = [ 1652 'id' => $tag->term_id, 1653 'name' => $tag->name, 1654 'count' => $tag->count 1655 ]; 1656 } 1657 } 1658 1659 // Build available WooCommerce attributes with their terms 1660 $available_attributes = []; 1661 if (function_exists('wc_get_attribute_taxonomies') ) { 1662 $attribute_taxonomies = wc_get_attribute_taxonomies(); 1663 foreach ( $attribute_taxonomies as $tax ) { 1664 $taxonomy = wc_attribute_taxonomy_name($tax->attribute_name); 1665 $terms = get_terms([ 'taxonomy' => $taxonomy, 'hide_empty' => false ]); 1666 $term_list = []; 1667 if (! is_wp_error($terms) ) { 1668 foreach ( $terms as $term ) { 1669 $term_list[] = [ 'slug' => $term->slug, 'name' => $term->name ]; 1670 } 1671 } 1672 $available_attributes[] = [ 1673 'taxonomy' => $taxonomy, 1674 'label' => $tax->attribute_label, 1675 'terms' => $term_list, 1676 ]; 1677 } 1678 } 1679 1680 $options = apply_filters('ssgsw_options', []); 1681 1682 // Token and spreadsheet state used by the React UI. 1683 $is_multistore_active = function_exists('ssgsw_multistore_license_active') ? ssgsw_multistore_license_active() : false; 1684 $existing_tokens = get_option('ssgsw_child_tokens', []); 1685 $spreadsheet_id = function_exists('ssgsw_get_option') ? ssgsw_get_option('spreadsheet_id') : ''; 1686 1687 $localized_data = array_merge( 1688 $localized_data, [ 1689 'isMultistoreLicenseActive' => (bool) $is_multistore_active, 1690 'tokenCount' => is_array($existing_tokens) ? count($existing_tokens) : 0, 1691 'hasSpreadsheet' => ! empty($spreadsheet_id), 1692 'flexstockSetupUrl' => admin_url('admin.php?page=ssgsw-admin'), 1693 'syncMode' => $mode, 1694 'syncBehavior' => $js_sync_behavior, 1695 'storeRole' => $js_store_role, 1696 'availableFields' => $available_fields, 1697 'availableCategories' => $available_categories, 1698 'availableTags' => $available_tags, 1699 'availableAttributes' => $available_attributes, 1700 'spreadsheetId' => isset($options['spreadsheet_id']) ? $options['spreadsheet_id'] : '', 1701 'connectedSites' => isset($options['multi_store_list']) ? $options['multi_store_list'] : [], 1702 'connectedSitesParent' => isset($options['child_tokens']) ? $options['child_tokens'] : [], 1703 'homeUrl' => home_url(), 1704 ] 1705 ); 1706 } 1707 1708 wp_localize_script('multissaf-react', 'multissaf_store_data', $localized_data); 1709 } 1710 1711 } 1712 1713 /** 1714 * Localizes (passes) relevant script variables, specifically multi-store URLs, 1715 * to be used by front-end JavaScript 1716 * 1717 * @since 1.0.0 (Example version) 1718 * 1719 * @param array $filters An array of data variables already set for localization. 1720 * @return array The updated array of localization data, including multi-store URLs. 1721 */ 1722 public function ssgsw_localized_script($filters) 1723 { 1724 $filters['multi_store_list'] = multissaf_sub_site_url(); 1725 $filters['multi_store_list_parent'] = multissaf_sub_site_url_parent(); 1726 1727 return $filters; 1728 } 1729 /** 1730 * Add setting options 1731 * 1732 * @return void 1733 */ 1734 public function ssgsw_options($options) 1735 { 1736 $options['multi_store_active'] = get_option('ssgsw_multi_store_active', true); 1737 $options['multi_store_list'] = multissaf_sub_site_url(); 1738 $options['multi_store_list_parent'] = multissaf_sub_site_url_parent(); 1739 $options['multi_store_mode_on'] = get_option('ssgsw_multi_store_mode_on', 'shop_to_sheet'); 1740 $options['multi_connect_mode_on'] = get_option('ssgsw_multi_connect_mode_on', 'child'); 1741 $options['child_tokens'] = get_option('ssgsw_child_tokens', []); 1742 $options['spreadsheet_id'] = function_exists('ssgsw_get_option') ? ssgsw_get_option('spreadsheet_id') : ''; 1743 1744 return $options; 1745 } 1746 1747 /** 1748 * Add vendor menu 1749 * 1750 * @return void 1751 */ 1752 public function set_menu() 1753 { 1754 add_submenu_page( 1755 'ssgsw-admin', 1756 __('Multi-Store Sync', 'multi-store-sync-addon-for-flexstock'), 1757 __('Multi-Store Sync', 'multi-store-sync-addon-for-flexstock'), 1758 'manage_options', 1759 'ssgsw-multistore', 1760 [ $this, 'render_vendor_page' ], 1761 99 1762 ); 1763 } 1764 1765 /** 1766 * Render the admin page: the React app mount point (src/, built to build/index.js). 1767 * 1768 * Markup printed by other plugins goes into hidden holders that React moves into place. 1769 * 1770 * @return void 1771 */ 1772 public function render_vendor_page() 1773 { 1774 $setup_complete = get_option('ssgsw_multi_store_setup_complete', false); 1775 ?> 1776 <div id="multissaf_srv_appscript" hidden> 1777 <?php 1778 if (function_exists('ssgsw')) { 1779 ssgsw()->load_template('dashboard/appscript'); 1780 } 1781 ?> 1782 </div> 1783 <div id="multissaf_srv_license" hidden> 1784 <?php 1785 if (function_exists('ssgsw_initialize_appsero_client_multistore')) { 1786 ssgsw_initialize_appsero_client_multistore(); 1787 } 1788 ?> 1789 </div> 1790 <?php if ($setup_complete) : ?> 1791 <div id="multissaf_app" data-page="dashboard" class="addons-container ssgsw-wrapper"> 1792 <div class="mss-flex-center" style="position:absolute; top:0; left:0; width:100%; height:100%; background:#f1f5f9; z-index:99999; flex-direction:column;"> 1793 <span class="dashicons dashicons-update mss-spin" style="font-size: 40px; width:40px; height:40px; color: #4F46E5;"></span> 1794 <p class="mss-mt-0"><?php esc_html_e('Loading Dashboard...', 'multi-store-sync-addon-for-flexstock'); ?></p> 1795 </div> 1796 </div> 1797 <?php else : ?> 1798 <div id="multissaf_app" data-page="setup" class="addons-container ssgsw-wrapper" style="position:relative; min-height:400px;"> 1799 <style> 1800 @keyframes ssgsw-spin { 100% { transform: rotate(360deg); } } 1801 </style> 1802 <div style="position:absolute; top:0; left:0; width:100%; height:100%; background:#f1f5f9; z-index:99999; display:flex; flex-direction:column; align-items:center; justify-content:center;"> 1803 <span class="dashicons dashicons-update" style="font-size: 40px; width:40px; height:40px; color: #6366f1; animation: ssgsw-spin 1.5s linear infinite;"></span> 1804 <p style="margin-top:16px; color:#6b7280; font-weight:600; font-size:14px;"><?php esc_html_e('Loading Dashboard...', 'multi-store-sync-addon-for-flexstock'); ?></p> 1805 </div> 1806 </div> 1807 <?php endif; ?> 1808 <?php 1809 } 1810 1811 /** 1812 * Add plugin action links callback. 1813 * 1814 * @param array $links Plugin action links. 1815 * @return array 1816 */ 1817 public function add_plugin_action_links( $links ) 1818 { 1819 if($this->app->is_plugin_activated()) { 1820 $links[] = '<a href="' . admin_url('admin.php?page=ssgsw-multistore') . '">' . __('Settings', 'multi-store-sync-addon-for-flexstock') . '</a>'; 1821 } 1822 return $links; 1823 } 1824 1825 /** 1826 * Redirects the user to the add-ons page after plugin activation, 1827 * but only if the redirection hasn't been triggered before. 1828 * 1829 * @return void 1830 */ 1831 public function redirect_after_active() 1832 { 1833 // Only run in wp-admin, never on frontend, REST, AJAX, or cron requests. 1834 if (! is_admin() || wp_doing_ajax() || ! current_user_can('manage_options') ) { 1835 return; 1836 } 1837 // Skip on bulk plugin activation. 1838 if (isset($_GET['activate-multi']) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended 1839 return; 1840 } 1841 1842 if($this->app->is_plugin_activated()) { 1843 $ssgsw_triggered_from_ultimate = wp_validate_boolean(get_option('ssgsw_triggered_from_multistore')); 1844 if (! $ssgsw_triggered_from_ultimate ) { 1845 update_option('ssgsw_triggered_from_multistore', true); 1846 wp_safe_redirect(admin_url('admin.php?page=ssgsw-multistore')); 1847 exit; 1848 } 1849 } 1850 } 1851 1852 /** 1853 * Activate plugin 1854 * 1855 * @return void 1856 */ 1857 public function activate() 1858 { 1859 delete_option('ssgsw_triggered_from_multistore'); 1860 } 1861 1862 /** 1863 * Print admin notices 1864 * 1865 * @return void 1866 */ 1867 public function admin_notices() 1868 { 1869 if ($this->app->is_plugin_activated() ) { 1870 return; 1871 } 1872 1873 if (! current_user_can('activate_plugins') ) { 1874 return; 1875 } 1876 $plugin_name = esc_html__('FlexStock Multi-Store', 'multi-store-sync-addon-for-flexstock'); 1877 $plugin_url = $this->app->free_version; 1878 1879 $dependency = esc_html__('FlexStock', 'multi-store-sync-addon-for-flexstock'); 1880 1881 if (defined('SSGSW_VERSION') && SSGSW_VERSION < '3.15.0') { 1882 $message2 = wp_sprintf('<strong>%s</strong> requires <strong>%s</strong> plugin to be activated version number up to 3.15.0', esc_html($plugin_name), esc_html($dependency)); 1883 echo wp_sprintf('<div class="error"><p>%s</p></div>', wp_kses_post($message2)); 1884 } 1885 1886 if ($this->app->is_plugin_installed() ) { 1887 $activation_url = wp_nonce_url('plugins.php?action=activate&plugin=' . esc_url($plugin_url) . '&plugin_status=all&paged=1&s', 'activate-plugin_' . esc_url($plugin_url)); 1888 1889 $message = wp_sprintf('<strong>%s</strong> requires <strong>%s</strong> plugin to be activated.', esc_html($plugin_name), esc_html($dependency)); 1890 $button_text = esc_html__('Activate', 'multi-store-sync-addon-for-flexstock'); 1891 } else { 1892 $activation_url = wp_nonce_url(self_admin_url('update.php?action=install-plugin&plugin=stock-sync-with-google-sheet-for-woocommerce'), 'install-plugin_woocommerce'); 1893 $message = wp_sprintf('<strong>%s</strong> requires <strong>%s</strong> plugin to be installed and activated.', esc_html($plugin_name), esc_html($dependency)); 1894 $button_text = esc_html__('Install', 'multi-store-sync-addon-for-flexstock'); 1895 } 1896 1897 echo wp_sprintf('<div class="error"><p>%s</p></div>', wp_kses_post($message)); 1898 } 1899 1900 /** 1901 * Initialize the Appsero opt-in analytics client and the WPPOOL plugin SDK. 1902 * 1903 * @return void 1904 */ 1905 public function init_appsero_client() 1906 { 1907 $appsero_file = MULTISSAF_MULTI_STORE_PATH . 'vendor/appsero/src/Client.php'; 1908 if (! file_exists($appsero_file) ) { 1909 return; 1910 } 1911 1912 // Load Appsero Client 1913 if (! class_exists('\MultiStoreSyncAddonForFlexStock\Appsero\Client') ) { 1914 include_once $appsero_file; 1915 } 1916 1917 if (! class_exists('\MultiStoreSyncAddonForFlexStock\Appsero\Client') ) { 1918 return; 1919 } 1920 1921 $client = new \MultiStoreSyncAddonForFlexStock\Appsero\Client( 1922 '1772338d-40f8-4cb5-907b-432a609cd0f2', 1923 'Multi-Store Sync', 1924 MULTISSAF_MULTI_STORE 1925 ); 1926 1927 // Activate Appsero insights tracking 1928 $client->insights()->init(); 1929 1930 // Initialize the WPPOOL plugin SDK. 1931 if (function_exists('wppool_plugin_init') ) { 1932 $default_image = MULTISSAF_MULTI_STORE_URL . '/includes/wppool/background-image.png'; 1933 1934 // Register plugin with SDK 1935 $multistore_plugin = wppool_plugin_init('multi_store_sync_addon_for_flexstock', $default_image); 1936 1937 $promo_image = MULTISSAF_MULTI_STORE_URL . '/includes/wppool/special.png'; 1938 $promo_end = '2026-12-31 23:59:59'; 1939 $promo_start = '2026-11-15 00:00:00'; 1940 $promo_cta = esc_html__('Get Pro Now', 'multi-store-sync-addon-for-flexstock'); 1941 $promo_link = 'https://wppool.dev/flexstock/addons/'; 1942 1943 if ($multistore_plugin && is_object($multistore_plugin) && method_exists($multistore_plugin, 'set_campaign') ) { 1944 $multistore_plugin->set_campaign($promo_image, $promo_end, $promo_start, $promo_cta, $promo_link); 1945 } 1946 1947 } 1948 } 1949 1950 1951 /** 1952 * Get current site data from option by URL. 1953 * Used to validate token hasn't changed since cron was scheduled. 1954 * 1955 * @param string $site_url The site URL to look up. 1956 * @return array|false Site data array if found and connected, false otherwise. 1957 */ 1958 private function get_current_site_data($site_url) 1959 { 1960 if (empty($site_url)) { 1961 return false; 1962 } 1963 1964 $store_role = get_option('ssgsw_multi_connect_mode_on'); 1965 1966 if ('child' === $store_role) { 1967 $all_sites = get_option('ssgsw_multi_store_list_parent', []); 1968 } else { 1969 $all_sites = get_option('ssgsw_multi_store_list', []); 1970 } 1971 1972 if (!is_array($all_sites) || empty($all_sites)) { 1973 return false; 1974 } 1975 1976 $target_url = trailingslashit($site_url); 1977 1978 foreach ($all_sites as $site) { 1979 if (empty($site['url'])) { 1980 continue; 1981 } 1982 1983 if (trailingslashit($site['url']) === $target_url) { 1984 // Check if connected 1985 $is_connected = isset($site['isConnected']) ? $site['isConnected'] : false; 1986 if ($is_connected === true || $is_connected === 1 || $is_connected === '1') { 1987 return $site; 1988 } 1989 return false; // Found but disconnected 1990 } 1991 } 1992 1993 return false; // Not found 1994 } 1995 1996 /** 1997 * Clean up any stale cron jobs for a specific site URL. 1998 * Called when a site is found to be disconnected during cron execution. 1999 * 2000 * @param string $site_url The site URL to clean up cron jobs for. 2001 */ 2002 private function cleanup_site_cron_jobs($site_url) 2003 { 2004 if (empty($site_url)) { 2005 return; 2006 } 2007 2008 // Clear failed sync data 2009 $site_hash = md5($site_url); 2010 delete_option('multissaf_failed_sync_' . $site_hash); 2011 delete_option('multissaf_failed_sync_meta_' . $site_hash); 2012 2013 // Note: Cannot easily clear specific cron jobs here because they don't store site URL 2014 // This is why we need comprehensive cleanup on disconnect/delete 2015 } 2016 2017 } 2018 // Initialize. 2019 $multissaf_hook = new MULTISSAF_Hooks(); 2013 2020 } -
multi-store-sync-addon-for-flexstock/trunk/includes/classes/export-functions.php
r3706977 r3752558 9 9 * @return array 10 10 */ 11 if ( ! function_exists( 'multissaf_get_all_columns' ) ) { 12 function multissaf_get_all_columns() { 13 defined( 'ABSPATH' ) || exit; 11 if (! function_exists('multissaf_get_all_columns') ) { 12 function multissaf_get_all_columns() 13 { 14 defined('ABSPATH') || exit; 14 15 $columns = [ 15 16 'ID' => [ … … 101 102 * @return array 102 103 */ 103 if ( ! function_exists( 'multissaf_get_column_keys' ) ) { 104 function multissaf_get_column_keys() { 105 defined( 'ABSPATH' ) || exit; 104 if (! function_exists('multissaf_get_column_keys') ) { 105 function multissaf_get_column_keys() 106 { 107 defined('ABSPATH') || exit; 106 108 107 109 $columns = multissaf_get_all_columns(); … … 113 115 ); 114 116 115 $keys = array_keys($columns);116 117 return $keys;117 $keys = array_keys($columns); 118 119 return $keys; 118 120 } 119 121 } … … 123 125 * @return array 124 126 */ 125 if ( ! function_exists( 'multissaf_get_queryable_columns' ) ) { 126 function multissaf_get_queryable_columns() { 127 defined( 'ABSPATH' ) || exit; 127 if (! function_exists('multissaf_get_queryable_columns') ) { 128 function multissaf_get_queryable_columns() 129 { 130 defined('ABSPATH') || exit; 128 131 129 132 $columns = multissaf_get_all_columns(); … … 133 136 } 134 137 ); 135 return $columns;138 return $columns; 136 139 } 137 140 } … … 142 145 * @return array 143 146 */ 144 if ( ! function_exists( 'multissaf_get_queryable_fields' ) ) { 145 function multissaf_get_queryable_fields() { 146 defined( 'ABSPATH' ) || exit; 147 if (! function_exists('multissaf_get_queryable_fields') ) { 148 function multissaf_get_queryable_fields() 149 { 150 defined('ABSPATH') || exit; 147 151 148 152 $columns = multissaf_get_queryable_columns(); … … 154 158 ); 155 159 156 return array_keys($columns);160 return array_keys($columns); 157 161 } 158 162 } … … 163 167 * @return array 164 168 */ 165 if ( ! function_exists( 'multissaf_multistore_get_queryable_metas' ) ) { 166 function multissaf_multistore_get_queryable_metas() { 167 defined( 'ABSPATH' ) || exit; 169 if (! function_exists('multissaf_multistore_get_queryable_metas') ) { 170 function multissaf_multistore_get_queryable_metas() 171 { 172 defined('ABSPATH') || exit; 168 173 169 174 $columns = multissaf_get_queryable_columns(); … … 175 180 ); 176 181 177 return apply_filters('ssgsw_multistore_queryable_metas', $columns);182 return apply_filters('ssgsw_multistore_queryable_metas', $columns); 178 183 } 179 184 } … … 184 189 * @return array 185 190 */ 186 if ( ! function_exists( 'multissaf_multistore_get_queryable_taxonomies' ) ) { 187 function multissaf_multistore_get_queryable_taxonomies() { 188 defined( 'ABSPATH' ) || exit; 191 if (! function_exists('multissaf_multistore_get_queryable_taxonomies') ) { 192 function multissaf_multistore_get_queryable_taxonomies() 193 { 194 defined('ABSPATH') || exit; 189 195 190 196 $columns = multissaf_get_queryable_columns(); … … 195 201 ); 196 202 197 return array_keys($columns); 198 } 199 } 200 201 if ( ! function_exists( 'multissaf_get_queryable_relations' ) ) { 202 function multissaf_get_queryable_relations() { 203 defined( 'ABSPATH' ) || exit; 204 205 $columns = multissaf_get_queryable_columns(); 206 $columns = array_filter( 207 $columns, function ( $column ) { 208 return 'relation' === $column['type']; 209 } 210 ); 211 212 return array_keys($columns); 213 } 214 } 215 216 if ( ! function_exists( 'multissaf_get_product_attribute_keys' ) ) { 217 function multissaf_get_product_attribute_keys() { 218 defined( 'ABSPATH' ) || exit; 203 return array_keys($columns); 204 } 205 } 206 207 if (! function_exists('multissaf_get_product_attribute_keys') ) { 208 function multissaf_get_product_attribute_keys() 209 { 210 defined('ABSPATH') || exit; 219 211 220 212 global $wpdb; … … 230 222 ); 231 223 232 // Ensure results are returned as an array 233 return is_array($results) && !empty($results) ? $results : []; 234 } 235 } 236 237 if ( ! function_exists( 'multissaf_get_raw_data' ) ) { 238 function multissaf_get_raw_data( ?array $ids = null, $offset = 0, $batch_size = 0, array $product_type = [ 'product', 'product_variation' ] ) { 239 defined( 'ABSPATH' ) || exit; 224 // Ensure results are returned as an array 225 return is_array($results) && !empty($results) ? $results : []; 226 } 227 } 228 229 if (! function_exists('multissaf_get_raw_data') ) { 230 function multissaf_get_raw_data( ?array $ids = null, $offset = 0, $batch_size = 0, array $product_type = [ 'product', 'product_variation' ] ) 231 { 232 defined('ABSPATH') || exit; 240 233 241 234 global $wpdb; 235 $main_table = $wpdb->prefix . 'posts'; 242 236 243 237 $fields = multissaf_get_queryable_fields(); 244 245 $main_table = $wpdb->prefix . 'posts';246 238 247 239 $query = 'SELECT '; … … 254 246 ); 255 247 256 //From post meta table257 $post_meta_table = $wpdb->prefix . 'postmeta';258 $meta_fields = multissaf_multistore_get_queryable_metas();248 //From post meta table 249 $post_meta_table = $wpdb->prefix . 'postmeta'; 250 $meta_fields = multissaf_multistore_get_queryable_metas(); 259 251 if ($meta_fields && count($meta_fields) > 0 ) { 260 252 $query .= ', '; … … 268 260 if(is_array($attribute_keys) && !empty($attribute_keys)) { 269 261 foreach ($attribute_keys as $attribute_key) { 270 // Sanitize the meta_key for safe use as a SQL identifier/alias. 271 // attribute_ keys are internal WooCommerce postmeta keys (e.g. attribute_pa_color) 272 // so sanitize_key() is the right tool — strips anything non-alphanumeric/underscore/dash. 273 $safe_meta_key = sanitize_key( $attribute_key->meta_key ); 274 $safe_meta_alias = sanitize_key( $attribute_key->meta_key ); 275 if ( empty( $safe_meta_key ) ) { 276 continue; 277 } 278 $escaped_meta_key = $wpdb->prepare( '%s', $safe_meta_key ); 279 $meta_query[] = "(SELECT {$post_meta_table}.meta_value FROM {$post_meta_table} WHERE {$post_meta_table}.post_id = {$main_table}.ID AND {$post_meta_table}.meta_key = $escaped_meta_key LIMIT 1) AS `{$safe_meta_alias}`"; 262 $escaped_meta_key = $wpdb->prepare('%s', $attribute_key->meta_key); 263 $meta_query[] = "(SELECT {$post_meta_table}.meta_value FROM {$post_meta_table} WHERE {$post_meta_table}.post_id = {$main_table}.ID AND {$post_meta_table}.meta_key = $escaped_meta_key LIMIT 1) AS $escaped_meta_key"; 280 264 } 281 265 } … … 284 268 } 285 269 286 //From taxonomy table287 $taxonomy_fields = multissaf_multistore_get_queryable_taxonomies();288 289 // Whitelist taxonomy fields against registered product taxonomies before SQL interpolation.290 if ( ! empty( $taxonomy_fields )) {291 $valid_product_taxonomies = get_object_taxonomies( 'product', 'names');270 //From taxonomy table 271 $taxonomy_fields = multissaf_multistore_get_queryable_taxonomies(); 272 273 // Whitelist taxonomy fields against registered product taxonomies before SQL interpolation. 274 if ($taxonomy_fields && count($taxonomy_fields) > 0 ) { 275 $valid_product_taxonomies = get_object_taxonomies('product', 'names'); 292 276 $taxonomy_fields = array_filter( 293 277 $taxonomy_fields, 294 278 function ( $field ) use ( $valid_product_taxonomies ) { 295 return in_array( $field, $valid_product_taxonomies, true);279 return in_array($field, $valid_product_taxonomies, true); 296 280 } 297 281 ); … … 308 292 ); 309 293 310 311 } 312 $query .= " From $main_table"; 313 // Includes variations — use $wpdb->prepare() for the full IN clause. 314 $type_placeholders = implode( ', ', array_fill( 0, count( $product_type ), '%s' ) ); 315 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared 316 $query .= $wpdb->prepare( " WHERE {$main_table}.post_type IN ($type_placeholders)", $product_type ); 317 318 $query .= " AND $main_table.post_status != 'trash'"; 319 $query .= " AND EXISTS ( SELECT 1 FROM {$wpdb->postmeta} WHERE post_id = $main_table.ID AND meta_key = '_sku' AND meta_value IS NOT NULL AND meta_value != '' )"; 294 295 } 296 297 // Use proper $wpdb->prepare() with placeholders for the full IN clause. 298 $type_placeholders = implode(', ', array_fill(0, count($product_type), '%s')); 299 300 $query .= " From $main_table"; 301 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared 302 $query .= $wpdb->prepare(" WHERE {$main_table}.post_type IN ($type_placeholders)", $product_type); 303 304 $query .= " AND $main_table.post_status != 'trash'"; 305 $query .= " AND EXISTS ( SELECT 1 FROM {$wpdb->postmeta} WHERE post_id = $main_table.ID AND meta_key = '_sku' AND meta_value IS NOT NULL AND meta_value != '' )"; 320 306 321 307 if ($ids && count($ids) > 0 ) { 322 308 // Cast all IDs to integers to prevent SQL injection. 323 $safe_ids = array_filter( array_map( 'absint', (array) $ids ));324 if ( ! empty( $safe_ids) ) {325 $id_placeholders = implode( ', ', array_fill( 0, count( $safe_ids ), '%d' ));309 $safe_ids = array_filter(array_map('absint', (array) $ids)); 310 if (! empty($safe_ids) ) { 311 $id_placeholders = implode(', ', array_fill(0, count($safe_ids), '%d')); 326 312 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared 327 $query .= $wpdb->prepare( " AND {$main_table}.ID IN ($id_placeholders)", $safe_ids ); 328 } 329 } 330 // Avoid duplicating IDs. 331 $query .= " GROUP BY $main_table.ID "; 332 333 // Order and Order By — whitelist column names, never interpolate raw filter values. 334 $allowed_columns = [ 'post_date', 'post_modified', 'post_title', 'ID', 'menu_order' ]; 335 $order_by_raw = apply_filters( 'multissaf_product_order_by', 'post_date' ); 336 $order_raw = apply_filters( 'multissaf_product_order', 'ASC' ); 337 $order_by = in_array( $order_by_raw, $allowed_columns, true ) ? $order_by_raw : 'post_date'; 338 $order = strtoupper( $order_raw ) === 'DESC' ? 'DESC' : 'ASC'; 339 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared 340 $query .= " ORDER BY {$main_table}.{$order_by} {$order}"; 313 $query .= $wpdb->prepare(" AND {$main_table}.ID IN ($id_placeholders)", $safe_ids); 314 } 315 } 316 // Avoid duplicating IDs. 317 $query .= " GROUP BY $main_table.ID "; 318 319 // Order and Order By — whitelist column names, never interpolate raw filter values. 320 $allowed_columns = [ 'post_date', 'post_modified', 'post_title', 'ID', 'menu_order' ]; 321 $order_by_raw = apply_filters('multissaf_product_order_by', 'post_date'); 322 $order_raw = apply_filters('multissaf_product_order', 'ASC'); 323 $order_by = in_array($order_by_raw, $allowed_columns, true) ? $order_by_raw : 'post_date'; 324 $order = strtoupper($order_raw) === 'DESC' ? 'DESC' : 'ASC'; 325 326 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared 327 $query .= " ORDER BY {$main_table}.{$order_by} {$order}"; 341 328 if($batch_size) { 342 329 $query .= $wpdb->prepare(' LIMIT %d OFFSET %d', $batch_size, $offset); 343 330 } 344 331 345 // phpcs:ignore346 $results = $wpdb->get_results( $query ); // db call ok; no-cache ok.332 // phpcs:ignore 333 $results = $wpdb->get_results( $query ); // db call ok; no-cache ok. 347 334 if ($wpdb->last_error ) { 348 335 return $wpdb->last_error; 349 336 } 350 337 351 return $results; 352 } 353 } 354 355 356 if ( ! function_exists( 'multissaf_get_formatted_data' ) ) { 357 function multissaf_get_formatted_data( $ids = null, $offset = 0, $batch_size = 0, array $product_type = [ 'product', 'product_variation' ], $keys = [] ) { 358 defined( 'ABSPATH' ) || exit; 338 return $results; 339 } 340 } 341 342 343 if (! function_exists('multissaf_get_formatted_data') ) { 344 function multissaf_get_formatted_data( $ids = null, $offset = 0, $batch_size = 0, array $product_type = [ 'product', 'product_variation' ], $keys = [] ) 345 { 346 defined('ABSPATH') || exit; 359 347 360 348 if (is_array($keys) && empty($keys)) { … … 382 370 } 383 371 384 // FIX: Fetch categories/tags/brands for ALL product types (not just variations)372 // Categories, tags and brands for all product types. 385 373 // Determine which post ID to use for fetching taxonomy terms 386 374 $term_source_id = $row->ID; … … 388 376 389 377 // For variations: terms live on the parent product, not the variation post itself 390 if ( 'variation' === $product_type ) {391 $parent_id = wp_get_post_parent_id( $row->ID);392 if ( $parent_id ) {378 if ('variation' === $product_type ) { 379 $parent_id = wp_get_post_parent_id($row->ID); 380 if ($parent_id ) { 393 381 $term_source_id = $parent_id; 394 382 } … … 396 384 397 385 // Get categories - for all product types 398 $categories = wp_get_post_terms( $term_source_id, 'product_cat');386 $categories = wp_get_post_terms($term_source_id, 'product_cat'); 399 387 $formatted_row['categories'] = []; 400 if ( ! is_wp_error( $categories ) && ! empty( $categories) ) {388 if (! is_wp_error($categories) && ! empty($categories) ) { 401 389 foreach ( $categories as $category ) { 402 390 // Skip the default WooCommerce 'uncategorized' term 403 if ( 'uncategorized' === $category->slug ) {391 if ('uncategorized' === $category->slug ) { 404 392 continue; 405 393 } … … 412 400 413 401 // Get tags - for all product types 414 $tags = wp_get_post_terms( $term_source_id, 'product_tag');402 $tags = wp_get_post_terms($term_source_id, 'product_tag'); 415 403 $formatted_row['tags'] = []; 416 if ( ! is_wp_error( $tags ) && ! empty( $tags) ) {404 if (! is_wp_error($tags) && ! empty($tags) ) { 417 405 foreach ( $tags as $tag ) { 418 406 $formatted_row['tags'][] = [ … … 424 412 425 413 // Get brands - for all product types 426 $brands = wp_get_post_terms( $term_source_id, 'product_brand');414 $brands = wp_get_post_terms($term_source_id, 'product_brand'); 427 415 $formatted_row['brands'] = []; 428 if ( ! is_wp_error( $brands ) && ! empty( $brands) ) {416 if (! is_wp_error($brands) && ! empty($brands) ) { 429 417 foreach ( $brands as $brand ) { 430 418 $formatted_row['brands'][] = [ … … 446 434 ); 447 435 448 return $formatted_data; 449 } 450 } 451 452 if ( ! function_exists( 'multissaf_get_formatted_data_with_permissions' ) ) { 453 function multissaf_get_formatted_data_with_permissions( $ids = null, $offset = 0, $batch_size = 0, array $product_type = [ 'product', 'product_variation' ], $permissions = [] ) { 454 defined( 'ABSPATH' ) || exit; 455 456 $allowed_fields = isset( $permissions['fields'] ) ? (array) $permissions['fields'] : [ 'all_fields' ]; 457 $allow_all = in_array( 'all_fields', $allowed_fields ) || empty( $allowed_fields ); 436 return $formatted_data; 437 } 438 } 439 440 if (! function_exists('multissaf_get_formatted_data_with_permissions') ) { 441 function multissaf_get_formatted_data_with_permissions( $ids = null, $offset = 0, $batch_size = 0, array $product_type = [ 'product', 'product_variation' ], $permissions = [] ) 442 { 443 defined('ABSPATH') || exit; 444 445 $allowed_fields = isset($permissions['fields']) ? (array) $permissions['fields'] : [ 'all_fields' ]; 446 $allow_all = in_array('all_fields', $allowed_fields) || empty($allowed_fields); 458 447 459 448 $keys = multissaf_get_column_keys(); … … 476 465 foreach ( $keys as $key ) { 477 466 // Always allow ID, product_type, and _product_attributes 478 if ( ! $allow_all && ! in_array( $key, $allowed_fields ) && ! in_array( $key, ['ID', 'product_type', '_product_attributes']) ) {467 if (! $allow_all && ! in_array($key, $allowed_fields) && ! in_array($key, ['ID', 'product_type', '_product_attributes']) ) { 479 468 continue; 480 469 } … … 488 477 489 478 // For variations: terms live on the parent product, not the variation post itself 490 if ( 'variation' === $product_type ) {491 $parent_id = wp_get_post_parent_id( $row->ID);492 if ( $parent_id ) {479 if ('variation' === $product_type ) { 480 $parent_id = wp_get_post_parent_id($row->ID); 481 if ($parent_id ) { 493 482 $term_source_id = $parent_id; 494 483 } … … 496 485 497 486 // Get categories (ALWAYS - critical for product structure) 498 $categories = wp_get_post_terms( $term_source_id, 'product_cat');487 $categories = wp_get_post_terms($term_source_id, 'product_cat'); 499 488 $formatted_row['categories'] = []; 500 if ( ! is_wp_error( $categories ) && ! empty( $categories) ) {489 if (! is_wp_error($categories) && ! empty($categories) ) { 501 490 foreach ( $categories as $category ) { 502 if ( 'uncategorized' === $category->slug ) {491 if ('uncategorized' === $category->slug ) { 503 492 continue; 504 493 } … … 511 500 512 501 // Get tags (ALWAYS - critical for product structure) 513 $tags = wp_get_post_terms( $term_source_id, 'product_tag');502 $tags = wp_get_post_terms($term_source_id, 'product_tag'); 514 503 $formatted_row['tags'] = []; 515 if ( ! is_wp_error( $tags ) && ! empty( $tags) ) {504 if (! is_wp_error($tags) && ! empty($tags) ) { 516 505 foreach ( $tags as $tag ) { 517 506 $formatted_row['tags'][] = [ … … 523 512 524 513 // Get brands (ALWAYS - critical for product structure) 525 $brands = wp_get_post_terms( $term_source_id, 'product_brand');514 $brands = wp_get_post_terms($term_source_id, 'product_brand'); 526 515 $formatted_row['brands'] = []; 527 if ( ! is_wp_error( $brands ) && ! empty( $brands) ) {516 if (! is_wp_error($brands) && ! empty($brands) ) { 528 517 foreach ( $brands as $brand ) { 529 518 $formatted_row['brands'][] = [ … … 535 524 536 525 // Get all custom meta data (only if allowed) 537 if ( $allow_all || in_array( '_custom_fields', $allowed_fields ) || in_array( 'custom_meta', $allowed_fields) ) {526 if ($allow_all || in_array('_custom_fields', $allowed_fields) || in_array('custom_meta', $allowed_fields) ) { 538 527 $woo_meta_keys_to_skip = [ 539 528 'chosen_product_cat', '_sku', '_price', '_regular_price', '_sale_price', '_stock', '_stock_status', … … 550 539 ); 551 540 552 return $formatted_data; 553 } 554 } 555 556 if ( ! function_exists( 'multissaf_get_product_by_ids' ) ) { 557 function multissaf_get_product_by_ids( $prduct_ids, $permissions = [] ) { 558 defined( 'ABSPATH' ) || exit; 559 560 if ( ! empty( $permissions ) && isset( $permissions['fields'] ) ) { 541 return $formatted_data; 542 } 543 } 544 545 if (! function_exists('multissaf_get_product_by_ids') ) { 546 /** 547 * @param int[] $prduct_ids Product IDs. 548 * @param array $permissions Token permissions (fields, and scope when $apply_scope). 549 * @param bool $apply_scope Keep only the variations inside the token's scope. 550 */ 551 function multissaf_get_product_by_ids( $prduct_ids, $permissions = [], $apply_scope = false ) 552 { 553 defined('ABSPATH') || exit; 554 555 if (! empty($permissions) && isset($permissions['fields']) ) { 561 556 $formatted_data = multissaf_get_formatted_data_with_permissions($prduct_ids, 0, 0, ['product'], $permissions); 562 557 } else { … … 573 568 574 569 $child_ids = multissaf_get_children_id($product_id); 570 if ($apply_scope && function_exists('multissaf_filter_ids_by_token_scope') ) { 571 $child_ids = multissaf_filter_ids_by_token_scope($child_ids, $permissions); 572 } 575 573 if (!empty($child_ids)) { 576 574 // Fetch and format variation data 577 if ( ! empty( $permissions ) && isset( $permissions['fields']) ) {575 if (! empty($permissions) && isset($permissions['fields']) ) { 578 576 $formatted_data_variation = multissaf_get_formatted_data_with_permissions($child_ids, 0, 0, ['product_variation'], $permissions); 579 577 } else { … … 594 592 } 595 593 596 if ( ! function_exists( 'multissaf_get_batch_products' ) ) { 597 function multissaf_get_batch_products( $offset, $batch_size, $permissions = [] ) { 598 defined( 'ABSPATH' ) || exit; 594 if (! function_exists('multissaf_count_ids_without_sku') ) { 595 /** 596 * Counts the given products or variations (not trashed) that have no SKU of their own. 597 * They are not sent for import, since products are matched by SKU. 598 * 599 * @param int[] $ids Product or variation IDs. 600 * @param string $post_type 'product' or 'product_variation'. 601 * @return int 602 */ 603 function multissaf_count_ids_without_sku( $ids, $post_type ) 604 { 605 defined('ABSPATH') || exit; 606 607 $ids = array_values(array_filter(array_map('absint', (array) $ids))); 608 if (empty($ids) ) { 609 return 0; 610 } 611 612 global $wpdb; 613 $placeholders = implode(',', array_fill(0, count($ids), '%d')); 614 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared 615 return (int) $wpdb->get_var( 616 $wpdb->prepare( 617 "SELECT COUNT(*) FROM {$wpdb->posts} p 618 WHERE p.ID IN ($placeholders) AND p.post_type = %s AND p.post_status <> 'trash' 619 AND NOT EXISTS ( SELECT 1 FROM {$wpdb->postmeta} pm WHERE pm.post_id = p.ID AND pm.meta_key = '_sku' AND pm.meta_value <> '' )", 620 array_merge($ids, [ $post_type ]) 621 ) 622 ); 623 } 624 } 625 626 if (! function_exists('multissaf_get_batch_products') ) { 627 /** 628 * @param int $offset 629 * @param int $batch_size 630 * @param array $permissions 631 * @param array|null $batch_info Filled with: scanned (parent products read on this page, to 632 * advance the offset), products_no_sku, variations_no_sku. 633 */ 634 function multissaf_get_batch_products( $offset, $batch_size, $permissions = [], &$batch_info = null ) 635 { 636 defined('ABSPATH') || exit; 637 638 $batch_info = [ 'scanned' => 0, 'products_no_sku' => 0, 'variations_no_sku' => 0 ]; 599 639 600 640 // Unwrap nested permissions if necessary. 601 if ( isset( $permissions['permissions'] ) && is_array( $permissions['permissions']) ) {641 if (isset($permissions['permissions']) && is_array($permissions['permissions']) ) { 602 642 $permissions = $permissions['permissions']; 603 643 } 604 644 605 645 $prduct_ids = null; 606 $requested_variation_ids = []; 607 608 if ( ! empty( $permissions ) && function_exists( 'multissaf_get_raw_data_by_token' ) ) { 609 610 $raw_filtered = multissaf_get_raw_data_by_token( null, $offset, $batch_size, ['product'], $permissions);611 612 if ( empty( $raw_filtered) ) {646 647 if (! empty($permissions) && function_exists('multissaf_get_raw_data_by_token') ) { 648 649 // Paged fetch of the parent products inside the token's scope. 650 $raw_filtered = multissaf_get_raw_data_by_token(null, $offset, $batch_size, ['product'], $permissions); 651 652 if (empty($raw_filtered) ) { 613 653 return []; 614 654 } 615 655 $filtered_ids = []; 616 656 foreach ( $raw_filtered as $raw_product ) { 617 if ( isset( $raw_product->ID) ) {657 if (isset($raw_product->ID) ) { 618 658 $filtered_ids[] = $raw_product->ID; 619 659 } 620 660 } 621 661 $prduct_ids = $filtered_ids; 662 $batch_info['scanned'] = count($filtered_ids); 663 $batch_info['products_no_sku'] = multissaf_count_ids_without_sku($filtered_ids, 'product'); 622 664 623 665 // Since we already paginated at the raw-data level, fetch exact IDs. … … 626 668 } 627 669 628 if ( ! empty( $permissions ) && isset( $permissions['fields']) ) {670 if (! empty($permissions) && isset($permissions['fields']) ) { 629 671 $formatted_data = multissaf_get_formatted_data_with_permissions($prduct_ids, $offset, $batch_size, ['product'], $permissions); 630 672 } else { … … 633 675 634 676 $new_format_store = []; 677 678 if (null === $prduct_ids ) { 679 // Without permissions the page itself only holds products with a SKU. 680 $batch_info['scanned'] = is_array($formatted_data) ? count($formatted_data) : 0; 681 } 635 682 636 683 if (is_array($formatted_data) && !empty($formatted_data)) { … … 642 689 // Get child IDs of the variable product 643 690 $child_ids = multissaf_get_children_id($product_id); 644 // If specific variation SKUs were requested, restrict to only those.645 if ( ! empty( $requested_variation_ids) ) {646 $child_ids = array_values( array_intersect( (array) $child_ids, $requested_variation_ids ));691 // Keep only the variations inside the token's scope. 692 if (! empty($permissions) ) { 693 $child_ids = multissaf_filter_ids_by_token_scope($child_ids, $permissions); 647 694 } 648 695 if (!empty($child_ids)) { 696 $batch_info['variations_no_sku'] += multissaf_count_ids_without_sku($child_ids, 'product_variation'); 649 697 // Fetch and format variation data 650 if ( ! empty( $permissions ) && isset( $permissions['fields']) ) {698 if (! empty($permissions) && isset($permissions['fields']) ) { 651 699 $formatted_data_variation = multissaf_get_formatted_data_with_permissions($child_ids, 0, 0, ['product_variation'], $permissions); 652 700 } else { … … 668 716 } 669 717 670 if ( ! function_exists( 'multissaf_get_children_id' ) ) { 671 function multissaf_get_children_id( $product_id ) { 672 defined( 'ABSPATH' ) || exit; 718 if (! function_exists('multissaf_get_children_id') ) { 719 function multissaf_get_children_id( $product_id ) 720 { 721 defined('ABSPATH') || exit; 673 722 674 723 global $wpdb; … … 688 737 } 689 738 690 if ( ! function_exists( 'multissaf_get_column_callback' ) ) { 691 function multissaf_get_column_callback( $value, $key, $row ) { 692 693 defined( 'ABSPATH' ) || exit; 739 if (! function_exists('multissaf_get_column_callback') ) { 740 function multissaf_get_column_callback( $value, $key, $row ) 741 { 742 743 defined('ABSPATH') || exit; 694 744 695 745 if ('_stock' === $key ) { … … 835 885 836 886 837 if ( ! function_exists( 'multissaf_get_product_type' ) ) { 838 function multissaf_get_product_type( $product_type = 'simple' ) { 839 defined( 'ABSPATH' ) || exit; 887 if (! function_exists('multissaf_get_product_type') ) { 888 function multissaf_get_product_type( $product_type = 'simple' ) 889 { 890 defined('ABSPATH') || exit; 840 891 841 892 $product_type = multissaf_get_first_term($product_type); 842 893 $types = [ 843 'simple' => __('simple', 'multi-store-sync-addon-for-flexstock'),844 'variable' => __('variable', 'multi-store-sync-addon-for-flexstock'),845 'grouped' => __('grouped', 'multi-store-sync-addon-for-flexstock'),846 'external' => __('external', 'multi-store-sync-addon-for-flexstock'),894 'simple' => __('simple', 'multi-store-sync-addon-for-flexstock'), 895 'variable' => __('variable', 'multi-store-sync-addon-for-flexstock'), 896 'grouped' => __('grouped', 'multi-store-sync-addon-for-flexstock'), 897 'external' => __('external', 'multi-store-sync-addon-for-flexstock'), 847 898 ]; 848 899 … … 852 903 853 904 854 if ( ! function_exists( 'multissaf_get_first_term' ) ) { 855 function multissaf_get_first_term( $categories = '0' ) { 856 defined( 'ABSPATH' ) || exit; 905 if (! function_exists('multissaf_get_first_term') ) { 906 function multissaf_get_first_term( $categories = '0' ) 907 { 908 defined('ABSPATH') || exit; 857 909 858 910 if ($categories !== null) {//phpcs:ignore … … 867 919 868 920 869 if ( ! function_exists( 'multissaf_colleciton_of_change_key' ) ) { 870 function multissaf_colleciton_of_change_key( $key ) { 871 defined( 'ABSPATH' ) || exit; 921 if (! function_exists('multissaf_colleciton_of_change_key') ) { 922 function multissaf_colleciton_of_change_key( $key ) 923 { 924 defined('ABSPATH') || exit; 872 925 873 926 $change_key = [ 874 'ID' => 'id',875 'post_title' => 'title',876 'post_content' => 'description',877 'post_excerpt' => 'short_description',878 'product_type' => 'type',879 '_price' => 'price',880 '_sku' => 'sku',881 '_stock' => 'stock_quantity',882 '_stock_status' => 'stock_status',883 'post_status' => 'status',884 '_regular_price' => 'regular_price',885 '_sale_price' =>'sale_price',886 '_product_attributes' => 'attributes',887 '_thumbnail_id' => 'featured_image_url',888 '_product_image_gallery' => 'gallery_image_urls'927 'ID' => 'id', 928 'post_title' => 'title', 929 'post_content' => 'description', 930 'post_excerpt' => 'short_description', 931 'product_type' => 'type', 932 '_price' => 'price', 933 '_sku' => 'sku', 934 '_stock' => 'stock_quantity', 935 '_stock_status' => 'stock_status', 936 'post_status' => 'status', 937 '_regular_price' => 'regular_price', 938 '_sale_price' =>'sale_price', 939 '_product_attributes' => 'attributes', 940 '_thumbnail_id' => 'featured_image_url', 941 '_product_image_gallery' => 'gallery_image_urls' 889 942 ]; 890 943 return isset($change_key[$key]) ? $change_key[$key] : $key; … … 893 946 894 947 895 if ( ! function_exists( 'multissaf_get_variation_attributes_simple' ) ) { 896 function multissaf_get_variation_attributes_simple( $variation_id ) { 897 defined( 'ABSPATH' ) || exit; 948 if (! function_exists('multissaf_get_variation_attributes_simple') ) { 949 function multissaf_get_variation_attributes_simple( $variation_id ) 950 { 951 defined('ABSPATH') || exit; 898 952 899 953 global $wpdb; -
multi-store-sync-addon-for-flexstock/trunk/includes/classes/functions.php
r3706977 r3752558 86 86 /** 87 87 * Returns child sites from ssgsw_multi_store_list with valid url+token. 88 * Free plugin: limited to 1 entry by design (no license check) 89 * Pro plugin: can return all via 'ssgsw_multistore_access' filter 88 * Only entries with both a url and a token are returned. 90 89 * 91 90 * @return array[] Each entry: {url, token, tab_name, sheet_gid, isConnected, ...} … … 96 95 $all_option = get_option( 'ssgsw_multi_store_list' ); 97 96 97 98 98 if ( ! $all_option ) { 99 99 return []; … … 106 106 ); 107 107 $filtered_options = array_values( $filtered_options ); 108 109 if ( empty( $filtered_options ) ) {110 return [];111 }112 108 113 109 return $filtered_options; … … 168 164 169 165 /** 166 * SQL conditions for a token's scope: '' for all products; other scopes come from the 167 * 'multissaf_token_scope_sql' filter, and match no products when it is not handled. 168 * 169 * @param array $permissions Token permissions (include_type and the scope settings). 170 * @param string $table Table name or alias of wp_posts in the outer query. 171 * @return string SQL starting with " AND", or '' for all products. 172 */ 173 if ( ! function_exists( 'multissaf_get_token_scope_sql' ) ) { 174 function multissaf_get_token_scope_sql( $permissions, $table ) { 175 $permissions = (array) $permissions; 176 $include_type = isset( $permissions['include_type'] ) ? $permissions['include_type'] : 'all'; 177 178 if ( $include_type === 'all' ) { 179 return ''; 180 } 181 182 /** 183 * SQL conditions for a token scope other than All Products. 184 * 185 * @param string|null $sql null when not handled; otherwise SQL starting with " AND", 186 * built with $wpdb->prepare(). 187 * @param array $permissions Token permissions. 188 * @param string $table Table name or alias of wp_posts in the outer query. 189 * @param string $include_type Normalized scope type. 190 */ 191 $sql = apply_filters( 'multissaf_token_scope_sql', null, $permissions, $table, $include_type ); 192 193 return is_string( $sql ) ? $sql : ' AND 1=0'; 194 } 195 } 196 197 /** 198 * Keeps only the IDs that fall inside a token's scope, in their original order. 199 * 200 * @param int[] $ids Product or variation IDs. 201 * @param array $permissions Token permissions. 202 * @return int[] 203 */ 204 if ( ! function_exists( 'multissaf_filter_ids_by_token_scope' ) ) { 205 function multissaf_filter_ids_by_token_scope( $ids, $permissions ) { 206 global $wpdb; 207 208 $ids = array_values( array_filter( array_map( 'absint', (array) $ids ) ) ); 209 if ( empty( $ids ) ) { 210 return []; 211 } 212 213 $scope_sql = multissaf_get_token_scope_sql( $permissions, 'p' ); 214 if ( '' === $scope_sql ) { 215 return $ids; 216 } 217 218 $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) ); 219 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $scope_sql is built with $wpdb->prepare(). 220 $in_scope = $wpdb->get_col( $wpdb->prepare( "SELECT p.ID FROM {$wpdb->posts} p WHERE p.ID IN ($placeholders)", $ids ) . $scope_sql ); 221 $in_scope = array_map( 'intval', (array) $in_scope ); 222 223 return array_values( array_intersect( $ids, $in_scope ) ); 224 } 225 } 226 227 /** 170 228 * Token-permission-aware raw data query. Extends multissaf_get_raw_data() 171 * with field selection (all products included).229 * with the token scope from multissaf_get_token_scope_sql(). 172 230 * Uses Column class to resolve allowed fields. 173 231 * 174 * Connected from: multissaf_get_ formatted_data_by_token(),175 * multissaf_get_batch_products _by_token()232 * Connected from: multissaf_get_batch_products_by_token(), 233 * multissaf_get_batch_products() 176 234 * 177 235 * @param int[]|null $ids … … 179 237 * @param int $batch_size 180 238 * @param string[] $product_types 181 * @param array $parmision_filds Resolved permissions (include_type, cats, tags, fields).239 * @param array $parmision_filds Resolved permissions (include_type, cats, tags, skus, fields, attribute_filters). 182 240 * @return object[]|string 183 241 */ … … 276 334 } 277 335 278 // All products included in sync permissions (no category/tag/SKU restrictions). 336 // Product scope from the token (all / by_category / by_sku). 337 $query .= multissaf_get_token_scope_sql( $parmision_filds, $main_table ); 279 338 280 339 // Always exclude trashed products from token-filtered exports. … … 300 359 $order = strtoupper( $order_raw ) === 'DESC' ? 'DESC' : 'ASC'; 301 360 302 // FIX (Layer 1): Add ID as secondary sort key to guarantee deterministic pagination. 303 // When products share the same post_date, MySQL doesn't guarantee stable order across 304 // separate queries. Adding ID (primary key) ensures the same offset always returns the 305 // same row, preventing products from appearing at multiple offsets or being skipped entirely. 361 // ID as secondary sort key keeps paging stable when products share a post_date. 306 362 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared 307 363 $query .= " ORDER BY {$main_table}.{$order_by} {$order}, {$main_table}.ID ASC"; … … 320 376 } 321 377 322 if ( ! function_exists( 'multissaf_get_formatted_data_by_token' ) ) {323 function multissaf_get_formatted_data_by_token( $ids = null, $offset = 0, $batch_size = 0, array $product_type = [ 'product', 'product_variation' ], $keys = [], $parmision_filds = [] ) {324 defined( 'ABSPATH' ) || exit;325 326 // Identify allowed fields from permissions327 $allowed_fields = isset( $parmision_filds['fields'] ) ? (array) $parmision_filds['fields'] : ( is_array( $parmision_filds ) && ! empty( $parmision_filds ) && ! isset( $parmision_filds['scope'] ) && ! isset( $parmision_filds['include_type'] ) ? $parmision_filds : [ 'all_fields' ] );328 $allow_all = in_array( 'all_fields', $allowed_fields ) || empty( $allowed_fields );329 330 if (is_array($keys) && empty($keys)) {331 $column_obj = class_exists('\StockSyncWithGoogleSheetForWooCommerce\Column') ? new \StockSyncWithGoogleSheetForWooCommerce\Column() : null;332 $keys = $column_obj ? $column_obj->get_column_keys() : [];333 }334 335 336 // Merge permission fields into keys if any are missing (avoid adding 'all_fields' as a literal key)337 if ( ! empty( $allowed_fields ) && ! in_array( 'all_fields', $allowed_fields ) ) {338 $keys = array_unique( array_merge( $keys, $allowed_fields ) );339 }340 341 // Create sanitized keys for property access without mutating original keys342 $sanitized_keys = [];343 foreach ( $keys as $key ) {344 $sanitized_keys[ $key ] = str_replace([ '-', ' ', ',', '.', '?' ], '_', $key);345 }346 347 348 $raw_data = multissaf_get_raw_data_by_token($ids, $offset, $batch_size, $product_type, $parmision_filds);349 350 if (! is_array($raw_data) ) {351 return false;352 }353 if (! $raw_data || (is_array($raw_data) && count($raw_data) === 0) ) {354 return false;355 }356 357 $formatted_data = array_map(358 function ( $row ) use ( $keys, $sanitized_keys, $allowed_fields, $allow_all ) {359 $formatted_row = [];360 361 foreach ( $keys as $key ) {362 // Check if field is allowed (always allow ID)363 if ( ! $allow_all && ! in_array( $key, $allowed_fields ) && 'ID' !== $key ) {364 continue;365 }366 367 if ( '_stock_status' === $key ) {368 continue;369 }370 371 $s_key = $sanitized_keys[ $key ];372 $cell_value = apply_filters( 'ssgs_get_column', $row->$s_key, $key, $row );373 374 // If the value is an array (like nested attributes), return an empty string375 $formatted_row[ $key ] = is_array($cell_value) ? '' : $cell_value;376 }377 return array_values($formatted_row);378 }, $raw_data379 );380 return $formatted_data;381 }382 }383 384 378 /** 385 379 * Builds the flat sheet-row format (array of values, no keys) for a … … 399 393 function multissaf_get_batch_products_by_token( $ids = null, $offset = 0, $batch_size = 0, $parmision_filds = [] ) { 400 394 401 402 395 // When explicit variation IDs are requested, fetch their parents and 396 // keep only those variations in the child-fetch loop below. 403 397 $requested_variation_ids = []; 404 398 … … 449 443 $all_raw_data[] = $parent_row; 450 444 451 // Check if variable (product_type field will have term_id:name pattern from the query)445 // Check if variable (product_type is a comma list of "taxonomy:slug" for all the product's terms) 452 446 $is_variable = false; 453 447 if (isset($parent_row->product_type)) { 454 $parts = explode(':', $parent_row->product_type); 455 $type_name = isset($parts[1]) ? strtolower($parts[1]) : ''; 456 if ('variable' === $type_name) { 457 $is_variable = true; 458 } 448 $is_variable = in_array( 'product_type:variable', explode( ',', strtolower( $parent_row->product_type ) ), true ); 459 449 } 460 450 … … 476 466 } 477 467 478 // Resolve keys first (same logic as formatted_data_by_token)479 $allowed_fields = isset( $parmision_filds['fields'] ) ? (array) $parmision_filds['fields'] : ( is_array( $parmision_filds ) && ! empty( $parmision_filds ) && ! isset( $parmision_filds['scope'] ) && ! isset( $parmision_filds['include_type'] ) ? $parmision_filds : [ 'all_fields' ] );468 // Resolve allowed field keys from the permissions 469 $allowed_fields = isset( $parmision_filds['fields'] ) ? (array) $parmision_filds['fields'] : [ 'all_fields' ]; 480 470 $column_obj = class_exists('\StockSyncWithGoogleSheetForWooCommerce\Column') ? new \StockSyncWithGoogleSheetForWooCommerce\Column() : null; 481 471 $keys = $column_obj ? $column_obj->get_column_keys() : []; … … 729 719 /** 730 720 * Saves or upserts a child site entry into ssgsw_multi_store_list. 731 * Blocks adding a 2nd site when unlicensed (ssgsw_multistore_access = false). 732 * 733 * Connected from: MULTISSAF_AJAX::multissaf_request_for_connect(), 734 * MULTISSAF_AJAX::multissaf_update_site_settings() 721 * 722 * Connected from: MULTISSAF_AJAX::multissaf_update_site_settings() 735 723 * 736 724 * @param array $data Site data including 'url' (required), 'token', 'tab_name', etc. 737 * @return bool True on success, false if blocked orurl missing.725 * @return bool True on success, false if url missing. 738 726 */ 739 727 if ( ! function_exists( 'multissaf_save_child_site' ) ) { … … 769 757 770 758 if ( ! $updated ) { 771 772 759 // Add as new 773 760 $existing_sites[] = $data; … … 796 783 defined( 'ABSPATH' ) || exit; 797 784 798 // Ensure isConnected is set when parent is first saved785 // A completed add_parent handshake is an active connection. 799 786 if ( ! isset( $data['isConnected'] ) ) { 800 $data['isConnected'] = true; // Completed add_parent handshake = active connection801 } 802 803 $exits_array = multissaf_sub_site_url_parent();804 805 $ exits_array[] = $data;806 // Serialize each sub-array into a unique string.807 $serialized_array = array_map( 'serialize', $exits_array );808 809 // Use array_unique to remove duplicate serialized strings.810 $unique_serialized_array = array_unique( $serialized_array);811 812 // Unserialize the unique strings back into arrays.813 $unique_array = array_map( 'unserialize', $unique_serialized_array );814 815 // Re-index the array to have a clean, sequential order.816 $cleaned_array = array_values( $unique_array ); 817 // used in verify_connections_callback in class-api and multissaf_site_remove in class-ajax 818 update_option( 'ssgsw_multi_store_list_parent', $cleaned_array);787 $data['isConnected'] = true; 788 } 789 790 // One entry per parent URL: a reconnect replaces the old (disconnected) entry 791 // instead of adding a second one next to it. 792 $parent_url = isset( $data['url'] ) ? untrailingslashit( $data['url'] ) : ''; 793 $parents = []; 794 foreach ( (array) multissaf_sub_site_url_parent() as $site ) { 795 if ( $parent_url && isset( $site['url'] ) && untrailingslashit( $site['url'] ) === $parent_url ) { 796 $data = array_merge( $site, $data ); 797 unset( $data['status'] ); 798 continue; 799 } 800 $parents[] = $site; 801 } 802 $parents[] = $data; 803 804 // used in verify_connections_callback in class-api and multissaf_site_remove in class-ajax 805 update_option( 'ssgsw_multi_store_list_parent', array_values( $parents ) ); 819 806 } 820 807 } … … 822 809 /** 823 810 * Returns parent sites from ssgsw_multi_store_list_parent. 824 * Free plugin: limited to 1 parent by design (no license check)825 * Pro plugin: can return all via 'ssgsw_multistore_access' filter826 811 * 827 812 * @return array[] Each entry: {url, token} … … 835 820 return []; 836 821 } 837 838 839 822 840 823 return $all_option; … … 842 825 } 843 826 844 845 if ( ! function_exists( 'multissaf_multi_store_license_check' ) ) {846 function multissaf_multi_store_license_check() {847 defined( 'ABSPATH' ) || exit;848 global $ssgsw_multistore;849 850 if ( ! $ssgsw_multistore ) {851 return false;852 }853 854 return $ssgsw_multistore->is_valid();855 }856 }857 827 858 828 … … 860 830 function multissaf_get_body() { 861 831 defined( 'ABSPATH' ) || exit; 862 $raw_input = file_get_contents( 'php://input' ); 863 if ( ! empty( $raw_input ) ) { 864 $inputs = json_decode( $raw_input, true ); 865 if ( is_array( $inputs ) ) { 866 return (object) multissaf_sanitize( $inputs ); 867 } 868 } 869 870 return (object) multissaf_sanitize( $_POST ); 832 $inputs = json_decode( file_get_contents( 'php://input' ), true ); 833 834 if ( is_array( $inputs ) ) { 835 return (object) multissaf_sanitize( $inputs ); 836 } 837 838 return (object) []; 871 839 } 872 840 } … … 875 843 function multissaf_sanitize( $array ) { //phpcs:ignore 876 844 defined( 'ABSPATH' ) || exit; 877 if ( ! is_array( $array ) ) {878 return is_string( $array ) ? sanitize_text_field( $array ) : $array;879 }880 845 foreach ( $array as $key => $value ) { 881 846 if ( is_array( $value ) ) { 882 847 $array[ $key ] = multissaf_sanitize( $value ); 883 } else if ( is_string( $value ) ){848 } else { 884 849 $array[ $key ] = sanitize_text_field( $value ); 885 } else {886 $array[ $key ] = $value;887 850 } 888 851 } … … 992 955 993 956 994 if ( ! function_exists( 'multissaf_get_product_data_batch' ) ) {995 function multissaf_get_product_data_batch( $offset, $limit ) {996 defined( 'ABSPATH' ) || exit;997 998 $args = array(999 'post_type' => array('product'),1000 'posts_per_page' => $limit,1001 'offset' => $offset,1002 'post_status' => 'any',1003 'meta_query' => array(1004 array(1005 'key' => '_sku',1006 'compare' => 'EXISTS',1007 ),1008 ),1009 );1010 1011 $products = new WP_Query($args);1012 $data = [];1013 1014 if ($products->have_posts()) {1015 foreach ($products->posts as $product_post) {1016 $product = wc_get_product($product_post->ID);1017 1018 if (!$product) {1019 continue;1020 }1021 1022 // Get product data1023 $product_data = [1024 'id' => $product->get_id(),1025 'title' => $product->get_name(),1026 'description' => $product->get_description(),1027 'short_description' => $product->get_short_description(),1028 'type' => $product->get_type(),1029 'sku' => $product->get_sku(),1030 'status' => $product->get_status(),1031 'regular_price' => $product->get_regular_price(),1032 'sale_price' => $product->get_sale_price(),1033 'stock_quantity' => $product->get_stock_quantity(),1034 'stock_status' => $product->get_stock_status(),1035 ];1036 1037 // Get featured image URL1038 $image_id = $product->get_image_id();1039 $product_data['featured_image_url'] = $image_id ? wp_get_attachment_url($image_id) : '';1040 1041 // Get gallery image URLs1042 $gallery_ids = $product->get_gallery_image_ids();1043 $product_data['gallery_image_urls'] = array_map('wp_get_attachment_url', $gallery_ids);1044 1045 // Get categories & tags1046 $categories = wp_get_post_terms($product->get_id(), 'product_cat');1047 $product_data['categories'] = [];1048 foreach ($categories as $category) {1049 $product_data['categories'][] = [1050 'name' => $category->name,1051 'slug' => $category->slug,1052 ];1053 }1054 1055 $tags = wp_get_post_terms($product->get_id(), 'product_tag');1056 $product_data['tags'] = [];1057 foreach ($tags as $tag) {1058 $product_data['tags'][] = [1059 'name' => $tag->name,1060 'slug' => $tag->slug,1061 ];1062 }1063 $brands = wp_get_post_terms($product->get_id(), 'product_brand'); // You may need to change 'product_brand' to your actual brand taxonomy slug1064 $product_data['brands'] = [];1065 foreach ($brands as $brand) {1066 $product_data['brands'][] = [1067 'name' => $brand->name,1068 'slug' => $brand->slug,1069 ];1070 }1071 // Get attributes1072 $product_data['attributes'] = [];1073 1074 $product_id = $product->get_id();1075 1076 // First: Try to get from _product_attributes post meta1077 $product_attributes = get_post_meta($product_id, '_product_attributes', true);1078 $product_attributes = maybe_unserialize($product_attributes);1079 1080 if (!empty($product_attributes) && is_array($product_attributes)) {1081 foreach ($product_attributes as $attribute_name => $attribute) {1082 $attribute_label = wc_attribute_label($attribute_name);1083 $is_variations = isset($attribute['is_variation']) ? $attribute['is_variation'] : false;1084 $is_visible = isset($attribute['is_visible']) ? $attribute['is_visible'] : false;1085 $attribute_value = '';1086 1087 if (!empty($attribute['is_taxonomy'])) {1088 // Handle global attribute (taxonomy)1089 $terms = wp_get_post_terms($product_id, $attribute_name, ['fields' => 'names']);1090 if (!is_wp_error($terms) && !empty($terms)) {1091 $attribute_value = implode(' | ', $terms);1092 }1093 } elseif (!empty($attribute['value'])) {1094 // Handle custom attribute (non-taxonomy)1095 $options = array_map('trim', explode('|', $attribute['value']));1096 $attribute_value = implode(' | ', $options);1097 }1098 1099 if (!empty($attribute_value)) {1100 $product_data['attributes'][] = [1101 'slug' => $attribute_name,1102 'name' => $attribute_label,1103 'value' => $attribute_value,1104 'is_variations' => $is_variations,1105 'is_visible' => $is_visible1106 ];1107 }1108 }1109 }1110 1111 $woo_meta_keys_to_skip = [1112 'chosen_product_cat',1113 '_sku',1114 '_price',1115 '_regular_price',1116 '_sale_price',1117 '_stock',1118 '_stock_status',1119 '_thumbnail_id',1120 '_product_image_gallery',1121 '_upsell_ids',1122 '_crosssell_ids',1123 '_featured',1124 ];1125 1126 // Get all custom meta data1127 $all_meta = get_post_meta($product->get_id());1128 1129 $product_data['custom_meta'] = array_diff_key($all_meta, array_flip($woo_meta_keys_to_skip));1130 1131 // Get variations (if a variable product)1132 $product_data['variations'] = [];1133 if ($product->is_type('variable')) {1134 foreach ($product->get_children() as $child_id) {1135 $variation = wc_get_product($child_id);1136 $variation_image_id = $variation->get_image_id();1137 $variation_image_url = $variation_image_id ? wp_get_attachment_url($variation_image_id) : '';1138 1139 $all_variation_meta = get_post_meta($variation->get_id());1140 $variation_custom_meta = array_diff_key($all_variation_meta, array_flip($woo_meta_keys_to_skip));1141 1142 $product_data['variations'][] = [1143 'id' => $variation->get_id(),1144 'sku' => $variation->get_sku(),1145 'regular_price' => $variation->get_regular_price(),1146 'sale_price' => $variation->get_sale_price(),1147 'price' => $variation->get_price(),1148 'stock_quantity' => $variation->get_stock_quantity(),1149 'stock_status' => $variation->get_stock_status(),1150 'attributes' => $variation->get_attributes(),1151 'image_url' => $variation_image_url,1152 'custom_meta' => $variation_custom_meta,1153 ];1154 }1155 }1156 1157 $data[] = $product_data;1158 }1159 }1160 1161 // Memory cleanup - Add these lines here.1162 wp_reset_postdata();1163 1164 // Store data before unsetting variables.1165 $result = $data;1166 1167 // Clean up memory.1168 unset( $products, $data, $product_post, $product, $all_meta, $product_attributes );1169 1170 return $result;1171 }1172 }1173 1174 957 if ( ! function_exists( 'multissaf_get_sub_product_count' ) ) { 1175 958 function multissaf_get_sub_product_count( $url, $token, $site ) { … … 1241 1024 $data = multissaf_get_access_data_by_token( $matched_token_data ); 1242 1025 if ( is_array( $data ) && isset( $data['count'] ) ) { 1243 return intval( $data['count'] ); 1026 // Stay within the token's product count (0 = all). 1027 $limit = multissaf_get_token_max_products( $matched_token_data ); 1028 return $limit ? min( intval( $data['count'] ), $limit ) : intval( $data['count'] ); 1244 1029 } 1245 1030 } … … 1264 1049 function multissaf_single_product_update( $api_url, $body_data, $bearer_token ) { 1265 1050 1266 // No global time limit — single product API call is bounded by wp_remote_post timeout. 1051 // Extend timeout only immediately before this HTTP request 1052 if ( function_exists( 'set_time_limit' ) ) { 1053 @set_time_limit( 120 ); 1054 } 1055 1267 1056 // Ensure the token and body data are not empty. 1268 1057 if ( empty( $api_url ) || empty( $bearer_token ) ) { … … 1310 1099 // Check for a WordPress error. 1311 1100 if ( is_wp_error( $response ) ) { 1312 return false; // Re turn the WP_Error object.1101 return false; // Request failed. 1313 1102 } 1314 1103 // Get the HTTP status code from the response. … … 1359 1148 // Check for a WordPress error. 1360 1149 if ( is_wp_error( $response ) ) { 1361 return false; // Re turn the WP_Error object.1150 return false; // Request failed. 1362 1151 } 1363 1152 // Get the HTTP status code from the response. … … 1409 1198 // Check for a WordPress error. 1410 1199 if ( is_wp_error( $response ) ) { 1411 return false; // Re turn the WP_Error object.1200 return false; // Request failed. 1412 1201 } 1413 1202 // Get the HTTP status code from the response. … … 1584 1373 } 1585 1374 1586 // Optionally apply the limit.1375 // Allow the count to be filtered. 1587 1376 if ( $product_count > 100 ) { 1588 1377 $limit = apply_filters( 'ssgsw_product_limit', 100 ); … … 1593 1382 1594 1383 return $product_count; 1595 }1596 }1597 1598 if ( ! function_exists( 'multissaf_get_parent_product_ids' ) ) {1599 function multissaf_get_parent_product_ids( array $product_ids ): array {1600 defined( 'ABSPATH' ) || exit;1601 if ( empty( $product_ids ) ) {1602 return [];1603 }1604 1605 global $wpdb;1606 1607 // Sanitize the product IDs to prevent SQL injection.1608 $sanitized_ids = array_map( 'intval', $product_ids );1609 $placeholders = implode( ',', array_fill( 0, count( $sanitized_ids ), '%d' ) );1610 1611 // SQL query to find only product IDs with post_type 'product'.1612 $parent_products = $wpdb->get_col(1613 $wpdb->prepare(1614 // SQL Query String as the first argument.1615 "SELECT ID FROM {$wpdb->posts} WHERE ID IN ({$placeholders}) AND post_type = 'product'",1616 // Splat operator (...) passes array elements as individual arguments.1617 ...$sanitized_ids1618 )1619 );1620 $unique_parent_ids = array_unique( array_map( 'intval', $parent_products ) );1621 1622 // Sort the IDs for consistent processing.1623 sort( $unique_parent_ids );1624 1625 return $unique_parent_ids;1626 1384 } 1627 1385 } … … 1660 1418 } 1661 1419 return false; 1420 } 1421 } 1422 1423 /** 1424 * FlexStock's ssgsw_format_headers_with_sheet_header() (sheet headers first, then the other 1425 * column labels, mapped to column keys), with the same logic for FlexStock versions without it. 1426 * 1427 * @param string[] $sheet_headers Current sheet header row. 1428 * @param array $colmuns FlexStock columns (key => ['label' => ...]). 1429 * @return array [ column keys, header labels ] 1430 */ 1431 if ( ! function_exists( 'multissaf_format_headers_with_sheet_header' ) ) { 1432 function multissaf_format_headers_with_sheet_header( $sheet_headers, $colmuns ) { 1433 if ( function_exists( 'ssgsw_format_headers_with_sheet_header' ) ) { 1434 return ssgsw_format_headers_with_sheet_header( $sheet_headers, $colmuns ); 1435 } 1436 $format_column = array_flip( array_map( function ( $value ) { 1437 return $value['label']; 1438 }, $colmuns ) ); 1439 $formatted_data = []; 1440 $column_lookup = $format_column; 1441 foreach ( (array) $sheet_headers as $header ) { 1442 if ( isset( $column_lookup[ $header ] ) ) { 1443 $formatted_data[] = $header; 1444 unset( $column_lookup[ $header ] ); 1445 } 1446 } 1447 $formatted_data = array_merge( $formatted_data, array_keys( $column_lookup ) ); 1448 $keys = []; 1449 foreach ( $formatted_data as $key => $value ) { 1450 $keys[ $key ] = str_replace( [ ' ', ',', '.', '?' ], '_', $format_column[ $value ] ); 1451 } 1452 return [ $keys, $formatted_data ]; 1453 } 1454 } 1455 1456 /** 1457 * Reads a FlexStock feature flag. Uses FlexStock's own flags when the installed version 1458 * has them; older FlexStock versions do not, so the same defaults apply. 1459 * 1460 * @param string $feature Flag name, e.g. 'enable_cron_sync' or 'max_retry_count'. 1461 * @return mixed Flag value, or false when unknown. 1462 */ 1463 if ( ! function_exists( 'multissaf_get_feature_flag' ) ) { 1464 function multissaf_get_feature_flag( $feature ) { 1465 if ( function_exists( 'ssgsw_get_feature_flags' ) ) { 1466 $flags = ssgsw_get_feature_flags(); 1467 } else { 1468 $flags = array_merge( 1469 [ 1470 'enable_cron_sync' => false, // Direct sync instead of cron. 1471 'max_retry_count' => 3, 1472 ], 1473 (array) get_option( 'ssgsw_feature_flags', [] ) 1474 ); 1475 } 1476 return isset( $flags[ $feature ] ) ? $flags[ $feature ] : false; 1477 } 1478 } 1479 1480 /** 1481 * Product count set on a token by the store using it (0 = all products). 1482 * 1483 * @param array|string $token_data Token record from ssgsw_child_tokens, or the token string. 1484 * @return int 1485 */ 1486 if ( ! function_exists( 'multissaf_get_token_max_products' ) ) { 1487 function multissaf_get_token_max_products( $token_data ) { 1488 if ( is_string( $token_data ) ) { 1489 $token = $token_data; 1490 $token_data = null; 1491 foreach ( (array) get_option( 'ssgsw_child_tokens', [] ) as $entry ) { 1492 if ( isset( $entry['token'] ) && '' !== $token && $token === $entry['token'] ) { 1493 $token_data = $entry; 1494 break; 1495 } 1496 } 1497 } 1498 return ( is_array( $token_data ) && ! empty( $token_data['max_products'] ) ) ? absint( $token_data['max_products'] ) : 0; 1499 } 1500 } 1501 1502 /** 1503 * The first N parent products in a token's scope, in the same order as the import. 1504 * 1505 * @param array $permissions Token permissions. 1506 * @param int $limit Number of products. 1507 * @return int[] 1508 */ 1509 if ( ! function_exists( 'multissaf_get_token_limited_ids' ) ) { 1510 function multissaf_get_token_limited_ids( $permissions, $limit ) { 1511 global $wpdb; 1512 $limit = absint( $limit ); 1513 if ( ! $limit ) { 1514 return []; 1515 } 1516 $allowed_columns = [ 'post_date', 'post_modified', 'post_title', 'ID', 'menu_order' ]; 1517 $order_by_raw = apply_filters( 'multissaf_product_order_by', 'post_date' ); 1518 $order_raw = apply_filters( 'multissaf_product_order', 'ASC' ); 1519 $order_by = in_array( $order_by_raw, $allowed_columns, true ) ? $order_by_raw : 'post_date'; 1520 $order = strtoupper( $order_raw ) === 'DESC' ? 'DESC' : 'ASC'; 1521 $table = $wpdb->posts; 1522 1523 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- scope SQL is built with $wpdb->prepare(). 1524 $ids = $wpdb->get_col( 1525 "SELECT $table.ID FROM $table WHERE $table.post_type = 'product' AND $table.post_status = 'publish'" 1526 . multissaf_get_token_scope_sql( $permissions, $table ) 1527 . " ORDER BY $table.$order_by $order, $table.ID ASC" 1528 . $wpdb->prepare( ' LIMIT %d', $limit ) 1529 ); 1530 return array_map( 'intval', (array) $ids ); 1531 } 1532 } 1533 1534 /** 1535 * Finds a connected store (destination side) by URL. Matches the URL used to connect 1536 * it or the store's own home URL (`source_url`, saved during the connection handshake), 1537 * compared without scheme, "www." and trailing slash. 1538 * 1539 * @param string $url Store URL, e.g. refarance_shop from an update. 1540 * @return array|null The store entry from ssgsw_multi_store_list. 1541 */ 1542 if ( ! function_exists( 'multissaf_find_child_site' ) ) { 1543 function multissaf_find_child_site( $url ) { 1544 $normalize = function ( $value ) { 1545 $value = strtolower( untrailingslashit( trim( (string) $value ) ) ); 1546 return preg_replace( '#^(https?://)?(www\.)?#', '', $value ); 1547 }; 1548 $target = $normalize( $url ); 1549 if ( '' === $target ) { 1550 return null; 1551 } 1552 foreach ( (array) get_option( 'ssgsw_multi_store_list', [] ) as $site ) { 1553 foreach ( [ 'url', 'source_url' ] as $key ) { 1554 if ( ! empty( $site[ $key ] ) && $normalize( $site[ $key ] ) === $target ) { 1555 return $site; 1556 } 1557 } 1558 } 1559 return null; 1560 } 1561 } 1562 1563 /** 1564 * How many new products a store's updates may still create here (null = any number). 1565 * 1566 * Connected from: API::update_single_callback() 1567 * 1568 * @param string $store_url The store's URL as sent with its updates (its home URL). 1569 * @return int|null Remaining count, or null for any number. 1570 */ 1571 if ( ! function_exists( 'multissaf_get_store_create_allowance' ) ) { 1572 function multissaf_get_store_create_allowance( $store_url ) { 1573 defined( 'ABSPATH' ) || exit; 1574 1575 $site = multissaf_find_child_site( $store_url ); 1576 if ( ! $site || ! isset( $site['max_products'] ) || '' === $site['max_products'] || null === $site['max_products'] ) { 1577 return 0; 1578 } 1579 $cap = absint( $site['max_products'] ); 1580 if ( 0 === $cap ) { 1581 return null; 1582 } 1583 1584 // Products imported from this store carry the store's remote-ID meta. 1585 $keys = []; 1586 foreach ( array_filter( [ $site['url'], isset( $site['source_url'] ) ? $site['source_url'] : '', $store_url ] ) as $url ) { 1587 foreach ( [ $url, trailingslashit( $url ), untrailingslashit( $url ) ] as $variant ) { 1588 $keys[] = '_ssgsw_remote_id_' . md5( $variant ); 1589 } 1590 } 1591 $keys = array_values( array_unique( $keys ) ); 1592 1593 global $wpdb; 1594 $placeholders = implode( ',', array_fill( 0, count( $keys ), '%s' ) ); 1595 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared 1596 $imported = (int) $wpdb->get_var( $wpdb->prepare( 1597 "SELECT COUNT(DISTINCT pm.post_id) FROM {$wpdb->postmeta} pm 1598 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id AND p.post_type = 'product' 1599 WHERE pm.meta_key IN ($placeholders)", 1600 ...$keys 1601 ) ); 1602 1603 return max( 0, $cap - $imported ); 1662 1604 } 1663 1605 } … … 1691 1633 } 1692 1634 1693 $join = ''; 1694 $where = $wpdb->prepare( " WHERE p.post_type IN (%s) AND p.post_status = %s", 'product', 'publish' ); 1695 $selected_terms = []; 1696 1635 // Same scope clauses as the product fetch, so the count matches what is imported or synced. 1636 $join = ''; 1637 $where = $wpdb->prepare( " WHERE p.post_type IN (%s) AND p.post_status = %s", 'product', 'publish' ); 1638 $scope_permissions = isset( $permissions['permissions'] ) ? (array) $permissions['permissions'] : (array) $permissions; 1639 $where .= multissaf_get_token_scope_sql( $scope_permissions, 'p' ); 1697 1640 1698 1641 $sql = "SELECT COUNT(DISTINCT p.ID) FROM {$wpdb->posts} p $join $where"; … … 1706 1649 1707 1650 // Field Logic 1708 $fields = isset( $ permissions['permissions']['fields'] ) ? (array) $permissions['permissions']['fields'] : [ 'all_fields' ];1651 $fields = isset( $scope_permissions['fields'] ) ? (array) $scope_permissions['fields'] : [ 'all_fields' ]; 1709 1652 $field_names = []; 1710 1653 … … 1726 1669 } 1727 1670 1728 1729 1671 return [ 1730 1672 'count' => $count, … … 1732 1674 'permissions' => $permissions, 1733 1675 'field_names' => array_values( array_filter( $field_names ) ), 1734 'selected_terms' => $selected_terms, 1735 ]; 1736 } 1737 } 1738 1739 if ( ! function_exists( 'multissaf_get_sample_sync_data' ) ) { 1740 function multissaf_get_sample_sync_data() { 1741 1742 return [ 1743 'ssgsw_products' => [ 1744 [ 1745 'id' => 192, 1746 'type' => 'simple', 1747 'title' => 'Hello22c334', 1748 'stock_quantity' => null, 1749 'stock_status' => 'instock', 1750 'regular_price' => 3314, 1751 'price' => 311, 1752 'sale_price' => 311, 1753 'featured_image_url' => '', 1754 'gallery_image_urls' => [], 1755 'status' => 'publish', 1756 'sku' => 'testcase', 1757 'attributes' => [], 1758 'short_description' => '', 1759 'description' => '', 1760 'custom_meta' => [ 1761 '_edit_last' => [ '1' ], 1762 'total_sales' => [ '0' ], 1763 '_tax_status' => [ 'taxable' ], 1764 '_tax_class' => [ '' ], 1765 '_manage_stock' => [ 'no' ], 1766 '_backorders' => [ 'no' ], 1767 '_sold_individually' => [ 'no' ], 1768 '_virtual' => [ 'no' ], 1769 '_downloadable' => [ 'no' ], 1770 '_download_limit' => [ '-1' ], 1771 '_download_expiry' => [ '-1' ], 1772 '_wc_average_rating' => [ '0' ], 1773 '_wc_review_count' => [ '0' ], 1774 '_product_version' => [ '10.5.2' ], 1775 '_per_product_admin_commission' => [ '' ], 1776 '_per_product_admin_commission_type' => [ 'fixed' ], 1777 '_per_product_admin_additional_fee' => [ '' ], 1778 ], 1779 ], 1780 ], 1781 'skus' => [ 'testcase' ], 1782 'refarance_shop' => 'http://store1.test', 1783 'refrance_token' => '421eb65e670f2e7ec717c3014a52c6c1ae210a47321540ab951f77b7aacf5f22', 1784 'orgingal_ids' => [ 192 ], 1785 'sync_mode' => 'multi_to_single', 1786 'sheet_sync' => '', 1676 'selected_terms' => [], 1787 1677 ]; 1788 1678 } … … 1995 1885 // Filter to only connected sites 1996 1886 $connected_sites = array_filter( $all_sites, function( $site ) { 1997 // FIX: Default to TRUE (connected) if isConnected field is missing 1998 // This fixes the bug where parent sites saved without isConnected field 1999 // were being filtered out, causing cross-site sync to fail 1887 // Treat a missing isConnected as connected. 2000 1888 $is_connected = isset($site['isConnected']) ? $site['isConnected'] : true; 2001 1889 -
multi-store-sync-addon-for-flexstock/trunk/includes/classes/import-function.php
r3624473 r3752558 4 4 5 5 /** 6 * Imports or updates a batch of products on the local WooCommerce store 7 * from a structured product array (as sent by multissaf_get_product_by_ids). 8 * Matches by SKU (exact or alias). Creates missing products; updates existing. 6 * Imports or updates products on this store, matched by SKU; missing products are created. 9 7 * 10 8 * Connected from: API::update_single_callback(), 11 * API::marge_with_parent_callback(),12 9 * MULTISSAF_AJAX::multissaf_manual_import_products() 13 10 * 14 * @param array $products_data Array of product arrays with keys: sku, title,11 * @param array $products_data Array of product arrays with keys: sku, title, 15 12 * stock_quantity, regular_price, etc. 16 * @param bool $sheet_sync Pass-through flag for downstream sheet update. 17 * @param int[] $original_ids Source IDs for sheet write scheduling. 18 * @param string $refrance_shop Source site URL. 19 * @param bool $manual_update Whether triggered by admin manual action. 13 * @param bool $sheet_sync Pass-through flag for downstream sheet update. 14 * @param int[] $original_ids Source IDs for sheet write scheduling. 15 * @param string $refrance_shop Source site URL. 16 * @param bool $manual_update Whether triggered by admin manual action. 17 * @param int|null $create_limit How many new products may be created (null = any number). 20 18 * @return array {success, created_count, updated_count, failed_count, messages, details} 21 19 */ 22 if ( ! function_exists( 'multissaf_import_update_products' ) ) { 23 function multissaf_import_update_products( $products_data, $sheet_sync = false, $original_ids = [], $refrance_shop = '', $manual_update = false ) { 24 defined( 'ABSPATH' ) || exit; 20 if (! function_exists('multissaf_save_meta_value') ) { 21 /** 22 * Saves an imported meta value through FlexStock when available (it also handles 23 * serialized values); older FlexStock versions lack that helper. 24 * 25 * @param int $id Product or variation ID. 26 * @param string $meta_key Meta key. 27 * @param mixed $value Meta value (may be serialized). 28 * @return mixed 29 */ 30 function multissaf_save_meta_value( $id, $meta_key, $value ) 31 { 32 if (function_exists('ssgsw_meta_field_value_save') ) { 33 return ssgsw_meta_field_value_save($id, $meta_key, $value); 34 } 35 if (empty($id) || ! is_numeric($id) ) { 36 return false; 37 } 38 return update_post_meta($id, $meta_key, maybe_unserialize($value)); 39 } 40 } 41 42 if (! function_exists('multissaf_import_update_products') ) { 43 function multissaf_import_update_products( $products_data, $sheet_sync = false, $original_ids = [], $refrance_shop = '', $manual_update = false, $create_limit = null ) 44 { 45 defined('ABSPATH') || exit; 25 46 26 47 if (empty($products_data) || !is_array($products_data)) { … … 43 64 44 65 // Defer term and comment counting for performance 45 wp_defer_term_counting( true);46 wp_defer_comment_counting( true);47 48 $skus_to_lookup = array_filter( array_column( $products_data, 'sku' ));49 $skus_to_lookup = apply_filters( 'multissaf_import_skus_to_lookup', $skus_to_lookup);66 wp_defer_term_counting(true); 67 wp_defer_comment_counting(true); 68 69 $skus_to_lookup = array_filter(array_column($products_data, 'sku')); 70 $skus_to_lookup = apply_filters('multissaf_import_skus_to_lookup', $skus_to_lookup); 50 71 $sku_map = []; 51 72 52 if ( ! empty( $skus_to_lookup) ) {73 if (! empty($skus_to_lookup) ) { 53 74 global $wpdb; 54 $placeholders = implode( ',', array_fill( 0, count( $skus_to_lookup ), '%s' ));75 $placeholders = implode(',', array_fill(0, count($skus_to_lookup), '%s')); 55 76 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared 56 77 $rows = $wpdb->get_results( … … 79 100 } 80 101 81 $existing_product_id = isset( $sku_map[ $sku ]) ? $sku_map[ $sku ] : 0;102 $existing_product_id = isset($sku_map[ $sku ]) ? $sku_map[ $sku ] : 0; 82 103 83 104 // Tier 2: Match by old SKU 84 if ( !$existing_product_id && isset($product_data['custom_meta']['_ssgsw_old_sku_temp']) ) {105 if (!$existing_product_id && isset($product_data['custom_meta']['_ssgsw_old_sku_temp']) ) { 85 106 $old_sku = $product_data['custom_meta']['_ssgsw_old_sku_temp']; 86 if ( is_array( $old_sku) ) {107 if (is_array($old_sku) ) { 87 108 $old_sku = $old_sku[0]; 88 109 } 89 if ( ! empty( $old_sku ) ) { 90 $existing_product_id = isset( $sku_map[ $old_sku ] ) ? $sku_map[ $old_sku ] : wc_get_product_id_by_sku( $old_sku ); 91 } 110 if (! empty($old_sku) ) { 111 $existing_product_id = isset($sku_map[ $old_sku ]) ? $sku_map[ $old_sku ] : wc_get_product_id_by_sku($old_sku); 112 } 113 } 114 115 // Top-level products may only match parent product posts, never variations. 116 if ($existing_product_id && 'product' !== get_post_type($existing_product_id) ) { 117 $existing_product_id = 0; 92 118 } 93 119 94 120 // Tier 3: Match by mapped remote ID 95 if ( !$existing_product_id && !empty($refrance_shop) && !empty($product_data['id']) ) {121 if (!$existing_product_id && !empty($refrance_shop) && !empty($product_data['id']) ) { 96 122 $remote_meta_key = '_ssgsw_remote_id_' . md5($refrance_shop); 97 123 global $wpdb; 98 $found_id = $wpdb->get_var($wpdb->prepare( 99 "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = %s AND meta_value = %s LIMIT 1", 100 $remote_meta_key, 101 absint($product_data['id']) 102 )); 103 if ( $found_id ) { 124 $found_id = $wpdb->get_var( 125 $wpdb->prepare( 126 "SELECT pm.post_id FROM {$wpdb->postmeta} pm 127 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id AND p.post_type = 'product' 128 WHERE pm.meta_key = %s AND pm.meta_value = %s LIMIT 1", 129 $remote_meta_key, 130 absint($product_data['id']) 131 ) 132 ); 133 if ($found_id ) { 104 134 $existing_product_id = (int) $found_id; 105 135 } 106 136 } 107 137 108 // Tier 4: Match by raw ID (cloned sites fallback) 109 if ( !$existing_product_id && !empty($product_data['id']) ) { 110 $check_id = absint($product_data['id']); 111 $post = get_post($check_id); 112 if ($post && in_array($post->post_type, ['product', 'product_variation'])) { 113 $existing_product_id = $check_id; 114 } 115 } 116 117 if ( isset( $product_data['status'] ) && 'trash' === strtolower( $product_data['status'] ) ) { 118 if ( $existing_product_id ) { 119 if ( 'trash' !== get_post_status( $existing_product_id ) ) { 120 wp_trash_post( $existing_product_id ); 138 // No raw post-ID matching: post IDs are not shared between sites, so matching 139 // on them overwrote unrelated destination products and variations. 140 141 if (isset($product_data['status']) && 'trash' === strtolower($product_data['status']) ) { 142 if ($existing_product_id ) { 143 if ('trash' !== get_post_status($existing_product_id) ) { 144 wp_trash_post($existing_product_id); 121 145 } 122 146 $product_ids[] = $existing_product_id; … … 134 158 135 159 if ($existing_product_id) { 136 if ( 'trash' === get_post_status( $existing_product_id ) && isset( $product_data['status'] ) && 'trash' !== strtolower( $product_data['status']) ) {137 wp_untrash_post( $existing_product_id);160 if ('trash' === get_post_status($existing_product_id) && isset($product_data['status']) && 'trash' !== strtolower($product_data['status']) ) { 161 wp_untrash_post($existing_product_id); 138 162 } 139 163 // Update existing product … … 141 165 142 166 if ($result['success']) { 143 if ( !empty($refrance_shop) && !empty($product_data['id']) ) {167 if (!empty($refrance_shop) && !empty($product_data['id']) ) { 144 168 update_post_meta($existing_product_id, '_ssgsw_remote_id_' . md5($refrance_shop), absint($product_data['id'])); 145 169 } … … 161 185 } else { 162 186 $store_role = get_option('ssgsw_multi_connect_mode_on'); 163 if ( 'child' === $store_role ) { 187 if ('child' === $store_role ) { 188 continue; 189 } 190 if (null !== $create_limit && $create_limit <= 0 ) { 191 $results['messages'][] = "Not created (product is not on this site): $sku"; 164 192 continue; 165 193 } … … 167 195 $result = multissaf_create_new_product($product_data); 168 196 if ($result['success']) { 169 if ( !empty($refrance_shop) && !empty($product_data['id']) ) { 197 if (null !== $create_limit ) { 198 $create_limit--; 199 } 200 if (!empty($refrance_shop) && !empty($product_data['id']) ) { 170 201 update_post_meta($result['product_id'], '_ssgsw_remote_id_' . md5($refrance_shop), absint($product_data['id'])); 171 202 } … … 199 230 200 231 // Resume term and comment counting 201 wp_defer_term_counting( false);202 wp_defer_comment_counting( false);232 wp_defer_term_counting(false); 233 wp_defer_comment_counting(false); 203 234 204 235 return $results; … … 207 238 208 239 209 if ( ! function_exists( 'multissaf_update_existing_product' ) ) { 210 function multissaf_update_existing_product( $product_id, $product_data ) { 211 defined( 'ABSPATH' ) || exit; 240 if (! function_exists('multissaf_update_existing_product') ) { 241 function multissaf_update_existing_product( $product_id, $product_data ) 242 { 243 defined('ABSPATH') || exit; 212 244 213 245 try { … … 239 271 if (isset($product_data['regular_price'])) { 240 272 $new_regular_price = floatval($product_data['regular_price']); 241 // $current_regular_price = floatval($product->get_regular_price());242 273 $current_regular_price = floatval(get_post_meta($product_id, '_regular_price', true)); 243 274 if (abs($new_regular_price - $current_regular_price) > 0.0001) { … … 261 292 $stock_status_provided = isset($product_data['stock_status']); 262 293 263 if ( $stock_quantity_provided || $stock_status_provided ) {294 if ($stock_quantity_provided || $stock_status_provided ) { 264 295 $current_quantity = $product->get_stock_quantity(); 265 296 $current_status = $product->get_stock_status(); … … 270 301 271 302 // If quantity is provided and numeric, derive status from it 272 if ( $stock_quantity_provided && is_numeric($product_data['stock_quantity']) ) {303 if ($stock_quantity_provided && is_numeric($product_data['stock_quantity']) ) { 273 304 $new_quantity = absint($product_data['stock_quantity']); 274 305 275 if ( $new_quantity > 0 ) {306 if ($new_quantity > 0 ) { 276 307 $new_status = 'instock'; 277 308 } else { 278 309 // Check backorders 279 310 $backorders = $product->get_backorders(); 280 if ( $backorders && $backorders !== 'no' ) {311 if ($backorders && $backorders !== 'no' ) { 281 312 $new_status = 'onbackorder'; 282 313 } else { … … 286 317 } 287 318 // If only status is provided (non-numeric), derive quantity from it 288 else if ( $stock_status_provided && !is_numeric($product_data['stock_status']) ) {319 else if ($stock_status_provided && !is_numeric($product_data['stock_status']) ) { 289 320 // Normalize the status string 290 $stock_normalized = strtolower( preg_replace( '/\s+/', '', trim( $product_data['stock_status'] ) ));291 $stock_normalized = preg_replace( '/\s*\(\d+\)\s*$/', '', $stock_normalized); // Remove any quantity in parentheses292 293 if ( $stock_normalized === 'outofstock' ) {321 $stock_normalized = strtolower(preg_replace('/\s+/', '', trim($product_data['stock_status']))); 322 $stock_normalized = preg_replace('/\s*\(\d+\)\s*$/', '', $stock_normalized); // Remove any quantity in parentheses 323 324 if ($stock_normalized === 'outofstock' ) { 294 325 $new_quantity = 0; 295 } else if ( $stock_normalized === 'instock' ) {326 } else if ($stock_normalized === 'instock' ) { 296 327 // Keep current quantity if positive, otherwise set to 1 297 $new_quantity = max( 1, $current_quantity);298 } else if ( $stock_normalized === 'onbackorder' ) {328 $new_quantity = max(1, $current_quantity); 329 } else if ($stock_normalized === 'onbackorder' ) { 299 330 $new_quantity = 0; 300 331 } … … 302 333 303 334 // Apply atomic updates - derive and set all three together 304 $product->set_manage_stock( true);305 $product->set_stock_quantity( $new_quantity);306 $product->set_stock_status( $new_status);335 $product->set_manage_stock(true); 336 $product->set_stock_quantity($new_quantity); 337 $product->set_stock_status($new_status); 307 338 308 339 $updated_fields[] = 'stock_quantity'; … … 317 348 318 349 // Update SKU — only when it changes and there is no conflict on another product. 319 if ( isset( $product_data['sku']) && '' !== $product_data['sku'] ) {350 if (isset($product_data['sku']) && '' !== $product_data['sku'] ) { 320 351 $new_sku = $product_data['sku']; 321 352 $current_sku = $product->get_sku(); 322 if ( $new_sku !== $current_sku ) {323 $sku_conflict_id = wc_get_product_id_by_sku( $new_sku);324 if ( ! $sku_conflict_id || (int) $sku_conflict_id === (int) $product_id ) {325 $product->set_sku( $new_sku);353 if ($new_sku !== $current_sku ) { 354 $sku_conflict_id = wc_get_product_id_by_sku($new_sku); 355 if (! $sku_conflict_id || (int) $sku_conflict_id === (int) $product_id ) { 356 $product->set_sku($new_sku); 326 357 $updated_fields[] = 'sku'; 327 358 } 328 359 } 329 360 } 330 331 /* if (!empty($updated_fields)) {332 $product->save();333 } */334 361 335 362 if (!empty($updated_fields)) { … … 339 366 340 367 // If the product was just trashed via the parent's REST API push, 341 if ( isset( $product_data['status']) && 'trash' === $product_data['status'] ) {368 if (isset($product_data['status']) && 'trash' === $product_data['status'] ) { 342 369 wp_schedule_single_event( 343 370 time() + 3, … … 347 374 } 348 375 } catch ( \Throwable $e ) { 349 error_log( '[MultiStore] Product save failed for product ' . $product_id . ': ' . $e->getMessage() ); 376 error_log('[MultiStore] Product save failed for product ' . $product_id . ': ' . $e->getMessage()); 377 return [ 'success' => false, 'message' => 'Product save failed: ' . $e->getMessage(), 'product_ids' => [] ]; 350 378 } 351 379 } … … 354 382 if (isset($product_data['custom_meta']) && is_array($product_data['custom_meta'])) { 355 383 foreach ($product_data['custom_meta'] as $meta_key => $meta_value) { 356 $transport_only_keys = [ '_ssgsw_old_sku_temp' ]; 357 if ( in_array( $meta_key, $transport_only_keys, true ) ) { 384 $transport_only_keys = [ '_ssgsw_old_sku_temp', '_ssgsw_main_sheet_row_written' ]; 385 // Site-local markers (sheet row written, remote-ID mappings) never copy across sites. 386 if (in_array($meta_key, $transport_only_keys, true) || 0 === strpos($meta_key, '_ssgsw_remote_id_') ) { 358 387 continue; 359 388 } … … 367 396 } 368 397 if ($current_meta !== $meta_value) { 369 ssgsw_meta_field_value_save($product_id, $meta_key, $meta_value);398 multissaf_save_meta_value($product_id, $meta_key, $meta_value); 370 399 $updated_fields[] = "meta: $meta_key"; 371 400 } … … 459 488 $variation_attrs = []; 460 489 foreach ( $product_data['attributes'] as $attr ) { 461 $slug = isset( $attr['slug'] ) ? sanitize_title( $attr['slug']) : '';462 $value = isset( $attr['value']) ? (string) $attr['value'] : '';490 $slug = isset($attr['slug']) ? sanitize_title($attr['slug']) : ''; 491 $value = isset($attr['value']) ? (string) $attr['value'] : ''; 463 492 // WC variations store ONE selected value per attribute, keyed by taxonomy 464 if ( ! empty( $slug) ) {493 if (! empty($slug) ) { 465 494 $variation_attrs[ $slug ] = $value; 466 495 } 467 496 } 468 $product->set_attributes( $variation_attrs);497 $product->set_attributes($variation_attrs); 469 498 $updated_fields[] = 'attributes'; 470 499 } else { … … 524 553 * @return array Result with success status, message and product_id 525 554 */ 526 if ( ! function_exists( 'multissaf_create_new_product' ) ) { 527 function multissaf_create_new_product( $product_data ) { 528 defined( 'ABSPATH' ) || exit; 555 if (! function_exists('multissaf_create_new_product') ) { 556 function multissaf_create_new_product( $product_data ) 557 { 558 defined('ABSPATH') || exit; 529 559 try { 530 560 $product_type = isset($product_data['type']) ? $product_data['type'] : 'simple'; … … 566 596 $stock_status_provided = isset($product_data['stock_status']); 567 597 568 if ( $stock_quantity_provided || $stock_status_provided ) {598 if ($stock_quantity_provided || $stock_status_provided ) { 569 599 $new_quantity = 0; 570 600 $new_status = 'instock'; 571 601 572 602 // If quantity is provided and numeric, derive status from it 573 if ( $stock_quantity_provided && is_numeric($product_data['stock_quantity']) ) {603 if ($stock_quantity_provided && is_numeric($product_data['stock_quantity']) ) { 574 604 $new_quantity = absint($product_data['stock_quantity']); 575 605 576 if ( $new_quantity > 0 ) {606 if ($new_quantity > 0 ) { 577 607 $new_status = 'instock'; 578 608 } else { … … 581 611 } 582 612 // If only status is provided 583 else if ( $stock_status_provided && !is_numeric($product_data['stock_status']) ) {584 $stock_normalized = strtolower( preg_replace( '/\s+/', '', trim( $product_data['stock_status'] ) ));585 $stock_normalized = preg_replace( '/\s*\(\d+\)\s*$/', '', $stock_normalized);613 else if ($stock_status_provided && !is_numeric($product_data['stock_status']) ) { 614 $stock_normalized = strtolower(preg_replace('/\s+/', '', trim($product_data['stock_status']))); 615 $stock_normalized = preg_replace('/\s*\(\d+\)\s*$/', '', $stock_normalized); 586 616 587 617 $valid_statuses = ['instock', 'outofstock', 'onbackorder']; 588 if ( in_array( $stock_normalized, $valid_statuses, true) ) {618 if (in_array($stock_normalized, $valid_statuses, true) ) { 589 619 $new_status = $stock_normalized; 590 if ( $stock_normalized === 'outofstock' || $stock_normalized === 'onbackorder' ) {620 if ($stock_normalized === 'outofstock' || $stock_normalized === 'onbackorder' ) { 591 621 $new_quantity = 0; 592 622 } else { … … 597 627 598 628 // Apply atomic stock update 599 $product->set_manage_stock( true);600 $product->set_stock_quantity( $new_quantity);601 $product->set_stock_status( $new_status);629 $product->set_manage_stock(true); 630 $product->set_stock_quantity($new_quantity); 631 $product->set_stock_status($new_status); 602 632 } 603 633 … … 620 650 if (isset($product_data['custom_meta']) && is_array($product_data['custom_meta'])) { 621 651 foreach ($product_data['custom_meta'] as $meta_key => $meta_value) { 622 $transport_only_keys = [ '_ssgsw_old_sku_temp' ]; 623 if ( in_array( $meta_key, $transport_only_keys, true ) ) { 652 $transport_only_keys = [ '_ssgsw_old_sku_temp', '_ssgsw_main_sheet_row_written' ]; 653 // Site-local markers (sheet row written, remote-ID mappings) never copy across sites. 654 if (in_array($meta_key, $transport_only_keys, true) || 0 === strpos($meta_key, '_ssgsw_remote_id_') ) { 624 655 continue; 625 656 } … … 630 661 $current_meta = get_post_meta($product_id, $meta_key, true); 631 662 if ($current_meta !== $meta_value) { 632 ssgsw_meta_field_value_save($product_id, $meta_key, $meta_value);663 multissaf_save_meta_value($product_id, $meta_key, $meta_value); 633 664 634 665 } … … 733 764 * @return array List of updated variations 734 765 */ 735 if ( ! function_exists( 'multissaf_create_update_product_variations' ) ) { 736 function multissaf_create_update_product_variations( $product_id, $variations_data ) { 737 defined( 'ABSPATH' ) || exit; 766 if (! function_exists('multissaf_create_update_product_variations') ) { 767 function multissaf_create_update_product_variations( $product_id, $variations_data ) 768 { 769 defined('ABSPATH') || exit; 738 770 $updated_variations = []; 739 771 foreach ($variations_data as $variation_data) { … … 751 783 } else { 752 784 $store_role = get_option('ssgsw_multi_connect_mode_on'); 753 if ( 'child' === $store_role ) {785 if ('child' === $store_role ) { 754 786 continue; 755 787 } … … 767 799 } 768 800 769 if ( ! function_exists( 'multissaf_create_product_variations' ) ) { 770 function multissaf_create_product_variations( $product_id, $variation_data ) { 771 defined( 'ABSPATH' ) || exit; 801 if (! function_exists('multissaf_create_product_variations') ) { 802 function multissaf_create_product_variations( $product_id, $variation_data ) 803 { 804 defined('ABSPATH') || exit; 772 805 773 806 $variation = new WC_Product_Variation(); … … 815 848 $current_meta = get_post_meta($variation_id, $meta_key, true); 816 849 if ($current_meta !== $meta_value) { 817 ssgsw_meta_field_value_save($variation_id, $meta_key, $meta_value);850 multissaf_save_meta_value($variation_id, $meta_key, $meta_value); 818 851 } 819 852 } … … 827 860 } 828 861 829 if ( ! function_exists( 'multissaf_update_variation' ) ) { 830 function multissaf_update_variation( $variation, $variation_data ) { 831 defined( 'ABSPATH' ) || exit; 862 if (! function_exists('multissaf_update_variation') ) { 863 function multissaf_update_variation( $variation, $variation_data ) 864 { 865 defined('ABSPATH') || exit; 832 866 833 867 $updated_fields = []; … … 879 913 880 914 // Variations trashed via API push — after_save_product is blocked 881 if ( isset( $variation_data['status']) && 'trash' === $variation_data['status'] ) {882 if ( class_exists('\StockSyncWithGoogleSheetForWooCommerce\Product') ) {915 if (isset($variation_data['status']) && 'trash' === $variation_data['status'] ) { 916 if (class_exists('\StockSyncWithGoogleSheetForWooCommerce\Product') ) { 883 917 $sheet_product = new \StockSyncWithGoogleSheetForWooCommerce\Product(); 884 $sheet_product->batch_update_delete_and_append( $variation->get_id());918 $sheet_product->batch_update_delete_and_append($variation->get_id()); 885 919 } 886 920 } … … 899 933 } 900 934 if ($current_meta !== $meta_value) { 901 ssgsw_meta_field_value_save($variation->get_id(), $meta_key, $meta_value);935 multissaf_save_meta_value($variation->get_id(), $meta_key, $meta_value); 902 936 $updated_fields[] = "meta: $meta_key"; 903 937 } … … 912 946 } 913 947 914 if ( ! function_exists( 'multissaf_manage_product_attributes' ) ) { 915 function multissaf_manage_product_attributes( WC_Product $product, array $attributes_data ) { 916 defined( 'ABSPATH' ) || exit; 948 if (! function_exists('multissaf_manage_product_attributes') ) { 949 function multissaf_manage_product_attributes( WC_Product $product, array $attributes_data ) 950 { 951 defined('ABSPATH') || exit; 917 952 if (!$product || empty($attributes_data)) { 918 953 return false; … … 966 1001 $term = get_term_by('slug', $term_slug, $slug); 967 1002 968 // Fall back to name lookup for backward compatibility1003 // Fall back to a name lookup for terms whose slug differs from the name 969 1004 if (!$term) { 970 1005 $term = get_term_by('name', $term_name, $slug); … … 1011 1046 1012 1047 // Flush WC attribute lookup cache to prevent stale key warnings 1013 if ( class_exists( '\Automattic\WooCommerce\Internal\ProductAttributesLookup\LookupDataStore') ) {1048 if (class_exists('\Automattic\WooCommerce\Internal\ProductAttributesLookup\LookupDataStore') ) { 1014 1049 try { 1015 1050 wc_get_container() 1016 ->get( \Automattic\WooCommerce\Internal\ProductAttributesLookup\LookupDataStore::class)1017 ->on_product_changed( $product->get_id());1051 ->get(\Automattic\WooCommerce\Internal\ProductAttributesLookup\LookupDataStore::class) 1052 ->on_product_changed($product->get_id()); 1018 1053 } catch ( \Exception $e ) { 1019 1054 error_log('[MultiStore] Failed to rebuild lookup data after save: ' . $e->getMessage()); … … 1028 1063 } 1029 1064 1030 if ( ! function_exists( 'multissaf_update_product_featured_image' ) ) { 1031 function multissaf_update_product_featured_image( $product_id, $image_url ) { 1032 defined( 'ABSPATH' ) || exit; 1033 1034 $image_url = function_exists('ssgsw_normalize_sheet_image_url') ? ssgsw_normalize_sheet_image_url( $image_url ) : $image_url; 1065 if (! function_exists('multissaf_update_product_featured_image') ) { 1066 function multissaf_update_product_featured_image( $product_id, $image_url ) 1067 { 1068 defined('ABSPATH') || exit; 1069 1070 $image_url = function_exists('ssgsw_normalize_sheet_image_url') ? ssgsw_normalize_sheet_image_url($image_url) : $image_url; 1035 1071 if (empty($image_url) || ! filter_var($image_url, FILTER_VALIDATE_URL) ) { 1036 1072 return false; … … 1059 1095 } 1060 1096 1061 if ( ! function_exists( 'multissaf_set_product_image_from_url' ) ) { 1062 function multissaf_set_product_image_from_url( $product_id, $image_url, $gallary = false ) { 1063 defined( 'ABSPATH' ) || exit; 1097 if (! function_exists('multissaf_set_product_image_from_url') ) { 1098 function multissaf_set_product_image_from_url( $product_id, $image_url, $gallary = false ) 1099 { 1100 defined('ABSPATH') || exit; 1064 1101 1065 1102 $image_data = function_exists('ssgsw_safe_remote_get_image') ? ssgsw_safe_remote_get_image($image_url) : wp_remote_get($image_url); … … 1098 1135 } 1099 1136 1100 if ( ! function_exists( 'multissaf_get_attachment_by_original_image_url' ) ) { 1101 function multissaf_get_attachment_by_original_image_url( $original_image_url ) { 1102 defined( 'ABSPATH' ) || exit; 1137 if (! function_exists('multissaf_get_attachment_by_original_image_url') ) { 1138 function multissaf_get_attachment_by_original_image_url( $original_image_url ) 1139 { 1140 defined('ABSPATH') || exit; 1103 1141 $args = array( 1104 1142 'post_type' => 'attachment', … … 1121 1159 } 1122 1160 1123 if ( ! function_exists( 'multissaf_get_attachment_id_by_url' ) ) { 1124 function multissaf_get_attachment_id_by_url( $image_url ) { 1125 defined( 'ABSPATH' ) || exit; 1161 if (! function_exists('multissaf_get_attachment_id_by_url') ) { 1162 function multissaf_get_attachment_id_by_url( $image_url ) 1163 { 1164 defined('ABSPATH') || exit; 1126 1165 $attachment_id = attachment_url_to_postid($image_url); 1127 1166 return $attachment_id; … … 1129 1168 } 1130 1169 1131 if ( ! function_exists( 'multissaf_update_product_gallery' ) ) { 1132 function multissaf_update_product_gallery( $product, $gallery_urls ) { 1133 defined( 'ABSPATH' ) || exit; 1170 if (! function_exists('multissaf_update_product_gallery') ) { 1171 function multissaf_update_product_gallery( $product, $gallery_urls ) 1172 { 1173 defined('ABSPATH') || exit; 1134 1174 $product_id = $product->get_id(); 1135 1175 if (!$product) { … … 1167 1207 $product->save(); 1168 1208 } catch ( \Throwable $e ) { 1169 error_log( '[MultiStore] Gallery save failed for product ' . $product->get_id() . ': ' . $e->getMessage());1209 error_log('[MultiStore] Gallery save failed for product ' . $product->get_id() . ': ' . $e->getMessage()); 1170 1210 return false; 1171 1211 } … … 1179 1219 1180 1220 1181 if ( ! function_exists( 'multissaf_set_product_gallery' ) ) { 1182 function multissaf_set_product_gallery( $product_id, $gallery_urls ) { 1183 defined( 'ABSPATH' ) || exit; 1221 if (! function_exists('multissaf_set_product_gallery') ) { 1222 function multissaf_set_product_gallery( $product_id, $gallery_urls ) 1223 { 1224 defined('ABSPATH') || exit; 1184 1225 1185 1226 if (empty($gallery_urls) || !is_array($gallery_urls)) { return; … … 1204 1245 } 1205 1246 1206 if ( ! function_exists( 'multissaf_import_image_from_url' ) ) { 1207 function multissaf_import_image_from_url( $image_url, $product_id ) { 1208 defined( 'ABSPATH' ) || exit; 1247 if (! function_exists('multissaf_import_image_from_url') ) { 1248 function multissaf_import_image_from_url( $image_url, $product_id ) 1249 { 1250 defined('ABSPATH') || exit; 1209 1251 1210 1252 if (empty($image_url) || ! filter_var($image_url, FILTER_VALIDATE_URL) ) { … … 1224 1266 } 1225 1267 1226 if ( ! function_exists( 'multissaf_handle_product_import_api' ) ) { 1227 function multissaf_handle_product_import_api( $request_body, $sheet_sync = false ) { 1228 defined( 'ABSPATH' ) || exit; 1268 if (! function_exists('multissaf_handle_product_import_api') ) { 1269 function multissaf_handle_product_import_api( $request_body, $sheet_sync = false, $create_limit = null ) 1270 { 1271 defined('ABSPATH') || exit; 1229 1272 if (!isset($request_body['ssgsw_products']) || empty($request_body['ssgsw_products'])) { 1230 1273 return [ … … 1239 1282 $refrance_shop = isset($request_body['refarance_shop']) ? $request_body['refarance_shop'] : ''; 1240 1283 // Process the import/update 1241 $results = multissaf_import_update_products($products_data, $sheet_sync, $original_ids, $refrance_shop, true );1284 $results = multissaf_import_update_products($products_data, $sheet_sync, $original_ids, $refrance_shop, true, $create_limit); 1242 1285 1243 1286 return [ -
multi-store-sync-addon-for-flexstock/trunk/includes/wppool/class-plugin.php
r3639255 r3752558 10 10 11 11 // Exit if accessed directly. 12 defined( 'ABSPATH') || exit();12 defined('ABSPATH') || exit(); 13 13 14 14 /** 15 15 * Plugin Class 16 16 */ 17 if ( ! class_exists( 'WPPOOL_Plugin' ) ) { 18 /** 19 * Handles all the WPPOOL Plugin related functionalities, promotions, etc. 20 * 21 * @version 3.0.0 22 */ 23 class WPPOOL_Plugin { 24 /** 25 * Contains instance of Plugin. 26 * 27 * @since 3.0.0 28 * @var self 29 */ 30 private static $instance = null; 31 32 /** 33 * Returns instance of Plugin. 34 * 35 * @since 3.0.0 36 * @return self 37 */ 38 public static function get_instance() { 39 if ( is_null( self::$instance ) ) { 40 self::$instance = new self(); 41 } 42 43 return self::$instance; 44 } 45 46 /** 47 * Contains sdk version. 48 * 49 * @var string 50 */ 51 public $sdk_version = '3.2.1'; 52 53 /** 54 * Contains all public plugins from WPPOOL. 55 * 56 * @var array 57 */ 58 public $plugins = [ 59 'wp_dark_mode' => [ 60 'list_id' => 20, 61 'button_link' => 'https://go.wppool.dev/LaSV', 62 'button_text' => 'Get Premium', 63 'color' => '#FF631A', 64 'demo_link' => 'https://go.wppool.dev/bjxy', 65 'demo_text' => 'Try a FREE demo', 66 ], 67 'sheets_to_wp_table_live_sync' => [ 68 'list_id' => 21, 69 'button_link' => 'https://go.wppool.dev/Rimc', 70 'button_text' => 'Get Premium', 71 'color' => '#1AD26E', 72 'demo_link' => 'https://go.wppool.dev/Yjcr', 73 'demo_text' => 'Try a FREE demo', 74 ], 75 'easy_video_reviews' => [ 76 'list_id' => 22, 77 'button_text' => 'Get Premium', 78 'color' => '#0288FD', 79 'demo_link' => 'https://go.wppool.dev/VjWZ', 80 'demo_text' => 'Try a FREE demo', 81 ], 82 'webinar_and_video_conference_with_jitsi_meet' => [ 83 'list_id' => 23, 84 'button_link' => 'https://go.wppool.dev/8iQC', 85 'button_text' => 'Get Premium', 86 'color' => '#1D5AE4', 87 'demo_link' => 'https://go.wppool.dev/ajve', 88 'demo_text' => 'Try a FREE demo', 89 ], 90 'zero_bs_accounting' => [ 91 'list_id' => 24, 92 'button_link' => 'https://go.wppool.dev/zbs', 93 'button_text' => 'Get Premium', 94 ], 95 'stock_sync_with_google_sheet_for_woocommerce' => [ 96 'list_id' => 46, 97 'button_link' => 'https://go.wppool.dev/dr8d', 98 'button_text' => 'Get Premium', 99 'color' => '#8F5CCB', 100 'demo_link' => 'https://go.wppool.dev/kjbW', 101 'demo_text' => 'Try a FREE demo', 102 ], 103 'stock_notifier_for_woocommerce' => [ 104 'list_id' => 47, 105 'button_link' => 'https://go.wppool.dev/hiE1', 106 'button_text' => 'Get Premium', 107 'color' => '#3FC250', 108 ], 109 'chat_widgets_for_multivendor_marketplaces' => [ 110 'list_id' => 26, 111 'button_link' => 'https://go.wppool.dev/EiRM', 112 'button_text' => 'Get Premium', 113 'color' => '#CC22FF', 114 ], 115 'omg_chat_widget' => [ 116 'list_id' => 26, 117 'button_link' => 'https://go.wppool.dev/EiRM', 118 'button_text' => 'Get Premium', 119 'color' => '#CC22FF', 120 ], 121 'social_contact_form' => [ 122 'list_id' => 49, 123 'button_link' => 'https://go.wppool.dev/2rc7', 124 'button_text' => 'Get Premium', 125 'color' => '#DC4FF3', 126 ], 127 'elementor_speed_optimizer' => [ 128 'list_id' => 54, 129 'button_link' => 'https://go.wppool.dev/cyVx', 130 'button_text' => 'Get Premium', 131 'color' => '#C91170', 132 ], 133 'easy_email_integration' => [ 134 'list_id' => 55, 135 'button_text' => 'Get Premium', 136 ], 137 'easy_cloudflare_trunstile' => [ 138 'list_id' => 56, 139 'button_link' => 'https://go.wppool.dev/easycloudflare', 140 'button_text' => 'Get Premium', 141 ], 142 'order_sync_with_google_sheets_for_woocommerce' => [ 143 'list_id' => 65, 144 'button_link' => 'https://lnk.wppool.dev/9uh9khK', 145 'button_text' => 'Get Premium', 146 'color' => '#6621ba', 147 'demo_link' => 'https://lnk.wppool.dev/SzdnSCP', 148 'demo_text' => 'Try a FREE demo', 149 ], 150 'echo_rewards' => [ 151 'list_id' => 70, 152 'button_link' => 'https://go.wppool.dev/Bg5L', 153 'button_text' => 'Get Premium', 154 'color' => '#6A32F8', 155 'demo_link' => 'https://go.wppool.dev/7jm2', 156 'demo_text' => 'Try a FREE demo', 157 ], 158 'multi_store_sync_addon_for_flexstock' => [ 159 'list_id' => 92, 160 'button_link' => 'https://lnk.wppool.dev/8wPJ7Qj', 161 'button_text' => 'Get Premium', 162 'color' => '#8F5CCB', 163 'demo_link' => 'https://lnk.wppool.dev/8wPJ7Qj', 164 'demo_text' => 'Try a FREE demo', 165 ], 166 ]; 167 168 /** 169 * FluentCRM Tags ID 170 * 171 * @var array 172 */ 173 public $tags = [ 174 'free' => 11, 175 'paid' => 12, 176 'pro' => 13, 177 'cancelled' => 23, 178 ]; 179 180 /** 181 * Contains CRM server endpoint. This is where we will send user data. 182 * 183 * @var string 184 */ 185 public $crm_endpoint = 'https://fluent.wppool.dev/wp-json/contact/sync'; 186 187 /** 188 * Contains CRM access token. This is used to authenticate the request. 189 * 190 * @var string 191 */ 192 public $crm_access_token = '66E6D9A59A5A948B'; 193 194 /** 195 * Contains plugin id. 196 * 197 * @var string 198 * @example wp_dark_mode 199 */ 200 public $plugin_id = 'wp_dark_mode'; 201 202 /** 203 * Temporarily stores user data. Like email, name, etc. Later it will be sent to CRM. 204 * 205 * @var array 206 */ 207 public $user_data = []; 208 209 /** 210 * Returns the plugin list. This is used to get the plugin name, list id, etc. 211 * 212 * @return array 213 */ 214 public function get_plugins() { 215 return apply_filters( 'wppool_plugins', $this->plugins ); 216 } 217 218 /** 219 * Returns current plugin. 220 * 221 * @return mixed 222 */ 223 public function get_current_plugin() { 224 $plugins = $this->get_plugins(); 225 226 return isset( $plugins[ $this->plugin_id ] ) ? $plugins[ $this->plugin_id ] : null; 227 } 228 229 /** 230 * Temporarily stores custom tags. 231 * 232 * @var array 233 */ 234 235 protected $custom_tags = []; 236 237 /** 238 * Returns the tags. 239 * 240 * @return array 241 */ 242 public function get_tags() { 243 return apply_filters( 'wppool_tags', array_merge( $this->tags, $this->custom_tags ) ); 244 } 245 /** 246 * Temporarily stores custom lists. 247 * 248 * @var array 249 */ 250 protected $custom_lists = []; 251 252 /** 253 * Returns the lists. 254 * 255 * @return array 256 */ 257 public function get_list_id() { 258 $plugin = $this->get_current_plugin(); 259 260 if ( ! $plugin ) { 261 return $this->custom_lists; 262 } 263 264 return array_merge( $this->custom_lists, [ $plugin['list_id'] ] ); 265 } 266 267 /** 268 * Constructor. 269 * 270 * @param string $product_slug The product plugin_id. 271 * @param array $user_data User data. 272 */ 273 public function __construct( 274 $product_slug = 'wp_dark_mode', 275 $user_data = [] 276 ) { 277 $this->plugin_id = $this->slugify( $product_slug ); 278 $this->user_data = is_array( $user_data ) ? $user_data : []; 279 } 280 281 /** 282 * Returns the slugged plugin_id from the plugin name or title. 283 * 284 * @param string $string The plugin_id. 285 * @return string 286 */ 287 public function slugify( $string = '' ) { 288 $string = sanitize_title( $string ); 289 $string = str_replace( '-', '_', $string ); 290 291 return $string; 292 } 293 294 /** 295 * Initialize the SDK and add hooks. 296 * 297 * @return void 298 */ 299 public static function init_plugin_sdk() { 300 $instance = new self(); 301 302 add_action( 'admin_enqueue_scripts', [ $instance, 'enqueue_scripts' ] ); 303 add_action( 'admin_footer', [ $instance, 'load_popup_template' ] ); 304 305 // Elementor support for popup. 306 add_action( 'elementor/editor/after_enqueue_scripts', [ $instance, 'enqueue_scripts' ] ); 307 add_action( 'elementor/editor/header', [ $instance, 'load_popup_template' ] ); 308 } 309 310 /** 311 * Get Default Popup Background Image. 312 * 313 * @return string 314 */ 315 public function get_image_url() { 316 return apply_filters( 'wppool_popup_image', ( file_exists( __DIR__ . '/background-image.png' ) ? plugin_dir_url( __FILE__ ) . '/background-image.png' : '' ), $this->plugin_id ); 317 } 318 319 /** 320 * Loads popup template. 321 * 322 * @return void 323 */ 324 public function load_popup_template() { 325 ?> 326 <div class="_wppool-popup" id="_wppool-popup" style="display: none;" data-plugin="wp_dark_mode" tabindex="1"> 327 <div class="_wppool-popup-overlay"></div> 328 <div class="_wppool-popup-modal"> 329 <!-- close --> 330 <div class="_wppool-popup-modal-close"> × </div> 331 <!-- content section --> 332 <div class="_wppool-popup-modal-footer"> 333 <!-- countdown --> 334 <div class="_wppool-popup-countdown" style="display: none"> 335 <span class="_wppool-popup-countdown-text"> 336 <?php echo esc_html__( 'Deal Ends In', 'multi-store-sync-addon-for-flexstock' ); ?> 337 </span> 338 <div class="_wppool-popup-countdown-time"> 339 <div> 340 <span data-counter="days"> 341 <?php echo esc_html__( '00', 'multi-store-sync-addon-for-flexstock' ); ?> 342 </span> 343 <span> 344 <?php echo esc_html__( 'Days', 'multi-store-sync-addon-for-flexstock' ); ?> 345 </span> 346 </div> 347 <span>:</span> 348 <div> 349 <span data-counter="hours"> 350 <?php echo esc_html__( '00', 'multi-store-sync-addon-for-flexstock' ); ?> 351 </span> 352 <span> 353 <?php echo esc_html__( 'Hours', 'multi-store-sync-addon-for-flexstock' ); ?> 354 </span> 355 </div> 356 <span>:</span> 357 <div> 358 <span data-counter="minutes"> 359 <?php echo esc_html__( '00', 'multi-store-sync-addon-for-flexstock' ); ?> 360 </span> 361 <span> 362 <?php echo esc_html__( 'Minutes', 'multi-store-sync-addon-for-flexstock' ); ?> 363 </span> 364 </div> 365 <span>:</span> 366 <div> 367 <span data-counter="seconds"> 368 <?php echo esc_html__( '00', 'multi-store-sync-addon-for-flexstock' ); ?> 369 </span> 370 <span> 371 <?php echo esc_html__( 'Seconds', 'multi-store-sync-addon-for-flexstock' ); ?> 372 </span> 373 </div> 374 </div> 375 </div> 376 <!-- button --> 377 <a class="_wppool-popup-button"> 378 <?php echo esc_html__( 'Upgrade to Pro', 'multi-store-sync-addon-for-flexstock' ); ?> 379 </a> 380 381 <a target="_blank" class="_wppool-popup-demo-link" href=""> 382 <?php echo esc_html__( 'Try a free demo', 'multi-store-sync-addon-for-flexstock' ); ?> 383 </a> 384 </div> 385 </div> 386 </div> 387 <?php 388 } 389 390 /** 391 * Get inline scripts. 392 * 393 * @return string 394 */ 395 public function get_inline_scripts() { 396 return '(function() { 17 if (! class_exists('WPPOOL_Plugin') ) { 18 /** 19 * Handles all the WPPOOL Plugin related functionalities, promotions, etc. 20 * 21 * @version 3.0.0 22 */ 23 class WPPOOL_Plugin 24 { 25 /** 26 * Contains instance of Plugin. 27 * 28 * @since 3.0.0 29 * @var self 30 */ 31 private static $instance = null; 32 33 /** 34 * Returns instance of Plugin. 35 * 36 * @since 3.0.0 37 * @return self 38 */ 39 public static function get_instance() 40 { 41 if (is_null(self::$instance) ) { 42 self::$instance = new self(); 43 } 44 45 return self::$instance; 46 } 47 48 /** 49 * Contains sdk version. 50 * 51 * @var string 52 */ 53 public $sdk_version = '3.2.1'; 54 55 /** 56 * Contains all public plugins from WPPOOL. 57 * 58 * @var array 59 */ 60 public $plugins = [ 61 'wp_dark_mode' => [ 62 'list_id' => 20, 63 'button_link' => 'https://go.wppool.dev/LaSV', 64 'button_text' => 'Get Premium', 65 'color' => '#FF631A', 66 'demo_link' => 'https://go.wppool.dev/bjxy', 67 'demo_text' => 'Try a FREE demo', 68 ], 69 'sheets_to_wp_table_live_sync' => [ 70 'list_id' => 21, 71 'button_link' => 'https://go.wppool.dev/Rimc', 72 'button_text' => 'Get Premium', 73 'color' => '#1AD26E', 74 'demo_link' => 'https://go.wppool.dev/Yjcr', 75 'demo_text' => 'Try a FREE demo', 76 ], 77 'easy_video_reviews' => [ 78 'list_id' => 22, 79 'button_text' => 'Get Premium', 80 'color' => '#0288FD', 81 'demo_link' => 'https://go.wppool.dev/VjWZ', 82 'demo_text' => 'Try a FREE demo', 83 ], 84 'webinar_and_video_conference_with_jitsi_meet' => [ 85 'list_id' => 23, 86 'button_link' => 'https://go.wppool.dev/8iQC', 87 'button_text' => 'Get Premium', 88 'color' => '#1D5AE4', 89 'demo_link' => 'https://go.wppool.dev/ajve', 90 'demo_text' => 'Try a FREE demo', 91 ], 92 'zero_bs_accounting' => [ 93 'list_id' => 24, 94 'button_link' => 'https://go.wppool.dev/zbs', 95 'button_text' => 'Get Premium', 96 ], 97 'stock_sync_with_google_sheet_for_woocommerce' => [ 98 'list_id' => 46, 99 'button_link' => 'https://go.wppool.dev/dr8d', 100 'button_text' => 'Get Premium', 101 'color' => '#8F5CCB', 102 'demo_link' => 'https://go.wppool.dev/kjbW', 103 'demo_text' => 'Try a FREE demo', 104 ], 105 'stock_notifier_for_woocommerce' => [ 106 'list_id' => 47, 107 'button_link' => 'https://go.wppool.dev/hiE1', 108 'button_text' => 'Get Premium', 109 'color' => '#3FC250', 110 ], 111 'chat_widgets_for_multivendor_marketplaces' => [ 112 'list_id' => 26, 113 'button_link' => 'https://go.wppool.dev/EiRM', 114 'button_text' => 'Get Premium', 115 'color' => '#CC22FF', 116 ], 117 'omg_chat_widget' => [ 118 'list_id' => 26, 119 'button_link' => 'https://go.wppool.dev/EiRM', 120 'button_text' => 'Get Premium', 121 'color' => '#CC22FF', 122 ], 123 'social_contact_form' => [ 124 'list_id' => 49, 125 'button_link' => 'https://go.wppool.dev/2rc7', 126 'button_text' => 'Get Premium', 127 'color' => '#DC4FF3', 128 ], 129 'elementor_speed_optimizer' => [ 130 'list_id' => 54, 131 'button_link' => 'https://go.wppool.dev/cyVx', 132 'button_text' => 'Get Premium', 133 'color' => '#C91170', 134 ], 135 'easy_email_integration' => [ 136 'list_id' => 55, 137 'button_text' => 'Get Premium', 138 ], 139 'easy_cloudflare_trunstile' => [ 140 'list_id' => 56, 141 'button_link' => 'https://go.wppool.dev/easycloudflare', 142 'button_text' => 'Get Premium', 143 ], 144 'order_sync_with_google_sheets_for_woocommerce' => [ 145 'list_id' => 65, 146 'button_link' => 'https://lnk.wppool.dev/9uh9khK', 147 'button_text' => 'Get Premium', 148 'color' => '#6621ba', 149 'demo_link' => 'https://lnk.wppool.dev/SzdnSCP', 150 'demo_text' => 'Try a FREE demo', 151 ], 152 'echo_rewards' => [ 153 'list_id' => 70, 154 'button_link' => 'https://go.wppool.dev/Bg5L', 155 'button_text' => 'Get Premium', 156 'color' => '#6A32F8', 157 'demo_link' => 'https://go.wppool.dev/7jm2', 158 'demo_text' => 'Try a FREE demo', 159 ], 160 'multi_store_sync_addon_for_flexstock' => [ 161 'list_id' => 92, 162 'button_link' => 'https://lnk.wppool.dev/8wPJ7Qj', 163 'button_text' => 'Get Premium', 164 'color' => '#8F5CCB', 165 'demo_link' => 'https://lnk.wppool.dev/8wPJ7Qj', 166 'demo_text' => 'Try a FREE demo', 167 ], 168 ]; 169 170 /** 171 * FluentCRM Tags ID 172 * 173 * @var array 174 */ 175 public $tags = [ 176 'free' => 11, 177 'paid' => 12, 178 'pro' => 13, 179 'cancelled' => 23, 180 ]; 181 182 /** 183 * Contains CRM server endpoint. This is where we will send user data. 184 * 185 * @var string 186 */ 187 public $crm_endpoint = 'https://fluent.wppool.dev/wp-json/contact/sync'; 188 189 /** 190 * Contains CRM access token. This is used to authenticate the request. 191 * 192 * @var string 193 */ 194 public $crm_access_token = '66E6D9A59A5A948B'; 195 196 /** 197 * Contains plugin id. 198 * 199 * @var string 200 * @example wp_dark_mode 201 */ 202 public $plugin_id = 'wp_dark_mode'; 203 204 /** 205 * Temporarily stores user data. Like email, name, etc. Later it will be sent to CRM. 206 * 207 * @var array 208 */ 209 public $user_data = []; 210 211 /** 212 * Returns the plugin list. This is used to get the plugin name, list id, etc. 213 * 214 * @return array 215 */ 216 public function get_plugins() 217 { 218 return apply_filters('wppool_plugins', $this->plugins); 219 } 220 221 /** 222 * Returns current plugin. 223 * 224 * @return mixed 225 */ 226 public function get_current_plugin() 227 { 228 $plugins = $this->get_plugins(); 229 230 return isset($plugins[ $this->plugin_id ]) ? $plugins[ $this->plugin_id ] : null; 231 } 232 233 /** 234 * Temporarily stores custom tags. 235 * 236 * @var array 237 */ 238 239 protected $custom_tags = []; 240 241 /** 242 * Returns the tags. 243 * 244 * @return array 245 */ 246 public function get_tags() 247 { 248 return apply_filters('wppool_tags', array_merge($this->tags, $this->custom_tags)); 249 } 250 /** 251 * Temporarily stores custom lists. 252 * 253 * @var array 254 */ 255 protected $custom_lists = []; 256 257 /** 258 * Returns the lists. 259 * 260 * @return array 261 */ 262 public function get_list_id() 263 { 264 $plugin = $this->get_current_plugin(); 265 266 if (! $plugin ) { 267 return $this->custom_lists; 268 } 269 270 return array_merge($this->custom_lists, [ $plugin['list_id'] ]); 271 } 272 273 /** 274 * Constructor. 275 * 276 * @param string $product_slug The product plugin_id. 277 * @param array $user_data User data. 278 */ 279 public function __construct( 280 $product_slug = 'wp_dark_mode', 281 $user_data = [] 282 ) { 283 $this->plugin_id = $this->slugify($product_slug); 284 $this->user_data = is_array($user_data) ? $user_data : []; 285 } 286 287 /** 288 * Returns the slugged plugin_id from the plugin name or title. 289 * 290 * @param string $string The plugin_id. 291 * @return string 292 */ 293 public function slugify( $string = '' ) 294 { 295 $string = sanitize_title($string); 296 $string = str_replace('-', '_', $string); 297 298 return $string; 299 } 300 301 /** 302 * Initialize the SDK and add hooks. 303 * 304 * @return void 305 */ 306 public static function init_plugin_sdk() 307 { 308 $instance = new self(); 309 310 add_action('admin_enqueue_scripts', [ $instance, 'enqueue_scripts' ]); 311 add_action('admin_footer', [ $instance, 'load_popup_template' ]); 312 313 // Elementor support for popup. 314 add_action('elementor/editor/after_enqueue_scripts', [ $instance, 'enqueue_scripts' ]); 315 add_action('elementor/editor/header', [ $instance, 'load_popup_template' ]); 316 } 317 318 /** 319 * Get Default Popup Background Image. 320 * 321 * @return string 322 */ 323 public function get_image_url() 324 { 325 return apply_filters('wppool_popup_image', ( file_exists(__DIR__ . '/background-image.png') ? plugin_dir_url(__FILE__) . '/background-image.png' : '' ), $this->plugin_id); 326 } 327 328 /** 329 * Loads popup template. 330 * 331 * @return void 332 */ 333 public function load_popup_template() 334 { 335 ?> 336 <div class="_wppool-popup" id="_wppool-popup" style="display: none;" data-plugin="wp_dark_mode" tabindex="1"> 337 <div class="_wppool-popup-overlay"></div> 338 <div class="_wppool-popup-modal"> 339 <!-- close --> 340 <div class="_wppool-popup-modal-close"> × </div> 341 <!-- content section --> 342 <div class="_wppool-popup-modal-footer"> 343 <!-- countdown --> 344 <div class="_wppool-popup-countdown" style="display: none"> 345 <span class="_wppool-popup-countdown-text"> 346 <?php echo esc_html__('Deal Ends In', 'multi-store-sync-addon-for-flexstock'); ?> 347 </span> 348 <div class="_wppool-popup-countdown-time"> 349 <div> 350 <span data-counter="days"> 351 <?php echo esc_html__('00', 'multi-store-sync-addon-for-flexstock'); ?> 352 </span> 353 <span> 354 <?php echo esc_html__('Days', 'multi-store-sync-addon-for-flexstock'); ?> 355 </span> 356 </div> 357 <span>:</span> 358 <div> 359 <span data-counter="hours"> 360 <?php echo esc_html__('00', 'multi-store-sync-addon-for-flexstock'); ?> 361 </span> 362 <span> 363 <?php echo esc_html__('Hours', 'multi-store-sync-addon-for-flexstock'); ?> 364 </span> 365 </div> 366 <span>:</span> 367 <div> 368 <span data-counter="minutes"> 369 <?php echo esc_html__('00', 'multi-store-sync-addon-for-flexstock'); ?> 370 </span> 371 <span> 372 <?php echo esc_html__('Minutes', 'multi-store-sync-addon-for-flexstock'); ?> 373 </span> 374 </div> 375 <span>:</span> 376 <div> 377 <span data-counter="seconds"> 378 <?php echo esc_html__('00', 'multi-store-sync-addon-for-flexstock'); ?> 379 </span> 380 <span> 381 <?php echo esc_html__('Seconds', 'multi-store-sync-addon-for-flexstock'); ?> 382 </span> 383 </div> 384 </div> 385 </div> 386 <!-- button --> 387 <a class="_wppool-popup-button"> 388 <?php echo esc_html__('Upgrade to Pro', 'multi-store-sync-addon-for-flexstock'); ?> 389 </a> 390 391 <a target="_blank" class="_wppool-popup-demo-link" href=""> 392 <?php echo esc_html__('Try a free demo', 'multi-store-sync-addon-for-flexstock'); ?> 393 </a> 394 </div> 395 </div> 396 </div> 397 <?php 398 } 399 400 /** 401 * Get inline scripts. 402 * 403 * @return string 404 */ 405 public function get_inline_scripts() 406 { 407 return '(function() { 397 408 398 409 if (typeof(WPPOOL) !== "undefined") { … … 626 637 627 638 // Check if the image url is valid image url online. 628 const fallback_image_url = "' . esc_url( $this->get_image_url()) . '";639 const fallback_image_url = "' . esc_url($this->get_image_url()) . '"; 629 640 630 641 if (data.background_image && data.background_image.length > 0) { … … 800 811 801 812 })(jQuery)'; 802 } 803 804 /** 805 * Get inline styles. 806 * 807 * @return string 808 */ 809 public function get_inline_styles() { 810 $css = 811 ':root { 813 } 814 815 /** 816 * Get inline styles. 817 * 818 * @return string 819 */ 820 public function get_inline_styles() 821 { 822 $css = 823 ':root { 812 824 --wppool-popup-color: #FF631A; 813 825 } … … 848 860 max-height: 600px !important; 849 861 color: white; 850 background: #222 url(' . esc_url( $this->get_image_url()) . ') no-repeat center center;862 background: #222 url(' . esc_url($this->get_image_url()) . ') no-repeat center center; 851 863 background-position: center; 852 864 background-size: cover; … … 1014 1026 '; 1015 1027 1016 return apply_filters( 'wppool_inline_styles', $css ); 1017 } 1018 1019 /** 1020 * Enqueues scripts. 1021 * 1022 * @return void 1023 */ 1024 public function enqueue_scripts() { 1025 wp_register_script( 'wppool-plugins', '', [], time(), true ); 1026 1027 // Localize script. 1028 wp_localize_script( 'wppool-plugins', 'WPPOOL_Plugins', [ 1029 'plugins' => $this->get_plugins(), 1030 'debug' => defined( 'WP_DEBUG' ) && WP_DEBUG, 1031 ] ); 1032 1033 wp_enqueue_script( 'wppool-plugins' ); 1034 1035 // Enqueue inline scripts. 1036 wp_add_inline_script( 'wppool-plugins', $this->get_inline_scripts() ); 1037 1038 // Enqueue inline styles. 1039 wp_register_style( 'wppool-plugins', '', [], time() ); 1040 wp_enqueue_style( 'wppool-plugins' ); 1041 wp_add_inline_style( 'wppool-plugins', $this->get_inline_styles() ); 1042 } 1043 1044 /** 1045 * Adds Appsero tracker integration hooks. 1046 * 1047 * @return void 1048 */ 1049 public function listen_appsero() { 1050 $hook_name = 1051 str_replace( '_', '-', $this->plugin_id ) . '_tracker_optin'; 1052 1053 add_action( 1054 $hook_name, 1055 [ $this, 'plugin_tracker_optin_callback' ], 1056 10, 1057 1 1058 ); 1059 } 1060 1061 /** 1062 * Appsero for older support 1063 * 1064 * @return void 1065 */ 1066 public function appsero() { 1067 $this->listen_appsero(); 1068 } 1069 1070 /** 1071 * Callback for Appsero tracker integration hooks. 1072 * 1073 * @param array $data The data. 1074 * @return void 1075 */ 1076 public function plugin_tracker_optin_callback( $data = [] ) { 1077 $this->user_data = [ 1078 'email' => $data['admin_email'], 1079 'first_name' => $data['first_name'], 1080 'last_name' => $data['last_name'], 1081 ]; 1082 1083 // Subscribe to CRM. 1084 $this->subscribe(); 1085 } 1086 1087 /** 1088 * Sets custom tags IDs. 1089 * set tag 1090 * 1091 * @param int|array $tag_id The tag IDs. 1092 * @return self 1093 */ 1094 public function set_tag( $tag_id = null ) { 1095 if ( $tag_id ) { 1096 $this->custom_tags = array_merge( 1097 $this->custom_tags, 1098 is_array( $tag_id ) ? $tag_id : [ $tag_id ] 1099 ); 1100 } 1101 1102 return $this; 1103 } 1104 1105 /** 1106 * Sets custom list IDs. 1107 * 1108 * @param int|array $list_id The list IDs. 1109 * @return self 1110 */ 1111 public function set_list( $list_id = null ) { 1112 if ( $list_id ) { 1113 $this->custom_lists = array_merge( 1114 $this->custom_lists, 1115 is_array( $list_id ) ? $list_id : [ $list_id ] 1116 ); 1117 } 1118 1119 return $this; 1120 } 1121 1122 /** 1123 * Removes custom tag IDs. 1124 * 1125 * @param int|array $tag_id The tag IDs. 1126 * @return self 1127 */ 1128 public function remove_tag( $tag_id = null ) { 1129 if ( $tag_id ) { 1130 $this->custom_tags = array_diff( 1131 $this->custom_tags, 1132 is_array( $tag_id ) ? $tag_id : [ $tag_id ] 1133 ); 1134 } 1135 1136 return $this; 1137 } 1138 1139 /** 1140 * Removes custom list IDs. 1141 * 1142 * @param int|array $list_id The list id. 1143 * @return self 1144 */ 1145 public function remove_list( $list_id = null ) { 1146 if ( $list_id ) { 1147 $this->custom_lists = array_diff( 1148 $this->custom_lists, 1149 is_array( $list_id ) ? $list_id : [ $list_id ] 1150 ); 1151 } 1152 1153 return $this; 1154 } 1155 1156 /** 1157 * Returns tag ID by tag name. 1158 * 1159 * @param string $tag The tag. 1160 * @return array 1161 */ 1162 public function get_tag_id( $tag = '' ) { 1163 $tags = $this->get_tags(); 1164 1165 return isset( $tags[ $tag ] ) ? $tags[ $tag ] : []; 1166 } 1167 1168 /** 1169 * Get current list 1170 * 1171 * @return array 1172 */ 1173 public function get_current_list_id() { 1174 $plugin = $this->get_current_plugin(); 1175 1176 return $plugin ? $plugin['list_id'] : []; 1177 } 1178 1179 /** 1180 * Send data to CRM. 1181 * Data WILL BE ONLY SENT IF user allowed to send data from Admin Notice. 1182 * 1183 * @param mixed $data The data to process. 1184 * @throws \Exception If email is not valid. 1185 * @return mixed 1186 */ 1187 protected function sent_to_fluent_server( $data = [] ) { 1188 // Check if email isset and email is valid. 1189 if ( ! isset( $data['email'] ) || ! is_email( $data['email'] ) ) { 1190 throw new \Exception( 'Email is not valid' ); 1191 } 1192 1193 $data = isset( $data ) && $data ? $data : $this->user_data; 1194 1195 $payload = [ 1196 'headers' => [ 1197 'Authorization' => 'Bearer ' . $this->crm_access_token, 1198 ], 1199 'body' => $data, 1200 ]; 1201 1202 $response = wp_remote_post( $this->crm_endpoint, $payload ); 1203 1204 if ( is_wp_error( $response ) ) { 1205 throw new \Exception( esc_html( $response->get_error_message() ) ); 1206 } 1207 1208 $response = json_decode( wp_remote_retrieve_body( $response ), true ); 1209 1210 return $response; 1211 } 1212 1213 /** 1214 * Subscribes to list and tag. 1215 * Data WILL BE ONLY SENT IF user allowed to send data from Admin Notice. 1216 * 1217 * @param string $tag The tag. 1218 * @return mixed 1219 */ 1220 public function subscribe( $tag = 'free' ) { 1221 $data = array_merge( $this->user_data, [ 1222 'tags' => [ $this->get_tag_id( $tag ) ], 1223 'lists' => $this->get_list_id(), 1224 ] ); 1225 1226 return $this->sent_to_fluent_server( $data ); 1227 } 1228 1229 /** 1230 * Unsubscribes from list 1231 * Data WILL BE ONLY SENT IF user allowed to send data from Admin Notice. 1232 * 1233 * @return array 1234 */ 1235 public function unsubscribe_plugin() { 1236 $data = array_merge( $this->user_data, [ 1237 'remove_lists' => $this->get_list_id(), 1238 ] ); 1239 1240 return $this->sent_to_fluent_server( $data ); 1241 } 1242 1243 /** 1244 * Unsubscribes from tag 1245 * Data WILL BE ONLY SENT IF user allowed to send data from Admin Notice. 1246 * 1247 * @param string $tag The tag name. 1248 * @return array 1249 */ 1250 public function unsubscribe_tag( $tag = 'free' ) { 1251 $data = array_merge( $this->user_data, [ 1252 'remove_tags' => $this->get_tag_id( $tag ), 1253 ] ); 1254 1255 return $this->sent_to_fluent_server( $data ); 1256 } 1257 1258 /** 1259 * Unsubscribe from the CRM 1260 * 1261 * @return array 1262 */ 1263 public function unsubscribe() { 1264 $data = array_merge( $this->user_data, [ 'status' => 'unsubscribed' ] ); 1265 1266 return $this->sent_to_fluent_server( $data ); 1267 } 1268 1269 /** 1270 * Returns the current plugin image 1271 * 1272 * @return string 1273 */ 1274 public function get_plugin_image() { 1275 return plugin_dir_url( __FILE__ ) . 'background-image.png'; 1276 } 1277 1278 /** 1279 * Init Plugin 1280 * 1281 * @param string $plugin_id The plugin id. 1282 * @param string $image_url The image url. 1283 * @return self 1284 */ 1285 public static function init( $plugin_id = 'wp_dark_mode', $image_url = null ) { 1286 $instance = new self( $plugin_id ); 1287 1288 // Add plugin image. 1289 add_filter( 'wppool_plugins', function ( $plugins ) use ( $instance, $image_url ) { 1290 $plugins[ $instance->plugin_id ]['background_image'] = isset( $image_url ) ? $image_url : $instance->get_plugin_image(); 1291 1292 return $plugins; 1293 } ); 1294 1295 // Trigger appsero. 1296 $instance->appsero(); 1297 1298 return $instance; 1299 } 1300 1301 /** 1302 * Set campaign details. 1303 * 1304 * @param string $image_url The image URL for the campaign. 1305 * @param string $to End date for the campaign. 1306 * @param string $from Start date for the campaign. 1307 * @param string $button_text The custom CTA text for the button. 1308 * @return mixed 1309 */ 1310 public function set_campaign( $image_url = null, $to = null, $from = null, $button_text = 'Get Premium', $button_link = 'https://go.wppool.dev/8aCD' ) { 1311 // Bailout if image url is not valid. 1312 if ( ! $image_url ) { 1313 return $this; 1314 } 1315 1316 // Set default times if not provided. 1317 $from_time = $from ? strtotime( $from ) : strtotime( 'now' ); 1318 $to_time = $to ? strtotime( $to ) : strtotime( '+2 weeks' ); 1319 $current_time = strtotime( 'now' ); 1320 1321 // If current time is not between from and to date, return. 1322 if ( $current_time < $from_time || $current_time > $to_time ) { 1323 return $this; 1324 } 1325 1326 // Modify the plugin data to include the campaign image, date range, and button text. 1327 add_filter( 'wppool_plugins', function ( $plugins ) use ( $image_url, $to, $from, $button_text, $button_link ) { 1328 $plugins[ $this->plugin_id ]['background_image'] = $image_url; 1329 $plugins[ $this->plugin_id ]['from'] = $from; 1330 $plugins[ $this->plugin_id ]['to'] = $to; 1331 $plugins[ $this->plugin_id ]['button_text'] = $button_text; 1332 $plugins[ $this->plugin_id ]['button_link'] = $button_link; 1333 1334 return $plugins; 1335 } ); 1336 } 1337 } 1338 1339 1340 // Bypass Appsero Local. 1341 add_filter( 'appsero_is_local', '__return_false' ); 1342 1343 // Instantiate the class after plugins loaded. 1344 add_action( 'plugins_loaded', [ '\WPPOOL_Plugin', 'init_plugin_sdk' ] ); 1345 1346 /** 1347 * If WPPOOL_Plugin function does not exists. 1348 * 1349 * @param string $plugin_id The plugin id. 1350 * @param string $image_url The image url. 1351 * @return mixed 1352 */ 1353 function wppool_plugin_init( $plugin_id = 'wp_dark_mode', $image_url = null ) { 1354 return WPPOOL_Plugin::init( $plugin_id, $image_url ); 1355 } 1028 return apply_filters('wppool_inline_styles', $css); 1029 } 1030 1031 /** 1032 * Enqueues scripts. 1033 * 1034 * @return void 1035 */ 1036 public function enqueue_scripts() 1037 { 1038 wp_register_script('wppool-plugins', '', [], time(), true); 1039 1040 // Localize script. 1041 wp_localize_script( 1042 'wppool-plugins', 'WPPOOL_Plugins', [ 1043 'plugins' => $this->get_plugins(), 1044 'debug' => defined('WP_DEBUG') && WP_DEBUG, 1045 ] 1046 ); 1047 1048 wp_enqueue_script('wppool-plugins'); 1049 1050 // Enqueue inline scripts. 1051 wp_add_inline_script('wppool-plugins', $this->get_inline_scripts()); 1052 1053 // Enqueue inline styles. 1054 wp_register_style('wppool-plugins', '', [], time()); 1055 wp_enqueue_style('wppool-plugins'); 1056 wp_add_inline_style('wppool-plugins', $this->get_inline_styles()); 1057 } 1058 1059 /** 1060 * Adds Appsero tracker integration hooks. 1061 * 1062 * @return void 1063 */ 1064 public function listen_appsero() 1065 { 1066 $hook_name = 1067 str_replace('_', '-', $this->plugin_id) . '_tracker_optin'; 1068 1069 add_action( 1070 $hook_name, 1071 [ $this, 'plugin_tracker_optin_callback' ], 1072 10, 1073 1 1074 ); 1075 } 1076 1077 /** 1078 * Appsero for older support 1079 * 1080 * @return void 1081 */ 1082 public function appsero() 1083 { 1084 $this->listen_appsero(); 1085 } 1086 1087 /** 1088 * Callback for Appsero tracker integration hooks. 1089 * 1090 * @param array $data The data. 1091 * @return void 1092 */ 1093 public function plugin_tracker_optin_callback( $data = [] ) 1094 { 1095 $this->user_data = [ 1096 'email' => $data['admin_email'], 1097 'first_name' => $data['first_name'], 1098 'last_name' => $data['last_name'], 1099 ]; 1100 1101 // Subscribe to CRM. 1102 $this->subscribe(); 1103 } 1104 1105 /** 1106 * Sets custom tags IDs. 1107 * set tag 1108 * 1109 * @param int|array $tag_id The tag IDs. 1110 * @return self 1111 */ 1112 public function set_tag( $tag_id = null ) 1113 { 1114 if ($tag_id ) { 1115 $this->custom_tags = array_merge( 1116 $this->custom_tags, 1117 is_array($tag_id) ? $tag_id : [ $tag_id ] 1118 ); 1119 } 1120 1121 return $this; 1122 } 1123 1124 /** 1125 * Sets custom list IDs. 1126 * 1127 * @param int|array $list_id The list IDs. 1128 * @return self 1129 */ 1130 public function set_list( $list_id = null ) 1131 { 1132 if ($list_id ) { 1133 $this->custom_lists = array_merge( 1134 $this->custom_lists, 1135 is_array($list_id) ? $list_id : [ $list_id ] 1136 ); 1137 } 1138 1139 return $this; 1140 } 1141 1142 /** 1143 * Removes custom tag IDs. 1144 * 1145 * @param int|array $tag_id The tag IDs. 1146 * @return self 1147 */ 1148 public function remove_tag( $tag_id = null ) 1149 { 1150 if ($tag_id ) { 1151 $this->custom_tags = array_diff( 1152 $this->custom_tags, 1153 is_array($tag_id) ? $tag_id : [ $tag_id ] 1154 ); 1155 } 1156 1157 return $this; 1158 } 1159 1160 /** 1161 * Removes custom list IDs. 1162 * 1163 * @param int|array $list_id The list id. 1164 * @return self 1165 */ 1166 public function remove_list( $list_id = null ) 1167 { 1168 if ($list_id ) { 1169 $this->custom_lists = array_diff( 1170 $this->custom_lists, 1171 is_array($list_id) ? $list_id : [ $list_id ] 1172 ); 1173 } 1174 1175 return $this; 1176 } 1177 1178 /** 1179 * Returns tag ID by tag name. 1180 * 1181 * @param string $tag The tag. 1182 * @return array 1183 */ 1184 public function get_tag_id( $tag = '' ) 1185 { 1186 $tags = $this->get_tags(); 1187 1188 return isset($tags[ $tag ]) ? $tags[ $tag ] : []; 1189 } 1190 1191 /** 1192 * Get current list 1193 * 1194 * @return array 1195 */ 1196 public function get_current_list_id() 1197 { 1198 $plugin = $this->get_current_plugin(); 1199 1200 return $plugin ? $plugin['list_id'] : []; 1201 } 1202 1203 /** 1204 * Send data to CRM. 1205 * Data WILL BE ONLY SENT IF user allowed to send data from Admin Notice. 1206 * 1207 * @param mixed $data The data to process. 1208 * @throws \Exception If email is not valid. 1209 * @return mixed 1210 */ 1211 protected function sent_to_fluent_server( $data = [] ) 1212 { 1213 // Check if email isset and email is valid. 1214 if (! isset($data['email']) || ! is_email($data['email']) ) { 1215 throw new \Exception('Email is not valid'); 1216 } 1217 1218 $data = isset($data) && $data ? $data : $this->user_data; 1219 1220 $payload = [ 1221 'headers' => [ 1222 'Authorization' => 'Bearer ' . $this->crm_access_token, 1223 ], 1224 'body' => $data, 1225 ]; 1226 1227 $response = wp_remote_post($this->crm_endpoint, $payload); 1228 1229 if (is_wp_error($response) ) { 1230 throw new \Exception(esc_html($response->get_error_message())); 1231 } 1232 1233 $response = json_decode(wp_remote_retrieve_body($response), true); 1234 1235 return $response; 1236 } 1237 1238 /** 1239 * Subscribes to list and tag. 1240 * Data WILL BE ONLY SENT IF user allowed to send data from Admin Notice. 1241 * 1242 * @param string $tag The tag. 1243 * @return mixed 1244 */ 1245 public function subscribe( $tag = 'free' ) 1246 { 1247 $data = array_merge( 1248 $this->user_data, [ 1249 'tags' => [ $this->get_tag_id($tag) ], 1250 'lists' => $this->get_list_id(), 1251 ] 1252 ); 1253 1254 return $this->sent_to_fluent_server($data); 1255 } 1256 1257 /** 1258 * Unsubscribes from list 1259 * Data WILL BE ONLY SENT IF user allowed to send data from Admin Notice. 1260 * 1261 * @return array 1262 */ 1263 public function unsubscribe_plugin() 1264 { 1265 $data = array_merge( 1266 $this->user_data, [ 1267 'remove_lists' => $this->get_list_id(), 1268 ] 1269 ); 1270 1271 return $this->sent_to_fluent_server($data); 1272 } 1273 1274 /** 1275 * Unsubscribes from tag 1276 * Data WILL BE ONLY SENT IF user allowed to send data from Admin Notice. 1277 * 1278 * @param string $tag The tag name. 1279 * @return array 1280 */ 1281 public function unsubscribe_tag( $tag = 'free' ) 1282 { 1283 $data = array_merge( 1284 $this->user_data, [ 1285 'remove_tags' => $this->get_tag_id($tag), 1286 ] 1287 ); 1288 1289 return $this->sent_to_fluent_server($data); 1290 } 1291 1292 /** 1293 * Unsubscribe from the CRM 1294 * 1295 * @return array 1296 */ 1297 public function unsubscribe() 1298 { 1299 $data = array_merge($this->user_data, [ 'status' => 'unsubscribed' ]); 1300 1301 return $this->sent_to_fluent_server($data); 1302 } 1303 1304 /** 1305 * Returns the current plugin image 1306 * 1307 * @return string 1308 */ 1309 public function get_plugin_image() 1310 { 1311 return plugin_dir_url(__FILE__) . 'background-image.png'; 1312 } 1313 1314 /** 1315 * Init Plugin 1316 * 1317 * @param string $plugin_id The plugin id. 1318 * @param string $image_url The image url. 1319 * @return self 1320 */ 1321 public static function init( $plugin_id = 'wp_dark_mode', $image_url = null ) 1322 { 1323 $instance = new self($plugin_id); 1324 1325 // Add plugin image. 1326 add_filter( 1327 'wppool_plugins', function ( $plugins ) use ( $instance, $image_url ) { 1328 $plugins[ $instance->plugin_id ]['background_image'] = isset($image_url) ? $image_url : $instance->get_plugin_image(); 1329 1330 return $plugins; 1331 } 1332 ); 1333 1334 // Trigger appsero. 1335 $instance->appsero(); 1336 1337 return $instance; 1338 } 1339 1340 /** 1341 * Set campaign details. 1342 * 1343 * @param string $image_url The image URL for the campaign. 1344 * @param string $to End date for the campaign. 1345 * @param string $from Start date for the campaign. 1346 * @param string $button_text The custom CTA text for the button. 1347 * @return mixed 1348 */ 1349 public function set_campaign( $image_url = null, $to = null, $from = null, $button_text = 'Get Premium', $button_link = 'https://go.wppool.dev/8aCD' ) 1350 { 1351 // Bailout if image url is not valid. 1352 if (! $image_url ) { 1353 return $this; 1354 } 1355 1356 // Set default times if not provided. 1357 $from_time = $from ? strtotime($from) : strtotime('now'); 1358 $to_time = $to ? strtotime($to) : strtotime('+2 weeks'); 1359 $current_time = strtotime('now'); 1360 1361 // If current time is not between from and to date, return. 1362 if ($current_time < $from_time || $current_time > $to_time ) { 1363 return $this; 1364 } 1365 1366 // Modify the plugin data to include the campaign image, date range, and button text. 1367 add_filter( 1368 'wppool_plugins', function ( $plugins ) use ( $image_url, $to, $from, $button_text, $button_link ) { 1369 $plugins[ $this->plugin_id ]['background_image'] = $image_url; 1370 $plugins[ $this->plugin_id ]['from'] = $from; 1371 $plugins[ $this->plugin_id ]['to'] = $to; 1372 $plugins[ $this->plugin_id ]['button_text'] = $button_text; 1373 $plugins[ $this->plugin_id ]['button_link'] = $button_link; 1374 1375 return $plugins; 1376 } 1377 ); 1378 } 1379 } 1380 1381 1382 // Bypass Appsero Local. 1383 add_filter('appsero_is_local', '__return_false'); 1384 1385 // Instantiate the class after plugins loaded. 1386 add_action('plugins_loaded', [ '\WPPOOL_Plugin', 'init_plugin_sdk' ]); 1387 1388 /** 1389 * If WPPOOL_Plugin function does not exists. 1390 * 1391 * @param string $plugin_id The plugin id. 1392 * @param string $image_url The image url. 1393 * @return mixed 1394 */ 1395 function wppool_plugin_init( $plugin_id = 'wp_dark_mode', $image_url = null ) 1396 { 1397 return WPPOOL_Plugin::init($plugin_id, $image_url); 1398 } 1356 1399 } -
multi-store-sync-addon-for-flexstock/trunk/multi-store-sync-addon-for-flexstock.php
r3706977 r3752558 4 4 * Plugin URI: https://wppool.dev/flexstock/addons/multi-store-sync-for-flexstock/ 5 5 * Description: Sync stock with Google Sheet for WooCommerce Multi-Store 6 * Version: 1.0.7 6 * Version: 1.1.0 7 * Requires at least: 7.0 8 * Requires PHP: 7.4 7 9 * Author: WPPOOL 8 10 * Author URI: https://www.wppool.dev/ … … 22 24 define('MULTISSAF_MULTI_STORE_PATH', plugin_dir_path(MULTISSAF_MULTI_STORE)); 23 25 define('MULTISSAF_MULTI_STORE_URL', plugins_url('', __FILE__)); 24 define('MULTISSAF_MULTI_STORE_TEMPLATES', MULTISSAF_MULTI_STORE_PATH . 'templates/'); 25 define('MULTISSAF_MULTI_STORE_VERSION', '1.0.7'); 26 define('MULTISSAF_MULTI_STORE_VERSION', '1.1.0'); 26 27 /** 27 28 * Loading base file … … 32 33 } 33 34 34 /**35 * Manipulating the plugin code WILL NOT ALLOW you to use the premium features.36 * Please download the free version of the plugin from https://wordpress.org/plugins/stock-sync-with-google-sheet-for-woocommerce/37 * Powered by WPPOOL38 * https://wppool.dev/39 */40 35 41 36 42 -
multi-store-sync-addon-for-flexstock/trunk/package.json
r3699976 r3752558 1 1 { 2 2 "name": "multi-store-sync-addon-for-flexstock", 3 "version": "1.0. 0",3 "version": "1.0.7", 4 4 "description": "Multi-Store Sync Addon for FlexStock", 5 "main": " index.js",5 "main": "src/index.js", 6 6 "scripts": { 7 "start": "npx webpack --watch", 8 "webpack": "npx webpack --mode production", 9 "sass": "npx sass ./assets/src/css/admin.scss ./assets/build/css/admin.min.css --style=compressed --watch", 10 "sass:build": "npx sass ./assets/src/css/admin.scss ./assets/build/css/admin.min.css --style=compressed", 11 "build": "npm run webpack && npm run sass:build" 7 "start": "wp-scripts start", 8 "build": "wp-scripts build", 9 "translate": "wp i18n make-pot ./ languages/multi-store-sync-addon-for-flexstock.pot --exclude=node_modules,src,build" 12 10 }, 13 11 "keywords": [], 14 "author": " sabbirsam",15 "license": " ISC",12 "author": "WPPOOL", 13 "license": "GPL-2.0-or-later", 16 14 "devDependencies": { 17 "@babel/core": "^7.17.12",18 15 "@babel/plugin-syntax-dynamic-import": "^7.8.3", 19 "@ babel/preset-env": "^7.17.10",20 "alpinejs": "^3.12.2",21 "babel-loader": "^8.2.5",22 " sass": "^1.69.5",23 " webpack": "^5.87.0",24 " webpack-cli": "^4.10.0"16 "@wordpress/scripts": "^31.5.1" 17 }, 18 "dependencies": { 19 "react": "^18.3.1", 20 "react-dom": "^18.3.1", 21 "react-router-dom": "^6.30.1" 25 22 } 26 23 } -
multi-store-sync-addon-for-flexstock/trunk/readme.txt
r3706977 r3752558 2 2 Contributors: wppool, devsabbirahmed 3 3 Tags: woocommerce multi store, google sheets, inventory management, product sync, stock management 4 Requires at least: 5.44 Requires at least: 7.0 5 5 Tested up to: 7.1 6 Requires PHP: 5.67 Stable tag: 1. 0.76 Requires PHP: 7.4 7 Stable tag: 1.1.0 8 8 License: GPLv2 or later 9 9 License URI: http://www.gnu.org/licenses/gpl-2.0.html … … 150 150 Share products using the workflow that fits your business: 151 151 * All Products 152 * Products by Category 153 * Products by Tag 152 * Products by Category (Pro) 153 * Products by Tag (Pro) 154 154 * Products by Attribute (Pro) 155 155 * Specific Products by SKU (Pro) … … 174 174 175 175 * **All Products:** Share every published product with connected WooCommerce stores. Available in Free. 176 * **Filtered Products :** Share only products that match specific conditions. Filter by **Category** or **Tag** in Free, and unlock **Attribute** filtering in Pro for more advanced product sharing.176 * **Filtered Products (Pro):** Share only products that match specific conditions, filtered by **Category**, **Tag** or **Attribute**. 177 177 178 178 * **Specific Products (Pro):** Share only selected products using their SKUs. Perfect for syncing specific catalogs, featured products, seasonal collections, or dealer-exclusive inventory. … … 193 193 * Unlimited product variations 194 194 * All Products mode 195 * Filter Products mode 195 196 === Pro === 197 198 Adds: 199 200 * Filtered Products mode 196 201 * Category Filter 197 202 * Tag Filter 198 199 === Pro === 200 201 Unlocks: 202 203 * Advanced Attribute Filter 203 * Attribute Filter 204 204 * Specific SKU Sharing 205 205 … … 302 302 303 303 The following compiled files have human-readable sources in the repository: 304 - ` assets/build/js/admin.min.js` → source in `assets/src/js/dashboard.js`305 - ` assets/build/js/setting.min.js` → source in `assets/src/js/setting.js`304 - `build/index.js` → React source in `src/` (entry: `src/index.js`) 305 - `build/index.css` → source in `src/scss/admin.scss` 306 306 307 307 Build instructions: 308 308 309 1. Run `npm start` in the root to install dependencies.309 1. Run `npm install` in the root to install dependencies. 310 310 2. Run `npm run build` to compile the JavaScript and CSS assets. 311 3. The compiled files are output to ` assets/build/`311 3. The compiled files are output to `build/` 312 312 313 313 == Frequently Asked Questions == … … 432 432 8. Synchronization status 433 433 434 = 1.1.0 = 435 * **Improvement:** Admin dashboard rebuilt with React 436 * **Fix:** Imports no longer update the wrong product when a product ID exists on both stores 437 * **Fix:** Product counts now match the products imported or synced 438 * **Fix:** Variations are included when syncing products to Google Sheets 439 434 440 = 1.0.7 = 435 441 * **Enhancement:** Activation redirect no longer affects front-end requests -
multi-store-sync-addon-for-flexstock/trunk/vendor/appsero/src/Client.php
r3699976 r3752558 89 89 90 90 /** 91 * The Object of License Class92 *93 * @var object94 */95 private $license;96 97 /**98 91 * Initialize the class 99 92 * … … 128 121 129 122 return $this->insights; 130 }131 132 /**133 * Initialize plugin/theme updater134 *135 * @return void136 */137 public function updater() {138 // do not show update notice on ajax request and rest api request139 if ( wp_doing_ajax() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {140 return;141 }142 143 // show deprecated notice144 _deprecated_function( __CLASS__ . '::updater', '2.0', '\MultiStoreSyncAddonForFlexStock\Appsero\Updater::init($client);, for more details please visit: https://appsero.com/docs/appsero-developers-guide/appsero-client/appsero-sdk-updater-changes/' );145 146 // initialize the new updater147 if ( method_exists( '\MultiStoreSyncAddonForFlexStock\Appsero\Updater', 'init' ) ) {148 \MultiStoreSyncAddonForFlexStock\Appsero\Updater::init( $this );149 }150 }151 152 /**153 * Initialize license checker154 *155 * @return Appsero\License156 */157 public function license() {158 if ( ! class_exists( __NAMESPACE__ . '\License' ) ) {159 require_once __DIR__ . '/License.php';160 }161 162 // if already instantiated, return the cached one163 if ( $this->license ) {164 return $this->license;165 }166 167 $this->license = new License( $this );168 169 return $this->license;170 123 } 171 124
Note:
See TracChangeset
for help on using the changeset viewer.