Plugin Directory

Changeset 3752558


Ignore:
Timestamp:
10/18/2026 05:06:35 AM (4 weeks ago)
Author:
wppool
Message:

Update to version 1.1.0 from GitHub

Location:
multi-store-sync-addon-for-flexstock
Files:
142 added
8 deleted
26 edited
1 copied

Legend:

Unmodified
Added
Removed
  • multi-store-sync-addon-for-flexstock/tags/1.1.0/includes/boot.php

    r3609617 r3752558  
    1717require_once MULTISSAF_MULTI_STORE_INCLUDES . 'classes/import-function.php';
    1818require_once MULTISSAF_MULTI_STORE_INCLUDES . 'classes/export-functions.php';
     19require_once MULTISSAF_MULTI_STORE_INCLUDES . 'classes/class-strings.php';
    1920// Loads app.
    2021require_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  
    2626
    2727                /**
    28                  * Is license active
     28                 * App class
    2929                 *
    30                  * @var bool
     30                 * @var App
    3131                 */
    3232                public $app;
    3333
    3434                /**
    35                  * Contains license information.
     35                 * Home URL reported by the source store in the last connection handshake.
    3636                 *
    37                  * @var mixed
    38                  */
    39                 public $license;
     37                 * @var string
     38                 */
     39                private $handshake_source_url = '';
    4040
    4141                /**
    … …  
    5656                public function add_actions() {
    5757            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']);
    5958                        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']);
    6359                        add_action('wp_ajax_multissaf_generate_token', [$this, 'multissaf_generate_token']);
    6460                        add_action('wp_ajax_multissaf_save_token_settings', [$this, 'multissaf_save_token_settings']);
    … …  
    7571            add_action('wp_ajax_multissaf_manual_import_products', [$this, 'multissaf_manual_import_products']);
    7672            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']);
    17574                }
    17675                /**
    … …  
    326225
    327226                /**
    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 request
    346                         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                 /**
    387227                 * Saves multi-store settings.
    388228                 *
    … …  
    426266                                }
    427267                        } else {
    428                                 //error_log('DEBUG: No settings found in options!');
    429268                        }
    430269           
    … …  
    507346                        wp_die();
    508347                }
    509                 /**
    510                  * Gets the sanitized request body.
    511                  * Callers must verify nonce and user capabilities.
     348        /**
     349                 * Gets the request body.
    512350                 *
    513351                 * @return object
    514352                 */
    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) [];
    529361                }
    530362        /**
    … …  
    535367                 */
    536368                public function sanitize( $array ) { //phpcs:ignore
    537                         if ( ! is_array($array) ) {
    538                                 return is_string($array) ? sanitize_text_field($array) : $array;
    539                         }
    540369                        foreach ( $array as $key => $value ) {
    541370                                if ( is_array($value) ) {
    542371                                        $array[ $key ] = $this->sanitize($value);
    543                                 } elseif ( is_string($value) ) {
     372                                } else {
    544373                                        $array[ $key ] = sanitize_text_field($value);
    545                                 } else {
    546                                         $array[ $key ] = $value;
    547374                                }
    548375                        }
    … …  
    566393                                $this->send_json(false, __('You do not have permission to do this', 'multi-store-sync-addon-for-flexstock'));
    567394                        }
    568 
    569 
    570395
    571396            $token = '';
    … …  
    621446                        }
    622447
     448            // If not found, it means it's a NEW token (from Step 1) -> Create it now
    623449                        if (!$updated) {
    624 
    625 
    626450                $new_token_data = [
    627451                    'token' => $target_token,
    … …  
    729553               
    730554                // 1. Product Scope
     555                $include_type = isset($permissions->include_type) ? $permissions->include_type : 'all';
    731556                $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                }
    733570
    734571                // 2. Fields Layer
    … …  
    762599                $message .= '</div>';
    763600            }
     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;">&#8594;</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;">&#8594;</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>';
    764638           
    765639                        $message .= '</div>'; // End Details Box
    … …  
    878752
    879753        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            }
    881760
    882761            if ( ! isset($body->nonce) || ! wp_verify_nonce($body->nonce, 'ssgsw_nonce') ) {
    883762                $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'));
    888763            }
    889764
    … …  
    955830                 */
    956831                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
    957837                        $child_access_data = multissaf_get_sub_product_count($store_url, $store_token, []);
    958838                        $total_child_products = 0;
    … …  
    974854                                        $child_permissions = $raw_permissions;
    975855                                }
    976                                 // Include all products.
    977                                 $child_permissions['include_type'] = 'all';
     856                                if ( ! isset( $child_permissions['include_type'] ) ) {
     857                                        $child_permissions['include_type'] = 'all';
     858                                }
    978859                                $allowed_field_names = isset($data_array['field_names']) ? (array)$data_array['field_names'] : [];
    979860                        } else if (is_numeric($child_access_data)) {
    … …  
    1096977                 */
    1097978                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 
    1102979                        $body = $this->get_body();
    1103980                        if ( ! isset($body->nonce) || ! wp_verify_nonce($body->nonce, 'ssgsw_nonce') ) {
    … …  
    11831060                 */
    11841061                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 
    11891062                        $body = $this->get_body();
    11901063                        if ( ! isset($body->nonce) || ! wp_verify_nonce($body->nonce, 'ssgsw_nonce') ) {
    … …  
    12551128                 */
    12561129                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                         }
    12601130
    12611131                        $body = $this->get_body();
    … …  
    12761146                }
    12771147
     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
    12781197                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                         }
    12821198
    12831199                        $body = $this->get_body();
    … …  
    12931209                        if (!$store_url || !$store_token) {
    12941210                                $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 );
    12951216                        }
    12961217
    … …  
    13051226                        ];
    13061227
     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
    13071237                        $response = multissaf_send_api_get_request($api_url, $payload, $store_token);
    13081238                       
    13091239                        if ($response && isset($response->success) && $response->success && is_array($response->data)) {
    13101240                                $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
    13121268                                if (!empty($products) && function_exists('multissaf_import_update_products')) {
    13131269                                       
     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();
    13141275                                        $import_result = multissaf_import_update_products($products, true, [],  $store_url, true);
     1276                                        $this->resume_base_live_sheet_sync( $live_sheet_handlers );
    13151277                                       
    13161278                                        $last_synced = '';
    … …  
    13201282                                        }
    13211283
    1322                                         $this->send_json(true, [
     1284                                        $this->send_json(true, array_merge( [
    13231285                                                'count' => count($products),
    13241286                                                'import_result' => $import_result,
    13251287                                                '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 ) );
    13291291                        }
    13301292                        $this->send_json(false, __('Failed to fetch products from child store', 'multi-store-sync-addon-for-flexstock'));
    … …  
    13351297                 */
    13361298                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 
    13411299                        $body = $this->get_body();
    13421300                        if ( ! isset($body->nonce) || ! wp_verify_nonce($body->nonce, 'ssgsw_nonce') ) {
    13431301                                $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 );
    13441307                        }
    13451308
    … …  
    13731336                        }
    13741337                    }
    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               
    13791339                    if (!$tab_exists && empty($tabs)) {
    13801340                        // Only block if we literally couldn't fetch ANY tabs (API error or permissions)
    … …  
    13861346            }
    13871347           
    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
    13921349                        $api_url = trailingslashit($store_url) . 'wp-json/ssgsw/v1/retrive_child';
    13931350                        $payload = [
    … …  
    13991356                                'access_token'  => $store_token,
    14001357                        ];
     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                        }
    14011362                        $response = multissaf_send_api_get_request($api_url, $payload, $store_token);
    14021363            $processed_count = 0;
    … …  
    14551416                        $new_colmun = array_search($status_label, $headers_row);
    14561417                       
    1457                         if (false !== $new_colmun) {
     1418                        if (false !== $new_colmun && function_exists('ssgsw_get_all_product_statuses')) {
    14581419                            $dropdown = ssgsw_get_all_product_statuses();
    14591420                            $status_count = count($dropdown);
    … …  
    15041465             */
    15051466             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 
    15101467            $body = $this->get_body();
    15111468            if ( ! isset($body->nonce) || ! wp_verify_nonce($body->nonce, 'ssgsw_nonce') ) {
    … …  
    15551512            }
    15561513
    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' ) ) {
    16221515                $this->send_json(false, __('Update function missing', 'multi-store-sync-addon-for-flexstock'));
    16231516            }
     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')]);
    16241588        }
    16251589
     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
    16261726        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             }
    16301727
    16311728            $body = $this->get_body();
  • multi-store-sync-addon-for-flexstock/tags/1.1.0/includes/classes/class-api.php

    r3699976 r3752558  
    3333        /**
    3434         * Current request permissions
     35         *
    3536         * @var array
    3637         */
    … …  
    6869                    'methods'               => [ 'GET', 'POST' ],
    6970                    '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' ],
    7571                    'permission_callback'   => [ $this, 'permission_callback' ],
    7672                ],
    … …  
    111107
    112108            // Check if token is in request body or query params
    113             if ( empty( $bearer ) ) {
     109            if (empty($bearer) ) {
    114110                $bearer = $request->get_param('access_token');
    115111            }
    … …  
    124120
    125121            $is_valid = false;
    126             // Check single token (legacy or main)
     122            // Check the site's own token
    127123            if (!empty($saved_token) && $bearer === $saved_token) {
    128124                $is_valid = true;
    129                 $this->current_permissions = [ 'scope' => 'all', 'fields' => [ 'all_fields' ] ];
     125                $this->current_permissions = [ 'include_type' => 'all', 'fields' => [ 'all_fields' ] ];
    130126            }
    131127
    … …  
    233229                                    $token_data['main_store'] = $request_site['url'];
    234230                                    $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']);
    235236                                    $token_updated = true;
    236237                                }
    … …  
    400401                                    if($status === 'active' && isset($request_site['url'])) {
    401402                                        $t_data['main_store'] = $request_site['url'];
     403                                        if (isset($params['max_products']) ) {
     404                                            $t_data['max_products'] = absint($params['max_products']);
     405                                        }
    402406                                    } else {
    403407                                        unset($t_data['main_store']);
    … …  
    413417                            }
    414418                        }
     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);
    415441                    }
    416442                }
    … …  
    440466            }
    441467            if ($is_successful) {
    442                 return $this->response(true, [
     468                return $this->response(
     469                    true, [
    443470                    '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                );
    446477            } else {
    447478                return $this->response(false, 'Failed to perform the requested action.');
    … …  
    458489        {
    459490            $permissions = $this->current_permissions;
    460             if ( empty( $permissions ) ) {
     491            if (empty($permissions) ) {
    461492                $token = $request->get_param('access_token');
    462493               
    463                 if ( ! empty( $token ) ) {
     494                if (! empty($token) ) {
    464495                    $child_tokens = get_option('ssgsw_child_tokens', []);
    465                     if ( is_array( $child_tokens ) ) {
     496                    if (is_array($child_tokens) ) {
    466497                        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'] : [];
    469500                                break;
    470501                            }
    … …  
    476507            if (function_exists('multissaf_get_access_data_by_token')) {
    477508                $data = multissaf_get_access_data_by_token($permissions);
     509
    478510                return $this->response(true, $data);
    479511            }
    … …  
    482514        }
    483515        /**
    484          * API Callback to merge data with a parent site.
     516         * API Callback to retrieve child site data.
    485517         *
    486518         * @param  \WP_REST_Request $request The request object.
    487519         * @return \WP_REST_Response
    488520         */
    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        {
    510523            $products_data = $request->get_json_params();
    511524
    … …  
    518531            $product_ids = isset($products_data['product_ids']) ? $products_data['product_ids'] : [];
    519532            $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'] : '';
    521534           
    522535            // 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) ) {
    526539                    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'] : [];
    529542                            break;
    530543                        }
    … …  
    533546            }
    534547
    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.
    536549            $permissions = $this->current_permissions;
    537             if ( ! empty( $permissions['permissions'] ) && is_array( $permissions['permissions'] ) ) {
     550            if (! empty($permissions['permissions']) && is_array($permissions['permissions']) ) {
    538551                $permissions = $permissions['permissions'];
    539552            }
    540553
    541             // Include all products.
    542             $permissions['include_type'] = 'all';
    543 
     554            if (! isset($permissions['include_type']) ) {
     555                $permissions['include_type'] = 'all';
     556            }
    544557
    545558
    … …  
    550563            // Support fetching by SKU
    551564            // 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) ) {
    553566                global $wpdb;
    554                 $placeholders = implode( ',', array_fill( 0, count( $product_skus ), '%s' ) );
     567                $placeholders = implode(',', array_fill(0, count($product_skus), '%s'));
    555568                // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
    556569                $product_ids = $wpdb->get_col(
    … …  
    563576                    )
    564577                );
    565                 $product_ids = array_map( 'intval', $product_ids );
     578                $product_ids = array_map('intval', $product_ids);
    566579            }
    567580           
     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
    568602            if ($import_request) {
    569603                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                    );
    571619                }
    572620            } else {
    … …  
    592640         * @return \WP_REST_Response
    593641         */
    594         public function update_single_callback( \WP_REST_Request $request ){
     642        public function update_single_callback( \WP_REST_Request $request )
     643        {
    595644           
    596645            $product_data = $request->get_json_params();
    … …  
    599648            }
    600649            $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');
    602651            $all_target_ids = [];
    603652
    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') ) {
    606655                    multissaf_sync_to_specific_store_sheet($product_data);
    607656                }
    608              } else {
     657            } else {
    609658                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);
    612663                    if (isset($results['data']['product_ids'])) {
    613664                        $all_target_ids = $results['data']['product_ids'];
    614665                    } else {
    615                         // error_log('[SSGSW DEBUG] Import Failed or returned no IDs. Results: ' . print_r($results, true));
    616666                    }
    617667                    // Trigger local cron unconditionally so scheduled sheet updates fire promptly,
    … …  
    620670                    spawn_cron();
    621671                }
    622              }
     672            }
    623673           
    624674
    625             return $this->response(true, [
     675            return $this->response(
     676                true, [
    626677                'message'    => 'Update processed.',
    627678                'target_ids' => $all_target_ids
    628             ]);
     679                ]
     680            );
    629681        }
    630682   
  • multi-store-sync-addon-for-flexstock/tags/1.1.0/includes/classes/class-app.php

    r3661535 r3752558  
    1717
    1818    /**
    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.
    2120     *
    2221     * @package MultiStoreSyncAddonforFlexStock
    … …  
    2524    {
    2625
    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         */
    3029        public $free_version = 'stock-sync-with-google-sheet-for-woocommerce/stock-sync-with-google-sheet-for-woocommerce.php';
    3130
    … …  
    4342            return false;
    4443        }
    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
    4659    }
    4760}
  • multi-store-sync-addon-for-flexstock/tags/1.1.0/includes/classes/class-hooks.php

    r3706977 r3752558  
    55 *
    66 * @package MultiStoreSyncAddonforFlexStock
    7  * @since 1.0.0
     7 * @since   1.0.0
    88 */
    99
    … …  
    1414
    1515// 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&amp;plugin=' . esc_url( $plugin_url ) . '&amp;plugin_status=all&amp;paged=1&amp;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();
     16defined('ABSPATH') || exit(1);
     17
     18if (! 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&amp;plugin=' . esc_url($plugin_url) . '&amp;plugin_status=all&amp;paged=1&amp;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();
    20132020}
  • multi-store-sync-addon-for-flexstock/tags/1.1.0/includes/classes/export-functions.php

    r3706977 r3752558  
    99 * @return array
    1010 */
    11 if ( ! function_exists( 'multissaf_get_all_columns' ) ) {
    12         function multissaf_get_all_columns() {
    13                 defined( 'ABSPATH' ) || exit;
     11if (! function_exists('multissaf_get_all_columns') ) {
     12    function multissaf_get_all_columns()
     13    {
     14        defined('ABSPATH') || exit;
    1415        $columns = [
    1516            'ID'             => [
    … …  
    101102 * @return array
    102103 */
    103 if ( ! function_exists( 'multissaf_get_column_keys' ) ) {
    104         function multissaf_get_column_keys() {
    105                 defined( 'ABSPATH' ) || exit;
     104if (! function_exists('multissaf_get_column_keys') ) {
     105    function multissaf_get_column_keys()
     106    {
     107        defined('ABSPATH') || exit;
    106108
    107109        $columns = multissaf_get_all_columns();
    … …  
    113115        );
    114116
    115         $keys = array_keys($columns);
    116 
    117         return $keys;
     117           $keys = array_keys($columns);
     118
     119           return $keys;
    118120    }
    119121}
    … …  
    123125 * @return array
    124126 */
    125 if ( ! function_exists( 'multissaf_get_queryable_columns' ) ) {
    126         function multissaf_get_queryable_columns() {
    127                 defined( 'ABSPATH' ) || exit;
     127if (! function_exists('multissaf_get_queryable_columns') ) {
     128    function multissaf_get_queryable_columns()
     129    {
     130        defined('ABSPATH') || exit;
    128131
    129132        $columns = multissaf_get_all_columns();
    … …  
    133136            }
    134137        );
    135         return $columns;
     138           return $columns;
    136139    }
    137140}
    … …  
    142145 * @return array
    143146 */
    144 if ( ! function_exists( 'multissaf_get_queryable_fields' ) ) {
    145         function multissaf_get_queryable_fields() {
    146                 defined( 'ABSPATH' ) || exit;
     147if (! function_exists('multissaf_get_queryable_fields') ) {
     148    function multissaf_get_queryable_fields()
     149    {
     150        defined('ABSPATH') || exit;
    147151
    148152        $columns = multissaf_get_queryable_columns();
    … …  
    154158        );
    155159
    156         return array_keys($columns);
     160           return array_keys($columns);
    157161    }
    158162}
    … …  
    163167 * @return array
    164168 */
    165 if ( ! function_exists( 'multissaf_multistore_get_queryable_metas' ) ) {
    166         function multissaf_multistore_get_queryable_metas() {
    167                 defined( 'ABSPATH' ) || exit;
     169if (! function_exists('multissaf_multistore_get_queryable_metas') ) {
     170    function multissaf_multistore_get_queryable_metas()
     171    {
     172        defined('ABSPATH') || exit;
    168173
    169174        $columns = multissaf_get_queryable_columns();
    … …  
    175180        );
    176181
    177         return apply_filters('ssgsw_multistore_queryable_metas', $columns);
     182           return apply_filters('ssgsw_multistore_queryable_metas', $columns);
    178183    }
    179184}
    … …  
    184189 * @return array
    185190 */
    186 if ( ! function_exists( 'multissaf_multistore_get_queryable_taxonomies' ) ) {
    187         function multissaf_multistore_get_queryable_taxonomies() {
    188                 defined( 'ABSPATH' ) || exit;
     191if (! function_exists('multissaf_multistore_get_queryable_taxonomies') ) {
     192    function multissaf_multistore_get_queryable_taxonomies()
     193    {
     194        defined('ABSPATH') || exit;
    189195
    190196        $columns = multissaf_get_queryable_columns();
    … …  
    195201        );
    196202
    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
     207if (! function_exists('multissaf_get_product_attribute_keys') ) {
     208    function multissaf_get_product_attribute_keys()
     209    {
     210        defined('ABSPATH') || exit;
    219211
    220212        global $wpdb;
    … …  
    230222        );
    231223       
    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
     229if (! 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;
    240233       
    241234        global $wpdb;
     235        $main_table = $wpdb->prefix . 'posts';
    242236
    243237        $fields = multissaf_get_queryable_fields();
    244 
    245         $main_table = $wpdb->prefix . 'posts';
    246238
    247239        $query  = 'SELECT ';
    … …  
    254246        );
    255247
    256         //From post meta table
    257         $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();
    259251        if ($meta_fields && count($meta_fields) > 0 ) {
    260252            $query .= ', ';
    … …  
    268260            if(is_array($attribute_keys) && !empty($attribute_keys)) {
    269261                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";
    280264                }
    281265            }
    … …  
    284268        }
    285269
    286         //From taxonomy table
    287         $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');
    292276            $taxonomy_fields = array_filter(
    293277                $taxonomy_fields,
    294278                function ( $field ) use ( $valid_product_taxonomies ) {
    295                     return in_array( $field, $valid_product_taxonomies, true );
     279                    return in_array($field, $valid_product_taxonomies, true);
    296280                }
    297281            );
    … …  
    308292            );
    309293
    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 != '' )";
    320306
    321307        if ($ids && count($ids) > 0 ) {
    322308            // 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'));
    326312                // 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}";
    341328        if($batch_size) {
    342329            $query .= $wpdb->prepare(' LIMIT %d OFFSET %d', $batch_size, $offset);
    343330        }
    344331   
    345         // phpcs:ignore
    346         $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.
    347334        if ($wpdb->last_error ) {
    348335            return $wpdb->last_error;
    349336        }
    350337
    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
     343if (! 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;
    359347
    360348        if (is_array($keys) && empty($keys)) {
    … …  
    382370                }
    383371               
    384                 // FIX: Fetch categories/tags/brands for ALL product types (not just variations)
     372                // Categories, tags and brands for all product types.
    385373                // Determine which post ID to use for fetching taxonomy terms
    386374                $term_source_id = $row->ID;
    … …  
    388376
    389377                // 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 ) {
    393381                        $term_source_id = $parent_id;
    394382                    }
    … …  
    396384
    397385                // 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');
    399387                $formatted_row['categories'] = [];
    400                 if ( ! is_wp_error( $categories ) && ! empty( $categories ) ) {
     388                if (! is_wp_error($categories) && ! empty($categories) ) {
    401389                    foreach ( $categories as $category ) {
    402390                        // Skip the default WooCommerce 'uncategorized' term
    403                         if ( 'uncategorized' === $category->slug ) {
     391                        if ('uncategorized' === $category->slug ) {
    404392                            continue;
    405393                        }
    … …  
    412400
    413401                // 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');
    415403                $formatted_row['tags'] = [];
    416                 if ( ! is_wp_error( $tags ) && ! empty( $tags ) ) {
     404                if (! is_wp_error($tags) && ! empty($tags) ) {
    417405                    foreach ( $tags as $tag ) {
    418406                        $formatted_row['tags'][] = [
    … …  
    424412
    425413                // 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');
    427415                $formatted_row['brands'] = [];
    428                 if ( ! is_wp_error( $brands ) && ! empty( $brands ) ) {
     416                if (! is_wp_error($brands) && ! empty($brands) ) {
    429417                    foreach ( $brands as $brand ) {
    430418                        $formatted_row['brands'][] = [
    … …  
    446434        );
    447435
    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
     440if (! 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);
    458447
    459448        $keys = multissaf_get_column_keys();
    … …  
    476465                foreach ( $keys as $key ) {
    477466                    // 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']) ) {
    479468                        continue;
    480469                    }
    … …  
    488477
    489478                // 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 ) {
    493482                        $term_source_id = $parent_id;
    494483                    }
    … …  
    496485
    497486                // 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');
    499488                $formatted_row['categories'] = [];
    500                 if ( ! is_wp_error( $categories ) && ! empty( $categories ) ) {
     489                if (! is_wp_error($categories) && ! empty($categories) ) {
    501490                    foreach ( $categories as $category ) {
    502                         if ( 'uncategorized' === $category->slug ) {
     491                        if ('uncategorized' === $category->slug ) {
    503492                            continue;
    504493                        }
    … …  
    511500
    512501                // 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');
    514503                $formatted_row['tags'] = [];
    515                 if ( ! is_wp_error( $tags ) && ! empty( $tags ) ) {
     504                if (! is_wp_error($tags) && ! empty($tags) ) {
    516505                    foreach ( $tags as $tag ) {
    517506                        $formatted_row['tags'][] = [
    … …  
    523512
    524513                // 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');
    526515                $formatted_row['brands'] = [];
    527                 if ( ! is_wp_error( $brands ) && ! empty( $brands ) ) {
     516                if (! is_wp_error($brands) && ! empty($brands) ) {
    528517                    foreach ( $brands as $brand ) {
    529518                        $formatted_row['brands'][] = [
    … …  
    535524
    536525                // 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) ) {
    538527                    $woo_meta_keys_to_skip = [
    539528                    'chosen_product_cat', '_sku', '_price', '_regular_price', '_sale_price', '_stock', '_stock_status',
    … …  
    550539        );
    551540
    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
     545if (! 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']) ) {
    561556            $formatted_data = multissaf_get_formatted_data_with_permissions($prduct_ids, 0, 0, ['product'], $permissions);
    562557        } else {
    … …  
    573568                   
    574569                    $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                    }
    575573                    if (!empty($child_ids)) {
    576574                        // Fetch and format variation data
    577                         if ( ! empty( $permissions ) && isset( $permissions['fields'] ) ) {
     575                        if (! empty($permissions) && isset($permissions['fields']) ) {
    578576                            $formatted_data_variation = multissaf_get_formatted_data_with_permissions($child_ids, 0, 0, ['product_variation'], $permissions);
    579577                        } else {
    … …  
    594592}
    595593
    596 if ( ! function_exists( 'multissaf_get_batch_products' ) ) {
    597         function multissaf_get_batch_products( $offset, $batch_size, $permissions = [] ) {
    598                 defined( 'ABSPATH' ) || exit;
     594if (! 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
     626if (! 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 ];
    599639
    600640        // Unwrap nested permissions if necessary.
    601         if ( isset( $permissions['permissions'] ) && is_array( $permissions['permissions'] ) ) {
     641        if (isset($permissions['permissions']) && is_array($permissions['permissions']) ) {
    602642            $permissions = $permissions['permissions'];
    603643        }
    604644
    605645        $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) ) {
    613653                return [];
    614654            }
    615655            $filtered_ids = [];
    616656            foreach ( $raw_filtered as $raw_product ) {
    617                 if ( isset( $raw_product->ID ) ) {
     657                if (isset($raw_product->ID) ) {
    618658                    $filtered_ids[] = $raw_product->ID;
    619659                }
    620660            }
    621661            $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');
    622664
    623665            // Since we already paginated at the raw-data level, fetch exact IDs.
    … …  
    626668        }
    627669
    628         if ( ! empty( $permissions ) && isset( $permissions['fields'] ) ) {
     670        if (! empty($permissions) && isset($permissions['fields']) ) {
    629671            $formatted_data = multissaf_get_formatted_data_with_permissions($prduct_ids, $offset, $batch_size, ['product'], $permissions);
    630672        } else {
    … …  
    633675
    634676        $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        }
    635682
    636683        if (is_array($formatted_data) && !empty($formatted_data)) {
    … …  
    642689                    // Get child IDs of the variable product
    643690                    $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);
    647694                    }
    648695                    if (!empty($child_ids)) {
     696                        $batch_info['variations_no_sku'] += multissaf_count_ids_without_sku($child_ids, 'product_variation');
    649697                        // Fetch and format variation data
    650                         if ( ! empty( $permissions ) && isset( $permissions['fields'] ) ) {
     698                        if (! empty($permissions) && isset($permissions['fields']) ) {
    651699                            $formatted_data_variation = multissaf_get_formatted_data_with_permissions($child_ids, 0, 0, ['product_variation'], $permissions);
    652700                        } else {
    … …  
    668716}
    669717
    670 if ( ! function_exists( 'multissaf_get_children_id' ) ) {
    671     function multissaf_get_children_id( $product_id ) {
    672             defined( 'ABSPATH' ) || exit;
     718if (! function_exists('multissaf_get_children_id') ) {
     719    function multissaf_get_children_id( $product_id )
     720    {
     721            defined('ABSPATH') || exit;
    673722
    674723        global $wpdb;
    … …  
    688737}
    689738
    690 if ( ! function_exists( 'multissaf_get_column_callback' ) ) {
    691     function multissaf_get_column_callback( $value, $key, $row ) {
    692        
    693         defined( 'ABSPATH' ) || exit;
     739if (! function_exists('multissaf_get_column_callback') ) {
     740    function multissaf_get_column_callback( $value, $key, $row )
     741    {
     742       
     743        defined('ABSPATH') || exit;
    694744
    695745        if ('_stock' === $key ) {
    … …  
    835885
    836886
    837 if ( ! function_exists( 'multissaf_get_product_type' ) ) {
    838         function multissaf_get_product_type( $product_type = 'simple' ) {
    839             defined( 'ABSPATH' ) || exit;
     887if (! function_exists('multissaf_get_product_type') ) {
     888    function multissaf_get_product_type( $product_type = 'simple' )
     889    {
     890        defined('ABSPATH') || exit;
    840891
    841892        $product_type = multissaf_get_first_term($product_type);
    842893        $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'),
    847898        ];
    848899
    … …  
    852903
    853904
    854 if ( ! function_exists( 'multissaf_get_first_term' ) ) {
    855         function multissaf_get_first_term( $categories = '0' ) {
    856             defined( 'ABSPATH' ) || exit;
     905if (! function_exists('multissaf_get_first_term') ) {
     906    function multissaf_get_first_term( $categories = '0' )
     907    {
     908        defined('ABSPATH') || exit;
    857909
    858910        if ($categories !== null) {//phpcs:ignore
    … …  
    867919
    868920
    869 if ( ! function_exists( 'multissaf_colleciton_of_change_key' ) ) {
    870         function multissaf_colleciton_of_change_key( $key ) {
    871             defined( 'ABSPATH' ) || exit;
     921if (! function_exists('multissaf_colleciton_of_change_key') ) {
     922    function multissaf_colleciton_of_change_key( $key )
     923    {
     924        defined('ABSPATH') || exit;
    872925
    873926        $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'
    889942        ];
    890943        return isset($change_key[$key]) ? $change_key[$key] : $key;
    … …  
    893946
    894947
    895 if ( ! function_exists( 'multissaf_get_variation_attributes_simple' ) ) {
    896         function multissaf_get_variation_attributes_simple( $variation_id ) {
    897             defined( 'ABSPATH' ) || exit;
     948if (! function_exists('multissaf_get_variation_attributes_simple') ) {
     949    function multissaf_get_variation_attributes_simple( $variation_id )
     950    {
     951        defined('ABSPATH') || exit;
    898952
    899953        global $wpdb;
  • multi-store-sync-addon-for-flexstock/tags/1.1.0/includes/classes/functions.php

    r3706977 r3752558  
    8686/**
    8787 * 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.
    9089 *
    9190 * @return array[]  Each entry: {url, token, tab_name, sheet_gid, isConnected, ...}
    … …  
    9695                $all_option = get_option( 'ssgsw_multi_store_list' );
    9796
     97
    9898                if ( ! $all_option ) {
    9999                        return [];
    … …  
    106106                );
    107107                $filtered_options = array_values( $filtered_options );
    108 
    109                 if ( empty( $filtered_options ) ) {
    110                         return [];
    111                 }
    112108
    113109                return $filtered_options;
    … …  
    168164
    169165/**
     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 */
     173if ( ! 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 */
     204if ( ! 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/**
    170228 * 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().
    172230 * Uses Column class to resolve allowed fields.
    173231 *
    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()
    176234 *
    177235 * @param int[]|null $ids
    … …  
    179237 * @param int        $batch_size
    180238 * @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).
    182240 * @return object[]|string
    183241 */
    … …  
    276334        }
    277335
    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 );
    279338
    280339        // Always exclude trashed products from token-filtered exports.
    … …  
    300359        $order           = strtoupper( $order_raw ) === 'DESC' ? 'DESC' : 'ASC';
    301360
    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.
    306362        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
    307363        $query .= " ORDER BY {$main_table}.{$order_by} {$order}, {$main_table}.ID ASC";
    … …  
    320376}
    321377
    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 permissions
    327         $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 keys
    342         $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 string
    375                     $formatted_row[ $key ] = is_array($cell_value) ? '' : $cell_value;
    376                 }
    377                 return array_values($formatted_row);
    378             }, $raw_data
    379         );
    380         return $formatted_data;
    381     }
    382 }
    383 
    384378/**
    385379 * Builds the flat sheet-row format (array of values, no keys) for a
    … …  
    399393        function multissaf_get_batch_products_by_token( $ids = null, $offset = 0, $batch_size = 0, $parmision_filds = [] ) {
    400394
    401 
    402 
     395        // When explicit variation IDs are requested, fetch their parents and
     396        // keep only those variations in the child-fetch loop below.
    403397        $requested_variation_ids = [];
    404398
    … …  
    449443            $all_raw_data[] = $parent_row;
    450444
    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)
    452446            $is_variable = false;
    453447            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 );
    459449            }
    460450
    … …  
    476466        }
    477467
    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' ];
    480470        $column_obj = class_exists('\StockSyncWithGoogleSheetForWooCommerce\Column') ? new \StockSyncWithGoogleSheetForWooCommerce\Column() : null;
    481471        $keys       = $column_obj ? $column_obj->get_column_keys() : [];
    … …  
    729719/**
    730720 * 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()
    735723 *
    736724 * @param array $data  Site data including 'url' (required), 'token', 'tab_name', etc.
    737  * @return bool True on success, false if blocked or url missing.
     725 * @return bool True on success, false if url missing.
    738726 */
    739727if ( ! function_exists( 'multissaf_save_child_site' ) ) {
    … …  
    769757
    770758                if ( ! $updated ) {
    771 
    772759                        // Add as new
    773760                        $existing_sites[] = $data;
    … …  
    796783                defined( 'ABSPATH' ) || exit;
    797784               
    798                 // Ensure isConnected is set when parent is first saved
     785                // A completed add_parent handshake is an active connection.
    799786                if ( ! isset( $data['isConnected'] ) ) {
    800                         $data['isConnected'] = true; // Completed add_parent handshake = active connection
    801                 }
    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 ) );
    819806        }
    820807}
    … …  
    822809/**
    823810 * 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' filter
    826811 *
    827812 * @return array[]  Each entry: {url, token}
    … …  
    835820                        return [];
    836821                }
    837                
    838 
    839822
    840823                return $all_option;
    … …  
    842825}
    843826
    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 }
    857827
    858828
    … …  
    860830        function multissaf_get_body() {
    861831                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) [];
    871839        }
    872840}
    … …  
    875843        function multissaf_sanitize( $array ) { //phpcs:ignore
    876844                defined( 'ABSPATH' ) || exit;
    877                 if ( ! is_array( $array ) ) {
    878                         return is_string( $array ) ? sanitize_text_field( $array ) : $array;
    879                 }
    880845                foreach ( $array as $key => $value ) {
    881846                        if ( is_array( $value ) ) {
    882847                                $array[ $key ] = multissaf_sanitize( $value );
    883                         } elseif ( is_string( $value ) ) {
     848                        } else {
    884849                                $array[ $key ] = sanitize_text_field( $value );
    885                         } else {
    886                                 $array[ $key ] = $value;
    887850                        }
    888851                }
    … …  
    992955
    993956
    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 data
    1023                                 $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 URL
    1038                                 $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 URLs
    1042                                 $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 & tags
    1046                                 $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 slug
    1064                                 $product_data['brands'] = [];
    1065                                 foreach ($brands as $brand) {
    1066                                         $product_data['brands'][] = [
    1067                                                 'name' => $brand->name,
    1068                                                 'slug' => $brand->slug,
    1069                                         ];
    1070                                 }
    1071                                 // Get attributes
    1072                                 $product_data['attributes'] = [];
    1073 
    1074                                 $product_id = $product->get_id();
    1075 
    1076                                 // First: Try to get from _product_attributes post meta
    1077                                 $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_visible
    1106                                                         ];
    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 data
    1127                                 $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 
    1174957if ( ! function_exists( 'multissaf_get_sub_product_count' ) ) {
    1175958        function multissaf_get_sub_product_count( $url, $token, $site ) {
    … …  
    12411024                        $data = multissaf_get_access_data_by_token( $matched_token_data );
    12421025                        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'] );
    12441029                        }
    12451030                }
    … …  
    12641049        function multissaf_single_product_update( $api_url, $body_data, $bearer_token ) {
    12651050
    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               
    12671056                // Ensure the token and body data are not empty.
    12681057                if ( empty( $api_url ) || empty( $bearer_token ) ) {
    … …  
    13101099                // Check for a WordPress error.
    13111100                if ( is_wp_error( $response ) ) {
    1312                         return false; // Return the WP_Error object.
     1101                        return false; // Request failed.
    13131102                }
    13141103                // Get the HTTP status code from the response.
    … …  
    13591148                // Check for a WordPress error.
    13601149                if ( is_wp_error( $response ) ) {
    1361                         return false; // Return the WP_Error object.
     1150                        return false; // Request failed.
    13621151                }
    13631152                // Get the HTTP status code from the response.
    … …  
    14091198                // Check for a WordPress error.
    14101199                if ( is_wp_error( $response ) ) {
    1411                         return false; // Return the WP_Error object.
     1200                        return false; // Request failed.
    14121201                }
    14131202                // Get the HTTP status code from the response.
    … …  
    15841373                }
    15851374
    1586                 // Optionally apply the limit.
     1375                // Allow the count to be filtered.
    15871376                if ( $product_count > 100 ) {
    15881377                        $limit = apply_filters( 'ssgsw_product_limit', 100 );
    … …  
    15931382
    15941383                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_ids
    1618                         )
    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;
    16261384        }
    16271385}
    … …  
    16601418                }
    16611419                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 */
     1431if ( ! 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 */
     1463if ( ! 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 */
     1486if ( ! 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 */
     1509if ( ! 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 */
     1542if ( ! 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 */
     1571if ( ! 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 );
    16621604        }
    16631605}
    … …  
    16911633                }
    16921634
    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' );
    16971640
    16981641                $sql   = "SELECT COUNT(DISTINCT p.ID) FROM {$wpdb->posts} p $join $where";
    … …  
    17061649
    17071650                // 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' ];
    17091652                $field_names = [];
    17101653
    … …  
    17261669                }
    17271670
    1728 
    17291671                return [
    17301672                        'count'          => $count,
    … …  
    17321674                        'permissions'    => $permissions,
    17331675                        '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' => [],
    17871677                ];
    17881678        }
    … …  
    19951885        // Filter to only connected sites
    19961886        $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.
    20001888            $is_connected = isset($site['isConnected']) ? $site['isConnected'] : true;
    20011889           
  • multi-store-sync-addon-for-flexstock/tags/1.1.0/includes/classes/import-function.php

    r3624473 r3752558  
    44
    55/**
    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.
    97 *
    108 * Connected from: API::update_single_callback(),
    11  *                 API::marge_with_parent_callback(),
    129 *                 MULTISSAF_AJAX::multissaf_manual_import_products()
    1310 *
    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,
    1512 *                               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).
    2018 * @return array {success, created_count, updated_count, failed_count, messages, details}
    2119 */
    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;
     20if (! 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
     42if (! 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;
    2546
    2647        if (empty($products_data) || !is_array($products_data)) {
    … …  
    4364
    4465        // 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);
    5071        $sku_map        = [];
    5172
    52         if ( ! empty( $skus_to_lookup ) ) {
     73        if (! empty($skus_to_lookup) ) {
    5374            global $wpdb;
    54             $placeholders = implode( ',', array_fill( 0, count( $skus_to_lookup ), '%s' ) );
     75            $placeholders = implode(',', array_fill(0, count($skus_to_lookup), '%s'));
    5576            // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
    5677            $rows = $wpdb->get_results(
    … …  
    79100                }
    80101
    81                 $existing_product_id = isset( $sku_map[ $sku ] ) ? $sku_map[ $sku ] : 0;
     102                $existing_product_id = isset($sku_map[ $sku ]) ? $sku_map[ $sku ] : 0;
    82103
    83104                // 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']) ) {
    85106                    $old_sku = $product_data['custom_meta']['_ssgsw_old_sku_temp'];
    86                     if ( is_array( $old_sku ) ) {
     107                    if (is_array($old_sku) ) {
    87108                        $old_sku = $old_sku[0];
    88109                    }
    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;
    92118                }
    93119
    94120                // 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']) ) {
    96122                    $remote_meta_key = '_ssgsw_remote_id_' . md5($refrance_shop);
    97123                    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 ) {
    104134                        $existing_product_id = (int) $found_id;
    105135                    }
    106136                }
    107137
    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);
    121145                        }
    122146                        $product_ids[] = $existing_product_id;
    … …  
    134158               
    135159                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);
    138162                    }
    139163                    // Update existing product
    … …  
    141165                   
    142166                    if ($result['success']) {
    143                         if ( !empty($refrance_shop) && !empty($product_data['id']) ) {
     167                        if (!empty($refrance_shop) && !empty($product_data['id']) ) {
    144168                            update_post_meta($existing_product_id, '_ssgsw_remote_id_' . md5($refrance_shop), absint($product_data['id']));
    145169                        }
    … …  
    161185                } else {
    162186                    $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";
    164192                        continue;
    165193                    }
    … …  
    167195                    $result = multissaf_create_new_product($product_data);
    168196                    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']) ) {
    170201                            update_post_meta($result['product_id'], '_ssgsw_remote_id_' . md5($refrance_shop), absint($product_data['id']));
    171202                        }
    … …  
    199230       
    200231        // 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);
    203234
    204235        return $results;
    … …  
    207238
    208239
    209 if ( ! function_exists( 'multissaf_update_existing_product' ) ) {
    210         function multissaf_update_existing_product( $product_id, $product_data ) {
    211                 defined( 'ABSPATH' ) || exit;
     240if (! function_exists('multissaf_update_existing_product') ) {
     241    function multissaf_update_existing_product( $product_id, $product_data )
     242    {
     243        defined('ABSPATH') || exit;
    212244
    213245        try {
    … …  
    239271            if (isset($product_data['regular_price'])) {
    240272                $new_regular_price = floatval($product_data['regular_price']);
    241                 // $current_regular_price = floatval($product->get_regular_price());
    242273                $current_regular_price = floatval(get_post_meta($product_id, '_regular_price', true));
    243274                if (abs($new_regular_price - $current_regular_price) > 0.0001) {
    … …  
    261292            $stock_status_provided = isset($product_data['stock_status']);
    262293
    263             if ( $stock_quantity_provided || $stock_status_provided ) {
     294            if ($stock_quantity_provided || $stock_status_provided ) {
    264295                $current_quantity = $product->get_stock_quantity();
    265296                $current_status = $product->get_stock_status();
    … …  
    270301
    271302                // 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']) ) {
    273304                    $new_quantity = absint($product_data['stock_quantity']);
    274305
    275                     if ( $new_quantity > 0 ) {
     306                    if ($new_quantity > 0 ) {
    276307                        $new_status = 'instock';
    277308                    } else {
    278309                        // Check backorders
    279310                        $backorders = $product->get_backorders();
    280                         if ( $backorders && $backorders !== 'no' ) {
     311                        if ($backorders && $backorders !== 'no' ) {
    281312                            $new_status = 'onbackorder';
    282313                        } else {
    … …  
    286317                }
    287318                // 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']) ) {
    289320                    // 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 parentheses
    292 
    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' ) {
    294325                        $new_quantity = 0;
    295                     } else if ( $stock_normalized === 'instock' ) {
     326                    } else if ($stock_normalized === 'instock' ) {
    296327                        // 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' ) {
    299330                        $new_quantity = 0;
    300331                    }
    … …  
    302333
    303334                // 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);
    307338
    308339                $updated_fields[] = 'stock_quantity';
    … …  
    317348
    318349            // 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'] ) {
    320351                $new_sku     = $product_data['sku'];
    321352                $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);
    326357                        $updated_fields[] = 'sku';
    327358                    }
    328359                }
    329360            }
    330 
    331             /* if (!empty($updated_fields)) {
    332                 $product->save();
    333             } */
    334361
    335362            if (!empty($updated_fields)) {
    … …  
    339366   
    340367                    // 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'] ) {
    342369                        wp_schedule_single_event(
    343370                            time() + 3,
    … …  
    347374                    }
    348375                } 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' => [] ];
    350378                }
    351379            }
    … …  
    354382            if (isset($product_data['custom_meta']) && is_array($product_data['custom_meta'])) {
    355383                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_') ) {
    358387                        continue;
    359388                    }
    … …  
    367396                        }
    368397                        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);
    370399                            $updated_fields[] = "meta: $meta_key";
    371400                        }
    … …  
    459488                    $variation_attrs = [];
    460489                    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'] : '';
    463492                        // WC variations store ONE selected value per attribute, keyed by taxonomy
    464                         if ( ! empty( $slug ) ) {
     493                        if (! empty($slug) ) {
    465494                            $variation_attrs[ $slug ] = $value;
    466495                        }
    467496                    }
    468                     $product->set_attributes( $variation_attrs );
     497                    $product->set_attributes($variation_attrs);
    469498                    $updated_fields[] = 'attributes';
    470499                } else {
    … …  
    524553 * @return array Result with success status, message and product_id
    525554 */
    526 if ( ! function_exists( 'multissaf_create_new_product' ) ) {
    527         function multissaf_create_new_product( $product_data ) {
    528                 defined( 'ABSPATH' ) || exit;
     555if (! function_exists('multissaf_create_new_product') ) {
     556    function multissaf_create_new_product( $product_data )
     557    {
     558        defined('ABSPATH') || exit;
    529559        try {
    530560            $product_type = isset($product_data['type']) ? $product_data['type'] : 'simple';
    … …  
    566596            $stock_status_provided = isset($product_data['stock_status']);
    567597
    568             if ( $stock_quantity_provided || $stock_status_provided ) {
     598            if ($stock_quantity_provided || $stock_status_provided ) {
    569599                $new_quantity = 0;
    570600                $new_status = 'instock';
    571601
    572602                // 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']) ) {
    574604                    $new_quantity = absint($product_data['stock_quantity']);
    575605
    576                     if ( $new_quantity > 0 ) {
     606                    if ($new_quantity > 0 ) {
    577607                        $new_status = 'instock';
    578608                    } else {
    … …  
    581611                }
    582612                // 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);
    586616
    587617                    $valid_statuses = ['instock', 'outofstock', 'onbackorder'];
    588                     if ( in_array( $stock_normalized, $valid_statuses, true ) ) {
     618                    if (in_array($stock_normalized, $valid_statuses, true) ) {
    589619                        $new_status = $stock_normalized;
    590                         if ( $stock_normalized === 'outofstock' || $stock_normalized === 'onbackorder' ) {
     620                        if ($stock_normalized === 'outofstock' || $stock_normalized === 'onbackorder' ) {
    591621                            $new_quantity = 0;
    592622                        } else {
    … …  
    597627
    598628                // 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);
    602632            }
    603633
    … …  
    620650            if (isset($product_data['custom_meta']) && is_array($product_data['custom_meta'])) {
    621651                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_') ) {
    624655                        continue;
    625656                    }
    … …  
    630661                        $current_meta = get_post_meta($product_id, $meta_key, true);
    631662                        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);
    633664               
    634665                        }
    … …  
    733764 * @return array List of updated variations
    734765 */
    735 if ( ! function_exists( 'multissaf_create_update_product_variations' ) ) {
    736         function multissaf_create_update_product_variations( $product_id, $variations_data ) {
    737                 defined( 'ABSPATH' ) || exit;
     766if (! function_exists('multissaf_create_update_product_variations') ) {
     767    function multissaf_create_update_product_variations( $product_id, $variations_data )
     768    {
     769        defined('ABSPATH') || exit;
    738770        $updated_variations = [];
    739771        foreach ($variations_data as $variation_data) {
    … …  
    751783                } else {
    752784                    $store_role = get_option('ssgsw_multi_connect_mode_on');
    753                     if ( 'child' === $store_role ) {
     785                    if ('child' === $store_role ) {
    754786                        continue;
    755787                    }
    … …  
    767799}
    768800
    769 if ( ! function_exists( 'multissaf_create_product_variations' ) ) {
    770         function multissaf_create_product_variations( $product_id, $variation_data ) {
    771                 defined( 'ABSPATH' ) || exit;
     801if (! function_exists('multissaf_create_product_variations') ) {
     802    function multissaf_create_product_variations( $product_id, $variation_data )
     803    {
     804        defined('ABSPATH') || exit;
    772805
    773806        $variation = new WC_Product_Variation();
    … …  
    815848                    $current_meta = get_post_meta($variation_id, $meta_key, true);
    816849                    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);
    818851                    }
    819852                }
    … …  
    827860}
    828861
    829 if ( ! function_exists( 'multissaf_update_variation' ) ) {
    830         function multissaf_update_variation( $variation, $variation_data ) {
    831                 defined( 'ABSPATH' ) || exit;
     862if (! function_exists('multissaf_update_variation') ) {
     863    function multissaf_update_variation( $variation, $variation_data )
     864    {
     865        defined('ABSPATH') || exit;
    832866
    833867        $updated_fields = [];
    … …  
    879913
    880914            // 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') ) {
    883917                    $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());
    885919                }
    886920            }
    … …  
    899933                    }
    900934                    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);
    902936                        $updated_fields[] = "meta: $meta_key";
    903937                    }
    … …  
    912946}
    913947
    914 if ( ! function_exists( 'multissaf_manage_product_attributes' ) ) {
    915         function multissaf_manage_product_attributes( WC_Product $product, array $attributes_data ) {
    916                 defined( 'ABSPATH' ) || exit;
     948if (! function_exists('multissaf_manage_product_attributes') ) {
     949    function multissaf_manage_product_attributes( WC_Product $product, array $attributes_data )
     950    {
     951        defined('ABSPATH') || exit;
    917952        if (!$product || empty($attributes_data)) {
    918953            return false;
    … …  
    9661001                    $term = get_term_by('slug', $term_slug, $slug);
    9671002                   
    968                     // Fallback to name lookup for backward compatibility
     1003                    // Fall back to a name lookup for terms whose slug differs from the name
    9691004                    if (!$term) {
    9701005                        $term = get_term_by('name', $term_name, $slug);
    … …  
    10111046           
    10121047            // 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') ) {
    10141049                try {
    10151050                    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());
    10181053                } catch ( \Exception $e ) {
    10191054                    error_log('[MultiStore] Failed to rebuild lookup data after save: ' . $e->getMessage());
    … …  
    10281063}
    10291064
    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;
     1065if (! 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;
    10351071        if (empty($image_url) || ! filter_var($image_url, FILTER_VALIDATE_URL) ) {
    10361072            return false;
    … …  
    10591095}
    10601096
    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;
     1097if (! 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;
    10641101
    10651102        $image_data = function_exists('ssgsw_safe_remote_get_image') ? ssgsw_safe_remote_get_image($image_url) : wp_remote_get($image_url);
    … …  
    10981135}
    10991136
    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;
     1137if (! 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;
    11031141        $args = array(
    11041142            'post_type'      => 'attachment',
    … …  
    11211159}
    11221160
    1123 if ( ! function_exists( 'multissaf_get_attachment_id_by_url' ) ) {
    1124         function multissaf_get_attachment_id_by_url( $image_url ) {
    1125                 defined( 'ABSPATH' ) || exit;
     1161if (! function_exists('multissaf_get_attachment_id_by_url') ) {
     1162    function multissaf_get_attachment_id_by_url( $image_url )
     1163    {
     1164        defined('ABSPATH') || exit;
    11261165        $attachment_id = attachment_url_to_postid($image_url);
    11271166        return $attachment_id;
    … …  
    11291168}
    11301169
    1131 if ( ! function_exists( 'multissaf_update_product_gallery' ) ) {
    1132         function multissaf_update_product_gallery( $product, $gallery_urls ) {
    1133                 defined( 'ABSPATH' ) || exit;
     1170if (! function_exists('multissaf_update_product_gallery') ) {
     1171    function multissaf_update_product_gallery( $product, $gallery_urls )
     1172    {
     1173        defined('ABSPATH') || exit;
    11341174        $product_id = $product->get_id();
    11351175        if (!$product) {
    … …  
    11671207                $product->save();
    11681208            } 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());
    11701210                return false;
    11711211            }
    … …  
    11791219
    11801220
    1181 if ( ! function_exists( 'multissaf_set_product_gallery' ) ) {
    1182         function multissaf_set_product_gallery( $product_id, $gallery_urls ) {
    1183                 defined( 'ABSPATH' ) || exit;
     1221if (! function_exists('multissaf_set_product_gallery') ) {
     1222    function multissaf_set_product_gallery( $product_id, $gallery_urls )
     1223    {
     1224        defined('ABSPATH') || exit;
    11841225
    11851226        if (empty($gallery_urls) || !is_array($gallery_urls)) { return;
    … …  
    12041245}
    12051246
    1206 if ( ! function_exists( 'multissaf_import_image_from_url' ) ) {
    1207         function multissaf_import_image_from_url( $image_url, $product_id ) {
    1208                 defined( 'ABSPATH' ) || exit;
     1247if (! function_exists('multissaf_import_image_from_url') ) {
     1248    function multissaf_import_image_from_url( $image_url, $product_id )
     1249    {
     1250        defined('ABSPATH') || exit;
    12091251
    12101252        if (empty($image_url) || ! filter_var($image_url, FILTER_VALIDATE_URL) ) {
    … …  
    12241266}
    12251267
    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;
     1268if (! 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;
    12291272        if (!isset($request_body['ssgsw_products']) || empty($request_body['ssgsw_products'])) {
    12301273            return [
    … …  
    12391282        $refrance_shop = isset($request_body['refarance_shop']) ? $request_body['refarance_shop'] : '';
    12401283        // 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);
    12421285       
    12431286        return [
  • multi-store-sync-addon-for-flexstock/tags/1.1.0/includes/wppool/class-plugin.php

    r3639255 r3752558  
    1010
    1111// Exit if accessed directly.
    12 defined( 'ABSPATH' ) || exit();
     12defined('ABSPATH') || exit();
    1313
    1414/**
    1515 * Plugin Class
    1616 */
    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"> &times; </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() {
     17if (! 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"> &times; </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() {
    397408
    398409                                if (typeof(WPPOOL) !== "undefined") {
    … …  
    626637               
    627638                                                        // 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()) . '";
    629640               
    630641                                                        if (data.background_image && data.background_image.length > 0) {
    … …  
    800811               
    801812                        })(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 {
    812824                --wppool-popup-color: #FF631A;
    813825            }
    … …  
    848860                max-height: 600px !important;
    849861                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;
    851863                background-position: center;
    852864                background-size: cover;
    … …  
    10141026            ';
    10151027
    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    }
    13561399}
  • multi-store-sync-addon-for-flexstock/tags/1.1.0/multi-store-sync-addon-for-flexstock.php

    r3706977 r3752558  
    44 * Plugin URI:              https://wppool.dev/flexstock/addons/multi-store-sync-for-flexstock/
    55 * 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
    79 * Author:                  WPPOOL
    810 * Author URI:              https://www.wppool.dev/
    … …  
    2224define('MULTISSAF_MULTI_STORE_PATH', plugin_dir_path(MULTISSAF_MULTI_STORE));
    2325define('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');
     26define('MULTISSAF_MULTI_STORE_VERSION', '1.1.0');
    2627/**
    2728 * Loading base file
    … …  
    3233}
    3334
    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 WPPOOL
    38  * https://wppool.dev/
    39  */
    4035
    4136
    42 
  • multi-store-sync-addon-for-flexstock/tags/1.1.0/package.json

    r3699976 r3752558  
    11{
    22  "name": "multi-store-sync-addon-for-flexstock",
    3   "version": "1.0.0",
     3  "version": "1.0.7",
    44  "description": "Multi-Store Sync Addon for FlexStock",
    5   "main": "index.js",
     5  "main": "src/index.js",
    66  "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"
    1210  },
    1311  "keywords": [],
    14   "author": "sabbirsam",
    15   "license": "ISC",
     12  "author": "WPPOOL",
     13  "license": "GPL-2.0-or-later",
    1614  "devDependencies": {
    17     "@babel/core": "^7.17.12",
    1815    "@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"
    2522  }
    2623}
  • multi-store-sync-addon-for-flexstock/tags/1.1.0/readme.txt

    r3706977 r3752558  
    22Contributors: wppool, devsabbirahmed
    33Tags: woocommerce multi store, google sheets, inventory management, product sync, stock management
    4 Requires at least: 5.4
     4Requires at least: 7.0
    55Tested up to: 7.1
    6 Requires PHP: 5.6
    7 Stable tag: 1.0.7
     6Requires PHP: 7.4
     7Stable tag: 1.1.0
    88License: GPLv2 or later
    99License URI: http://www.gnu.org/licenses/gpl-2.0.html
    … …  
    150150Share products using the workflow that fits your business:
    151151  * All Products
    152   * Products by Category
    153   * Products by Tag
     152  * Products by Category (Pro)
     153  * Products by Tag (Pro)
    154154  * Products by Attribute (Pro)
    155155  * Specific Products by SKU (Pro)
    … …  
    174174
    175175  * **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**.
    177177
    178178  * **Specific Products (Pro):** Share only selected products using their SKUs. Perfect for syncing specific catalogs, featured products, seasonal collections, or dealer-exclusive inventory.
    … …  
    193193* Unlimited product variations
    194194* All Products mode
    195 * Filter Products mode
     195
     196=== Pro ===
     197
     198Adds:
     199
     200* Filtered Products mode
    196201* Category Filter
    197202* Tag Filter
    198 
    199 === Pro ===
    200 
    201 Unlocks:
    202 
    203 * Advanced Attribute Filter
     203* Attribute Filter
    204204* Specific SKU Sharing
    205205
    … …  
    302302
    303303The 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`
    306306
    307307Build instructions:
    308308
    309 1. Run `npm start` in the root to install dependencies.
     3091. Run `npm install` in the root to install dependencies.
    3103102. Run `npm run build` to compile the JavaScript and CSS assets.
    311 3. The compiled files are output to `assets/build/`
     3113. The compiled files are output to `build/`
    312312
    313313== Frequently Asked Questions ==
    … …  
    4324328. Synchronization status
    433433
     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
    434440= 1.0.7 =
    435441* **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  
    8989
    9090    /**
    91      * The Object of License Class
    92      *
    93      * @var object
    94      */
    95     private $license;
    96 
    97     /**
    9891     * Initialize the class
    9992     *
    … …  
    128121
    129122        return $this->insights;
    130     }
    131 
    132     /**
    133      * Initialize plugin/theme updater
    134      *
    135      * @return void
    136      */
    137     public function updater() {
    138         // do not show update notice on ajax request and rest api request
    139         if ( wp_doing_ajax() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
    140             return;
    141         }
    142 
    143         // show deprecated notice
    144         _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 updater
    147         if ( method_exists( '\MultiStoreSyncAddonForFlexStock\Appsero\Updater', 'init' ) ) {
    148             \MultiStoreSyncAddonForFlexStock\Appsero\Updater::init( $this );
    149         }
    150     }
    151 
    152     /**
    153      * Initialize license checker
    154      *
    155      * @return Appsero\License
    156      */
    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 one
    163         if ( $this->license ) {
    164             return $this->license;
    165         }
    166 
    167         $this->license = new License( $this );
    168 
    169         return $this->license;
    170123    }
    171124
  • multi-store-sync-addon-for-flexstock/trunk/includes/boot.php

    r3609617 r3752558  
    1717require_once MULTISSAF_MULTI_STORE_INCLUDES . 'classes/import-function.php';
    1818require_once MULTISSAF_MULTI_STORE_INCLUDES . 'classes/export-functions.php';
     19require_once MULTISSAF_MULTI_STORE_INCLUDES . 'classes/class-strings.php';
    1920// Loads app.
    2021require_once MULTISSAF_MULTI_STORE_INCLUDES . 'classes/class-app.php';
  • multi-store-sync-addon-for-flexstock/trunk/includes/classes/class-ajax.php

    r3706977 r3752558  
    2626
    2727                /**
    28                  * Is license active
     28                 * App class
    2929                 *
    30                  * @var bool
     30                 * @var App
    3131                 */
    3232                public $app;
    3333
    3434                /**
    35                  * Contains license information.
     35                 * Home URL reported by the source store in the last connection handshake.
    3636                 *
    37                  * @var mixed
    38                  */
    39                 public $license;
     37                 * @var string
     38                 */
     39                private $handshake_source_url = '';
    4040
    4141                /**
    … …  
    5656                public function add_actions() {
    5757            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']);
    5958                        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']);
    6359                        add_action('wp_ajax_multissaf_generate_token', [$this, 'multissaf_generate_token']);
    6460                        add_action('wp_ajax_multissaf_save_token_settings', [$this, 'multissaf_save_token_settings']);
    … …  
    7571            add_action('wp_ajax_multissaf_manual_import_products', [$this, 'multissaf_manual_import_products']);
    7672            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']);
    17574                }
    17675                /**
    … …  
    326225
    327226                /**
    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 request
    346                         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                 /**
    387227                 * Saves multi-store settings.
    388228                 *
    … …  
    426266                                }
    427267                        } else {
    428                                 //error_log('DEBUG: No settings found in options!');
    429268                        }
    430269           
    … …  
    507346                        wp_die();
    508347                }
    509                 /**
    510                  * Gets the sanitized request body.
    511                  * Callers must verify nonce and user capabilities.
     348        /**
     349                 * Gets the request body.
    512350                 *
    513351                 * @return object
    514352                 */
    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) [];
    529361                }
    530362        /**
    … …  
    535367                 */
    536368                public function sanitize( $array ) { //phpcs:ignore
    537                         if ( ! is_array($array) ) {
    538                                 return is_string($array) ? sanitize_text_field($array) : $array;
    539                         }
    540369                        foreach ( $array as $key => $value ) {
    541370                                if ( is_array($value) ) {
    542371                                        $array[ $key ] = $this->sanitize($value);
    543                                 } elseif ( is_string($value) ) {
     372                                } else {
    544373                                        $array[ $key ] = sanitize_text_field($value);
    545                                 } else {
    546                                         $array[ $key ] = $value;
    547374                                }
    548375                        }
    … …  
    566393                                $this->send_json(false, __('You do not have permission to do this', 'multi-store-sync-addon-for-flexstock'));
    567394                        }
    568 
    569 
    570395
    571396            $token = '';
    … …  
    621446                        }
    622447
     448            // If not found, it means it's a NEW token (from Step 1) -> Create it now
    623449                        if (!$updated) {
    624 
    625 
    626450                $new_token_data = [
    627451                    'token' => $target_token,
    … …  
    729553               
    730554                // 1. Product Scope
     555                $include_type = isset($permissions->include_type) ? $permissions->include_type : 'all';
    731556                $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                }
    733570
    734571                // 2. Fields Layer
    … …  
    762599                $message .= '</div>';
    763600            }
     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;">&#8594;</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;">&#8594;</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>';
    764638           
    765639                        $message .= '</div>'; // End Details Box
    … …  
    878752
    879753        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            }
    881760
    882761            if ( ! isset($body->nonce) || ! wp_verify_nonce($body->nonce, 'ssgsw_nonce') ) {
    883762                $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'));
    888763            }
    889764
    … …  
    955830                 */
    956831                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
    957837                        $child_access_data = multissaf_get_sub_product_count($store_url, $store_token, []);
    958838                        $total_child_products = 0;
    … …  
    974854                                        $child_permissions = $raw_permissions;
    975855                                }
    976                                 // Include all products.
    977                                 $child_permissions['include_type'] = 'all';
     856                                if ( ! isset( $child_permissions['include_type'] ) ) {
     857                                        $child_permissions['include_type'] = 'all';
     858                                }
    978859                                $allowed_field_names = isset($data_array['field_names']) ? (array)$data_array['field_names'] : [];
    979860                        } else if (is_numeric($child_access_data)) {
    … …  
    1096977                 */
    1097978                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 
    1102979                        $body = $this->get_body();
    1103980                        if ( ! isset($body->nonce) || ! wp_verify_nonce($body->nonce, 'ssgsw_nonce') ) {
    … …  
    11831060                 */
    11841061                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 
    11891062                        $body = $this->get_body();
    11901063                        if ( ! isset($body->nonce) || ! wp_verify_nonce($body->nonce, 'ssgsw_nonce') ) {
    … …  
    12551128                 */
    12561129                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                         }
    12601130
    12611131                        $body = $this->get_body();
    … …  
    12761146                }
    12771147
     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
    12781197                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                         }
    12821198
    12831199                        $body = $this->get_body();
    … …  
    12931209                        if (!$store_url || !$store_token) {
    12941210                                $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 );
    12951216                        }
    12961217
    … …  
    13051226                        ];
    13061227
     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
    13071237                        $response = multissaf_send_api_get_request($api_url, $payload, $store_token);
    13081238                       
    13091239                        if ($response && isset($response->success) && $response->success && is_array($response->data)) {
    13101240                                $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
    13121268                                if (!empty($products) && function_exists('multissaf_import_update_products')) {
    13131269                                       
     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();
    13141275                                        $import_result = multissaf_import_update_products($products, true, [],  $store_url, true);
     1276                                        $this->resume_base_live_sheet_sync( $live_sheet_handlers );
    13151277                                       
    13161278                                        $last_synced = '';
    … …  
    13201282                                        }
    13211283
    1322                                         $this->send_json(true, [
     1284                                        $this->send_json(true, array_merge( [
    13231285                                                'count' => count($products),
    13241286                                                'import_result' => $import_result,
    13251287                                                '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 ) );
    13291291                        }
    13301292                        $this->send_json(false, __('Failed to fetch products from child store', 'multi-store-sync-addon-for-flexstock'));
    … …  
    13351297                 */
    13361298                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 
    13411299                        $body = $this->get_body();
    13421300                        if ( ! isset($body->nonce) || ! wp_verify_nonce($body->nonce, 'ssgsw_nonce') ) {
    13431301                                $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 );
    13441307                        }
    13451308
    … …  
    13731336                        }
    13741337                    }
    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               
    13791339                    if (!$tab_exists && empty($tabs)) {
    13801340                        // Only block if we literally couldn't fetch ANY tabs (API error or permissions)
    … …  
    13861346            }
    13871347           
    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
    13921349                        $api_url = trailingslashit($store_url) . 'wp-json/ssgsw/v1/retrive_child';
    13931350                        $payload = [
    … …  
    13991356                                'access_token'  => $store_token,
    14001357                        ];
     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                        }
    14011362                        $response = multissaf_send_api_get_request($api_url, $payload, $store_token);
    14021363            $processed_count = 0;
    … …  
    14551416                        $new_colmun = array_search($status_label, $headers_row);
    14561417                       
    1457                         if (false !== $new_colmun) {
     1418                        if (false !== $new_colmun && function_exists('ssgsw_get_all_product_statuses')) {
    14581419                            $dropdown = ssgsw_get_all_product_statuses();
    14591420                            $status_count = count($dropdown);
    … …  
    15041465             */
    15051466             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 
    15101467            $body = $this->get_body();
    15111468            if ( ! isset($body->nonce) || ! wp_verify_nonce($body->nonce, 'ssgsw_nonce') ) {
    … …  
    15551512            }
    15561513
    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' ) ) {
    16221515                $this->send_json(false, __('Update function missing', 'multi-store-sync-addon-for-flexstock'));
    16231516            }
     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')]);
    16241588        }
    16251589
     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
    16261726        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             }
    16301727
    16311728            $body = $this->get_body();
  • multi-store-sync-addon-for-flexstock/trunk/includes/classes/class-api.php

    r3699976 r3752558  
    3333        /**
    3434         * Current request permissions
     35         *
    3536         * @var array
    3637         */
    … …  
    6869                    'methods'               => [ 'GET', 'POST' ],
    6970                    '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' ],
    7571                    'permission_callback'   => [ $this, 'permission_callback' ],
    7672                ],
    … …  
    111107
    112108            // Check if token is in request body or query params
    113             if ( empty( $bearer ) ) {
     109            if (empty($bearer) ) {
    114110                $bearer = $request->get_param('access_token');
    115111            }
    … …  
    124120
    125121            $is_valid = false;
    126             // Check single token (legacy or main)
     122            // Check the site's own token
    127123            if (!empty($saved_token) && $bearer === $saved_token) {
    128124                $is_valid = true;
    129                 $this->current_permissions = [ 'scope' => 'all', 'fields' => [ 'all_fields' ] ];
     125                $this->current_permissions = [ 'include_type' => 'all', 'fields' => [ 'all_fields' ] ];
    130126            }
    131127
    … …  
    233229                                    $token_data['main_store'] = $request_site['url'];
    234230                                    $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']);
    235236                                    $token_updated = true;
    236237                                }
    … …  
    400401                                    if($status === 'active' && isset($request_site['url'])) {
    401402                                        $t_data['main_store'] = $request_site['url'];
     403                                        if (isset($params['max_products']) ) {
     404                                            $t_data['max_products'] = absint($params['max_products']);
     405                                        }
    402406                                    } else {
    403407                                        unset($t_data['main_store']);
    … …  
    413417                            }
    414418                        }
     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);
    415441                    }
    416442                }
    … …  
    440466            }
    441467            if ($is_successful) {
    442                 return $this->response(true, [
     468                return $this->response(
     469                    true, [
    443470                    '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                );
    446477            } else {
    447478                return $this->response(false, 'Failed to perform the requested action.');
    … …  
    458489        {
    459490            $permissions = $this->current_permissions;
    460             if ( empty( $permissions ) ) {
     491            if (empty($permissions) ) {
    461492                $token = $request->get_param('access_token');
    462493               
    463                 if ( ! empty( $token ) ) {
     494                if (! empty($token) ) {
    464495                    $child_tokens = get_option('ssgsw_child_tokens', []);
    465                     if ( is_array( $child_tokens ) ) {
     496                    if (is_array($child_tokens) ) {
    466497                        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'] : [];
    469500                                break;
    470501                            }
    … …  
    476507            if (function_exists('multissaf_get_access_data_by_token')) {
    477508                $data = multissaf_get_access_data_by_token($permissions);
     509
    478510                return $this->response(true, $data);
    479511            }
    … …  
    482514        }
    483515        /**
    484          * API Callback to merge data with a parent site.
     516         * API Callback to retrieve child site data.
    485517         *
    486518         * @param  \WP_REST_Request $request The request object.
    487519         * @return \WP_REST_Response
    488520         */
    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        {
    510523            $products_data = $request->get_json_params();
    511524
    … …  
    518531            $product_ids = isset($products_data['product_ids']) ? $products_data['product_ids'] : [];
    519532            $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'] : '';
    521534           
    522535            // 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) ) {
    526539                    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'] : [];
    529542                            break;
    530543                        }
    … …  
    533546            }
    534547
    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.
    536549            $permissions = $this->current_permissions;
    537             if ( ! empty( $permissions['permissions'] ) && is_array( $permissions['permissions'] ) ) {
     550            if (! empty($permissions['permissions']) && is_array($permissions['permissions']) ) {
    538551                $permissions = $permissions['permissions'];
    539552            }
    540553
    541             // Include all products.
    542             $permissions['include_type'] = 'all';
    543 
     554            if (! isset($permissions['include_type']) ) {
     555                $permissions['include_type'] = 'all';
     556            }
    544557
    545558
    … …  
    550563            // Support fetching by SKU
    551564            // 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) ) {
    553566                global $wpdb;
    554                 $placeholders = implode( ',', array_fill( 0, count( $product_skus ), '%s' ) );
     567                $placeholders = implode(',', array_fill(0, count($product_skus), '%s'));
    555568                // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
    556569                $product_ids = $wpdb->get_col(
    … …  
    563576                    )
    564577                );
    565                 $product_ids = array_map( 'intval', $product_ids );
     578                $product_ids = array_map('intval', $product_ids);
    566579            }
    567580           
     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
    568602            if ($import_request) {
    569603                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                    );
    571619                }
    572620            } else {
    … …  
    592640         * @return \WP_REST_Response
    593641         */
    594         public function update_single_callback( \WP_REST_Request $request ){
     642        public function update_single_callback( \WP_REST_Request $request )
     643        {
    595644           
    596645            $product_data = $request->get_json_params();
    … …  
    599648            }
    600649            $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');
    602651            $all_target_ids = [];
    603652
    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') ) {
    606655                    multissaf_sync_to_specific_store_sheet($product_data);
    607656                }
    608              } else {
     657            } else {
    609658                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);
    612663                    if (isset($results['data']['product_ids'])) {
    613664                        $all_target_ids = $results['data']['product_ids'];
    614665                    } else {
    615                         // error_log('[SSGSW DEBUG] Import Failed or returned no IDs. Results: ' . print_r($results, true));
    616666                    }
    617667                    // Trigger local cron unconditionally so scheduled sheet updates fire promptly,
    … …  
    620670                    spawn_cron();
    621671                }
    622              }
     672            }
    623673           
    624674
    625             return $this->response(true, [
     675            return $this->response(
     676                true, [
    626677                'message'    => 'Update processed.',
    627678                'target_ids' => $all_target_ids
    628             ]);
     679                ]
     680            );
    629681        }
    630682   
  • multi-store-sync-addon-for-flexstock/trunk/includes/classes/class-app.php

    r3661535 r3752558  
    1717
    1818    /**
    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.
    2120     *
    2221     * @package MultiStoreSyncAddonforFlexStock
    … …  
    2524    {
    2625
    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         */
    3029        public $free_version = 'stock-sync-with-google-sheet-for-woocommerce/stock-sync-with-google-sheet-for-woocommerce.php';
    3130
    … …  
    4342            return false;
    4443        }
    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
    4659    }
    4760}
  • multi-store-sync-addon-for-flexstock/trunk/includes/classes/class-hooks.php

    r3706977 r3752558  
    55 *
    66 * @package MultiStoreSyncAddonforFlexStock
    7  * @since 1.0.0
     7 * @since   1.0.0
    88 */
    99
    … …  
    1414
    1515// 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&amp;plugin=' . esc_url( $plugin_url ) . '&amp;plugin_status=all&amp;paged=1&amp;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();
     16defined('ABSPATH') || exit(1);
     17
     18if (! 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&amp;plugin=' . esc_url($plugin_url) . '&amp;plugin_status=all&amp;paged=1&amp;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();
    20132020}
  • multi-store-sync-addon-for-flexstock/trunk/includes/classes/export-functions.php

    r3706977 r3752558  
    99 * @return array
    1010 */
    11 if ( ! function_exists( 'multissaf_get_all_columns' ) ) {
    12         function multissaf_get_all_columns() {
    13                 defined( 'ABSPATH' ) || exit;
     11if (! function_exists('multissaf_get_all_columns') ) {
     12    function multissaf_get_all_columns()
     13    {
     14        defined('ABSPATH') || exit;
    1415        $columns = [
    1516            'ID'             => [
    … …  
    101102 * @return array
    102103 */
    103 if ( ! function_exists( 'multissaf_get_column_keys' ) ) {
    104         function multissaf_get_column_keys() {
    105                 defined( 'ABSPATH' ) || exit;
     104if (! function_exists('multissaf_get_column_keys') ) {
     105    function multissaf_get_column_keys()
     106    {
     107        defined('ABSPATH') || exit;
    106108
    107109        $columns = multissaf_get_all_columns();
    … …  
    113115        );
    114116
    115         $keys = array_keys($columns);
    116 
    117         return $keys;
     117           $keys = array_keys($columns);
     118
     119           return $keys;
    118120    }
    119121}
    … …  
    123125 * @return array
    124126 */
    125 if ( ! function_exists( 'multissaf_get_queryable_columns' ) ) {
    126         function multissaf_get_queryable_columns() {
    127                 defined( 'ABSPATH' ) || exit;
     127if (! function_exists('multissaf_get_queryable_columns') ) {
     128    function multissaf_get_queryable_columns()
     129    {
     130        defined('ABSPATH') || exit;
    128131
    129132        $columns = multissaf_get_all_columns();
    … …  
    133136            }
    134137        );
    135         return $columns;
     138           return $columns;
    136139    }
    137140}
    … …  
    142145 * @return array
    143146 */
    144 if ( ! function_exists( 'multissaf_get_queryable_fields' ) ) {
    145         function multissaf_get_queryable_fields() {
    146                 defined( 'ABSPATH' ) || exit;
     147if (! function_exists('multissaf_get_queryable_fields') ) {
     148    function multissaf_get_queryable_fields()
     149    {
     150        defined('ABSPATH') || exit;
    147151
    148152        $columns = multissaf_get_queryable_columns();
    … …  
    154158        );
    155159
    156         return array_keys($columns);
     160           return array_keys($columns);
    157161    }
    158162}
    … …  
    163167 * @return array
    164168 */
    165 if ( ! function_exists( 'multissaf_multistore_get_queryable_metas' ) ) {
    166         function multissaf_multistore_get_queryable_metas() {
    167                 defined( 'ABSPATH' ) || exit;
     169if (! function_exists('multissaf_multistore_get_queryable_metas') ) {
     170    function multissaf_multistore_get_queryable_metas()
     171    {
     172        defined('ABSPATH') || exit;
    168173
    169174        $columns = multissaf_get_queryable_columns();
    … …  
    175180        );
    176181
    177         return apply_filters('ssgsw_multistore_queryable_metas', $columns);
     182           return apply_filters('ssgsw_multistore_queryable_metas', $columns);
    178183    }
    179184}
    … …  
    184189 * @return array
    185190 */
    186 if ( ! function_exists( 'multissaf_multistore_get_queryable_taxonomies' ) ) {
    187         function multissaf_multistore_get_queryable_taxonomies() {
    188                 defined( 'ABSPATH' ) || exit;
     191if (! function_exists('multissaf_multistore_get_queryable_taxonomies') ) {
     192    function multissaf_multistore_get_queryable_taxonomies()
     193    {
     194        defined('ABSPATH') || exit;
    189195
    190196        $columns = multissaf_get_queryable_columns();
    … …  
    195201        );
    196202
    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
     207if (! function_exists('multissaf_get_product_attribute_keys') ) {
     208    function multissaf_get_product_attribute_keys()
     209    {
     210        defined('ABSPATH') || exit;
    219211
    220212        global $wpdb;
    … …  
    230222        );
    231223       
    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
     229if (! 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;
    240233       
    241234        global $wpdb;
     235        $main_table = $wpdb->prefix . 'posts';
    242236
    243237        $fields = multissaf_get_queryable_fields();
    244 
    245         $main_table = $wpdb->prefix . 'posts';
    246238
    247239        $query  = 'SELECT ';
    … …  
    254246        );
    255247
    256         //From post meta table
    257         $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();
    259251        if ($meta_fields && count($meta_fields) > 0 ) {
    260252            $query .= ', ';
    … …  
    268260            if(is_array($attribute_keys) && !empty($attribute_keys)) {
    269261                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";
    280264                }
    281265            }
    … …  
    284268        }
    285269
    286         //From taxonomy table
    287         $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');
    292276            $taxonomy_fields = array_filter(
    293277                $taxonomy_fields,
    294278                function ( $field ) use ( $valid_product_taxonomies ) {
    295                     return in_array( $field, $valid_product_taxonomies, true );
     279                    return in_array($field, $valid_product_taxonomies, true);
    296280                }
    297281            );
    … …  
    308292            );
    309293
    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 != '' )";
    320306
    321307        if ($ids && count($ids) > 0 ) {
    322308            // 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'));
    326312                // 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}";
    341328        if($batch_size) {
    342329            $query .= $wpdb->prepare(' LIMIT %d OFFSET %d', $batch_size, $offset);
    343330        }
    344331   
    345         // phpcs:ignore
    346         $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.
    347334        if ($wpdb->last_error ) {
    348335            return $wpdb->last_error;
    349336        }
    350337
    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
     343if (! 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;
    359347
    360348        if (is_array($keys) && empty($keys)) {
    … …  
    382370                }
    383371               
    384                 // FIX: Fetch categories/tags/brands for ALL product types (not just variations)
     372                // Categories, tags and brands for all product types.
    385373                // Determine which post ID to use for fetching taxonomy terms
    386374                $term_source_id = $row->ID;
    … …  
    388376
    389377                // 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 ) {
    393381                        $term_source_id = $parent_id;
    394382                    }
    … …  
    396384
    397385                // 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');
    399387                $formatted_row['categories'] = [];
    400                 if ( ! is_wp_error( $categories ) && ! empty( $categories ) ) {
     388                if (! is_wp_error($categories) && ! empty($categories) ) {
    401389                    foreach ( $categories as $category ) {
    402390                        // Skip the default WooCommerce 'uncategorized' term
    403                         if ( 'uncategorized' === $category->slug ) {
     391                        if ('uncategorized' === $category->slug ) {
    404392                            continue;
    405393                        }
    … …  
    412400
    413401                // 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');
    415403                $formatted_row['tags'] = [];
    416                 if ( ! is_wp_error( $tags ) && ! empty( $tags ) ) {
     404                if (! is_wp_error($tags) && ! empty($tags) ) {
    417405                    foreach ( $tags as $tag ) {
    418406                        $formatted_row['tags'][] = [
    … …  
    424412
    425413                // 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');
    427415                $formatted_row['brands'] = [];
    428                 if ( ! is_wp_error( $brands ) && ! empty( $brands ) ) {
     416                if (! is_wp_error($brands) && ! empty($brands) ) {
    429417                    foreach ( $brands as $brand ) {
    430418                        $formatted_row['brands'][] = [
    … …  
    446434        );
    447435
    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
     440if (! 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);
    458447
    459448        $keys = multissaf_get_column_keys();
    … …  
    476465                foreach ( $keys as $key ) {
    477466                    // 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']) ) {
    479468                        continue;
    480469                    }
    … …  
    488477
    489478                // 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 ) {
    493482                        $term_source_id = $parent_id;
    494483                    }
    … …  
    496485
    497486                // 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');
    499488                $formatted_row['categories'] = [];
    500                 if ( ! is_wp_error( $categories ) && ! empty( $categories ) ) {
     489                if (! is_wp_error($categories) && ! empty($categories) ) {
    501490                    foreach ( $categories as $category ) {
    502                         if ( 'uncategorized' === $category->slug ) {
     491                        if ('uncategorized' === $category->slug ) {
    503492                            continue;
    504493                        }
    … …  
    511500
    512501                // 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');
    514503                $formatted_row['tags'] = [];
    515                 if ( ! is_wp_error( $tags ) && ! empty( $tags ) ) {
     504                if (! is_wp_error($tags) && ! empty($tags) ) {
    516505                    foreach ( $tags as $tag ) {
    517506                        $formatted_row['tags'][] = [
    … …  
    523512
    524513                // 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');
    526515                $formatted_row['brands'] = [];
    527                 if ( ! is_wp_error( $brands ) && ! empty( $brands ) ) {
     516                if (! is_wp_error($brands) && ! empty($brands) ) {
    528517                    foreach ( $brands as $brand ) {
    529518                        $formatted_row['brands'][] = [
    … …  
    535524
    536525                // 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) ) {
    538527                    $woo_meta_keys_to_skip = [
    539528                    'chosen_product_cat', '_sku', '_price', '_regular_price', '_sale_price', '_stock', '_stock_status',
    … …  
    550539        );
    551540
    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
     545if (! 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']) ) {
    561556            $formatted_data = multissaf_get_formatted_data_with_permissions($prduct_ids, 0, 0, ['product'], $permissions);
    562557        } else {
    … …  
    573568                   
    574569                    $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                    }
    575573                    if (!empty($child_ids)) {
    576574                        // Fetch and format variation data
    577                         if ( ! empty( $permissions ) && isset( $permissions['fields'] ) ) {
     575                        if (! empty($permissions) && isset($permissions['fields']) ) {
    578576                            $formatted_data_variation = multissaf_get_formatted_data_with_permissions($child_ids, 0, 0, ['product_variation'], $permissions);
    579577                        } else {
    … …  
    594592}
    595593
    596 if ( ! function_exists( 'multissaf_get_batch_products' ) ) {
    597         function multissaf_get_batch_products( $offset, $batch_size, $permissions = [] ) {
    598                 defined( 'ABSPATH' ) || exit;
     594if (! 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
     626if (! 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 ];
    599639
    600640        // Unwrap nested permissions if necessary.
    601         if ( isset( $permissions['permissions'] ) && is_array( $permissions['permissions'] ) ) {
     641        if (isset($permissions['permissions']) && is_array($permissions['permissions']) ) {
    602642            $permissions = $permissions['permissions'];
    603643        }
    604644
    605645        $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) ) {
    613653                return [];
    614654            }
    615655            $filtered_ids = [];
    616656            foreach ( $raw_filtered as $raw_product ) {
    617                 if ( isset( $raw_product->ID ) ) {
     657                if (isset($raw_product->ID) ) {
    618658                    $filtered_ids[] = $raw_product->ID;
    619659                }
    620660            }
    621661            $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');
    622664
    623665            // Since we already paginated at the raw-data level, fetch exact IDs.
    … …  
    626668        }
    627669
    628         if ( ! empty( $permissions ) && isset( $permissions['fields'] ) ) {
     670        if (! empty($permissions) && isset($permissions['fields']) ) {
    629671            $formatted_data = multissaf_get_formatted_data_with_permissions($prduct_ids, $offset, $batch_size, ['product'], $permissions);
    630672        } else {
    … …  
    633675
    634676        $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        }
    635682
    636683        if (is_array($formatted_data) && !empty($formatted_data)) {
    … …  
    642689                    // Get child IDs of the variable product
    643690                    $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);
    647694                    }
    648695                    if (!empty($child_ids)) {
     696                        $batch_info['variations_no_sku'] += multissaf_count_ids_without_sku($child_ids, 'product_variation');
    649697                        // Fetch and format variation data
    650                         if ( ! empty( $permissions ) && isset( $permissions['fields'] ) ) {
     698                        if (! empty($permissions) && isset($permissions['fields']) ) {
    651699                            $formatted_data_variation = multissaf_get_formatted_data_with_permissions($child_ids, 0, 0, ['product_variation'], $permissions);
    652700                        } else {
    … …  
    668716}
    669717
    670 if ( ! function_exists( 'multissaf_get_children_id' ) ) {
    671     function multissaf_get_children_id( $product_id ) {
    672             defined( 'ABSPATH' ) || exit;
     718if (! function_exists('multissaf_get_children_id') ) {
     719    function multissaf_get_children_id( $product_id )
     720    {
     721            defined('ABSPATH') || exit;
    673722
    674723        global $wpdb;
    … …  
    688737}
    689738
    690 if ( ! function_exists( 'multissaf_get_column_callback' ) ) {
    691     function multissaf_get_column_callback( $value, $key, $row ) {
    692        
    693         defined( 'ABSPATH' ) || exit;
     739if (! function_exists('multissaf_get_column_callback') ) {
     740    function multissaf_get_column_callback( $value, $key, $row )
     741    {
     742       
     743        defined('ABSPATH') || exit;
    694744
    695745        if ('_stock' === $key ) {
    … …  
    835885
    836886
    837 if ( ! function_exists( 'multissaf_get_product_type' ) ) {
    838         function multissaf_get_product_type( $product_type = 'simple' ) {
    839             defined( 'ABSPATH' ) || exit;
     887if (! function_exists('multissaf_get_product_type') ) {
     888    function multissaf_get_product_type( $product_type = 'simple' )
     889    {
     890        defined('ABSPATH') || exit;
    840891
    841892        $product_type = multissaf_get_first_term($product_type);
    842893        $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'),
    847898        ];
    848899
    … …  
    852903
    853904
    854 if ( ! function_exists( 'multissaf_get_first_term' ) ) {
    855         function multissaf_get_first_term( $categories = '0' ) {
    856             defined( 'ABSPATH' ) || exit;
     905if (! function_exists('multissaf_get_first_term') ) {
     906    function multissaf_get_first_term( $categories = '0' )
     907    {
     908        defined('ABSPATH') || exit;
    857909
    858910        if ($categories !== null) {//phpcs:ignore
    … …  
    867919
    868920
    869 if ( ! function_exists( 'multissaf_colleciton_of_change_key' ) ) {
    870         function multissaf_colleciton_of_change_key( $key ) {
    871             defined( 'ABSPATH' ) || exit;
     921if (! function_exists('multissaf_colleciton_of_change_key') ) {
     922    function multissaf_colleciton_of_change_key( $key )
     923    {
     924        defined('ABSPATH') || exit;
    872925
    873926        $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'
    889942        ];
    890943        return isset($change_key[$key]) ? $change_key[$key] : $key;
    … …  
    893946
    894947
    895 if ( ! function_exists( 'multissaf_get_variation_attributes_simple' ) ) {
    896         function multissaf_get_variation_attributes_simple( $variation_id ) {
    897             defined( 'ABSPATH' ) || exit;
     948if (! function_exists('multissaf_get_variation_attributes_simple') ) {
     949    function multissaf_get_variation_attributes_simple( $variation_id )
     950    {
     951        defined('ABSPATH') || exit;
    898952
    899953        global $wpdb;
  • multi-store-sync-addon-for-flexstock/trunk/includes/classes/functions.php

    r3706977 r3752558  
    8686/**
    8787 * 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.
    9089 *
    9190 * @return array[]  Each entry: {url, token, tab_name, sheet_gid, isConnected, ...}
    … …  
    9695                $all_option = get_option( 'ssgsw_multi_store_list' );
    9796
     97
    9898                if ( ! $all_option ) {
    9999                        return [];
    … …  
    106106                );
    107107                $filtered_options = array_values( $filtered_options );
    108 
    109                 if ( empty( $filtered_options ) ) {
    110                         return [];
    111                 }
    112108
    113109                return $filtered_options;
    … …  
    168164
    169165/**
     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 */
     173if ( ! 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 */
     204if ( ! 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/**
    170228 * 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().
    172230 * Uses Column class to resolve allowed fields.
    173231 *
    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()
    176234 *
    177235 * @param int[]|null $ids
    … …  
    179237 * @param int        $batch_size
    180238 * @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).
    182240 * @return object[]|string
    183241 */
    … …  
    276334        }
    277335
    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 );
    279338
    280339        // Always exclude trashed products from token-filtered exports.
    … …  
    300359        $order           = strtoupper( $order_raw ) === 'DESC' ? 'DESC' : 'ASC';
    301360
    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.
    306362        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
    307363        $query .= " ORDER BY {$main_table}.{$order_by} {$order}, {$main_table}.ID ASC";
    … …  
    320376}
    321377
    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 permissions
    327         $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 keys
    342         $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 string
    375                     $formatted_row[ $key ] = is_array($cell_value) ? '' : $cell_value;
    376                 }
    377                 return array_values($formatted_row);
    378             }, $raw_data
    379         );
    380         return $formatted_data;
    381     }
    382 }
    383 
    384378/**
    385379 * Builds the flat sheet-row format (array of values, no keys) for a
    … …  
    399393        function multissaf_get_batch_products_by_token( $ids = null, $offset = 0, $batch_size = 0, $parmision_filds = [] ) {
    400394
    401 
    402 
     395        // When explicit variation IDs are requested, fetch their parents and
     396        // keep only those variations in the child-fetch loop below.
    403397        $requested_variation_ids = [];
    404398
    … …  
    449443            $all_raw_data[] = $parent_row;
    450444
    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)
    452446            $is_variable = false;
    453447            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 );
    459449            }
    460450
    … …  
    476466        }
    477467
    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' ];
    480470        $column_obj = class_exists('\StockSyncWithGoogleSheetForWooCommerce\Column') ? new \StockSyncWithGoogleSheetForWooCommerce\Column() : null;
    481471        $keys       = $column_obj ? $column_obj->get_column_keys() : [];
    … …  
    729719/**
    730720 * 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()
    735723 *
    736724 * @param array $data  Site data including 'url' (required), 'token', 'tab_name', etc.
    737  * @return bool True on success, false if blocked or url missing.
     725 * @return bool True on success, false if url missing.
    738726 */
    739727if ( ! function_exists( 'multissaf_save_child_site' ) ) {
    … …  
    769757
    770758                if ( ! $updated ) {
    771 
    772759                        // Add as new
    773760                        $existing_sites[] = $data;
    … …  
    796783                defined( 'ABSPATH' ) || exit;
    797784               
    798                 // Ensure isConnected is set when parent is first saved
     785                // A completed add_parent handshake is an active connection.
    799786                if ( ! isset( $data['isConnected'] ) ) {
    800                         $data['isConnected'] = true; // Completed add_parent handshake = active connection
    801                 }
    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 ) );
    819806        }
    820807}
    … …  
    822809/**
    823810 * 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' filter
    826811 *
    827812 * @return array[]  Each entry: {url, token}
    … …  
    835820                        return [];
    836821                }
    837                
    838 
    839822
    840823                return $all_option;
    … …  
    842825}
    843826
    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 }
    857827
    858828
    … …  
    860830        function multissaf_get_body() {
    861831                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) [];
    871839        }
    872840}
    … …  
    875843        function multissaf_sanitize( $array ) { //phpcs:ignore
    876844                defined( 'ABSPATH' ) || exit;
    877                 if ( ! is_array( $array ) ) {
    878                         return is_string( $array ) ? sanitize_text_field( $array ) : $array;
    879                 }
    880845                foreach ( $array as $key => $value ) {
    881846                        if ( is_array( $value ) ) {
    882847                                $array[ $key ] = multissaf_sanitize( $value );
    883                         } elseif ( is_string( $value ) ) {
     848                        } else {
    884849                                $array[ $key ] = sanitize_text_field( $value );
    885                         } else {
    886                                 $array[ $key ] = $value;
    887850                        }
    888851                }
    … …  
    992955
    993956
    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 data
    1023                                 $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 URL
    1038                                 $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 URLs
    1042                                 $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 & tags
    1046                                 $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 slug
    1064                                 $product_data['brands'] = [];
    1065                                 foreach ($brands as $brand) {
    1066                                         $product_data['brands'][] = [
    1067                                                 'name' => $brand->name,
    1068                                                 'slug' => $brand->slug,
    1069                                         ];
    1070                                 }
    1071                                 // Get attributes
    1072                                 $product_data['attributes'] = [];
    1073 
    1074                                 $product_id = $product->get_id();
    1075 
    1076                                 // First: Try to get from _product_attributes post meta
    1077                                 $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_visible
    1106                                                         ];
    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 data
    1127                                 $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 
    1174957if ( ! function_exists( 'multissaf_get_sub_product_count' ) ) {
    1175958        function multissaf_get_sub_product_count( $url, $token, $site ) {
    … …  
    12411024                        $data = multissaf_get_access_data_by_token( $matched_token_data );
    12421025                        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'] );
    12441029                        }
    12451030                }
    … …  
    12641049        function multissaf_single_product_update( $api_url, $body_data, $bearer_token ) {
    12651050
    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               
    12671056                // Ensure the token and body data are not empty.
    12681057                if ( empty( $api_url ) || empty( $bearer_token ) ) {
    … …  
    13101099                // Check for a WordPress error.
    13111100                if ( is_wp_error( $response ) ) {
    1312                         return false; // Return the WP_Error object.
     1101                        return false; // Request failed.
    13131102                }
    13141103                // Get the HTTP status code from the response.
    … …  
    13591148                // Check for a WordPress error.
    13601149                if ( is_wp_error( $response ) ) {
    1361                         return false; // Return the WP_Error object.
     1150                        return false; // Request failed.
    13621151                }
    13631152                // Get the HTTP status code from the response.
    … …  
    14091198                // Check for a WordPress error.
    14101199                if ( is_wp_error( $response ) ) {
    1411                         return false; // Return the WP_Error object.
     1200                        return false; // Request failed.
    14121201                }
    14131202                // Get the HTTP status code from the response.
    … …  
    15841373                }
    15851374
    1586                 // Optionally apply the limit.
     1375                // Allow the count to be filtered.
    15871376                if ( $product_count > 100 ) {
    15881377                        $limit = apply_filters( 'ssgsw_product_limit', 100 );
    … …  
    15931382
    15941383                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_ids
    1618                         )
    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;
    16261384        }
    16271385}
    … …  
    16601418                }
    16611419                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 */
     1431if ( ! 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 */
     1463if ( ! 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 */
     1486if ( ! 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 */
     1509if ( ! 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 */
     1542if ( ! 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 */
     1571if ( ! 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 );
    16621604        }
    16631605}
    … …  
    16911633                }
    16921634
    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' );
    16971640
    16981641                $sql   = "SELECT COUNT(DISTINCT p.ID) FROM {$wpdb->posts} p $join $where";
    … …  
    17061649
    17071650                // 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' ];
    17091652                $field_names = [];
    17101653
    … …  
    17261669                }
    17271670
    1728 
    17291671                return [
    17301672                        'count'          => $count,
    … …  
    17321674                        'permissions'    => $permissions,
    17331675                        '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' => [],
    17871677                ];
    17881678        }
    … …  
    19951885        // Filter to only connected sites
    19961886        $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.
    20001888            $is_connected = isset($site['isConnected']) ? $site['isConnected'] : true;
    20011889           
  • multi-store-sync-addon-for-flexstock/trunk/includes/classes/import-function.php

    r3624473 r3752558  
    44
    55/**
    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.
    97 *
    108 * Connected from: API::update_single_callback(),
    11  *                 API::marge_with_parent_callback(),
    129 *                 MULTISSAF_AJAX::multissaf_manual_import_products()
    1310 *
    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,
    1512 *                               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).
    2018 * @return array {success, created_count, updated_count, failed_count, messages, details}
    2119 */
    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;
     20if (! 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
     42if (! 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;
    2546
    2647        if (empty($products_data) || !is_array($products_data)) {
    … …  
    4364
    4465        // 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);
    5071        $sku_map        = [];
    5172
    52         if ( ! empty( $skus_to_lookup ) ) {
     73        if (! empty($skus_to_lookup) ) {
    5374            global $wpdb;
    54             $placeholders = implode( ',', array_fill( 0, count( $skus_to_lookup ), '%s' ) );
     75            $placeholders = implode(',', array_fill(0, count($skus_to_lookup), '%s'));
    5576            // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
    5677            $rows = $wpdb->get_results(
    … …  
    79100                }
    80101
    81                 $existing_product_id = isset( $sku_map[ $sku ] ) ? $sku_map[ $sku ] : 0;
     102                $existing_product_id = isset($sku_map[ $sku ]) ? $sku_map[ $sku ] : 0;
    82103
    83104                // 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']) ) {
    85106                    $old_sku = $product_data['custom_meta']['_ssgsw_old_sku_temp'];
    86                     if ( is_array( $old_sku ) ) {
     107                    if (is_array($old_sku) ) {
    87108                        $old_sku = $old_sku[0];
    88109                    }
    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;
    92118                }
    93119
    94120                // 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']) ) {
    96122                    $remote_meta_key = '_ssgsw_remote_id_' . md5($refrance_shop);
    97123                    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 ) {
    104134                        $existing_product_id = (int) $found_id;
    105135                    }
    106136                }
    107137
    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);
    121145                        }
    122146                        $product_ids[] = $existing_product_id;
    … …  
    134158               
    135159                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);
    138162                    }
    139163                    // Update existing product
    … …  
    141165                   
    142166                    if ($result['success']) {
    143                         if ( !empty($refrance_shop) && !empty($product_data['id']) ) {
     167                        if (!empty($refrance_shop) && !empty($product_data['id']) ) {
    144168                            update_post_meta($existing_product_id, '_ssgsw_remote_id_' . md5($refrance_shop), absint($product_data['id']));
    145169                        }
    … …  
    161185                } else {
    162186                    $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";
    164192                        continue;
    165193                    }
    … …  
    167195                    $result = multissaf_create_new_product($product_data);
    168196                    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']) ) {
    170201                            update_post_meta($result['product_id'], '_ssgsw_remote_id_' . md5($refrance_shop), absint($product_data['id']));
    171202                        }
    … …  
    199230       
    200231        // 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);
    203234
    204235        return $results;
    … …  
    207238
    208239
    209 if ( ! function_exists( 'multissaf_update_existing_product' ) ) {
    210         function multissaf_update_existing_product( $product_id, $product_data ) {
    211                 defined( 'ABSPATH' ) || exit;
     240if (! function_exists('multissaf_update_existing_product') ) {
     241    function multissaf_update_existing_product( $product_id, $product_data )
     242    {
     243        defined('ABSPATH') || exit;
    212244
    213245        try {
    … …  
    239271            if (isset($product_data['regular_price'])) {
    240272                $new_regular_price = floatval($product_data['regular_price']);
    241                 // $current_regular_price = floatval($product->get_regular_price());
    242273                $current_regular_price = floatval(get_post_meta($product_id, '_regular_price', true));
    243274                if (abs($new_regular_price - $current_regular_price) > 0.0001) {
    … …  
    261292            $stock_status_provided = isset($product_data['stock_status']);
    262293
    263             if ( $stock_quantity_provided || $stock_status_provided ) {
     294            if ($stock_quantity_provided || $stock_status_provided ) {
    264295                $current_quantity = $product->get_stock_quantity();
    265296                $current_status = $product->get_stock_status();
    … …  
    270301
    271302                // 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']) ) {
    273304                    $new_quantity = absint($product_data['stock_quantity']);
    274305
    275                     if ( $new_quantity > 0 ) {
     306                    if ($new_quantity > 0 ) {
    276307                        $new_status = 'instock';
    277308                    } else {
    278309                        // Check backorders
    279310                        $backorders = $product->get_backorders();
    280                         if ( $backorders && $backorders !== 'no' ) {
     311                        if ($backorders && $backorders !== 'no' ) {
    281312                            $new_status = 'onbackorder';
    282313                        } else {
    … …  
    286317                }
    287318                // 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']) ) {
    289320                    // 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 parentheses
    292 
    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' ) {
    294325                        $new_quantity = 0;
    295                     } else if ( $stock_normalized === 'instock' ) {
     326                    } else if ($stock_normalized === 'instock' ) {
    296327                        // 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' ) {
    299330                        $new_quantity = 0;
    300331                    }
    … …  
    302333
    303334                // 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);
    307338
    308339                $updated_fields[] = 'stock_quantity';
    … …  
    317348
    318349            // 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'] ) {
    320351                $new_sku     = $product_data['sku'];
    321352                $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);
    326357                        $updated_fields[] = 'sku';
    327358                    }
    328359                }
    329360            }
    330 
    331             /* if (!empty($updated_fields)) {
    332                 $product->save();
    333             } */
    334361
    335362            if (!empty($updated_fields)) {
    … …  
    339366   
    340367                    // 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'] ) {
    342369                        wp_schedule_single_event(
    343370                            time() + 3,
    … …  
    347374                    }
    348375                } 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' => [] ];
    350378                }
    351379            }
    … …  
    354382            if (isset($product_data['custom_meta']) && is_array($product_data['custom_meta'])) {
    355383                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_') ) {
    358387                        continue;
    359388                    }
    … …  
    367396                        }
    368397                        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);
    370399                            $updated_fields[] = "meta: $meta_key";
    371400                        }
    … …  
    459488                    $variation_attrs = [];
    460489                    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'] : '';
    463492                        // WC variations store ONE selected value per attribute, keyed by taxonomy
    464                         if ( ! empty( $slug ) ) {
     493                        if (! empty($slug) ) {
    465494                            $variation_attrs[ $slug ] = $value;
    466495                        }
    467496                    }
    468                     $product->set_attributes( $variation_attrs );
     497                    $product->set_attributes($variation_attrs);
    469498                    $updated_fields[] = 'attributes';
    470499                } else {
    … …  
    524553 * @return array Result with success status, message and product_id
    525554 */
    526 if ( ! function_exists( 'multissaf_create_new_product' ) ) {
    527         function multissaf_create_new_product( $product_data ) {
    528                 defined( 'ABSPATH' ) || exit;
     555if (! function_exists('multissaf_create_new_product') ) {
     556    function multissaf_create_new_product( $product_data )
     557    {
     558        defined('ABSPATH') || exit;
    529559        try {
    530560            $product_type = isset($product_data['type']) ? $product_data['type'] : 'simple';
    … …  
    566596            $stock_status_provided = isset($product_data['stock_status']);
    567597
    568             if ( $stock_quantity_provided || $stock_status_provided ) {
     598            if ($stock_quantity_provided || $stock_status_provided ) {
    569599                $new_quantity = 0;
    570600                $new_status = 'instock';
    571601
    572602                // 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']) ) {
    574604                    $new_quantity = absint($product_data['stock_quantity']);
    575605
    576                     if ( $new_quantity > 0 ) {
     606                    if ($new_quantity > 0 ) {
    577607                        $new_status = 'instock';
    578608                    } else {
    … …  
    581611                }
    582612                // 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);
    586616
    587617                    $valid_statuses = ['instock', 'outofstock', 'onbackorder'];
    588                     if ( in_array( $stock_normalized, $valid_statuses, true ) ) {
     618                    if (in_array($stock_normalized, $valid_statuses, true) ) {
    589619                        $new_status = $stock_normalized;
    590                         if ( $stock_normalized === 'outofstock' || $stock_normalized === 'onbackorder' ) {
     620                        if ($stock_normalized === 'outofstock' || $stock_normalized === 'onbackorder' ) {
    591621                            $new_quantity = 0;
    592622                        } else {
    … …  
    597627
    598628                // 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);
    602632            }
    603633
    … …  
    620650            if (isset($product_data['custom_meta']) && is_array($product_data['custom_meta'])) {
    621651                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_') ) {
    624655                        continue;
    625656                    }
    … …  
    630661                        $current_meta = get_post_meta($product_id, $meta_key, true);
    631662                        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);
    633664               
    634665                        }
    … …  
    733764 * @return array List of updated variations
    734765 */
    735 if ( ! function_exists( 'multissaf_create_update_product_variations' ) ) {
    736         function multissaf_create_update_product_variations( $product_id, $variations_data ) {
    737                 defined( 'ABSPATH' ) || exit;
     766if (! function_exists('multissaf_create_update_product_variations') ) {
     767    function multissaf_create_update_product_variations( $product_id, $variations_data )
     768    {
     769        defined('ABSPATH') || exit;
    738770        $updated_variations = [];
    739771        foreach ($variations_data as $variation_data) {
    … …  
    751783                } else {
    752784                    $store_role = get_option('ssgsw_multi_connect_mode_on');
    753                     if ( 'child' === $store_role ) {
     785                    if ('child' === $store_role ) {
    754786                        continue;
    755787                    }
    … …  
    767799}
    768800
    769 if ( ! function_exists( 'multissaf_create_product_variations' ) ) {
    770         function multissaf_create_product_variations( $product_id, $variation_data ) {
    771                 defined( 'ABSPATH' ) || exit;
     801if (! function_exists('multissaf_create_product_variations') ) {
     802    function multissaf_create_product_variations( $product_id, $variation_data )
     803    {
     804        defined('ABSPATH') || exit;
    772805
    773806        $variation = new WC_Product_Variation();
    … …  
    815848                    $current_meta = get_post_meta($variation_id, $meta_key, true);
    816849                    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);
    818851                    }
    819852                }
    … …  
    827860}
    828861
    829 if ( ! function_exists( 'multissaf_update_variation' ) ) {
    830         function multissaf_update_variation( $variation, $variation_data ) {
    831                 defined( 'ABSPATH' ) || exit;
     862if (! function_exists('multissaf_update_variation') ) {
     863    function multissaf_update_variation( $variation, $variation_data )
     864    {
     865        defined('ABSPATH') || exit;
    832866
    833867        $updated_fields = [];
    … …  
    879913
    880914            // 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') ) {
    883917                    $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());
    885919                }
    886920            }
    … …  
    899933                    }
    900934                    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);
    902936                        $updated_fields[] = "meta: $meta_key";
    903937                    }
    … …  
    912946}
    913947
    914 if ( ! function_exists( 'multissaf_manage_product_attributes' ) ) {
    915         function multissaf_manage_product_attributes( WC_Product $product, array $attributes_data ) {
    916                 defined( 'ABSPATH' ) || exit;
     948if (! function_exists('multissaf_manage_product_attributes') ) {
     949    function multissaf_manage_product_attributes( WC_Product $product, array $attributes_data )
     950    {
     951        defined('ABSPATH') || exit;
    917952        if (!$product || empty($attributes_data)) {
    918953            return false;
    … …  
    9661001                    $term = get_term_by('slug', $term_slug, $slug);
    9671002                   
    968                     // Fallback to name lookup for backward compatibility
     1003                    // Fall back to a name lookup for terms whose slug differs from the name
    9691004                    if (!$term) {
    9701005                        $term = get_term_by('name', $term_name, $slug);
    … …  
    10111046           
    10121047            // 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') ) {
    10141049                try {
    10151050                    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());
    10181053                } catch ( \Exception $e ) {
    10191054                    error_log('[MultiStore] Failed to rebuild lookup data after save: ' . $e->getMessage());
    … …  
    10281063}
    10291064
    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;
     1065if (! 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;
    10351071        if (empty($image_url) || ! filter_var($image_url, FILTER_VALIDATE_URL) ) {
    10361072            return false;
    … …  
    10591095}
    10601096
    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;
     1097if (! 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;
    10641101
    10651102        $image_data = function_exists('ssgsw_safe_remote_get_image') ? ssgsw_safe_remote_get_image($image_url) : wp_remote_get($image_url);
    … …  
    10981135}
    10991136
    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;
     1137if (! 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;
    11031141        $args = array(
    11041142            'post_type'      => 'attachment',
    … …  
    11211159}
    11221160
    1123 if ( ! function_exists( 'multissaf_get_attachment_id_by_url' ) ) {
    1124         function multissaf_get_attachment_id_by_url( $image_url ) {
    1125                 defined( 'ABSPATH' ) || exit;
     1161if (! function_exists('multissaf_get_attachment_id_by_url') ) {
     1162    function multissaf_get_attachment_id_by_url( $image_url )
     1163    {
     1164        defined('ABSPATH') || exit;
    11261165        $attachment_id = attachment_url_to_postid($image_url);
    11271166        return $attachment_id;
    … …  
    11291168}
    11301169
    1131 if ( ! function_exists( 'multissaf_update_product_gallery' ) ) {
    1132         function multissaf_update_product_gallery( $product, $gallery_urls ) {
    1133                 defined( 'ABSPATH' ) || exit;
     1170if (! function_exists('multissaf_update_product_gallery') ) {
     1171    function multissaf_update_product_gallery( $product, $gallery_urls )
     1172    {
     1173        defined('ABSPATH') || exit;
    11341174        $product_id = $product->get_id();
    11351175        if (!$product) {
    … …  
    11671207                $product->save();
    11681208            } 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());
    11701210                return false;
    11711211            }
    … …  
    11791219
    11801220
    1181 if ( ! function_exists( 'multissaf_set_product_gallery' ) ) {
    1182         function multissaf_set_product_gallery( $product_id, $gallery_urls ) {
    1183                 defined( 'ABSPATH' ) || exit;
     1221if (! function_exists('multissaf_set_product_gallery') ) {
     1222    function multissaf_set_product_gallery( $product_id, $gallery_urls )
     1223    {
     1224        defined('ABSPATH') || exit;
    11841225
    11851226        if (empty($gallery_urls) || !is_array($gallery_urls)) { return;
    … …  
    12041245}
    12051246
    1206 if ( ! function_exists( 'multissaf_import_image_from_url' ) ) {
    1207         function multissaf_import_image_from_url( $image_url, $product_id ) {
    1208                 defined( 'ABSPATH' ) || exit;
     1247if (! function_exists('multissaf_import_image_from_url') ) {
     1248    function multissaf_import_image_from_url( $image_url, $product_id )
     1249    {
     1250        defined('ABSPATH') || exit;
    12091251
    12101252        if (empty($image_url) || ! filter_var($image_url, FILTER_VALIDATE_URL) ) {
    … …  
    12241266}
    12251267
    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;
     1268if (! 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;
    12291272        if (!isset($request_body['ssgsw_products']) || empty($request_body['ssgsw_products'])) {
    12301273            return [
    … …  
    12391282        $refrance_shop = isset($request_body['refarance_shop']) ? $request_body['refarance_shop'] : '';
    12401283        // 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);
    12421285       
    12431286        return [
  • multi-store-sync-addon-for-flexstock/trunk/includes/wppool/class-plugin.php

    r3639255 r3752558  
    1010
    1111// Exit if accessed directly.
    12 defined( 'ABSPATH' ) || exit();
     12defined('ABSPATH') || exit();
    1313
    1414/**
    1515 * Plugin Class
    1616 */
    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"> &times; </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() {
     17if (! 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"> &times; </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() {
    397408
    398409                                if (typeof(WPPOOL) !== "undefined") {
    … …  
    626637               
    627638                                                        // 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()) . '";
    629640               
    630641                                                        if (data.background_image && data.background_image.length > 0) {
    … …  
    800811               
    801812                        })(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 {
    812824                --wppool-popup-color: #FF631A;
    813825            }
    … …  
    848860                max-height: 600px !important;
    849861                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;
    851863                background-position: center;
    852864                background-size: cover;
    … …  
    10141026            ';
    10151027
    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    }
    13561399}
  • multi-store-sync-addon-for-flexstock/trunk/multi-store-sync-addon-for-flexstock.php

    r3706977 r3752558  
    44 * Plugin URI:              https://wppool.dev/flexstock/addons/multi-store-sync-for-flexstock/
    55 * 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
    79 * Author:                  WPPOOL
    810 * Author URI:              https://www.wppool.dev/
    … …  
    2224define('MULTISSAF_MULTI_STORE_PATH', plugin_dir_path(MULTISSAF_MULTI_STORE));
    2325define('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');
     26define('MULTISSAF_MULTI_STORE_VERSION', '1.1.0');
    2627/**
    2728 * Loading base file
    … …  
    3233}
    3334
    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 WPPOOL
    38  * https://wppool.dev/
    39  */
    4035
    4136
    42 
  • multi-store-sync-addon-for-flexstock/trunk/package.json

    r3699976 r3752558  
    11{
    22  "name": "multi-store-sync-addon-for-flexstock",
    3   "version": "1.0.0",
     3  "version": "1.0.7",
    44  "description": "Multi-Store Sync Addon for FlexStock",
    5   "main": "index.js",
     5  "main": "src/index.js",
    66  "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"
    1210  },
    1311  "keywords": [],
    14   "author": "sabbirsam",
    15   "license": "ISC",
     12  "author": "WPPOOL",
     13  "license": "GPL-2.0-or-later",
    1614  "devDependencies": {
    17     "@babel/core": "^7.17.12",
    1815    "@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"
    2522  }
    2623}
  • multi-store-sync-addon-for-flexstock/trunk/readme.txt

    r3706977 r3752558  
    22Contributors: wppool, devsabbirahmed
    33Tags: woocommerce multi store, google sheets, inventory management, product sync, stock management
    4 Requires at least: 5.4
     4Requires at least: 7.0
    55Tested up to: 7.1
    6 Requires PHP: 5.6
    7 Stable tag: 1.0.7
     6Requires PHP: 7.4
     7Stable tag: 1.1.0
    88License: GPLv2 or later
    99License URI: http://www.gnu.org/licenses/gpl-2.0.html
    … …  
    150150Share products using the workflow that fits your business:
    151151  * All Products
    152   * Products by Category
    153   * Products by Tag
     152  * Products by Category (Pro)
     153  * Products by Tag (Pro)
    154154  * Products by Attribute (Pro)
    155155  * Specific Products by SKU (Pro)
    … …  
    174174
    175175  * **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**.
    177177
    178178  * **Specific Products (Pro):** Share only selected products using their SKUs. Perfect for syncing specific catalogs, featured products, seasonal collections, or dealer-exclusive inventory.
    … …  
    193193* Unlimited product variations
    194194* All Products mode
    195 * Filter Products mode
     195
     196=== Pro ===
     197
     198Adds:
     199
     200* Filtered Products mode
    196201* Category Filter
    197202* Tag Filter
    198 
    199 === Pro ===
    200 
    201 Unlocks:
    202 
    203 * Advanced Attribute Filter
     203* Attribute Filter
    204204* Specific SKU Sharing
    205205
    … …  
    302302
    303303The 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`
    306306
    307307Build instructions:
    308308
    309 1. Run `npm start` in the root to install dependencies.
     3091. Run `npm install` in the root to install dependencies.
    3103102. Run `npm run build` to compile the JavaScript and CSS assets.
    311 3. The compiled files are output to `assets/build/`
     3113. The compiled files are output to `build/`
    312312
    313313== Frequently Asked Questions ==
    … …  
    4324328. Synchronization status
    433433
     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
    434440= 1.0.7 =
    435441* **Enhancement:** Activation redirect no longer affects front-end requests
  • multi-store-sync-addon-for-flexstock/trunk/vendor/appsero/src/Client.php

    r3699976 r3752558  
    8989
    9090    /**
    91      * The Object of License Class
    92      *
    93      * @var object
    94      */
    95     private $license;
    96 
    97     /**
    9891     * Initialize the class
    9992     *
    … …  
    128121
    129122        return $this->insights;
    130     }
    131 
    132     /**
    133      * Initialize plugin/theme updater
    134      *
    135      * @return void
    136      */
    137     public function updater() {
    138         // do not show update notice on ajax request and rest api request
    139         if ( wp_doing_ajax() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
    140             return;
    141         }
    142 
    143         // show deprecated notice
    144         _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 updater
    147         if ( method_exists( '\MultiStoreSyncAddonForFlexStock\Appsero\Updater', 'init' ) ) {
    148             \MultiStoreSyncAddonForFlexStock\Appsero\Updater::init( $this );
    149         }
    150     }
    151 
    152     /**
    153      * Initialize license checker
    154      *
    155      * @return Appsero\License
    156      */
    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 one
    163         if ( $this->license ) {
    164             return $this->license;
    165         }
    166 
    167         $this->license = new License( $this );
    168 
    169         return $this->license;
    170123    }
    171124
Note: See TracChangeset for help on using the changeset viewer.