6eadf3d266
- Add download_status, retry_after, queued_at columns to mls_media table - Add mls_media_log table for download attempt tracking - Rewrite media handler to queue downloads instead of immediate download - Add 700ms delay between downloads (25% buffer over 2/sec limit) - Add 3-hour backoff for rate-limited (429) responses - Add max 5 attempts before marking as permanently failed - Add wp mls media command: status, process, reset, logs - Deprecate wp mls sync media in favor of wp mls media process - Update documentation with queue system details and cron examples Media downloads are now separate from property sync: 1. wp mls sync full/incremental - syncs properties, queues media 2. wp mls media process - downloads queued media with rate limiting Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1084 lines
38 KiB
PHP
1084 lines
38 KiB
PHP
<?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 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'));
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
|
|
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
|
|
*
|
|
* <type>
|
|
* : Sync type: full, incremental, media, 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)
|
|
*
|
|
* [--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':
|
|
// Redirect to the new media command
|
|
WP_CLI::line('Note: "wp mls sync media" is deprecated. Use "wp mls media process" instead.');
|
|
WP_CLI::line('');
|
|
$this->media(array('process'), array(
|
|
'limit' => $limit ?: 100,
|
|
'verbose' => $verbose,
|
|
'quiet' => $quiet,
|
|
));
|
|
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', 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>
|
|
* : 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'.");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Manage media download queue.
|
|
*
|
|
* ## OPTIONS
|
|
*
|
|
* <action>
|
|
* : Action: queue, process, status, reset, logs
|
|
*
|
|
* [--limit=<n>]
|
|
* : Limit number of items to process
|
|
*
|
|
* [--verbose]
|
|
* : Show detailed output
|
|
*
|
|
* [--quiet]
|
|
* : Suppress progress output
|
|
*
|
|
* [--days=<n>]
|
|
* : Days of logs to keep (for logs --clear)
|
|
*
|
|
* [--clear]
|
|
* : Clear logs older than --days
|
|
*
|
|
* ## EXAMPLES
|
|
*
|
|
* wp mls media status # Show queue statistics
|
|
* wp mls media process # Process pending downloads (rate limited)
|
|
* wp mls media process --limit=50 # Process up to 50 items
|
|
* wp mls media reset # Reset failed downloads for retry
|
|
* wp mls media logs # Show recent download logs
|
|
* wp mls media logs --clear --days=7 # Clear logs older than 7 days
|
|
*
|
|
* @subcommand media
|
|
*/
|
|
public function media($args, $assoc_args) {
|
|
$action = isset($args[0]) ? $args[0] : 'status';
|
|
$limit = isset($assoc_args['limit']) ? (int) $assoc_args['limit'] : 100;
|
|
$verbose = isset($assoc_args['verbose']);
|
|
$quiet = isset($assoc_args['quiet']);
|
|
|
|
$media_handler = $this->plugin->get_media_handler();
|
|
|
|
switch ($action) {
|
|
case 'status':
|
|
case 'queue':
|
|
$stats = $media_handler->get_queue_stats();
|
|
|
|
WP_CLI::line('');
|
|
WP_CLI::line('=== Media Download Queue ===');
|
|
WP_CLI::line('');
|
|
WP_CLI::line(sprintf('Pending total: %d', $stats['pending']));
|
|
WP_CLI::line(sprintf('Ready now: %d', $stats['ready']));
|
|
WP_CLI::line(sprintf('In backoff: %d (retry scheduled)', $stats['in_backoff']));
|
|
WP_CLI::line(sprintf('Failed: %d (max attempts reached)', $stats['failed']));
|
|
WP_CLI::line(sprintf('Completed: %d', $stats['completed']));
|
|
WP_CLI::line('');
|
|
|
|
if ($stats['ready'] > 0) {
|
|
WP_CLI::line(sprintf(
|
|
'Run "wp mls media process --limit=%d" to download pending media.',
|
|
min($stats['ready'], 100)
|
|
));
|
|
WP_CLI::line(sprintf(
|
|
'Estimated time: %d minutes (at 700ms per image)',
|
|
ceil($stats['ready'] * 0.7 / 60)
|
|
));
|
|
}
|
|
|
|
if ($stats['failed'] > 0) {
|
|
WP_CLI::line('');
|
|
WP_CLI::line('Run "wp mls media reset" to retry failed downloads.');
|
|
}
|
|
WP_CLI::line('');
|
|
break;
|
|
|
|
case 'process':
|
|
$stats = $media_handler->get_queue_stats();
|
|
|
|
if ($stats['ready'] === 0) {
|
|
WP_CLI::success('No media ready to download.');
|
|
break;
|
|
}
|
|
|
|
$process_count = min($limit, $stats['ready']);
|
|
|
|
WP_CLI::line(sprintf(
|
|
'Processing %d media items (rate limited: 1 per 700ms)...',
|
|
$process_count
|
|
));
|
|
WP_CLI::line(sprintf(
|
|
'Estimated time: %d minutes',
|
|
ceil($process_count * 0.7 / 60)
|
|
));
|
|
|
|
if (!$quiet) {
|
|
WP_CLI::line('Legend: P=downloaded B=backoff (retry later) E=error');
|
|
echo "\n";
|
|
}
|
|
|
|
// Progress callback
|
|
$progress_callback = null;
|
|
if (!$quiet) {
|
|
$progress_callback = function($event, $data = array()) use ($verbose) {
|
|
if ($verbose) {
|
|
$this->output_verbose_media_event($event, $data);
|
|
} else {
|
|
switch ($event) {
|
|
case 'media_downloaded':
|
|
echo 'P';
|
|
break;
|
|
case 'media_backoff':
|
|
echo 'B';
|
|
break;
|
|
case 'media_error':
|
|
echo 'E';
|
|
break;
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
$result = $media_handler->process_queue($process_count, $progress_callback);
|
|
|
|
if (!$quiet) {
|
|
echo "\n\n";
|
|
}
|
|
|
|
WP_CLI::line(sprintf(
|
|
'Results: %d success, %d backoff, %d failed out of %d processed',
|
|
$result['success'],
|
|
$result['skipped'],
|
|
$result['failed'],
|
|
$result['processed']
|
|
));
|
|
|
|
// Show updated stats
|
|
$new_stats = $media_handler->get_queue_stats();
|
|
WP_CLI::line(sprintf('Queue remaining: %d ready, %d in backoff', $new_stats['ready'], $new_stats['in_backoff']));
|
|
|
|
if ($result['failed'] > 0 || $result['skipped'] > 0) {
|
|
WP_CLI::line('');
|
|
WP_CLI::line('Items in backoff will be retried after 3 hours.');
|
|
WP_CLI::line('Run "wp mls media logs" to see download history.');
|
|
}
|
|
|
|
if ($result['success'] > 0) {
|
|
WP_CLI::success('Media processing complete.');
|
|
}
|
|
break;
|
|
|
|
case 'reset':
|
|
WP_CLI::line('Resetting failed downloads for retry...');
|
|
|
|
$reset_count = $media_handler->reset_failed_downloads();
|
|
|
|
if ($reset_count > 0) {
|
|
WP_CLI::success(sprintf('Reset %d failed downloads. They will be retried on next process.', $reset_count));
|
|
} else {
|
|
WP_CLI::success('No failed downloads to reset.');
|
|
}
|
|
break;
|
|
|
|
case 'logs':
|
|
if (isset($assoc_args['clear'])) {
|
|
$days = isset($assoc_args['days']) ? (int) $assoc_args['days'] : 7;
|
|
$deleted = $media_handler->clear_old_logs($days);
|
|
WP_CLI::success(sprintf('Deleted %d log entries older than %d days.', $deleted, $days));
|
|
break;
|
|
}
|
|
|
|
$logs = $media_handler->get_download_logs($limit);
|
|
|
|
if (empty($logs)) {
|
|
WP_CLI::success('No download logs found.');
|
|
break;
|
|
}
|
|
|
|
WP_CLI::line('');
|
|
WP_CLI::line('=== Recent Download Logs ===');
|
|
WP_CLI::line('');
|
|
|
|
foreach ($logs as $log) {
|
|
$status_indicator = '';
|
|
switch ($log->action) {
|
|
case 'success':
|
|
$status_indicator = '[OK]';
|
|
break;
|
|
case 'rate_limited':
|
|
$status_indicator = '[429]';
|
|
break;
|
|
case 'permanent_error':
|
|
$status_indicator = '[ERR]';
|
|
break;
|
|
case 'error':
|
|
$status_indicator = '[FAIL]';
|
|
break;
|
|
default:
|
|
$status_indicator = "[{$log->action}]";
|
|
}
|
|
|
|
$line = sprintf(
|
|
'%s %s %s %s %dms',
|
|
$log->created_at,
|
|
$status_indicator,
|
|
$log->listing_key,
|
|
$log->media_key,
|
|
$log->response_time_ms
|
|
);
|
|
|
|
if ($log->status_code) {
|
|
$line .= " HTTP:{$log->status_code}";
|
|
}
|
|
|
|
if ($log->error_message) {
|
|
$line .= " - {$log->error_message}";
|
|
}
|
|
|
|
WP_CLI::line($line);
|
|
}
|
|
|
|
WP_CLI::line('');
|
|
WP_CLI::line(sprintf('Showing %d most recent entries. Use --limit=N to see more.', count($logs)));
|
|
WP_CLI::line('');
|
|
break;
|
|
|
|
default:
|
|
WP_CLI::error("Unknown action: {$action}. Use 'status', 'process', 'reset', or 'logs'.");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Output verbose media event information
|
|
*
|
|
* @param string $event Event name
|
|
* @param array $data Event data
|
|
*/
|
|
private function output_verbose_media_event($event, $data) {
|
|
$timestamp = date('H:i:s');
|
|
|
|
switch ($event) {
|
|
case 'media_downloaded':
|
|
$listing = $data['listing_key'] ?? 'unknown';
|
|
$key = $data['media_key'] ?? 'unknown';
|
|
WP_CLI::line("[{$timestamp}] DOWNLOADED: {$listing} / {$key}");
|
|
break;
|
|
|
|
case 'media_backoff':
|
|
$listing = $data['listing_key'] ?? 'unknown';
|
|
$key = $data['media_key'] ?? 'unknown';
|
|
WP_CLI::warning("[{$timestamp}] BACKOFF: {$listing} / {$key} - will retry in 3 hours");
|
|
break;
|
|
|
|
case 'media_error':
|
|
$listing = $data['listing_key'] ?? 'unknown';
|
|
$key = $data['media_key'] ?? 'unknown';
|
|
$error = $data['error'] ?? 'Unknown error';
|
|
WP_CLI::error("[{$timestamp}] ERROR: {$listing} / {$key} - {$error}", false);
|
|
break;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
}
|