Add MLS by HansonXyz plugin for MLS Grid API integration

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

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

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

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

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Hanson.xyz Dev
2025-12-14 21:24:38 -06:00
parent ec5a309555
commit 6556479417
18 changed files with 5324 additions and 10 deletions
@@ -0,0 +1,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(),
);
}
}