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>
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
#wpadminbar .wp-mail-smtp-admin-bar-menu-notification-counter,#wpadminbar .wp-mail-smtp-admin-bar-menu-error{display:inline-block !important;vertical-align:top !important;box-sizing:border-box !important;margin:7px 0 0 2px !important;padding:0 5px !important;min-width:18px !important;height:18px !important;border-radius:9px !important;background-color:#d63638 !important;color:#fff !important;font-size:11px !important;line-height:1.6 !important;text-align:center !important}#wpadminbar .wp-mail-smtp-admin-bar-menu-notification-counter span,#wpadminbar .wp-mail-smtp-admin-bar-menu-error span{line-height:1 !important;font-size:11px !important}body.wp-admin #adminmenu #toplevel_page_wp-mail-smtp .wp-menu-image.svg{background-position-x:10px;background-position-y:8px}@media screen and (max-width: 782px){body.wp-admin #adminmenu #toplevel_page_wp-mail-smtp .wp-menu-image.svg{background-position-x:9px}}
|
||||
@@ -0,0 +1 @@
|
||||
#wp-mail-smtp-notifications{position:relative;background:#fff 0 0 no-repeat padding-box;box-shadow:0 2px 4px rgba(0,0,0,.05);border-radius:6px;opacity:1;min-height:48px;margin:0 0 14px 0}#wp-mail-smtp-notifications *{box-sizing:border-box}#wp-mail-smtp-notifications .wp-mail-smtp-notifications-header{display:flex;align-items:center;padding:8px 16px;border-bottom:1px solid rgba(204,208,212,.5)}#wp-mail-smtp-notifications .wp-mail-smtp-notifications-header .wp-mail-smtp-notifications-bell{position:relative;top:2px;margin-right:10px}#wp-mail-smtp-notifications .wp-mail-smtp-notifications-header .wp-mail-smtp-notifications-title{font-style:normal;font-weight:500;font-size:14px;line-height:17px;color:#23282d}#wp-mail-smtp-notifications .wp-mail-smtp-notifications-body{position:relative}#wp-mail-smtp-notifications .wp-mail-smtp-notifications-messages{padding:16px 100px 16px 16px}#wp-mail-smtp-notifications .wp-mail-smtp-notifications-messages .wp-mail-smtp-notifications-message{display:none}#wp-mail-smtp-notifications .wp-mail-smtp-notifications-messages .wp-mail-smtp-notifications-message.current{display:block}#wp-mail-smtp-notifications .wp-mail-smtp-notifications-messages .wp-mail-smtp-notifications-title{font-weight:600;font-size:17px;line-height:17px;margin:0;color:#2c3337}#wp-mail-smtp-notifications .wp-mail-smtp-notifications-messages .wp-mail-smtp-notifications-content{font-weight:normal;font-size:14px;line-height:18px;margin:8px 0 41px 0;color:#50575e}#wp-mail-smtp-notifications .wp-mail-smtp-notifications-messages .wp-mail-smtp-notifications-content p{font-size:inherit;line-height:inherit;margin:0 0 5px}#wp-mail-smtp-notifications .wp-mail-smtp-notifications-messages .wp-mail-smtp-notifications-buttons{margin:-30px 80px 0 0}#wp-mail-smtp-notifications .wp-mail-smtp-notifications-messages .wp-mail-smtp-notifications-buttons a{margin:0 10px 0 0;padding:8px 10px;line-height:13px;font-size:13px;min-height:unset}#wp-mail-smtp-notifications .wp-mail-smtp-notifications-messages .wp-mail-smtp-notifications-buttons .button-secondary{border:1px solid #0071a1}#wp-mail-smtp-notifications .dismiss{position:absolute;top:15px;right:16px;width:16px;height:16px;color:#a0a5aa;font-size:16px;cursor:pointer;text-align:center;vertical-align:middle;line-height:16px}#wp-mail-smtp-notifications .dismiss:hover{color:#d63638}#wp-mail-smtp-notifications .dismiss i{width:100%;height:100%;font-size:inherit}#wp-mail-smtp-notifications .navigation{position:absolute;bottom:20px;right:16px;width:63px;height:30px}#wp-mail-smtp-notifications .navigation a{display:block;width:30px;height:30px;border:1px solid #7e8993;border-radius:3px;font-size:16px;line-height:1.625;text-align:center;cursor:pointer;background-color:#fff;color:#41454a}#wp-mail-smtp-notifications .navigation a:hover{background-color:#f1f1f1}#wp-mail-smtp-notifications .navigation a .dashicons{margin-top:8px;font-size:12px}#wp-mail-smtp-notifications .navigation .prev{float:left}#wp-mail-smtp-notifications .navigation .next{float:right}#wp-mail-smtp-notifications .navigation .disabled{border-color:#ddd;color:#a0a5aa;cursor:default}#wp-mail-smtp-notifications .navigation .disabled:hover{background-color:#fff}@media screen and (max-width: 768px){#wp-mail-smtp-notifications .wp-mail-smtp-notifications-messages{padding:15px 50px 20px 16px}#wp-mail-smtp-notifications .wp-mail-smtp-notifications-messages .wp-mail-smtp-notifications-title{margin:0 30px 0 0;line-height:22px}#wp-mail-smtp-notifications .wp-mail-smtp-notifications-messages .wp-mail-smtp-notifications-content{font-size:16px;line-height:22px}#wp-mail-smtp-notifications .wp-mail-smtp-notifications-messages .wp-mail-smtp-notifications-buttons{margin:-30px 80px 0 0}#wp-mail-smtp-notifications .wp-mail-smtp-notifications-messages .wp-mail-smtp-notifications-buttons a{margin:0;display:table}#wp-mail-smtp-notifications .wp-mail-smtp-notifications-messages .wp-mail-smtp-notifications-buttons .button-secondary{margin-top:6px}}
|
||||
@@ -0,0 +1 @@
|
||||
#health-check-accordion-block-wp_mail_smtp_email_domain_check>p:first-child{margin-top:0}#wp-mail-smtp-domain-check-details h2{color:#2c3338;font-weight:600;font-size:13px;margin:0 0 15px}#wp-mail-smtp-domain-check-details .wp-mail-smtp-domain-check-details-check-list-item{padding:10px 0 0;border-top:1px solid #e4e4e4;display:flex}#wp-mail-smtp-domain-check-details .wp-mail-smtp-domain-check-details-check-list-item:last-child{border-bottom:1px solid #e4e4e4}#wp-mail-smtp-domain-check-details .wp-mail-smtp-domain-check-details-check-list-item-icon{width:18px;height:16px;margin-right:10px}#wp-mail-smtp-domain-check-details .wp-mail-smtp-domain-check-details-check-list-item-content h3{text-transform:uppercase;font-weight:500;font-size:14px;line-height:17px;margin:0 0 4px 0;color:#2c3337}#wp-mail-smtp-domain-check-details .wp-mail-smtp-domain-check-details-check-list-item-content p{margin:0 0 10px 0;color:#50575e}
|
||||
@@ -0,0 +1,299 @@
|
||||
/* General styles. */
|
||||
|
||||
#outlook a {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ExternalClass {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ExternalClass, .ExternalClass p, .ExternalClass span, .ExternalClass font, .ExternalClass td, .ExternalClass div {
|
||||
line-height: 100%;
|
||||
}
|
||||
|
||||
#backgroundTable {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100% !important;
|
||||
line-height: 100% !important;
|
||||
}
|
||||
|
||||
/* Prevent blue links in subject. */
|
||||
.stats-subject-heading a {
|
||||
color: inherit !important;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
/* Mobile styles. */
|
||||
|
||||
@media only screen and (max-width: 599px) {
|
||||
table.body .container {
|
||||
width: 94% !important;
|
||||
max-width: 600px !important;
|
||||
}
|
||||
|
||||
.header img {
|
||||
width: 240px !important;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 30px !important;
|
||||
}
|
||||
|
||||
.main-heading {
|
||||
font-size: 16px !important;
|
||||
line-height: 20px !important;
|
||||
}
|
||||
|
||||
.main-description {
|
||||
margin-bottom: 20px !important;
|
||||
}
|
||||
|
||||
.stats-totals-wrapper.two .stats-totals-item-wrapper {
|
||||
width: 50% !important;
|
||||
}
|
||||
|
||||
.stats-totals-wrapper.three .stats-totals-item-wrapper {
|
||||
width: 33.3% !important;
|
||||
}
|
||||
|
||||
.stats-totals-wrapper.four .stats-totals-item-wrapper {
|
||||
width: 25% !important;
|
||||
}
|
||||
|
||||
.stats-totals-wrapper {
|
||||
border-collapse: separate !important;
|
||||
border: 1px solid #DDDDDD !important;
|
||||
border-radius: 4px !important;
|
||||
}
|
||||
|
||||
.stats-total-item {
|
||||
width: 100% !important;
|
||||
min-width: 100% !important;
|
||||
}
|
||||
|
||||
.stats-total-item-inner {
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.stats-total-item-icon-wrapper {
|
||||
height: 24px !important;
|
||||
}
|
||||
|
||||
.stats-total-item-icon {
|
||||
width: 24px !important;
|
||||
height: 24px !important;
|
||||
}
|
||||
|
||||
.stats-totals-wrapper.three .stats-total-item-title,
|
||||
.stats-totals-wrapper.four .stats-total-item-title {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.stats-total-item-value {
|
||||
font-size: 18px !important;
|
||||
line-height: 22px !important;
|
||||
}
|
||||
|
||||
.stats-total-item-percent {
|
||||
font-size: 12px !important;
|
||||
line-height: 14px !important;
|
||||
}
|
||||
|
||||
.stats-total-item-percent img {
|
||||
width: 9px !important;
|
||||
height: 9px !important;
|
||||
}
|
||||
|
||||
.stats-heading th {
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
min-width: 100% !important;
|
||||
padding-right: 0 !important;
|
||||
padding-left: 0 !important;
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
.stats-heading .first-col {
|
||||
padding-top: 20px !important;
|
||||
padding-bottom: 5px !important;
|
||||
}
|
||||
|
||||
.stats-heading .second-col {
|
||||
padding-top: 0 !important;
|
||||
padding-bottom: 20px !important;
|
||||
}
|
||||
|
||||
.stats-heading h2 {
|
||||
text-align: center !important;
|
||||
font-size: 15px !important;
|
||||
line-height: 18px !important;
|
||||
}
|
||||
|
||||
.stats-heading a {
|
||||
font-size: 13px !important;
|
||||
line-height: 16px !important;
|
||||
}
|
||||
|
||||
.stats-subject-heading {
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
.stats-subject-row {
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
.stats-subject-column.total,
|
||||
.stats-subject-column.sent,
|
||||
.stats-subject-column.confirmed,
|
||||
.stats-subject-column.unconfirmed,
|
||||
.stats-subject-column.unsent {
|
||||
max-width: 64px !important;
|
||||
}
|
||||
|
||||
.stats-subject-column.opened,
|
||||
.stats-subject-column.clicked {
|
||||
max-width: 111px !important;
|
||||
}
|
||||
|
||||
.spacer-40 {
|
||||
line-height: 20px !important;
|
||||
height: 20px !important;
|
||||
}
|
||||
|
||||
.upgrade-heading {
|
||||
font-size: 18px !important;
|
||||
line-height: 22px !important;
|
||||
}
|
||||
|
||||
.upgrade-text {
|
||||
font-size: 14px !important;
|
||||
line-height: 20px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 360px) {
|
||||
.content {
|
||||
padding: 20px !important;
|
||||
}
|
||||
|
||||
.stats-subject-column.total,
|
||||
.stats-subject-column.sent,
|
||||
.stats-subject-column.confirmed,
|
||||
.stats-subject-column.unconfirmed,
|
||||
.stats-subject-column.unsent {
|
||||
max-width: 61px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark Mode. */
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.dark-body-bg {
|
||||
background: #1C1E20 !important;
|
||||
}
|
||||
|
||||
.dark-content-bg {
|
||||
background: #23282C !important;
|
||||
}
|
||||
|
||||
.dark-bg {
|
||||
background: #202326 !important;
|
||||
}
|
||||
|
||||
.dark-white-color {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.dark-img {
|
||||
display: block !important;
|
||||
width: auto !important;
|
||||
overflow: visible !important;
|
||||
float: none !important;
|
||||
max-height: inherit !important;
|
||||
max-width: inherit !important;
|
||||
line-height: auto !important;
|
||||
margin-top: 0px !important;
|
||||
visibility: inherit !important;
|
||||
}
|
||||
|
||||
.light-img {
|
||||
display: none;
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.stats-total-item-inner,
|
||||
.stats-heading {
|
||||
border-color: #395360 !important;
|
||||
}
|
||||
|
||||
.stats-subject-column-value,
|
||||
.stats-total-item-percent,
|
||||
.footer {
|
||||
color: #8C8F94 !important;
|
||||
}
|
||||
|
||||
.stats-subject-column-value span {
|
||||
color: #4A5057 !important;
|
||||
}
|
||||
|
||||
.upgrade-text {
|
||||
color: #8C8F94 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark Mode Outlook. */
|
||||
|
||||
[data-ogsc] .dark-body-bg {
|
||||
background: #1C1E20 !important;
|
||||
}
|
||||
|
||||
[data-ogsc] .dark-content-bg {
|
||||
background: #23282C !important;
|
||||
}
|
||||
|
||||
[data-ogsc] .dark-bg {
|
||||
background: #202326 !important;
|
||||
}
|
||||
|
||||
[data-ogsc] .dark-white-color {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
[data-ogsc] .dark-img {
|
||||
display: block !important;
|
||||
width: auto !important;
|
||||
overflow: visible !important;
|
||||
float: none !important;
|
||||
max-height: inherit !important;
|
||||
max-width: inherit !important;
|
||||
line-height: auto !important;
|
||||
margin-top: 0px !important;
|
||||
visibility: inherit !important;
|
||||
}
|
||||
|
||||
[data-ogsc] .light-img {
|
||||
display: none;
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
[data-ogsc] .stats-total-item-inner,
|
||||
[data-ogsc] .stats-heading {
|
||||
border-color: #395360 !important;
|
||||
}
|
||||
|
||||
[data-ogsc] .stats-subject-column-value,
|
||||
[data-ogsc] .stats-total-item-percent,
|
||||
[data-ogsc] .footer {
|
||||
color: #8C8F94 !important;
|
||||
}
|
||||
|
||||
[data-ogsc] .stats-subject-column-value span {
|
||||
color: #4A5057 !important;
|
||||
}
|
||||
|
||||
[data-ogsc] .upgrade-text {
|
||||
color: #8C8F94 !important;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
#wp-mail-smtp .wp-mail-smtp-smart-routing-header{padding-bottom:0 !important;border-bottom:none !important}#wp-mail-smtp .wp-mail-smtp-smart-routing-header__heading{display:flex;align-items:center}#wp-mail-smtp .wp-mail-smtp-smart-routing-header__heading>a{margin-left:15px;font-weight:600;font-size:14px;line-height:22px}#wp-mail-smtp .wp-mail-smtp-smart-routing-notice-top{max-width:1000px;margin:10px 0 0 !important;border:1px solid #bbb}#wp-mail-smtp .wp-mail-smtp-smart-routing-notice-top--no-connections~.wp-mail-smtp-setting-row,#wp-mail-smtp .wp-mail-smtp-smart-routing-notice-top--no-connections~.wp-mail-smtp-submit{opacity:.5;pointer-events:none}#wp-mail-smtp .wp-mail-smtp-smart-routing-toggle .wp-mail-smtp-toggle__label{font-weight:500;font-size:14px;text-transform:capitalize}#wp-mail-smtp .wp-mail-smtp-smart-routing-routes{max-width:1000px;margin-bottom:30px}#wp-mail-smtp .wp-mail-smtp-smart-routing-route{background:#fff;box-shadow:0px 2px 4px rgba(0,0,0,.07);border-radius:6px}#wp-mail-smtp .wp-mail-smtp-smart-routing-route+.wp-mail-smtp-smart-routing-route{margin-top:30px}#wp-mail-smtp .wp-mail-smtp-smart-routing-route__header{display:flex;align-items:center;padding:20px;border-bottom:1px solid #f1f1f1}@media(max-width: 600px){#wp-mail-smtp .wp-mail-smtp-smart-routing-route__header{flex-direction:column;align-items:flex-start}}#wp-mail-smtp .wp-mail-smtp-smart-routing-route__header>span{margin-right:10px;font-size:14px;line-height:17px;font-style:italic;color:#646970}@media(max-width: 600px){#wp-mail-smtp .wp-mail-smtp-smart-routing-route__header>span{margin-right:0;margin-top:10px}}#wp-mail-smtp .wp-mail-smtp-smart-routing-route__header>select{margin-right:10px !important}@media(max-width: 600px){#wp-mail-smtp .wp-mail-smtp-smart-routing-route__header>select{margin-right:0;margin-top:10px !important}}#wp-mail-smtp .wp-mail-smtp-smart-routing-route__connection{max-width:273px;width:100%}#wp-mail-smtp .wp-mail-smtp-smart-routing-route__connection--invalid{border-color:#d63638 !important}#wp-mail-smtp .wp-mail-smtp-smart-routing-route__actions{display:flex;align-items:center;margin-left:auto}@media(max-width: 600px){#wp-mail-smtp .wp-mail-smtp-smart-routing-route__actions{order:-1}}#wp-mail-smtp .wp-mail-smtp-smart-routing-route__order{margin-right:23px;white-space:nowrap}#wp-mail-smtp .wp-mail-smtp-smart-routing-route__order-btn{background-color:rgba(0,0,0,0);border:none;padding:0;vertical-align:middle;cursor:pointer}#wp-mail-smtp .wp-mail-smtp-smart-routing-route__order-btn--down{margin-left:13px;transform:rotate(-180deg)}#wp-mail-smtp .wp-mail-smtp-smart-routing-route__order-btn img{display:block}#wp-mail-smtp .wp-mail-smtp-smart-routing-route:first-child .wp-mail-smtp-smart-routing-route__order-btn--up,#wp-mail-smtp .wp-mail-smtp-smart-routing-route:last-child .wp-mail-smtp-smart-routing-route__order-btn--down{opacity:.25;cursor:not-allowed;pointer-events:none}#wp-mail-smtp .wp-mail-smtp-smart-routing-route__delete{background-color:rgba(0,0,0,0);border:none;color:#999;padding:0;vertical-align:middle;cursor:pointer}#wp-mail-smtp .wp-mail-smtp-smart-routing-route__delete:hover:not(:disabled){color:#d63638}#wp-mail-smtp .wp-mail-smtp-smart-routing-route__delete:disabled{opacity:.25;cursor:not-allowed}#wp-mail-smtp .wp-mail-smtp-smart-routing-route__main{padding:20px}#wp-mail-smtp .wp-mail-smtp-smart-routing-route__notice{margin-top:0 !important;margin-bottom:20px !important;border:1px solid #bbb}#wp-mail-smtp .wp-mail-smtp-smart-routing-route__notice p{margin:6px 0 !important;font-size:13px !important}#wp-mail-smtp .wp-mail-smtp-smart-routing-route .wp-mail-smtp-btn-grey{font-weight:500}#wp-mail-smtp .wp-mail-smtp-smart-routing-routes-note{display:flex;align-items:center;margin-bottom:5px;font-size:14px;line-height:20px;color:#646970}#wp-mail-smtp .wp-mail-smtp-smart-routing-routes-note img{margin-right:10px}#wp-mail-smtp .wp-mail-smtp-smart-routing-routes-note p{margin:0 !important}#wp-mail-smtp .wp-mail-smtp-smart-routing-routes-note a{color:inherit}#wp-mail-smtp .wp-mail-smtp-smart-routing-routes-note a:hover,#wp-mail-smtp .wp-mail-smtp-smart-routing-routes-note a:active{color:#555}
|
||||
@@ -0,0 +1,3 @@
|
||||
/*! Lity - v2.4.1 - 2020-04-26
|
||||
* http://sorgalla.com/lity/
|
||||
* Copyright (c) 2015-2020 Jan Sorgalla; Licensed MIT */.lity{z-index:9990;position:fixed;top:0;right:0;bottom:0;left:0;white-space:nowrap;background:#0b0b0b;background:rgba(0,0,0,0.9);outline:none !important;opacity:0;-webkit-transition:opacity .3s ease;-o-transition:opacity .3s ease;transition:opacity .3s ease}.lity.lity-opened{opacity:1}.lity.lity-closed{opacity:0}.lity *{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}.lity-wrap{z-index:9990;position:fixed;top:0;right:0;bottom:0;left:0;text-align:center;outline:none !important}.lity-wrap:before{content:'';display:inline-block;height:100%;vertical-align:middle;margin-right:-0.25em}.lity-loader{z-index:9991;color:#fff;position:absolute;top:50%;margin-top:-0.8em;width:100%;text-align:center;font-size:14px;font-family:Arial,Helvetica,sans-serif;opacity:0;-webkit-transition:opacity .3s ease;-o-transition:opacity .3s ease;transition:opacity .3s ease}.lity-loading .lity-loader{opacity:1}.lity-container{z-index:9992;position:relative;text-align:left;vertical-align:middle;display:inline-block;white-space:normal;max-width:100%;max-height:100%;outline:none !important}.lity-content{z-index:9993;width:100%;-webkit-transform:scale(1);-ms-transform:scale(1);-o-transform:scale(1);transform:scale(1);-webkit-transition:-webkit-transform .3s ease;transition:-webkit-transform .3s ease;-o-transition:-o-transform .3s ease;transition:transform .3s ease;transition:transform .3s ease, -webkit-transform .3s ease, -o-transform .3s ease}.lity-loading .lity-content,.lity-closed .lity-content{-webkit-transform:scale(.8);-ms-transform:scale(.8);-o-transform:scale(.8);transform:scale(.8)}.lity-content:after{content:'';position:absolute;left:0;top:0;bottom:0;display:block;right:0;width:auto;height:auto;z-index:-1;-webkit-box-shadow:0 0 8px rgba(0,0,0,0.6);box-shadow:0 0 8px rgba(0,0,0,0.6)}.lity-close{z-index:9994;width:35px;height:35px;position:fixed;right:0;top:0;-webkit-appearance:none;cursor:pointer;text-decoration:none;text-align:center;padding:0;color:#fff;font-style:normal;font-size:35px;font-family:Arial,Baskerville,monospace;line-height:35px;text-shadow:0 1px 2px rgba(0,0,0,0.6);border:0;background:none;outline:none;-webkit-box-shadow:none;box-shadow:none}.lity-close::-moz-focus-inner{border:0;padding:0}.lity-close:hover,.lity-close:focus,.lity-close:active,.lity-close:visited{text-decoration:none;text-align:center;padding:0;color:#fff;font-style:normal;font-size:35px;font-family:Arial,Baskerville,monospace;line-height:35px;text-shadow:0 1px 2px rgba(0,0,0,0.6);border:0;background:none;outline:none;-webkit-box-shadow:none;box-shadow:none}.lity-close:active{top:1px}.lity-image img{max-width:100%;display:block;line-height:0;border:0}.lity-iframe .lity-container,.lity-youtube .lity-container,.lity-vimeo .lity-container,.lity-facebookvideo .lity-container,.lity-googlemaps .lity-container{width:100%;max-width:964px}.lity-iframe-container{width:100%;height:0;padding-top:56.25%;overflow:auto;pointer-events:auto;-webkit-transform:translateZ(0);transform:translateZ(0);-webkit-overflow-scrolling:touch}.lity-iframe-container iframe{position:absolute;display:block;top:0;left:0;width:100%;height:100%;-webkit-box-shadow:0 0 8px rgba(0,0,0,0.6);box-shadow:0 0 8px rgba(0,0,0,0.6);background:#000}.lity-hide{display:none}
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 37 28" xmlns="http://www.w3.org/2000/svg" fill-rule="evenodd" clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.41"><path d="M12.29 26.46c.33.38.75.56 1.27.56s.93-.18 1.26-.56L35.56 5.8c.33-.33.5-.75.5-1.27 0-.51-.17-.93-.5-1.26L32.96.73a1.57 1.57 0 0 0-1.23-.57c-.5 0-.92.2-1.3.57L13.56 17.6 5.68 9.73c-.37-.38-.8-.57-1.3-.57s-.9.2-1.23.57l-2.6 2.53c-.33.33-.5.75-.5 1.26 0 .52.17.94.5 1.27l11.74 11.67z" fill="#6ab255" fill-rule="nonzero"/></svg>
|
||||
|
After Width: | Height: | Size: 481 B |
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 33 33" xmlns="http://www.w3.org/2000/svg" fill-rule="evenodd" clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.41"><path d="M27.6 32.36a1.93 1.93 0 0 1-1.43.58 1.93 1.93 0 0 1-1.41-.58l-8.09-8.08-8.08 8.08a1.93 1.93 0 0 1-1.42.58 1.93 1.93 0 0 1-1.41-.58l-4.5-4.5a1.93 1.93 0 0 1-.59-1.42c0-.55.2-1.03.59-1.42l8.08-8.08-8.08-8.08a1.93 1.93 0 0 1-.59-1.42c0-.55.2-1.03.59-1.42l4.5-4.5A1.93 1.93 0 0 1 7.18.95c.55 0 1.02.2 1.41.58l8.09 8.09 8.08-8.08a1.93 1.93 0 0 1 1.41-.59c.56 0 1.03.2 1.42.59l4.5 4.5c.4.38.58.86.58 1.41 0 .56-.19 1.03-.58 1.42l-8.08 8.08 8.08 8.08c.4.4.58.87.58 1.42 0 .56-.19 1.03-.58 1.42l-4.5 4.5z" fill="#d83638" fill-rule="nonzero"/></svg>
|
||||
|
After Width: | Height: | Size: 698 B |
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 53 53" xmlns="http://www.w3.org/2000/svg" fill-rule="evenodd" clip-rule="evenodd" stroke-miterlimit="10"><path d="M37.6 42.36a1.93 1.93 0 0 1-1.43.58 1.93 1.93 0 0 1-1.41-.58l-8.08-8.08-8.09 8.08a1.93 1.93 0 0 1-1.42.58 1.93 1.93 0 0 1-1.41-.58l-4.5-4.5a1.93 1.93 0 0 1-.59-1.42c0-.55.2-1.03.59-1.41l8.08-8.09-8.08-8.08a1.93 1.93 0 0 1-.59-1.42c0-.55.2-1.03.59-1.41l4.5-4.5a1.93 1.93 0 0 1 1.41-.59 1.93 1.93 0 0 1 1.42.59l8.09 8.08 8.08-8.08a1.93 1.93 0 0 1 1.42-.59 1.93 1.93 0 0 1 1.41.59l4.5 4.5c.4.38.58.86.58 1.41 0 .56-.19 1.03-.58 1.42l-8.08 8.08 8.08 8.09c.4.38.58.86.58 1.41s-.19 1.03-.58 1.42l-4.5 4.5z" fill="none" stroke="#d83638" stroke-width="2" stroke-dasharray="4,2"/></svg>
|
||||
|
After Width: | Height: | Size: 709 B |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1 @@
|
||||
<svg width="34" height="34" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M17 .984c2.917 0 5.599.73 8.047 2.188a15.401 15.401 0 0 1 5.781 5.781c1.458 2.448 2.188 5.13 2.188 8.047 0 2.917-.73 5.599-2.188 8.047-1.406 2.448-3.333 4.4-5.781 5.86-2.448 1.405-5.13 2.109-8.047 2.109-2.917 0-5.599-.703-8.047-2.11a16.617 16.617 0 0 1-5.86-5.86C1.689 22.6.985 19.917.985 17s.704-5.599 2.11-8.047c1.458-2.448 3.411-4.375 5.86-5.781C11.4 1.714 14.083.984 17 .984Zm2.266 18.75.703-12.89H14.03l.703 12.89h4.532Zm-.157 6.72c.47-.47.703-1.095.703-1.876 0-.833-.234-1.484-.703-1.953-.468-.469-1.172-.703-2.109-.703-.938 0-1.667.234-2.188.703-.468.469-.703 1.12-.703 1.953 0 .781.235 1.406.704 1.875.52.469 1.223.703 2.109.703.937 0 1.666-.234 2.187-.703Z" fill="#D63638"/></svg>
|
||||
|
After Width: | Height: | Size: 773 B |
@@ -0,0 +1 @@
|
||||
<svg width="16" height="17" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M8 .5a7.71 7.71 0 0 0-4.02 1.093A7.988 7.988 0 0 0 1.054 4.48C.35 5.703 0 7.043 0 8.5c0 1.457.351 2.797 1.054 4.02a8.3 8.3 0 0 0 2.926 2.926C5.203 16.15 6.543 16.5 8 16.5c1.457 0 2.797-.351 4.02-1.054a7.986 7.986 0 0 0 2.887-2.926A7.709 7.709 0 0 0 16 8.5a7.709 7.709 0 0 0-1.093-4.02 7.693 7.693 0 0 0-2.887-2.887A7.71 7.71 0 0 0 8 .5zm-.624 12.644H6.049l-3.24-4.527 1.328-1.249 2.575 2.42 5.151-5.932 1.327.937-5.814 8.35z" fill="#6AA08B"/></svg>
|
||||
|
After Width: | Height: | Size: 532 B |
@@ -0,0 +1 @@
|
||||
<svg width="16" height="17" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M8 .5a7.71 7.71 0 0 0-4.02 1.093A7.988 7.988 0 0 0 1.054 4.48C.35 5.703 0 7.043 0 8.5c0 1.457.351 2.797 1.054 4.02a8.3 8.3 0 0 0 2.926 2.926C5.203 16.15 6.543 16.5 8 16.5c1.457 0 2.797-.351 4.02-1.054a7.986 7.986 0 0 0 2.887-2.926A7.709 7.709 0 0 0 16 8.5a7.709 7.709 0 0 0-1.093-4.02 7.693 7.693 0 0 0-2.887-2.887A7.71 7.71 0 0 0 8 .5zm-.624 12.644H6.049l-3.24-4.527 1.328-1.249 2.575 2.42 5.151-5.932 1.327.937-5.814 8.35z" fill="#A7AAAD"/></svg>
|
||||
|
After Width: | Height: | Size: 532 B |
@@ -0,0 +1 @@
|
||||
<svg width="17" height="13" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1.87.5h13.208c1.247 0 1.87.597 1.87 1.792v8.416c0 1.195-.623 1.792-1.87 1.792H1.87C.623 12.5 0 11.903 0 10.708V2.292C0 1.097.623.5 1.87.5zm6.624 8.61 6.7-5.532a.83.83 0 0 0 .273-.468c.053-.207 0-.402-.155-.584a.71.71 0 0 0-.546-.35.937.937 0 0 0-.623.155l-5.65 3.857-5.688-3.857a.87.87 0 0 0-.623-.156.71.71 0 0 0-.546.351.805.805 0 0 0-.155.584c.051.208.142.364.272.468l6.74 5.532z" fill="#809EB0"/></svg>
|
||||
|
After Width: | Height: | Size: 491 B |
@@ -0,0 +1 @@
|
||||
<svg width="16" height="17" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M8 .5a7.71 7.71 0 0 1 4.02 1.093 7.693 7.693 0 0 1 2.887 2.887A7.709 7.709 0 0 1 16 8.5a7.709 7.709 0 0 1-1.093 4.02 7.986 7.986 0 0 1-2.887 2.926C10.796 16.15 9.457 16.5 8 16.5c-1.457 0-2.797-.351-4.02-1.054a8.3 8.3 0 0 1-2.926-2.926C.35 11.296 0 9.957 0 8.5c0-1.457.351-2.797 1.054-4.02A7.988 7.988 0 0 1 3.98 1.593 7.71 7.71 0 0 1 8 .5zm4.995 11.005L9.99 8.5l3.005-3.005-1.99-1.99L8 6.51 4.995 3.505l-1.99 1.99L6.01 8.5l-3.005 3.005 1.99 1.99L8 10.49l3.005 3.005 1.99-1.99z" fill="#D63638"/></svg>
|
||||
|
After Width: | Height: | Size: 584 B |
@@ -0,0 +1 @@
|
||||
<svg width="16" height="17" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M8 .5a7.71 7.71 0 0 0-4.02 1.093A7.988 7.988 0 0 0 1.054 4.48C.35 5.703 0 7.043 0 8.5c0 1.457.351 2.797 1.054 4.02a8.3 8.3 0 0 0 2.926 2.926C5.203 16.15 6.543 16.5 8 16.5c1.457 0 2.797-.351 4.02-1.054a7.986 7.986 0 0 0 2.887-2.926A7.709 7.709 0 0 0 16 8.5a7.709 7.709 0 0 0-1.093-4.02 7.693 7.693 0 0 0-2.887-2.887A7.71 7.71 0 0 0 8 .5zm-.624 12.644H6.049l-3.24-4.527 1.328-1.249 2.575 2.42 5.151-5.932 1.327.937-5.814 8.35z" fill="#2271B1"/></svg>
|
||||
|
After Width: | Height: | Size: 532 B |
@@ -0,0 +1 @@
|
||||
<svg width="16" height="17" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M8 .5a7.71 7.71 0 0 0-4.02 1.093A7.988 7.988 0 0 0 1.054 4.48C.35 5.703 0 7.043 0 8.5c0 1.457.351 2.797 1.054 4.02a8.3 8.3 0 0 0 2.926 2.926C5.203 16.15 6.543 16.5 8 16.5c1.457 0 2.797-.351 4.02-1.054a7.986 7.986 0 0 0 2.887-2.926A7.709 7.709 0 0 0 16 8.5a7.709 7.709 0 0 0-1.093-4.02 7.693 7.693 0 0 0-2.887-2.887A7.71 7.71 0 0 0 8 .5zm-.624 12.644H6.049l-3.24-4.527 1.328-1.249 2.575 2.42 5.151-5.932 1.327.937-5.814 8.35z" fill="#A7AAAD"/></svg>
|
||||
|
After Width: | Height: | Size: 532 B |
@@ -0,0 +1 @@
|
||||
<svg width="17" height="13" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1.87.5h13.208c1.247 0 1.87.597 1.87 1.792v8.416c0 1.195-.623 1.792-1.87 1.792H1.87C.623 12.5 0 11.903 0 10.708V2.292C0 1.097.623.5 1.87.5zm6.624 8.61 6.7-5.532a.83.83 0 0 0 .273-.468c.053-.207 0-.402-.155-.584a.71.71 0 0 0-.546-.35.937.937 0 0 0-.623.155l-5.65 3.857-5.688-3.857a.87.87 0 0 0-.623-.156.71.71 0 0 0-.546.351.805.805 0 0 0-.155.584c.051.208.142.364.272.468l6.74 5.532z" fill="#646970"/></svg>
|
||||
|
After Width: | Height: | Size: 491 B |
@@ -0,0 +1 @@
|
||||
<svg width="16" height="17" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M8 .5a7.71 7.71 0 0 1 4.02 1.093 7.693 7.693 0 0 1 2.887 2.887A7.709 7.709 0 0 1 16 8.5a7.709 7.709 0 0 1-1.093 4.02 7.986 7.986 0 0 1-2.887 2.926C10.796 16.15 9.457 16.5 8 16.5c-1.457 0-2.797-.351-4.02-1.054a8.3 8.3 0 0 1-2.926-2.926C.35 11.296 0 9.957 0 8.5c0-1.457.351-2.797 1.054-4.02A7.988 7.988 0 0 1 3.98 1.593 7.71 7.71 0 0 1 8 .5zm4.995 11.005L9.99 8.5l3.005-3.005-1.99-1.99L8 6.51 4.995 3.505l-1.99 1.99L6.01 8.5l-3.005 3.005 1.99 1.99L8 10.49l3.005 3.005 1.99-1.99z" fill="#D63638"/></svg>
|
||||
|
After Width: | Height: | Size: 584 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="#6aa08b"><path fill="none" d="M0 0h20v20H0z"/><path d="M10 2c-4.42 0-8 3.58-8 8s3.58 8 8 8 8-3.58 8-8-3.58-8-8-8zm-.615 12.66h-1.34l-3.24-4.54 1.34-1.25 2.57 2.4 5.14-5.93 1.34.94-5.81 8.38z"/></svg>
|
||||
|
After Width: | Height: | Size: 265 B |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1 @@
|
||||
<svg width="20" height="20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M20 10.06C20 4.504 15.524 0 10 0S0 4.503 0 10.06c0 5.031 3.629 9.21 8.427 9.94v-7.018h-2.54V10.06h2.54V7.87c0-2.516 1.492-3.936 3.75-3.936 1.13 0 2.258.203 2.258.203v2.475h-1.25c-1.25 0-1.653.77-1.653 1.582v1.866h2.783l-.444 2.92h-2.339V20C16.331 19.27 20 15.091 20 10.06z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 377 B |
@@ -0,0 +1 @@
|
||||
<svg width="20" height="20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M10 0C4.476 0 0 4.516 0 10c0 5.524 4.476 10 10 10 5.484 0 10-4.476 10-10 0-5.484-4.516-10-10-10zm6.976 4.839-2.54 2.54a5.574 5.574 0 0 0-1.815-1.814l2.54-2.54c.565.402 1.412 1.25 1.815 1.814zM10 13.87c-2.177 0-3.871-1.694-3.871-3.871A3.846 3.846 0 0 1 10 6.129 3.872 3.872 0 0 1 13.871 10 3.846 3.846 0 0 1 10 13.871zM4.798 3.024l2.54 2.54A5.574 5.574 0 0 0 5.525 7.38l-2.54-2.54c.403-.565 1.25-1.412 1.814-1.815zM2.984 15.202l2.54-2.54a5.573 5.573 0 0 0 1.815 1.814l-2.54 2.54c-.565-.403-1.412-1.25-1.815-1.814zm12.177 1.814-2.54-2.54a5.574 5.574 0 0 0 1.814-1.815l2.54 2.54c-.402.565-1.25 1.412-1.814 1.815z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 714 B |
@@ -0,0 +1 @@
|
||||
<svg width="14" height="20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M3.818 17.739c0 .234.04.468.2.701l.675.975c.2.312.676.585 1.074.585h2.426c.398 0 .875-.273 1.074-.585l.676-.974c.12-.195.199-.507.199-.702v-1.52H3.818v1.52zM0 6.862a6.73 6.73 0 0 0 1.71 4.522c.676.74 1.67 2.3 2.068 3.587v.039h6.404v-.04c.397-1.286 1.392-2.845 2.068-3.586C13.324 10.175 14 8.616 14 6.862 14 3.08 10.818 0 6.96 0 2.903.039 0 3.236 0 6.862zm7-3.12c-1.79 0-3.182 1.404-3.182 3.12 0 .35-.318.623-.636.623a.622.622 0 0 1-.637-.623c0-2.379 1.99-4.367 4.455-4.367.318 0 .636.312.636.624 0 .35-.318.624-.636.624z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 625 B |
@@ -0,0 +1 @@
|
||||
<svg width="55" height="55" fill="none" xmlns="http://www.w3.org/2000/svg"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M39.126 21.427c1.601-.069 2.987 1.788 3.095 4.147.11 2.359-1.1 4.327-2.7 4.396-1.602.07-2.987-1.787-3.096-4.146-.108-2.36 1.1-4.328 2.701-4.397zm-23.386.214c1.581 0 2.863 1.893 2.863 4.227 0 2.335-1.282 4.228-2.862 4.228-1.581 0-2.863-1.893-2.863-4.227 0-2.335 1.282-4.228 2.863-4.228z" fill="#395360"/><path d="M33.795 26.785H21.833v13.658h11.962V26.785z" fill="#FBAA6F"/><path d="m29.708 33.81-.1-.098c-.598-.488-.797-1.269-.298-1.951.498-.586 1.296-.78 1.993-.293-.398.098-.897.293-1.196.78a1.88 1.88 0 0 0-.399 1.561zm-4.486 0c.1-.391 0-.976-.399-1.464-.299-.488-.797-.683-1.196-.78.598-.488 1.496-.293 1.994.292s.3 1.463-.299 1.951c0-.097 0 0-.1 0zm7.278-3.123h.498l-1.096 4.683-2.094 6.244h-4.287l-3.19-5.268 1.097-3.122a200.504 200.504 0 0 1 2.093 2.634c.499.488 2.692.488 3.789-.488.697-.683 1.694-2.244 3.19-4.682z" fill="#DC7F3C"/><path d="M13.658 29.712h5.284v-8.78h-4.087c.598-4.39 3.588-8 7.476-9.755.798-3.317 1.695-5.463 2.692-6.634l.2-.195.1-.098c.298-.195.697-.39 1.096-.487 1.296-.195 2.492.683 2.691 1.853.1.683-.1 1.268-.498 1.756l-.1.098-.299.292c-.498.488-.897.976-1.396 1.464 1.197-.976 2.194-1.464 2.991-1.464.2 0 .399.098.698.195.598.39.897 1.171.498 1.756a4.203 4.203 0 0 1-.598.683c5.284 1.073 9.371 5.366 10.069 10.731l.1.78h-3.789v7.805h4.785l1.795 13.072c-4.187 2.537-9.47 3.805-15.751 3.805-6.28 0-11.564-1.268-15.751-3.805l1.794-13.072zm15.054 9.17c2.591-4.195 3.887-6.634 3.887-7.414 0-2.146-3.19-4-4.884-4-1.695 0-4.885 1.756-4.885 4 0 .78 1.296 3.317 3.788 7.414.3.39.598.585 1.097.585.498 0 .797-.195.997-.585z" fill="#BDCFC8"/><path d="M45.26 59.2c-3.587 5.528-11.08.223-17.655.223-6.575 0-12.354-3.61-15.94-9.024l.92-7.24c.3.112.574.585.873.585 1.395 0 2.59-1.24 2.79-2.707v1.354c0 1.804 1.295 3.158 2.789 3.158 1.494 0 2.79-1.466 2.79-3.158v-1.354 3.158c0 1.805 1.295 3.159 2.79 3.159 1.494 0 2.789-1.467 2.789-3.159 0 1.805 1.295 3.159 2.79 3.159 1.593 0 2.789-1.467 2.789-3.159v-3.158 1.354c0 1.804 1.295 3.158 2.79 3.158 1.594 0 2.79-1.466 2.79-3.158v-1.354c.198 1.58 1.394 2.707 2.789 2.707.398 0 .697-.113.996-.225L45.26 59.2z" fill="#809EB0"/><path d="m12.367 45.016.19-1.886c.298.097.695.194.992.194 1.39 0 2.581-1.069 2.78-2.331v1.165c0 1.554 1.29 2.72 2.779 2.72 1.489 0 2.78-1.263 2.78-2.72v-1.165 2.72c0 1.553 1.29 2.72 2.778 2.72 1.49 0 2.78-1.264 2.78-2.72 0 1.553 1.29 2.72 2.779 2.72 1.588 0 2.78-1.264 2.78-2.72v-2.72 1.165c0 1.554 1.29 2.72 2.778 2.72 1.589 0 2.78-1.263 2.78-2.72v-1.165c.198 1.36 1.39 2.33 2.779 2.33.397 0 .695-.096.992-.193l.298 1.845a2.796 2.796 0 0 1-1.29.291c-1.39 0-2.58-1.068-2.78-2.33V44.1c0 1.554-1.29 2.72-2.779 2.72-1.588 0-2.779-1.263-2.779-2.72v-1.166 2.72c0 1.554-1.29 2.72-2.779 2.72-1.588 0-2.78-1.263-2.78-2.72 0 1.554-1.29 2.72-2.779 2.72-1.488 0-2.779-1.263-2.779-2.72v-2.72V44.1c0 1.554-1.29 2.72-2.779 2.72-1.489 0-2.78-1.263-2.78-2.72v-1.166c-.198 1.36-1.389 2.331-2.778 2.331-.497 0-.885-.153-1.183-.25z" fill="#738E9E"/><path d="M41.571 23.273c-.997-2.829-2.99-4.585-4.685-4.39-2.193.196-2.692 3.707-2.293 7.805.399 4.097 1.695 7.414 3.888 7.121 2.093-.195 3.987-3.804 3.688-7.804-.1 1.17-.498 2.244-1.395 2.341-1.097.098-1.396-1.17-1.595-2.731-.1-1.561-.1-2.927.997-3.024.598-.098 1.096.195 1.395.682z" fill="#86A196"/><path d="M40.774 22.688c-.598-1.17-1.495-1.854-2.393-1.854-1.495.196-1.894 2.537-1.595 5.366.3 2.83 1.197 5.073 2.692 4.878 1.097-.098 1.894-1.464 2.193-3.317-.2.293-.498.488-.897.585-1.097.098-1.396-1.17-1.595-2.731-.1-1.561-.1-2.927.997-3.024.2 0 .399 0 .598.097z" fill="#fff"/><path d="M13.459 23.273c.997-2.829 2.99-4.585 4.685-4.39 2.194.196 2.692 3.707 2.293 7.805-.399 4.097-1.695 7.414-3.888 7.121-2.093-.195-3.987-3.804-3.688-7.804.1 1.17.498 2.244 1.396 2.341 1.096.098 1.395-1.17 1.595-2.731.1-1.561.1-2.927-.997-3.024-.499-.098-.997.195-1.396.682z" fill="#86A196"/><path d="M14.121 22.653c.598-1.171 1.495-1.854 2.393-1.854 1.495.195 1.894 2.537 1.595 5.366-.3 2.829-1.197 5.072-2.692 4.877-1.097-.097-1.894-1.463-2.094-3.317.2.293.499.488.898.586 1.096.097 1.395-1.17 1.595-2.732.1-1.56.1-2.926-.997-3.024-.3 0-.499 0-.698.098z" fill="#fff"/><path d="M22.83 29.322c0-.195-.1-.488-.1-.683 0-2.439 1.196-5.17 4.885-5.17 3.688 0 4.884 2.731 4.884 5.17 0 .39 0 .683-.1.975-.797-1.268-2.292-2.146-4.784-2.146-2.293.098-3.888.78-4.785 1.854z" fill="#F4F8FF"/><path d="M30.486 10.396c-1.794-.097-2.89-.195-3.19-.195l3.888-1.17c0 .585-.2.975-.698 1.365zm-3.589-1.229-1.296.975c.898-1.17 1.595-2.439 1.994-3.61.3-.682.399-1.56.3-2.536.896.39 1.295 1.171 1.295 2.147 0 .39-.299 1.073-.797 1.463-.3.293-.798.78-1.496 1.56z" fill="#86A196"/></g><defs><clipPath id="a"><rect width="55" height="55" rx="27.5" fill="#fff"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 4.7 KiB |
@@ -0,0 +1 @@
|
||||
<svg width="20" height="20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M8.934.688 6.503 5.882l-5.499.82C.032 6.858-.342 8.107.37 8.849l3.927 4.022-.935 5.662c-.15 1.015.898 1.796 1.758 1.328l4.9-2.695 4.862 2.695c.86.468 1.908-.313 1.758-1.328l-.935-5.662 3.927-4.022c.711-.742.337-1.991-.635-2.147l-5.461-.82-2.47-5.194a1.17 1.17 0 0 0-2.132 0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 379 B |
@@ -0,0 +1 @@
|
||||
<svg width="12" height="12" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M11.719.188c.14 0 .281.14.281.28v4.5c0 .165-.14.282-.281.282h-4.5a.27.27 0 0 1-.282-.281v-.633a.27.27 0 0 1 .282-.281l2.953.07C9.445 2.531 7.852 1.406 6 1.406A4.586 4.586 0 0 0 1.406 6 4.571 4.571 0 0 0 6 10.594a4.546 4.546 0 0 0 3.047-1.149c.117-.093.281-.093.375 0l.469.47a.297.297 0 0 1 0 .42A5.889 5.889 0 0 1 6 11.813c-3.21 0-5.813-2.578-5.813-5.789C.165 2.836 2.767.211 5.978.211a5.732 5.732 0 0 1 4.851 2.578L10.781.492c0-.164.117-.304.281-.304h.657Z" fill="#2271B1"/></svg>
|
||||
|
After Width: | Height: | Size: 565 B |
@@ -0,0 +1 @@
|
||||
<svg width="11" height="11" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M5.5.047a5.327 5.327 0 0 1 5.328 5.328A5.327 5.327 0 0 1 5.5 10.703 5.327 5.327 0 0 1 .172 5.375 5.327 5.327 0 0 1 5.5.047Zm2.6 6.746L6.703 5.375 8.1 3.979c.107-.086.107-.258 0-.366l-.838-.838c-.108-.107-.28-.107-.366 0L5.5 4.172 4.104 2.775c-.108-.107-.28-.107-.366 0l-.86.838c-.107.108-.107.28 0 .366l1.419 1.396-1.418 1.396c-.108.108-.108.28 0 .366l.838.86c.107.107.28.107.365 0L5.5 6.577l1.396 1.418c.086.108.258.108.366 0l.838-.838c.107-.107.107-.28 0-.365Z" fill="#D63638"/></svg>
|
||||
|
After Width: | Height: | Size: 570 B |
@@ -0,0 +1 @@
|
||||
<svg width="11" height="11" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M10.828 5.375c0 2.943-2.406 5.328-5.328 5.328A5.327 5.327 0 0 1 .172 5.375C.172 2.453 2.557.047 5.5.047c2.922 0 5.328 2.406 5.328 5.328ZM4.877 8.211 8.83 4.258a.364.364 0 0 0 0-.494l-.494-.473a.304.304 0 0 0-.473 0L4.641 6.514 3.115 5.01a.304.304 0 0 0-.472 0l-.495.472a.364.364 0 0 0 0 .495L4.383 8.21a.364.364 0 0 0 .494 0Z" fill="#008A20"/></svg>
|
||||
|
After Width: | Height: | Size: 433 B |
@@ -0,0 +1 @@
|
||||
<svg aria-hidden="true" data-prefix="fas" data-icon="check-circle" class="svg-inline--fa fa-check-circle fa-w-16" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="#00A32A" d="M504 256a248 248 0 1 1-496 0 248 248 0 0 1 496 0zM227 387l184-184c7-6 7-16 0-22l-22-23c-7-6-17-6-23 0L216 308l-70-70c-6-6-16-6-23 0l-22 23c-7 6-7 16 0 22l104 104c6 7 16 7 22 0z"/></svg>
|
||||
|
After Width: | Height: | Size: 380 B |
@@ -0,0 +1 @@
|
||||
<svg width="11" height="11" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M5.5.047a5.327 5.327 0 0 1 5.328 5.328A5.327 5.327 0 0 1 5.5 10.703 5.327 5.327 0 0 1 .172 5.375 5.327 5.327 0 0 1 5.5.047ZM7.477 6.77a.328.328 0 0 0 .085-.214c0-.108-.064-.194-.128-.258l-1.247-.924V2.281a.37.37 0 0 0-.343-.344h-.688a.353.353 0 0 0-.343.344v3.352c0 .28.107.515.322.666l1.44 1.074a.32.32 0 0 0 .193.086.335.335 0 0 0 .279-.15l.43-.538Z" fill="#BD8600"/></svg>
|
||||
|
After Width: | Height: | Size: 459 B |
@@ -0,0 +1 @@
|
||||
<svg aria-hidden="true" data-prefix="far" data-icon="exclamation-circle" class="svg-inline--fa fa-exclamation-circle fa-w-16" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="#D63638" d="M256 8a248 248 0 1 0 0 496 248 248 0 0 0 0-496zm0 448a200 200 0 1 1-.1-400.1A200 200 0 0 1 256 456zm42-104a42 42 0 1 1-84 0 42 42 0 0 1 84 0zm-81.4-211.4 6.8 136a12 12 0 0 0 12 11.4h41.2a12 12 0 0 0 12-11.4l6.8-136a12 12 0 0 0-12-12.6h-54.8a12 12 0 0 0-12 12.6z"/></svg>
|
||||
|
After Width: | Height: | Size: 477 B |
@@ -0,0 +1 @@
|
||||
<svg aria-hidden="true" data-prefix="fas" data-icon="exclamation-circle" class="svg-inline--fa fa-exclamation-circle fa-w-16" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="#E27730" d="M504 256a248 248 0 1 1-496 0 248 248 0 0 1 496 0zm-248 50a46 46 0 1 0 0 92 46 46 0 0 0 0-92zm-43.7-165.3 7.4 136a12 12 0 0 0 12 11.3h48.6a12 12 0 0 0 12-11.3l7.4-136a12 12 0 0 0-12-12.7h-63.4a12 12 0 0 0-12 12.7z"/></svg>
|
||||
|
After Width: | Height: | Size: 428 B |
@@ -0,0 +1 @@
|
||||
<svg aria-hidden="true" data-prefix="fas" data-icon="exclamation-circle" class="svg-inline--fa fa-exclamation-circle fa-w-16" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="#D63638" d="M504 256a248 248 0 1 1-496 0 248 248 0 0 1 496 0zm-248 50a46 46 0 1 0 0 92 46 46 0 0 0 0-92zm-43.7-165.3 7.4 136a12 12 0 0 0 12 11.3h48.6a12 12 0 0 0 12-11.3l7.4-136a12 12 0 0 0-12-12.7h-63.4a12 12 0 0 0-12 12.7z"/></svg>
|
||||
|
After Width: | Height: | Size: 428 B |
@@ -0,0 +1 @@
|
||||
<svg width="46" height="45" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M23 .219C10.691.219.719 10.28.719 22.5.719 34.809 10.69 44.781 23 44.781c12.219 0 22.281-9.972 22.281-22.281C45.281 10.281 35.22.219 23 .219zm0 9.883c2.066 0 3.773 1.707 3.773 3.773 0 2.156-1.707 3.773-3.773 3.773-2.156 0-3.773-1.617-3.773-3.773 0-2.066 1.617-3.773 3.773-3.773zm5.031 22.82c0 .629-.539 1.078-1.078 1.078h-7.906c-.629 0-1.078-.45-1.078-1.078v-2.156c0-.54.449-1.078 1.078-1.078h1.078v-5.75h-1.078c-.629 0-1.078-.45-1.078-1.079v-2.156c0-.539.449-1.078 1.078-1.078h5.75c.539 0 1.078.54 1.078 1.078v8.985h1.078c.54 0 1.078.539 1.078 1.078v2.156z" fill="#3582C4"/></svg>
|
||||
|
After Width: | Height: | Size: 665 B |
@@ -0,0 +1 @@
|
||||
<svg width="14" height="15" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M7 .469c3.719 0 6.781 3.062 6.781 6.781 0 3.746-3.062 6.781-6.781 6.781A6.78 6.78 0 0 1 .219 7.25C.219 3.531 3.254.469 7 .469Zm0 12.25a5.467 5.467 0 0 0 5.469-5.469c0-3.008-2.461-5.469-5.469-5.469A5.467 5.467 0 0 0 1.531 7.25 5.45 5.45 0 0 0 7 12.719Zm0-9.242c.629 0 1.148.52 1.148 1.148 0 .656-.52 1.148-1.148 1.148a1.121 1.121 0 0 1-1.148-1.148c0-.629.492-1.148 1.148-1.148Zm1.531 6.945a.332.332 0 0 1-.328.328H5.797a.316.316 0 0 1-.328-.328v-.656c0-.164.136-.329.328-.329h.328v-1.75h-.328a.316.316 0 0 1-.328-.328v-.656c0-.164.136-.328.328-.328h1.75c.164 0 .328.164.328.328v2.734h.328c.164 0 .328.165.328.329v.656Z" fill="#646970"/></svg>
|
||||
|
After Width: | Height: | Size: 725 B |
@@ -0,0 +1 @@
|
||||
<svg width="14" height="9" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="m12 8.484-5-5-5 5-1.992-.976L7 .516l6.992 6.992L12 8.484z" fill="#999"/></svg>
|
||||
|
After Width: | Height: | Size: 161 B |
@@ -0,0 +1 @@
|
||||
<svg width="16" height="16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M8 0c1.125 0 2.125.656 2.625 1.625 1.031-.344 2.188-.094 3.031.719.813.812 1.031 2 .719 3.031C15.344 5.875 16 6.875 16 8a2.984 2.984 0 0 1-1.625 2.656 2.918 2.918 0 0 1-.719 3 3.07 3.07 0 0 1-3.031.75C10.125 15.375 9.125 16 8 16c-1.156 0-2.156-.625-2.656-1.594a3.07 3.07 0 0 1-3.032-.75c-.812-.812-1.03-1.969-.718-3C.625 10.156 0 9.156 0 8c0-1.125.625-2.125 1.594-2.625a2.972 2.972 0 0 1 .718-3.031c.844-.813 2-1.063 3.032-.719C5.844.656 6.844 0 8 0ZM6 7c.531 0 1-.438 1-1 0-.531-.469-1-1-1-.563 0-1 .469-1 1 0 .563.438 1 1 1Zm4 2c-.563 0-1 .469-1 1 0 .563.438 1 1 1 .531 0 1-.438 1-1 0-.531-.469-1-1-1Zm.5-2.469c.313-.281.313-.75 0-1.062a.736.736 0 0 0-1.031 0l-4 4c-.313.312-.313.781 0 1.062a.684.684 0 0 0 1.031 0l4-4Z" fill="#008A20"/></svg>
|
||||
|
After Width: | Height: | Size: 829 B |
@@ -0,0 +1 @@
|
||||
<svg width="16" height="13" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M15.657 3.04a.98.98 0 0 0-.283-.688L14 .98a.98.98 0 0 0-1.374 0L6 7.615l-2.97-2.98a.98.98 0 0 0-1.373 0L.283 6.01a.98.98 0 0 0 0 1.374l5.03 5.03a.98.98 0 0 0 1.374 0l8.687-8.687a.98.98 0 0 0 .283-.687z" fill="#00A32A"/></svg>
|
||||
|
After Width: | Height: | Size: 309 B |
@@ -0,0 +1 @@
|
||||
<svg width="10" height="7" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M9.18.32a.602.602 0 0 1 0 .88l-5 5a.602.602 0 0 1-.88 0L.8 3.7a.602.602 0 0 1 0-.88.602.602 0 0 1 .88 0l2.07 2.051L8.3.321a.602.602 0 0 1 .88 0Z" fill="#008A20"/></svg>
|
||||
|
After Width: | Height: | Size: 251 B |
@@ -0,0 +1 @@
|
||||
<svg width="16" height="16" fill="none" xmlns="http://www.w3.org/2000/svg"><path opacity=".5" d="M8 .25A7.749 7.749 0 0 0 .25 8 7.749 7.749 0 0 0 8 15.75 7.749 7.749 0 0 0 15.75 8 7.749 7.749 0 0 0 8 .25Zm0 14A6.228 6.228 0 0 1 1.75 8 6.248 6.248 0 0 1 8 1.75c3.438 0 6.25 2.813 6.25 6.25A6.248 6.248 0 0 1 8 14.25Zm3.156-8.188c.156-.125.156-.375 0-.53l-.687-.688c-.156-.157-.406-.157-.531 0L8 6.78 6.031 4.844c-.125-.157-.375-.157-.531 0l-.688.687c-.156.157-.156.407 0 .532L6.75 8 4.812 9.969c-.156.125-.156.375 0 .531l.688.688c.156.156.406.156.531 0L8 9.25l1.938 1.938c.124.156.374.156.53 0l.688-.688c.156-.156.156-.406 0-.531L9.22 8l1.937-1.938Z" fill="#6F6F84"/></svg>
|
||||
|
After Width: | Height: | Size: 672 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="#999DA0"><path fill="none" d="M0 0h20v20H0z"/><path d="M5.8 14H5v1h.8c.3 0 .5-.2.5-.5s-.2-.5-.5-.5zM11 2H3v16h13V7l-5-5zM7.2 14.6c0 .8-.6 1.4-1.4 1.4H5v1H4v-4h1.8c.8 0 1.4.6 1.4 1.4v.2zm4.1.5c0 1-.8 1.9-1.9 1.9H8v-4h1.4c1 0 1.9.8 1.9 1.9v.2zM15 14h-2v1h1.5v1H13v1h-1v-4h3v1zm0-2H4V3h7v4h4v5zm-5.6 2H9v2h.4c.6 0 1-.4 1-1s-.5-1-1-1z"/></svg>
|
||||
|
After Width: | Height: | Size: 405 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="#00A32A"><path fill="none" d="M0 0h20v20H0z"/><path d="M10 2c-4.42 0-8 3.58-8 8s3.58 8 8 8 8-3.58 8-8-3.58-8-8-8zm-.615 12.66h-1.34l-3.24-4.54 1.34-1.25 2.57 2.4 5.14-5.93 1.34.94-5.81 8.38z"/></svg>
|
||||
|
After Width: | Height: | Size: 265 B |
@@ -0,0 +1 @@
|
||||
<svg width="16" height="16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M8 .25A7.749 7.749 0 0 0 .25 8 7.749 7.749 0 0 0 8 15.75 7.749 7.749 0 0 0 15.75 8 7.749 7.749 0 0 0 8 .25zm3.781 9.813c.156.124.156.374 0 .53l-1.219 1.22c-.156.156-.406.156-.53 0L8 9.75l-2.063 2.063c-.125.156-.375.156-.53 0l-1.22-1.25c-.156-.126-.156-.376 0-.532L6.25 8 4.187 5.969c-.156-.125-.156-.375 0-.532l1.25-1.218c.125-.157.375-.157.532 0L8 6.25l2.031-2.031c.125-.157.375-.157.531 0l1.22 1.218c.155.157.155.407 0 .532L9.75 8l2.031 2.063z" fill="#d63638"/></svg>
|
||||
|
After Width: | Height: | Size: 553 B |
@@ -0,0 +1 @@
|
||||
<svg width="16" height="17" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M16 8.5C16 4.25 12.5.75 8.25.75 3.969.75.5 4.25.5 8.5a7.749 7.749 0 0 0 7.75 7.75c4.25 0 7.75-3.469 7.75-7.75zm-7.75 1.563c.781 0 1.438.656 1.438 1.437 0 .813-.657 1.438-1.438 1.438A1.414 1.414 0 0 1 6.812 11.5c0-.781.625-1.438 1.438-1.438zM6.875 4.905c-.031-.218.156-.406.375-.406h1.969c.219 0 .406.188.375.406l-.219 4.25c-.031.219-.188.344-.375.344H7.469c-.188 0-.344-.125-.375-.344l-.219-4.25z" fill="#D63637"/></svg>
|
||||
|
After Width: | Height: | Size: 504 B |
@@ -0,0 +1 @@
|
||||
<svg width="18" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M5.75 19.969c0 .117 0 .234.078.351l.977 1.407a.57.57 0 0 0 .508.273h3.085a.569.569 0 0 0 .508-.273l.977-1.407a1.14 1.14 0 0 0 .078-.351V18.25H5.75v1.719zM8.875 14.5c.664 0 1.25-.547 1.25-1.25 0-.664-.586-1.25-1.25-1.25-.703 0-1.25.586-1.25 1.25 0 .703.547 1.25 1.25 1.25zm0-12.5C4.852 2 2 5.242 2 8.875a6.82 6.82 0 0 0 1.68 4.531c.664.742 1.68 2.305 2.03 3.594h1.876c0-.156 0-.352-.078-.547-.196-.703-.86-2.5-2.422-4.258a5.186 5.186 0 0 1-1.211-3.32c-.04-2.852 2.305-5 5-5 2.734 0 5 2.266 5 5 0 1.21-.469 2.383-1.25 3.32-1.523 1.72-2.227 3.555-2.422 4.258-.078.195-.078.352-.078.547H12c.352-1.29 1.367-2.852 2.031-3.594 1.055-1.21 1.719-2.773 1.719-4.531C15.75 5.085 12.664 2 8.875 2zm-.39 8.75h.742a.632.632 0 0 0 .625-.547l.507-3.75c.04-.351-.273-.703-.625-.703H7.977c-.352 0-.665.352-.625.703l.507 3.75c.04.313.313.547.625.547z" fill="#888"/></svg>
|
||||
|
After Width: | Height: | Size: 935 B |