From 4fc6676e0c62ac539633c8767656cf6a288dad66 Mon Sep 17 00:00:00 2001
From: ben
Date: Sat, 20 Dec 2025 09:06:59 -0400
Subject: [PATCH] fix: Zoho scheduled sync persistence issue
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Load HVAC_Zoho_Scheduled_Sync on ALL requests (not just admin)
so WP-Cron can find custom schedules and action hooks
- Add add_option hook for first-time setting creation
- Explicitly call schedule_sync() in save_settings() to ensure
scheduling works even when option value hasn't changed
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5
---
includes/admin/class-zoho-admin.php | 280 +++++++++++---
includes/class-hvac-plugin.php | 33 +-
includes/zoho/class-zoho-scheduled-sync.php | 389 ++++++++++++++++++++
3 files changed, 650 insertions(+), 52 deletions(-)
create mode 100644 includes/zoho/class-zoho-scheduled-sync.php
diff --git a/includes/admin/class-zoho-admin.php b/includes/admin/class-zoho-admin.php
index 8f3ee018..ec285f9a 100644
--- a/includes/admin/class-zoho-admin.php
+++ b/includes/admin/class-zoho-admin.php
@@ -43,6 +43,8 @@ class HVAC_Zoho_Admin {
add_action('wp_ajax_hvac_zoho_sync_data', array($this, 'sync_data'));
add_action('wp_ajax_hvac_zoho_save_credentials', array($this, 'save_credentials'));
add_action('wp_ajax_hvac_zoho_flush_rewrite_rules', array($this, 'flush_rewrite_rules_ajax'));
+ add_action('wp_ajax_hvac_zoho_save_settings', array($this, 'save_settings'));
+ add_action('wp_ajax_hvac_zoho_run_scheduled_sync', array($this, 'run_scheduled_sync_now'));
// Add simple test handler
add_action('wp_ajax_hvac_zoho_simple_test', array($this, 'simple_test'));
// Add OAuth callback handler - only use one method to prevent duplicates
@@ -50,8 +52,22 @@ class HVAC_Zoho_Admin {
add_filter('query_vars', array($this, 'add_oauth_query_vars'), 10, 1);
add_action('template_redirect', array($this, 'handle_oauth_template_redirect'));
+ // Fallback: Check for OAuth params on init (in case rewrite rules fail and we land on homepage)
+ add_action('init', array($this, 'check_for_oauth_params'));
+
// Ensure rewrite rules are flushed when plugin is activated
register_activation_hook(HVAC_PLUGIN_FILE, array($this, 'flush_rewrite_rules_on_activation'));
+
+ // Initialize scheduled sync if enabled
+ $this->init_scheduled_sync();
+ }
+
+ /**
+ * Initialize scheduled sync
+ */
+ private function init_scheduled_sync() {
+ require_once HVAC_PLUGIN_DIR . 'includes/zoho/class-zoho-scheduled-sync.php';
+ HVAC_Zoho_Scheduled_Sync::instance();
}
/**
@@ -125,27 +141,13 @@ class HVAC_Zoho_Admin {
// Debug logging
- // More robust production detection
- $parsed_url = parse_url($site_url);
- $host = isset($parsed_url['host']) ? $parsed_url['host'] : '';
-
- // Remove www prefix for comparison
- $clean_host = preg_replace('/^www\./', '', $host);
-
- // Check if this is production
- $is_production = ($clean_host === 'upskillhvac.com');
-
- // Double-check with string comparison as fallback
- if (!$is_production) {
- $is_production = (strpos($site_url, 'upskillhvac.com') !== false &&
- strpos($site_url, 'staging') === false &&
- strpos($site_url, 'test') === false &&
- strpos($site_url, 'dev') === false &&
- strpos($site_url, 'cloudwaysapps.com') === false);
+ // Ensure Auth class is loaded for staging detection
+ if (!class_exists('HVAC_Zoho_CRM_Auth')) {
+ require_once HVAC_PLUGIN_DIR . 'includes/zoho/class-zoho-crm-auth.php';
}
- // Set staging as opposite of production
- $is_staging = !$is_production;
+ // Use central logic for staging detection
+ $is_staging = HVAC_Zoho_CRM_Auth::is_staging_mode();
// Load secure storage class
@@ -268,6 +270,9 @@ class HVAC_Zoho_Admin {
🚀 Authorize with Zoho
+
@@ -329,24 +334,82 @@ class HVAC_Zoho_Admin {
-
Sync Settings
+
Scheduled Sync Settings
+ get_status();
+ $current_frequency = get_option('hvac_zoho_sync_frequency', 'every_5_minutes');
+ ?>
+
@@ -359,8 +422,23 @@ class HVAC_Zoho_Admin {
* Simple test handler to isolate issues
*/
public function simple_test() {
+ // Check permissions
+ if (!current_user_can('manage_options')) {
+ wp_send_json_error(array('message' => 'Unauthorized access'));
+ return;
+ }
- wp_send_json_success(array('message' => 'Simple test works!'));
+ $payload_status = 'No payload received';
+ if (!empty($_POST['test_payload'])) {
+ $payload_status = 'Payload received (' . strlen($_POST['test_payload']) . ' chars)';
+ }
+
+ wp_send_json_success(array(
+ 'message' => 'Simple test works!',
+ 'server_time' => date('Y-m-d H:i:s'),
+ 'payload_status' => $payload_status,
+ 'request_method' => $_SERVER['REQUEST_METHOD']
+ ));
}
/**
@@ -488,9 +566,6 @@ class HVAC_Zoho_Admin {
}
}
- /**
- * Parse OAuth request using parse_request hook
- */
public function parse_oauth_request($wp) {
// Check if this is an OAuth callback request
@@ -506,6 +581,33 @@ class HVAC_Zoho_Admin {
}
}
}
+
+ /**
+ * Manual Router for OAuth Callback
+ *
+ * Catches the request on 'init' before WordPress internal routing can 404 it.
+ * This is a robust fallback for when rewrite rules fail or haven't flushed.
+ */
+ public function check_for_oauth_params() {
+ // Check if we are at the oauth callback URL path
+ $path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
+
+ // Check strict path match or if regex matches
+ if (strpos($path, '/oauth/callback') !== false) {
+
+ // We are at the right URL. Do we have the code?
+ if (isset($_GET['code'])) {
+
+ // We have a code.
+ // We MUST process this, otherwise WP will display a 404.
+ // Even if state matches or not, we should handle it here.
+ // The process_oauth_callback method handles validation.
+
+ $this->process_oauth_callback();
+ // process_oauth_callback exits, so we won't continue to 404.
+ }
+ }
+ }
/**
* Add OAuth callback rewrite rule
@@ -794,10 +896,14 @@ class HVAC_Zoho_Admin {
return;
}
+ // Success!
// Success!
wp_send_json_success(array(
'message' => 'Connection successful!',
'modules' => isset($response['modules']) ? count($response['modules']) . ' modules available' : 'API connected',
+ 'client_id' => substr($client_id, 0, 10) . '...',
+ 'client_secret_exists' => true,
+ 'refresh_token_exists' => true,
'credentials_status' => array(
'client_id' => substr($client_id, 0, 10) . '...',
'client_secret_exists' => true,
@@ -847,6 +953,8 @@ class HVAC_Zoho_Admin {
}
$type = sanitize_text_field($_POST['type']);
+ $offset = isset($_POST['offset']) ? absint($_POST['offset']) : 0;
+ $limit = isset($_POST['limit']) ? absint($_POST['limit']) : 50;
try {
require_once HVAC_PLUGIN_DIR . 'includes/zoho/class-zoho-sync.php';
@@ -854,19 +962,19 @@ class HVAC_Zoho_Admin {
switch ($type) {
case 'events':
- $result = $sync->sync_events();
+ $result = $sync->sync_events($offset, $limit);
break;
case 'users':
- $result = $sync->sync_users();
+ $result = $sync->sync_users($offset, $limit);
break;
case 'attendees':
- $result = $sync->sync_attendees();
+ $result = $sync->sync_attendees($offset, $limit);
break;
case 'rsvps':
- $result = $sync->sync_rsvps();
+ $result = $sync->sync_rsvps($offset, $limit);
break;
case 'purchases':
- $result = $sync->sync_purchases();
+ $result = $sync->sync_purchases($offset, $limit);
break;
default:
throw new Exception('Invalid sync type');
@@ -880,5 +988,89 @@ class HVAC_Zoho_Admin {
));
}
}
+
+ /**
+ * Save scheduled sync settings
+ */
+ public function save_settings() {
+ check_ajax_referer('hvac_zoho_nonce', 'nonce');
+
+ if (!current_user_can('manage_options')) {
+ wp_send_json_error(array('message' => 'Unauthorized access'));
+ return;
+ }
+
+ $auto_sync = isset($_POST['auto_sync']) && $_POST['auto_sync'] === '1' ? '1' : '0';
+ $sync_frequency = sanitize_text_field($_POST['sync_frequency'] ?? 'every_5_minutes');
+
+ // Validate frequency value
+ $valid_frequencies = array(
+ 'every_5_minutes',
+ 'every_15_minutes',
+ 'every_30_minutes',
+ 'hourly',
+ 'every_6_hours',
+ 'daily'
+ );
+
+ if (!in_array($sync_frequency, $valid_frequencies)) {
+ $sync_frequency = 'every_5_minutes';
+ }
+
+ // Save settings
+ update_option('hvac_zoho_auto_sync', $auto_sync);
+ update_option('hvac_zoho_sync_frequency', $sync_frequency);
+
+ // Get scheduled sync instance and explicitly schedule/unschedule
+ // This ensures scheduling works even if option value didn't change
+ require_once HVAC_PLUGIN_DIR . 'includes/zoho/class-zoho-scheduled-sync.php';
+ $scheduled_sync = HVAC_Zoho_Scheduled_Sync::instance();
+
+ if ($auto_sync === '1') {
+ $scheduled_sync->schedule_sync($sync_frequency);
+ } else {
+ $scheduled_sync->unschedule_sync();
+ }
+
+ $status = $scheduled_sync->get_status();
+
+ wp_send_json_success(array(
+ 'message' => 'Settings saved successfully',
+ 'auto_sync' => $auto_sync,
+ 'sync_frequency' => $sync_frequency,
+ 'is_scheduled' => $status['is_scheduled'],
+ 'next_sync' => $status['next_sync_formatted']
+ ));
+ }
+
+ /**
+ * Run scheduled sync manually
+ */
+ public function run_scheduled_sync_now() {
+ check_ajax_referer('hvac_zoho_nonce', 'nonce');
+
+ if (!current_user_can('manage_options')) {
+ wp_send_json_error(array('message' => 'Unauthorized access'));
+ return;
+ }
+
+ try {
+ require_once HVAC_PLUGIN_DIR . 'includes/zoho/class-zoho-scheduled-sync.php';
+ $scheduled_sync = HVAC_Zoho_Scheduled_Sync::instance();
+
+ $result = $scheduled_sync->run_now();
+
+ wp_send_json_success(array(
+ 'message' => 'Scheduled sync completed',
+ 'result' => $result
+ ));
+ } catch (Exception $e) {
+ wp_send_json_error(array(
+ 'message' => 'Sync failed',
+ 'error' => $e->getMessage()
+ ));
+ }
+ }
+
}
?>
\ No newline at end of file
diff --git a/includes/class-hvac-plugin.php b/includes/class-hvac-plugin.php
index 0725865d..c9ba3593 100644
--- a/includes/class-hvac-plugin.php
+++ b/includes/class-hvac-plugin.php
@@ -337,8 +337,11 @@ final class HVAC_Plugin {
'admin/class-hvac-enhanced-settings.php',
];
+ // Check if this is an OAuth callback request
+ $is_oauth_callback = isset($_SERVER['REQUEST_URI']) && strpos($_SERVER['REQUEST_URI'], '/oauth/callback') !== false;
+
// Load admin files conditionally
- if (is_admin() || wp_doing_ajax()) {
+ if (is_admin() || wp_doing_ajax() || $is_oauth_callback) {
foreach ($this->loadFeatureFiles($adminFiles) as $file => $status) {
if ($status === 'loaded') {
$this->componentStatus["admin_{$file}"] = true;
@@ -548,8 +551,10 @@ final class HVAC_Plugin {
// Schedule non-critical components for lazy loading
// Use 'init' instead of 'wp_loaded' so components can register wp_enqueue_scripts hooks
add_action('init', [$this, 'initializeSecondaryComponents'], 5);
- // Use admin_menu (not admin_init) so components can register their menus in time
- add_action('admin_menu', [$this, 'initializeAdminComponents'], 5);
+
+ // Use 'init' for admin components too, ensuring AJAX handlers are registered
+ // (admin_menu hook doesn't fire on AJAX requests, causing 400 Bad Request/0 response)
+ add_action('init', [$this, 'initializeAdminComponents'], 5);
}
/**
@@ -701,7 +706,16 @@ final class HVAC_Plugin {
if (class_exists('HVAC_Communication_Scheduler')) {
hvac_communication_scheduler();
}
-
+
+ // Initialize Zoho scheduled sync (must load on ALL requests for WP-Cron to work)
+ // This registers custom cron schedules and the cron action hook
+ if (file_exists(HVAC_PLUGIN_DIR . 'includes/zoho/class-zoho-scheduled-sync.php')) {
+ require_once HVAC_PLUGIN_DIR . 'includes/zoho/class-zoho-scheduled-sync.php';
+ if (class_exists('HVAC_Zoho_Scheduled_Sync')) {
+ HVAC_Zoho_Scheduled_Sync::instance();
+ }
+ }
+
// Initialize Master Trainer manager classes (fix for missing shortcode registrations)
if (class_exists('HVAC_Master_Events_Overview')) {
HVAC_Master_Events_Overview::instance();
@@ -731,19 +745,22 @@ final class HVAC_Plugin {
* Only loads admin components in admin context to improve frontend performance.
*/
public function initializeAdminComponents(): void {
+ // Check if this is an OAuth callback request
+ $is_oauth_callback = isset($_SERVER['REQUEST_URI']) && strpos($_SERVER['REQUEST_URI'], '/oauth/callback') !== false;
+
// Initialize admin components only when needed
- if (class_exists('HVAC_Zoho_Admin')) {
+ if (class_exists('HVAC_Zoho_Admin') && (is_admin() || wp_doing_ajax() || $is_oauth_callback)) {
HVAC_Zoho_Admin::instance();
}
- if (class_exists('HVAC_Admin_Dashboard')) {
+ if (class_exists('HVAC_Admin_Dashboard') && is_admin()) {
new HVAC_Admin_Dashboard();
}
- if (class_exists('HVAC_Enhanced_Settings')) {
+ if (class_exists('HVAC_Enhanced_Settings') && is_admin()) {
HVAC_Enhanced_Settings::instance();
}
// Initialize trainer certification admin interface
- if (class_exists('HVAC_Certification_Admin') && current_user_can('manage_hvac_certifications')) {
+ if (class_exists('HVAC_Certification_Admin') && current_user_can('manage_hvac_certifications') && is_admin()) {
HVAC_Certification_Admin::instance();
}
}
diff --git a/includes/zoho/class-zoho-scheduled-sync.php b/includes/zoho/class-zoho-scheduled-sync.php
new file mode 100644
index 00000000..409f7e84
--- /dev/null
+++ b/includes/zoho/class-zoho-scheduled-sync.php
@@ -0,0 +1,389 @@
+ 5 * MINUTE_IN_SECONDS,
+ 'display' => __('Every 5 Minutes', 'hvac-community-events')
+ );
+
+ $schedules['every_15_minutes'] = array(
+ 'interval' => 15 * MINUTE_IN_SECONDS,
+ 'display' => __('Every 15 Minutes', 'hvac-community-events')
+ );
+
+ $schedules['every_30_minutes'] = array(
+ 'interval' => 30 * MINUTE_IN_SECONDS,
+ 'display' => __('Every 30 Minutes', 'hvac-community-events')
+ );
+
+ $schedules['every_6_hours'] = array(
+ 'interval' => 6 * HOUR_IN_SECONDS,
+ 'display' => __('Every 6 Hours', 'hvac-community-events')
+ );
+
+ return $schedules;
+ }
+
+ /**
+ * Schedule the sync cron event
+ *
+ * @param string|null $interval Optional interval override
+ * @return bool True if scheduled successfully
+ */
+ public function schedule_sync($interval = null) {
+ // Unschedule any existing event first
+ $this->unschedule_sync();
+
+ // Get interval from option if not provided
+ if (null === $interval) {
+ $interval = get_option(self::OPTION_INTERVAL, 'every_5_minutes');
+ }
+
+ // Schedule the event
+ $result = wp_schedule_event(time(), $interval, self::CRON_HOOK);
+
+ if ($result !== false) {
+ if (class_exists('HVAC_Logger')) {
+ HVAC_Logger::info("Scheduled Zoho sync with interval: {$interval}", 'ZohoScheduledSync');
+ }
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Unschedule the sync cron event
+ *
+ * @return bool True if unscheduled successfully
+ */
+ public function unschedule_sync() {
+ $timestamp = wp_next_scheduled(self::CRON_HOOK);
+
+ if ($timestamp) {
+ wp_unschedule_event($timestamp, self::CRON_HOOK);
+
+ if (class_exists('HVAC_Logger')) {
+ HVAC_Logger::info('Unscheduled Zoho sync', 'ZohoScheduledSync');
+ }
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Check if sync is currently scheduled
+ *
+ * @return bool
+ */
+ public function is_scheduled() {
+ return wp_next_scheduled(self::CRON_HOOK) !== false;
+ }
+
+ /**
+ * Get next scheduled sync time
+ *
+ * @return int|false Timestamp or false if not scheduled
+ */
+ public function get_next_scheduled() {
+ return wp_next_scheduled(self::CRON_HOOK);
+ }
+
+ /**
+ * Handle first-time option creation
+ *
+ * @param string $option Option name
+ * @param mixed $value Option value
+ */
+ public function on_setting_added($option, $value) {
+ if ($value === '1' || $value === 1 || $value === true) {
+ $this->schedule_sync();
+ }
+ }
+
+ /**
+ * Handle setting enabled/disabled change
+ *
+ * @param mixed $old_value Old option value
+ * @param mixed $new_value New option value
+ */
+ public function on_setting_change($old_value, $new_value) {
+ if ($new_value === '1' || $new_value === 1 || $new_value === true) {
+ $this->schedule_sync();
+ } else {
+ $this->unschedule_sync();
+ }
+ }
+
+ /**
+ * Handle interval change
+ *
+ * @param mixed $old_value Old interval value
+ * @param mixed $new_value New interval value
+ */
+ public function on_interval_change($old_value, $new_value) {
+ // Only reschedule if currently enabled
+ if (get_option(self::OPTION_ENABLED) === '1') {
+ $this->schedule_sync($new_value);
+ }
+ }
+
+ /**
+ * Run the scheduled sync
+ *
+ * This is the main cron callback that syncs all data types.
+ */
+ public function run_scheduled_sync() {
+ $start_time = time();
+
+ if (class_exists('HVAC_Logger')) {
+ HVAC_Logger::info('Starting scheduled Zoho sync', 'ZohoScheduledSync');
+ }
+
+ // Get last sync time for incremental sync
+ $last_sync = $this->get_last_sync_time();
+
+ // Initialize results
+ $results = array(
+ 'started_at' => date('Y-m-d H:i:s', $start_time),
+ 'last_sync_from' => $last_sync ? date('Y-m-d H:i:s', $last_sync) : 'Never',
+ 'events' => null,
+ 'users' => null,
+ 'attendees' => null,
+ 'rsvps' => null,
+ 'purchases' => null,
+ 'errors' => array(),
+ );
+
+ try {
+ // Load dependencies
+ require_once HVAC_PLUGIN_DIR . 'includes/zoho/class-zoho-sync.php';
+ $sync = new HVAC_Zoho_Sync();
+
+ // Sync each type with since_timestamp for incremental sync
+ // Events
+ $results['events'] = $this->sync_all_batches($sync, 'sync_events', $last_sync);
+
+ // Users/Trainers
+ $results['users'] = $this->sync_all_batches($sync, 'sync_users', $last_sync);
+
+ // Attendees
+ $results['attendees'] = $this->sync_all_batches($sync, 'sync_attendees', $last_sync);
+
+ // RSVPs
+ $results['rsvps'] = $this->sync_all_batches($sync, 'sync_rsvps', $last_sync);
+
+ // Purchases/Orders
+ $results['purchases'] = $this->sync_all_batches($sync, 'sync_purchases', $last_sync);
+
+ } catch (Exception $e) {
+ $results['errors'][] = $e->getMessage();
+
+ if (class_exists('HVAC_Logger')) {
+ HVAC_Logger::error('Scheduled sync error: ' . $e->getMessage(), 'ZohoScheduledSync');
+ }
+ }
+
+ // Update last sync time
+ $this->set_last_sync_time($start_time);
+
+ // Calculate totals
+ $total_synced = 0;
+ $total_failed = 0;
+ foreach (array('events', 'users', 'attendees', 'rsvps', 'purchases') as $type) {
+ if (isset($results[$type]['synced'])) {
+ $total_synced += $results[$type]['synced'];
+ }
+ if (isset($results[$type]['failed'])) {
+ $total_failed += $results[$type]['failed'];
+ }
+ }
+
+ $results['completed_at'] = date('Y-m-d H:i:s');
+ $results['duration_seconds'] = time() - $start_time;
+ $results['total_synced'] = $total_synced;
+ $results['total_failed'] = $total_failed;
+
+ // Save last result
+ update_option(self::OPTION_LAST_RESULT, $results);
+
+ if (class_exists('HVAC_Logger')) {
+ HVAC_Logger::info("Scheduled sync completed: {$total_synced} synced, {$total_failed} failed", 'ZohoScheduledSync');
+ }
+
+ return $results;
+ }
+
+ /**
+ * Sync all batches for a given sync method
+ *
+ * @param HVAC_Zoho_Sync $sync Sync instance
+ * @param string $method Method name (e.g., 'sync_events')
+ * @param int|null $since_timestamp Optional timestamp for incremental sync
+ * @return array Aggregated results
+ */
+ private function sync_all_batches($sync, $method, $since_timestamp = null) {
+ $offset = 0;
+ $limit = 50;
+ $aggregated = array(
+ 'total' => 0,
+ 'synced' => 0,
+ 'failed' => 0,
+ 'errors' => array(),
+ );
+
+ do {
+ // Call the sync method with since_timestamp support
+ $result = $sync->$method($offset, $limit, $since_timestamp);
+
+ // Aggregate results
+ $aggregated['total'] = $result['total'] ?? 0;
+ $aggregated['synced'] += $result['synced'] ?? 0;
+ $aggregated['failed'] += $result['failed'] ?? 0;
+
+ if (!empty($result['errors'])) {
+ $aggregated['errors'] = array_merge($aggregated['errors'], $result['errors']);
+ }
+
+ // Check for more batches
+ $has_more = $result['has_more'] ?? false;
+ $offset = $result['next_offset'] ?? ($offset + $limit);
+
+ } while ($has_more);
+
+ return $aggregated;
+ }
+
+ /**
+ * Get the last sync timestamp
+ *
+ * @return int|null Timestamp or null if never synced
+ */
+ public function get_last_sync_time() {
+ $time = get_option(self::OPTION_LAST_SYNC);
+ return $time ? (int) $time : null;
+ }
+
+ /**
+ * Set the last sync timestamp
+ *
+ * @param int $timestamp Timestamp
+ */
+ public function set_last_sync_time($timestamp) {
+ update_option(self::OPTION_LAST_SYNC, $timestamp);
+ }
+
+ /**
+ * Get the last sync result
+ *
+ * @return array|null Result array or null
+ */
+ public function get_last_sync_result() {
+ return get_option(self::OPTION_LAST_RESULT);
+ }
+
+ /**
+ * Get sync status summary
+ *
+ * @return array Status information
+ */
+ public function get_status() {
+ $is_enabled = get_option(self::OPTION_ENABLED) === '1';
+ $interval = get_option(self::OPTION_INTERVAL, 'every_5_minutes');
+ $last_sync = $this->get_last_sync_time();
+ $next_sync = $this->get_next_scheduled();
+ $last_result = $this->get_last_sync_result();
+
+ return array(
+ 'enabled' => $is_enabled,
+ 'interval' => $interval,
+ 'is_scheduled' => $this->is_scheduled(),
+ 'last_sync_time' => $last_sync,
+ 'last_sync_formatted' => $last_sync ? date('Y-m-d H:i:s', $last_sync) : 'Never',
+ 'next_sync_time' => $next_sync,
+ 'next_sync_formatted' => $next_sync ? date('Y-m-d H:i:s', $next_sync) : 'Not scheduled',
+ 'last_result' => $last_result,
+ );
+ }
+
+ /**
+ * Run sync manually (for "Run Now" button)
+ *
+ * @return array Sync results
+ */
+ public function run_now() {
+ return $this->run_scheduled_sync();
+ }
+}