Snapshot: MLS sync fixes, image refresh, plugin/theme updates

MLS plugin fixes from this session:
- Fix silent insert failures: location column NOT NULL was rejecting wpdb->insert calls,
  causing ~18k new properties since Dec 2025 to be lost. Inserts now build raw SQL
  with ST_PointFromText so the spatial column is populated atomically.
- Auto-refresh expired media URLs in MLS_Media_Handler::fetch_and_cache(), guarded by
  a property-level GET_LOCK so concurrent fetches share one API refresh.
- Normalize WP_Error to null in mls_get_property_image() so callers can rely on the
  documented string|null contract.
- Support comma-separated property_type filters in MLS_Query and MLS_Cluster so the
  homepage "View All Commercial" link (?property_type=Commercial+Sale,Land,Farm)
  actually filters correctly.
- Incremental sync now looks back 10 minutes past the latest modification timestamp
  as a safety margin against missed records.
- Smart sync exits silently (info-level, not warning) when a full sync is in progress.

Operational:
- New cron: weekly full sync Sundays at 3 AM (/usr/local/bin/mls-full-sync).
- New cron: hourly 2GB cap on mls-thumbnails/ and cache/transformed-images/
  (/usr/local/bin/mls-image-cache-cap).
- Logrotate config for wp-content/debug.log (2-day retention, daily rotation,
  delaycompress).

Repo policy:
- CLAUDE.md updated with explicit "commit everything except build artifacts" policy.
- .gitignore: untrack runtime image caches and debug.log rotations.

Other modifications in this snapshot are pre-existing in-flight theme/plugin/db_content_updates work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
root
2026-04-29 15:32:23 +00:00
parent 57b752f54e
commit b6df4dbb92
5385 changed files with 838580 additions and 2416 deletions
@@ -128,11 +128,12 @@ class MLS_API_Client {
* @param string $endpoint API endpoint (relative to base URL)
* @param array $params Query parameters
* @param int $retry Current retry attempt
* @param string $channel Rate limit channel ('general' or 'image')
* @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);
public function request($endpoint, $params = array(), $retry = 0, $channel = 'general') {
// Check and wait for rate limits (uses global advisory lock coordination)
$this->rate_limiter->check_and_wait(true, $channel);
$url = $this->build_url($endpoint, $params);
@@ -172,7 +173,7 @@ class MLS_API_Client {
// Retry on transient errors
if ($retry < self::MAX_RETRIES) {
sleep(pow(2, $retry)); // Exponential backoff
return $this->request($endpoint, $params, $retry + 1);
return $this->request($endpoint, $params, $retry + 1, $channel);
}
return $response;
@@ -195,7 +196,7 @@ class MLS_API_Client {
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 $this->request($endpoint, $params, $retry + 1, $channel);
}
return new WP_Error('api_error', $error_message, array('status' => $status_code));
@@ -382,7 +383,8 @@ class MLS_API_Client {
* Get a single property by listing ID with media
*
* Used to refresh media URLs for a specific listing without
* fetching the entire dataset.
* fetching the entire dataset. Uses the 'image' rate limit channel
* with a 2-second interval for on-demand image requests.
*
* Note: MLS Grid only allows filtering by ListingId (not ListingKey)
* for the Property resource. The caller must provide the listing_id.
@@ -400,7 +402,8 @@ class MLS_API_Client {
$params['$expand'] = 'Media';
$params['$top'] = 1;
$response = $this->request('Property', $params);
// Use 'image' channel with 2-second rate limiting for on-demand media fetches
$response = $this->request('Property', $params, 0, 'image');
if (is_wp_error($response)) {
return $response;
@@ -414,6 +417,41 @@ class MLS_API_Client {
return null;
}
/**
* Get multiple properties by listing IDs with media (batched)
*
* Fetches up to 25 properties in a single API request using OData 'in' filter.
* Used for efficient media URL refresh without making individual API calls.
* Uses the 'image' rate limit channel with 2-second interval.
*
* @param array $listing_ids Array of MLS listing IDs (max 25)
* @return array|WP_Error Array of property data with Media, or error
*/
public function get_properties_by_ids($listing_ids) {
if (empty($listing_ids)) {
return array('value' => array());
}
// Limit to 25 (MLS Grid's max with $expand)
$listing_ids = array_slice($listing_ids, 0, 25);
$params = array();
$system = $this->options->get_originating_system();
// Build 'in' filter: ListingId in ('ID1', 'ID2', 'ID3')
$escaped_ids = array_map(function($id) {
return "'" . addslashes($id) . "'";
}, $listing_ids);
$in_list = implode(',', $escaped_ids);
$params['$filter'] = "OriginatingSystemName eq '{$system}' and ListingId in ({$in_list})";
$params['$expand'] = 'Media';
$params['$top'] = 25;
// Use 'image' channel with 2-second rate limiting for media fetches
return $this->request('Property', $params, 0, 'image');
}
/**
* Get next page of results
*
@@ -41,16 +41,31 @@ class MLS_Cluster {
/**
* Minimum properties before any grouping kicks in
* Below this, always show individual markers
* Below this, always show individual markers regardless of zoom
*/
const MIN_FOR_GROUPING = 30;
/**
* Viewport-aware marker threshold
* If viewport contains fewer than this many properties AND zoom >= 9,
* show individual markers instead of clusters.
* This helps mobile viewports which show smaller geographic areas.
*/
const VIEWPORT_MARKER_THRESHOLD = 120;
/**
* Minimum zoom level for viewport-aware marker display
* Below this zoom, always use density/cluster mode even with few properties
* (prevents showing 100+ scattered markers across entire state)
*/
const MIN_ZOOM_FOR_VIEWPORT_MARKERS = 9;
/**
* Zoom thresholds for visualization modes
*/
const ZOOM_DENSE_MAX = 5; // 1-5: density dots (40% more dense)
const ZOOM_DENSITY_MAX = 8; // 6-8: density dots (normal)
const ZOOM_CLUSTER_MAX = 15; // 9-15: numbered clusters
const ZOOM_CLUSTER_MAX = 15; // 9-15: numbered clusters (unless viewport threshold met)
// 16+: individual markers
/**
@@ -269,8 +284,15 @@ class MLS_Cluster {
}
if ($args['property_type']) {
$where[] = 'property_type = %s';
$values[] = $args['property_type'];
$types = array_filter(array_map('trim', explode(',', $args['property_type'])));
if (count($types) === 1) {
$where[] = 'property_type = %s';
$values[] = $types[0];
} elseif (count($types) > 1) {
$placeholders = implode(',', array_fill(0, count($types), '%s'));
$where[] = "property_type IN ({$placeholders})";
$values = array_merge($values, $types);
}
}
if ($args['city']) {
@@ -314,7 +336,7 @@ class MLS_Cluster {
$total = (int) $wpdb->get_var($count_sql);
}
// If few properties, always show individual markers (no grouping)
// If very few properties, always show individual markers (no grouping)
if ($total <= self::MIN_FOR_GROUPING) {
return $this->get_individual_markers($where_sql, $values, $total);
}
@@ -327,19 +349,34 @@ class MLS_Cluster {
$zoom = (int) $args['zoom'];
// Determine visualization mode based on zoom level
// Zoom 1-5: Density dots (40% more dense)
// Determine visualization mode based on zoom level AND viewport property count
//
// Priority order:
// 1. Very zoomed out (zoom 1-5): Always density dots (dense)
// 2. Zoomed out (zoom 6-8): Always density dots (normal)
// 3. Medium zoom (9-15) with FEW properties in viewport: Individual markers
// 4. Medium zoom (9-15) with MANY properties: Clusters
// 5. Very zoomed in (16+): Always individual markers
// Zoom 1-5: Density dots (40% more dense) - always, regardless of count
if ($zoom <= self::ZOOM_DENSE_MAX) {
return $this->get_density_data($where_sql, $values, $zoom, $center_lat, $total, self::DENSITY_DOT_SPACING_DENSE);
}
// Zoom 6-11: Density dots (normal spacing)
// Zoom 6-8: Density dots (normal spacing) - always, regardless of count
if ($zoom <= self::ZOOM_DENSITY_MAX) {
return $this->get_density_data($where_sql, $values, $zoom, $center_lat, $total, self::DENSITY_DOT_SPACING);
}
// Zoom 9-15: Always use server-side clusters (let server handle grouping)
// Zoom 9-15: Use viewport-aware threshold
// If viewport has relatively few properties, show individual markers
// This helps mobile viewports which show smaller geographic areas at same zoom level
if ($zoom <= self::ZOOM_CLUSTER_MAX) {
if ($total <= self::VIEWPORT_MARKER_THRESHOLD) {
// Few enough properties in viewport - show individual markers
return $this->get_individual_markers($where_sql, $values, $total);
}
// Many properties - use clusters
return $this->get_cluster_data($where_sql, $values, $zoom, $center_lat, $total);
}
@@ -13,7 +13,7 @@ class MLS_DB {
* Schema version for index migrations
* Increment this when adding new indexes or columns
*/
const SCHEMA_VERSION = 5;
const SCHEMA_VERSION = 6;
/**
* Get table name with prefix
@@ -82,6 +82,13 @@ class MLS_DB {
return $this->get_table_name(MLS_TABLE_GEO_ZIPCODES);
}
/**
* Get manual properties table name
*/
public function manual_properties_table() {
return $this->get_table_name(MLS_TABLE_MANUAL_PROPERTIES);
}
/**
* Create all database tables
*/
@@ -143,6 +150,7 @@ class MLS_DB {
is_homeproz TINYINT(1) NOT NULL DEFAULT 0,
photos_count INT(5) DEFAULT 0,
media_expires_at DATETIME DEFAULT NULL,
modification_timestamp DATETIME NOT NULL,
photos_change_timestamp DATETIME DEFAULT NULL,
listing_contract_date DATE DEFAULT NULL,
@@ -332,6 +340,83 @@ class MLS_DB {
dbDelta($sql_geo_zipcodes);
// Manual properties table (for manually entered listings)
$table_manual = $wpdb->prefix . MLS_TABLE_MANUAL_PROPERTIES;
$sql_manual = "CREATE TABLE {$table_manual} (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
wp_post_id BIGINT(20) UNSIGNED NOT NULL,
listing_key VARCHAR(50) NOT NULL,
listing_id VARCHAR(50) DEFAULT NULL,
standard_status VARCHAR(30) NOT NULL DEFAULT 'Active',
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,
full_address VARCHAR(255) DEFAULT NULL,
city VARCHAR(100) DEFAULT 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 'Acres',
year_built INT(4) DEFAULT NULL,
stories INT(3) DEFAULT NULL,
garage_spaces INT(3) DEFAULT NULL,
architectural_style VARCHAR(100) DEFAULT NULL,
public_remarks TEXT DEFAULT NULL,
private_remarks TEXT DEFAULT NULL,
directions TEXT DEFAULT NULL,
list_agent_post_id BIGINT(20) UNSIGNED DEFAULT NULL,
co_list_agent_post_id BIGINT(20) UNSIGNED DEFAULT NULL,
is_homeproz TINYINT(1) NOT NULL DEFAULT 0,
is_featured TINYINT(1) NOT NULL DEFAULT 0,
virtual_tour_url VARCHAR(500) DEFAULT NULL,
association_fee DECIMAL(10,2) DEFAULT NULL,
list_date DATE DEFAULT NULL,
contract_date DATE DEFAULT NULL,
close_date DATE DEFAULT NULL,
expiration_date DATE DEFAULT NULL,
photos_count INT(5) DEFAULT 0,
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),
UNIQUE KEY wp_post_id (wp_post_id),
KEY listing_id (listing_id),
KEY standard_status (standard_status),
KEY city (city),
KEY property_type (property_type),
KEY list_price (list_price),
KEY is_homeproz (is_homeproz),
KEY is_featured (is_featured),
KEY latitude (latitude),
KEY longitude (longitude)
) {$charset_collate};";
dbDelta($sql_manual);
// Run index migrations
self::run_index_migrations();
}
@@ -473,8 +558,66 @@ class MLS_DB {
update_option('mls_schema_version', 5);
}
// Migration to schema version 6: Add media_expires_at column for proactive URL refresh
if ($current_schema < 6) {
$table_properties = $wpdb->prefix . MLS_TABLE_PROPERTIES;
// Check if column exists
$column_exists = $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND COLUMN_NAME = 'media_expires_at'",
DB_NAME,
$table_properties
));
if (!$column_exists) {
$wpdb->query("ALTER TABLE {$table_properties} ADD COLUMN media_expires_at DATETIME DEFAULT NULL AFTER photos_count");
}
// Add index for finding properties with expiring media
$existing_indexes = self::get_existing_indexes($table_properties);
if (!isset($existing_indexes['idx_media_expires_at'])) {
$wpdb->query("ALTER TABLE {$table_properties} ADD INDEX idx_media_expires_at (media_expires_at)");
}
update_option('mls_schema_version', 6);
}
// Migration to schema version 7: Add indexes for search query optimization
if ($current_schema < 7) {
$table_properties = $wpdb->prefix . MLS_TABLE_PROPERTIES;
$existing_indexes = self::get_existing_indexes($table_properties);
// Index for postal code searches
if (!isset($existing_indexes['idx_postal_code'])) {
$wpdb->query("ALTER TABLE {$table_properties} ADD INDEX idx_postal_code (postal_code)");
}
// Index for bathroom filter
if (!isset($existing_indexes['idx_bathrooms_total'])) {
$wpdb->query("ALTER TABLE {$table_properties} ADD INDEX idx_bathrooms_total (bathrooms_total)");
}
// Index for living area (sqft) filter
if (!isset($existing_indexes['idx_living_area'])) {
$wpdb->query("ALTER TABLE {$table_properties} ADD INDEX idx_living_area (living_area)");
}
// Index for state filter
if (!isset($existing_indexes['idx_state'])) {
$wpdb->query("ALTER TABLE {$table_properties} ADD INDEX idx_state (state_or_province)");
}
// Composite index for media refresh sync query
if (!isset($existing_indexes['idx_status_media_expires'])) {
$wpdb->query("ALTER TABLE {$table_properties} ADD INDEX idx_status_media_expires (standard_status, media_expires_at)");
}
update_option('mls_schema_version', 7);
}
// Future migrations go here:
// if ($current_schema < 6) { ... }
// if ($current_schema < 8) { ... }
}
/**
@@ -511,6 +654,7 @@ class MLS_DB {
MLS_TABLE_MEDIA_LOG,
MLS_TABLE_GEO_CITIES,
MLS_TABLE_GEO_ZIPCODES,
MLS_TABLE_MANUAL_PROPERTIES,
);
foreach ($tables as $table) {
+57 -2
View File
@@ -5,12 +5,18 @@
* Cleans up old MLS image directories when disk space is low.
* Runs after sync to prevent disk from filling up.
*
* IMPORTANT: Only cleans the standard cache directory (mls-listings).
* The persistent cache directory (mls-listings-persistent) is NEVER touched.
* HomeProz listing images are stored in persistent cache and preserved
* even after listings are sold or removed from MLS.
*
* Configuration (wp-config.php):
* - MLS_GC_DISK_THRESHOLD: Minimum free disk space in bytes before cleanup triggers
* Example: define('MLS_GC_DISK_THRESHOLD', 5 * 1024 * 1024 * 1024); // 5GB
*
* Behavior:
* - Only runs if MLS_GC_DISK_THRESHOLD is defined
* - Only cleans standard cache (mls-listings), never persistent cache
* - Skips directories modified within the last 24 hours
* - Deletes oldest directories first
* - Stops when free space >= 5GB or 2GB deleted per run
@@ -42,13 +48,20 @@ class MLS_Garbage_Collector {
*/
private $logger;
/**
* Database instance
*/
private $db;
/**
* Constructor
*
* @param MLS_Logger $logger Logger instance
* @param MLS_DB|null $db Database instance (optional for backwards compatibility)
*/
public function __construct(MLS_Logger $logger) {
public function __construct(MLS_Logger $logger, MLS_DB $db = null) {
$this->logger = $logger;
$this->db = $db;
}
/**
@@ -70,7 +83,11 @@ class MLS_Garbage_Collector {
}
/**
* Get the MLS images upload directory
* Get the MLS images upload directory (standard cache only)
*
* Returns the standard cache directory that is subject to garbage collection.
* The persistent cache (mls-listings-persistent) is intentionally excluded
* to preserve HomeProz listing images indefinitely.
*
* @return string Absolute path to MLS images directory
*/
@@ -324,6 +341,10 @@ class MLS_Garbage_Collector {
$deleted_bytes += $size;
$deleted_count++;
// Reset download_status to 'pending' for this listing's media
// so images can be re-downloaded on demand later
$this->reset_media_download_status($listing_key);
$this->logger->info('Garbage collection deleted directory', array(
'listing_key' => $listing_key,
'size' => $size,
@@ -366,6 +387,40 @@ class MLS_Garbage_Collector {
return $result;
}
/**
* Reset download_status to 'pending' for a listing's media records
*
* Called after deleting cached files so images can be re-downloaded on demand.
*
* @param string $listing_key Listing key
*/
private function reset_media_download_status($listing_key) {
global $wpdb;
// Get the media table name
$media_table = $this->db ? $this->db->media_table() : $wpdb->prefix . 'mls_media';
$updated = $wpdb->update(
$media_table,
array(
'download_status' => 'pending',
'local_path' => null,
'local_url' => null,
'downloaded_at' => null,
),
array('listing_key' => $listing_key),
array('%s', null, null, null),
array('%s')
);
if ($updated > 0) {
$this->logger->debug('Reset media download status for garbage collected listing', array(
'listing_key' => $listing_key,
'records_updated' => $updated,
));
}
}
/**
* Clean up empty prefix directories
*/
@@ -0,0 +1,264 @@
<?php
/**
* Geocoder class for address lookup
*
* Uses Nominatim (OpenStreetMap) for free geocoding.
* Results are cached in transients to minimize API calls.
*/
if (!defined('ABSPATH')) {
exit;
}
class MLS_Geocoder {
/**
* Cache expiration in seconds (30 days)
*/
const CACHE_EXPIRATION = 2592000;
/**
* Nominatim API endpoint
*/
const API_URL = 'https://nominatim.openstreetmap.org/search';
/**
* Geocode an address string
*
* @param string $address Full address string
* @return array|null Geocoding result or null on failure
*/
public function geocode($address) {
if (empty($address)) {
return null;
}
// Normalize address for cache key
$cache_key = 'mls_geo_' . md5(strtolower(trim($address)));
// Check cache
$cached = get_transient($cache_key);
if ($cached !== false) {
return $cached;
}
// Make API request
$result = $this->fetch_geocode($address);
if ($result) {
// Cache the result
set_transient($cache_key, $result, self::CACHE_EXPIRATION);
}
return $result;
}
/**
* Fetch geocoding data from Nominatim
*
* @param string $address
* @return array|null
*/
private function fetch_geocode($address) {
// Build request URL
$url = add_query_arg(array(
'q' => $address,
'format' => 'json',
'addressdetails' => 1,
'limit' => 1,
'countrycodes' => 'us',
), self::API_URL);
// Make request with proper User-Agent (required by Nominatim)
$response = wp_remote_get($url, array(
'timeout' => 10,
'headers' => array(
'User-Agent' => 'HomeProz WordPress MLS Plugin/1.0 (contact@homeproz.com)',
),
));
if (is_wp_error($response)) {
return null;
}
$body = wp_remote_retrieve_body($response);
$data = json_decode($body, true);
if (empty($data) || !is_array($data) || empty($data[0])) {
return null;
}
$result = $data[0];
$addr = $result['address'] ?? array();
// Extract components
return array(
'latitude' => isset($result['lat']) ? (float) $result['lat'] : null,
'longitude' => isset($result['lon']) ? (float) $result['lon'] : null,
'city' => $this->extract_city($addr),
'state' => $addr['state'] ?? null,
'state_code' => $this->get_state_code($addr['state'] ?? ''),
'postal_code' => $addr['postcode'] ?? null,
'county' => isset($addr['county']) ? str_replace(' County', '', $addr['county']) : null,
'country' => $addr['country'] ?? null,
'display_name' => $result['display_name'] ?? null,
);
}
/**
* Extract city from address components
*
* Nominatim may return city in different fields depending on location type
*
* @param array $addr Address components
* @return string|null
*/
private function extract_city($addr) {
// Try different fields where city might be
$city_fields = array('city', 'town', 'village', 'municipality', 'hamlet');
foreach ($city_fields as $field) {
if (!empty($addr[$field])) {
return $addr[$field];
}
}
return null;
}
/**
* Get state code from state name
*
* @param string $state_name Full state name
* @return string|null Two-letter state code
*/
private function get_state_code($state_name) {
$states = array(
'Alabama' => 'AL',
'Alaska' => 'AK',
'Arizona' => 'AZ',
'Arkansas' => 'AR',
'California' => 'CA',
'Colorado' => 'CO',
'Connecticut' => 'CT',
'Delaware' => 'DE',
'Florida' => 'FL',
'Georgia' => 'GA',
'Hawaii' => 'HI',
'Idaho' => 'ID',
'Illinois' => 'IL',
'Indiana' => 'IN',
'Iowa' => 'IA',
'Kansas' => 'KS',
'Kentucky' => 'KY',
'Louisiana' => 'LA',
'Maine' => 'ME',
'Maryland' => 'MD',
'Massachusetts' => 'MA',
'Michigan' => 'MI',
'Minnesota' => 'MN',
'Mississippi' => 'MS',
'Missouri' => 'MO',
'Montana' => 'MT',
'Nebraska' => 'NE',
'Nevada' => 'NV',
'New Hampshire' => 'NH',
'New Jersey' => 'NJ',
'New Mexico' => 'NM',
'New York' => 'NY',
'North Carolina' => 'NC',
'North Dakota' => 'ND',
'Ohio' => 'OH',
'Oklahoma' => 'OK',
'Oregon' => 'OR',
'Pennsylvania' => 'PA',
'Rhode Island' => 'RI',
'South Carolina' => 'SC',
'South Dakota' => 'SD',
'Tennessee' => 'TN',
'Texas' => 'TX',
'Utah' => 'UT',
'Vermont' => 'VT',
'Virginia' => 'VA',
'Washington' => 'WA',
'West Virginia' => 'WV',
'Wisconsin' => 'WI',
'Wyoming' => 'WY',
);
// If already a code, return as-is
if (strlen($state_name) === 2) {
return strtoupper($state_name);
}
return $states[$state_name] ?? null;
}
/**
* Reverse geocode coordinates to address
*
* @param float $lat Latitude
* @param float $lng Longitude
* @return array|null Address components or null on failure
*/
public function reverse_geocode($lat, $lng) {
$cache_key = 'mls_rgeo_' . md5($lat . '_' . $lng);
$cached = get_transient($cache_key);
if ($cached !== false) {
return $cached;
}
$url = add_query_arg(array(
'lat' => $lat,
'lon' => $lng,
'format' => 'json',
'addressdetails' => 1,
), 'https://nominatim.openstreetmap.org/reverse');
$response = wp_remote_get($url, array(
'timeout' => 10,
'headers' => array(
'User-Agent' => 'HomeProz WordPress MLS Plugin/1.0 (contact@homeproz.com)',
),
));
if (is_wp_error($response)) {
return null;
}
$body = wp_remote_retrieve_body($response);
$data = json_decode($body, true);
if (empty($data) || !isset($data['address'])) {
return null;
}
$addr = $data['address'];
$result = array(
'latitude' => (float) $lat,
'longitude' => (float) $lng,
'city' => $this->extract_city($addr),
'state' => $addr['state'] ?? null,
'state_code' => $this->get_state_code($addr['state'] ?? ''),
'postal_code' => $addr['postcode'] ?? null,
'county' => isset($addr['county']) ? str_replace(' County', '', $addr['county']) : null,
'display_name' => $data['display_name'] ?? null,
);
set_transient($cache_key, $result, self::CACHE_EXPIRATION);
return $result;
}
/**
* Clear geocoding cache for an address
*
* @param string $address
*/
public function clear_cache($address) {
$cache_key = 'mls_geo_' . md5(strtolower(trim($address)));
delete_transient($cache_key);
}
}
@@ -133,6 +133,19 @@ class MLS_Image_Endpoint {
// Get the source image
$source_path = $this->get_source_image($listing_key, $index);
if (is_wp_error($source_path)) {
// Handle specific errors
if ($source_path->get_error_code() === 'rate_limited') {
$this->logger->warning('MLS Image: Rate limited by MLS Grid', array(
'listing_key' => $listing_key,
'index' => $index,
));
$this->send_429();
return;
}
$this->send_404();
return;
}
if (!$source_path) {
$this->logger->error('MLS Image: Source not found', array(
'listing_key' => $listing_key,
@@ -142,7 +155,7 @@ class MLS_Image_Endpoint {
return;
}
// Generate thumbnail
// Generate thumbnail from cached source
$result = $this->generate_thumbnail($source_path, $cached_path, $max_dimension);
if (!$result) {
// Fall back to serving original if conversion fails
@@ -156,10 +169,13 @@ class MLS_Image_Endpoint {
/**
* Get source image path, fetching from MLS if needed
*
* Source images are cached in the thumbnails directory (mls-thumbnails)
* alongside generated thumbnails so they don't get garbage collected.
*
* This method handles:
* 1. Returning cached local images if available
* 2. Checking if media URL has expired and refreshing if needed
* 3. Fetching images from MLS Grid on demand
* 1. Returning cached source from thumbnails directory
* 2. Falling back to media handler cache (mls-listings) if available
* 3. Fetching from MLS Grid on demand and caching source locally
*/
private function get_source_image($listing_key, $index) {
global $wpdb;
@@ -167,6 +183,12 @@ class MLS_Image_Endpoint {
$plugin = mls_plugin();
$db = $plugin->get_db();
// First check for cached source in thumbnails directory (won't be garbage collected)
$source_path = $this->get_cached_source_path($listing_key, $index);
if ($source_path) {
return $source_path;
}
// Get media record for this index
$media = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$db->media_table()}
@@ -180,24 +202,47 @@ class MLS_Image_Endpoint {
return null;
}
// Check if already cached locally
// Check if source exists in media handler cache (mls-listings directories)
// These may have been garbage collected, but check anyway
$found_file = null;
if ($media->local_path) {
$full_path = $this->media_handler->get_upload_dir() . '/' . $media->local_path;
if (file_exists($full_path)) {
return $full_path;
$filename = basename($media->local_path);
$webp_filename = preg_replace('/\.[^.]+$/', '.webp', $filename);
$found_file = $this->media_handler->find_cached_file($listing_key, $webp_filename);
if (!$found_file) {
$found_file = $this->media_handler->find_cached_file($listing_key, $filename);
}
}
// Check if the media URL has expired before trying to fetch
if (!$found_file) {
$extensions = array('webp', 'jpg', 'jpeg', 'png', 'gif');
foreach ($extensions as $ext) {
$pattern_file = $index . '.' . $ext;
$found_file = $this->media_handler->find_cached_file($listing_key, $pattern_file);
if ($found_file) {
break;
}
}
}
// If found in mls-listings, copy to thumbnails directory for future use
if ($found_file) {
$copied_path = $this->copy_source_to_cache($found_file['path'], $listing_key, $index);
if ($copied_path) {
return $copied_path;
}
return $found_file['path'];
}
// If media URL has expired, refresh the entire property on demand
if ($this->media_handler->is_url_expired($media->media_url)) {
$this->logger->debug('Media URL expired, refreshing', array(
$this->logger->debug('Media URL expired, attempting on-demand refresh', array(
'listing_key' => $listing_key,
'index' => $index,
));
// Refresh media URLs from API
if ($this->media_handler->refresh_media_urls($listing_key)) {
// Re-fetch the record with fresh URL
if ($this->refresh_property_on_demand($listing_key)) {
$media = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$db->media_table()}
WHERE listing_key = %s AND media_order = %d
@@ -206,37 +251,259 @@ class MLS_Image_Endpoint {
$index
));
if (!$media) {
if (!$media || $this->media_handler->is_url_expired($media->media_url)) {
return null;
}
} else {
return null;
}
}
// Fetch from MLS on demand
$url = $this->media_handler->get_image_url($media, true);
if (!$url) {
return null;
}
// Fetch from MLS and cache source directly to thumbnails directory
return $this->fetch_and_cache_source($media, $listing_key, $index);
}
// Re-fetch the record to get updated local_path
$media = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$db->media_table()}
WHERE listing_key = %s AND media_order = %d
LIMIT 1",
$listing_key,
$index
));
/**
* Get cached source path from thumbnails directory
*
* @param string $listing_key Listing key
* @param int $index Image index
* @return string|null Path if found, null otherwise
*/
private function get_cached_source_path($listing_key, $index) {
$cache_dir = $this->get_cache_dir($listing_key);
$extensions = array('webp', 'jpg', 'jpeg', 'png', 'gif');
if ($media && $media->local_path) {
$full_path = $this->media_handler->get_upload_dir() . '/' . $media->local_path;
if (file_exists($full_path)) {
return $full_path;
foreach ($extensions as $ext) {
$path = $cache_dir . '/' . $index . '-source.' . $ext;
if (file_exists($path)) {
return $path;
}
}
return null;
}
/**
* Copy source file to thumbnails cache directory
*
* @param string $source_path Original source path
* @param string $listing_key Listing key
* @param int $index Image index
* @return string|null New path if copied, null on failure
*/
private function copy_source_to_cache($source_path, $listing_key, $index) {
$cache_dir = $this->get_cache_dir($listing_key);
if (!file_exists($cache_dir)) {
wp_mkdir_p($cache_dir);
}
$ext = strtolower(pathinfo($source_path, PATHINFO_EXTENSION));
$dest_path = $cache_dir . '/' . $index . '-source.' . $ext;
if (copy($source_path, $dest_path)) {
return $dest_path;
}
return null;
}
/**
* Fetch image from MLS and cache source to thumbnails directory
*
* @param object $media Media record from database
* @param string $listing_key Listing key
* @param int $index Image index
* @return string|WP_Error Path to cached source, or error
*/
private function fetch_and_cache_source($media, $listing_key, $index) {
if (empty($media->media_url)) {
return null;
}
// Check rate limiter
$rate_limiter = mls_plugin()->get_rate_limiter();
if (!$rate_limiter->can_fetch_image()) {
$this->logger->warning('Daily data budget exhausted, skipping image fetch', array(
'listing_key' => $listing_key,
'index' => $index,
));
return null;
}
// Fetch image from MLS
$request_args = array('timeout' => 30);
if (defined('MLS_SKIP_SSL_VERIFY') && MLS_SKIP_SSL_VERIFY) {
$request_args['sslverify'] = false;
}
$response = wp_remote_get($media->media_url, $request_args);
if (is_wp_error($response)) {
$this->logger->warning('Source fetch failed', array(
'listing_key' => $listing_key,
'index' => $index,
'error' => $response->get_error_message(),
));
return null;
}
$status_code = wp_remote_retrieve_response_code($response);
if ($status_code === 429) {
return new WP_Error('rate_limited', 'MLS Grid rate limit exceeded', array('status' => 429));
}
if ($status_code !== 200) {
$this->logger->warning('Source fetch HTTP error', array(
'listing_key' => $listing_key,
'index' => $index,
'status' => $status_code,
));
return null;
}
$body = wp_remote_retrieve_body($response);
if (empty($body)) {
return null;
}
// Record bytes downloaded
$rate_limiter->record_data_transfer(strlen($body));
// Determine extension from content type
$content_type = wp_remote_retrieve_header($response, 'content-type');
$ext = $this->get_extension_from_content_type($content_type);
// Save to thumbnails cache directory
$cache_dir = $this->get_cache_dir($listing_key);
if (!file_exists($cache_dir)) {
wp_mkdir_p($cache_dir);
}
$source_path = $cache_dir . '/' . $index . '-source.' . $ext;
if (file_put_contents($source_path, $body) === false) {
$this->logger->error('Failed to write source file', array(
'path' => $source_path,
));
return null;
}
$this->logger->debug('Source fetched and cached', array(
'listing_key' => $listing_key,
'index' => $index,
'path' => $source_path,
'size' => strlen($body),
));
return $source_path;
}
/**
* Get file extension from content type
*
* @param string $content_type Content-Type header
* @return string Extension
*/
private function get_extension_from_content_type($content_type) {
$content_type = strtolower($content_type);
if (strpos($content_type, 'jpeg') !== false || strpos($content_type, 'jpg') !== false) {
return 'jpg';
} elseif (strpos($content_type, 'png') !== false) {
return 'png';
} elseif (strpos($content_type, 'gif') !== false) {
return 'gif';
} elseif (strpos($content_type, 'webp') !== false) {
return 'webp';
}
return 'jpg'; // Default
}
/**
* Refresh a property on demand when media URLs have expired
*
* Uses MySQL advisory lock to prevent multiple simultaneous refreshes
* of the same property. Includes a 4 second delay to respect API rate limits.
*
* @param string $listing_key Property listing key
* @return bool True if refresh succeeded, false otherwise
*/
private function refresh_property_on_demand($listing_key) {
global $wpdb;
// Get the listing_id for API lookup
$db = mls_plugin()->get_db();
$property = $wpdb->get_row($wpdb->prepare(
"SELECT listing_id FROM {$db->properties_table()} WHERE listing_key = %s",
$listing_key
));
if (!$property || empty($property->listing_id)) {
$this->logger->warning('Cannot refresh property: listing_id not found', array(
'listing_key' => $listing_key,
));
return false;
}
// Advisory lock to prevent concurrent refreshes of the same property
$lock_name = 'mls_property_refresh_' . $listing_key;
$lock_timeout = 0; // Non-blocking - return immediately if lock not available
$lock_acquired = $wpdb->get_var($wpdb->prepare(
"SELECT GET_LOCK(%s, %d)",
$lock_name,
$lock_timeout
));
if ($lock_acquired !== '1') {
// Another request is already refreshing this property
$this->logger->debug('Property refresh already in progress', array(
'listing_key' => $listing_key,
));
return false;
}
try {
// Fetch fresh property data from API
$api_client = mls_plugin()->get_api_client();
$property_data = $api_client->get_property_media($property->listing_id);
if (is_wp_error($property_data)) {
$this->logger->warning('Failed to refresh property from API', array(
'listing_key' => $listing_key,
'error' => $property_data->get_error_message(),
));
return false;
}
if (empty($property_data)) {
$this->logger->warning('Property not found in API', array(
'listing_key' => $listing_key,
));
return false;
}
// Update media records with fresh URLs
if (isset($property_data['Media']) && is_array($property_data['Media'])) {
$this->media_handler->sync_property_media($listing_key, $property_data['Media']);
$this->logger->info('Property media refreshed on demand', array(
'listing_key' => $listing_key,
'media_count' => count($property_data['Media']),
));
return true;
}
return false;
} finally {
// Always release the lock
$wpdb->query($wpdb->prepare("SELECT RELEASE_LOCK(%s)", $lock_name));
}
}
/**
* Get cached thumbnail path
*/
@@ -423,6 +690,16 @@ class MLS_Image_Endpoint {
exit;
}
/**
* Send 429 Too Many Requests response
*/
private function send_429() {
status_header(429);
header('Retry-After: 5');
nocache_headers();
exit;
}
/**
* Get URL for an MLS image
*
@@ -432,6 +709,25 @@ class MLS_Image_Endpoint {
* @return string Image URL
*/
public static function get_url($listing_key, $index = 1, $size = 'thumb') {
// Handle manual properties - return WordPress attachment URL directly
if (strpos($listing_key, 'MANUAL-') === 0) {
$post_id = (int) str_replace('MANUAL-', '', $listing_key);
if ($post_id) {
$gallery = get_field('gallery', $post_id);
if (!empty($gallery) && is_array($gallery)) {
// $index is 1-based, convert to 0-based array index
$idx = max(0, $index - 1);
if (isset($gallery[$idx])) {
// Use WordPress image size based on requested size
$wp_size = ($size === 'full') ? 'large' : 'medium_large';
$image_url = wp_get_attachment_image_url($gallery[$idx]['ID'], $wp_size);
return $image_url ?: $gallery[$idx]['url'];
}
}
}
return '';
}
$sig = self::generate_signature($listing_key);
return home_url("/mls-image/{$listing_key}/{$index}/{$size}/") . '?sig=' . $sig;
}
@@ -17,10 +17,15 @@ if (!defined('ABSPATH')) {
class MLS_Media_Handler {
/**
* Upload subdirectory for MLS media
* Upload subdirectory for MLS media (standard cache - subject to garbage collection)
*/
const UPLOAD_SUBDIR = 'mls-listings';
/**
* Upload subdirectory for persistent HomeProz media (never garbage collected)
*/
const PERSISTENT_SUBDIR = 'mls-listings-persistent';
/**
* Database instance
*/
@@ -161,7 +166,7 @@ class MLS_Media_Handler {
}
/**
* Get base upload directory for MLS media
* Get base upload directory for MLS media (standard cache)
*
* @return string Absolute path
*/
@@ -171,7 +176,7 @@ class MLS_Media_Handler {
}
/**
* Get base upload URL for MLS media
* Get base upload URL for MLS media (standard cache)
*
* @return string URL
*/
@@ -181,14 +186,113 @@ class MLS_Media_Handler {
}
/**
* Get storage directory for a specific listing
* Get base upload directory for persistent HomeProz media
*
* @param string $listing_key Listing key
* @return string Absolute path
*/
public function get_listing_dir($listing_key) {
public function get_persistent_upload_dir() {
$upload_dir = wp_upload_dir();
return $upload_dir['basedir'] . '/' . self::PERSISTENT_SUBDIR;
}
/**
* Get base upload URL for persistent HomeProz media
*
* @return string URL
*/
public function get_persistent_upload_url() {
$upload_dir = wp_upload_dir();
return $upload_dir['baseurl'] . '/' . self::PERSISTENT_SUBDIR;
}
/**
* Check if a listing is a HomeProz listing
*
* @param string $listing_key Listing key
* @return bool True if HomeProz listing
*/
public function is_homeproz_listing($listing_key) {
global $wpdb;
$is_homeproz = $wpdb->get_var($wpdb->prepare(
"SELECT is_homeproz FROM {$this->db->properties_table()} WHERE listing_key = %s",
$listing_key
));
return (bool) $is_homeproz;
}
/**
* Get storage directory for a specific listing
*
* HomeProz listings use the persistent cache directory.
* Other listings use the standard cache directory (subject to garbage collection).
*
* @param string $listing_key Listing key
* @param bool|null $is_homeproz Override HomeProz check (null = look up from DB)
* @return string Absolute path
*/
public function get_listing_dir($listing_key, $is_homeproz = null) {
$prefix = substr($listing_key, 0, 2);
return $this->get_upload_dir() . '/' . $prefix . '/' . $listing_key;
// Determine if HomeProz if not explicitly provided
if ($is_homeproz === null) {
$is_homeproz = $this->is_homeproz_listing($listing_key);
}
$base_dir = $is_homeproz ? $this->get_persistent_upload_dir() : $this->get_upload_dir();
return $base_dir . '/' . $prefix . '/' . $listing_key;
}
/**
* Get the base URL for a listing's media
*
* @param string $listing_key Listing key
* @param bool|null $is_homeproz Override HomeProz check (null = look up from DB)
* @return string URL
*/
public function get_listing_url($listing_key, $is_homeproz = null) {
$prefix = substr($listing_key, 0, 2);
if ($is_homeproz === null) {
$is_homeproz = $this->is_homeproz_listing($listing_key);
}
$base_url = $is_homeproz ? $this->get_persistent_upload_url() : $this->get_upload_url();
return $base_url . '/' . $prefix . '/' . $listing_key;
}
/**
* Find existing cached file for a listing, checking both persistent and standard cache
*
* @param string $listing_key Listing key
* @param string $filename Filename to find
* @return array|null ['path' => absolute path, 'url' => url, 'persistent' => bool] or null
*/
public function find_cached_file($listing_key, $filename) {
$prefix = substr($listing_key, 0, 2);
// Check persistent cache first (HomeProz listings)
$persistent_path = $this->get_persistent_upload_dir() . '/' . $prefix . '/' . $listing_key . '/' . $filename;
if (file_exists($persistent_path)) {
return array(
'path' => $persistent_path,
'url' => $this->get_persistent_upload_url() . '/' . $prefix . '/' . $listing_key . '/' . $filename,
'persistent' => true,
);
}
// Check standard cache
$standard_path = $this->get_upload_dir() . '/' . $prefix . '/' . $listing_key . '/' . $filename;
if (file_exists($standard_path)) {
return array(
'path' => $standard_path,
'url' => $this->get_upload_url() . '/' . $prefix . '/' . $listing_key . '/' . $filename,
'persistent' => false,
);
}
return null;
}
/**
@@ -241,15 +345,40 @@ class MLS_Media_Handler {
);
if ($existing) {
// Check if URL changed - if so, clear cached file
if ($existing->media_url !== ($media['MediaURL'] ?? null) && $existing->local_path) {
$file_path = $this->get_upload_dir() . '/' . $existing->local_path;
if (file_exists($file_path)) {
unlink($file_path);
// Check if URL changed
$url_changed = $existing->media_url !== ($media['MediaURL'] ?? null);
if ($url_changed && $existing->local_path) {
// Check if this is a HomeProz listing
$is_homeproz = $this->is_homeproz_listing($listing_key);
if ($is_homeproz) {
// HomeProz: Keep existing cached file, don't reset local_path
// The existing image continues to work even if MLS URL expires
// Only replace if a new download succeeds later
} else {
// Non-HomeProz: Delete old cached files to save space
$filename = basename($existing->local_path);
$prefix = substr($listing_key, 0, 2);
// Delete from standard cache
$standard_path = $this->get_upload_dir() . '/' . $prefix . '/' . $listing_key . '/' . $filename;
if (file_exists($standard_path)) {
unlink($standard_path);
}
// Also delete WebP versions
$webp_filename = preg_replace('/\.[^.]+$/', '.webp', $filename);
$webp_standard = $this->get_upload_dir() . '/' . $prefix . '/' . $listing_key . '/' . $webp_filename;
if (file_exists($webp_standard)) {
unlink($webp_standard);
}
$data['local_path'] = null;
$data['local_url'] = null;
$data['downloaded_at'] = null;
$data['download_status'] = 'pending';
}
$data['local_path'] = null;
$data['local_url'] = null;
$data['downloaded_at'] = null;
}
$wpdb->update(
@@ -269,7 +398,7 @@ class MLS_Media_Handler {
}
}
// Delete orphaned media records
// Delete orphaned media records and their files
if (!empty($received_keys)) {
$placeholders = implode(',', array_fill(0, count($received_keys), '%s'));
$values = array_merge(array($listing_key), $received_keys);
@@ -282,21 +411,220 @@ class MLS_Media_Handler {
foreach ($orphaned as $record) {
if ($record->local_path) {
$file_path = $this->get_upload_dir() . '/' . $record->local_path;
if (file_exists($file_path)) {
unlink($file_path);
$filename = basename($record->local_path);
$prefix = substr($listing_key, 0, 2);
// Delete from both directories
$paths_to_delete = array(
$this->get_upload_dir() . '/' . $prefix . '/' . $listing_key . '/' . $filename,
$this->get_persistent_upload_dir() . '/' . $prefix . '/' . $listing_key . '/' . $filename,
);
// Also include WebP versions
$webp_filename = preg_replace('/\.[^.]+$/', '.webp', $filename);
$paths_to_delete[] = $this->get_upload_dir() . '/' . $prefix . '/' . $listing_key . '/' . $webp_filename;
$paths_to_delete[] = $this->get_persistent_upload_dir() . '/' . $prefix . '/' . $listing_key . '/' . $webp_filename;
foreach ($paths_to_delete as $path) {
if (file_exists($path)) {
unlink($path);
}
}
}
$wpdb->delete($this->db->media_table(), array('id' => $record->id));
}
}
// Update property's media_expires_at with earliest expiration from all media URLs
$this->update_property_media_expiration($listing_key);
return array('stored' => $stored, 'skipped' => $skipped);
}
/**
* Download and cache all images for a HomeProz listing
*
* Called during sync to immediately cache images for HomeProz listings
* so they're available even after the listing is sold and removed from MLS.
*
* @param string $listing_key Listing key
* @param callable|null $progress_callback Progress callback
* @param int $delay_seconds Delay between each image download (default 10s to respect API limits)
* @return array Stats with 'downloaded', 'skipped', and 'errors' counts
*/
public function download_homeproz_images($listing_key, $progress_callback = null, $delay_seconds = 10) {
global $wpdb;
$stats = array('downloaded' => 0, 'skipped' => 0, 'errors' => 0);
// Get all media records that haven't been downloaded yet
$media_records = $wpdb->get_results($wpdb->prepare(
"SELECT * FROM {$this->db->media_table()}
WHERE listing_key = %s AND media_url IS NOT NULL AND download_status = 'pending'
ORDER BY media_order ASC",
$listing_key
));
if (empty($media_records)) {
return $stats;
}
$total_records = count($media_records);
$current = 0;
foreach ($media_records as $media) {
$current++;
// Check if already cached (check both directories)
// Try known local_path first, then search by media_order pattern
$found_file = null;
if ($media->local_path) {
$filename = basename($media->local_path);
// Check for WebP version first
$webp_filename = preg_replace('/\.[^.]+$/', '.webp', $filename);
$found_file = $this->find_cached_file($listing_key, $webp_filename);
// Check for original file
if (!$found_file) {
$found_file = $this->find_cached_file($listing_key, $filename);
}
}
// If no local_path or file not found, search by media_order pattern
if (!$found_file) {
$extensions = array('webp', 'jpg', 'jpeg', 'png', 'gif');
foreach ($extensions as $ext) {
$pattern_file = $media->media_order . '.' . $ext;
$found_file = $this->find_cached_file($listing_key, $pattern_file);
if ($found_file) {
break;
}
}
}
// If file exists on disk, update database and skip download
if ($found_file) {
// Update database to reflect the cached file
$prefix = substr($listing_key, 0, 2);
$filename = basename($found_file['path']);
$relative_path = $prefix . '/' . $listing_key . '/' . $filename;
$wpdb->update(
$this->db->media_table(),
array(
'local_path' => $relative_path,
'local_url' => $found_file['url'],
'download_status' => 'completed',
'downloaded_at' => current_time('mysql'),
),
array('id' => $media->id)
);
$stats['skipped']++;
if ($progress_callback) {
call_user_func($progress_callback, 'media_skipped', array('media_key' => $media->media_key));
}
continue;
}
// Download the image
$url = $this->fetch_and_cache($media);
if ($url) {
$stats['downloaded']++;
if ($progress_callback) {
call_user_func($progress_callback, 'media_downloaded', array('media_key' => $media->media_key));
}
// Rate limit: delay between image downloads to respect MLS API limits
// Only delay if there are more images to download
if ($delay_seconds > 0 && $current < $total_records) {
sleep($delay_seconds);
}
} else {
$stats['errors']++;
if ($progress_callback) {
call_user_func($progress_callback, 'media_error', array('media_key' => $media->media_key));
}
}
}
return $stats;
}
/**
* Update a property's media_expires_at field based on its media URLs
*
* Finds the earliest expiration timestamp from all media URLs
* and sets it on the property record.
*
* @param string $listing_key Listing key
*/
public function update_property_media_expiration($listing_key) {
global $wpdb;
// Get all media URLs for this property
$media_urls = $wpdb->get_col($wpdb->prepare(
"SELECT media_url FROM {$this->db->media_table()}
WHERE listing_key = %s AND media_url IS NOT NULL",
$listing_key
));
if (empty($media_urls)) {
return;
}
// Find the earliest expiration timestamp
$earliest_expires = null;
foreach ($media_urls as $url) {
$expires = $this->extract_url_expiration($url);
if ($expires !== null) {
if ($earliest_expires === null || $expires < $earliest_expires) {
$earliest_expires = $expires;
}
}
}
// Update the property record
if ($earliest_expires !== null) {
$expires_at = gmdate('Y-m-d H:i:s', $earliest_expires);
$wpdb->update(
$this->db->properties_table(),
array('media_expires_at' => $expires_at),
array('listing_key' => $listing_key),
array('%s'),
array('%s')
);
}
}
/**
* Extract expiration timestamp from a media URL
*
* MLS Grid media URLs contain an 'expires' parameter with Unix timestamp.
*
* @param string $media_url The media URL
* @return int|null Unix timestamp or null if not found
*/
public function extract_url_expiration($media_url) {
if (empty($media_url)) {
return null;
}
if (preg_match('/expires=(\d+)/', $media_url, $matches)) {
return (int) $matches[1];
}
return null;
}
/**
* Get image URL for a media record, fetching on-demand if needed
*
* Checks both persistent (HomeProz) and standard cache directories.
*
* @param int|object $media Media ID or media record object
* @param bool $fetch_if_missing Whether to fetch if not cached
* @return string|null Local URL or null
@@ -316,24 +644,29 @@ class MLS_Media_Handler {
return null;
}
// Already cached - check for WebP version first
if ($media->local_url && $media->local_path) {
$file_path = $this->get_upload_dir() . '/' . $media->local_path;
$actual_path = $this->prefer_webp_path($file_path);
if (file_exists($actual_path)) {
// If WebP version exists, return WebP URL
if ($actual_path !== $file_path) {
$webp_path = preg_replace('/\.[^.]+$/', '.webp', $media->local_path);
return $this->get_upload_url() . '/' . $webp_path;
}
return $media->local_url;
// Check for cached file in both directories
if ($media->local_path) {
$filename = basename($media->local_path);
// Check for WebP version first
$webp_filename = preg_replace('/\.[^.]+$/', '.webp', $filename);
$webp_found = $this->find_cached_file($media->listing_key, $webp_filename);
if ($webp_found) {
return $webp_found['url'];
}
// Check for original file
$found = $this->find_cached_file($media->listing_key, $filename);
if ($found) {
return $found['url'];
}
}
// Fetch on demand
if ($fetch_if_missing && $media->media_url) {
$result = $this->fetch_and_cache($media);
if ($result) {
// Propagate WP_Error (e.g., rate limiting) or return URL
if (is_wp_error($result) || $result) {
return $result;
}
}
@@ -344,6 +677,8 @@ class MLS_Media_Handler {
/**
* Get primary image URL for a listing (on-demand)
*
* Checks both persistent (HomeProz) and standard cache directories.
*
* @param string $listing_key Listing key
* @param bool $fetch_if_missing Whether to fetch if not cached
* @return string|null Image URL
@@ -360,16 +695,20 @@ class MLS_Media_Handler {
$listing_key
));
if ($cached) {
$file_path = $this->get_upload_dir() . '/' . $cached->local_path;
$actual_path = $this->prefer_webp_path($file_path);
if (file_exists($actual_path)) {
// If WebP version exists, return WebP URL
if ($actual_path !== $file_path) {
$webp_path = preg_replace('/\.[^.]+$/', '.webp', $cached->local_path);
return $this->get_upload_url() . '/' . $webp_path;
}
return $cached->local_url;
if ($cached && $cached->local_path) {
$filename = basename($cached->local_path);
// Check for WebP version first
$webp_filename = preg_replace('/\.[^.]+$/', '.webp', $filename);
$webp_found = $this->find_cached_file($listing_key, $webp_filename);
if ($webp_found) {
return $webp_found['url'];
}
// Check for original file
$found = $this->find_cached_file($listing_key, $filename);
if ($found) {
return $found['url'];
}
}
@@ -386,17 +725,21 @@ class MLS_Media_Handler {
return null;
}
// If already cached and file exists, return it - check for WebP first
if ($media->local_url && $media->local_path) {
$file_path = $this->get_upload_dir() . '/' . $media->local_path;
$actual_path = $this->prefer_webp_path($file_path);
if (file_exists($actual_path)) {
// If WebP version exists, return WebP URL
if ($actual_path !== $file_path) {
$webp_path = preg_replace('/\.[^.]+$/', '.webp', $media->local_path);
return $this->get_upload_url() . '/' . $webp_path;
}
return $media->local_url;
// Check for cached file in both directories
if ($media->local_path) {
$filename = basename($media->local_path);
// Check for WebP version first
$webp_filename = preg_replace('/\.[^.]+$/', '.webp', $filename);
$webp_found = $this->find_cached_file($listing_key, $webp_filename);
if ($webp_found) {
return $webp_found['url'];
}
// Check for original file
$found = $this->find_cached_file($listing_key, $filename);
if ($found) {
return $found['url'];
}
}
@@ -411,6 +754,8 @@ class MLS_Media_Handler {
/**
* Get all images for a listing (on-demand for first N)
*
* Checks both persistent (HomeProz) and standard cache directories.
*
* @param string $listing_key Listing key
* @param int $fetch_limit Max images to fetch on-demand (0 = none)
* @return array Media records with local_url populated where available
@@ -431,16 +776,22 @@ class MLS_Media_Handler {
$fetched = 0;
foreach ($media as &$item) {
// Check if cached and file exists - prefer WebP version
if ($item->local_url && $item->local_path) {
$file_path = $this->get_upload_dir() . '/' . $item->local_path;
$actual_path = $this->prefer_webp_path($file_path);
if (file_exists($actual_path)) {
// If WebP version exists, update the URL
if ($actual_path !== $file_path) {
$webp_path = preg_replace('/\.[^.]+$/', '.webp', $item->local_path);
$item->local_url = $this->get_upload_url() . '/' . $webp_path;
}
// Check for cached file in both directories - prefer WebP version
if ($item->local_path) {
$filename = basename($item->local_path);
// Check for WebP version first
$webp_filename = preg_replace('/\.[^.]+$/', '.webp', $filename);
$webp_found = $this->find_cached_file($listing_key, $webp_filename);
if ($webp_found) {
$item->local_url = $webp_found['url'];
continue;
}
// Check for original file
$found = $this->find_cached_file($listing_key, $filename);
if ($found) {
$item->local_url = $found['url'];
continue;
}
}
@@ -509,23 +860,97 @@ class MLS_Media_Handler {
}
try {
// Determine if this is a HomeProz listing (determines cache location)
$is_homeproz = $this->is_homeproz_listing($media->listing_key);
// Re-check if image was cached while we waited for lock
$updated_media = $wpdb->get_row($wpdb->prepare(
"SELECT local_path, local_url FROM {$this->db->media_table()} WHERE id = %d",
$media->id
));
// Check for existing file - first by local_path, then by media_order pattern
$found_file = null;
if ($updated_media && $updated_media->local_path) {
$file_path = $this->get_upload_dir() . '/' . $updated_media->local_path;
$actual_path = $this->prefer_webp_path($file_path);
if (file_exists($actual_path)) {
// Another request cached it while we waited
// If WebP version exists, return WebP URL
if ($actual_path !== $file_path) {
$webp_path = preg_replace('/\.[^.]+$/', '.webp', $updated_media->local_path);
return $this->get_upload_url() . '/' . $webp_path;
// Check both cache directories for existing file
$filename = basename($updated_media->local_path);
// Check for WebP version first
$webp_filename = preg_replace('/\.[^.]+$/', '.webp', $filename);
$found_file = $this->find_cached_file($media->listing_key, $webp_filename);
if (!$found_file) {
$found_file = $this->find_cached_file($media->listing_key, $filename);
}
}
// If no local_path or file not found, search by media_order pattern
if (!$found_file) {
$extensions = array('webp', 'jpg', 'jpeg', 'png', 'gif');
foreach ($extensions as $ext) {
$pattern_file = $media->media_order . '.' . $ext;
$found_file = $this->find_cached_file($media->listing_key, $pattern_file);
if ($found_file) {
break;
}
return $updated_media->local_url;
}
}
// If file exists on disk, update database and return URL
if ($found_file) {
$prefix = substr($media->listing_key, 0, 2);
$filename = basename($found_file['path']);
$relative_path = $prefix . '/' . $media->listing_key . '/' . $filename;
$wpdb->update(
$this->db->media_table(),
array(
'local_path' => $relative_path,
'local_url' => $found_file['url'],
'download_status' => 'completed',
'downloaded_at' => current_time('mysql'),
),
array('id' => $media->id)
);
return $found_file['url'];
}
// If the media URL has expired, refresh property URLs from the API.
// Use a property-level lock so concurrent fetches share one refresh.
if ($this->is_url_expired($media->media_url)) {
$refresh_lock = 'mls_url_refresh_' . $media->listing_key;
$refresh_acquired = $wpdb->get_var($wpdb->prepare(
"SELECT GET_LOCK(%s, %d)",
$refresh_lock,
15
));
try {
if ($refresh_acquired === '1') {
// Re-check after lock — another process may have refreshed
$latest = $wpdb->get_row($wpdb->prepare(
"SELECT media_url FROM {$this->db->media_table()} WHERE id = %d",
$media->id
));
if (!$latest || $this->is_url_expired($latest->media_url)) {
$this->refresh_media_urls($media->listing_key);
}
}
$media = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$this->db->media_table()} WHERE id = %d",
$media->id
));
} finally {
if ($refresh_acquired === '1') {
$wpdb->query($wpdb->prepare("SELECT RELEASE_LOCK(%s)", $refresh_lock));
}
}
if (!$media || empty($media->media_url) || $this->is_url_expired($media->media_url)) {
return null;
}
}
@@ -558,6 +983,10 @@ class MLS_Media_Handler {
'media_key' => $media->media_key,
'status' => $status_code,
));
// Return error code for rate limiting so caller can handle appropriately
if ($status_code === 429) {
return new WP_Error('rate_limited', 'MLS Grid rate limit exceeded', array('status' => 429));
}
return null;
}
@@ -574,8 +1003,8 @@ class MLS_Media_Handler {
$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);
// Create directory (HomeProz listings go to persistent cache)
$listing_dir = $this->get_listing_dir($media->listing_key, $is_homeproz);
if (!file_exists($listing_dir)) {
wp_mkdir_p($listing_dir);
}
@@ -600,10 +1029,11 @@ class MLS_Media_Handler {
$content_type = 'image/webp';
}
// Update database
// Update database with correct URL based on cache location
$prefix = substr($media->listing_key, 0, 2);
$relative_path = $prefix . '/' . $media->listing_key . '/' . $filename;
$local_url = $this->get_upload_url() . '/' . $relative_path;
$base_url = $is_homeproz ? $this->get_persistent_upload_url() : $this->get_upload_url();
$local_url = $base_url . '/' . $relative_path;
// Get actual file size after any conversion
$final_size = filesize($file_path);
@@ -616,6 +1046,7 @@ class MLS_Media_Handler {
'file_size' => $final_size,
'mime_type' => $content_type,
'downloaded_at' => current_time('mysql'),
'download_status' => 'completed',
),
array('id' => $media->id)
);
@@ -668,15 +1099,25 @@ class MLS_Media_Handler {
/**
* Delete all media for a property
*
* Deletes from both persistent and standard cache directories.
*
* @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);
$prefix = substr($listing_key, 0, 2);
// Delete from persistent cache directory
$persistent_dir = $this->get_persistent_upload_dir() . '/' . $prefix . '/' . $listing_key;
if (file_exists($persistent_dir)) {
$this->recursive_delete($persistent_dir);
}
// Delete from standard cache directory
$standard_dir = $this->get_upload_dir() . '/' . $prefix . '/' . $listing_key;
if (file_exists($standard_dir)) {
$this->recursive_delete($standard_dir);
}
// Delete records
@@ -811,43 +1252,52 @@ class MLS_Media_Handler {
/**
* Clean up orphaned media files (files without database records)
*
* Checks both standard and persistent cache directories.
*
* @return int Number of directories deleted
*/
public function cleanup_orphaned_files() {
global $wpdb;
$deleted = 0;
$base_dir = $this->get_upload_dir();
if (!is_dir($base_dir)) {
return 0;
}
// Check both cache directories
$directories = array(
$this->get_upload_dir(),
$this->get_persistent_upload_dir(),
);
foreach (scandir($base_dir) as $prefix) {
if ($prefix === '.' || $prefix === '..' || !is_dir($base_dir . '/' . $prefix)) {
foreach ($directories as $base_dir) {
if (!is_dir($base_dir)) {
continue;
}
$prefix_dir = $base_dir . '/' . $prefix;
foreach (scandir($prefix_dir) as $listing_key) {
if ($listing_key === '.' || $listing_key === '..') {
foreach (scandir($base_dir) as $prefix) {
if ($prefix === '.' || $prefix === '..' || !is_dir($base_dir . '/' . $prefix)) {
continue;
}
$listing_dir = $prefix_dir . '/' . $listing_key;
if (!is_dir($listing_dir)) {
continue;
}
$prefix_dir = $base_dir . '/' . $prefix;
$exists = $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM {$this->db->properties_table()} WHERE listing_key = %s",
$listing_key
));
foreach (scandir($prefix_dir) as $listing_key) {
if ($listing_key === '.' || $listing_key === '..') {
continue;
}
if (!$exists) {
$this->recursive_delete($listing_dir);
$deleted++;
$listing_dir = $prefix_dir . '/' . $listing_key;
if (!is_dir($listing_dir)) {
continue;
}
$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++;
}
}
}
}
@@ -50,6 +50,25 @@ class MLS_Query {
return "(street_number IS NULL OR (street_number != 'TBD' AND street_number NOT LIKE 'TBD %'))";
}
/**
* Build a WHERE clause for property_type, supporting comma-separated values.
*
* @param string $property_type Single type or comma-separated types
* @param array &$where WHERE clause fragments
* @param array &$values Prepared statement values
*/
private function build_property_type_clause($property_type, &$where, &$values) {
$types = array_filter(array_map('trim', explode(',', $property_type)));
if (count($types) === 1) {
$where[] = 'property_type = %s';
$values[] = $types[0];
} elseif (count($types) > 1) {
$placeholders = implode(',', array_fill(0, count($types), '%s'));
$where[] = "property_type IN ({$placeholders})";
$values = array_merge($values, $types);
}
}
/**
* Get coordinates for a zip code from geo table
*
@@ -246,13 +265,20 @@ class MLS_Query {
}
if ($args['status']) {
$where[] = 'standard_status = %s';
$values[] = $args['status'];
if (is_array($args['status'])) {
// Multiple statuses - use IN clause
$placeholders = implode(',', array_fill(0, count($args['status']), '%s'));
$where[] = "standard_status IN ({$placeholders})";
$values = array_merge($values, $args['status']);
} else {
// Single status
$where[] = 'standard_status = %s';
$values[] = $args['status'];
}
}
if ($args['property_type']) {
$where[] = 'property_type = %s';
$values[] = $args['property_type'];
$this->build_property_type_clause($args['property_type'], $where, $values);
}
// City and postal_code are mutually exclusive - city takes priority
@@ -503,17 +529,16 @@ class MLS_Query {
}
$sql = "SELECT * FROM {$table}";
// Don't filter by status here - we filter AFTER normalization
// because status is derived from MLS for linked properties
$where = array("standard_status != 'Withdrawn'");
$values = array();
if ($args['status']) {
$where[] = 'standard_status = %s';
$values[] = $args['status'];
}
// Store requested status for post-normalization filtering
$requested_status = $args['status'];
if ($args['property_type']) {
$where[] = 'property_type = %s';
$values[] = $args['property_type'];
$this->build_property_type_clause($args['property_type'], $where, $values);
}
if ($args['city']) {
@@ -566,6 +591,31 @@ class MLS_Query {
$values[] = $args['listing_id'];
}
// Filter by agent MLS ID - find agent post with this MLS ID
if (!empty($args['agent_mls_id'])) {
$agent_query = new WP_Query(array(
'post_type' => 'agent',
'posts_per_page' => 1,
'post_status' => 'publish',
'fields' => 'ids',
'meta_query' => array(
array(
'key' => 'agent_mls_id',
'value' => $args['agent_mls_id'],
'compare' => '=',
),
),
));
if ($agent_query->have_posts()) {
$where[] = 'list_agent_post_id = %d';
$values[] = $agent_query->posts[0];
} else {
// No agent found with this MLS ID - return empty for manual properties
return array();
}
wp_reset_postdata();
}
if ($args['search']) {
$search_term = '%' . $wpdb->esc_like($args['search']) . '%';
$where[] = '(full_address LIKE %s OR city LIKE %s OR public_remarks LIKE %s OR listing_id LIKE %s)';
@@ -616,10 +666,22 @@ class MLS_Query {
}
// Normalize results to match MLS schema
// This fetches the real status from MLS for linked properties
foreach ($results as $key => $property) {
$results[$key] = $this->normalize_manual_property($property);
}
// Filter by status AFTER normalization (status is now MLS-derived)
if ($requested_status) {
$results = array_filter($results, function($property) use ($requested_status) {
if (is_array($requested_status)) {
return in_array($property->standard_status, $requested_status);
}
return $property->standard_status === $requested_status;
});
$results = array_values($results); // Re-index array
}
return $results;
}
@@ -668,9 +730,99 @@ class MLS_Query {
}
}
// Check if media records need refresh (for single property view)
if ($property) {
$this->ensure_media_records($property);
}
return $property;
}
/**
* Ensure media records exist and are fresh for a property
*
* Checks if:
* 1. Media record count < photos_count (missing media records)
* 2. Any media URLs are expired
*
* If either condition is true, refreshes media from the API.
*
* @param object $property Property object with listing_key, listing_id, photos_count
*/
private function ensure_media_records($property) {
global $wpdb;
// Skip for spider/bot requests to avoid unnecessary API calls
if (function_exists('homeproz_is_spider') && homeproz_is_spider()) {
return;
}
// Skip if no photos or no listing_id (can't fetch from API without it)
if (empty($property->photos_count) || $property->photos_count <= 0) {
return;
}
if (empty($property->listing_id)) {
return;
}
$media_table = $this->db->media_table();
$listing_key = $property->listing_key;
// Single query to get media count and earliest expiry
$media_stats = $wpdb->get_row($wpdb->prepare(
"SELECT COUNT(*) as media_count, MIN(url_expires_at) as earliest_expiry
FROM {$media_table}
WHERE listing_key = %s",
$listing_key
));
$media_count = (int) ($media_stats ? $media_stats->media_count : 0);
$earliest_expiry = $media_stats ? $media_stats->earliest_expiry : null;
// Check 1: Do we have fewer media records than photos?
$needs_refresh = $media_count < $property->photos_count;
// Check 2: Are any URLs expired? (only check if we have records)
if (!$needs_refresh && $media_count > 0 && $earliest_expiry) {
$needs_refresh = strtotime($earliest_expiry) < time();
}
if (!$needs_refresh) {
return;
}
// Refresh media from API
$this->refresh_property_media($property->listing_key, $property->listing_id);
}
/**
* Refresh media records for a property from the MLS API
*
* @param string $listing_key Property listing key
* @param string $listing_id Property listing ID (MLS ID)
*/
private function refresh_property_media($listing_key, $listing_id) {
$plugin = mls_plugin();
$api_client = $plugin->get_api_client();
$media_handler = $plugin->get_media_handler();
if (!$api_client || !$media_handler) {
return;
}
// Fetch property with media from API
$property_data = $api_client->get_property_media($listing_id);
if (is_wp_error($property_data) || empty($property_data)) {
return;
}
// Sync media records (URLs only, no downloads)
if (isset($property_data['Media']) && is_array($property_data['Media'])) {
$media_handler->sync_property_media($listing_key, $property_data['Media']);
}
}
/**
* Get a manual property by listing key or listing ID
*
@@ -979,8 +1131,7 @@ class MLS_Query {
}
if (!empty($args['property_type'])) {
$where[] = 'property_type = %s';
$values[] = $args['property_type'];
$this->build_property_type_clause($args['property_type'], $where, $values);
}
// City and postal_code are mutually exclusive - city takes priority
@@ -1095,8 +1246,7 @@ class MLS_Query {
}
if (!empty($args['property_type'])) {
$where[] = 'property_type = %s';
$values[] = $args['property_type'];
$this->build_property_type_clause($args['property_type'], $where, $values);
}
if (!empty($args['city'])) {
@@ -1327,8 +1477,7 @@ class MLS_Query {
}
if (!empty($args['property_type'])) {
$where[] = 'property_type = %s';
$values[] = $args['property_type'];
$this->build_property_type_clause($args['property_type'], $where, $values);
}
// City and postal_code are mutually exclusive - city takes priority
@@ -29,15 +29,37 @@ class MLS_Rate_Limiter {
const MLSGRID_BYTES_PER_DAY = 42949672960; // 40GB
/**
* Sync operation limits (50% of daily quota paced over 24 hours)
* Sync operation limits
*
* Goal: If sync ran continuously for 24h, use max 50% of daily quota
* - 20,000 requests / 86,400 seconds = 0.23 RPS (~4.3s between requests)
* - 20GB data / 86,400 seconds = ~243KB/s average
* Fixed 5-second interval between API requests for rock-solid rate limiting.
* This ensures we never exceed MLS Grid limits regardless of sync duration.
*
* At 5s intervals: 17,280 requests/day max (43% of 40,000 limit)
*/
const SYNC_REQUESTS_PER_DAY = 20000; // 50% of 40,000
const SYNC_REQUESTS_PER_DAY = 17280; // 86400s / 5s = 17,280 max
const SYNC_BYTES_PER_DAY = 21474836480; // 20GB (50% of 40GB)
const SYNC_MIN_INTERVAL_MS = 4320; // 86400000ms / 20000 = 4.32s between requests
const SYNC_MIN_INTERVAL_MS = 5000; // 5 seconds between requests (legacy)
/**
* Global rate limit intervals (cross-process coordination via MySQL advisory locks)
*
* These are enforced across ALL processes to stay well under MLS Grid limits.
* MLS Grid warns at 2 RPS, suspends at 4+ RPS. We use conservative intervals.
*/
const GENERAL_API_INTERVAL_MS = 4000; // 4 seconds between general API requests
const IMAGE_API_INTERVAL_MS = 2000; // 2 seconds between image API requests
/**
* Advisory lock names for cross-process coordination
*/
const LOCK_GENERAL_API = 'mls_api_general';
const LOCK_IMAGE_API = 'mls_api_image';
/**
* Option keys for storing last request times
*/
const OPTION_LAST_GENERAL_REQUEST = 'mls_last_general_api_request';
const OPTION_LAST_IMAGE_REQUEST = 'mls_last_image_api_request';
/**
* Rate limit constants (used for tracking against MLS Grid limits)
@@ -58,11 +80,6 @@ class MLS_Rate_Limiter {
*/
private $db;
/**
* Last request timestamp for per-second limiting
*/
private $last_request_time = 0;
/**
* Constructor
*
@@ -72,19 +89,131 @@ class MLS_Rate_Limiter {
$this->db = $db;
}
/**
* Wait for and acquire the global API rate limit (general API)
*
* Uses MySQL advisory locks to coordinate across all PHP processes.
* Enforces 4-second minimum interval between general API requests.
*
* @param int $timeout_seconds Max seconds to wait for lock (0 = non-blocking)
* @return bool True if rate limit acquired, false if timeout
*/
public function acquire_general_api_slot($timeout_seconds = 30) {
return $this->acquire_api_slot(
self::LOCK_GENERAL_API,
self::OPTION_LAST_GENERAL_REQUEST,
self::GENERAL_API_INTERVAL_MS,
$timeout_seconds
);
}
/**
* Wait for and acquire the global API rate limit (image API)
*
* Uses MySQL advisory locks to coordinate across all PHP processes.
* Enforces 2-second minimum interval between image API requests.
*
* @param int $timeout_seconds Max seconds to wait for lock (0 = non-blocking)
* @return bool True if rate limit acquired, false if timeout
*/
public function acquire_image_api_slot($timeout_seconds = 30) {
return $this->acquire_api_slot(
self::LOCK_IMAGE_API,
self::OPTION_LAST_IMAGE_REQUEST,
self::IMAGE_API_INTERVAL_MS,
$timeout_seconds
);
}
/**
* Internal method to acquire an API slot with advisory lock coordination
*
* @param string $lock_name Advisory lock name
* @param string $option_key Option key for last request timestamp
* @param int $interval_ms Minimum interval between requests in milliseconds
* @param int $timeout_seconds Max seconds to wait
* @return bool True if slot acquired
*/
private function acquire_api_slot($lock_name, $option_key, $interval_ms, $timeout_seconds) {
global $wpdb;
$start_time = time();
$interval_sec = $interval_ms / 1000.0;
while (true) {
// Check timeout
if ($timeout_seconds > 0 && (time() - $start_time) >= $timeout_seconds) {
return false;
}
// Try to acquire the advisory lock (blocking for up to 1 second)
$lock_acquired = $wpdb->get_var($wpdb->prepare(
"SELECT GET_LOCK(%s, %d)",
$lock_name,
1 // 1 second timeout for each attempt
));
if ($lock_acquired !== '1') {
// Lock held by another process, wait and retry
usleep(100000); // 100ms
continue;
}
try {
// We have the lock - check/wait for rate limit interval
$last_request = (float) get_option($option_key, 0);
$now = microtime(true);
$elapsed = $now - $last_request;
if ($elapsed < $interval_sec) {
// Need to wait for the remaining interval
$wait_time = ($interval_sec - $elapsed) * 1000000; // Convert to microseconds
usleep((int) $wait_time);
}
// Update the last request timestamp
update_option($option_key, microtime(true), false); // false = don't autoload
return true;
} finally {
// Always release the advisory lock
$wpdb->query($wpdb->prepare("SELECT RELEASE_LOCK(%s)", $lock_name));
}
}
}
/**
* Rate limit channels
*/
const CHANNEL_GENERAL = 'general';
const CHANNEL_IMAGE = 'image';
/**
* Check if we can make a request (and wait if needed)
*
* For sync operations, this enforces the 50% daily quota pacing.
* The minimum interval between requests ensures that even continuous
* syncing won't exceed 50% of the daily quota.
* Uses global advisory lock-based rate limiting to coordinate across
* all PHP processes. Different channels have different intervals:
* - general: 4-second interval
* - image: 2-second interval
*
* @param bool $wait Whether to wait if rate limited
* @param string $channel Rate limit channel ('general' or 'image')
* @return bool True if request can proceed
*/
public function check_and_wait($wait = true) {
// Enforce sync pacing (4.32s between requests for 50% daily quota)
$this->enforce_sync_pacing();
public function check_and_wait($wait = true, $channel = self::CHANNEL_GENERAL) {
// Use global advisory lock-based rate limiting
$timeout = $wait ? 60 : 0;
if ($channel === self::CHANNEL_IMAGE) {
if (!$this->acquire_image_api_slot($timeout)) {
return false;
}
} else {
if (!$this->acquire_general_api_slot($timeout)) {
return false;
}
}
// Check hourly limit (hard stop if approaching MLS Grid limits)
if (!$this->check_limit(self::WINDOW_HOUR, self::LIMIT_PER_HOUR)) {
@@ -107,27 +236,6 @@ class MLS_Rate_Limiter {
return true;
}
/**
* Enforce sync operation pacing
*
* Ensures minimum interval between sync requests so that
* 24 hours of continuous syncing uses max 50% of daily quota.
*/
private function enforce_sync_pacing() {
$now = microtime(true);
$min_interval = self::SYNC_MIN_INTERVAL_MS / 1000.0; // Convert ms to seconds (4.32s)
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
*
@@ -474,6 +582,8 @@ class MLS_Rate_Limiter {
public function reset() {
global $wpdb;
$wpdb->query("TRUNCATE TABLE {$this->db->rate_limits_table()}");
$this->last_request_time = 0;
// Reset global timestamps
delete_option(self::OPTION_LAST_GENERAL_REQUEST);
delete_option(self::OPTION_LAST_IMAGE_REQUEST);
}
}
@@ -21,6 +21,7 @@ class MLS_Sync_Engine {
const TYPE_FULL = 'full';
const TYPE_INCREMENTAL = 'incremental';
const TYPE_MEDIA = 'media';
const TYPE_MEDIA_REFRESH = 'media_refresh';
/**
* Sync statuses
@@ -65,6 +66,8 @@ class MLS_Sync_Engine {
'updated' => 0,
'deleted' => 0,
'errors' => 0,
'homeproz_media_downloaded' => 0,
'homeproz_media_skipped' => 0,
);
/**
@@ -387,6 +390,12 @@ class MLS_Sync_Engine {
$this->logger->info('Incremental sync completed', $this->stats);
// Download pending media for HomeProz properties
// This catches any HomeProz listings that have media records but images weren't downloaded
$media_stats = $this->download_pending_homeproz_media($dry_run);
$this->stats['homeproz_media_downloaded'] = $media_stats['downloaded'];
$this->stats['homeproz_media_skipped'] = $media_stats['skipped'];
} catch (Exception $e) {
$this->logger->error('Incremental sync failed', array('error' => $e->getMessage()));
@@ -410,6 +419,211 @@ class MLS_Sync_Engine {
);
}
/**
* Run media refresh sync for properties with expiring media URLs
*
* Fetches fresh data for properties whose media URLs will expire within
* the specified number of days. This prevents on-demand API calls when
* visitors try to view images with expired URLs.
*
* If a property is no longer listed (not Active/Pending or MlgCanView=false),
* it will be removed from the local database.
*
* @param int $days_ahead Number of days to look ahead for expiring media (default: 3)
* @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_media_refresh_sync($days_ahead = 3, $dry_run = false, $progress_callback = null) {
global $wpdb;
$this->logger->info('Starting media refresh sync', array(
'days_ahead' => $days_ahead,
'dry_run' => $dry_run,
));
$this->progress_callback = $progress_callback;
if (!$dry_run) {
$this->sync_state_id = $this->create_sync_state(self::TYPE_MEDIA_REFRESH);
$this->logger->set_sync_state($this->sync_state_id);
}
$this->stats = array(
'processed' => 0,
'created' => 0,
'updated' => 0,
'deleted' => 0,
'errors' => 0,
);
try {
// Find properties with media expiring within X days
$expiry_threshold = gmdate('Y-m-d H:i:s', strtotime("+{$days_ahead} days"));
// Include Active/Pending properties, plus Closed HomeProz properties
// (HomeProz wants to keep sold property images for portfolio)
$properties = $wpdb->get_results($wpdb->prepare(
"SELECT listing_key, listing_id, media_expires_at
FROM {$this->db->properties_table()}
WHERE (media_expires_at IS NULL OR media_expires_at <= %s)
AND (standard_status IN ('Active', 'Pending') OR (standard_status = 'Closed' AND is_homeproz = 1))
ORDER BY media_expires_at ASC",
$expiry_threshold
));
$total = count($properties);
$this->logger->info("Found {$total} properties with expiring media");
$this->emit_progress('media_refresh_start', array(
'total' => $total,
'expiry_threshold' => $expiry_threshold,
));
// Process in batches of 25 (MLS Grid max with $expand)
$batch_size = 25;
$batches = array_chunk($properties, $batch_size);
$batch_num = 0;
foreach ($batches as $batch) {
$batch_num++;
// Build array of listing_ids for this batch
$listing_ids = array_map(function($prop) {
return $prop->listing_id;
}, $batch);
// Fetch batch from API
$start_time = microtime(true);
$this->emit_progress('api_request', array(
'method' => 'GET',
'url' => 'Property',
'params' => array('batch' => $batch_num, 'count' => count($listing_ids)),
));
$response = $this->api_client->get_properties_by_ids($listing_ids);
$elapsed = round((microtime(true) - $start_time) * 1000);
if (is_wp_error($response)) {
$this->emit_progress('api_response', array(
'success' => false,
'status_code' => 0,
'error' => $response->get_error_message(),
'response_time' => $elapsed,
));
// Mark all in batch as errors
foreach ($batch as $prop) {
$this->stats['processed']++;
$this->stats['errors']++;
}
$this->logger->warning('Failed to fetch batch for media refresh', array(
'batch' => $batch_num,
'error' => $response->get_error_message(),
));
continue;
}
$returned_count = isset($response['value']) ? count($response['value']) : 0;
$this->emit_progress('api_response', array(
'success' => true,
'status_code' => 200,
'response_time' => $elapsed,
'record_count' => $returned_count,
));
// Index returned properties by ListingId
$returned_by_id = array();
if (isset($response['value'])) {
foreach ($response['value'] as $property_data) {
if (isset($property_data['ListingId'])) {
$returned_by_id[$property_data['ListingId']] = $property_data;
}
}
}
// Process each property in the batch
foreach ($batch as $prop) {
$this->stats['processed']++;
if (isset($returned_by_id[$prop->listing_id])) {
// Property found - process_property handles its own progress events
$property_data = $returned_by_id[$prop->listing_id];
if (!$dry_run) {
$this->process_property($property_data, false);
} else {
$this->stats['updated']++;
$this->emit_progress('property_skipped', array(
'listing_key' => $prop->listing_key,
));
}
} else {
// Property not in API response - may have been removed
if (!$dry_run) {
$this->delete_property($prop->listing_key);
}
$this->stats['deleted']++;
$this->emit_progress('property_deleted', array(
'listing_key' => $prop->listing_key,
'reason' => 'Not found in API',
));
}
}
// Update sync state after each batch
if (!$dry_run) {
$this->update_sync_state(array(
'records_processed' => $this->stats['processed'],
'records_updated' => $this->stats['updated'],
'records_deleted' => $this->stats['deleted'],
));
}
// Emit batch/page complete
$this->emit_progress('page_complete', array('processed' => $this->stats['processed']));
}
// 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_updated' => $this->stats['updated'],
'records_deleted' => $this->stats['deleted'],
));
}
$this->logger->info('Media refresh sync completed', $this->stats);
$this->emit_progress('media_refresh_complete', array(
'stats' => $this->stats,
));
} catch (Exception $e) {
$this->logger->error('Media refresh 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
*
@@ -535,16 +749,24 @@ class MLS_Sync_Engine {
private $progress_callback = null;
/**
* Allowed statuses for our database (Active/Pending only)
* Allowed statuses for non-HomeProz listings (Active/Pending only)
*/
const ALLOWED_STATUSES = array('Active', 'Pending');
/**
* Allowed statuses for HomeProz listings (includes Closed for historical records)
*/
const HOMEPROZ_ALLOWED_STATUSES = array('Active', 'Pending', 'Closed');
/**
* Process a single property record
*
* During replication, properties are deleted if:
* - MlgCanView = false (removed from feed)
* - StandardStatus not in (Active, Pending)
* - StandardStatus not in allowed list (varies by HomeProz status)
*
* HomeProz listings are retained even when Closed (sold) for historical viewing.
* Non-HomeProz listings are deleted when status is not Active/Pending.
*
* @param array $property Property data from API
* @param bool $dry_run If true, don't make changes
@@ -565,8 +787,17 @@ class MLS_Sync_Engine {
$can_view = $property['MlgCanView'] ?? true;
$status = $property['StandardStatus'] ?? null;
// Delete if: not viewable OR status is not Active/Pending
$should_delete = !$can_view || !in_array($status, self::ALLOWED_STATUSES);
// Check if this is a HomeProz listing (by office ID or override list)
$listing_id = $property['ListingId'] ?? '';
$is_homeproz = (($property['ListOfficeMlsId'] ?? '') === MLS_HOMEPROZ_OFFICE_ID)
|| (defined('MLS_HOMEPROZ_OVERRIDE_LISTINGS') && in_array($listing_id, MLS_HOMEPROZ_OVERRIDE_LISTINGS));
// Determine allowed statuses based on whether it's a HomeProz listing
$allowed_statuses = $is_homeproz ? self::HOMEPROZ_ALLOWED_STATUSES : self::ALLOWED_STATUSES;
// Delete if: not viewable OR status is not in allowed list
// HomeProz listings are retained even when Closed (sold)
$should_delete = !$can_view || !in_array($status, $allowed_statuses);
if ($should_delete) {
// Check if we have this record locally before attempting delete
@@ -609,6 +840,13 @@ class MLS_Sync_Engine {
return;
}
// Build spatial location value for the NOT NULL location column
$lat = $property['Latitude'] ?? null;
$lng = $property['Longitude'] ?? null;
$has_coords = ($lat !== null && $lng !== null);
$point_lat = $has_coords ? (float) $lat : 0.0;
$point_lng = $has_coords ? (float) $lng : 0.0;
if ($existing) {
// Update existing
$wpdb->update(
@@ -618,33 +856,136 @@ class MLS_Sync_Engine {
);
$this->stats['updated']++;
$this->emit_progress('property_updated', array('listing_key' => $listing_key));
} else {
// Insert new
$data['listing_key'] = $listing_key;
$data['created_at'] = current_time('mysql');
$wpdb->insert($this->db->properties_table(), $data);
$this->stats['created']++;
$this->emit_progress('property_created', array('listing_key' => $listing_key));
}
// Update spatial location column (wpdb can't handle ST_PointFromText directly)
$lat = $property['Latitude'] ?? null;
$lng = $property['Longitude'] ?? null;
if ($lat !== null && $lng !== null) {
// Update spatial location column (wpdb can't handle ST_PointFromText directly)
$wpdb->query($wpdb->prepare(
"UPDATE {$this->db->properties_table()} SET location = ST_PointFromText(CONCAT('POINT(', %f, ' ', %f, ')'), 4326) WHERE listing_key = %s",
(float) $lat,
(float) $lng,
$point_lat,
$point_lng,
$listing_key
));
} else {
// Insert new -- must use raw SQL to include the NOT NULL spatial location column
$data['listing_key'] = $listing_key;
$data['created_at'] = current_time('mysql');
$columns = array();
$placeholders = array();
$values = array();
foreach ($data as $col => $val) {
$columns[] = "`{$col}`";
if ($val === null) {
$placeholders[] = 'NULL';
} elseif (is_int($val) || is_float($val)) {
$placeholders[] = is_int($val) ? '%d' : '%f';
$values[] = $val;
} else {
$placeholders[] = '%s';
$values[] = $val;
}
}
// Append spatial location column
$columns[] = '`location`';
$placeholders[] = "ST_PointFromText(CONCAT('POINT(', %f, ' ', %f, ')'), 4326)";
$values[] = $point_lat;
$values[] = $point_lng;
$sql = "INSERT INTO {$this->db->properties_table()} (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $placeholders) . ")";
$wpdb->query($wpdb->prepare($sql, $values));
$this->stats['created']++;
$this->emit_progress('property_created', array('listing_key' => $listing_key));
}
// Process media if present
if (isset($property['Media']) && is_array($property['Media'])) {
$this->media_handler->sync_property_media($listing_key, $property['Media'], false, $this->progress_callback);
// Auto-download and cache all images for HomeProz listings
// These images are stored in persistent cache and never garbage collected
if ($is_homeproz) {
$this->media_handler->download_homeproz_images($listing_key, $this->progress_callback);
}
}
}
/**
* Download pending media for all HomeProz properties
*
* Finds HomeProz properties that have media records with pending download status
* and downloads them. This ensures HomeProz images are always cached locally.
*
* @param bool $dry_run If true, don't download
* @return array Stats with 'downloaded' and 'skipped' counts
*/
private function download_pending_homeproz_media($dry_run = false) {
global $wpdb;
$stats = array('downloaded' => 0, 'skipped' => 0, 'properties' => 0);
// Find HomeProz properties with pending media downloads
$properties_table = $this->db->properties_table();
$media_table = $this->db->media_table();
$homeproz_with_pending = $wpdb->get_results(
"SELECT DISTINCT p.listing_key
FROM {$properties_table} p
INNER JOIN {$media_table} m ON p.listing_key = m.listing_key
WHERE p.is_homeproz = 1
AND m.download_status = 'pending'
AND m.media_url IS NOT NULL
ORDER BY p.modification_timestamp DESC"
);
if (empty($homeproz_with_pending)) {
$this->logger->info('No HomeProz properties with pending media downloads');
return $stats;
}
$this->logger->info('Found HomeProz properties with pending media', array(
'count' => count($homeproz_with_pending),
));
$this->emit_progress('homeproz_media_start', array(
'total_properties' => count($homeproz_with_pending),
));
$property_count = count($homeproz_with_pending);
$current = 0;
foreach ($homeproz_with_pending as $row) {
$current++;
if ($dry_run) {
$stats['properties']++;
continue;
}
$this->logger->info('Downloading HomeProz media', array(
'listing_key' => $row->listing_key,
'progress' => "{$current}/{$property_count}",
));
// Download with 10-second delay between each image to respect MLS API limits
$result = $this->media_handler->download_homeproz_images(
$row->listing_key,
$this->progress_callback,
10 // delay_seconds between each image
);
$stats['downloaded'] += $result['downloaded'];
$stats['skipped'] += $result['skipped'];
$stats['properties']++;
}
$this->emit_progress('homeproz_media_complete', $stats);
$this->logger->info('HomeProz media download completed', $stats);
return $stats;
}
/**
* Emit progress event
*
@@ -714,7 +1055,10 @@ class MLS_Sync_Engine {
'list_office_key' => $property['ListOfficeKey'] ?? null,
'list_office_mls_id' => $property['ListOfficeMlsId'] ?? null,
'list_office_name' => $property['ListOfficeName'] ?? null,
'is_homeproz' => (($property['ListOfficeMlsId'] ?? '') === MLS_HOMEPROZ_OFFICE_ID) ? 1 : 0,
'is_homeproz' => (
(($property['ListOfficeMlsId'] ?? '') === MLS_HOMEPROZ_OFFICE_ID)
|| (defined('MLS_HOMEPROZ_OVERRIDE_LISTINGS') && in_array($property['ListingId'] ?? '', MLS_HOMEPROZ_OVERRIDE_LISTINGS))
) ? 1 : 0,
'photos_count' => $property['PhotosCount'] ?? 0,
'modification_timestamp' => $this->format_timestamp($property['ModificationTimestamp'] ?? null),
@@ -832,7 +1176,11 @@ class MLS_Sync_Engine {
);
if ($timestamp) {
// Look back 10 minutes past the latest timestamp as a safety margin
// to catch any records that may have been missed due to race conditions
// or clock skew between our DB and the MLS API
$dt = new DateTime($timestamp);
$dt->modify('-10 minutes');
return $dt->format('Y-m-d\TH:i:s.v\Z');
}
@@ -992,7 +1340,13 @@ class MLS_Sync_Engine {
// Step 2: Check if a sync is actively running
$running = $this->get_running_sync();
if ($running) {
$status("Sync #{$running->id} is already running (started {$running->started_at})", 'warning');
// If a full sync is in progress, exit silently so cron incremental
// syncs don't log warnings while the weekly full sync runs
if ($running->sync_type === 'full') {
$status("Full sync #{$running->id} in progress (started {$running->started_at}), skipping", 'info');
} else {
$status("Sync #{$running->id} is already running (started {$running->started_at})", 'warning');
}
return array(
'success' => false,
'action' => 'aborted',