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:
@@ -22,10 +22,12 @@ WordPress plugin for syncing MLS Grid API data (NorthStar MLS) into a local data
|
||||
## Features
|
||||
|
||||
- Syncs Active and Pending property listings from MLS Grid API
|
||||
- **HomeProz Listing Persistence**: Sold HomeProz listings are retained for historical viewing
|
||||
- Automatic incremental updates via replication
|
||||
- On-demand image fetching and local caching
|
||||
- **Persistent Image Cache**: HomeProz listing images are permanently cached
|
||||
- Automatic WebP conversion for cached images
|
||||
- Disk space garbage collection for image cache
|
||||
- Disk space garbage collection for image cache (excludes HomeProz images)
|
||||
- Self-healing sync with automatic error recovery
|
||||
- Rate limit compliance (MLS Grid limits enforced)
|
||||
- Resume capability for interrupted syncs
|
||||
@@ -462,6 +464,30 @@ Per MLS Grid rules, media URLs cannot be used directly on websites. Images must
|
||||
- Automatic re-fetch if cache cleared
|
||||
- Works with MLS Grid's URL expiration
|
||||
|
||||
### HomeProz Persistent Cache
|
||||
|
||||
HomeProz listings receive special treatment to preserve images even after properties are sold:
|
||||
|
||||
**How it works:**
|
||||
|
||||
1. HomeProz listings are identified by `ListOfficeMlsId` matching the configured HomeProz office ID
|
||||
2. During sync, ALL images for HomeProz listings are automatically downloaded
|
||||
3. Images are stored in the persistent cache: `wp-content/uploads/mls-listings-persistent/`
|
||||
4. The persistent cache is NEVER subject to garbage collection
|
||||
5. Images remain available indefinitely, even after the listing is sold and removed from MLS
|
||||
|
||||
**Cache Directories:**
|
||||
|
||||
| Directory | Purpose | Garbage Collected |
|
||||
|-----------|---------|-------------------|
|
||||
| `mls-listings/` | Standard cache (non-HomeProz) | Yes |
|
||||
| `mls-listings-persistent/` | HomeProz listings | No |
|
||||
|
||||
**Benefits:**
|
||||
- Sold HomeProz listings can be displayed on a "Sold Homes" page
|
||||
- No loss of images when listings are removed from MLS feed
|
||||
- Historical record of HomeProz sales preserved
|
||||
|
||||
### Pre-caching Images
|
||||
|
||||
To pre-cache images for specific listings:
|
||||
@@ -482,6 +508,8 @@ Shows total media records, cached count, and uncached count.
|
||||
|
||||
The plugin includes automatic garbage collection to prevent disk space from filling up with cached MLS images.
|
||||
|
||||
**Important:** Garbage collection ONLY affects the standard cache (`mls-listings/`). The persistent cache (`mls-listings-persistent/`) containing HomeProz listing images is NEVER touched.
|
||||
|
||||
### Enabling Garbage Collection
|
||||
|
||||
Add to `wp-config.php`:
|
||||
@@ -497,11 +525,12 @@ If `MLS_GC_DISK_THRESHOLD` is not defined, garbage collection is disabled.
|
||||
|
||||
1. After each sync (`wp mls run`), the plugin checks free disk space on the volume hosting MLS images
|
||||
2. If free space is below the threshold, cleanup begins
|
||||
3. Directories older than 24 hours are deleted, oldest first
|
||||
3. Directories older than 24 hours are deleted from the standard cache, oldest first
|
||||
4. Cleanup stops when:
|
||||
- Free space reaches 5GB, OR
|
||||
- 2GB has been deleted in this run
|
||||
5. Directories modified within the last 24 hours are never deleted (protects recently accessed images)
|
||||
6. HomeProz images in the persistent cache are NEVER deleted
|
||||
|
||||
### Behavior Summary
|
||||
|
||||
@@ -552,6 +581,33 @@ define('MLS_GC_DISK_THRESHOLD', 10 * 1024 * 1024 * 1024);
|
||||
|
||||
When a deleted image is requested again, it is automatically re-fetched from MLS Grid and cached. This is the normal on-demand fetching behavior - garbage collection simply clears old cached files to free disk space.
|
||||
|
||||
## HomeProz Listing Persistence
|
||||
|
||||
HomeProz listings (properties listed by the configured HomeProz office) receive special handling to preserve them even after they are sold.
|
||||
|
||||
### Retention Rules
|
||||
|
||||
| Listing Type | Active | Pending | Sold | Other Statuses |
|
||||
|--------------|--------|---------|------|----------------|
|
||||
| HomeProz | Retained | Retained | Retained | Deleted |
|
||||
| Non-HomeProz | Retained | Retained | Deleted | Deleted |
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **Identification**: Listings are identified as HomeProz by matching `ListOfficeMlsId` to `MLS_HOMEPROZ_OFFICE_ID` (configured in plugin constants)
|
||||
2. **Sync Behavior**: HomeProz listings with Active, Pending, or Sold status are kept in the database
|
||||
3. **Image Caching**: All images for HomeProz listings are automatically downloaded during sync
|
||||
4. **Persistent Storage**: Images are stored in `mls-listings-persistent/` which is never garbage collected
|
||||
5. **Future Use**: Sold HomeProz listings can be displayed on a "Sold Homes" page
|
||||
|
||||
### Configuration
|
||||
|
||||
The HomeProz office ID is defined in the main plugin file:
|
||||
|
||||
```php
|
||||
define('MLS_HOMEPROZ_OFFICE_ID', 'NST253235');
|
||||
```
|
||||
|
||||
## Sync Strategy
|
||||
|
||||
### Initial Import (Full Sync)
|
||||
@@ -568,8 +624,10 @@ When a deleted image is requested again, it is automatically re-fetched from MLS
|
||||
- No filter on status (need to detect changes)
|
||||
- For each record:
|
||||
- If `MlgCanView = false`: DELETE from local DB
|
||||
- If `StandardStatus` not Active/Pending: DELETE from local DB
|
||||
- If HomeProz listing: DELETE only if status not Active/Pending/Sold
|
||||
- If non-HomeProz listing: DELETE if status not Active/Pending
|
||||
- Otherwise: INSERT or UPDATE
|
||||
- HomeProz images are auto-downloaded during sync
|
||||
|
||||
### Why This Approach?
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/* Manual Property Admin Styles */
|
||||
|
||||
#mls-clone-section {
|
||||
background: #f0f0f1;
|
||||
border: 1px solid #c3c4c7;
|
||||
padding: 15px 20px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
#mls-clone-section h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#mls-clone-section p {
|
||||
margin-bottom: 12px;
|
||||
color: #50575e;
|
||||
}
|
||||
|
||||
#mls-clone-search {
|
||||
width: 200px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
#mls-clone-btn {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
#mls-clone-status {
|
||||
display: inline-block;
|
||||
margin-left: 10px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Admin list table columns */
|
||||
.column-mp_status {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.column-mp_price {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.column-mp_city {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.column-mp_homeproz,
|
||||
.column-mp_featured {
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
.column-mp_view {
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
/* Status badges */
|
||||
.mp-status-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.mp-status-badge.active {
|
||||
background-color: #28a745;
|
||||
}
|
||||
|
||||
.mp-status-badge.pending {
|
||||
background-color: #ffc107;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.mp-status-badge.sold {
|
||||
background-color: #6c757d;
|
||||
}
|
||||
|
||||
.mp-status-badge.cancelled,
|
||||
.mp-status-badge.expired,
|
||||
.mp-status-badge.withdrawn {
|
||||
background-color: #dc3545;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
(function($) {
|
||||
'use strict';
|
||||
|
||||
$(document).ready(function() {
|
||||
var $searchInput = $('#mls-clone-search');
|
||||
var $cloneBtn = $('#mls-clone-btn');
|
||||
var $status = $('#mls-clone-status');
|
||||
|
||||
if (!$searchInput.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
$cloneBtn.on('click', function() {
|
||||
var mlsId = $searchInput.val().trim();
|
||||
|
||||
if (!mlsId) {
|
||||
$status.text('Please enter an MLS ID').css('color', '#dc3545');
|
||||
return;
|
||||
}
|
||||
|
||||
$status.text('Searching...').css('color', '#666');
|
||||
$cloneBtn.prop('disabled', true);
|
||||
|
||||
// First search for the listing
|
||||
$.ajax({
|
||||
url: mlsManualProperty.ajaxUrl,
|
||||
type: 'POST',
|
||||
data: {
|
||||
action: 'mls_search_for_clone',
|
||||
nonce: mlsManualProperty.nonce,
|
||||
mls_id: mlsId
|
||||
},
|
||||
success: function(response) {
|
||||
if (response.success) {
|
||||
// Found - confirm and clone
|
||||
var listing = response.data;
|
||||
var confirmMsg = 'Found listing:\n\n' +
|
||||
'MLS #: ' + listing.listing_id + '\n' +
|
||||
'Address: ' + listing.address + ', ' + listing.city + '\n' +
|
||||
'Price: $' + Number(listing.price).toLocaleString() + '\n\n' +
|
||||
'Clone this listing?';
|
||||
|
||||
if (confirm(confirmMsg)) {
|
||||
cloneListing(listing.listing_key);
|
||||
} else {
|
||||
$status.text('').css('color', '');
|
||||
$cloneBtn.prop('disabled', false);
|
||||
}
|
||||
} else {
|
||||
$status.text(response.data || 'Listing not found').css('color', '#dc3545');
|
||||
$cloneBtn.prop('disabled', false);
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
$status.text('Error searching for listing').css('color', '#dc3545');
|
||||
$cloneBtn.prop('disabled', false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Allow Enter key to trigger search
|
||||
$searchInput.on('keypress', function(e) {
|
||||
if (e.which === 13) {
|
||||
e.preventDefault();
|
||||
$cloneBtn.click();
|
||||
}
|
||||
});
|
||||
|
||||
function cloneListing(listingKey) {
|
||||
$status.text('Cloning listing and downloading images...').css('color', '#666');
|
||||
|
||||
// Get post ID from URL or hidden field
|
||||
var postId = $('#post_ID').val() || getUrlParam('post');
|
||||
|
||||
$.ajax({
|
||||
url: mlsManualProperty.ajaxUrl,
|
||||
type: 'POST',
|
||||
data: {
|
||||
action: 'mls_clone_listing',
|
||||
nonce: mlsManualProperty.nonce,
|
||||
listing_key: listingKey,
|
||||
post_id: postId
|
||||
},
|
||||
success: function(response) {
|
||||
if (response.success) {
|
||||
$status.html('<strong style="color:#28a745;">Success!</strong> ' +
|
||||
response.data.images_imported + ' images imported. Redirecting to edit page...');
|
||||
|
||||
// Redirect to the edit page of the cloned post (not reload, which creates a new auto-draft)
|
||||
setTimeout(function() {
|
||||
window.location.href = 'post.php?post=' + postId + '&action=edit';
|
||||
}, 1500);
|
||||
} else {
|
||||
$status.text(response.data || 'Clone failed').css('color', '#dc3545');
|
||||
$cloneBtn.prop('disabled', false);
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
$status.text('Error cloning listing').css('color', '#dc3545');
|
||||
$cloneBtn.prop('disabled', false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getUrlParam(name) {
|
||||
var results = new RegExp('[\\?&]' + name + '=([^&#]*)').exec(window.location.href);
|
||||
return results ? results[1] : null;
|
||||
}
|
||||
});
|
||||
})(jQuery);
|
||||
@@ -40,6 +40,7 @@ class MLS_CLI {
|
||||
WP_CLI::add_command('mls recovery', array($instance, 'recovery'));
|
||||
WP_CLI::add_command('mls media', array($instance, 'media'));
|
||||
WP_CLI::add_command('mls geo', array($instance, 'geo'));
|
||||
WP_CLI::add_command('mls property', array($instance, 'property'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -256,7 +257,7 @@ class MLS_CLI {
|
||||
* ## OPTIONS
|
||||
*
|
||||
* <type>
|
||||
* : Sync type: full, incremental, media, or resume
|
||||
* : Sync type: full, incremental, media-refresh, or resume
|
||||
*
|
||||
* [--dry-run]
|
||||
* : Show what would be synced without making changes
|
||||
@@ -267,8 +268,8 @@ class MLS_CLI {
|
||||
* [--id=<sync_id>]
|
||||
* : Sync state ID to resume (for resume command)
|
||||
*
|
||||
* [--force]
|
||||
* : Force re-download of media (for media command)
|
||||
* [--days=<n>]
|
||||
* : Days ahead to check for expiring media (for incremental and media-refresh, default: 3)
|
||||
*
|
||||
* [--quiet]
|
||||
* : Suppress progress output
|
||||
@@ -281,8 +282,10 @@ class MLS_CLI {
|
||||
* wp mls sync full
|
||||
* wp mls sync full --dry-run --limit=10
|
||||
* wp mls sync incremental
|
||||
* wp mls sync incremental --days=7
|
||||
* wp mls sync incremental --verbose
|
||||
* wp mls sync media --limit=100
|
||||
* wp mls sync media-refresh
|
||||
* wp mls sync media-refresh --days=7
|
||||
* wp mls sync resume --id=5
|
||||
*
|
||||
* @subcommand sync
|
||||
@@ -375,6 +378,22 @@ class MLS_CLI {
|
||||
echo "\n";
|
||||
}
|
||||
$this->output_sync_result($result);
|
||||
|
||||
// Run media refresh after successful incremental sync
|
||||
if ($result['success'] && !$dry_run) {
|
||||
$days = isset($assoc_args['days']) ? (int) $assoc_args['days'] : 3;
|
||||
WP_CLI::line('');
|
||||
WP_CLI::line("Running media refresh (properties expiring within {$days} days)...");
|
||||
if (!$quiet) {
|
||||
$this->print_progress_legend($verbose);
|
||||
}
|
||||
|
||||
$media_result = $sync_engine->run_media_refresh_sync($days, false, $progress_callback);
|
||||
if (!$quiet) {
|
||||
echo "\n";
|
||||
}
|
||||
$this->output_sync_result($media_result);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'media':
|
||||
@@ -384,6 +403,24 @@ class MLS_CLI {
|
||||
WP_CLI::line('');
|
||||
WP_CLI::line('Use "wp mls media status" to see cache statistics.');
|
||||
WP_CLI::line('Use "wp mls media fetch --listing=<key>" to pre-cache a specific listing.');
|
||||
WP_CLI::line('Use "wp mls sync media-refresh" to proactively refresh expiring media URLs.');
|
||||
break;
|
||||
|
||||
case 'media-refresh':
|
||||
$days = isset($assoc_args['days']) ? (int) $assoc_args['days'] : 3;
|
||||
WP_CLI::line("Starting media refresh sync (properties expiring within {$days} days)...");
|
||||
if ($dry_run) {
|
||||
WP_CLI::line('DRY RUN - No changes will be made');
|
||||
}
|
||||
if (!$quiet) {
|
||||
$this->print_progress_legend($verbose);
|
||||
}
|
||||
|
||||
$result = $sync_engine->run_media_refresh_sync($days, $dry_run, $progress_callback);
|
||||
if (!$quiet) {
|
||||
echo "\n";
|
||||
}
|
||||
$this->output_sync_result($result);
|
||||
break;
|
||||
|
||||
case 'resume':
|
||||
@@ -405,7 +442,7 @@ class MLS_CLI {
|
||||
break;
|
||||
|
||||
default:
|
||||
WP_CLI::error("Unknown sync type: {$type}. Use 'full', 'incremental', 'media', or 'resume'.");
|
||||
WP_CLI::error("Unknown sync type: {$type}. Use 'full', 'incremental', 'media-refresh', or 'resume'.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1306,4 +1343,113 @@ class MLS_CLI {
|
||||
WP_CLI::log("... and " . ($total - 100) . " more.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a property directly from the MLS API and dump the response.
|
||||
*
|
||||
* ## OPTIONS
|
||||
*
|
||||
* <listing_id>
|
||||
* : The MLS listing ID (e.g., NST6755550 or 6755550)
|
||||
*
|
||||
* [--format=<format>]
|
||||
* : Output format: json, table, or fields (default: fields)
|
||||
*
|
||||
* [--fields=<fields>]
|
||||
* : Comma-separated list of fields to show (for fields format)
|
||||
*
|
||||
* ## EXAMPLES
|
||||
*
|
||||
* wp mls property 6755550
|
||||
* wp mls property NST6755550 --format=json
|
||||
* wp mls property 6755550 --fields=StandardStatus,ListPrice,CloseDate
|
||||
*
|
||||
* @subcommand property
|
||||
*/
|
||||
public function property($args, $assoc_args) {
|
||||
$listing_id = isset($args[0]) ? $args[0] : null;
|
||||
|
||||
if (!$listing_id) {
|
||||
WP_CLI::error('Please provide a listing ID');
|
||||
}
|
||||
|
||||
// Add NST prefix if not present
|
||||
if (!preg_match('/^[A-Z]{3}/', $listing_id)) {
|
||||
$listing_id = 'NST' . $listing_id;
|
||||
}
|
||||
|
||||
WP_CLI::line("Fetching property {$listing_id} from MLS API...");
|
||||
|
||||
$api_client = $this->plugin->get_api_client();
|
||||
$result = $api_client->get_property_media($listing_id);
|
||||
|
||||
if (is_wp_error($result)) {
|
||||
WP_CLI::error('API Error: ' . $result->get_error_message());
|
||||
}
|
||||
|
||||
if (!$result) {
|
||||
WP_CLI::error("Property {$listing_id} not found in MLS");
|
||||
}
|
||||
|
||||
$format = isset($assoc_args['format']) ? $assoc_args['format'] : 'fields';
|
||||
|
||||
switch ($format) {
|
||||
case 'json':
|
||||
echo json_encode($result, JSON_PRETTY_PRINT) . "\n";
|
||||
break;
|
||||
|
||||
case 'table':
|
||||
// Flatten for table display
|
||||
$flat = array();
|
||||
foreach ($result as $key => $value) {
|
||||
if (!is_array($value)) {
|
||||
$flat[$key] = $value;
|
||||
}
|
||||
}
|
||||
WP_CLI\Utils\format_items('table', array($flat), array_keys($flat));
|
||||
break;
|
||||
|
||||
case 'fields':
|
||||
default:
|
||||
// Show key fields
|
||||
$key_fields = array(
|
||||
'ListingId',
|
||||
'ListingKey',
|
||||
'StandardStatus',
|
||||
'MlsStatus',
|
||||
'ListPrice',
|
||||
'ClosePrice',
|
||||
'CloseDate',
|
||||
'ListOfficeName',
|
||||
'ListAgentFullName',
|
||||
'StreetNumber',
|
||||
'StreetName',
|
||||
'City',
|
||||
'StateOrProvince',
|
||||
'ModificationTimestamp',
|
||||
);
|
||||
|
||||
// Allow custom fields
|
||||
if (isset($assoc_args['fields'])) {
|
||||
$key_fields = explode(',', $assoc_args['fields']);
|
||||
}
|
||||
|
||||
WP_CLI::line('');
|
||||
WP_CLI::line('=== Property Details from MLS API ===');
|
||||
WP_CLI::line('');
|
||||
|
||||
foreach ($key_fields as $field) {
|
||||
$field = trim($field);
|
||||
$value = isset($result[$field]) ? $result[$field] : '(not set)';
|
||||
if (is_array($value)) {
|
||||
$value = json_encode($value);
|
||||
}
|
||||
WP_CLI::line(sprintf(' %-25s %s', $field . ':', $value));
|
||||
}
|
||||
|
||||
WP_CLI::line('');
|
||||
WP_CLI::line('Use --format=json to see full response');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Regular → Executable
+57
-2
@@ -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',
|
||||
|
||||
@@ -17,7 +17,7 @@ if (!defined('ABSPATH')) {
|
||||
}
|
||||
|
||||
// Plugin constants
|
||||
define('MLS_PLUGIN_VERSION', '1.0.0');
|
||||
define('MLS_PLUGIN_VERSION', '1.0.1');
|
||||
define('MLS_PLUGIN_FILE', __FILE__);
|
||||
define('MLS_PLUGIN_DIR', plugin_dir_path(__FILE__));
|
||||
define('MLS_PLUGIN_URL', plugin_dir_url(__FILE__));
|
||||
@@ -33,10 +33,16 @@ define('MLS_TABLE_SYNC_LOG', 'mls_sync_log');
|
||||
define('MLS_TABLE_MEDIA_LOG', 'mls_media_log');
|
||||
define('MLS_TABLE_GEO_CITIES', 'mls_geo_cities');
|
||||
define('MLS_TABLE_GEO_ZIPCODES', 'mls_geo_zipcodes');
|
||||
define('MLS_TABLE_MANUAL_PROPERTIES', 'mls_properties_manual');
|
||||
|
||||
// HomeProz office MLS ID for identifying our listings
|
||||
define('MLS_HOMEPROZ_OFFICE_ID', 'NST253235');
|
||||
|
||||
// Specific MLS listing IDs to treat as HomeProz (for listings from other offices we want to showcase)
|
||||
define('MLS_HOMEPROZ_OVERRIDE_LISTINGS', array(
|
||||
'NST6769023', // 121 Main Street, Glenville, MN 56036 (LandProz)
|
||||
));
|
||||
|
||||
// Allowed states for MLS queries (MN and IA only)
|
||||
define('MLS_ALLOWED_STATES', array('MN', 'IA'));
|
||||
|
||||
@@ -64,6 +70,7 @@ final class MLS_Plugin {
|
||||
private $query;
|
||||
private $cluster;
|
||||
private $garbage_collector;
|
||||
private $manual_property;
|
||||
|
||||
/**
|
||||
* Get single instance
|
||||
@@ -100,6 +107,8 @@ final class MLS_Plugin {
|
||||
require_once MLS_PLUGIN_DIR . 'includes/class-mls-cluster.php';
|
||||
require_once MLS_PLUGIN_DIR . 'includes/class-mls-geo-validator.php';
|
||||
require_once MLS_PLUGIN_DIR . 'includes/class-mls-garbage-collector.php';
|
||||
require_once MLS_PLUGIN_DIR . 'includes/class-mls-geocoder.php';
|
||||
require_once MLS_PLUGIN_DIR . 'includes/class-mls-manual-property.php';
|
||||
|
||||
// Activation/Deactivation
|
||||
require_once MLS_PLUGIN_DIR . 'includes/class-mls-activator.php';
|
||||
@@ -151,7 +160,8 @@ final class MLS_Plugin {
|
||||
);
|
||||
$this->query = new MLS_Query($this->db);
|
||||
$this->cluster = new MLS_Cluster($this->db);
|
||||
$this->garbage_collector = new MLS_Garbage_Collector($this->logger);
|
||||
$this->garbage_collector = new MLS_Garbage_Collector($this->logger, $this->db);
|
||||
$this->manual_property = new MLS_Manual_Property($this->db);
|
||||
|
||||
// Register AJAX handlers
|
||||
add_action('wp_ajax_mls_get_clusters', array($this, 'ajax_get_clusters'));
|
||||
@@ -257,6 +267,13 @@ final class MLS_Plugin {
|
||||
return $this->garbage_collector;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Manual Property instance
|
||||
*/
|
||||
public function get_manual_property() {
|
||||
return $this->manual_property;
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX handler for getting map clusters
|
||||
*/
|
||||
@@ -326,12 +343,12 @@ function mls_get_properties($args = array()) {
|
||||
* @param string $identifier Listing key or MLS ID
|
||||
* @return object|null Property object or null
|
||||
*/
|
||||
function mls_get_property($identifier) {
|
||||
function mls_get_property($identifier, $skip_manual_override = false) {
|
||||
$plugin = mls_plugin();
|
||||
if (!$plugin->get_query()) {
|
||||
return null;
|
||||
}
|
||||
return $plugin->get_query()->get_property($identifier);
|
||||
return $plugin->get_query()->get_property($identifier, $skip_manual_override);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -353,17 +370,31 @@ function mls_get_property_media($listing_key) {
|
||||
*
|
||||
* Images are fetched from MLS Grid and cached locally on first request.
|
||||
* Per MLS Grid rules, images must be served from our own server.
|
||||
* For manual properties (MANUAL-xxx), returns the first gallery image.
|
||||
*
|
||||
* @param string $listing_key The listing key
|
||||
* @param bool $fetch_if_missing Whether to fetch from MLS Grid if not cached (default: true)
|
||||
* @return string|null Image URL or null
|
||||
*/
|
||||
function mls_get_property_image($listing_key, $fetch_if_missing = true) {
|
||||
// Handle manual properties - images are in WordPress media library
|
||||
if (strpos($listing_key, 'MANUAL-') === 0) {
|
||||
$images = MLS_Manual_Property::get_manual_property_images($listing_key);
|
||||
if (!empty($images) && isset($images[0]->local_url)) {
|
||||
return $images[0]->local_url;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
$plugin = mls_plugin();
|
||||
if (!$plugin->get_media_handler()) {
|
||||
return null;
|
||||
}
|
||||
return $plugin->get_media_handler()->get_primary_image($listing_key, $fetch_if_missing);
|
||||
$result = $plugin->get_media_handler()->get_primary_image($listing_key, $fetch_if_missing);
|
||||
if (is_wp_error($result) || !is_string($result)) {
|
||||
return null;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user