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:
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user