Add MLS by HansonXyz plugin for MLS Grid API integration

Features:
- Full sync of NorthStar MLS properties via MLS Grid API v2
- Incremental sync using ModificationTimestamp
- Local media download and storage
- Rate limit compliance (2 req/sec, 7200/hr, 40000/day)
- Sync state tracking with resume capability
- WP-CLI commands: test, sync, status, stats, cache
- Admin settings page with manual sync triggers
- Public API functions: mls_get_properties, mls_get_property, etc.

Database tables:
- mls_properties: Listing data with full field mapping
- mls_media: Downloaded images
- mls_sync_state: Sync progress tracking
- mls_rate_limits: API usage tracking
- mls_sync_log: Debug logging

Documentation:
- docs/CLAUDE.md: AI development guide
- docs/API.md: MLS Grid API reference
- docs/USAGE.md: User documentation

Tested: Connection, auth, sync 10 records, media download verified

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Hanson.xyz Dev
2025-12-14 21:24:38 -06:00
parent ec5a309555
commit 6556479417
18 changed files with 5324 additions and 10 deletions
@@ -0,0 +1,478 @@
<?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'));
}
/**
* 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)
*
* ## EXAMPLES
*
* wp mls sync full
* wp mls sync full --dry-run --limit=10
* wp mls sync incremental
* 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;
$sync_engine = $this->plugin->get_sync_engine();
// Progress callback for CLI
$progress = null;
$progress_callback = function($stats) use (&$progress) {
if ($progress) {
$progress->tick();
}
};
switch ($type) {
case 'full':
WP_CLI::line('Starting full sync...');
if ($dry_run) {
WP_CLI::line('DRY RUN - No changes will be made');
}
$result = $sync_engine->run_full_sync($dry_run, $limit, $progress_callback);
$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');
}
$result = $sync_engine->run_incremental_sync($dry_run, $progress_callback);
$this->output_sync_result($result);
break;
case 'media':
WP_CLI::line('Downloading pending media...');
$media_handler = $this->plugin->get_media_handler();
$result = $media_handler->download_pending($limit ?: 100);
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.');
}
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}...");
$result = $sync_engine->resume_sync($sync_id, $progress_callback);
$this->output_sync_result($result);
break;
default:
WP_CLI::error("Unknown sync type: {$type}. Use 'full', 'incremental', 'media', or 'resume'.");
}
}
/**
* 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 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
*
* [--confirm]
* : Confirm destructive operations
*
* [--listing=<key>]
* : Listing key for clear-listing
*
* ## EXAMPLES
*
* wp mls cache clear --confirm
* wp mls cache clear-listing --listing=NST123456
* wp mls cache cleanup
*
* @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);
}
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;
default:
WP_CLI::error("Unknown action: {$action}. Use 'clear', 'clear-listing', 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);
}
}