Files
homeproz/wp-content/plugins/mls-by-hansonxyz/cli/class-mls-cli.php
T
root b6df4dbb92 Snapshot: MLS sync fixes, image refresh, plugin/theme updates
MLS plugin fixes from this session:
- Fix silent insert failures: location column NOT NULL was rejecting wpdb->insert calls,
  causing ~18k new properties since Dec 2025 to be lost. Inserts now build raw SQL
  with ST_PointFromText so the spatial column is populated atomically.
- Auto-refresh expired media URLs in MLS_Media_Handler::fetch_and_cache(), guarded by
  a property-level GET_LOCK so concurrent fetches share one API refresh.
- Normalize WP_Error to null in mls_get_property_image() so callers can rely on the
  documented string|null contract.
- Support comma-separated property_type filters in MLS_Query and MLS_Cluster so the
  homepage "View All Commercial" link (?property_type=Commercial+Sale,Land,Farm)
  actually filters correctly.
- Incremental sync now looks back 10 minutes past the latest modification timestamp
  as a safety margin against missed records.
- Smart sync exits silently (info-level, not warning) when a full sync is in progress.

Operational:
- New cron: weekly full sync Sundays at 3 AM (/usr/local/bin/mls-full-sync).
- New cron: hourly 2GB cap on mls-thumbnails/ and cache/transformed-images/
  (/usr/local/bin/mls-image-cache-cap).
- Logrotate config for wp-content/debug.log (2-day retention, daily rotation,
  delaycompress).

Repo policy:
- CLAUDE.md updated with explicit "commit everything except build artifacts" policy.
- .gitignore: untrack runtime image caches and debug.log rotations.

Other modifications in this snapshot are pre-existing in-flight theme/plugin/db_content_updates work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 15:32:23 +00:00

1456 lines
51 KiB
PHP
Executable File

<?php
/**
* WP-CLI Commands for MLS Plugin
*/
if (!defined('ABSPATH')) {
exit;
}
if (!class_exists('WP_CLI')) {
return;
}
class MLS_CLI {
/**
* Plugin instance
*/
private $plugin;
/**
* Constructor
*/
public function __construct($plugin) {
$this->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 run', array($instance, 'run'));
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'));
WP_CLI::add_command('mls media', array($instance, 'media'));
WP_CLI::add_command('mls geo', array($instance, 'geo'));
WP_CLI::add_command('mls property', array($instance, 'property'));
}
/**
* Test API connection and authentication.
*
* ## OPTIONS
*
* <type>
* : 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
*
* [<type>]
* : 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();
$summary = $rate_limiter->get_usage_summary();
WP_CLI::line('=== MLS Grid Rate Limits ===');
WP_CLI::line('');
// Requests
WP_CLI::line('Requests:');
WP_CLI::line(sprintf(
' Hourly: %s / %s (%s%%)',
number_format($status['hourly']['used']),
number_format($status['hourly']['limit']),
$summary['requests_hourly_pct']
));
WP_CLI::line(sprintf(
' Daily: %s / %s (%s%%)',
number_format($status['daily']['used']),
number_format($status['daily']['limit']),
$summary['requests_daily_pct']
));
WP_CLI::line('');
// Data transfer
WP_CLI::line('Data Transfer:');
WP_CLI::line(sprintf(
' Hourly: %s / %s (%s%%)',
size_format($status['data_hourly']['used']),
size_format($status['data_hourly']['limit']),
$summary['data_hourly_pct']
));
WP_CLI::line(sprintf(
' Daily: %s / %s (%s%%)',
size_format($status['data_daily']['used']),
size_format($status['data_daily']['limit']),
$summary['data_daily_pct']
));
WP_CLI::line(sprintf(
' Remaining today: %s GB',
$summary['data_daily_remaining_gb']
));
WP_CLI::line('');
// Warnings
if ($rate_limiter->is_approaching_limit(0.7)) {
WP_CLI::warning('Approaching rate limits (>70% used)');
}
if ($rate_limiter->is_approaching_limit(0.9)) {
WP_CLI::error('Critical: Near rate limit threshold (>90% used)', false);
}
// Sync pacing info
WP_CLI::line('Sync Pacing:');
WP_CLI::line(sprintf(
' Min interval: %s seconds between API requests',
number_format(MLS_Rate_Limiter::SYNC_MIN_INTERVAL_MS / 1000, 2)
));
WP_CLI::line(' (Ensures max 50%% of daily quota used even if sync runs 24h)');
WP_CLI::line('');
}
/**
* Run property sync.
*
* ## OPTIONS
*
* <type>
* : Sync type: full, incremental, media-refresh, or resume
*
* [--dry-run]
* : Show what would be synced without making changes
*
* [--limit=<n>]
* : Limit number of records to process
*
* [--id=<sync_id>]
* : Sync state ID to resume (for resume command)
*
* [--days=<n>]
* : Days ahead to check for expiring media (for incremental and media-refresh, default: 3)
*
* [--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 --days=7
* wp mls sync incremental --verbose
* wp mls sync media-refresh
* wp mls sync media-refresh --days=7
* 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);
// Run media refresh after successful incremental sync
if ($result['success'] && !$dry_run) {
$days = isset($assoc_args['days']) ? (int) $assoc_args['days'] : 3;
WP_CLI::line('');
WP_CLI::line("Running media refresh (properties expiring within {$days} days)...");
if (!$quiet) {
$this->print_progress_legend($verbose);
}
$media_result = $sync_engine->run_media_refresh_sync($days, false, $progress_callback);
if (!$quiet) {
echo "\n";
}
$this->output_sync_result($media_result);
}
break;
case 'media':
// Media is now on-demand, this sync type is deprecated
WP_CLI::line('Note: "wp mls sync media" is deprecated.');
WP_CLI::line('Media is now fetched on-demand when properties are viewed on the website.');
WP_CLI::line('');
WP_CLI::line('Use "wp mls media status" to see cache statistics.');
WP_CLI::line('Use "wp mls media fetch --listing=<key>" to pre-cache a specific listing.');
WP_CLI::line('Use "wp mls sync media-refresh" to proactively refresh expiring media URLs.');
break;
case 'media-refresh':
$days = isset($assoc_args['days']) ? (int) $assoc_args['days'] : 3;
WP_CLI::line("Starting media refresh sync (properties expiring within {$days} days)...");
if ($dry_run) {
WP_CLI::line('DRY RUN - No changes will be made');
}
if (!$quiet) {
$this->print_progress_legend($verbose);
}
$result = $sync_engine->run_media_refresh_sync($days, $dry_run, $progress_callback);
if (!$quiet) {
echo "\n";
}
$this->output_sync_result($result);
break;
case 'resume':
$sync_id = isset($assoc_args['id']) ? (int) $assoc_args['id'] : null;
if (!$sync_id) {
WP_CLI::error('Please specify --id=<sync_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-refresh', or 'resume'.");
}
}
/**
* Run smart sync - autonomous self-healing sync.
*
* This is the recommended command for automated/cron usage. It automatically
* determines the best action based on current state:
*
* - If a sync is running: abort (prevents duplicate syncs)
* - If a previous sync failed/interrupted: resume it
* - If no data exists: run full sync
* - Otherwise: run incremental sync
*
* Failed syncs are automatically recoverable on the next run.
*
* ## OPTIONS
*
* [--quiet]
* : Suppress progress output (still shows status messages)
*
* [--verbose]
* : Show detailed output including API requests
*
* [--silent]
* : Suppress all output except errors (for cron)
*
* ## EXAMPLES
*
* wp mls run # Smart sync with progress
* wp mls run --quiet # Smart sync, status only
* wp mls run --verbose # Smart sync with full details
* wp mls run --silent # For cron jobs
*
* @subcommand run
*/
public function run($args, $assoc_args) {
$quiet = isset($assoc_args['quiet']);
$verbose = isset($assoc_args['verbose']);
$silent = isset($assoc_args['silent']);
$sync_engine = $this->plugin->get_sync_engine();
// Status callback for high-level messages
$status_callback = null;
if (!$silent) {
$status_callback = function($message, $level = 'info') {
$timestamp = date('H:i:s');
switch ($level) {
case 'warning':
WP_CLI::warning("[{$timestamp}] {$message}");
break;
case 'error':
WP_CLI::warning("[{$timestamp}] {$message}");
break;
default:
WP_CLI::line("[{$timestamp}] {$message}");
}
};
}
// Progress callback for record-level progress
$progress_callback = null;
if (!$quiet && !$silent) {
$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 'property_error':
echo '!';
break;
case 'page_complete':
echo '|';
break;
}
}
};
}
if (!$silent) {
WP_CLI::line('');
WP_CLI::line('=== MLS Smart Sync ===');
WP_CLI::line('');
}
// Run smart sync
$result = $sync_engine->smart_sync($progress_callback, $status_callback);
// Handle aborted case (sync already running)
if (isset($result['action']) && $result['action'] === 'aborted') {
if (!$silent) {
WP_CLI::warning('Sync aborted: ' . ($result['reason'] ?? 'Unknown reason'));
}
return;
}
// Output newline after progress dots
if (!$quiet && !$silent && !$verbose) {
echo "\n";
}
// Output results
if (!$silent) {
$action_labels = array(
'full' => 'Full sync',
'incremental' => 'Incremental sync',
'resumed' => 'Resumed sync',
);
$action_label = $action_labels[$result['action']] ?? 'Sync';
if ($result['success']) {
WP_CLI::success("{$action_label} completed successfully!");
} else {
WP_CLI::warning("{$action_label} failed: " . ($result['error'] ?? 'Unknown error'));
WP_CLI::line('The sync can be resumed on the next run.');
}
if (isset($result['stats'])) {
$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']
));
}
}
// Exit with error code if failed (for cron monitoring)
if (!$result['success']) {
WP_CLI::halt(1);
}
// Run garbage collection after successful sync
if ($result['success']) {
$gc = $this->plugin->get_garbage_collector();
if ($gc && $gc->is_enabled()) {
if (!$silent) {
WP_CLI::line('');
WP_CLI::line('=== MLS Image Garbage Collection ===');
WP_CLI::line('');
}
$gc_result = $gc->run($status_callback);
if (!$silent && $gc_result['ran']) {
WP_CLI::line(sprintf(
'Deleted: %d directories (%s)',
$gc_result['deleted_count'],
$this->format_bytes($gc_result['deleted_bytes'])
));
}
}
}
}
/**
* Format bytes to human readable string
*
* @param int $bytes Bytes
* @return string Formatted string
*/
private function format_bytes($bytes) {
if ($bytes >= 1073741824) {
return number_format($bytes / 1073741824, 2) . ' GB';
} elseif ($bytes >= 1048576) {
return number_format($bytes / 1048576, 2) . ' MB';
} elseif ($bytes >= 1024) {
return number_format($bytes / 1024, 2) . ' KB';
}
return $bytes . ' bytes';
}
/**
* 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>
* : Action: clear, clear-listing, cleanup, missing
*
* [--confirm]
* : Confirm destructive operations
*
* [--listing=<key>]
* : Listing key for clear-listing
*
* [--clear]
* : Clear the missing media log (for missing action)
*
* [--limit=<n>]
* : 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=<key>');
}
$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>
* : 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=<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'.");
}
}
/**
* Show media cache status and manage cached files.
*
* Media is now fetched on-demand when properties are viewed on the website.
* This command shows cache statistics and allows management of cached files.
*
* ## OPTIONS
*
* [<action>]
* : Action: status (default), fetch, clear
*
* [--listing=<key>]
* : Listing key for fetch or clear actions
*
* [--limit=<n>]
* : For fetch action, max images to fetch (default: 1)
*
* ## EXAMPLES
*
* wp mls media status # Show cache statistics
* wp mls media fetch --listing=NST123456 # Fetch images for a listing
* wp mls media fetch --listing=NST123456 --limit=10 # Fetch up to 10 images
* wp mls media clear --listing=NST123456 # Clear cached images for a listing
*
* @subcommand media
*/
public function media($args, $assoc_args) {
$action = isset($args[0]) ? $args[0] : 'status';
$media_handler = $this->plugin->get_media_handler();
switch ($action) {
case 'status':
$stats = $media_handler->get_cache_stats();
WP_CLI::line('');
WP_CLI::line('=== Media Cache Status ===');
WP_CLI::line('');
WP_CLI::line(sprintf('Total media records: %d', $stats['total_media']));
WP_CLI::line(sprintf('Cached locally: %d', $stats['cached']));
WP_CLI::line(sprintf('Not yet cached: %d', $stats['uncached']));
WP_CLI::line('');
$cache_percent = $stats['total_media'] > 0
? round(($stats['cached'] / $stats['total_media']) * 100, 1)
: 0;
WP_CLI::line(sprintf('Cache rate: %.1f%%', $cache_percent));
WP_CLI::line('');
WP_CLI::line('Images are fetched on-demand when properties are viewed.');
WP_CLI::line('Use "wp mls media fetch --listing=<key>" to pre-cache specific listings.');
WP_CLI::line('');
break;
case 'fetch':
$listing_key = isset($assoc_args['listing']) ? $assoc_args['listing'] : null;
if (!$listing_key) {
WP_CLI::error('Please specify --listing=<key>');
}
$limit = isset($assoc_args['limit']) ? (int) $assoc_args['limit'] : 1;
WP_CLI::line(sprintf('Fetching up to %d images for listing %s...', $limit, $listing_key));
$images = $media_handler->get_listing_images($listing_key, $limit);
$cached_count = 0;
foreach ($images as $img) {
if ($img->local_url) {
$cached_count++;
}
}
WP_CLI::line(sprintf(
'Result: %d/%d images now cached for this listing.',
$cached_count,
count($images)
));
if ($cached_count > 0) {
WP_CLI::success('Images fetched successfully.');
} elseif (count($images) === 0) {
WP_CLI::warning('No media records found for this listing.');
} else {
WP_CLI::warning('Failed to fetch images. Check logs for details.');
}
break;
case 'clear':
$listing_key = isset($assoc_args['listing']) ? $assoc_args['listing'] : null;
if (!$listing_key) {
WP_CLI::error('Please specify --listing=<key>. To clear all media, use "wp mls cache clear --confirm".');
}
// Just clear the local files, keep metadata
global $wpdb;
$listing_dir = $media_handler->get_listing_dir($listing_key);
if (is_dir($listing_dir)) {
$this->recursive_delete($listing_dir);
}
// Clear local_path and local_url but keep the records
$wpdb->query($wpdb->prepare(
"UPDATE {$this->plugin->get_db()->media_table()}
SET local_path = NULL, local_url = NULL, downloaded_at = NULL
WHERE listing_key = %s",
$listing_key
));
WP_CLI::success(sprintf('Cleared cached images for listing %s. They will be re-fetched on demand.', $listing_key));
break;
default:
WP_CLI::error("Unknown action: {$action}. Use 'status', 'fetch', or 'clear'.");
}
}
/**
* 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);
}
/**
* Geographic coordinate validation commands.
*
* ## OPTIONS
*
* <action>
* : Action to perform: validate, stats, or show-invalid
*
* ## EXAMPLES
*
* # Validate all property coordinates
* wp mls geo validate
*
* # Show validation statistics
* wp mls geo stats
*
* # Show properties with invalid coordinates
* wp mls geo show-invalid
*
* @param array $args Positional arguments
* @param array $assoc_args Associative arguments
*/
public function geo($args, $assoc_args) {
if (empty($args[0])) {
WP_CLI::error('Please specify an action: validate, stats, or show-invalid');
return;
}
$action = $args[0];
switch ($action) {
case 'validate':
$this->geo_validate();
break;
case 'stats':
$this->geo_stats();
break;
case 'show-invalid':
$this->geo_show_invalid();
break;
default:
WP_CLI::error("Unknown action: {$action}. Use validate, stats, or show-invalid.");
}
}
/**
* Validate all property coordinates
*/
private function geo_validate() {
WP_CLI::log('Validating property coordinates against state boundaries...');
WP_CLI::log('');
$progress = null;
$progress_callback = function($processed, $total, $valid, $invalid) use (&$progress) {
if ($progress === null) {
$progress = WP_CLI\Utils\make_progress_bar('Validating', $total);
}
$progress->tick();
};
$results = MLS_Geo_Validator::validate_all_properties($progress_callback);
if ($progress) {
$progress->finish();
}
WP_CLI::log('');
WP_CLI::success("Validation complete!");
WP_CLI::log(" Total properties: " . number_format($results['total']));
WP_CLI::log(" Valid coordinates: " . number_format($results['valid']));
WP_CLI::log(" Invalid coordinates: " . number_format($results['invalid']));
if ($results['invalid'] > 0) {
WP_CLI::log('');
WP_CLI::log("Properties with invalid coordinates will be excluded from map views.");
WP_CLI::log("Run 'wp mls geo show-invalid' to see details.");
}
}
/**
* Show coordinate validation statistics
*/
private function geo_stats() {
$stats = MLS_Geo_Validator::get_stats();
WP_CLI::log('Coordinate Validation Statistics:');
WP_CLI::log('');
WP_CLI::log(" Total properties: " . number_format($stats['total']));
WP_CLI::log(" Valid coordinates: " . number_format($stats['valid']));
WP_CLI::log(" Invalid coordinates: " . number_format($stats['invalid']));
WP_CLI::log(" Null coordinates: " . number_format($stats['null_coords']));
if ($stats['total'] > 0) {
$pct_valid = round(($stats['valid'] / $stats['total']) * 100, 1);
$pct_invalid = round(($stats['invalid'] / $stats['total']) * 100, 1);
WP_CLI::log('');
WP_CLI::log(" Valid %: {$pct_valid}%");
WP_CLI::log(" Invalid %: {$pct_invalid}%");
}
}
/**
* Show properties with invalid coordinates
*/
private function geo_show_invalid() {
global $wpdb;
$table = $this->plugin->get_db()->properties_table();
$invalid = $wpdb->get_results(
"SELECT listing_id, listing_key, street_number, street_name, city, state_or_province, latitude, longitude
FROM {$table}
WHERE coordinates_invalid = 1
ORDER BY city, street_name
LIMIT 100"
);
if (empty($invalid)) {
WP_CLI::success("No properties with invalid coordinates found.");
return;
}
WP_CLI::log("Properties with invalid coordinates (showing up to 100):");
WP_CLI::log('');
$items = array();
foreach ($invalid as $row) {
$address = trim($row->street_number . ' ' . $row->street_name);
$items[] = array(
'MLS ID' => $row->listing_id,
'Address' => $address ?: '(no address)',
'City' => $row->city,
'State' => $row->state_or_province,
'Lat' => $row->latitude,
'Lng' => $row->longitude,
);
}
WP_CLI\Utils\format_items('table', $items, array('MLS ID', 'Address', 'City', 'State', 'Lat', 'Lng'));
$total = $wpdb->get_var("SELECT COUNT(*) FROM {$table} WHERE coordinates_invalid = 1");
if ($total > 100) {
WP_CLI::log('');
WP_CLI::log("... and " . ($total - 100) . " more.");
}
}
/**
* Fetch a property directly from the MLS API and dump the response.
*
* ## OPTIONS
*
* <listing_id>
* : The MLS listing ID (e.g., NST6755550 or 6755550)
*
* [--format=<format>]
* : Output format: json, table, or fields (default: fields)
*
* [--fields=<fields>]
* : Comma-separated list of fields to show (for fields format)
*
* ## EXAMPLES
*
* wp mls property 6755550
* wp mls property NST6755550 --format=json
* wp mls property 6755550 --fields=StandardStatus,ListPrice,CloseDate
*
* @subcommand property
*/
public function property($args, $assoc_args) {
$listing_id = isset($args[0]) ? $args[0] : null;
if (!$listing_id) {
WP_CLI::error('Please provide a listing ID');
}
// Add NST prefix if not present
if (!preg_match('/^[A-Z]{3}/', $listing_id)) {
$listing_id = 'NST' . $listing_id;
}
WP_CLI::line("Fetching property {$listing_id} from MLS API...");
$api_client = $this->plugin->get_api_client();
$result = $api_client->get_property_media($listing_id);
if (is_wp_error($result)) {
WP_CLI::error('API Error: ' . $result->get_error_message());
}
if (!$result) {
WP_CLI::error("Property {$listing_id} not found in MLS");
}
$format = isset($assoc_args['format']) ? $assoc_args['format'] : 'fields';
switch ($format) {
case 'json':
echo json_encode($result, JSON_PRETTY_PRINT) . "\n";
break;
case 'table':
// Flatten for table display
$flat = array();
foreach ($result as $key => $value) {
if (!is_array($value)) {
$flat[$key] = $value;
}
}
WP_CLI\Utils\format_items('table', array($flat), array_keys($flat));
break;
case 'fields':
default:
// Show key fields
$key_fields = array(
'ListingId',
'ListingKey',
'StandardStatus',
'MlsStatus',
'ListPrice',
'ClosePrice',
'CloseDate',
'ListOfficeName',
'ListAgentFullName',
'StreetNumber',
'StreetName',
'City',
'StateOrProvince',
'ModificationTimestamp',
);
// Allow custom fields
if (isset($assoc_args['fields'])) {
$key_fields = explode(',', $assoc_args['fields']);
}
WP_CLI::line('');
WP_CLI::line('=== Property Details from MLS API ===');
WP_CLI::line('');
foreach ($key_fields as $field) {
$field = trim($field);
$value = isset($result[$field]) ? $result[$field] : '(not set)';
if (is_array($value)) {
$value = json_encode($value);
}
WP_CLI::line(sprintf(' %-25s %s', $field . ':', $value));
}
WP_CLI::line('');
WP_CLI::line('Use --format=json to see full response');
break;
}
}
}