plugin = $plugin; } /** * Register CLI commands */ public static function register($plugin) { $instance = new self($plugin); WP_CLI::add_command('mls test', array($instance, 'test')); WP_CLI::add_command('mls status', array($instance, 'status')); WP_CLI::add_command('mls sync', array($instance, 'sync')); WP_CLI::add_command('mls stats', array($instance, 'stats')); WP_CLI::add_command('mls cache', array($instance, 'cache')); WP_CLI::add_command('mls recovery', array($instance, 'recovery')); } /** * Test API connection and authentication. * * ## OPTIONS * * * : Test type: connection or auth * * [--verbose] * : Show detailed information * * ## EXAMPLES * * wp mls test connection * wp mls test auth * wp mls test connection --verbose * * @subcommand test */ public function test($args, $assoc_args) { $type = isset($args[0]) ? $args[0] : 'connection'; $verbose = isset($assoc_args['verbose']); $api_client = $this->plugin->get_api_client(); switch ($type) { case 'connection': WP_CLI::line('Testing connection to MLS Grid API...'); $result = $api_client->test_connection(); if ($result['success']) { WP_CLI::success('Connection successful!'); WP_CLI::line(sprintf('Response time: %dms', $result['response_time'])); if ($verbose && !empty($result['endpoints'])) { WP_CLI::line('Available endpoints:'); foreach ($result['endpoints'] as $endpoint) { WP_CLI::line(' - ' . $endpoint); } } } else { WP_CLI::error('Connection failed: ' . $result['error']); } break; case 'auth': WP_CLI::line('Testing API authentication...'); $options = $this->plugin->get_options(); if (!$options->get_api_token()) { WP_CLI::error('No API token configured. Set MLSGRID_ACCESS_TOKEN in wp-config.php'); } $result = $api_client->test_auth(); if ($result['success']) { WP_CLI::success('Authentication successful!'); WP_CLI::line('Originating System: ' . $result['originating_system']); } else { WP_CLI::error('Authentication failed: ' . $result['error']); } break; default: WP_CLI::error("Unknown test type: {$type}. Use 'connection' or 'auth'."); } } /** * Show sync status and rate limits. * * ## OPTIONS * * [] * : Status type: sync, rate-limits, or all (default) * * ## EXAMPLES * * wp mls status * wp mls status sync * wp mls status rate-limits * * @subcommand status */ public function status($args, $assoc_args) { $type = isset($args[0]) ? $args[0] : 'all'; if ($type === 'all' || $type === 'sync') { $this->show_sync_status(); } if ($type === 'all' || $type === 'rate-limits') { $this->show_rate_limits(); } } /** * Show sync status */ private function show_sync_status() { $sync_engine = $this->plugin->get_sync_engine(); $status = $sync_engine->get_status(); WP_CLI::line(''); WP_CLI::line('=== Sync Status ==='); if ($status['running_sync']) { WP_CLI::warning('Sync currently running'); WP_CLI::line(sprintf( ' Type: %s | Started: %s | Processed: %d', $status['running_sync']->sync_type, $status['running_sync']->started_at, $status['running_sync']->records_processed )); } if ($status['last_sync']) { WP_CLI::line('Last completed sync:'); WP_CLI::line(sprintf( ' Type: %s | Completed: %s', $status['last_sync']->sync_type, $status['last_sync']->completed_at )); WP_CLI::line(sprintf( ' Records: %d processed, %d created, %d updated, %d deleted', $status['last_sync']->records_processed, $status['last_sync']->records_created, $status['last_sync']->records_updated, $status['last_sync']->records_deleted )); } else { WP_CLI::line('No completed syncs found.'); } if ($status['last_failed']) { WP_CLI::warning('Last failed sync:'); WP_CLI::line(' Error: ' . $status['last_failed']->last_error); } WP_CLI::line(''); } /** * Show rate limit status */ private function show_rate_limits() { $rate_limiter = $this->plugin->get_rate_limiter(); $status = $rate_limiter->get_status(); WP_CLI::line('=== Rate Limits ==='); WP_CLI::line(sprintf( 'Hourly: %d / %d requests (%d remaining)', $status['hourly']['used'], $status['hourly']['limit'], $status['hourly']['remaining'] )); WP_CLI::line(sprintf( 'Daily: %d / %d requests (%d remaining)', $status['daily']['used'], $status['daily']['limit'], $status['daily']['remaining'] )); WP_CLI::line(sprintf( 'Data: %s / 4GB this hour', size_format($status['bytes_this_hour']) )); WP_CLI::line(''); } /** * Run property sync. * * ## OPTIONS * * * : Sync type: full, incremental, media, or resume * * [--dry-run] * : Show what would be synced without making changes * * [--limit=] * : Limit number of records to process * * [--id=] * : Sync state ID to resume (for resume command) * * [--force] * : Force re-download of media (for media command) * * [--quiet] * : Suppress progress output * * [--verbose] * : Show detailed output including API requests and responses * * ## EXAMPLES * * wp mls sync full * wp mls sync full --dry-run --limit=10 * wp mls sync incremental * wp mls sync incremental --verbose * wp mls sync media --limit=100 * wp mls sync resume --id=5 * * @subcommand sync */ public function sync($args, $assoc_args) { $type = isset($args[0]) ? $args[0] : 'incremental'; $dry_run = isset($assoc_args['dry-run']); $limit = isset($assoc_args['limit']) ? (int) $assoc_args['limit'] : null; $quiet = isset($assoc_args['quiet']); $verbose = isset($assoc_args['verbose']); $sync_engine = $this->plugin->get_sync_engine(); // Progress callback for CLI $progress_callback = null; if (!$quiet) { $progress_callback = function($event, $data = array()) use ($verbose) { if ($verbose) { // Verbose mode: full line output $this->output_verbose_event($event, $data); } else { // Compact mode: single character symbols switch ($event) { case 'property_created': echo '.'; break; case 'property_updated': echo '#'; break; case 'property_deleted': echo 'x'; break; case 'property_skipped': echo '-'; break; case 'property_error': echo '!'; break; case 'media_downloaded': echo 'P'; break; case 'media_skipped': echo 'p'; break; case 'media_error': echo 'E'; break; case 'page_complete': echo '|'; break; case 'api_request': // Silent in compact mode break; case 'api_response': // Silent in compact mode break; } } }; } switch ($type) { case 'full': WP_CLI::line('Starting full sync...'); if ($dry_run) { WP_CLI::line('DRY RUN - No changes will be made'); } if (!$quiet) { $this->print_progress_legend($verbose); } $result = $sync_engine->run_full_sync($dry_run, $limit, $progress_callback); if (!$quiet) { echo "\n"; } $this->output_sync_result($result); break; case 'incremental': WP_CLI::line('Starting incremental sync...'); if ($dry_run) { WP_CLI::line('DRY RUN - No changes will be made'); } if (!$quiet) { $this->print_progress_legend($verbose); } $result = $sync_engine->run_incremental_sync($dry_run, $progress_callback); if (!$quiet) { echo "\n"; } $this->output_sync_result($result); break; case 'media': WP_CLI::line('Downloading pending media...'); if (!$quiet) { WP_CLI::line('Legend: P=downloaded p=skipped E=error'); echo "\n"; } $media_handler = $this->plugin->get_media_handler(); $result = $media_handler->download_pending($limit ?: 100, $progress_callback); if (!$quiet) { echo "\n"; } WP_CLI::line(sprintf( 'Media download complete: %d success, %d failed out of %d total', $result['success'], $result['failed'], $result['total'] )); if ($result['failed'] === 0 && $result['total'] > 0) { WP_CLI::success('All media downloaded successfully!'); } elseif ($result['total'] === 0) { WP_CLI::success('No pending media to download.'); } else { WP_CLI::warning('Some media failed to download.'); $missing_count = $media_handler->get_missing_count(); if ($missing_count > 0) { WP_CLI::line(sprintf( 'Missing media log: %s (%d entries)', $media_handler->get_missing_log_path(), $missing_count )); } } break; case 'resume': $sync_id = isset($assoc_args['id']) ? (int) $assoc_args['id'] : null; if (!$sync_id) { WP_CLI::error('Please specify --id= to resume'); } WP_CLI::line("Resuming sync #{$sync_id}..."); if (!$quiet) { $this->print_progress_legend($verbose); } $result = $sync_engine->resume_sync($sync_id, $progress_callback); if (!$quiet) { echo "\n"; } $this->output_sync_result($result); break; default: WP_CLI::error("Unknown sync type: {$type}. Use 'full', 'incremental', 'media', or 'resume'."); } } /** * Print progress legend * * @param bool $verbose Whether verbose mode is enabled */ private function print_progress_legend($verbose = false) { if (!$verbose) { WP_CLI::line('Legend: .=new #=updated x=deleted -=skipped !=error P=photo p=photo-skip E=photo-error |=page'); echo "\n"; } } /** * Output verbose event information * * @param string $event Event name * @param array $data Event data */ private function output_verbose_event($event, $data) { $timestamp = date('H:i:s'); switch ($event) { case 'api_request': WP_CLI::line("[{$timestamp}] API REQUEST: {$data['method']} {$data['url']}"); if (!empty($data['params'])) { WP_CLI::line(" Params: " . json_encode($data['params'])); } break; case 'api_response': $status = $data['success'] ? 'OK' : 'ERROR'; WP_CLI::line("[{$timestamp}] API RESPONSE: {$status} ({$data['status_code']}) - {$data['record_count']} records, {$data['response_time']}ms"); if (!$data['success'] && !empty($data['error'])) { WP_CLI::warning(" Error: {$data['error']}"); } break; case 'property_created': $key = $data['listing_key'] ?? 'unknown'; $address = $data['address'] ?? ''; WP_CLI::line("[{$timestamp}] CREATED: {$key} {$address}"); break; case 'property_updated': $key = $data['listing_key'] ?? 'unknown'; $address = $data['address'] ?? ''; WP_CLI::line("[{$timestamp}] UPDATED: {$key} {$address}"); break; case 'property_deleted': $key = $data['listing_key'] ?? 'unknown'; WP_CLI::line("[{$timestamp}] DELETED: {$key}"); break; case 'property_skipped': $key = $data['listing_key'] ?? 'unknown'; WP_CLI::line("[{$timestamp}] SKIPPED: {$key} (dry-run)"); break; case 'property_error': $key = $data['listing_key'] ?? 'unknown'; $error = $data['error'] ?? 'Unknown error'; WP_CLI::warning("[{$timestamp}] ERROR: {$key} - {$error}"); break; case 'media_downloaded': $key = $data['media_key'] ?? 'unknown'; $listing = $data['listing_key'] ?? ''; WP_CLI::line("[{$timestamp}] MEDIA OK: {$key}" . ($listing ? " ({$listing})" : "")); break; case 'media_skipped': $key = $data['media_key'] ?? 'unknown'; WP_CLI::line("[{$timestamp}] MEDIA SKIP: {$key} (already downloaded)"); break; case 'media_error': $key = $data['media_key'] ?? 'unknown'; $error = $data['error'] ?? 'Download failed'; WP_CLI::warning("[{$timestamp}] MEDIA ERROR: {$key} - {$error}"); break; case 'page_complete': $processed = $data['processed'] ?? 0; WP_CLI::line("[{$timestamp}] PAGE COMPLETE: {$processed} records processed so far"); break; } } /** * Output sync result */ private function output_sync_result($result) { if ($result['success']) { WP_CLI::success('Sync completed successfully!'); } else { WP_CLI::error('Sync failed: ' . $result['error']); } $stats = $result['stats']; WP_CLI::line(sprintf( 'Processed: %d | Created: %d | Updated: %d | Deleted: %d | Errors: %d', $stats['processed'], $stats['created'], $stats['updated'], $stats['deleted'], $stats['errors'] )); // Show missing media log info if there are failures $media_handler = $this->plugin->get_media_handler(); $missing_count = $media_handler->get_missing_count(); if ($missing_count > 0) { WP_CLI::line(sprintf( 'Missing media log: %s (%d entries)', $media_handler->get_missing_log_path(), $missing_count )); } } /** * Show database statistics. * * ## EXAMPLES * * wp mls stats * * @subcommand stats */ public function stats($args, $assoc_args) { $db = $this->plugin->get_db(); $stats = $db->get_stats(); WP_CLI::line(''); WP_CLI::line('=== MLS Database Statistics ==='); WP_CLI::line(''); WP_CLI::line('Properties:'); WP_CLI::line(sprintf(' Total: %d', $stats['total_properties'])); WP_CLI::line(sprintf(' Active: %d', $stats['active_properties'])); WP_CLI::line(sprintf(' Pending: %d', $stats['pending_properties'])); WP_CLI::line(sprintf(' Sold: %d', $stats['sold_properties'])); WP_CLI::line(''); WP_CLI::line('Media:'); WP_CLI::line(sprintf(' Total records: %d', $stats['total_media'])); WP_CLI::line(sprintf(' Downloaded: %d', $stats['downloaded_media'])); WP_CLI::line(sprintf(' Pending: %d', $stats['total_media'] - $stats['downloaded_media'])); WP_CLI::line(''); // Show distinct values $query = $this->plugin->get_query(); $cities = $query->get_distinct_cities('Active'); WP_CLI::line('Cities with active listings: ' . count($cities)); if (count($cities) <= 20) { WP_CLI::line(' ' . implode(', ', $cities)); } WP_CLI::line(''); } /** * Manage cache. * * ## OPTIONS * * * : Action: clear, clear-listing, cleanup, missing * * [--confirm] * : Confirm destructive operations * * [--listing=] * : Listing key for clear-listing * * [--clear] * : Clear the missing media log (for missing action) * * [--limit=] * : Limit output lines (for missing action) * * ## EXAMPLES * * wp mls cache clear --confirm * wp mls cache clear-listing --listing=NST123456 * wp mls cache cleanup * wp mls cache missing * wp mls cache missing --limit=20 * wp mls cache missing --clear * * @subcommand cache */ public function cache($args, $assoc_args) { $action = isset($args[0]) ? $args[0] : null; switch ($action) { case 'clear': if (!isset($assoc_args['confirm'])) { WP_CLI::error('This will delete ALL synced data. Add --confirm to proceed.'); } WP_CLI::line('Clearing all MLS data...'); $db = $this->plugin->get_db(); $db->truncate_data(); // Also clear media files $media_handler = $this->plugin->get_media_handler(); $upload_dir = $media_handler->get_upload_dir(); if (is_dir($upload_dir)) { WP_CLI::line('Clearing media files...'); // Note: This is a simplified clear - in production you'd want more careful deletion $this->recursive_delete($upload_dir); wp_mkdir_p($upload_dir); } // Also clear missing log $media_handler->clear_missing_log(); WP_CLI::success('Cache cleared successfully.'); break; case 'clear-listing': $listing_key = isset($assoc_args['listing']) ? $assoc_args['listing'] : null; if (!$listing_key) { WP_CLI::error('Please specify --listing='); } $media_handler = $this->plugin->get_media_handler(); $media_handler->delete_property_media($listing_key); global $wpdb; $db = $this->plugin->get_db(); $wpdb->delete($db->properties_table(), array('listing_key' => $listing_key)); WP_CLI::success("Listing {$listing_key} cleared."); break; case 'cleanup': WP_CLI::line('Cleaning up orphaned media files...'); $media_handler = $this->plugin->get_media_handler(); $deleted = $media_handler->cleanup_orphaned_files(); WP_CLI::success("Cleaned up {$deleted} orphaned directories."); break; case 'missing': $media_handler = $this->plugin->get_media_handler(); $log_file = $media_handler->get_missing_log_path(); if (isset($assoc_args['clear'])) { $media_handler->clear_missing_log(); WP_CLI::success('Missing media log cleared.'); break; } if (!file_exists($log_file)) { WP_CLI::success('No missing media logged.'); break; } $limit = isset($assoc_args['limit']) ? (int) $assoc_args['limit'] : null; $lines = file($log_file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); $total = count($lines); WP_CLI::line(sprintf('Missing media log: %s', $log_file)); WP_CLI::line(sprintf('Total entries: %d', $total)); WP_CLI::line(''); WP_CLI::line('Format: [timestamp] listing_key | media_key | error | url'); WP_CLI::line(str_repeat('-', 80)); $display = $limit ? array_slice($lines, 0, $limit) : $lines; foreach ($display as $line) { WP_CLI::line($line); } if ($limit && $total > $limit) { WP_CLI::line(''); WP_CLI::line(sprintf('... and %d more entries', $total - $limit)); } break; default: WP_CLI::error("Unknown action: {$action}. Use 'clear', 'clear-listing', 'cleanup', or 'missing'."); } } /** * Manage sync recovery and resumption. * * ## OPTIONS * * * : Action: list, auto, cleanup * * [--verbose] * : Show detailed output during resume * * [--quiet] * : Suppress progress output * * ## EXAMPLES * * wp mls recovery list # Show resumable syncs * wp mls recovery auto # Auto-resume most recent failed sync * wp mls recovery auto --verbose # Auto-resume with detailed output * wp mls recovery cleanup # Mark stale syncs as failed * * @subcommand recovery */ public function recovery($args, $assoc_args) { $action = isset($args[0]) ? $args[0] : 'list'; $verbose = isset($assoc_args['verbose']); $quiet = isset($assoc_args['quiet']); $sync_engine = $this->plugin->get_sync_engine(); switch ($action) { case 'list': $resumable = $sync_engine->get_resumable_syncs(); if (empty($resumable)) { WP_CLI::success('No resumable syncs found.'); break; } WP_CLI::line(''); WP_CLI::line('=== Resumable Syncs ==='); WP_CLI::line(''); foreach ($resumable as $sync) { $status_color = $sync->status === 'failed' ? '%R' : '%Y'; WP_CLI::line(sprintf( 'ID: %d | Type: %s | Status: %s | Processed: %d', $sync->id, $sync->sync_type, $sync->status, $sync->records_processed )); WP_CLI::line(sprintf( ' Started: %s | Updated: %s', $sync->started_at, $sync->updated_at )); if ($sync->last_error) { WP_CLI::warning(' Error: ' . $sync->last_error); } if ($sync->last_next_link) { WP_CLI::line(' Has resume point: Yes'); } WP_CLI::line(''); } WP_CLI::line('To resume a specific sync: wp mls sync resume --id='); WP_CLI::line('To auto-resume the most recent: wp mls recovery auto'); break; case 'auto': WP_CLI::line('Checking for resumable syncs...'); // Build progress callback $progress_callback = null; if (!$quiet) { $progress_callback = function($event, $data = array()) use ($verbose) { if ($verbose) { $this->output_verbose_event($event, $data); } else { switch ($event) { case 'property_created': echo '.'; break; case 'property_updated': echo '#'; break; case 'property_deleted': echo 'x'; break; case 'media_downloaded': echo 'P'; break; case 'media_skipped': echo 'p'; break; case 'media_error': echo 'E'; break; case 'page_complete': echo '|'; break; } } }; } $result = $sync_engine->auto_resume($progress_callback); if ($result === null) { WP_CLI::success('No syncs to resume.'); break; } if (!$quiet && !$verbose) { echo "\n"; } $this->output_sync_result($result); break; case 'cleanup': WP_CLI::line('Cleaning up stale syncs...'); $cleaned = $sync_engine->cleanup_stale_syncs(); if ($cleaned > 0) { WP_CLI::success("Marked {$cleaned} stale sync(s) as failed."); } else { WP_CLI::success('No stale syncs found.'); } break; default: WP_CLI::error("Unknown action: {$action}. Use 'list', 'auto', or 'cleanup'."); } } /** * Recursively delete a directory */ private function recursive_delete($dir) { if (!is_dir($dir)) { return; } $files = array_diff(scandir($dir), array('.', '..')); foreach ($files as $file) { $path = $dir . '/' . $file; if (is_dir($path)) { $this->recursive_delete($path); } else { unlink($path); } } rmdir($dir); } }