css()); } public function register_settings() { register_setting('wpgss_settings_group','wpgss_settings',array($this,'sanitize_settings')); } public function sanitize_settings($input) { $old = get_option('wpgss_settings', array()); if (!is_array($old)) $old = array(); if (!is_array($input)) $input = array(); $out = array_merge($old,$input); $raw = trim(sanitize_text_field($input['spreadsheet_id'] ?? ($old['spreadsheet_id'] ?? ''))); if (preg_match('~spreadsheets/d/([a-zA-Z0-9_-]+)~',$raw,$m)) $raw = $m[1]; $out['spreadsheet_id'] = preg_replace('/[^a-zA-Z0-9_-]/','',$raw); $out['oauth_client_id'] = sanitize_text_field($input['oauth_client_id'] ?? ($old['oauth_client_id'] ?? '')); $secret = isset($input['oauth_client_secret']) ? trim(wp_unslash($input['oauth_client_secret'])) : ''; $out['oauth_client_secret'] = $secret !== '' ? sanitize_text_field($secret) : ($old['oauth_client_secret'] ?? ''); $credentials_changed = (!empty($old['oauth_client_id']) && $old['oauth_client_id'] !== $out['oauth_client_id']) || ($secret !== '' && !empty($old['oauth_client_secret']) && $old['oauth_client_secret'] !== $out['oauth_client_secret']); foreach (array('elementor_enabled','cf7_enabled','woocommerce_orders_enabled','woocommerce_customers_enabled') as $key) { if (!empty($input['wpgss_integrations_form'])) { $out[$key] = !empty($input[$key]) ? 1 : 0; } elseif (array_key_exists($key, $input)) { $out[$key] = !empty($input[$key]) ? 1 : 0; } else { $out[$key] = !empty($old[$key]) ? 1 : 0; } } foreach (array('forms_sheet'=>'Contact Forms','orders_sheet'=>'Orders','items_sheet'=>'Order Items','customers_sheet'=>'Customers') as $key=>$default) { $value = sanitize_text_field($input[$key] ?? ($old[$key] ?? $default)); // Migrate legacy course-specific defaults to standard WooCommerce worksheet names. if ($key === 'orders_sheet' && $value === 'Courses') { $value = 'Orders'; } if ($key === 'items_sheet' && $value === 'Course Items') { $value = 'Order Items'; } $out[$key] = $value; } $out['worksheet_names'] = isset($old['worksheet_names']) && is_array($old['worksheet_names']) ? $old['worksheet_names'] : array(); $out['max_retries'] = min(10,max(1,absint($input['max_retries'] ?? ($old['max_retries'] ?? 5)))); // OAuth tokens and account details are managed only by the OAuth callback. $out['oauth_refresh_token'] = $credentials_changed ? '' : ($old['oauth_refresh_token'] ?? ''); $out['oauth_access_token'] = $credentials_changed ? '' : ($old['oauth_access_token'] ?? ''); $out['oauth_token_expires_at'] = $credentials_changed ? 0 : absint($old['oauth_token_expires_at'] ?? 0); $out['google_email'] = $credentials_changed ? '' : ($old['google_email'] ?? ''); $out['oauth_last_error'] = $credentials_changed ? '' : sanitize_text_field($old['oauth_last_error'] ?? ''); return $out; } private function settings() { $s = get_option('wpgss_settings', array()); if (!is_array($s)) { $s = array(); } if (($s['orders_sheet'] ?? '') === 'Courses') { $s['orders_sheet'] = 'Orders'; } if (($s['items_sheet'] ?? '') === 'Course Items') { $s['items_sheet'] = 'Order Items'; } // OAuth values are also stored as independent options. This avoids a third-party // filter or stale cached aggregate option hiding tokens after Google's callback. foreach (array('oauth_refresh_token','oauth_access_token','oauth_token_expires_at','google_email') as $key) { $v = get_option('wpgss_' . $key, null); if ($v !== null && $v !== '') { $s[$key] = $v; } } return $s; } private function save_oauth_settings($settings) { $settings = is_array($settings) ? $settings : array(); $token_keys = array('oauth_refresh_token','oauth_access_token','oauth_token_expires_at','google_email'); foreach ($token_keys as $key) { if (array_key_exists($key, $settings)) { $value = $settings[$key]; if ($key === 'oauth_token_expires_at') { $value = absint($value); } // update_option() returns false when the value is unchanged; read-back is authoritative. update_option('wpgss_' . $key, $value, false); } } // Keep the aggregate option in sync, but independent token options are authoritative. update_option('wpgss_settings', $settings, false); wp_cache_delete('alloptions', 'options'); wp_cache_delete('notoptions', 'options'); foreach (array('oauth_refresh_token','oauth_access_token','oauth_token_expires_at','google_email') as $key) { wp_cache_delete('wpgss_' . $key, 'options'); } $ok = true; foreach (array('oauth_access_token','oauth_token_expires_at') as $key) { $expected = $settings[$key] ?? ''; $actual = get_option('wpgss_' . $key, null); if ($actual === null || (string) $actual !== (string) $expected) { $ok = false; } } return $ok; } private function notice() { $key = 'wpgss_admin_notice_' . get_current_user_id(); $notice = get_transient( $key ); if ( ! is_array( $notice ) || empty( $notice['message'] ) ) { return; } delete_transient( $key ); $type = ( ( $notice['type'] ?? '' ) === 'error' ) ? 'notice-error' : 'notice-success'; echo '

' . esc_html( sanitize_text_field( (string) $notice['message'] ) ) . '

'; } private function redirect_notice($message,$type='success',$tab='connect') { set_transient( 'wpgss_admin_notice_' . get_current_user_id(), array( 'message' => sanitize_text_field( (string) $message ), 'type' => ( $type === 'error' ) ? 'error' : 'success', ), MINUTE_IN_SECONDS ); wp_safe_redirect( add_query_arg( array( 'page' => 'wpgss-sync', 'tab' => sanitize_key( $tab ) ), admin_url( 'admin.php' ) ) ); exit; } private function action_button($action,$label,$class='button') { echo '
'; wp_nonce_field($action); echo '
'; } /** Fixed custom-table name derived only from the trusted $wpdb prefix. */ private function queue_table_name() { global $wpdb; return $wpdb->prefix . 'wpgss_queue'; } private function bust_queue_count_cache() { foreach ( array( 'pending', 'processing', 'completed', 'failed', 'retry' ) as $status ) { wp_cache_delete( 'wpgss_queue_count_' . $status, 'wpgss' ); } } public function page() { if (!current_user_can('manage_options')) return; $s=$this->settings(); // Display-only tab switch; capability checked above. No form mutation occurs here. // phpcs:ignore WordPress.Security.NonceVerification.Recommended $tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'dashboard'; if (!in_array($tab,array('dashboard','connect','integrations','activity','diagnostics'),true)) $tab='dashboard'; $connected = !empty($s['oauth_refresh_token']) || (!empty($s['oauth_access_token']) && !empty($s['oauth_token_expires_at']) && (int) $s['oauth_token_expires_at'] > time() + 60); $sheet_ready=!empty($s['spreadsheet_id']); global $wpdb; $table=$this->queue_table_name(); $counts=array(); foreach ( array( 'pending', 'processing', 'completed', 'failed', 'retry' ) as $status ) { $cache_key = 'wpgss_queue_count_' . $status; $count = wp_cache_get( $cache_key, 'wpgss' ); if ( false === $count ) { // Custom plugin queue table — not available via WP APIs. %i is the identifier placeholder (WP 6.2+). // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $count = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE status = %s', $table, $status ) ); wp_cache_set( $cache_key, $count, 'wpgss', 30 ); } $counts[ $status ] = (int) $count; } $logs=get_option('wpgss_logs',array()); echo '
WORDPRESS INTEGRATION

Sheets Sync for Google

Connect once, choose what to send, and let your website keep your spreadsheet updated.

'; $this->notice(); echo ''; if ($tab==='dashboard') { echo '

Setup checklist

Follow these steps in order. You only need to connect your account once.

'; $this->check_step('1','Connect Google account',$connected?'Connected'.(!empty($s['google_email'])?' as '.$s['google_email']:''):'Authorize access to Google Sheets',$connected,admin_url('admin.php?page=wpgss-sync&tab=connect')); $this->check_step('2','Choose your spreadsheet',$sheet_ready?'Spreadsheet ID saved':'Paste your spreadsheet URL or ID',$sheet_ready,admin_url('admin.php?page=wpgss-sync&tab=connect')); $this->check_step('3','Choose worksheet tabs',!empty($s['worksheet_names'])?'Worksheets loaded':'Load worksheets and select destinations',!empty($s['worksheet_names']),admin_url('admin.php?page=wpgss-sync&tab=connect')); $this->check_step('4','Enable integrations','Select the data sources you want to sync',!empty($s['elementor_enabled'])||!empty($s['cf7_enabled'])||!empty($s['woocommerce_orders_enabled'])||!empty($s['woocommerce_customers_enabled']),admin_url('admin.php?page=wpgss-sync&tab=integrations')); echo '

Sync activity

Queue status for your latest records.

View activity →
'; foreach(array('pending'=>'Waiting','processing'=>'In progress','completed'=>'Synced','failed'=>'Failed') as $key=>$label) echo '
'.esc_html($counts[$key]).''.esc_html($label).'
'; echo '
'; } elseif ($tab==='connect') { $this->connect_page($s); } elseif ($tab==='integrations') { $this->integrations_page($s); } elseif ($tab==='activity') { $this->activity_page($counts,$logs); } else { $this->diagnostics_page($s, $counts, $logs); } echo '

Google OAuth tokens are stored in WordPress options. Restrict administrator access and keep your OAuth client secret private.

'; } private function check_step($n,$title,$desc,$done,$url) { echo '
'.($done?'✓':esc_html( (string) $n )).'
'.esc_html($title).'

'.esc_html($desc).'

'.esc_html($done?'Review':'Set up').' →
'; } /** * Step-by-step OAuth / Google Cloud setup instructions shown on the Connect tab. * * @param string $redirect_uri Exact WordPress redirect URI for Google Cloud. */ private function oauth_setup_guide( $redirect_uri ) { $guide_url = plugins_url( 'docs/SETUP-GUIDE.md', WPGSS_FILE ); echo '
'; echo '

How to create OAuth Client ID & Secret (step by step)

'; echo '

Follow these steps once in Google Cloud, then paste the Client ID and Secret into the form below. You do not need a Service Account JSON key.

'; echo '
    '; echo '
  1. Create a Google Cloud project
    '; echo 'Open Google Cloud Console, sign in, and create or select a project (for example “WordPress Sheets Sync”).
  2. '; echo '
  3. Enable Google Sheets API
    '; echo 'Open Google Sheets API and click Enable.
  4. '; echo '
  5. Configure the OAuth consent screen
    '; echo 'Open Google Auth Platform / OAuth consent screen. Set app name, support email, and developer contact. Choose External (unless everyone is in your Google Workspace). If the app is in Testing mode, add your Google account under Test users.
  6. '; echo '
  7. Create an OAuth Client (Web application)
    '; echo 'Open Credentials / Clients → Create Client → choose Web application (not Desktop, Android, iOS, or Service account).
  8. '; echo '
  9. Add this exact Authorized redirect URI
    '; echo 'Under Authorized redirect URIs, click Add URI and paste this value exactly (https, domain, path, and query must match):
  10. '; echo '
'; echo '
'; echo ''; echo '

Do not put this under Authorized JavaScript origins. A mismatch causes Google’s redirect_uri_mismatch error.

'; echo '
    '; echo '
  1. Copy Client ID and Client Secret
    '; echo 'After creating the client, copy the Client ID and Client Secret from Google Cloud.
  2. '; echo '
  3. Save them in WordPress (form below)
    '; echo 'Paste Client ID and Client Secret into the fields below → click Save OAuth settings → then click Connect Google account and approve access with the Google account that can edit your spreadsheet.
  4. '; echo '
  5. Finish spreadsheet setup
    '; echo 'On this same page: paste your spreadsheet URL or ID → Save spreadsheet → Test connection → Load worksheet tabs → choose destinations and save.
  6. '; echo '
'; echo '
Troubleshooting common issues
'; echo '

Full written guide (same steps in more detail): docs/SETUP-GUIDE.md

'; echo '
'; } private function connect_page($s) { $connected = !empty($s['oauth_refresh_token']) || (!empty($s['oauth_access_token']) && !empty($s['oauth_token_expires_at']) && (int) $s['oauth_token_expires_at'] > time() + 60); $redirect=admin_url('admin-post.php?action=wpgss_oauth_callback'); $this->oauth_setup_guide( $redirect ); echo '

Privacy & external services

'; echo '

This plugin uses Google OAuth 2.0 and the Google Sheets API only. Data is sent to Google when an administrator connects an account, tests the connection, loads worksheets, or when an enabled integration syncs a form submission or WooCommerce record.

'; echo '

Google Terms of Service, Privacy Policy, and Google APIs Terms of Service. Full disclosure is also in the plugin readme.

'; echo '
'; echo '

1. Connect your Google account

Paste the OAuth Client ID and Client Secret from Google Cloud below. This method does not require a Service Account JSON key.

'; settings_fields('wpgss_settings_group'); echo '
'; echo '

The saved secret is not displayed. Leave blank to keep the current one.

'; echo '

In Google Cloud → APIs & Services → Credentials → your OAuth client → Authorized redirect URIs, add this exact URI.

'; echo '

Before connecting: enable Google Sheets API and configure the OAuth consent screen. If the app is in Testing mode, add your Google account as a test user.

'; if ($connected && empty($s['oauth_refresh_token'])) { echo '

Temporary connection: Google authorized this account but did not provide a refresh token. It may stop working when the access token expires. Reconnect with offline access enabled.

'; } submit_button('Save OAuth settings','primary','submit',false); echo '

Google account: '.($connected?'Connected'.(!empty($s['google_email'])?' — '.esc_html($s['google_email']):''):'Not connected').'

'; if (!$connected && !empty($s['oauth_last_error'])) echo '

Last connection issue: '.esc_html($s['oauth_last_error']).'

'; echo '

Diagnostic: '.(!empty($s['oauth_refresh_token']) ? 'refresh token saved — connection renews automatically' : (!empty($s['oauth_access_token']) ? 'temporary access token saved (reconnect recommended for a lasting connection)' : 'no OAuth token saved')).'.

'; if (!empty($s['oauth_client_id'])&&!empty($s['oauth_client_secret'])) $this->action_button('wpgss_oauth_start',$connected?'Reconnect Google account':'Connect Google account','button-primary'); if ($connected) $this->action_button('wpgss_disconnect','Disconnect Google account','button-secondary'); echo '

2. Select the exact spreadsheet

Paste the URL from your browser or just the spreadsheet ID. The plugin will use this spreadsheet only.

'; settings_fields('wpgss_settings_group'); echo '
'; echo '

Current spreadsheet: '.esc_html($s['spreadsheet_title']??($s['spreadsheet_id']?'ID saved, not yet tested':'None selected')).'

'; submit_button('Save spreadsheet','primary','submit',false); echo '
'; $this->action_button('wpgss_test_connection','Test connection','button-primary'); $this->action_button('wpgss_load_sheets','Load worksheet tabs','button-secondary'); echo '
'; if (!empty($s['worksheet_names'])) echo '

Found '.count($s['worksheet_names']).' worksheet tab(s). Choose a destination for each integration below.

'; echo '

3. Choose destination worksheet tabs

Use standard WooCommerce worksheet tabs: Orders, Order Items, and Customers. These dropdowns are populated from the spreadsheet above. If you change the spreadsheet, click “Load worksheet tabs” again.

'; settings_fields('wpgss_settings_group'); foreach ( array( 'forms_sheet' => 'Form submissions', 'orders_sheet' => 'WooCommerce orders', 'items_sheet' => 'WooCommerce order items', 'customers_sheet' => 'WooCommerce customers' ) as $key => $label ) { $saved_value = isset( $s[ $key ] ) ? (string) $s[ $key ] : ''; echo '
'; echo ''; } else { foreach ( $s['worksheet_names'] as $name ) { echo ''; } echo ''; } echo '
'; } submit_button('Save worksheet destinations','primary','submit',false); echo '

' . ( empty( $s['worksheet_names'] ) ? 'Save your spreadsheet, then click “Load worksheet tabs” to populate these dropdowns.' : 'A missing worksheet tab is created automatically when the first record is synced.' ) . '

'; } private function integrations_page($s) { echo '
'; settings_fields('wpgss_settings_group'); echo '

Choose what to sync

Turn on only the data you want sent to the selected spreadsheet.

'; $cards=array('elementor_enabled'=>array('Elementor Pro Forms','New submissions from Elementor Pro Forms.'),'cf7_enabled'=>array('Contact Form 7','Submissions are sent after Contact Form 7 mail is sent.'),'woocommerce_orders_enabled'=>array('WooCommerce orders','Order totals, status, customer details, and product summary.'),'woocommerce_customers_enabled'=>array('WooCommerce customers','Registered customer account and billing details.')); foreach($cards as $key=>$v) echo ''; echo '
Advanced settings
'; submit_button('Save sync settings','primary','submit',false); echo '

This build implements Elementor Pro Forms, Contact Form 7, WooCommerce orders, and registered customers. WPForms, Fluent Forms, and Gravity Forms are not yet implemented.

'; } private function activity_page($counts,$logs) { echo '

Manual tools

Use these if you need to process queued data or recover failed jobs.

'; $this->action_button('wpgss_sync_now','Process queue now','button-primary'); $this->action_button('wpgss_retry_failed','Retry failed jobs','button-secondary'); $this->action_button('wpgss_import_orders','Queue up to 100 historical orders','button-secondary'); echo '
'; foreach(array('pending'=>'Waiting','processing'=>'In progress','completed'=>'Synced','failed'=>'Failed','retry'=>'Retrying') as $key=>$label) echo '
'.esc_html($counts[$key]).''.esc_html($label).'
'; echo '

Recent activity

'; if (!$logs) echo ''; else foreach(array_slice($logs,0,40) as $log) echo ''; echo '
TimeTypeMessage
No activity yet. Test the connection or send a test form submission.
'.esc_html($log['time']??'').''.esc_html($log['type']??'').''.esc_html($log['message']??'').'
'; } public function wpgss_oauth_start() { if (!current_user_can('manage_options') || !check_admin_referer('wpgss_oauth_start')) wp_die('Unauthorized'); $s=$this->settings(); if (empty($s['oauth_client_id'])||empty($s['oauth_client_secret'])) $this->redirect_notice('Save the OAuth Client ID and Client Secret first.','error'); $state=wp_generate_password(48,false,false); $state_key = hash('sha256', $state); $state_data = array('user_id'=>get_current_user_id(),'created'=>time()); // Store state in both transient and a non-autoloaded option. Some hosts/object caches // drop transients between the outbound redirect and Google's callback. set_transient('wpgss_oauth_state_'.$state_key, $state_data, 15*MINUTE_IN_SECONDS); update_option('wpgss_oauth_state_'.$state_key, $state_data, false); $url = add_query_arg( array( 'client_id' => $s['oauth_client_id'], 'redirect_uri' => admin_url('admin-post.php?action=wpgss_oauth_callback'), 'response_type' => 'code', 'scope' => 'https://www.googleapis.com/auth/spreadsheets https://www.googleapis.com/auth/userinfo.email', 'access_type' => 'offline', 'prompt' => 'consent', 'include_granted_scopes' => 'true', 'state' => $state, ), 'https://accounts.google.com/o/oauth2/v2/auth' ); // Allow only Google's OAuth host for this redirect, then use the safe // WordPress redirect API. This satisfies redirect validation without // permitting arbitrary external redirect destinations. $google_oauth_host = 'accounts.google.com'; $allow_google_oauth_redirect = function ( $hosts ) use ( $google_oauth_host ) { $hosts[] = $google_oauth_host; return array_unique( $hosts ); }; add_filter( 'allowed_redirect_hosts', $allow_google_oauth_redirect ); wp_safe_redirect( esc_url_raw( $url ), 302, 'Sheets Sync Manager for Google' ); remove_filter( 'allowed_redirect_hosts', $allow_google_oauth_redirect ); exit; } public function wpgss_oauth_callback() { /* * Google redirects here with ?state=&code= (or ?error=). CSRF is enforced via the * one-time hashed state stored when Connect started — not a WordPress nonce. */ // phpcs:disable WordPress.Security.NonceVerification.Recommended $state = isset( $_GET['state'] ) ? sanitize_text_field( wp_unslash( $_GET['state'] ) ) : ''; $google_error_raw = isset( $_GET['error'] ) ? sanitize_text_field( wp_unslash( $_GET['error'] ) ) : ''; $code = isset( $_GET['code'] ) ? sanitize_text_field( wp_unslash( $_GET['code'] ) ) : ''; // phpcs:enable WordPress.Security.NonceVerification.Recommended $state_key = $state !== '' ? 'wpgss_oauth_state_' . hash('sha256', $state) : ''; $state_data = $state_key !== '' ? get_transient($state_key) : false; if (!is_array($state_data) && $state !== '') { $state_data = get_option('wpgss_oauth_state_'.hash('sha256', $state), false); } if ($state_key !== '') delete_transient($state_key); if ($state !== '') delete_option('wpgss_oauth_state_'.hash('sha256', $state)); if (!is_array($state_data) || empty($state_data['user_id']) || empty($state) || (time() - absint($state_data['created'] ?? 0)) > 15 * MINUTE_IN_SECONDS) { $message = 'OAuth callback reached WordPress, but its security state was missing or expired. No token was saved. Start Connect again; if this repeats, check caching/security plugins and ensure the site uses one consistent HTTPS/www domain.'; $this->record_diagnostic('oauth_error', $message); $this->redirect_notice($message, 'error'); } $uid = absint($state_data['user_id']); $oauth_user = get_user_by('id', $uid); if (!$oauth_user || !user_can($oauth_user, 'manage_options')) $this->redirect_notice('The administrator who started this connection is no longer authorized. Sign in as an administrator and connect again.','error'); if ($google_error_raw !== '') { $s = $this->settings(); $s['oauth_last_error'] = 'Google authorization failed: ' . $google_error_raw; update_option('wpgss_settings', $s, false); $this->record_diagnostic('oauth_error', $s['oauth_last_error']); $this->redirect_notice($s['oauth_last_error'], 'error'); } $s = $this->settings(); if (!$code) { $err = 'Google did not return an authorization code. Check the OAuth consent screen, redirect URI, and Google account authorization.'; $s['oauth_last_error'] = $err; update_option('wpgss_settings', $s, false); $this->record_diagnostic('oauth_error', $err); $this->redirect_notice($err, 'error'); } $response = wp_remote_post('https://oauth2.googleapis.com/token', array( 'timeout'=>25, 'headers'=>array('Content-Type'=>'application/x-www-form-urlencoded'), 'body'=>array('code'=>$code,'client_id'=>$s['oauth_client_id'] ?? '', 'client_secret'=>$s['oauth_client_secret'] ?? '', 'redirect_uri'=>admin_url('admin-post.php?action=wpgss_oauth_callback'), 'grant_type'=>'authorization_code') )); if (is_wp_error($response)) { $err = 'Could not contact Google: '.$response->get_error_message(); $s['oauth_last_error'] = $err; update_option('wpgss_settings',$s,false); $this->record_diagnostic('oauth_error', $err); $this->redirect_notice($err,'error'); } $body = json_decode(wp_remote_retrieve_body($response), true); if (wp_remote_retrieve_response_code($response)<200 || wp_remote_retrieve_response_code($response)>=300 || empty($body['access_token'])) { $err = 'Google token exchange failed (HTTP '.absint(wp_remote_retrieve_response_code($response)).'): '.sanitize_text_field($body['error_description'] ?? $body['error'] ?? 'Check OAuth client credentials, consent screen, and exact redirect URI.'); $s['oauth_last_error'] = $err; update_option('wpgss_settings',$s,false); $this->record_diagnostic('oauth_error', $err); $this->redirect_notice($err,'error'); } if (!empty($body['refresh_token'])) $s['oauth_refresh_token'] = sanitize_text_field($body['refresh_token']); $s['oauth_access_token'] = sanitize_text_field($body['access_token']); $s['oauth_token_expires_at'] = time() + max(60, absint($body['expires_in'] ?? 3600)); $s['oauth_last_error'] = ''; $email_response = wp_remote_get('https://www.googleapis.com/oauth2/v2/userinfo', array('timeout'=>15,'headers'=>array('Authorization'=>'Bearer '.$s['oauth_access_token']))); if (!is_wp_error($email_response)) { $u = json_decode(wp_remote_retrieve_body($email_response), true); if (!empty($u['email'])) $s['google_email'] = sanitize_email($u['email']); } $saved_ok = $this->save_oauth_settings($s); if (!$saved_ok) { global $wpdb; $db_detail = !empty($wpdb->last_error) ? ' Database reported an error while saving plugin options.' : ''; $err = 'Google returned an access token, but WordPress could not verify the dedicated OAuth token options after saving.' . $db_detail . ' Check the database error log and any persistent object-cache or security plugin. No token value was written to the diagnostic log.'; $this->record_diagnostic('oauth_error', $err); $this->redirect_notice($err, 'error', 'diagnostics'); } if (empty($s['oauth_refresh_token'])) { $err = 'Google returned a temporary access token but no refresh token. The plugin cannot maintain a long-lived connection. In Google Cloud, set the OAuth consent screen to External and add this Google account as a test user if the app is in Testing. Then remove the app from Google Account → Security → Your connections to third-party apps, and click Reconnect Google account again. If the app is Internal, use an account from that Workspace organization.'; $s['oauth_last_error'] = $err; update_option('wpgss_settings', $s, false); $this->record_diagnostic('oauth_error', $err); $this->redirect_notice($err, 'error'); } $this->redirect_notice('Google account connected successfully'.(!empty($s['google_email']) ? ' as '.$s['google_email'] : '').'. Now test access to your selected spreadsheet.','success'); } public function wpgss_disconnect() { if (!current_user_can('manage_options')||!check_admin_referer('wpgss_disconnect')) wp_die('Unauthorized'); $s=$this->settings(); unset($s['oauth_refresh_token'],$s['oauth_access_token'],$s['oauth_token_expires_at'],$s['google_email'],$s['oauth_last_error']); foreach (array('oauth_refresh_token','oauth_access_token','oauth_token_expires_at','google_email') as $key) { delete_option('wpgss_'.$key); } update_option('wpgss_settings',$s,false); $this->redirect_notice('Google account disconnected.','success'); } private function record_diagnostic($type, $message) { $logs=get_option('wpgss_logs',array()); if(!is_array($logs)) $logs=array(); array_unshift($logs,array('time'=>current_time('mysql'),'type'=>sanitize_key($type),'message'=>sanitize_text_field($message))); update_option('wpgss_logs',array_slice($logs,0,200),false); } public function wpgss_test_connection() { if (!current_user_can('manage_options')||!check_admin_referer('wpgss_test_connection')) wp_die('Unauthorized'); $result=WPGSS_Sync_Manager::instance()->test_connection(); if (is_wp_error($result)) { $this->record_diagnostic('connection_error', $result->get_error_message()); $this->redirect_notice($result->get_error_message(),'error','diagnostics'); } $s=$this->settings(); $s['spreadsheet_title']=$result['properties']['title']??'Connected spreadsheet'; update_option('wpgss_settings',$s,false); $this->redirect_notice('Connection successful. Google can access “'.$s['spreadsheet_title'].'”.','success'); } public function wpgss_load_sheets() { if (!current_user_can('manage_options') || !check_admin_referer('wpgss_load_sheets')) { wp_die('Unauthorized'); } $s = $this->settings(); if (empty($s['spreadsheet_id'])) { $this->redirect_notice('Paste and save a spreadsheet URL or ID first.', 'error'); } $client = new WPGSS_Google_Sheets($s); $result = $client->list_worksheets($s['spreadsheet_id']); if (is_wp_error($result)) { $this->redirect_notice($result->get_error_message(), 'error'); } $names = $result['worksheets']; // Rename legacy course-specific tabs to standard WooCommerce names when the target does not exist. $renames = array('Courses' => 'Orders', 'Course Items' => 'Order Items'); foreach ($renames as $legacy => $standard) { if (in_array($legacy, $names, true) && !in_array($standard, $names, true)) { $rename_result = $client->rename_worksheet($s['spreadsheet_id'], $legacy, $standard); if (is_wp_error($rename_result)) { $this->redirect_notice('Could not rename worksheet “' . $legacy . '” to “' . $standard . '”: ' . $rename_result->get_error_message(), 'error'); } $names = array_map(function($name) use ($legacy, $standard) { return $name === $legacy ? $standard : $name; }, $names); } } $s['worksheet_names'] = $names; $s['spreadsheet_title'] = $result['title']; foreach (array('forms_sheet' => 'Contact Forms', 'orders_sheet' => 'Orders', 'items_sheet' => 'Order Items', 'customers_sheet' => 'Customers') as $key => $default) { $current = $s[$key] ?? ''; if ($key === 'orders_sheet' && $current === 'Courses') { $current = 'Orders'; } if ($key === 'items_sheet' && $current === 'Course Items') { $current = 'Order Items'; } if (empty($current) || !in_array($current, $names, true)) { $s[$key] = in_array($default, $names, true) ? $default : ($names[0] ?? $default); } else { $s[$key] = $current; } } update_option('wpgss_settings', $s, false); $this->redirect_notice('Loaded ' . count($names) . ' worksheet tabs from “' . $result['title'] . '”. Legacy Courses tabs were renamed where applicable. Save destinations below.'); } public function wpgss_sync_now() { if (!current_user_can('manage_options')||!check_admin_referer('wpgss_sync_now')) wp_die('Unauthorized'); WPGSS_Sync_Manager::instance()->process_queue(); $this->redirect_notice('Queue processing was triggered. Review Activity & tools for results.','success','activity'); } public function wpgss_retry_failed() { if ( ! current_user_can( 'manage_options' ) || ! check_admin_referer( 'wpgss_retry_failed' ) ) { wp_die( 'Unauthorized' ); } global $wpdb; $table = $this->queue_table_name(); // Custom plugin queue table — not available via WP APIs. %i is the identifier placeholder (WP 6.2+). // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->query( $wpdb->prepare( "UPDATE %i SET status = 'retry', attempts = 0, updated_at = %s WHERE status = 'failed'", $table, current_time( 'mysql' ) ) ); $this->bust_queue_count_cache(); WPGSS_Sync_Manager::instance()->process_queue(); $this->redirect_notice( 'Failed jobs have been queued for retry.', 'success', 'activity' ); } public function wpgss_import_orders() { if ( ! current_user_can( 'manage_options' ) || ! check_admin_referer( 'wpgss_import_orders' ) ) { wp_die( 'Unauthorized' ); } if ( ! function_exists( 'wc_get_orders' ) ) { $this->redirect_notice( 'WooCommerce is not active.', 'error', 'activity' ); } $ids = wc_get_orders( array( 'limit' => 100, 'return' => 'ids', 'orderby' => 'date', 'order' => 'ASC', ) ); $sync = WPGSS_Sync_Manager::instance(); foreach ( $ids as $id ) { // Call the plugin sync path directly (avoids inventing/firing unprefixed hooks). $sync->woocommerce_order_created( $id ); } $this->bust_queue_count_cache(); $this->redirect_notice( 'Queued up to 100 historical orders. Check Activity & tools for progress.', 'success', 'activity' ); } private function diagnostics_page($s, $counts, $logs) { global $wpdb; $table = $this->queue_table_name(); // Custom plugin queue table presence check. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) === $table; $checks = array( array('WordPress version', get_bloginfo('version'), version_compare(get_bloginfo('version'), '6.2', '>=')), array('PHP version', PHP_VERSION, version_compare(PHP_VERSION, '7.4', '>=')), array('WordPress HTTP API', function_exists('wp_remote_post') ? 'Available' : 'Missing', function_exists('wp_remote_post')), array('OpenSSL extension', extension_loaded('openssl') ? 'Available' : 'Missing', extension_loaded('openssl')), array('Queue database table', $table_exists ? 'Present' : 'Missing — deactivate/activate plugin to recreate it', $table_exists), array('Google OAuth Client ID', !empty($s['oauth_client_id']) ? 'Saved' : 'Not saved', !empty($s['oauth_client_id'])), array('Google OAuth Client Secret', !empty($s['oauth_client_secret']) ? 'Saved (hidden)' : 'Not saved', !empty($s['oauth_client_secret'])), array('Google refresh token', !empty($s['oauth_refresh_token']) ? 'Saved' : 'Not saved — reconnect Google', !empty($s['oauth_refresh_token'])), array('Spreadsheet ID', !empty($s['spreadsheet_id']) ? 'Saved' : 'Not saved', !empty($s['spreadsheet_id'])), array('Debug logging', (defined('WP_DEBUG') && WP_DEBUG) ? 'Enabled' : 'Disabled', true), ); echo '

System health checks

These checks do not expose your client secret or OAuth tokens. A green row means the local prerequisite is present; it does not by itself prove that Google API access works.

'; foreach ($checks as $check) { echo ''; } echo '
CheckResultStatus
'.esc_html($check[0]).''.esc_html($check[1]).''.($check[2]?'OK':'ACTION REQUIRED').'

Live Google test

This sends a real request to the Google Sheets API using the saved authorization.

'; $this->action_button('wpgss_test_connection','Test Google + spreadsheet access','button-primary'); echo '

Queue counts

Waiting: '.absint($counts['pending']??0).' · Retrying: '.absint($counts['retry']??0).' · Failed: '.absint($counts['failed']??0).' · Completed: '.absint($counts['completed']??0).'

'; echo '

Recent diagnostic events

Full messages are shown below. Never share a screenshot containing client secrets or tokens.

'; $shown = 0; foreach ((array)$logs as $log) { $type = (string)($log['type'] ?? ''); if (stripos($type, 'error') === false && stripos($type, 'oauth') === false && stripos($type, 'connection') === false) continue; echo ''; if (++$shown >= 30) break; } if (!$shown) echo ''; echo '
TimeTypeMessage
'.esc_html($log['time']??'').''.esc_html($type).''.esc_html($log['message']??'').'
No diagnostic errors recorded yet. Run the live Google test above.
'; if (defined('WP_DEBUG_LOG') && WP_DEBUG_LOG) { echo '

WordPress debug logging is enabled. Check the configured debug log for PHP fatal errors. Plugin diagnostics cannot intercept every fatal error safely.

'; } else { echo '

WordPress debug logging is not enabled. If the site crashes, enable WP_DEBUG and WP_DEBUG_LOG temporarily in wp-config.php, reproduce the issue, then inspect wp-content/debug.log. Do not leave WP_DEBUG_DISPLAY enabled on a live site.

'; } echo '
'; } private function css() { return '.wpgss{max-width:1180px;margin:22px 20px 40px 0;color:#1d2327}.wpgss *{box-sizing:border-box}.wpgss-hero{display:flex;justify-content:space-between;align-items:center;gap:20px;background:#153c32;color:#fff;padding:27px 30px;border-radius:14px;margin:18px 0 20px}.wpgss-hero h1{color:#fff;font-size:28px;margin:8px 0}.wpgss-hero p{color:#d7e8df;margin:0}.wpgss-hero>.dashicons{font-size:44px;width:44px;height:44px;color:#cce9d8}.wpgss-kicker{font-size:11px;letter-spacing:1.5px;font-weight:700;color:#b8dec9}.wpgss-nav{display:flex;gap:4px;overflow:auto;border-bottom:1px solid #dcdcde;margin-bottom:22px}.wpgss-nav a{padding:13px 15px;text-decoration:none;color:#50575e;font-weight:600;white-space:nowrap;border-bottom:3px solid transparent}.wpgss-nav a.active{border-color:#228153;color:#153c32}.wpgss-grid{display:grid;grid-template-columns:minmax(0,1.7fr) minmax(270px,1fr);gap:20px;align-items:start}.wpgss-grid main,.wpgss-grid aside{display:grid;gap:20px;min-width:0}.wpgss-card{background:#fff;border:1px solid #dcdcde;border-radius:12px;padding:24px;margin:0 0 20px;min-width:0;box-shadow:0 2px 7px #00000005}.wpgss-grid .wpgss-card{margin:0}.wpgss-card h2{font-size:18px;margin:0 0 7px}.wpgss-card p{line-height:1.55;color:#646970}.wpgss-card-head{display:flex;justify-content:space-between;gap:15px;align-items:start}.wpgss-card-head a{text-decoration:none;font-weight:600}.wpgss-step{display:flex;gap:14px;padding:17px 0;border-top:1px solid #f0f0f1}.wpgss-step:first-of-type{margin-top:12px}.wpgss-step-num{width:33px;height:33px;border-radius:50%;background:#f0f0f1;display:grid;place-items:center;font-weight:700;flex-shrink:0}.wpgss-step-num.done{background:#e2f3e8;color:#17663b}.wpgss-step strong{display:block;margin:1px 0 3px}.wpgss-step p{margin:0 0 5px}.wpgss-step a{font-size:13px;font-weight:600;text-decoration:none}.wpgss-stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin-top:17px}.wpgss-stats>div{padding:14px;background:#f7f9f8;border:1px solid #edf0ee;border-radius:8px;display:flex;flex-direction:column;gap:5px}.wpgss-stats strong{font-size:24px;color:#153c32}.wpgss-stats span{font-size:12px;color:#646970}.wpgss-dot{display:inline-block;width:9px;height:9px;border-radius:50%;background:#dba617;margin-right:8px}.wpgss-dot.good{background:#218653}.wpgss-muted,.wpgss-foot{font-size:13px;color:#646970}.wpgss-foot{margin-top:20px}.wpgss-field{margin:18px 0}.wpgss-field>label{display:block;font-weight:650;margin-bottom:8px}.wpgss-field input:not([type=hidden]),.wpgss-field select{display:block;width:100%;max-width:520px;min-height:42px;padding:9px 12px;border:1px solid #8c8f94;border-radius:5px;background:#fff;color:#1d2327;font-size:14px;line-height:1.4;box-shadow:0 0 0 transparent}.wpgss-field input:focus,.wpgss-field select:focus{border-color:#2271b1;box-shadow:0 0 0 1px #2271b1;outline:2px solid transparent}.wpgss-field select:disabled{background:#f6f7f7;color:#646970;cursor:not-allowed}.wpgss-destination-field{margin:16px 0 20px}.wpgss-field .description{margin-top:7px}.wpgss-card hr{border:0;border-top:1px solid #eee;margin:22px 0}.wpgss-inline-form{display:inline-block;margin:0 8px 8px 0}.wpgss-actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}.wpgss-toggle{display:flex;align-items:center;gap:14px;padding:16px;border:1px solid #e2e5e3;border-radius:9px;margin:12px 0;cursor:pointer}.wpgss-toggle input{position:absolute;opacity:0}.wpgss-toggle>span:last-child{display:flex;flex-direction:column;gap:4px;flex:1}.wpgss-toggle small{font-size:12px;color:#646970}.wpgss-switch{width:38px;height:22px;border-radius:20px;background:#c3c4c7;position:relative;flex-shrink:0}.wpgss-switch:after{content:"";position:absolute;left:3px;top:3px;width:16px;height:16px;background:white;border-radius:50%;transition:transform .15s}.wpgss-toggle input:checked+.wpgss-switch{background:#218653}.wpgss-toggle input:checked+.wpgss-switch:after{transform:translateX(16px)}.wpgss-toggle input:focus-visible+.wpgss-switch{outline:2px solid #2271b1;outline-offset:3px}.wpgss-advanced{padding:14px;background:#f8f9f9;border:1px solid #e6e8e7;border-radius:8px;margin:20px 0}.wpgss-advanced summary{font-weight:600;cursor:pointer}.wpgss-table{overflow:auto}.wpgss-table table{border:0}.wpgss-table td,.wpgss-table th{padding:12px}.wpgss-success{padding:10px 12px;background:#edf8f0;border-radius:7px;color:#17663b!important}.wpgss .button-primary{background:#176b48;border-color:#176b48}.wpgss .button-primary:hover{background:#105438;border-color:#105438}.wpgss-guide{border-color:#b8dec9;background:linear-gradient(180deg,#f4faf6 0%,#fff 48px)}.wpgss-guide-steps{margin:14px 0 0;padding-left:22px;color:#1d2327}.wpgss-guide-steps li{margin:0 0 14px;line-height:1.55}.wpgss-guide-steps a{font-weight:600}.wpgss-guide-uri{margin-top:4px;padding:14px;background:#edf8f0;border:1px solid #cce9d8;border-radius:8px}.wpgss-guide-uri input{max-width:100%!important;background:#fff}.wpgss-guide-help{margin-top:16px;padding:12px 14px;background:#f8f9f9;border:1px solid #e6e8e7;border-radius:8px}.wpgss-guide-help summary{font-weight:600;cursor:pointer}.wpgss-guide-help ul{margin:10px 0 0;padding-left:18px}.wpgss-guide-help li{margin:0 0 8px;line-height:1.5}@media(max-width:900px){.wpgss-grid{grid-template-columns:1fr}.wpgss-stats{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:600px){.wpgss{margin-right:10px}.wpgss-hero{padding:20px}.wpgss-hero h1{font-size:23px}.wpgss-card{padding:17px}.wpgss-stats{gap:7px}.wpgss-stats>div{padding:11px}}'; } }