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
+18 -10
View File
@@ -1,10 +1,18 @@
{"id":"html-2fp","title":"Separate Residential and Commercial listings on homepage","description":"","status":"closed","priority":1,"issue_type":"task","created_at":"2025-11-30T02:24:09.984594683-06:00","updated_at":"2025-11-30T02:33:12.32537052-06:00","closed_at":"2025-11-30T02:33:12.32537052-06:00","close_reason":"Separated Featured Homes and Commercial/Land into distinct homepage sections"} {"id":"html-2fp","title":"Separate Residential and Commercial listings on homepage","description":"","status":"closed","priority":1,"issue_type":"task","created_at":"2025-11-30T02:24:09.984594683-06:00","updated_at":"2025-11-30T02:33:12.32537052-06:00","closed_at":"2025-11-30T02:33:12.32537052-06:00"}
{"id":"html-3nq","title":"Enhance footer with office hours, professional logos, license numbers","description":"","status":"closed","priority":3,"issue_type":"task","created_at":"2025-11-30T02:24:30.889106857-06:00","updated_at":"2025-11-30T02:46:52.35661921-06:00","closed_at":"2025-11-30T02:46:52.35661921-06:00","close_reason":"Enhanced footer with office hours, professional logos (REALTOR, Equal Housing), and license number"} {"id":"html-3fb","title":"MLS by HansonXyz Plugin - Phase 5: Public API","description":"Query class with filter support, global helper functions (mls_get_properties, etc), integration hooks for themes","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-14T21:02:45.05131653-06:00","updated_at":"2025-12-14T21:21:46.940975819-06:00","closed_at":"2025-12-14T21:21:46.940975819-06:00","dependencies":[{"issue_id":"html-3fb","depends_on_id":"html-5j7","type":"blocks","created_at":"2025-12-14T21:04:05.308661828-06:00","created_by":"unknown"}]}
{"id":"html-5bw","title":"Add service cards section (Buy/Rent/Sell) to homepage","description":"","status":"closed","priority":1,"issue_type":"task","created_at":"2025-11-30T02:24:04.779591681-06:00","updated_at":"2025-11-30T02:32:12.064316318-06:00","closed_at":"2025-11-30T02:32:12.064316318-06:00","close_reason":"Added service cards section with Buy/Rent/Sell options"} {"id":"html-3nq","title":"Enhance footer with office hours, professional logos, license numbers","description":"","status":"closed","priority":3,"issue_type":"task","created_at":"2025-11-30T02:24:30.889106857-06:00","updated_at":"2025-11-30T02:46:52.35661921-06:00","closed_at":"2025-11-30T02:46:52.35661921-06:00"}
{"id":"html-7jz","title":"Add Communities section to navigation and create community pages structure","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-11-30T02:24:15.204568226-06:00","updated_at":"2025-11-30T02:43:22.075934867-06:00","closed_at":"2025-11-30T02:43:22.075934867-06:00","close_reason":"Created Communities landing page, community page template, 3 community pages, and added to navigation"} {"id":"html-4q8","title":"MLS by HansonXyz Plugin - Phase 6: Admin Interface","description":"Settings page under Settings menu, API token configuration, sync status display, manual sync triggers","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-14T21:02:50.276941526-06:00","updated_at":"2025-12-14T21:21:56.543615901-06:00","closed_at":"2025-12-14T21:21:56.543615901-06:00","dependencies":[{"issue_id":"html-4q8","depends_on_id":"html-3fb","type":"blocks","created_at":"2025-12-14T21:04:10.388690618-06:00","created_by":"unknown"}]}
{"id":"html-98b","title":"Add location search dropdown to homepage hero","description":"","status":"closed","priority":1,"issue_type":"task","created_at":"2025-11-30T02:23:59.555310037-06:00","updated_at":"2025-11-30T02:30:59.92891882-06:00","closed_at":"2025-11-30T02:30:59.92891882-06:00","close_reason":"Added location search dropdown to hero section with community taxonomy"} {"id":"html-4za","title":"MLS by HansonXyz Plugin - Phase 2: API Client","description":"API Client class with auth, gzip, error handling. Rate Limiter class. CLI test commands (wp mls test connection/auth)","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-14T21:02:29.352944416-06:00","updated_at":"2025-12-14T21:21:36.854872754-06:00","closed_at":"2025-12-14T21:21:36.854872754-06:00","dependencies":[{"issue_id":"html-4za","depends_on_id":"html-ha4","type":"blocks","created_at":"2025-12-14T21:03:50.090841814-06:00","created_by":"unknown"}]}
{"id":"html-bfd","title":"Update DESIGN-DOCUMENT.md and IMPLEMENTATION-PLAN.md with RHR structural changes","description":"","status":"closed","priority":0,"issue_type":"task","created_at":"2025-11-30T02:24:40.504170573-06:00","updated_at":"2025-11-30T02:28:50.551587345-06:00","closed_at":"2025-11-30T02:28:50.551587345-06:00","close_reason":"Updated DESIGN-DOCUMENT.md and IMPLEMENTATION-PLAN.md with RHR structural changes"} {"id":"html-5bw","title":"Add service cards section (Buy/Rent/Sell) to homepage","description":"","status":"closed","priority":1,"issue_type":"task","created_at":"2025-11-30T02:24:04.779591681-06:00","updated_at":"2025-11-30T02:32:12.064316318-06:00","closed_at":"2025-11-30T02:32:12.064316318-06:00"}
{"id":"html-clv","title":"Analyze Robert Hoffman Realty site structure for HomeProz redesign","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-11-30T02:11:43.511290155-06:00","updated_at":"2025-11-30T02:21:47.665340956-06:00","closed_at":"2025-11-30T02:21:47.665340956-06:00","close_reason":"Completed site analysis comparing RHR to HomeProz design"} {"id":"html-5j7","title":"MLS by HansonXyz Plugin - Phase 3: Sync Engine","description":"Sync Engine class, full sync with pagination, incremental sync with ModificationTimestamp, sync state tracking for resume, CLI sync commands","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-14T21:02:34.581442915-06:00","updated_at":"2025-12-14T21:21:46.933578124-06:00","closed_at":"2025-12-14T21:21:46.933578124-06:00","dependencies":[{"issue_id":"html-5j7","depends_on_id":"html-4za","type":"blocks","created_at":"2025-12-14T21:03:55.163778736-06:00","created_by":"unknown"}]}
{"id":"html-cpd","title":"Add map view to property listings archive page","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-11-30T02:24:20.442584472-06:00","updated_at":"2025-11-30T02:48:58.865691376-06:00","closed_at":"2025-11-30T02:48:58.865691376-06:00","close_reason":"Added map view to property archive with Grid/Map toggle using Leaflet, city-based property markers, and split layout"} {"id":"html-7jz","title":"Add Communities section to navigation and create community pages structure","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-11-30T02:24:15.204568226-06:00","updated_at":"2025-11-30T02:43:22.075934867-06:00","closed_at":"2025-11-30T02:43:22.075934867-06:00"}
{"id":"html-lci","title":"Scrape homeprozrealestate.com property listings and import to WordPress","description":"","status":"closed","priority":1,"issue_type":"task","created_at":"2025-11-30T17:37:41.948645374-06:00","updated_at":"2025-11-30T18:06:33.347607321-06:00","closed_at":"2025-11-30T18:06:33.347607321-06:00","close_reason":"Imported 5 properties with images, ACF fields, and external listing URLs"} {"id":"html-98b","title":"Add location search dropdown to homepage hero","description":"","status":"closed","priority":1,"issue_type":"task","created_at":"2025-11-30T02:23:59.555310037-06:00","updated_at":"2025-11-30T02:30:59.92891882-06:00","closed_at":"2025-11-30T02:30:59.92891882-06:00"}
{"id":"html-t8u","title":"Add Resources section to navigation and create resource pages","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-11-30T02:24:25.662824938-06:00","updated_at":"2025-11-30T02:45:31.13972652-06:00","closed_at":"2025-11-30T02:45:31.13972652-06:00","close_reason":"Created Resources landing page, resource page template, Buyer's Guide, Seller's Guide, and added to navigation"} {"id":"html-bfd","title":"Update DESIGN-DOCUMENT.md and IMPLEMENTATION-PLAN.md with RHR structural changes","description":"","status":"closed","priority":0,"issue_type":"task","created_at":"2025-11-30T02:24:40.504170573-06:00","updated_at":"2025-11-30T02:28:50.551587345-06:00","closed_at":"2025-11-30T02:28:50.551587345-06:00"}
{"id":"html-clv","title":"Analyze Robert Hoffman Realty site structure for HomeProz redesign","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-11-30T02:11:43.511290155-06:00","updated_at":"2025-11-30T02:21:47.665340956-06:00","closed_at":"2025-11-30T02:21:47.665340956-06:00"}
{"id":"html-cpd","title":"Add map view to property listings archive page","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-11-30T02:24:20.442584472-06:00","updated_at":"2025-11-30T02:48:58.865691376-06:00","closed_at":"2025-11-30T02:48:58.865691376-06:00"}
{"id":"html-ha4","title":"MLS by HansonXyz Plugin - Phase 1: Foundation","description":"Create plugin structure, main file, activator/deactivator, database schema with dbDelta, options handling, logger class","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-14T21:02:24.104655824-06:00","updated_at":"2025-12-14T21:21:36.848348568-06:00","closed_at":"2025-12-14T21:21:36.848348568-06:00"}
{"id":"html-k37","title":"MLS by HansonXyz Plugin - Phase 8: Documentation \u0026 Testing","description":"CLAUDE.md, API.md, USAGE.md documentation. Full CLI test sequence. Final verification.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-14T21:03:00.728802408-06:00","updated_at":"2025-12-14T21:22:16.755253009-06:00","closed_at":"2025-12-14T21:22:16.755253009-06:00","dependencies":[{"issue_id":"html-k37","depends_on_id":"html-x03","type":"blocks","created_at":"2025-12-14T21:04:20.543019628-06:00","created_by":"unknown"}]}
{"id":"html-lci","title":"Scrape homeprozrealestate.com property listings and import to WordPress","description":"","status":"closed","priority":1,"issue_type":"task","created_at":"2025-11-30T17:37:41.948645374-06:00","updated_at":"2025-11-30T18:06:33.347607321-06:00","closed_at":"2025-11-30T18:06:33.347607321-06:00"}
{"id":"html-sbh","title":"MLS by HansonXyz Plugin - Phase 4: Media Handler","description":"Media Handler class, download and organize media files, PhotosChangeTimestamp detection, CLI media commands","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-14T21:02:39.793324508-06:00","updated_at":"2025-12-14T21:21:46.94012466-06:00","closed_at":"2025-12-14T21:21:46.94012466-06:00","dependencies":[{"issue_id":"html-sbh","depends_on_id":"html-5j7","type":"blocks","created_at":"2025-12-14T21:04:00.24472923-06:00","created_by":"unknown"}]}
{"id":"html-t8u","title":"Add Resources section to navigation and create resource pages","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-11-30T02:24:25.662824938-06:00","updated_at":"2025-11-30T02:45:31.13972652-06:00","closed_at":"2025-11-30T02:45:31.13972652-06:00"}
{"id":"html-x03","title":"MLS by HansonXyz Plugin - Phase 7: Cron \u0026 Automation","description":"WP Cron scheduling (configurable interval), standalone cron script for Unix cron","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-14T21:02:55.483012095-06:00","updated_at":"2025-12-14T21:22:06.519310981-06:00","closed_at":"2025-12-14T21:22:06.519310981-06:00","dependencies":[{"issue_id":"html-x03","depends_on_id":"html-4q8","type":"blocks","created_at":"2025-12-14T21:04:15.476790917-06:00","created_by":"unknown"}]}
@@ -0,0 +1,386 @@
<?php
/**
* MLS Admin Settings Page
*/
if (!defined('ABSPATH')) {
exit;
}
class MLS_Admin {
/**
* Plugin instance
*/
private $plugin;
/**
* Constructor
*/
public function __construct($plugin) {
$this->plugin = $plugin;
add_action('admin_menu', array($this, 'add_menu'));
add_action('admin_init', array($this, 'register_settings'));
add_action('wp_ajax_mls_test_connection', array($this, 'ajax_test_connection'));
add_action('wp_ajax_mls_run_sync', array($this, 'ajax_run_sync'));
}
/**
* Add admin menu
*/
public function add_menu() {
add_options_page(
'MLS Settings',
'MLS Settings',
'manage_options',
'mls-settings',
array($this, 'render_settings_page')
);
}
/**
* Register settings
*/
public function register_settings() {
register_setting('mls_settings', MLS_Options::OPTION_KEY, array(
'sanitize_callback' => array($this, 'sanitize_options'),
));
}
/**
* Sanitize options
*/
public function sanitize_options($input) {
$sanitized = array();
if (isset($input['api_url'])) {
$sanitized['api_url'] = esc_url_raw($input['api_url']);
}
if (isset($input['api_token'])) {
$sanitized['api_token'] = sanitize_text_field($input['api_token']);
}
if (isset($input['originating_system'])) {
$sanitized['originating_system'] = sanitize_text_field($input['originating_system']);
}
$sanitized['auto_sync_enabled'] = !empty($input['auto_sync_enabled']);
$sanitized['sync_media'] = !empty($input['sync_media']);
if (isset($input['sync_interval'])) {
$allowed = array('every_30_minutes', 'hourly', 'every_2_hours', 'every_6_hours', 'every_12_hours', 'daily');
$sanitized['sync_interval'] = in_array($input['sync_interval'], $allowed)
? $input['sync_interval']
: 'hourly';
}
// Preserve timestamps
$existing = get_option(MLS_Options::OPTION_KEY, array());
if (isset($existing['last_full_sync'])) {
$sanitized['last_full_sync'] = $existing['last_full_sync'];
}
if (isset($existing['last_incremental_sync'])) {
$sanitized['last_incremental_sync'] = $existing['last_incremental_sync'];
}
return $sanitized;
}
/**
* Render settings page
*/
public function render_settings_page() {
if (!current_user_can('manage_options')) {
return;
}
$options = $this->plugin->get_options();
$db = $this->plugin->get_db();
$stats = $db->get_stats();
$sync_engine = $this->plugin->get_sync_engine();
$sync_status = $sync_engine->get_status();
$rate_limiter = $this->plugin->get_rate_limiter();
$rate_status = $rate_limiter->get_status();
// Check if using wp-config constants
$using_config_url = defined('MLSGRID_API_URL') && MLSGRID_API_URL;
$using_config_token = defined('MLSGRID_ACCESS_TOKEN') && MLSGRID_ACCESS_TOKEN;
?>
<div class="wrap">
<h1>MLS Settings</h1>
<div class="mls-admin-grid" style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-top: 20px;">
<!-- Settings Form -->
<div class="mls-settings-form">
<form method="post" action="options.php">
<?php settings_fields('mls_settings'); ?>
<table class="form-table">
<tr>
<th scope="row">API URL</th>
<td>
<?php if ($using_config_url): ?>
<input type="text" value="<?php echo esc_attr($options->get_api_url()); ?>" class="regular-text" disabled />
<p class="description">Set via MLSGRID_API_URL in wp-config.php</p>
<?php else: ?>
<input type="url" name="<?php echo MLS_Options::OPTION_KEY; ?>[api_url]"
value="<?php echo esc_attr($options->get('api_url')); ?>"
class="regular-text" />
<?php endif; ?>
</td>
</tr>
<tr>
<th scope="row">API Token</th>
<td>
<?php if ($using_config_token): ?>
<input type="password" value="••••••••••••" class="regular-text" disabled />
<p class="description">Set via MLSGRID_ACCESS_TOKEN in wp-config.php</p>
<?php else: ?>
<input type="password" name="<?php echo MLS_Options::OPTION_KEY; ?>[api_token]"
value="<?php echo esc_attr($options->get('api_token')); ?>"
class="regular-text" />
<?php endif; ?>
</td>
</tr>
<tr>
<th scope="row">Originating System</th>
<td>
<input type="text" name="<?php echo MLS_Options::OPTION_KEY; ?>[originating_system]"
value="<?php echo esc_attr($options->get('originating_system', 'northstar')); ?>"
class="regular-text" />
<p class="description">MLS system identifier (e.g., northstar)</p>
</td>
</tr>
<tr>
<th scope="row">Auto Sync</th>
<td>
<label>
<input type="checkbox" name="<?php echo MLS_Options::OPTION_KEY; ?>[auto_sync_enabled]"
value="1" <?php checked($options->get('auto_sync_enabled')); ?> />
Enable automatic sync
</label>
</td>
</tr>
<tr>
<th scope="row">Sync Interval</th>
<td>
<select name="<?php echo MLS_Options::OPTION_KEY; ?>[sync_interval]">
<option value="every_30_minutes" <?php selected($options->get('sync_interval'), 'every_30_minutes'); ?>>Every 30 minutes</option>
<option value="hourly" <?php selected($options->get('sync_interval'), 'hourly'); ?>>Hourly</option>
<option value="every_2_hours" <?php selected($options->get('sync_interval'), 'every_2_hours'); ?>>Every 2 hours</option>
<option value="every_6_hours" <?php selected($options->get('sync_interval'), 'every_6_hours'); ?>>Every 6 hours</option>
<option value="every_12_hours" <?php selected($options->get('sync_interval'), 'every_12_hours'); ?>>Every 12 hours</option>
<option value="daily" <?php selected($options->get('sync_interval'), 'daily'); ?>>Daily</option>
</select>
</td>
</tr>
<tr>
<th scope="row">Sync Media</th>
<td>
<label>
<input type="checkbox" name="<?php echo MLS_Options::OPTION_KEY; ?>[sync_media]"
value="1" <?php checked($options->get('sync_media', true)); ?> />
Download listing photos
</label>
</td>
</tr>
</table>
<?php submit_button(); ?>
</form>
<hr />
<h2>Manual Actions</h2>
<p>
<button type="button" class="button" id="mls-test-connection">Test Connection</button>
<span id="mls-test-result"></span>
</p>
<p>
<button type="button" class="button button-primary" id="mls-run-sync">Run Incremental Sync</button>
<button type="button" class="button" id="mls-run-full-sync">Run Full Sync</button>
</p>
<div id="mls-sync-progress" style="display:none; margin-top: 10px;">
<span class="spinner is-active" style="float: none;"></span>
<span id="mls-sync-status">Syncing...</span>
</div>
</div>
<!-- Status Panel -->
<div class="mls-status-panel">
<div class="card" style="max-width: none; padding: 15px;">
<h2 style="margin-top: 0;">Database Statistics</h2>
<table class="widefat">
<tr>
<td>Total Properties</td>
<td><strong><?php echo number_format($stats['total_properties']); ?></strong></td>
</tr>
<tr>
<td>Active</td>
<td><?php echo number_format($stats['active_properties']); ?></td>
</tr>
<tr>
<td>Pending</td>
<td><?php echo number_format($stats['pending_properties']); ?></td>
</tr>
<tr>
<td>Sold/Closed</td>
<td><?php echo number_format($stats['sold_properties']); ?></td>
</tr>
<tr>
<td>Media Files</td>
<td><?php echo number_format($stats['downloaded_media']); ?> / <?php echo number_format($stats['total_media']); ?></td>
</tr>
</table>
</div>
<div class="card" style="max-width: none; padding: 15px; margin-top: 15px;">
<h2 style="margin-top: 0;">Last Sync</h2>
<?php if ($sync_status['last_sync']): ?>
<p>
<strong>Type:</strong> <?php echo esc_html($sync_status['last_sync']->sync_type); ?><br />
<strong>Completed:</strong> <?php echo esc_html($sync_status['last_sync']->completed_at); ?><br />
<strong>Records:</strong> <?php echo number_format($sync_status['last_sync']->records_processed); ?> processed
</p>
<?php else: ?>
<p>No sync completed yet.</p>
<?php endif; ?>
</div>
<div class="card" style="max-width: none; padding: 15px; margin-top: 15px;">
<h2 style="margin-top: 0;">Rate Limits</h2>
<table class="widefat">
<tr>
<td>Hourly</td>
<td><?php echo number_format($rate_status['hourly']['used']); ?> / <?php echo number_format($rate_status['hourly']['limit']); ?></td>
</tr>
<tr>
<td>Daily</td>
<td><?php echo number_format($rate_status['daily']['used']); ?> / <?php echo number_format($rate_status['daily']['limit']); ?></td>
</tr>
<tr>
<td>Data This Hour</td>
<td><?php echo size_format($rate_status['bytes_this_hour']); ?></td>
</tr>
</table>
</div>
</div>
</div>
</div>
<script>
jQuery(document).ready(function($) {
$('#mls-test-connection').on('click', function() {
var $btn = $(this);
var $result = $('#mls-test-result');
$btn.prop('disabled', true);
$result.text('Testing...');
$.post(ajaxurl, {
action: 'mls_test_connection',
_wpnonce: '<?php echo wp_create_nonce('mls_admin'); ?>'
}, function(response) {
$btn.prop('disabled', false);
if (response.success) {
$result.html('<span style="color:green;">Connected! (' + response.data.response_time + 'ms)</span>');
} else {
$result.html('<span style="color:red;">Failed: ' + response.data + '</span>');
}
});
});
$('#mls-run-sync, #mls-run-full-sync').on('click', function() {
var syncType = $(this).attr('id') === 'mls-run-full-sync' ? 'full' : 'incremental';
if (syncType === 'full' && !confirm('Run full sync? This may take a while.')) {
return;
}
$('#mls-sync-progress').show();
$('#mls-sync-status').text('Starting ' + syncType + ' sync...');
$.post(ajaxurl, {
action: 'mls_run_sync',
sync_type: syncType,
_wpnonce: '<?php echo wp_create_nonce('mls_admin'); ?>'
}, function(response) {
$('#mls-sync-progress').hide();
if (response.success) {
alert('Sync completed! ' + response.data.message);
location.reload();
} else {
alert('Sync failed: ' + response.data);
}
});
});
});
</script>
<?php
}
/**
* AJAX: Test connection
*/
public function ajax_test_connection() {
check_ajax_referer('mls_admin', '_wpnonce');
if (!current_user_can('manage_options')) {
wp_send_json_error('Permission denied');
}
$api_client = $this->plugin->get_api_client();
$result = $api_client->test_connection();
if ($result['success']) {
wp_send_json_success($result);
} else {
wp_send_json_error($result['error']);
}
}
/**
* AJAX: Run sync
*/
public function ajax_run_sync() {
check_ajax_referer('mls_admin', '_wpnonce');
if (!current_user_can('manage_options')) {
wp_send_json_error('Permission denied');
}
$sync_type = isset($_POST['sync_type']) ? sanitize_text_field($_POST['sync_type']) : 'incremental';
$sync_engine = $this->plugin->get_sync_engine();
// Run sync with a reasonable limit for AJAX
if ($sync_type === 'full') {
$result = $sync_engine->run_full_sync(false, 500);
} else {
$result = $sync_engine->run_incremental_sync(false);
}
if ($result['success']) {
wp_send_json_success(array(
'message' => sprintf(
'%d processed, %d created, %d updated',
$result['stats']['processed'],
$result['stats']['created'],
$result['stats']['updated']
),
'stats' => $result['stats'],
));
} else {
wp_send_json_error($result['error']);
}
}
}
@@ -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);
}
}
@@ -0,0 +1,298 @@
# MLS Grid API Reference
Documentation for the MLS Grid API v2 used by this plugin.
## Base URL
```
https://api.mlsgrid.com/v2
```
## Authentication
Bearer token authentication via HTTP header:
```
Authorization: Bearer {access_token}
```
**Required Header:**
```
Accept-Encoding: gzip
```
The API requires gzip compression and will return error 400 without it.
## Rate Limits
| Limit | Value |
|-------|-------|
| Per Second | 2 requests |
| Per Hour | 7,200 requests |
| Per Day | 40,000 requests |
| Data Per Hour | 4GB |
Exceeding limits returns HTTP 429 and temporarily suspends access.
## Endpoints
### Property
```
GET /Property
```
Main endpoint for listing data.
**Query Parameters:**
- `$filter` - OData filter expression (required)
- `$expand` - Include related resources: Media, Rooms, UnitTypes
- `$top` - Records per page (max 5000, max 1000 with $expand)
- `$select` - Specific fields to return
- `$orderby` - Sort order
**Example:**
```
/Property?$filter=OriginatingSystemName eq 'northstar' and MlgCanView eq true&$expand=Media&$top=1000
```
### Member
```
GET /Member
```
Agent/member records.
### Office
```
GET /Office
```
Brokerage office records.
### OpenHouse
```
GET /OpenHouse
```
Open house event records.
### Lookup
```
GET /Lookup
```
Field value definitions. Query no more than once per day.
## OData Filter Syntax
### Operators
| Operator | Description | Example |
|----------|-------------|---------|
| eq | Equals | `City eq 'Austin'` |
| ne | Not equals | `Status ne 'Sold'` |
| gt | Greater than | `ListPrice gt 200000` |
| ge | Greater or equal | `BedroomsTotal ge 3` |
| lt | Less than | `ListPrice lt 500000` |
| le | Less or equal | `YearBuilt le 2020` |
| and | Logical AND | `City eq 'Austin' and BedroomsTotal ge 3` |
| or | Logical OR | Limited to 5 per query |
| in | In list | `City in ('Austin', 'Dallas')` |
### Required Filters
Every Property request MUST include:
```
OriginatingSystemName eq 'northstar'
```
For initial import, add:
```
MlgCanView eq true
```
### Timestamp Filters
For incremental sync:
```
ModificationTimestamp gt 2024-01-15T00:00:00.000Z
```
## Pagination
Responses include `@odata.nextLink` field containing URL for next page.
```json
{
"@odata.context": "...",
"value": [...],
"@odata.nextLink": "https://api.mlsgrid.com/v2/Property?$filter=...&$skip=1000"
}
```
Continue fetching until `@odata.nextLink` is absent.
## Property Fields
### Core Fields
| Field | Type | Description |
|-------|------|-------------|
| ListingKey | string | Unique identifier |
| ListingId | string | MLS listing number |
| StandardStatus | string | Active, Pending, Closed, etc. |
| ListPrice | decimal | Listing price |
| ClosePrice | decimal | Sold price |
### Address Fields
| Field | Type | Description |
|-------|------|-------------|
| StreetNumber | string | Street number |
| StreetName | string | Street name |
| StreetSuffix | string | St, Ave, Blvd, etc. |
| UnitNumber | string | Unit/apt number |
| City | string | City name |
| StateOrProvince | string | State abbreviation |
| PostalCode | string | ZIP code |
| CountyOrParish | string | County name |
| Latitude | decimal | GPS latitude |
| Longitude | decimal | GPS longitude |
### Property Details
| Field | Type | Description |
|-------|------|-------------|
| PropertyType | string | Residential, Land, Commercial, etc. |
| PropertySubType | string | Single Family, Condo, etc. |
| BedroomsTotal | integer | Total bedrooms |
| BathroomsTotalInteger | integer | Total bathrooms |
| BathroomsFull | integer | Full bathrooms |
| BathroomsHalf | integer | Half bathrooms |
| LivingArea | integer | Square feet |
| LotSizeArea | decimal | Lot size |
| LotSizeUnits | string | Acres, SqFt |
| YearBuilt | integer | Year built |
| GarageSpaces | integer | Garage spaces |
### Description Fields
| Field | Type | Description |
|-------|------|-------------|
| PublicRemarks | string | Property description |
| Directions | string | Driving directions |
### Agent/Office Fields
| Field | Type | Description |
|-------|------|-------------|
| ListAgentKey | string | Listing agent ID |
| ListAgentMlsId | string | Agent MLS ID |
| ListOfficeKey | string | Listing office ID |
| ListOfficeName | string | Office name |
| ListOfficeMlsId | string | Office MLS ID |
### Timestamps
| Field | Type | Description |
|-------|------|-------------|
| ModificationTimestamp | datetime | Last modified (use for sync) |
| PhotosChangeTimestamp | datetime | Media last changed |
| ListingContractDate | date | Listed date |
| CloseDate | date | Sold date |
| DaysOnMarket | integer | DOM count |
### MLS Grid Fields
| Field | Type | Description |
|-------|------|-------------|
| MlgCanView | boolean | OK to display (false = delete) |
| MlgCanUse | array | Permitted use cases (IDX, VOW, etc.) |
| OriginatingSystemName | string | Source MLS identifier |
## Media (via $expand)
When using `$expand=Media`, each property includes Media array:
```json
{
"Media": [
{
"MediaKey": "abc123",
"MediaURL": "https://media.mlsgrid.com/...",
"Order": 1,
"ImageWidth": 1200,
"ImageHeight": 800,
"MediaModificationTimestamp": "2024-01-15T10:30:00Z"
}
]
}
```
**Important:** MediaURL is for downloading only. Store images locally.
## Sync Strategy
### Initial Import
1. Query with `MlgCanView eq true` to get viewable records
2. Follow `@odata.nextLink` for pagination
3. Store `ModificationTimestamp` from last record
### Incremental Sync
1. Query with `ModificationTimestamp gt {last_timestamp}`
2. Do NOT filter by MlgCanView (need to see deletions)
3. If `MlgCanView = false`, delete local record
### Media Sync
1. Check `PhotosChangeTimestamp` on each property
2. If changed, replace all media for that listing
3. Match by `MediaKey`, download via `MediaURL`
4. Delete media where `MediaKey` no longer exists
### Error Recovery
Store `@odata.nextLink` after each page. On failure, resume from that URL.
## Best Practices
1. **Sequential requests only** - Do not parallelize API calls
2. **Respect rate limits** - 2 req/sec max, pause if approaching limits
3. **Use $expand wisely** - Reduces per-page limit from 5000 to 1000
4. **Store raw JSON** - Keep original response for debugging
5. **Query Lookup sparingly** - Once per day maximum
6. **Don't hotlink media** - Download and serve from local storage
## Error Responses
```json
{
"error": {
"code": 400,
"message": "Error description",
"target": "misc",
"details": []
}
}
```
| Code | Meaning |
|------|---------|
| 400 | Bad request (check filters, missing gzip) |
| 401 | Unauthorized (invalid token) |
| 429 | Rate limited (wait and retry) |
| 500+ | Server error (retry with backoff) |
## Resources
- [MLS Grid Documentation](https://docs.mlsgrid.com/)
- [API v2 Reference](https://docs.mlsgrid.com/api-documentation/api-version-2.0)
- [Best Practices Guide](https://www.mlsgrid.com/resources)
@@ -0,0 +1,140 @@
# MLS by HansonXyz Plugin
WordPress plugin for syncing MLS Grid API data (NorthStar MLS) into local database.
## Development Rules
1. **No emojis** - nowhere in code, commits, docs, or conversation
2. **PHP 7.4+** compatible code
3. **WordPress Coding Standards**
4. Follow patterns from existing HomeProz theme
## Quick Reference
### Database Tables
All tables use `{$wpdb->prefix}mls_` prefix:
| Table | Purpose |
|-------|---------|
| `mls_properties` | Listing data |
| `mls_media` | Media files |
| `mls_sync_state` | Sync progress tracking |
| `mls_rate_limits` | API usage tracking |
| `mls_sync_log` | Debug logging |
### API Configuration
Credentials in wp-config.php:
```php
define('MLSGRID_API_URL', 'https://api.mlsgrid.com/v2');
define('MLSGRID_ACCESS_TOKEN', 'your-token-here');
```
### MLS Grid API Rate Limits
MUST comply with these limits:
- 2 requests/second
- 7,200 requests/hour
- 40,000 requests/day
- 4GB data/hour
### Key Files
| File | Purpose |
|------|---------|
| `includes/class-mls-api-client.php` | API communication, auth, gzip |
| `includes/class-mls-sync-engine.php` | Sync orchestration |
| `includes/class-mls-media-handler.php` | Media download/storage |
| `includes/class-mls-query.php` | Public query API |
| `includes/class-mls-rate-limiter.php` | Rate limit compliance |
| `cli/class-mls-cli.php` | WP-CLI commands |
### WP-CLI Commands
```bash
# Test connectivity
wp mls test connection
wp mls test auth
# Show status
wp mls status
wp mls status rate-limits
# Run sync
wp mls sync full [--dry-run] [--limit=N]
wp mls sync incremental [--dry-run]
wp mls sync media [--limit=N]
wp mls sync resume --id=<sync_id>
# Statistics
wp mls stats
# Cache management
wp mls cache clear --confirm
wp mls cache cleanup
```
### Public API Functions
Available for themes/plugins:
```php
// Get properties with filters
$properties = mls_get_properties([
'status' => 'Active',
'city' => 'Albert Lea',
'min_price' => 100000,
'limit' => 20,
]);
// Get single property
$property = mls_get_property('NST123456');
// Get media
$media = mls_get_property_media('NST123456');
$image_url = mls_get_property_image('NST123456');
// Get distinct values
$cities = mls_get_cities('Active');
// Check data availability
if (mls_is_available()) { ... }
```
### Sync Strategy
1. **Initial Import**: Full sync downloads all viewable properties
2. **Incremental**: Uses ModificationTimestamp to fetch only changes
3. **Delete Handling**: MlgCanView=false triggers local deletion
4. **Media**: Downloads to wp-content/uploads/mls-listings/
5. **Recovery**: Stores last_next_link for resume on failure
### Testing After Changes
```bash
wp mls test connection
wp mls test auth
wp mls sync full --dry-run --limit=10
wp mls stats
```
### Property Data Mapping
Key fields from API to database:
| API Field | DB Column |
|-----------|-----------|
| ListingKey | listing_key |
| ListingId | listing_id |
| ListPrice | list_price |
| StandardStatus | standard_status |
| BedroomsTotal | bedrooms_total |
| BathroomsTotalInteger | bathrooms_total |
| LivingArea | living_area |
| City | city |
| ModificationTimestamp | modification_timestamp |
| PhotosChangeTimestamp | photos_change_timestamp |
| MlgCanView | mlg_can_view |
Full API response stored in `raw_data` column as JSON.
@@ -0,0 +1,244 @@
# MLS by HansonXyz - User Documentation
## Overview
This plugin syncs property listing data from MLS Grid (NorthStar MLS) into your WordPress database, making it available for use by themes and other plugins.
## Installation
1. Upload the `mls-by-hansonxyz` folder to `/wp-content/plugins/`
2. Activate the plugin through the WordPress admin
3. Configure API credentials (see below)
4. Run initial sync
## Configuration
### API Credentials
Add to your `wp-config.php`:
```php
define('MLSGRID_API_URL', 'https://api.mlsgrid.com/v2');
define('MLSGRID_ACCESS_TOKEN', 'your-access-token-here');
```
Alternatively, configure via Settings > MLS Settings in WordPress admin.
### Settings
Navigate to **Settings > MLS Settings** to configure:
- **Originating System**: MLS identifier (default: `northstar`)
- **Auto Sync**: Enable automatic background sync
- **Sync Interval**: How often to sync (30min to daily)
- **Sync Media**: Whether to download listing photos
## Running Sync
### Via Admin Panel
1. Go to Settings > MLS Settings
2. Click "Run Incremental Sync" or "Run Full Sync"
3. Wait for completion
### Via WP-CLI
```bash
# Test connection first
wp mls test connection
wp mls test auth
# Run initial full sync
wp mls sync full
# Run incremental updates
wp mls sync incremental
# Download pending media
wp mls sync media
```
### Via Cron
Add to your system crontab for scheduled sync:
```bash
# Run incremental sync every hour
0 * * * * cd /var/www/html && wp mls sync incremental --allow-root
```
## Checking Status
### Via Admin
Settings > MLS Settings shows:
- Database statistics (property counts by status)
- Last sync time and results
- Rate limit usage
### Via CLI
```bash
# Full status
wp mls status
# Just rate limits
wp mls status rate-limits
# Database statistics
wp mls stats
```
## Using the Data
### For Theme Developers
The plugin provides global helper functions:
```php
// Get active properties in a city
$properties = mls_get_properties([
'status' => 'Active',
'city' => 'Albert Lea',
'limit' => 20,
]);
foreach ($properties as $property) {
echo $property->list_price;
echo $property->bedrooms_total;
echo $property->city;
}
// Get a single property
$property = mls_get_property('NST123456');
// Get property images
$media = mls_get_property_media($property->listing_key);
$primary_image = mls_get_property_image($property->listing_key);
// Get cities with active listings
$cities = mls_get_cities('Active');
// Check if data is available
if (mls_is_available()) {
// Show property search
}
```
### Query Parameters
`mls_get_properties()` accepts these filters:
| Parameter | Type | Description |
|-----------|------|-------------|
| status | string | Active, Pending, Closed |
| property_type | string | Residential, Land, etc. |
| city | string | City name |
| county | string | County name |
| postal_code | string | ZIP code |
| min_price | int | Minimum price |
| max_price | int | Maximum price |
| min_beds | int | Minimum bedrooms |
| max_beds | int | Maximum bedrooms |
| min_baths | int | Minimum bathrooms |
| min_sqft | int | Minimum square feet |
| max_sqft | int | Maximum square feet |
| search | string | Search address/remarks |
| limit | int | Results per page |
| offset | int | Pagination offset |
| orderby | string | Sort field |
| order | string | ASC or DESC |
| include_media | bool | Include media array |
### Property Object Fields
Each property object includes:
```php
$property->listing_key // Unique ID
$property->listing_id // MLS number
$property->list_price // Price
$property->standard_status // Active, Pending, Closed
$property->street_number
$property->street_name
$property->street_suffix
$property->city
$property->state_or_province
$property->postal_code
$property->county
$property->latitude
$property->longitude
$property->property_type
$property->property_sub_type
$property->bedrooms_total
$property->bathrooms_total
$property->living_area // Square feet
$property->lot_size_area
$property->year_built
$property->garage_spaces
$property->public_remarks // Description
$property->directions
$property->list_office_name
$property->photos_count
$property->days_on_market
$property->modification_timestamp
```
## Media Storage
Downloaded images are stored in:
```
wp-content/uploads/mls-listings/{prefix}/{listing_key}/
```
Images are named by order: `1.jpg`, `2.jpg`, etc.
Access via:
```php
$media = mls_get_property_media($listing_key);
foreach ($media as $image) {
echo '<img src="' . esc_url($image->local_url) . '">';
}
```
## Troubleshooting
### Connection Failed
1. Verify API token is correct in wp-config.php
2. Check that MLSGRID_API_URL is set
3. Run `wp mls test connection` for details
### No Data After Sync
1. Check `wp mls status` for errors
2. Review rate limits - may need to wait
3. Check WordPress debug log for API errors
### Media Not Downloading
1. Verify `sync_media` is enabled in settings
2. Check upload directory is writable
3. Run `wp mls sync media` manually
### Rate Limit Exceeded
The plugin automatically waits when approaching limits. If suspended:
1. Wait for the rate limit window to reset
2. Reduce sync frequency
3. Contact MLS Grid support if persistent
### Clearing Data
To start fresh:
```bash
wp mls cache clear --confirm
```
This removes all synced data but keeps settings.
## Support
For plugin issues: Check logs at Settings > MLS Settings
For API issues: Contact MLS Grid support at support@mlsgrid.com
@@ -0,0 +1,137 @@
<?php
/**
* Plugin activator class
*/
if (!defined('ABSPATH')) {
exit;
}
class MLS_Activator {
/**
* Run on plugin activation
*/
public static function activate() {
// Create database tables
self::create_tables();
// Set default options
self::set_defaults();
// Schedule cron events if auto-sync enabled
self::schedule_cron();
// Create upload directory
self::create_upload_dir();
// Store activation time
update_option('mls_activated_at', current_time('mysql'));
// Flush rewrite rules
flush_rewrite_rules();
}
/**
* Create database tables
*/
public static function create_tables() {
MLS_DB::create_tables();
update_option('mls_db_version', MLS_DB_VERSION);
}
/**
* Set default options
*/
private static function set_defaults() {
$defaults = array(
'api_url' => defined('MLSGRID_API_URL') ? MLSGRID_API_URL : 'https://api.mlsgrid.com/v2',
'originating_system' => 'northstar',
'auto_sync_enabled' => false,
'sync_interval' => 'hourly',
'sync_media' => true,
);
$existing = get_option(MLS_Options::OPTION_KEY, array());
$merged = wp_parse_args($existing, $defaults);
update_option(MLS_Options::OPTION_KEY, $merged);
}
/**
* Schedule cron events
*/
private static function schedule_cron() {
// Register custom cron intervals
add_filter('cron_schedules', array(__CLASS__, 'add_cron_intervals'));
// Only schedule if auto-sync is enabled
$options = get_option(MLS_Options::OPTION_KEY, array());
if (empty($options['auto_sync_enabled'])) {
return;
}
$interval = !empty($options['sync_interval']) ? $options['sync_interval'] : 'hourly';
if (!wp_next_scheduled('mls_sync_properties')) {
wp_schedule_event(time(), $interval, 'mls_sync_properties');
}
if (!wp_next_scheduled('mls_sync_media')) {
wp_schedule_event(time() + 1800, $interval, 'mls_sync_media');
}
}
/**
* Add custom cron intervals
*
* @param array $schedules Existing schedules
* @return array Modified schedules
*/
public static function add_cron_intervals($schedules) {
$schedules['every_30_minutes'] = array(
'interval' => 1800,
'display' => 'Every 30 Minutes',
);
$schedules['every_2_hours'] = array(
'interval' => 7200,
'display' => 'Every 2 Hours',
);
$schedules['every_6_hours'] = array(
'interval' => 21600,
'display' => 'Every 6 Hours',
);
$schedules['every_12_hours'] = array(
'interval' => 43200,
'display' => 'Every 12 Hours',
);
return $schedules;
}
/**
* Create upload directory for MLS media
*/
private static function create_upload_dir() {
$upload_dir = wp_upload_dir();
$mls_dir = $upload_dir['basedir'] . '/mls-listings';
if (!file_exists($mls_dir)) {
wp_mkdir_p($mls_dir);
// Create .htaccess to prevent directory listing
$htaccess = $mls_dir . '/.htaccess';
if (!file_exists($htaccess)) {
file_put_contents($htaccess, "Options -Indexes\n");
}
// Create index.php for extra protection
$index = $mls_dir . '/index.php';
if (!file_exists($index)) {
file_put_contents($index, "<?php\n// Silence is golden.\n");
}
}
}
}
@@ -0,0 +1,469 @@
<?php
/**
* MLS Grid API Client
*
* Handles all communication with the MLS Grid API including:
* - Authentication (Bearer token)
* - Gzip compression (required)
* - Pagination via @odata.nextLink
* - Rate limit compliance
* - Error handling and retries
*/
if (!defined('ABSPATH')) {
exit;
}
class MLS_API_Client {
/**
* Default records per page (max 5000 without expand, 1000 with expand)
*/
const DEFAULT_TOP = 1000;
const MAX_TOP_WITH_EXPAND = 1000;
const MAX_TOP_NO_EXPAND = 5000;
/**
* Request timeout in seconds
*/
const TIMEOUT = 60;
/**
* Max retry attempts
*/
const MAX_RETRIES = 3;
/**
* Options instance
*/
private $options;
/**
* Rate limiter instance
*/
private $rate_limiter;
/**
* Logger instance
*/
private $logger;
/**
* Constructor
*
* @param MLS_Options $options Options instance
* @param MLS_Rate_Limiter $rate_limiter Rate limiter instance
* @param MLS_Logger $logger Logger instance
*/
public function __construct(MLS_Options $options, MLS_Rate_Limiter $rate_limiter, MLS_Logger $logger) {
$this->options = $options;
$this->rate_limiter = $rate_limiter;
$this->logger = $logger;
}
/**
* Test API connection
*
* @return array Result with success status and message
*/
public function test_connection() {
$start_time = microtime(true);
$response = $this->request('');
$elapsed = round((microtime(true) - $start_time) * 1000);
if (is_wp_error($response)) {
return array(
'success' => false,
'error' => $response->get_error_message(),
'response_time' => $elapsed,
);
}
return array(
'success' => true,
'message' => 'Connection successful',
'response_time' => $elapsed,
'endpoints' => isset($response['value']) ? array_column($response['value'], 'name') : array(),
);
}
/**
* Test API authentication
*
* @return array Result with success status
*/
public function test_auth() {
// Try to fetch a single property to verify auth
$response = $this->get_properties(null, null, 1);
if (is_wp_error($response)) {
$error_code = $response->get_error_code();
$error_message = $response->get_error_message();
if (strpos($error_message, '401') !== false || strpos($error_message, 'Unauthorized') !== false) {
return array(
'success' => false,
'error' => 'Authentication failed. Please check your API token.',
);
}
return array(
'success' => false,
'error' => $error_message,
);
}
return array(
'success' => true,
'message' => 'Authentication successful',
'originating_system' => $this->options->get_originating_system(),
);
}
/**
* Make an API request
*
* @param string $endpoint API endpoint (relative to base URL)
* @param array $params Query parameters
* @param int $retry Current retry attempt
* @return array|WP_Error Response data or error
*/
public function request($endpoint, $params = array(), $retry = 0) {
// Check and wait for rate limits
$this->rate_limiter->check_and_wait(true);
$url = $this->build_url($endpoint, $params);
$this->logger->debug('API Request', array(
'url' => $url,
'retry' => $retry,
));
$args = array(
'method' => 'GET',
'timeout' => self::TIMEOUT,
'headers' => array(
'Authorization' => 'Bearer ' . $this->options->get_api_token(),
'Accept' => 'application/json',
'Accept-Encoding' => 'gzip',
'User-Agent' => 'MLS-by-HansonXyz/' . MLS_PLUGIN_VERSION . ' WordPress/' . get_bloginfo('version'),
),
);
$response = wp_remote_get($url, $args);
// Track the request
$bytes = 0;
if (!is_wp_error($response)) {
$body = wp_remote_retrieve_body($response);
$bytes = strlen($body);
}
$this->rate_limiter->record_request($bytes);
// Handle errors
if (is_wp_error($response)) {
$this->logger->error('API Request Failed', array(
'error' => $response->get_error_message(),
'url' => $url,
));
// Retry on transient errors
if ($retry < self::MAX_RETRIES) {
sleep(pow(2, $retry)); // Exponential backoff
return $this->request($endpoint, $params, $retry + 1);
}
return $response;
}
$status_code = wp_remote_retrieve_response_code($response);
$body = wp_remote_retrieve_body($response);
// Handle HTTP errors
if ($status_code >= 400) {
$error_message = $this->parse_error_response($body, $status_code);
$this->logger->error('API HTTP Error', array(
'status' => $status_code,
'error' => $error_message,
'url' => $url,
));
// Retry on 429 (rate limit) or 5xx errors
if (($status_code === 429 || $status_code >= 500) && $retry < self::MAX_RETRIES) {
$wait = $status_code === 429 ? 60 : pow(2, $retry);
sleep($wait);
return $this->request($endpoint, $params, $retry + 1);
}
return new WP_Error('api_error', $error_message, array('status' => $status_code));
}
// Parse JSON response
$data = json_decode($body, true);
if (json_last_error() !== JSON_ERROR_NONE) {
$this->logger->error('API JSON Parse Error', array(
'error' => json_last_error_msg(),
'body_preview' => substr($body, 0, 500),
));
return new WP_Error('json_error', 'Failed to parse API response: ' . json_last_error_msg());
}
$this->logger->debug('API Response', array(
'status' => $status_code,
'record_count' => isset($data['value']) ? count($data['value']) : 0,
'has_next' => isset($data['@odata.nextLink']),
));
return $data;
}
/**
* Build full URL with parameters
*
* @param string $endpoint Endpoint
* @param array $params Parameters
* @return string Full URL
*/
private function build_url($endpoint, $params = array()) {
$base_url = rtrim($this->options->get_api_url(), '/');
if (!empty($endpoint)) {
$url = $base_url . '/' . ltrim($endpoint, '/');
} else {
$url = $base_url . '/';
}
if (!empty($params)) {
$query_string = http_build_query($params, '', '&', PHP_QUERY_RFC3986);
// OData uses $ prefix which gets encoded, decode it
$query_string = str_replace('%24', '$', $query_string);
$url .= '?' . $query_string;
}
return $url;
}
/**
* Parse error response
*
* @param string $body Response body
* @param int $status_code HTTP status code
* @return string Error message
*/
private function parse_error_response($body, $status_code) {
$data = json_decode($body, true);
if (isset($data['error']['message'])) {
return $data['error']['message'];
}
if (isset($data['message'])) {
return $data['message'];
}
return "HTTP Error {$status_code}";
}
/**
* Get properties from API
*
* @param string|null $filter OData filter
* @param string|null $expand Expand parameter (Media, Rooms, UnitTypes)
* @param int|null $top Number of records to fetch
* @return array|WP_Error Response data or error
*/
public function get_properties($filter = null, $expand = null, $top = null) {
$params = array();
// Build filter - always include originating system
$system = $this->options->get_originating_system();
$base_filter = "OriginatingSystemName eq '{$system}'";
if ($filter) {
$params['$filter'] = $base_filter . ' and ' . $filter;
} else {
$params['$filter'] = $base_filter . ' and MlgCanView eq true';
}
// Expand for media
if ($expand) {
$params['$expand'] = $expand;
}
// Records per page
if ($top) {
$params['$top'] = min($top, $expand ? self::MAX_TOP_WITH_EXPAND : self::MAX_TOP_NO_EXPAND);
} else {
$params['$top'] = $expand ? self::MAX_TOP_WITH_EXPAND : self::DEFAULT_TOP;
}
return $this->request('Property', $params);
}
/**
* Get properties modified since timestamp
*
* @param string $timestamp ISO 8601 timestamp
* @param string|null $expand Expand parameter
* @param int|null $top Number of records
* @return array|WP_Error Response data or error
*/
public function get_properties_since($timestamp, $expand = null, $top = null) {
$filter = "ModificationTimestamp gt {$timestamp}";
return $this->get_properties($filter, $expand, $top);
}
/**
* Get properties including those marked for deletion (for sync)
*
* @param string|null $timestamp Optional modification timestamp filter
* @param string|null $expand Expand parameter
* @param int|null $top Number of records
* @return array|WP_Error Response data or error
*/
public function get_properties_for_sync($timestamp = null, $expand = null, $top = null) {
// Don't filter by MlgCanView for sync - we need to see deleted records
$params = array();
$system = $this->options->get_originating_system();
if ($timestamp) {
$params['$filter'] = "OriginatingSystemName eq '{$system}' and ModificationTimestamp gt {$timestamp}";
} else {
// Initial sync - only get viewable records
$params['$filter'] = "OriginatingSystemName eq '{$system}' and MlgCanView eq true";
}
if ($expand) {
$params['$expand'] = $expand;
}
if ($top) {
$params['$top'] = min($top, $expand ? self::MAX_TOP_WITH_EXPAND : self::MAX_TOP_NO_EXPAND);
} else {
$params['$top'] = $expand ? self::MAX_TOP_WITH_EXPAND : self::DEFAULT_TOP;
}
return $this->request('Property', $params);
}
/**
* Get next page of results
*
* @param string $next_link The @odata.nextLink URL
* @return array|WP_Error Response data or error
*/
public function get_next_page($next_link) {
// Check and wait for rate limits
$this->rate_limiter->check_and_wait(true);
$this->logger->debug('API Next Page Request', array(
'url' => $next_link,
));
$args = array(
'method' => 'GET',
'timeout' => self::TIMEOUT,
'headers' => array(
'Authorization' => 'Bearer ' . $this->options->get_api_token(),
'Accept' => 'application/json',
'Accept-Encoding' => 'gzip',
'User-Agent' => 'MLS-by-HansonXyz/' . MLS_PLUGIN_VERSION . ' WordPress/' . get_bloginfo('version'),
),
);
$response = wp_remote_get($next_link, $args);
// Track the request
$bytes = 0;
if (!is_wp_error($response)) {
$body = wp_remote_retrieve_body($response);
$bytes = strlen($body);
}
$this->rate_limiter->record_request($bytes);
if (is_wp_error($response)) {
$this->logger->error('API Next Page Failed', array(
'error' => $response->get_error_message(),
));
return $response;
}
$status_code = wp_remote_retrieve_response_code($response);
$body = wp_remote_retrieve_body($response);
if ($status_code >= 400) {
$error_message = $this->parse_error_response($body, $status_code);
return new WP_Error('api_error', $error_message, array('status' => $status_code));
}
$data = json_decode($body, true);
if (json_last_error() !== JSON_ERROR_NONE) {
return new WP_Error('json_error', 'Failed to parse API response');
}
return $data;
}
/**
* Get members (agents) from API
*
* @param string|null $filter OData filter
* @return array|WP_Error Response data or error
*/
public function get_members($filter = null) {
$params = array();
$system = $this->options->get_originating_system();
$base_filter = "OriginatingSystemName eq '{$system}'";
if ($filter) {
$params['$filter'] = $base_filter . ' and ' . $filter;
} else {
$params['$filter'] = $base_filter;
}
return $this->request('Member', $params);
}
/**
* Get offices from API
*
* @param string|null $filter OData filter
* @return array|WP_Error Response data or error
*/
public function get_offices($filter = null) {
$params = array();
$system = $this->options->get_originating_system();
$base_filter = "OriginatingSystemName eq '{$system}'";
if ($filter) {
$params['$filter'] = $base_filter . ' and ' . $filter;
} else {
$params['$filter'] = $base_filter;
}
return $this->request('Office', $params);
}
/**
* Get lookup values (field definitions)
* Note: Should not be called more than once per day
*
* @return array|WP_Error Response data or error
*/
public function get_lookups() {
$system = $this->options->get_originating_system();
$params = array(
'$filter' => "OriginatingSystemName eq '{$system}'",
);
return $this->request('Lookup', $params);
}
}
@@ -0,0 +1,326 @@
<?php
/**
* Database handler class
*/
if (!defined('ABSPATH')) {
exit;
}
class MLS_DB {
/**
* Get table name with prefix
*
* @param string $table Table name constant
* @return string Full table name
*/
public function get_table_name($table) {
global $wpdb;
return $wpdb->prefix . $table;
}
/**
* Get properties table name
*/
public function properties_table() {
return $this->get_table_name(MLS_TABLE_PROPERTIES);
}
/**
* Get media table name
*/
public function media_table() {
return $this->get_table_name(MLS_TABLE_MEDIA);
}
/**
* Get sync state table name
*/
public function sync_state_table() {
return $this->get_table_name(MLS_TABLE_SYNC_STATE);
}
/**
* Get rate limits table name
*/
public function rate_limits_table() {
return $this->get_table_name(MLS_TABLE_RATE_LIMITS);
}
/**
* Get sync log table name
*/
public function sync_log_table() {
return $this->get_table_name(MLS_TABLE_SYNC_LOG);
}
/**
* Create all database tables
*/
public static function create_tables() {
global $wpdb;
$charset_collate = $wpdb->get_charset_collate();
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
// Properties table
$table_properties = $wpdb->prefix . MLS_TABLE_PROPERTIES;
$sql_properties = "CREATE TABLE {$table_properties} (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
listing_key VARCHAR(50) NOT NULL,
listing_id VARCHAR(50) NOT NULL,
originating_system VARCHAR(50) DEFAULT 'northstar',
standard_status VARCHAR(30) NOT NULL,
mls_status VARCHAR(50) DEFAULT NULL,
mlg_can_view TINYINT(1) DEFAULT 1,
list_price DECIMAL(15,2) DEFAULT NULL,
original_list_price DECIMAL(15,2) DEFAULT NULL,
close_price DECIMAL(15,2) DEFAULT NULL,
street_number VARCHAR(20) DEFAULT NULL,
street_name VARCHAR(100) DEFAULT NULL,
street_suffix VARCHAR(30) DEFAULT NULL,
unit_number VARCHAR(20) DEFAULT NULL,
city VARCHAR(100) NOT NULL,
state_or_province VARCHAR(50) DEFAULT 'MN',
postal_code VARCHAR(20) DEFAULT NULL,
county VARCHAR(100) DEFAULT NULL,
latitude DECIMAL(10,8) DEFAULT NULL,
longitude DECIMAL(11,8) DEFAULT NULL,
property_type VARCHAR(50) DEFAULT NULL,
property_sub_type VARCHAR(50) DEFAULT NULL,
bedrooms_total INT(3) DEFAULT NULL,
bathrooms_total DECIMAL(4,1) DEFAULT NULL,
bathrooms_full INT(3) DEFAULT NULL,
bathrooms_half INT(3) DEFAULT NULL,
living_area INT(10) DEFAULT NULL,
lot_size_area DECIMAL(12,4) DEFAULT NULL,
lot_size_units VARCHAR(20) DEFAULT NULL,
year_built INT(4) DEFAULT NULL,
garage_spaces INT(3) DEFAULT NULL,
public_remarks TEXT DEFAULT NULL,
directions TEXT DEFAULT NULL,
list_agent_key VARCHAR(50) DEFAULT NULL,
list_agent_mls_id VARCHAR(50) DEFAULT NULL,
list_agent_name VARCHAR(150) DEFAULT NULL,
list_office_key VARCHAR(50) DEFAULT NULL,
list_office_mls_id VARCHAR(50) DEFAULT NULL,
list_office_name VARCHAR(150) DEFAULT NULL,
photos_count INT(5) DEFAULT 0,
modification_timestamp DATETIME NOT NULL,
photos_change_timestamp DATETIME DEFAULT NULL,
listing_contract_date DATE DEFAULT NULL,
close_date DATE DEFAULT NULL,
days_on_market INT(5) DEFAULT NULL,
raw_data LONGTEXT DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY listing_key (listing_key),
KEY listing_id (listing_id),
KEY standard_status (standard_status),
KEY city (city),
KEY property_type (property_type),
KEY modification_timestamp (modification_timestamp),
KEY list_price (list_price),
KEY mlg_can_view (mlg_can_view),
KEY bedrooms_total (bedrooms_total),
KEY county (county)
) {$charset_collate};";
dbDelta($sql_properties);
// Media table
$table_media = $wpdb->prefix . MLS_TABLE_MEDIA;
$sql_media = "CREATE TABLE {$table_media} (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
listing_key VARCHAR(50) NOT NULL,
media_key VARCHAR(100) NOT NULL,
media_type VARCHAR(30) DEFAULT 'Photo',
media_order INT(5) DEFAULT 0,
media_url VARCHAR(1000) DEFAULT NULL,
local_path VARCHAR(500) DEFAULT NULL,
local_url VARCHAR(500) DEFAULT NULL,
file_size INT(11) DEFAULT NULL,
mime_type VARCHAR(50) DEFAULT NULL,
image_width INT(5) DEFAULT NULL,
image_height INT(5) DEFAULT NULL,
media_modification_timestamp DATETIME DEFAULT NULL,
downloaded_at DATETIME DEFAULT NULL,
download_attempts INT(3) DEFAULT 0,
download_error TEXT DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY listing_media (listing_key, media_key),
KEY listing_key (listing_key),
KEY media_order (media_order)
) {$charset_collate};";
dbDelta($sql_media);
// Sync state table
$table_sync_state = $wpdb->prefix . MLS_TABLE_SYNC_STATE;
$sql_sync_state = "CREATE TABLE {$table_sync_state} (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
sync_type VARCHAR(30) NOT NULL,
entity_type VARCHAR(30) NOT NULL DEFAULT 'Property',
status VARCHAR(20) DEFAULT 'pending',
started_at DATETIME DEFAULT NULL,
completed_at DATETIME DEFAULT NULL,
last_modification_timestamp DATETIME DEFAULT NULL,
last_next_link VARCHAR(2000) DEFAULT NULL,
records_processed INT(11) DEFAULT 0,
records_created INT(11) DEFAULT 0,
records_updated INT(11) DEFAULT 0,
records_deleted INT(11) DEFAULT 0,
error_count INT(11) DEFAULT 0,
last_error TEXT DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY sync_type_entity (sync_type, entity_type),
KEY status (status)
) {$charset_collate};";
dbDelta($sql_sync_state);
// Rate limits table
$table_rate_limits = $wpdb->prefix . MLS_TABLE_RATE_LIMITS;
$sql_rate_limits = "CREATE TABLE {$table_rate_limits} (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
window_type VARCHAR(20) NOT NULL,
window_start DATETIME NOT NULL,
request_count INT(11) DEFAULT 0,
bytes_transferred BIGINT(20) DEFAULT 0,
PRIMARY KEY (id),
UNIQUE KEY window_type_start (window_type, window_start),
KEY window_start (window_start)
) {$charset_collate};";
dbDelta($sql_rate_limits);
// Sync log table
$table_sync_log = $wpdb->prefix . MLS_TABLE_SYNC_LOG;
$sql_sync_log = "CREATE TABLE {$table_sync_log} (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
sync_state_id BIGINT(20) UNSIGNED DEFAULT NULL,
level VARCHAR(20) DEFAULT 'info',
message TEXT NOT NULL,
context LONGTEXT DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY sync_state_id (sync_state_id),
KEY level (level),
KEY created_at (created_at)
) {$charset_collate};";
dbDelta($sql_sync_log);
}
/**
* Drop all tables (for uninstall)
*/
public static function drop_tables() {
global $wpdb;
$tables = array(
MLS_TABLE_PROPERTIES,
MLS_TABLE_MEDIA,
MLS_TABLE_SYNC_STATE,
MLS_TABLE_RATE_LIMITS,
MLS_TABLE_SYNC_LOG,
);
foreach ($tables as $table) {
$table_name = $wpdb->prefix . $table;
$wpdb->query("DROP TABLE IF EXISTS {$table_name}");
}
}
/**
* Truncate all data tables (keep structure)
*/
public function truncate_data() {
global $wpdb;
$wpdb->query("TRUNCATE TABLE {$this->properties_table()}");
$wpdb->query("TRUNCATE TABLE {$this->media_table()}");
$wpdb->query("TRUNCATE TABLE {$this->sync_state_table()}");
$wpdb->query("TRUNCATE TABLE {$this->sync_log_table()}");
}
/**
* Get database statistics
*/
public function get_stats() {
global $wpdb;
$stats = array(
'total_properties' => 0,
'active_properties' => 0,
'pending_properties' => 0,
'sold_properties' => 0,
'total_media' => 0,
'downloaded_media' => 0,
);
// Property counts
$stats['total_properties'] = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM {$this->properties_table()} WHERE mlg_can_view = 1"
);
$status_counts = $wpdb->get_results(
"SELECT standard_status, COUNT(*) as count
FROM {$this->properties_table()}
WHERE mlg_can_view = 1
GROUP BY standard_status",
OBJECT_K
);
if (isset($status_counts['Active'])) {
$stats['active_properties'] = (int) $status_counts['Active']->count;
}
if (isset($status_counts['Pending'])) {
$stats['pending_properties'] = (int) $status_counts['Pending']->count;
}
if (isset($status_counts['Closed']) || isset($status_counts['Sold'])) {
$stats['sold_properties'] = (int) ($status_counts['Closed']->count ?? 0)
+ (int) ($status_counts['Sold']->count ?? 0);
}
// Media counts
$stats['total_media'] = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM {$this->media_table()}"
);
$stats['downloaded_media'] = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM {$this->media_table()} WHERE local_path IS NOT NULL"
);
return $stats;
}
}
@@ -0,0 +1,50 @@
<?php
/**
* Plugin deactivator class
*/
if (!defined('ABSPATH')) {
exit;
}
class MLS_Deactivator {
/**
* Run on plugin deactivation
*/
public static function deactivate() {
// Clear scheduled cron events
self::clear_cron();
// Flush rewrite rules
flush_rewrite_rules();
}
/**
* Clear all scheduled cron events
*/
private static function clear_cron() {
// Clear property sync cron
$timestamp = wp_next_scheduled('mls_sync_properties');
if ($timestamp) {
wp_unschedule_event($timestamp, 'mls_sync_properties');
}
// Clear media sync cron
$timestamp = wp_next_scheduled('mls_sync_media');
if ($timestamp) {
wp_unschedule_event($timestamp, 'mls_sync_media');
}
// Clear cleanup cron
$timestamp = wp_next_scheduled('mls_cleanup');
if ($timestamp) {
wp_unschedule_event($timestamp, 'mls_cleanup');
}
// Clear all hooks with our prefix
wp_clear_scheduled_hook('mls_sync_properties');
wp_clear_scheduled_hook('mls_sync_media');
wp_clear_scheduled_hook('mls_cleanup');
}
}
@@ -0,0 +1,198 @@
<?php
/**
* Logger class for sync operations
*/
if (!defined('ABSPATH')) {
exit;
}
class MLS_Logger {
/**
* Log levels
*/
const DEBUG = 'debug';
const INFO = 'info';
const WARNING = 'warning';
const ERROR = 'error';
/**
* Database instance
*/
private $db;
/**
* Current sync state ID
*/
private $sync_state_id = null;
/**
* Whether to also write to WP debug log
*/
private $write_to_debug_log = true;
/**
* Constructor
*
* @param MLS_DB $db Database instance
*/
public function __construct(MLS_DB $db) {
$this->db = $db;
$this->write_to_debug_log = defined('WP_DEBUG') && WP_DEBUG;
}
/**
* Set current sync state ID for associating logs
*
* @param int|null $sync_state_id
*/
public function set_sync_state($sync_state_id) {
$this->sync_state_id = $sync_state_id;
}
/**
* Log a message
*
* @param string $level Log level
* @param string $message Message
* @param array $context Additional context
*/
public function log($level, $message, $context = array()) {
global $wpdb;
// Insert into database
$wpdb->insert(
$this->db->sync_log_table(),
array(
'sync_state_id' => $this->sync_state_id,
'level' => $level,
'message' => $message,
'context' => !empty($context) ? wp_json_encode($context) : null,
'created_at' => current_time('mysql'),
),
array('%d', '%s', '%s', '%s', '%s')
);
// Also write to WP debug log if enabled
if ($this->write_to_debug_log) {
$log_message = sprintf(
'[MLS] [%s] %s',
strtoupper($level),
$message
);
if (!empty($context)) {
$log_message .= ' | Context: ' . wp_json_encode($context);
}
error_log($log_message);
}
}
/**
* Debug log
*
* @param string $message
* @param array $context
*/
public function debug($message, $context = array()) {
$this->log(self::DEBUG, $message, $context);
}
/**
* Info log
*
* @param string $message
* @param array $context
*/
public function info($message, $context = array()) {
$this->log(self::INFO, $message, $context);
}
/**
* Warning log
*
* @param string $message
* @param array $context
*/
public function warning($message, $context = array()) {
$this->log(self::WARNING, $message, $context);
}
/**
* Error log
*
* @param string $message
* @param array $context
*/
public function error($message, $context = array()) {
$this->log(self::ERROR, $message, $context);
}
/**
* Get recent logs
*
* @param int $limit Number of logs to retrieve
* @param string|null $level Filter by level
* @param int|null $sync_state_id Filter by sync state
* @return array
*/
public function get_logs($limit = 100, $level = null, $sync_state_id = null) {
global $wpdb;
$where = array('1=1');
$values = array();
if ($level) {
$where[] = 'level = %s';
$values[] = $level;
}
if ($sync_state_id) {
$where[] = 'sync_state_id = %d';
$values[] = $sync_state_id;
}
$where_sql = implode(' AND ', $where);
$values[] = $limit;
$sql = "SELECT * FROM {$this->db->sync_log_table()}
WHERE {$where_sql}
ORDER BY created_at DESC
LIMIT %d";
if (!empty($values)) {
$sql = $wpdb->prepare($sql, $values);
}
return $wpdb->get_results($sql);
}
/**
* Clear old logs
*
* @param int $days_to_keep Keep logs from last N days
* @return int Number of deleted rows
*/
public function clear_old_logs($days_to_keep = 30) {
global $wpdb;
$cutoff = gmdate('Y-m-d H:i:s', strtotime("-{$days_to_keep} days"));
return $wpdb->query(
$wpdb->prepare(
"DELETE FROM {$this->db->sync_log_table()} WHERE created_at < %s",
$cutoff
)
);
}
/**
* Clear all logs
*
* @return bool
*/
public function clear_all() {
global $wpdb;
return false !== $wpdb->query("TRUNCATE TABLE {$this->db->sync_log_table()}");
}
}
@@ -0,0 +1,499 @@
<?php
/**
* MLS Media Handler
*
* Handles downloading and managing media files from MLS listings
*/
if (!defined('ABSPATH')) {
exit;
}
class MLS_Media_Handler {
/**
* Upload subdirectory for MLS media
*/
const UPLOAD_SUBDIR = 'mls-listings';
/**
* Database instance
*/
private $db;
/**
* Logger instance
*/
private $logger;
/**
* Constructor
*/
public function __construct(MLS_DB $db, MLS_Logger $logger) {
$this->db = $db;
$this->logger = $logger;
}
/**
* Get base upload directory for MLS media
*
* @return string Absolute path
*/
public function get_upload_dir() {
$upload_dir = wp_upload_dir();
return $upload_dir['basedir'] . '/' . self::UPLOAD_SUBDIR;
}
/**
* Get base upload URL for MLS media
*
* @return string URL
*/
public function get_upload_url() {
$upload_dir = wp_upload_dir();
return $upload_dir['baseurl'] . '/' . self::UPLOAD_SUBDIR;
}
/**
* Get storage directory for a specific listing
*
* @param string $listing_key Listing key
* @return string Absolute path
*/
public function get_listing_dir($listing_key) {
// Use first 2 characters as subdirectory to prevent too many files in one folder
$prefix = substr($listing_key, 0, 2);
return $this->get_upload_dir() . '/' . $prefix . '/' . $listing_key;
}
/**
* Sync media for a property
*
* @param string $listing_key Listing key
* @param array $media_array Media array from API
* @param bool $force Force re-download all media
*/
public function sync_property_media($listing_key, $media_array, $force = false) {
global $wpdb;
if (empty($media_array)) {
return;
}
$received_keys = array();
foreach ($media_array as $media) {
$media_key = $media['MediaKey'] ?? null;
if (!$media_key) {
continue;
}
$received_keys[] = $media_key;
// Check if media record exists
$existing = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$this->db->media_table()}
WHERE listing_key = %s AND media_key = %s",
$listing_key,
$media_key
));
$data = array(
'listing_key' => $listing_key,
'media_key' => $media_key,
'media_type' => $media['MediaType'] ?? 'Photo',
'media_order' => $media['Order'] ?? 0,
'media_url' => $media['MediaURL'] ?? null,
'image_width' => $media['ImageWidth'] ?? null,
'image_height' => $media['ImageHeight'] ?? null,
'media_modification_timestamp' => isset($media['MediaModificationTimestamp'])
? date('Y-m-d H:i:s', strtotime($media['MediaModificationTimestamp']))
: null,
'updated_at' => current_time('mysql'),
);
if ($existing) {
// Update existing record
$wpdb->update(
$this->db->media_table(),
$data,
array('id' => $existing->id)
);
// Check if we need to re-download
if ($force || $this->needs_download($existing, $media)) {
$this->download_media($existing->id);
}
} else {
// Insert new record
$data['created_at'] = current_time('mysql');
$wpdb->insert($this->db->media_table(), $data);
// Queue download
$this->download_media($wpdb->insert_id);
}
}
// Delete media that no longer exists
if (!empty($received_keys)) {
$placeholders = implode(',', array_fill(0, count($received_keys), '%s'));
$values = array_merge(array($listing_key), $received_keys);
$orphaned = $wpdb->get_results($wpdb->prepare(
"SELECT id, local_path FROM {$this->db->media_table()}
WHERE listing_key = %s AND media_key NOT IN ({$placeholders})",
$values
));
foreach ($orphaned as $record) {
// Delete file if exists
if ($record->local_path) {
$file_path = $this->get_upload_dir() . '/' . $record->local_path;
if (file_exists($file_path)) {
unlink($file_path);
}
}
// Delete record
$wpdb->delete($this->db->media_table(), array('id' => $record->id));
}
}
}
/**
* Check if media needs to be downloaded
*
* @param object $existing Existing media record
* @param array $new_data New media data from API
* @return bool
*/
private function needs_download($existing, $new_data) {
// No local file
if (empty($existing->local_path)) {
return true;
}
// File doesn't exist
$file_path = $this->get_upload_dir() . '/' . $existing->local_path;
if (!file_exists($file_path)) {
return true;
}
// Media URL changed
if ($existing->media_url !== ($new_data['MediaURL'] ?? null)) {
return true;
}
return false;
}
/**
* Download a media file
*
* @param int $media_id Media record ID
* @return bool Success
*/
public function download_media($media_id) {
global $wpdb;
$media = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$this->db->media_table()} WHERE id = %d",
$media_id
));
if (!$media || empty($media->media_url)) {
return false;
}
// Increment attempt counter
$wpdb->update(
$this->db->media_table(),
array('download_attempts' => $media->download_attempts + 1),
array('id' => $media_id)
);
// Download file
$response = wp_remote_get($media->media_url, array(
'timeout' => 60,
'stream' => false,
));
if (is_wp_error($response)) {
$this->logger->warning('Media download failed', array(
'media_id' => $media_id,
'error' => $response->get_error_message(),
));
$wpdb->update(
$this->db->media_table(),
array('download_error' => $response->get_error_message()),
array('id' => $media_id)
);
return false;
}
$status_code = wp_remote_retrieve_response_code($response);
if ($status_code !== 200) {
$wpdb->update(
$this->db->media_table(),
array('download_error' => "HTTP {$status_code}"),
array('id' => $media_id)
);
return false;
}
$body = wp_remote_retrieve_body($response);
if (empty($body)) {
$wpdb->update(
$this->db->media_table(),
array('download_error' => 'Empty response'),
array('id' => $media_id)
);
return false;
}
// Determine file extension from content type or URL
$content_type = wp_remote_retrieve_header($response, 'content-type');
$extension = $this->get_extension_from_content_type($content_type, $media->media_url);
// Create directory
$listing_dir = $this->get_listing_dir($media->listing_key);
if (!file_exists($listing_dir)) {
wp_mkdir_p($listing_dir);
}
// Save file
$filename = $media->media_order . '.' . $extension;
$file_path = $listing_dir . '/' . $filename;
if (file_put_contents($file_path, $body) === false) {
$wpdb->update(
$this->db->media_table(),
array('download_error' => 'Failed to write file'),
array('id' => $media_id)
);
return false;
}
// Calculate relative path
$prefix = substr($media->listing_key, 0, 2);
$relative_path = $prefix . '/' . $media->listing_key . '/' . $filename;
$local_url = $this->get_upload_url() . '/' . $relative_path;
// Update record
$wpdb->update(
$this->db->media_table(),
array(
'local_path' => $relative_path,
'local_url' => $local_url,
'file_size' => strlen($body),
'mime_type' => $content_type,
'downloaded_at' => current_time('mysql'),
'download_error' => null,
),
array('id' => $media_id)
);
return true;
}
/**
* Get file extension from content type
*
* @param string $content_type Content type header
* @param string $url Original URL as fallback
* @return string File extension
*/
private function get_extension_from_content_type($content_type, $url) {
// Extract main type from content-type header
$content_type = strtolower(explode(';', $content_type)[0]);
$map = array(
'image/jpeg' => 'jpg',
'image/jpg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
);
if (isset($map[$content_type])) {
return $map[$content_type];
}
// Fallback to URL extension
$path = parse_url($url, PHP_URL_PATH);
$ext = pathinfo($path, PATHINFO_EXTENSION);
return $ext ?: 'jpg';
}
/**
* Delete all media for a property
*
* @param string $listing_key Listing key
*/
public function delete_property_media($listing_key) {
global $wpdb;
// Delete files
$listing_dir = $this->get_listing_dir($listing_key);
if (file_exists($listing_dir)) {
$this->recursive_delete($listing_dir);
}
// Delete records
$wpdb->delete(
$this->db->media_table(),
array('listing_key' => $listing_key)
);
}
/**
* Recursively delete a directory
*
* @param string $dir Directory path
*/
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);
}
/**
* Get media for a listing
*
* @param string $listing_key Listing key
* @return array Media records
*/
public function get_listing_media($listing_key) {
global $wpdb;
return $wpdb->get_results($wpdb->prepare(
"SELECT * FROM {$this->db->media_table()}
WHERE listing_key = %s
ORDER BY media_order ASC",
$listing_key
));
}
/**
* Get primary image URL for a listing
*
* @param string $listing_key Listing key
* @return string|null Image URL
*/
public function get_primary_image($listing_key) {
global $wpdb;
$media = $wpdb->get_row($wpdb->prepare(
"SELECT local_url, media_url FROM {$this->db->media_table()}
WHERE listing_key = %s AND local_path IS NOT NULL
ORDER BY media_order ASC
LIMIT 1",
$listing_key
));
if ($media && $media->local_url) {
return $media->local_url;
}
return null;
}
/**
* Download pending media (for batch processing)
*
* @param int $limit Max media to download
* @return array Stats
*/
public function download_pending($limit = 100) {
global $wpdb;
$pending = $wpdb->get_results($wpdb->prepare(
"SELECT id FROM {$this->db->media_table()}
WHERE local_path IS NULL AND media_url IS NOT NULL
AND download_attempts < 3
LIMIT %d",
$limit
));
$stats = array(
'total' => count($pending),
'success' => 0,
'failed' => 0,
);
foreach ($pending as $media) {
if ($this->download_media($media->id)) {
$stats['success']++;
} else {
$stats['failed']++;
}
}
return $stats;
}
/**
* Clean up orphaned media (files without database records)
*
* @return int Number of files deleted
*/
public function cleanup_orphaned_files() {
$deleted = 0;
$base_dir = $this->get_upload_dir();
if (!is_dir($base_dir)) {
return 0;
}
// Iterate through prefix directories
foreach (scandir($base_dir) as $prefix) {
if ($prefix === '.' || $prefix === '..' || !is_dir($base_dir . '/' . $prefix)) {
continue;
}
$prefix_dir = $base_dir . '/' . $prefix;
// Iterate through listing directories
foreach (scandir($prefix_dir) as $listing_key) {
if ($listing_key === '.' || $listing_key === '..') {
continue;
}
$listing_dir = $prefix_dir . '/' . $listing_key;
if (!is_dir($listing_dir)) {
continue;
}
// Check if listing exists in database
global $wpdb;
$exists = $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM {$this->db->properties_table()} WHERE listing_key = %s",
$listing_key
));
if (!$exists) {
$this->recursive_delete($listing_dir);
$deleted++;
}
}
}
return $deleted;
}
}
@@ -0,0 +1,207 @@
<?php
/**
* Options handler class
*/
if (!defined('ABSPATH')) {
exit;
}
class MLS_Options {
/**
* Option key for storing all plugin options
*/
const OPTION_KEY = 'mls_plugin_options';
/**
* Default options
*/
private $defaults = array(
'api_url' => '',
'api_token' => '',
'originating_system' => 'northstar',
'auto_sync_enabled' => false,
'sync_interval' => 'hourly',
'sync_media' => true,
'last_full_sync' => null,
'last_incremental_sync' => null,
);
/**
* Get all options
*
* @return array
*/
public function get_all() {
$options = get_option(self::OPTION_KEY, array());
return wp_parse_args($options, $this->defaults);
}
/**
* Get a single option
*
* @param string $key Option key
* @param mixed $default Default value if not set
* @return mixed
*/
public function get($key, $default = null) {
$options = $this->get_all();
if (isset($options[$key])) {
return $options[$key];
}
if (null !== $default) {
return $default;
}
return isset($this->defaults[$key]) ? $this->defaults[$key] : null;
}
/**
* Set a single option
*
* @param string $key Option key
* @param mixed $value Option value
* @return bool
*/
public function set($key, $value) {
$options = $this->get_all();
$options[$key] = $value;
return update_option(self::OPTION_KEY, $options);
}
/**
* Set multiple options
*
* @param array $values Key-value pairs
* @return bool
*/
public function set_multiple($values) {
$options = $this->get_all();
foreach ($values as $key => $value) {
$options[$key] = $value;
}
return update_option(self::OPTION_KEY, $options);
}
/**
* Delete a single option
*
* @param string $key Option key
* @return bool
*/
public function delete($key) {
$options = $this->get_all();
if (isset($options[$key])) {
unset($options[$key]);
return update_option(self::OPTION_KEY, $options);
}
return true;
}
/**
* Delete all options
*
* @return bool
*/
public function delete_all() {
return delete_option(self::OPTION_KEY);
}
/**
* Get API URL from wp-config or options
*
* @return string
*/
public function get_api_url() {
// Check wp-config constant first
if (defined('MLSGRID_API_URL') && MLSGRID_API_URL) {
return MLSGRID_API_URL;
}
return $this->get('api_url', 'https://api.mlsgrid.com/v2');
}
/**
* Get API token from wp-config or options
*
* @return string
*/
public function get_api_token() {
// Check wp-config constant first
if (defined('MLSGRID_ACCESS_TOKEN') && MLSGRID_ACCESS_TOKEN) {
return MLSGRID_ACCESS_TOKEN;
}
return $this->get('api_token', '');
}
/**
* Get originating system name
*
* @return string
*/
public function get_originating_system() {
return $this->get('originating_system', 'northstar');
}
/**
* Check if API is configured
*
* @return bool
*/
public function is_configured() {
return !empty($this->get_api_url()) && !empty($this->get_api_token());
}
/**
* Check if auto sync is enabled
*
* @return bool
*/
public function is_auto_sync_enabled() {
return (bool) $this->get('auto_sync_enabled', false);
}
/**
* Get sync interval
*
* @return string WordPress cron interval name
*/
public function get_sync_interval() {
return $this->get('sync_interval', 'hourly');
}
/**
* Check if media sync is enabled
*
* @return bool
*/
public function is_media_sync_enabled() {
return (bool) $this->get('sync_media', true);
}
/**
* Update last sync timestamps
*
* @param string $type 'full' or 'incremental'
* @return bool
*/
public function update_last_sync($type = 'incremental') {
$key = 'full' === $type ? 'last_full_sync' : 'last_incremental_sync';
return $this->set($key, current_time('mysql'));
}
/**
* Get last sync time
*
* @param string $type 'full' or 'incremental'
* @return string|null MySQL datetime or null
*/
public function get_last_sync($type = 'incremental') {
$key = 'full' === $type ? 'last_full_sync' : 'last_incremental_sync';
return $this->get($key);
}
}
@@ -0,0 +1,484 @@
<?php
/**
* MLS Query Class
*
* Public API for querying cached MLS data
*/
if (!defined('ABSPATH')) {
exit;
}
class MLS_Query {
/**
* Database instance
*/
private $db;
/**
* Constructor
*/
public function __construct(MLS_DB $db) {
$this->db = $db;
}
/**
* Get properties matching criteria
*
* @param array $args Query arguments
* @return array Property objects
*/
public function get_properties($args = array()) {
global $wpdb;
$defaults = array(
'status' => null, // Active, Pending, Closed
'property_type' => null, // Residential, Land, Commercial
'city' => null,
'county' => null,
'postal_code' => null,
'min_price' => null,
'max_price' => null,
'min_beds' => null,
'max_beds' => null,
'min_baths' => null,
'min_sqft' => null,
'max_sqft' => null,
'year_built_min' => null,
'year_built_max' => null,
'listing_key' => null,
'listing_id' => null,
'search' => null, // Search in address/remarks
'limit' => 20,
'offset' => 0,
'orderby' => 'modification_timestamp',
'order' => 'DESC',
'include_media' => false,
'fields' => '*', // Specific fields or *
);
$args = wp_parse_args($args, $defaults);
// Build query
$table = $this->db->properties_table();
// Fields
if ($args['fields'] === '*') {
$select = '*';
} else {
$fields = array_map('sanitize_key', (array) $args['fields']);
$select = implode(', ', $fields);
}
$sql = "SELECT {$select} FROM {$table}";
// WHERE conditions
$where = array('mlg_can_view = 1');
$values = array();
if ($args['status']) {
$where[] = 'standard_status = %s';
$values[] = $args['status'];
}
if ($args['property_type']) {
$where[] = 'property_type = %s';
$values[] = $args['property_type'];
}
if ($args['city']) {
$where[] = 'city = %s';
$values[] = $args['city'];
}
if ($args['county']) {
$where[] = 'county = %s';
$values[] = $args['county'];
}
if ($args['postal_code']) {
$where[] = 'postal_code = %s';
$values[] = $args['postal_code'];
}
if ($args['min_price']) {
$where[] = 'list_price >= %d';
$values[] = (int) $args['min_price'];
}
if ($args['max_price']) {
$where[] = 'list_price <= %d';
$values[] = (int) $args['max_price'];
}
if ($args['min_beds']) {
$where[] = 'bedrooms_total >= %d';
$values[] = (int) $args['min_beds'];
}
if ($args['max_beds']) {
$where[] = 'bedrooms_total <= %d';
$values[] = (int) $args['max_beds'];
}
if ($args['min_baths']) {
$where[] = 'bathrooms_total >= %d';
$values[] = (int) $args['min_baths'];
}
if ($args['min_sqft']) {
$where[] = 'living_area >= %d';
$values[] = (int) $args['min_sqft'];
}
if ($args['max_sqft']) {
$where[] = 'living_area <= %d';
$values[] = (int) $args['max_sqft'];
}
if ($args['year_built_min']) {
$where[] = 'year_built >= %d';
$values[] = (int) $args['year_built_min'];
}
if ($args['year_built_max']) {
$where[] = 'year_built <= %d';
$values[] = (int) $args['year_built_max'];
}
if ($args['listing_key']) {
$where[] = 'listing_key = %s';
$values[] = $args['listing_key'];
}
if ($args['listing_id']) {
$where[] = 'listing_id = %s';
$values[] = $args['listing_id'];
}
if ($args['search']) {
$search_term = '%' . $wpdb->esc_like($args['search']) . '%';
$where[] = '(street_name LIKE %s OR city LIKE %s OR public_remarks LIKE %s OR listing_id LIKE %s)';
$values[] = $search_term;
$values[] = $search_term;
$values[] = $search_term;
$values[] = $search_term;
}
$sql .= ' WHERE ' . implode(' AND ', $where);
// ORDER BY
$allowed_orderby = array(
'modification_timestamp',
'list_price',
'bedrooms_total',
'bathrooms_total',
'living_area',
'year_built',
'days_on_market',
'city',
'created_at',
);
$orderby = in_array($args['orderby'], $allowed_orderby) ? $args['orderby'] : 'modification_timestamp';
$order = strtoupper($args['order']) === 'ASC' ? 'ASC' : 'DESC';
$sql .= " ORDER BY {$orderby} {$order}";
// LIMIT/OFFSET
$sql .= ' LIMIT %d OFFSET %d';
$values[] = (int) $args['limit'];
$values[] = (int) $args['offset'];
// Execute
$results = $wpdb->get_results($wpdb->prepare($sql, $values));
// Include media if requested
if ($args['include_media'] && $results) {
foreach ($results as &$property) {
$property->media = $this->get_property_media($property->listing_key);
}
}
return $results;
}
/**
* Get a single property
*
* @param string $identifier Listing key or listing ID
* @return object|null Property object
*/
public function get_property($identifier) {
global $wpdb;
$table = $this->db->properties_table();
// Try listing_key first
$property = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$table} WHERE listing_key = %s AND mlg_can_view = 1",
$identifier
));
// Try listing_id if not found
if (!$property) {
$property = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$table} WHERE listing_id = %s AND mlg_can_view = 1",
$identifier
));
}
return $property;
}
/**
* Get media for a property
*
* @param string $listing_key Listing key
* @return array Media objects
*/
public function get_property_media($listing_key) {
global $wpdb;
return $wpdb->get_results($wpdb->prepare(
"SELECT * FROM {$this->db->media_table()}
WHERE listing_key = %s
ORDER BY media_order ASC",
$listing_key
));
}
/**
* Get primary image URL
*
* @param string $listing_key Listing key
* @return string|null Image URL
*/
public function get_primary_image($listing_key) {
global $wpdb;
return $wpdb->get_var($wpdb->prepare(
"SELECT local_url FROM {$this->db->media_table()}
WHERE listing_key = %s AND local_url IS NOT NULL
ORDER BY media_order ASC
LIMIT 1",
$listing_key
));
}
/**
* Get distinct cities
*
* @param string|null $status Optional status filter
* @return array City names
*/
public function get_distinct_cities($status = null) {
global $wpdb;
$table = $this->db->properties_table();
if ($status) {
$cities = $wpdb->get_col($wpdb->prepare(
"SELECT DISTINCT city FROM {$table}
WHERE mlg_can_view = 1 AND standard_status = %s AND city IS NOT NULL
ORDER BY city ASC",
$status
));
} else {
$cities = $wpdb->get_col(
"SELECT DISTINCT city FROM {$table}
WHERE mlg_can_view = 1 AND city IS NOT NULL
ORDER BY city ASC"
);
}
return $cities;
}
/**
* Get distinct counties
*
* @param string|null $status Optional status filter
* @return array County names
*/
public function get_distinct_counties($status = null) {
global $wpdb;
$table = $this->db->properties_table();
if ($status) {
$counties = $wpdb->get_col($wpdb->prepare(
"SELECT DISTINCT county FROM {$table}
WHERE mlg_can_view = 1 AND standard_status = %s AND county IS NOT NULL
ORDER BY county ASC",
$status
));
} else {
$counties = $wpdb->get_col(
"SELECT DISTINCT county FROM {$table}
WHERE mlg_can_view = 1 AND county IS NOT NULL
ORDER BY county ASC"
);
}
return $counties;
}
/**
* Get property count
*
* @param array $args Filter arguments (same as get_properties)
* @return int Count
*/
public function get_count($args = array()) {
global $wpdb;
$table = $this->db->properties_table();
$where = array('mlg_can_view = 1');
$values = array();
if (!empty($args['status'])) {
$where[] = 'standard_status = %s';
$values[] = $args['status'];
}
if (!empty($args['property_type'])) {
$where[] = 'property_type = %s';
$values[] = $args['property_type'];
}
if (!empty($args['city'])) {
$where[] = 'city = %s';
$values[] = $args['city'];
}
$sql = "SELECT COUNT(*) FROM {$table} WHERE " . implode(' AND ', $where);
if (!empty($values)) {
return (int) $wpdb->get_var($wpdb->prepare($sql, $values));
}
return (int) $wpdb->get_var($sql);
}
/**
* Check if data exists
*
* @return bool
*/
public function has_data() {
global $wpdb;
$count = $wpdb->get_var(
"SELECT COUNT(*) FROM {$this->db->properties_table()} WHERE mlg_can_view = 1"
);
return (int) $count > 0;
}
/**
* Get property types with counts
*
* @param string|null $status Optional status filter
* @return array Property types with counts
*/
public function get_property_types($status = null) {
global $wpdb;
$table = $this->db->properties_table();
if ($status) {
return $wpdb->get_results($wpdb->prepare(
"SELECT property_type, COUNT(*) as count
FROM {$table}
WHERE mlg_can_view = 1 AND standard_status = %s AND property_type IS NOT NULL
GROUP BY property_type
ORDER BY count DESC",
$status
));
}
return $wpdb->get_results(
"SELECT property_type, COUNT(*) as count
FROM {$table}
WHERE mlg_can_view = 1 AND property_type IS NOT NULL
GROUP BY property_type
ORDER BY count DESC"
);
}
/**
* Get price range
*
* @param string|null $status Optional status filter
* @return object Min and max prices
*/
public function get_price_range($status = null) {
global $wpdb;
$table = $this->db->properties_table();
if ($status) {
return $wpdb->get_row($wpdb->prepare(
"SELECT MIN(list_price) as min_price, MAX(list_price) as max_price
FROM {$table}
WHERE mlg_can_view = 1 AND standard_status = %s AND list_price > 0",
$status
));
}
return $wpdb->get_row(
"SELECT MIN(list_price) as min_price, MAX(list_price) as max_price
FROM {$table}
WHERE mlg_can_view = 1 AND list_price > 0"
);
}
/**
* Get formatted address for a property
*
* @param object $property Property object
* @return string Formatted address
*/
public function format_address($property) {
$parts = array();
if ($property->street_number) {
$parts[] = $property->street_number;
}
if ($property->street_name) {
$parts[] = $property->street_name;
}
if ($property->street_suffix) {
$parts[] = $property->street_suffix;
}
if ($property->unit_number) {
$parts[] = '#' . $property->unit_number;
}
$street = implode(' ', $parts);
$location_parts = array();
if ($property->city) {
$location_parts[] = $property->city;
}
if ($property->state_or_province) {
$location_parts[] = $property->state_or_province;
}
if ($property->postal_code) {
$location_parts[] = $property->postal_code;
}
$location = implode(', ', $location_parts);
if ($street && $location) {
return $street . ', ' . $location;
}
return $street ?: $location;
}
}
@@ -0,0 +1,323 @@
<?php
/**
* Rate limiter class for MLS Grid API compliance
*
* MLS Grid Rate Limits:
* - 2 requests per second
* - 7,200 requests per hour
* - 40,000 requests per day
* - 4GB data per hour
*/
if (!defined('ABSPATH')) {
exit;
}
class MLS_Rate_Limiter {
/**
* Rate limit constants
*/
const LIMIT_PER_SECOND = 2;
const LIMIT_PER_HOUR = 7200;
const LIMIT_PER_DAY = 40000;
const LIMIT_BYTES_PER_HOUR = 4294967296; // 4GB
/**
* Window types
*/
const WINDOW_SECOND = 'second';
const WINDOW_HOUR = 'hour';
const WINDOW_DAY = 'day';
/**
* Database instance
*/
private $db;
/**
* Last request timestamp for per-second limiting
*/
private $last_request_time = 0;
/**
* Constructor
*
* @param MLS_DB $db Database instance
*/
public function __construct(MLS_DB $db) {
$this->db = $db;
}
/**
* Check if we can make a request (and wait if needed)
*
* @param bool $wait Whether to wait if rate limited
* @return bool True if request can proceed
*/
public function check_and_wait($wait = true) {
// Check per-second limit (most restrictive)
$this->enforce_per_second_limit();
// Check hourly limit
if (!$this->check_limit(self::WINDOW_HOUR, self::LIMIT_PER_HOUR)) {
if ($wait) {
$this->wait_for_window(self::WINDOW_HOUR);
} else {
return false;
}
}
// Check daily limit
if (!$this->check_limit(self::WINDOW_DAY, self::LIMIT_PER_DAY)) {
if ($wait) {
$this->wait_for_window(self::WINDOW_DAY);
} else {
return false;
}
}
return true;
}
/**
* Enforce per-second rate limit
*/
private function enforce_per_second_limit() {
$now = microtime(true);
$min_interval = 1.0 / self::LIMIT_PER_SECOND; // 0.5 seconds
if ($this->last_request_time > 0) {
$elapsed = $now - $this->last_request_time;
if ($elapsed < $min_interval) {
$sleep_time = ($min_interval - $elapsed) * 1000000; // microseconds
usleep((int) $sleep_time);
}
}
$this->last_request_time = microtime(true);
}
/**
* Check if under the limit for a window type
*
* @param string $window_type Window type
* @param int $limit Limit for this window
* @return bool True if under limit
*/
private function check_limit($window_type, $limit) {
$count = $this->get_window_count($window_type);
return $count < $limit;
}
/**
* Get current count for a window
*
* @param string $window_type Window type
* @return int Current request count
*/
private function get_window_count($window_type) {
global $wpdb;
$window_start = $this->get_window_start($window_type);
$count = $wpdb->get_var($wpdb->prepare(
"SELECT request_count FROM {$this->db->rate_limits_table()}
WHERE window_type = %s AND window_start = %s",
$window_type,
$window_start
));
return $count ? (int) $count : 0;
}
/**
* Get window start time
*
* @param string $window_type Window type
* @return string MySQL datetime
*/
private function get_window_start($window_type) {
$now = current_time('timestamp');
switch ($window_type) {
case self::WINDOW_SECOND:
return gmdate('Y-m-d H:i:s', $now);
case self::WINDOW_HOUR:
return gmdate('Y-m-d H:00:00', $now);
case self::WINDOW_DAY:
return gmdate('Y-m-d 00:00:00', $now);
default:
return gmdate('Y-m-d H:i:s', $now);
}
}
/**
* Record a request
*
* @param int $bytes_transferred Optional bytes transferred
*/
public function record_request($bytes_transferred = 0) {
global $wpdb;
// Record for hourly window
$this->increment_window(self::WINDOW_HOUR, $bytes_transferred);
// Record for daily window
$this->increment_window(self::WINDOW_DAY, $bytes_transferred);
// Clean up old records
$this->cleanup_old_records();
}
/**
* Increment count for a window
*
* @param string $window_type Window type
* @param int $bytes_transferred Bytes transferred
*/
private function increment_window($window_type, $bytes_transferred = 0) {
global $wpdb;
$window_start = $this->get_window_start($window_type);
// Try to update existing record
$updated = $wpdb->query($wpdb->prepare(
"UPDATE {$this->db->rate_limits_table()}
SET request_count = request_count + 1,
bytes_transferred = bytes_transferred + %d
WHERE window_type = %s AND window_start = %s",
$bytes_transferred,
$window_type,
$window_start
));
// If no record existed, insert new one
if (0 === $updated) {
$wpdb->insert(
$this->db->rate_limits_table(),
array(
'window_type' => $window_type,
'window_start' => $window_start,
'request_count' => 1,
'bytes_transferred' => $bytes_transferred,
),
array('%s', '%s', '%d', '%d')
);
}
}
/**
* Wait for a rate limit window to reset
*
* @param string $window_type Window type
*/
private function wait_for_window($window_type) {
$now = current_time('timestamp');
switch ($window_type) {
case self::WINDOW_HOUR:
// Wait until next hour
$next_hour = strtotime('+1 hour', strtotime(gmdate('Y-m-d H:00:00', $now)));
$wait_seconds = $next_hour - $now;
break;
case self::WINDOW_DAY:
// Wait until next day
$next_day = strtotime('+1 day', strtotime(gmdate('Y-m-d 00:00:00', $now)));
$wait_seconds = $next_day - $now;
break;
default:
$wait_seconds = 1;
}
if ($wait_seconds > 0) {
sleep(min($wait_seconds, 60)); // Max 60 second wait per call
}
}
/**
* Clean up old rate limit records
*/
private function cleanup_old_records() {
global $wpdb;
// Delete records older than 48 hours
$cutoff = gmdate('Y-m-d H:i:s', strtotime('-48 hours'));
$wpdb->query($wpdb->prepare(
"DELETE FROM {$this->db->rate_limits_table()} WHERE window_start < %s",
$cutoff
));
}
/**
* Get current rate limit status
*
* @return array Rate limit status
*/
public function get_status() {
return array(
'hourly' => array(
'used' => $this->get_window_count(self::WINDOW_HOUR),
'limit' => self::LIMIT_PER_HOUR,
'remaining' => max(0, self::LIMIT_PER_HOUR - $this->get_window_count(self::WINDOW_HOUR)),
),
'daily' => array(
'used' => $this->get_window_count(self::WINDOW_DAY),
'limit' => self::LIMIT_PER_DAY,
'remaining' => max(0, self::LIMIT_PER_DAY - $this->get_window_count(self::WINDOW_DAY)),
),
'bytes_this_hour' => $this->get_bytes_this_hour(),
'bytes_limit' => self::LIMIT_BYTES_PER_HOUR,
);
}
/**
* Get bytes transferred this hour
*
* @return int Bytes
*/
private function get_bytes_this_hour() {
global $wpdb;
$window_start = $this->get_window_start(self::WINDOW_HOUR);
$bytes = $wpdb->get_var($wpdb->prepare(
"SELECT bytes_transferred FROM {$this->db->rate_limits_table()}
WHERE window_type = %s AND window_start = %s",
self::WINDOW_HOUR,
$window_start
));
return $bytes ? (int) $bytes : 0;
}
/**
* Check if we're approaching rate limits
*
* @param float $threshold Percentage threshold (0.0 - 1.0)
* @return bool True if approaching limits
*/
public function is_approaching_limit($threshold = 0.9) {
$status = $this->get_status();
$hourly_pct = $status['hourly']['used'] / $status['hourly']['limit'];
$daily_pct = $status['daily']['used'] / $status['daily']['limit'];
return $hourly_pct >= $threshold || $daily_pct >= $threshold;
}
/**
* Reset all rate limit counters (for testing)
*/
public function reset() {
global $wpdb;
$wpdb->query("TRUNCATE TABLE {$this->db->rate_limits_table()}");
$this->last_request_time = 0;
}
}
@@ -0,0 +1,694 @@
<?php
/**
* MLS Sync Engine
*
* Handles synchronization of MLS data including:
* - Full initial import
* - Incremental updates
* - Delete handling (MlgCanView = false)
* - Sync state tracking for resume capability
*/
if (!defined('ABSPATH')) {
exit;
}
class MLS_Sync_Engine {
/**
* Sync types
*/
const TYPE_FULL = 'full';
const TYPE_INCREMENTAL = 'incremental';
const TYPE_MEDIA = 'media';
/**
* Sync statuses
*/
const STATUS_PENDING = 'pending';
const STATUS_RUNNING = 'running';
const STATUS_COMPLETED = 'completed';
const STATUS_FAILED = 'failed';
const STATUS_PAUSED = 'paused';
/**
* Database instance
*/
private $db;
/**
* API client instance
*/
private $api_client;
/**
* Media handler instance
*/
private $media_handler;
/**
* Logger instance
*/
private $logger;
/**
* Current sync state ID
*/
private $sync_state_id = null;
/**
* Stats for current sync
*/
private $stats = array(
'processed' => 0,
'created' => 0,
'updated' => 0,
'deleted' => 0,
'errors' => 0,
);
/**
* Constructor
*/
public function __construct(MLS_DB $db, MLS_API_Client $api_client, MLS_Media_Handler $media_handler, MLS_Logger $logger) {
$this->db = $db;
$this->api_client = $api_client;
$this->media_handler = $media_handler;
$this->logger = $logger;
}
/**
* Run full sync
*
* @param bool $dry_run If true, don't make changes
* @param int|null $limit Max records to process
* @param callable|null $progress_callback Callback for progress updates
* @return array Sync results
*/
public function run_full_sync($dry_run = false, $limit = null, $progress_callback = null) {
$this->logger->info('Starting full sync', array('dry_run' => $dry_run, 'limit' => $limit));
// Create sync state record
if (!$dry_run) {
$this->sync_state_id = $this->create_sync_state(self::TYPE_FULL);
$this->logger->set_sync_state($this->sync_state_id);
}
$this->stats = array(
'processed' => 0,
'created' => 0,
'updated' => 0,
'deleted' => 0,
'errors' => 0,
);
try {
// Get first page of properties with media
$response = $this->api_client->get_properties_for_sync(null, 'Media', $limit ? min($limit, 1000) : null);
if (is_wp_error($response)) {
throw new Exception($response->get_error_message());
}
// Process pages
$continue = true;
while ($continue && isset($response['value'])) {
foreach ($response['value'] as $property) {
if ($limit && $this->stats['processed'] >= $limit) {
$continue = false;
break;
}
$this->process_property($property, $dry_run);
if ($progress_callback) {
call_user_func($progress_callback, $this->stats);
}
}
// Check for next page
if ($continue && isset($response['@odata.nextLink'])) {
// Save progress
if (!$dry_run) {
$this->update_sync_state(array(
'last_next_link' => $response['@odata.nextLink'],
'records_processed' => $this->stats['processed'],
'records_created' => $this->stats['created'],
'records_updated' => $this->stats['updated'],
));
}
$response = $this->api_client->get_next_page($response['@odata.nextLink']);
if (is_wp_error($response)) {
throw new Exception($response->get_error_message());
}
} else {
$continue = false;
}
}
// Mark sync as completed
if (!$dry_run) {
$this->update_sync_state(array(
'status' => self::STATUS_COMPLETED,
'completed_at' => current_time('mysql'),
'records_processed' => $this->stats['processed'],
'records_created' => $this->stats['created'],
'records_updated' => $this->stats['updated'],
'records_deleted' => $this->stats['deleted'],
));
// Update last sync time
$options = mls_plugin()->get_options();
$options->update_last_sync('full');
}
$this->logger->info('Full sync completed', $this->stats);
} catch (Exception $e) {
$this->logger->error('Full sync failed', array('error' => $e->getMessage()));
if (!$dry_run) {
$this->update_sync_state(array(
'status' => self::STATUS_FAILED,
'last_error' => $e->getMessage(),
'error_count' => $this->stats['errors'] + 1,
));
}
return array(
'success' => false,
'error' => $e->getMessage(),
'stats' => $this->stats,
);
}
return array(
'success' => true,
'stats' => $this->stats,
);
}
/**
* Run incremental sync
*
* @param bool $dry_run If true, don't make changes
* @param callable|null $progress_callback Callback for progress updates
* @return array Sync results
*/
public function run_incremental_sync($dry_run = false, $progress_callback = null) {
// Get last modification timestamp
$last_timestamp = $this->get_last_modification_timestamp();
if (!$last_timestamp) {
$this->logger->info('No previous sync found, running full sync instead');
return $this->run_full_sync($dry_run, null, $progress_callback);
}
$this->logger->info('Starting incremental sync', array(
'since' => $last_timestamp,
'dry_run' => $dry_run,
));
if (!$dry_run) {
$this->sync_state_id = $this->create_sync_state(self::TYPE_INCREMENTAL);
$this->logger->set_sync_state($this->sync_state_id);
}
$this->stats = array(
'processed' => 0,
'created' => 0,
'updated' => 0,
'deleted' => 0,
'errors' => 0,
);
try {
// Get modified properties (including those marked for deletion)
$response = $this->api_client->get_properties_since($last_timestamp, 'Media');
if (is_wp_error($response)) {
throw new Exception($response->get_error_message());
}
// Process pages
while (isset($response['value'])) {
foreach ($response['value'] as $property) {
$this->process_property($property, $dry_run);
if ($progress_callback) {
call_user_func($progress_callback, $this->stats);
}
}
// Check for next page
if (isset($response['@odata.nextLink'])) {
$response = $this->api_client->get_next_page($response['@odata.nextLink']);
if (is_wp_error($response)) {
throw new Exception($response->get_error_message());
}
} else {
break;
}
}
// Mark sync as completed
if (!$dry_run) {
$this->update_sync_state(array(
'status' => self::STATUS_COMPLETED,
'completed_at' => current_time('mysql'),
'records_processed' => $this->stats['processed'],
'records_created' => $this->stats['created'],
'records_updated' => $this->stats['updated'],
'records_deleted' => $this->stats['deleted'],
));
$options = mls_plugin()->get_options();
$options->update_last_sync('incremental');
}
$this->logger->info('Incremental sync completed', $this->stats);
} catch (Exception $e) {
$this->logger->error('Incremental sync failed', array('error' => $e->getMessage()));
if (!$dry_run) {
$this->update_sync_state(array(
'status' => self::STATUS_FAILED,
'last_error' => $e->getMessage(),
));
}
return array(
'success' => false,
'error' => $e->getMessage(),
'stats' => $this->stats,
);
}
return array(
'success' => true,
'stats' => $this->stats,
);
}
/**
* Resume an interrupted sync
*
* @param int $sync_state_id Sync state ID to resume
* @param callable|null $progress_callback Progress callback
* @return array Sync results
*/
public function resume_sync($sync_state_id, $progress_callback = null) {
global $wpdb;
$state = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$this->db->sync_state_table()} WHERE id = %d",
$sync_state_id
));
if (!$state) {
return array(
'success' => false,
'error' => 'Sync state not found',
);
}
if ($state->status === self::STATUS_COMPLETED) {
return array(
'success' => false,
'error' => 'Sync already completed',
);
}
$this->sync_state_id = $sync_state_id;
$this->logger->set_sync_state($sync_state_id);
$this->logger->info('Resuming sync', array('sync_state_id' => $sync_state_id));
// Load existing stats
$this->stats = array(
'processed' => (int) $state->records_processed,
'created' => (int) $state->records_created,
'updated' => (int) $state->records_updated,
'deleted' => (int) $state->records_deleted,
'errors' => (int) $state->error_count,
);
// Update status to running
$this->update_sync_state(array('status' => self::STATUS_RUNNING));
try {
// Resume from last next_link
if ($state->last_next_link) {
$response = $this->api_client->get_next_page($state->last_next_link);
} else {
// Start fresh
$response = $this->api_client->get_properties_for_sync(
$state->last_modification_timestamp,
'Media'
);
}
if (is_wp_error($response)) {
throw new Exception($response->get_error_message());
}
// Process remaining pages
while (isset($response['value'])) {
foreach ($response['value'] as $property) {
$this->process_property($property, false);
if ($progress_callback) {
call_user_func($progress_callback, $this->stats);
}
}
if (isset($response['@odata.nextLink'])) {
$this->update_sync_state(array(
'last_next_link' => $response['@odata.nextLink'],
'records_processed' => $this->stats['processed'],
));
$response = $this->api_client->get_next_page($response['@odata.nextLink']);
if (is_wp_error($response)) {
throw new Exception($response->get_error_message());
}
} else {
break;
}
}
$this->update_sync_state(array(
'status' => self::STATUS_COMPLETED,
'completed_at' => current_time('mysql'),
'records_processed' => $this->stats['processed'],
'records_created' => $this->stats['created'],
'records_updated' => $this->stats['updated'],
'records_deleted' => $this->stats['deleted'],
));
$this->logger->info('Resume sync completed', $this->stats);
} catch (Exception $e) {
$this->logger->error('Resume sync failed', array('error' => $e->getMessage()));
$this->update_sync_state(array(
'status' => self::STATUS_FAILED,
'last_error' => $e->getMessage(),
));
return array(
'success' => false,
'error' => $e->getMessage(),
'stats' => $this->stats,
);
}
return array(
'success' => true,
'stats' => $this->stats,
);
}
/**
* Process a single property record
*
* @param array $property Property data from API
* @param bool $dry_run If true, don't make changes
*/
private function process_property($property, $dry_run = false) {
global $wpdb;
$this->stats['processed']++;
$listing_key = $property['ListingKey'] ?? null;
if (!$listing_key) {
$this->stats['errors']++;
$this->logger->warning('Property missing ListingKey', array('property' => $property));
return;
}
// Check MlgCanView - if false, delete the record
$can_view = $property['MlgCanView'] ?? true;
if (!$can_view) {
if (!$dry_run) {
$this->delete_property($listing_key);
}
$this->stats['deleted']++;
return;
}
// Check if property exists
$existing = $wpdb->get_var($wpdb->prepare(
"SELECT id FROM {$this->db->properties_table()} WHERE listing_key = %s",
$listing_key
));
// Prepare data for insert/update
$data = $this->map_property_data($property);
if ($dry_run) {
if ($existing) {
$this->stats['updated']++;
} else {
$this->stats['created']++;
}
return;
}
if ($existing) {
// Update existing
$wpdb->update(
$this->db->properties_table(),
$data,
array('listing_key' => $listing_key)
);
$this->stats['updated']++;
} else {
// Insert new
$data['listing_key'] = $listing_key;
$data['created_at'] = current_time('mysql');
$wpdb->insert($this->db->properties_table(), $data);
$this->stats['created']++;
}
// Process media if present
if (isset($property['Media']) && is_array($property['Media'])) {
$this->media_handler->sync_property_media($listing_key, $property['Media']);
}
}
/**
* Map API property data to database columns
*
* @param array $property API property data
* @return array Mapped data for database
*/
private function map_property_data($property) {
return array(
'listing_id' => $property['ListingId'] ?? null,
'originating_system' => $property['OriginatingSystemName'] ?? 'northstar',
'standard_status' => $property['StandardStatus'] ?? null,
'mls_status' => $property['MlsStatus'] ?? null,
'mlg_can_view' => isset($property['MlgCanView']) ? ($property['MlgCanView'] ? 1 : 0) : 1,
'list_price' => $property['ListPrice'] ?? null,
'original_list_price' => $property['OriginalListPrice'] ?? null,
'close_price' => $property['ClosePrice'] ?? null,
'street_number' => $property['StreetNumber'] ?? null,
'street_name' => $property['StreetName'] ?? null,
'street_suffix' => $property['StreetSuffix'] ?? null,
'unit_number' => $property['UnitNumber'] ?? null,
'city' => $property['City'] ?? null,
'state_or_province' => $property['StateOrProvince'] ?? 'MN',
'postal_code' => $property['PostalCode'] ?? null,
'county' => $property['CountyOrParish'] ?? null,
'latitude' => $property['Latitude'] ?? null,
'longitude' => $property['Longitude'] ?? null,
'property_type' => $property['PropertyType'] ?? null,
'property_sub_type' => $property['PropertySubType'] ?? null,
'bedrooms_total' => $property['BedroomsTotal'] ?? null,
'bathrooms_total' => $property['BathroomsTotalInteger'] ?? null,
'bathrooms_full' => $property['BathroomsFull'] ?? null,
'bathrooms_half' => $property['BathroomsHalf'] ?? null,
'living_area' => $property['LivingArea'] ?? null,
'lot_size_area' => $property['LotSizeArea'] ?? null,
'lot_size_units' => $property['LotSizeUnits'] ?? null,
'year_built' => $property['YearBuilt'] ?? null,
'garage_spaces' => $property['GarageSpaces'] ?? null,
'public_remarks' => $property['PublicRemarks'] ?? null,
'directions' => $property['Directions'] ?? null,
'list_agent_key' => $property['ListAgentKey'] ?? null,
'list_agent_mls_id' => $property['ListAgentMlsId'] ?? null,
'list_office_key' => $property['ListOfficeKey'] ?? null,
'list_office_mls_id' => $property['ListOfficeMlsId'] ?? null,
'list_office_name' => $property['ListOfficeName'] ?? null,
'photos_count' => $property['PhotosCount'] ?? 0,
'modification_timestamp' => $this->format_timestamp($property['ModificationTimestamp'] ?? null),
'photos_change_timestamp' => $this->format_timestamp($property['PhotosChangeTimestamp'] ?? null),
'listing_contract_date' => $this->format_date($property['ListingContractDate'] ?? null),
'close_date' => $this->format_date($property['CloseDate'] ?? null),
'days_on_market' => $property['DaysOnMarket'] ?? null,
'raw_data' => wp_json_encode($property),
'updated_at' => current_time('mysql'),
);
}
/**
* Format ISO 8601 timestamp to MySQL datetime
*
* @param string|null $timestamp ISO 8601 timestamp
* @return string|null MySQL datetime
*/
private function format_timestamp($timestamp) {
if (!$timestamp) {
return null;
}
$dt = new DateTime($timestamp);
return $dt->format('Y-m-d H:i:s');
}
/**
* Format date string to MySQL date
*
* @param string|null $date Date string
* @return string|null MySQL date
*/
private function format_date($date) {
if (!$date) {
return null;
}
return date('Y-m-d', strtotime($date));
}
/**
* Delete a property and its media
*
* @param string $listing_key Listing key
*/
private function delete_property($listing_key) {
global $wpdb;
// Delete media files
$this->media_handler->delete_property_media($listing_key);
// Delete from database
$wpdb->delete(
$this->db->properties_table(),
array('listing_key' => $listing_key)
);
$this->logger->debug('Deleted property', array('listing_key' => $listing_key));
}
/**
* Create a sync state record
*
* @param string $type Sync type
* @return int Sync state ID
*/
private function create_sync_state($type) {
global $wpdb;
$wpdb->insert(
$this->db->sync_state_table(),
array(
'sync_type' => $type,
'entity_type' => 'Property',
'status' => self::STATUS_RUNNING,
'started_at' => current_time('mysql'),
'created_at' => current_time('mysql'),
)
);
return $wpdb->insert_id;
}
/**
* Update sync state record
*
* @param array $data Data to update
*/
private function update_sync_state($data) {
global $wpdb;
if (!$this->sync_state_id) {
return;
}
$data['updated_at'] = current_time('mysql');
$wpdb->update(
$this->db->sync_state_table(),
$data,
array('id' => $this->sync_state_id)
);
}
/**
* Get last modification timestamp from synced data
*
* @return string|null ISO 8601 timestamp
*/
private function get_last_modification_timestamp() {
global $wpdb;
$timestamp = $wpdb->get_var(
"SELECT MAX(modification_timestamp) FROM {$this->db->properties_table()}"
);
if ($timestamp) {
$dt = new DateTime($timestamp);
return $dt->format('Y-m-d\TH:i:s.v\Z');
}
return null;
}
/**
* Get sync status
*
* @return array Sync status
*/
public function get_status() {
global $wpdb;
$last_sync = $wpdb->get_row(
"SELECT * FROM {$this->db->sync_state_table()}
WHERE status = 'completed'
ORDER BY completed_at DESC
LIMIT 1"
);
$running_sync = $wpdb->get_row(
"SELECT * FROM {$this->db->sync_state_table()}
WHERE status = 'running'
ORDER BY started_at DESC
LIMIT 1"
);
$failed_sync = $wpdb->get_row(
"SELECT * FROM {$this->db->sync_state_table()}
WHERE status = 'failed'
ORDER BY updated_at DESC
LIMIT 1"
);
return array(
'last_sync' => $last_sync,
'running_sync' => $running_sync,
'last_failed' => $failed_sync,
'stats' => $this->db->get_stats(),
);
}
}
@@ -0,0 +1,325 @@
<?php
/**
* Plugin Name: MLS by HansonXyz
* Plugin URI: https://hanson.xyz
* Description: Syncs MLS Grid API data (NorthStar MLS) into local database with CLI tools and public API for themes/plugins.
* Version: 1.0.0
* Author: HansonXyz
* Author URI: https://hanson.xyz
* License: GPL-2.0+
* License URI: http://www.gnu.org/licenses/gpl-2.0.txt
* Text Domain: mls-by-hansonxyz
*/
// Prevent direct access
if (!defined('ABSPATH')) {
exit;
}
// Plugin constants
define('MLS_PLUGIN_VERSION', '1.0.0');
define('MLS_PLUGIN_FILE', __FILE__);
define('MLS_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('MLS_PLUGIN_URL', plugin_dir_url(__FILE__));
define('MLS_PLUGIN_BASENAME', plugin_basename(__FILE__));
define('MLS_DB_VERSION', '1.0.0');
// Database table names (without prefix)
define('MLS_TABLE_PROPERTIES', 'mls_properties');
define('MLS_TABLE_MEDIA', 'mls_media');
define('MLS_TABLE_SYNC_STATE', 'mls_sync_state');
define('MLS_TABLE_RATE_LIMITS', 'mls_rate_limits');
define('MLS_TABLE_SYNC_LOG', 'mls_sync_log');
/**
* Main plugin class
*/
final class MLS_Plugin {
/**
* Single instance
*/
private static $instance = null;
/**
* Plugin components
*/
private $db;
private $options;
private $logger;
private $rate_limiter;
private $api_client;
private $sync_engine;
private $media_handler;
private $query;
/**
* Get single instance
*/
public static function get_instance() {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Constructor
*/
private function __construct() {
$this->load_dependencies();
$this->init_hooks();
}
/**
* Load required files
*/
private function load_dependencies() {
// Core classes
require_once MLS_PLUGIN_DIR . 'includes/class-mls-db.php';
require_once MLS_PLUGIN_DIR . 'includes/class-mls-options.php';
require_once MLS_PLUGIN_DIR . 'includes/class-mls-logger.php';
require_once MLS_PLUGIN_DIR . 'includes/class-mls-rate-limiter.php';
require_once MLS_PLUGIN_DIR . 'includes/class-mls-api-client.php';
require_once MLS_PLUGIN_DIR . 'includes/class-mls-sync-engine.php';
require_once MLS_PLUGIN_DIR . 'includes/class-mls-media-handler.php';
require_once MLS_PLUGIN_DIR . 'includes/class-mls-query.php';
// Activation/Deactivation
require_once MLS_PLUGIN_DIR . 'includes/class-mls-activator.php';
require_once MLS_PLUGIN_DIR . 'includes/class-mls-deactivator.php';
// Admin
if (is_admin()) {
require_once MLS_PLUGIN_DIR . 'admin/class-mls-admin.php';
}
// WP-CLI
if (defined('WP_CLI') && WP_CLI) {
require_once MLS_PLUGIN_DIR . 'cli/class-mls-cli.php';
}
}
/**
* Initialize hooks
*/
private function init_hooks() {
// Activation/Deactivation hooks
register_activation_hook(MLS_PLUGIN_FILE, array('MLS_Activator', 'activate'));
register_deactivation_hook(MLS_PLUGIN_FILE, array('MLS_Deactivator', 'deactivate'));
// Initialize components after plugins loaded
add_action('plugins_loaded', array($this, 'init_components'));
// Check for database updates
add_action('plugins_loaded', array($this, 'check_db_updates'));
}
/**
* Initialize plugin components
*/
public function init_components() {
$this->db = new MLS_DB();
$this->options = new MLS_Options();
$this->logger = new MLS_Logger($this->db);
$this->rate_limiter = new MLS_Rate_Limiter($this->db);
$this->api_client = new MLS_API_Client($this->options, $this->rate_limiter, $this->logger);
$this->media_handler = new MLS_Media_Handler($this->db, $this->logger);
$this->sync_engine = new MLS_Sync_Engine(
$this->db,
$this->api_client,
$this->media_handler,
$this->logger
);
$this->query = new MLS_Query($this->db);
// Initialize admin
if (is_admin()) {
new MLS_Admin($this);
}
// Initialize CLI
if (defined('WP_CLI') && WP_CLI) {
MLS_CLI::register($this);
}
}
/**
* Check and run database updates
*/
public function check_db_updates() {
$current_version = get_option('mls_db_version', '0');
if (version_compare($current_version, MLS_DB_VERSION, '<')) {
MLS_Activator::create_tables();
update_option('mls_db_version', MLS_DB_VERSION);
}
}
/**
* Get DB instance
*/
public function get_db() {
return $this->db;
}
/**
* Get Options instance
*/
public function get_options() {
return $this->options;
}
/**
* Get Logger instance
*/
public function get_logger() {
return $this->logger;
}
/**
* Get Rate Limiter instance
*/
public function get_rate_limiter() {
return $this->rate_limiter;
}
/**
* Get API Client instance
*/
public function get_api_client() {
return $this->api_client;
}
/**
* Get Sync Engine instance
*/
public function get_sync_engine() {
return $this->sync_engine;
}
/**
* Get Media Handler instance
*/
public function get_media_handler() {
return $this->media_handler;
}
/**
* Get Query instance
*/
public function get_query() {
return $this->query;
}
}
/**
* Initialize the plugin
*/
function mls_plugin() {
return MLS_Plugin::get_instance();
}
// Start the plugin
add_action('plugins_loaded', 'mls_plugin', 0);
/**
* Global helper functions for themes/plugins
*/
/**
* Get MLS properties
*
* @param array $args Query arguments
* @return array Array of property objects
*/
function mls_get_properties($args = array()) {
$plugin = mls_plugin();
if (!$plugin->get_query()) {
return array();
}
return $plugin->get_query()->get_properties($args);
}
/**
* Get a single MLS property
*
* @param string $identifier Listing key or MLS ID
* @return object|null Property object or null
*/
function mls_get_property($identifier) {
$plugin = mls_plugin();
if (!$plugin->get_query()) {
return null;
}
return $plugin->get_query()->get_property($identifier);
}
/**
* Get media for a listing
*
* @param string $listing_key The listing key
* @return array Array of media objects
*/
function mls_get_property_media($listing_key) {
$plugin = mls_plugin();
if (!$plugin->get_query()) {
return array();
}
return $plugin->get_query()->get_property_media($listing_key);
}
/**
* Get primary image URL for a listing
*
* @param string $listing_key The listing key
* @return string|null Image URL or null
*/
function mls_get_property_image($listing_key) {
$plugin = mls_plugin();
if (!$plugin->get_query()) {
return null;
}
return $plugin->get_query()->get_primary_image($listing_key);
}
/**
* Get distinct cities with listings
*
* @param string|null $status Optional status filter
* @return array Array of city names
*/
function mls_get_cities($status = null) {
$plugin = mls_plugin();
if (!$plugin->get_query()) {
return array();
}
return $plugin->get_query()->get_distinct_cities($status);
}
/**
* Check if MLS data is available
*
* @return bool True if synced data exists
*/
function mls_is_available() {
$plugin = mls_plugin();
if (!$plugin->get_query()) {
return false;
}
return $plugin->get_query()->has_data();
}
/**
* Get property count
*
* @param array $args Optional filter arguments
* @return int Property count
*/
function mls_get_property_count($args = array()) {
$plugin = mls_plugin();
if (!$plugin->get_query()) {
return 0;
}
return $plugin->get_query()->get_count($args);
}
@@ -0,0 +1,48 @@
<?php
/**
* Uninstall MLS by HansonXyz Plugin
*
* This file runs when the plugin is deleted via the WordPress admin.
* It removes all plugin data including database tables and options.
*/
// Exit if not called by WordPress
if (!defined('WP_UNINSTALL_PLUGIN')) {
exit;
}
global $wpdb;
// Delete options
delete_option('mls_plugin_options');
delete_option('mls_db_version');
delete_option('mls_activated_at');
// Drop tables
$tables = array(
$wpdb->prefix . 'mls_properties',
$wpdb->prefix . 'mls_media',
$wpdb->prefix . 'mls_sync_state',
$wpdb->prefix . 'mls_rate_limits',
$wpdb->prefix . 'mls_sync_log',
);
foreach ($tables as $table) {
$wpdb->query("DROP TABLE IF EXISTS {$table}");
}
// Clear scheduled cron events
wp_clear_scheduled_hook('mls_sync_properties');
wp_clear_scheduled_hook('mls_sync_media');
wp_clear_scheduled_hook('mls_cleanup');
// Optionally delete media files
// Note: Uncomment this if you want to delete all downloaded media on uninstall
/*
$upload_dir = wp_upload_dir();
$mls_dir = $upload_dir['basedir'] . '/mls-listings';
if (is_dir($mls_dir)) {
// Recursive delete would go here
}
*/