Integrate MLS listings with property map and add smart sync
Property Map: - Replace ACF-based property display with MLS database queries - Use real lat/lng coordinates from MLS (100% coverage) - Create property-card-mls.php template for MLS property cards - Update AJAX handler to filter MLS properties MLS Plugin Enhancements: - Add 'wp mls run' smart sync command (auto-detects full/incremental/resume) - Add database index migrations for lat/lng and composite search indexes - Add comprehensive README.md documentation Documentation: - Update site README.md with sysadmin quick reference - Add FEATURES_PENDING_12_15.md tracking client feature requests 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,12 @@ if (!defined('ABSPATH')) {
|
||||
|
||||
class MLS_DB {
|
||||
|
||||
/**
|
||||
* Schema version for index migrations
|
||||
* Increment this when adding new indexes
|
||||
*/
|
||||
const SCHEMA_VERSION = 2;
|
||||
|
||||
/**
|
||||
* Get table name with prefix
|
||||
*
|
||||
@@ -275,6 +281,78 @@ class MLS_DB {
|
||||
) {$charset_collate};";
|
||||
|
||||
dbDelta($sql_media_log);
|
||||
|
||||
// Run index migrations
|
||||
self::run_index_migrations();
|
||||
}
|
||||
|
||||
/**
|
||||
* Run index migrations that dbDelta cannot handle
|
||||
*
|
||||
* dbDelta can create tables and add columns, but cannot add indexes
|
||||
* to existing tables. This method handles incremental index additions.
|
||||
*/
|
||||
public static function run_index_migrations() {
|
||||
global $wpdb;
|
||||
|
||||
$current_schema = (int) get_option('mls_schema_version', 1);
|
||||
|
||||
// Migration to schema version 2: Add search and geo indexes
|
||||
if ($current_schema < 2) {
|
||||
$table_properties = $wpdb->prefix . MLS_TABLE_PROPERTIES;
|
||||
$table_media = $wpdb->prefix . MLS_TABLE_MEDIA;
|
||||
|
||||
// Check and add indexes only if they don't exist
|
||||
$existing_indexes = self::get_existing_indexes($table_properties);
|
||||
|
||||
// Geospatial indexes for bounding box queries
|
||||
if (!isset($existing_indexes['idx_latitude'])) {
|
||||
$wpdb->query("ALTER TABLE {$table_properties} ADD INDEX idx_latitude (latitude)");
|
||||
}
|
||||
if (!isset($existing_indexes['idx_longitude'])) {
|
||||
$wpdb->query("ALTER TABLE {$table_properties} ADD INDEX idx_longitude (longitude)");
|
||||
}
|
||||
|
||||
// Composite index for common search pattern (status + city + price)
|
||||
if (!isset($existing_indexes['idx_status_city_price'])) {
|
||||
$wpdb->query("ALTER TABLE {$table_properties} ADD INDEX idx_status_city_price (standard_status, city, list_price)");
|
||||
}
|
||||
|
||||
// Composite index for status + property_type searches
|
||||
if (!isset($existing_indexes['idx_status_type'])) {
|
||||
$wpdb->query("ALTER TABLE {$table_properties} ADD INDEX idx_status_type (standard_status, property_type)");
|
||||
}
|
||||
|
||||
// Media table: composite index for listing + order (eliminates filesort)
|
||||
$media_indexes = self::get_existing_indexes($table_media);
|
||||
if (!isset($media_indexes['idx_listing_order'])) {
|
||||
$wpdb->query("ALTER TABLE {$table_media} ADD INDEX idx_listing_order (listing_key, media_order)");
|
||||
}
|
||||
|
||||
update_option('mls_schema_version', 2);
|
||||
}
|
||||
|
||||
// Future migrations go here:
|
||||
// if ($current_schema < 3) { ... }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get existing indexes for a table
|
||||
*
|
||||
* @param string $table Full table name
|
||||
* @return array Index names as keys
|
||||
*/
|
||||
private static function get_existing_indexes($table) {
|
||||
global $wpdb;
|
||||
|
||||
$indexes = array();
|
||||
$results = $wpdb->get_results("SHOW INDEX FROM {$table}");
|
||||
|
||||
foreach ($results as $row) {
|
||||
$indexes[$row->Key_name] = true;
|
||||
}
|
||||
|
||||
return $indexes;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -937,4 +937,113 @@ class MLS_Sync_Engine {
|
||||
|
||||
return $this->resume_sync($resumable->id, $progress_callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Smart sync - autonomous self-healing sync that handles all scenarios
|
||||
*
|
||||
* Decision logic:
|
||||
* 1. If a sync is currently running (and not stale), abort
|
||||
* 2. If there's a resumable failed/interrupted sync, resume it
|
||||
* 3. If no data exists, run full sync
|
||||
* 4. Otherwise, run incremental sync
|
||||
*
|
||||
* On failure, the sync state is preserved for future resume.
|
||||
*
|
||||
* @param callable|null $progress_callback Progress callback
|
||||
* @param callable|null $status_callback Callback for status messages: function(string $message, string $level)
|
||||
* @return array Sync results with 'action' key indicating what was done
|
||||
*/
|
||||
public function smart_sync($progress_callback = null, $status_callback = null) {
|
||||
// Helper to emit status messages
|
||||
$status = function($message, $level = 'info') use ($status_callback) {
|
||||
if ($status_callback) {
|
||||
call_user_func($status_callback, $message, $level);
|
||||
}
|
||||
$this->logger->log($level, $message);
|
||||
};
|
||||
|
||||
// Step 1: Clean up stale syncs (running > 1 hour = probably dead)
|
||||
$stale_cleaned = $this->cleanup_stale_syncs();
|
||||
if ($stale_cleaned > 0) {
|
||||
$status("Cleaned up {$stale_cleaned} stale sync(s)", 'info');
|
||||
}
|
||||
|
||||
// Step 2: Check if a sync is actively running
|
||||
$running = $this->get_running_sync();
|
||||
if ($running) {
|
||||
$status("Sync #{$running->id} is already running (started {$running->started_at})", 'warning');
|
||||
return array(
|
||||
'success' => false,
|
||||
'action' => 'aborted',
|
||||
'reason' => 'Sync already running',
|
||||
'running_sync' => $running,
|
||||
);
|
||||
}
|
||||
|
||||
// Step 3: Check for resumable syncs
|
||||
$resumable = $this->get_latest_resumable();
|
||||
if ($resumable) {
|
||||
$status("Found resumable sync #{$resumable->id} ({$resumable->sync_type}), processed {$resumable->records_processed} records", 'info');
|
||||
$status("Resuming...", 'info');
|
||||
|
||||
$result = $this->resume_sync($resumable->id, $progress_callback);
|
||||
$result['action'] = 'resumed';
|
||||
$result['resumed_sync_id'] = $resumable->id;
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Step 4: Check if we have any data
|
||||
$has_data = $this->has_synced_data();
|
||||
|
||||
if (!$has_data) {
|
||||
// No data - need full sync
|
||||
$status("No existing data found, starting full sync", 'info');
|
||||
|
||||
$result = $this->run_full_sync(false, null, $progress_callback);
|
||||
$result['action'] = 'full';
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Step 5: We have data - run incremental sync
|
||||
$last_timestamp = $this->get_last_modification_timestamp();
|
||||
$status("Running incremental sync (changes since {$last_timestamp})", 'info');
|
||||
|
||||
$result = $this->run_incremental_sync(false, $progress_callback);
|
||||
$result['action'] = 'incremental';
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there's a currently running sync (not stale)
|
||||
*
|
||||
* @return object|null Running sync state or null
|
||||
*/
|
||||
public function get_running_sync() {
|
||||
global $wpdb;
|
||||
|
||||
$one_hour_ago = date('Y-m-d H:i:s', strtotime('-1 hour'));
|
||||
|
||||
return $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT * FROM {$this->db->sync_state_table()}
|
||||
WHERE status = 'running' AND updated_at >= %s
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 1",
|
||||
$one_hour_ago
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we have any synced property data
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function has_synced_data() {
|
||||
global $wpdb;
|
||||
|
||||
$count = $wpdb->get_var(
|
||||
"SELECT COUNT(*) FROM {$this->db->properties_table()} WHERE mlg_can_view = 1"
|
||||
);
|
||||
|
||||
return (int) $count > 0;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user