Integrate MLS listings with property map and add smart sync
Property Map: - Replace ACF-based property display with MLS database queries - Use real lat/lng coordinates from MLS (100% coverage) - Create property-card-mls.php template for MLS property cards - Update AJAX handler to filter MLS properties MLS Plugin Enhancements: - Add 'wp mls run' smart sync command (auto-detects full/incremental/resume) - Add database index migrations for lat/lng and composite search indexes - Add comprehensive README.md documentation Documentation: - Update site README.md with sysadmin quick reference - Add FEATURES_PENDING_12_15.md tracking client feature requests 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,637 @@
|
||||
# MLS by HansonXyz
|
||||
|
||||
WordPress plugin for syncing MLS Grid API data (NorthStar MLS) into a local database with WP-CLI tools and a public API for themes and plugins.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Features](#features)
|
||||
- [Requirements](#requirements)
|
||||
- [Installation](#installation)
|
||||
- [Configuration](#configuration)
|
||||
- [Running Sync](#running-sync)
|
||||
- [WP-CLI Commands](#wp-cli-commands)
|
||||
- [Cron Setup](#cron-setup)
|
||||
- [Public API](#public-api)
|
||||
- [Database Schema](#database-schema)
|
||||
- [Media Handling](#media-handling)
|
||||
- [Sync Strategy](#sync-strategy)
|
||||
- [Error Recovery](#error-recovery)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
## Features
|
||||
|
||||
- Syncs Active and Pending property listings from MLS Grid API
|
||||
- Automatic incremental updates via replication
|
||||
- On-demand image fetching and local caching
|
||||
- Self-healing sync with automatic error recovery
|
||||
- Rate limit compliance (MLS Grid limits enforced)
|
||||
- Resume capability for interrupted syncs
|
||||
- WP-CLI commands for all operations
|
||||
- Public PHP API for theme/plugin integration
|
||||
- Optimized database indexes for search queries
|
||||
|
||||
## Requirements
|
||||
|
||||
- WordPress 5.0+
|
||||
- PHP 7.4+
|
||||
- MySQL 5.7+ or MariaDB 10.2+
|
||||
- WP-CLI (for command-line operations)
|
||||
- MLS Grid API access token
|
||||
|
||||
## Installation
|
||||
|
||||
1. Upload the `mls-by-hansonxyz` folder to `/wp-content/plugins/`
|
||||
2. Activate the plugin through WordPress admin
|
||||
3. Configure API credentials (see Configuration)
|
||||
4. Run initial sync: `wp mls run`
|
||||
|
||||
## Configuration
|
||||
|
||||
### API Credentials
|
||||
|
||||
Add to your `wp-config.php`:
|
||||
|
||||
```php
|
||||
define('MLSGRID_API_URL', 'https://api.mlsgrid.com/v2');
|
||||
define('MLSGRID_ACCESS_TOKEN', 'your-access-token-here');
|
||||
```
|
||||
|
||||
### WordPress Admin Settings
|
||||
|
||||
Navigate to **Settings > MLS Settings** to configure:
|
||||
|
||||
| Setting | Description | Default |
|
||||
|---------|-------------|---------|
|
||||
| Originating System | MLS identifier | `northstar` |
|
||||
| Auto Sync | Enable WP-Cron sync | Disabled |
|
||||
| Sync Interval | WP-Cron frequency | Hourly |
|
||||
|
||||
## Running Sync
|
||||
|
||||
### Smart Sync (Recommended)
|
||||
|
||||
The `wp mls run` command handles all scenarios automatically:
|
||||
|
||||
```bash
|
||||
wp mls run # Smart sync with progress
|
||||
wp mls run --quiet # Status messages only
|
||||
wp mls run --verbose # Full API details
|
||||
wp mls run --silent # For cron (exit code only)
|
||||
```
|
||||
|
||||
**Automatic behavior:**
|
||||
- If no data exists: runs full sync
|
||||
- If data exists: runs incremental sync
|
||||
- If previous sync failed: resumes from checkpoint
|
||||
- If sync already running: safely aborts
|
||||
|
||||
### Manual Sync Commands
|
||||
|
||||
For more control over sync operations:
|
||||
|
||||
```bash
|
||||
# Full sync (Active/Pending properties only)
|
||||
wp mls sync full
|
||||
|
||||
# Incremental sync (changes since last sync)
|
||||
wp mls sync incremental
|
||||
|
||||
# Resume a specific failed sync
|
||||
wp mls sync resume --id=<sync_id>
|
||||
|
||||
# Dry run (no changes)
|
||||
wp mls sync full --dry-run --limit=100
|
||||
```
|
||||
|
||||
### Progress Indicators
|
||||
|
||||
During sync, progress characters indicate activity:
|
||||
|
||||
| Symbol | Meaning |
|
||||
|--------|---------|
|
||||
| `.` | Property created |
|
||||
| `#` | Property updated |
|
||||
| `x` | Property deleted |
|
||||
| `!` | Error occurred |
|
||||
| `\|` | Page complete |
|
||||
|
||||
Use `--verbose` for detailed timestamped output.
|
||||
|
||||
## WP-CLI Commands
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
wp mls test connection # Test API connectivity
|
||||
wp mls test auth # Verify authentication
|
||||
```
|
||||
|
||||
### Status and Statistics
|
||||
|
||||
```bash
|
||||
wp mls status # Full status overview
|
||||
wp mls status rate-limits # Rate limit usage only
|
||||
wp mls stats # Database statistics
|
||||
```
|
||||
|
||||
### Sync Operations
|
||||
|
||||
```bash
|
||||
# Smart sync (recommended)
|
||||
wp mls run [--quiet] [--verbose] [--silent]
|
||||
|
||||
# Manual sync
|
||||
wp mls sync full [--dry-run] [--limit=N] [--verbose]
|
||||
wp mls sync incremental [--dry-run] [--verbose]
|
||||
wp mls sync resume --id=<sync_id>
|
||||
```
|
||||
|
||||
### Media Management
|
||||
|
||||
Images are fetched on-demand when properties are viewed. These commands manage the cache:
|
||||
|
||||
```bash
|
||||
wp mls media status # Cache statistics
|
||||
wp mls media fetch --listing=<key> # Pre-cache a listing's images
|
||||
wp mls media fetch --listing=<key> --limit=10
|
||||
wp mls media clear --listing=<key> # Clear cached images
|
||||
```
|
||||
|
||||
### Cache Management
|
||||
|
||||
```bash
|
||||
wp mls cache clear --confirm # Delete ALL synced data
|
||||
wp mls cache cleanup # Remove orphaned media files
|
||||
wp mls cache missing # View failed media downloads
|
||||
wp mls cache missing --clear # Clear the missing media log
|
||||
```
|
||||
|
||||
### Recovery
|
||||
|
||||
```bash
|
||||
wp mls recovery list # Show resumable syncs
|
||||
wp mls recovery auto # Auto-resume most recent failed sync
|
||||
wp mls recovery cleanup # Mark stale syncs as failed
|
||||
```
|
||||
|
||||
## Cron Setup
|
||||
|
||||
### Recommended Setup
|
||||
|
||||
Add to system crontab (`crontab -e`):
|
||||
|
||||
```bash
|
||||
# Smart sync every 15 minutes (handles everything automatically)
|
||||
*/15 * * * * cd /var/www/html && wp mls run --silent --allow-root >> /var/log/mls-sync.log 2>&1
|
||||
```
|
||||
|
||||
This single entry handles:
|
||||
- Initial full sync on first run
|
||||
- Incremental updates on subsequent runs
|
||||
- Automatic recovery from failures
|
||||
- Safe concurrent execution (aborts if already running)
|
||||
|
||||
### Alternative: Manual Control
|
||||
|
||||
```bash
|
||||
# Incremental sync every 15 minutes
|
||||
*/15 * * * * cd /var/www/html && wp mls sync incremental --allow-root >> /var/log/mls-sync.log 2>&1
|
||||
|
||||
# Full rebuild weekly (Sunday 3am)
|
||||
0 3 * * 0 cd /var/www/html && wp mls cache clear --confirm --allow-root && wp mls sync full --allow-root >> /var/log/mls-sync.log 2>&1
|
||||
```
|
||||
|
||||
### Important Notes
|
||||
|
||||
- Use `--allow-root` when running as root
|
||||
- MLS Grid requires refresh at least every 12 hours per IDX rules
|
||||
- Rate limits are handled automatically (plugin waits when approaching limits)
|
||||
- No separate media cron needed - images are fetched on-demand
|
||||
|
||||
## Public API
|
||||
|
||||
### Available Functions
|
||||
|
||||
```php
|
||||
// Get properties with filters
|
||||
$properties = mls_get_properties([
|
||||
'status' => 'Active',
|
||||
'city' => 'Albert Lea',
|
||||
'min_price' => 100000,
|
||||
'max_price' => 500000,
|
||||
'min_beds' => 3,
|
||||
'property_type' => 'Residential',
|
||||
'limit' => 20,
|
||||
'offset' => 0,
|
||||
'orderby' => 'list_price',
|
||||
'order' => 'DESC',
|
||||
]);
|
||||
|
||||
// Get single property by listing key or MLS ID
|
||||
$property = mls_get_property('NST123456');
|
||||
|
||||
// Get primary image (fetches on-demand if not cached)
|
||||
$image_url = mls_get_property_image('NST123456');
|
||||
$image_url = mls_get_property_image('NST123456', false); // Don't fetch, return null if uncached
|
||||
|
||||
// Get all images for a listing
|
||||
$images = mls_get_property_images('NST123456'); // Fetch first 1 if uncached
|
||||
$images = mls_get_property_images('NST123456', 10); // Fetch first 10 if uncached
|
||||
$images = mls_get_property_images('NST123456', 0); // Don't fetch any
|
||||
|
||||
// Get media metadata (no fetching)
|
||||
$media = mls_get_property_media('NST123456');
|
||||
|
||||
// Get distinct cities with listings
|
||||
$cities = mls_get_cities(); // All cities
|
||||
$cities = mls_get_cities('Active'); // Cities with active listings only
|
||||
|
||||
// Get property count
|
||||
$count = mls_get_property_count(['status' => 'Active']);
|
||||
|
||||
// Check if data is available
|
||||
if (mls_is_available()) {
|
||||
// Show property search
|
||||
}
|
||||
|
||||
// Get cache statistics
|
||||
$stats = mls_get_cache_stats();
|
||||
// Returns: ['total_media' => 50000, 'cached' => 1200, 'uncached' => 48800]
|
||||
```
|
||||
|
||||
### Query Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `status` | string | Active, Pending, Closed |
|
||||
| `property_type` | string | Residential, Land, Commercial, etc. |
|
||||
| `city` | string | City name |
|
||||
| `county` | string | County name |
|
||||
| `postal_code` | string | ZIP code |
|
||||
| `min_price` | int | Minimum list price |
|
||||
| `max_price` | int | Maximum list price |
|
||||
| `min_beds` | int | Minimum bedrooms |
|
||||
| `max_beds` | int | Maximum bedrooms |
|
||||
| `min_baths` | int | Minimum bathrooms |
|
||||
| `min_sqft` | int | Minimum living area |
|
||||
| `max_sqft` | int | Maximum living area |
|
||||
| `year_built_min` | int | Minimum year built |
|
||||
| `year_built_max` | int | Maximum year built |
|
||||
| `listing_key` | string | Specific listing key |
|
||||
| `listing_id` | string | Specific MLS ID |
|
||||
| `search` | string | Search address/remarks |
|
||||
| `limit` | int | Results per page (default: 20) |
|
||||
| `offset` | int | Pagination offset |
|
||||
| `orderby` | string | Sort field |
|
||||
| `order` | string | ASC or DESC |
|
||||
| `include_media` | bool | Include media array |
|
||||
| `fields` | array | Specific fields to return |
|
||||
|
||||
### Property Object Fields
|
||||
|
||||
```php
|
||||
$property->listing_key // Unique identifier
|
||||
$property->listing_id // MLS number
|
||||
$property->standard_status // Active, Pending, Closed
|
||||
$property->list_price // Current price
|
||||
$property->original_list_price
|
||||
$property->close_price
|
||||
|
||||
// Address
|
||||
$property->street_number
|
||||
$property->street_name
|
||||
$property->street_suffix
|
||||
$property->unit_number
|
||||
$property->city
|
||||
$property->state_or_province
|
||||
$property->postal_code
|
||||
$property->county
|
||||
$property->latitude
|
||||
$property->longitude
|
||||
|
||||
// Property details
|
||||
$property->property_type
|
||||
$property->property_sub_type
|
||||
$property->bedrooms_total
|
||||
$property->bathrooms_total
|
||||
$property->bathrooms_full
|
||||
$property->bathrooms_half
|
||||
$property->living_area // Square feet
|
||||
$property->lot_size_area
|
||||
$property->lot_size_units
|
||||
$property->year_built
|
||||
$property->garage_spaces
|
||||
|
||||
// Description
|
||||
$property->public_remarks
|
||||
$property->directions
|
||||
|
||||
// Listing info
|
||||
$property->list_agent_key
|
||||
$property->list_agent_mls_id
|
||||
$property->list_agent_name
|
||||
$property->list_office_key
|
||||
$property->list_office_mls_id
|
||||
$property->list_office_name
|
||||
|
||||
// Dates and timestamps
|
||||
$property->photos_count
|
||||
$property->modification_timestamp
|
||||
$property->photos_change_timestamp
|
||||
$property->listing_contract_date
|
||||
$property->close_date
|
||||
$property->days_on_market
|
||||
$property->created_at
|
||||
$property->updated_at
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
|
||||
### Tables
|
||||
|
||||
All tables use the WordPress prefix (e.g., `wp_mls_properties`).
|
||||
|
||||
#### mls_properties
|
||||
|
||||
Main property listing data. Only Active and Pending properties are stored.
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| id | BIGINT | Auto-increment primary key |
|
||||
| listing_key | VARCHAR(50) | Unique MLS Grid key |
|
||||
| listing_id | VARCHAR(50) | MLS number |
|
||||
| standard_status | VARCHAR(30) | Active, Pending |
|
||||
| list_price | DECIMAL(15,2) | Current price |
|
||||
| city | VARCHAR(100) | City name |
|
||||
| latitude | DECIMAL(10,8) | GPS latitude |
|
||||
| longitude | DECIMAL(11,8) | GPS longitude |
|
||||
| ... | ... | See property fields above |
|
||||
| raw_data | LONGTEXT | Full API response (JSON) |
|
||||
| modification_timestamp | DATETIME | Last modified in MLS |
|
||||
| created_at | DATETIME | Record creation |
|
||||
| updated_at | DATETIME | Record update |
|
||||
|
||||
**Indexes:**
|
||||
- `listing_key` (UNIQUE)
|
||||
- `listing_id`
|
||||
- `standard_status`
|
||||
- `city`
|
||||
- `property_type`
|
||||
- `list_price`
|
||||
- `modification_timestamp`
|
||||
- `bedrooms_total`
|
||||
- `county`
|
||||
- `idx_latitude` - for geo queries
|
||||
- `idx_longitude` - for geo queries
|
||||
- `idx_status_city_price` - composite for search
|
||||
- `idx_status_type` - composite for filtering
|
||||
|
||||
#### mls_media
|
||||
|
||||
Media metadata and cache status. Images are downloaded on-demand.
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| id | BIGINT | Auto-increment primary key |
|
||||
| listing_key | VARCHAR(50) | Property reference |
|
||||
| media_key | VARCHAR(100) | Unique media identifier |
|
||||
| media_type | VARCHAR(30) | Photo, Document, etc. |
|
||||
| media_order | INT | Display order |
|
||||
| media_url | VARCHAR(1000) | Original MLS Grid URL |
|
||||
| local_path | VARCHAR(500) | Cached file path |
|
||||
| local_url | VARCHAR(500) | Cached file URL |
|
||||
| downloaded_at | DATETIME | When cached |
|
||||
|
||||
#### mls_sync_state
|
||||
|
||||
Sync progress tracking for resume capability.
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| id | BIGINT | Sync operation ID |
|
||||
| sync_type | VARCHAR(30) | full, incremental |
|
||||
| status | VARCHAR(20) | pending, running, completed, failed |
|
||||
| last_next_link | VARCHAR(2000) | Resume checkpoint |
|
||||
| records_processed | INT | Total processed |
|
||||
| records_created | INT | New records |
|
||||
| records_updated | INT | Updated records |
|
||||
| records_deleted | INT | Deleted records |
|
||||
|
||||
#### mls_rate_limits
|
||||
|
||||
API rate limit tracking.
|
||||
|
||||
#### mls_sync_log
|
||||
|
||||
Debug logging for sync operations.
|
||||
|
||||
#### mls_media_log
|
||||
|
||||
Media download audit trail.
|
||||
|
||||
## Media Handling
|
||||
|
||||
### On-Demand Fetching
|
||||
|
||||
Per MLS Grid rules, media URLs cannot be used directly on websites. Images must be downloaded and served from your own server.
|
||||
|
||||
**How it works:**
|
||||
|
||||
1. Property sync stores media metadata (URLs, keys, order) but does NOT download images
|
||||
2. When `mls_get_property_image()` is called, the image is fetched and cached locally
|
||||
3. Subsequent requests serve from local cache
|
||||
4. Cache location: `wp-content/uploads/mls-listings/{prefix}/{listing_key}/`
|
||||
|
||||
**Benefits:**
|
||||
- No rate limit issues from bulk downloading
|
||||
- Images cached only when needed
|
||||
- Automatic re-fetch if cache cleared
|
||||
- Works with MLS Grid's URL expiration
|
||||
|
||||
### Pre-caching Images
|
||||
|
||||
To pre-cache images for specific listings:
|
||||
|
||||
```bash
|
||||
wp mls media fetch --listing=NST123456 --limit=10
|
||||
```
|
||||
|
||||
### Cache Statistics
|
||||
|
||||
```bash
|
||||
wp mls media status
|
||||
```
|
||||
|
||||
Shows total media records, cached count, and uncached count.
|
||||
|
||||
## Sync Strategy
|
||||
|
||||
### Initial Import (Full Sync)
|
||||
|
||||
- Fetches ONLY Active and Pending properties
|
||||
- Filter: `MlgCanView eq true AND (StandardStatus eq 'Active' OR StandardStatus eq 'Pending')`
|
||||
- Uses `@odata.nextLink` for pagination (NOT `$skip`)
|
||||
- Approximately 30,000 records for NorthStar MLS
|
||||
- Takes 30-45 minutes on first run
|
||||
|
||||
### Replication (Incremental Sync)
|
||||
|
||||
- Fetches ALL properties modified since last sync
|
||||
- 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
|
||||
- Otherwise: INSERT or UPDATE
|
||||
|
||||
### Why This Approach?
|
||||
|
||||
1. MLS Grid API limits `$skip` to ~80,000 - bulk scanning fails
|
||||
2. Only Active/Pending properties needed for display
|
||||
3. Replication is efficient - only fetches changes
|
||||
4. Proper deletion handling when properties sell
|
||||
|
||||
## Error Recovery
|
||||
|
||||
### Automatic Recovery
|
||||
|
||||
The plugin saves progress after each API page. If a sync fails:
|
||||
|
||||
1. Progress is preserved in `mls_sync_state` table
|
||||
2. Next `wp mls run` automatically resumes from checkpoint
|
||||
3. Failed syncs older than 1 hour are marked for resume
|
||||
|
||||
### Manual Recovery
|
||||
|
||||
```bash
|
||||
# View resumable syncs
|
||||
wp mls recovery list
|
||||
|
||||
# Auto-resume most recent
|
||||
wp mls recovery auto
|
||||
|
||||
# Resume specific sync
|
||||
wp mls sync resume --id=<sync_id>
|
||||
|
||||
# Mark stale syncs as failed
|
||||
wp mls recovery cleanup
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Connection Failed
|
||||
|
||||
```bash
|
||||
wp mls test connection
|
||||
wp mls test auth
|
||||
```
|
||||
|
||||
Check:
|
||||
- API token in wp-config.php
|
||||
- Network connectivity
|
||||
- MLS Grid API status
|
||||
|
||||
### No Data After Sync
|
||||
|
||||
```bash
|
||||
wp mls status
|
||||
wp mls stats
|
||||
```
|
||||
|
||||
Check:
|
||||
- Rate limits (may need to wait)
|
||||
- WordPress debug log for API errors
|
||||
- Sync state for failures
|
||||
|
||||
### Media Not Loading
|
||||
|
||||
```bash
|
||||
wp mls media status
|
||||
```
|
||||
|
||||
Check:
|
||||
- Upload directory permissions
|
||||
- Disk space
|
||||
- MLS Grid media URL validity
|
||||
|
||||
### Sync Taking Too Long
|
||||
|
||||
Initial sync of ~30K properties takes 30-45 minutes. Use `--verbose` to monitor progress.
|
||||
|
||||
### Rate Limit Exceeded
|
||||
|
||||
The plugin automatically waits when approaching limits. If persistent:
|
||||
- Reduce sync frequency
|
||||
- Check for other API consumers
|
||||
- Contact MLS Grid support
|
||||
|
||||
### Clearing Data
|
||||
|
||||
To start fresh:
|
||||
|
||||
```bash
|
||||
wp mls cache clear --confirm
|
||||
wp mls run
|
||||
```
|
||||
|
||||
### Database Issues
|
||||
|
||||
If indexes are missing, trigger recreation:
|
||||
|
||||
```bash
|
||||
wp eval "MLS_DB::create_tables();"
|
||||
```
|
||||
|
||||
## Rate Limits
|
||||
|
||||
MLS Grid enforces these limits:
|
||||
|
||||
| Limit | Value |
|
||||
|-------|-------|
|
||||
| Per second | 2 requests |
|
||||
| Per hour | 7,200 requests |
|
||||
| Per day | 40,000 requests |
|
||||
| Data per hour | 4 GB |
|
||||
|
||||
The plugin automatically:
|
||||
- Waits 500ms between requests
|
||||
- Tracks hourly/daily usage
|
||||
- Pauses when approaching limits
|
||||
- Retries with exponential backoff on 429 errors
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
mls-by-hansonxyz/
|
||||
├── mls-by-hansonxyz.php # Main plugin file, public API
|
||||
├── uninstall.php # Cleanup on uninstall
|
||||
├── README.md # This file
|
||||
├── admin/
|
||||
│ └── class-mls-admin.php # WordPress admin interface
|
||||
├── cli/
|
||||
│ └── class-mls-cli.php # WP-CLI commands
|
||||
├── includes/
|
||||
│ ├── class-mls-activator.php # Plugin activation
|
||||
│ ├── class-mls-api-client.php # MLS Grid API communication
|
||||
│ ├── class-mls-db.php # Database operations
|
||||
│ ├── class-mls-deactivator.php # Plugin deactivation
|
||||
│ ├── class-mls-logger.php # Event logging
|
||||
│ ├── class-mls-media-handler.php # On-demand image caching
|
||||
│ ├── class-mls-options.php # Configuration management
|
||||
│ ├── class-mls-query.php # Public query API
|
||||
│ ├── class-mls-rate-limiter.php # Rate limit compliance
|
||||
│ └── class-mls-sync-engine.php # Sync orchestration
|
||||
└── docs/
|
||||
├── API.md # MLS Grid API reference
|
||||
├── CLAUDE.md # AI assistant context
|
||||
└── USAGE.md # User documentation
|
||||
```
|
||||
|
||||
## Support
|
||||
|
||||
- Plugin logs: Settings > MLS Settings in WordPress admin
|
||||
- Debug log: `wp-content/debug.log` (if WP_DEBUG enabled)
|
||||
- MLS Grid API: support@mlsgrid.com
|
||||
|
||||
## License
|
||||
|
||||
GPL-2.0+
|
||||
@@ -34,6 +34,7 @@ class MLS_CLI {
|
||||
WP_CLI::add_command('mls test', array($instance, 'test'));
|
||||
WP_CLI::add_command('mls status', array($instance, 'status'));
|
||||
WP_CLI::add_command('mls sync', array($instance, 'sync'));
|
||||
WP_CLI::add_command('mls run', array($instance, 'run'));
|
||||
WP_CLI::add_command('mls stats', array($instance, 'stats'));
|
||||
WP_CLI::add_command('mls cache', array($instance, 'cache'));
|
||||
WP_CLI::add_command('mls recovery', array($instance, 'recovery'));
|
||||
@@ -369,6 +370,149 @@ class MLS_CLI {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run smart sync - autonomous self-healing sync.
|
||||
*
|
||||
* This is the recommended command for automated/cron usage. It automatically
|
||||
* determines the best action based on current state:
|
||||
*
|
||||
* - If a sync is running: abort (prevents duplicate syncs)
|
||||
* - If a previous sync failed/interrupted: resume it
|
||||
* - If no data exists: run full sync
|
||||
* - Otherwise: run incremental sync
|
||||
*
|
||||
* Failed syncs are automatically recoverable on the next run.
|
||||
*
|
||||
* ## OPTIONS
|
||||
*
|
||||
* [--quiet]
|
||||
* : Suppress progress output (still shows status messages)
|
||||
*
|
||||
* [--verbose]
|
||||
* : Show detailed output including API requests
|
||||
*
|
||||
* [--silent]
|
||||
* : Suppress all output except errors (for cron)
|
||||
*
|
||||
* ## EXAMPLES
|
||||
*
|
||||
* wp mls run # Smart sync with progress
|
||||
* wp mls run --quiet # Smart sync, status only
|
||||
* wp mls run --verbose # Smart sync with full details
|
||||
* wp mls run --silent # For cron jobs
|
||||
*
|
||||
* @subcommand run
|
||||
*/
|
||||
public function run($args, $assoc_args) {
|
||||
$quiet = isset($assoc_args['quiet']);
|
||||
$verbose = isset($assoc_args['verbose']);
|
||||
$silent = isset($assoc_args['silent']);
|
||||
|
||||
$sync_engine = $this->plugin->get_sync_engine();
|
||||
|
||||
// Status callback for high-level messages
|
||||
$status_callback = null;
|
||||
if (!$silent) {
|
||||
$status_callback = function($message, $level = 'info') {
|
||||
$timestamp = date('H:i:s');
|
||||
switch ($level) {
|
||||
case 'warning':
|
||||
WP_CLI::warning("[{$timestamp}] {$message}");
|
||||
break;
|
||||
case 'error':
|
||||
WP_CLI::warning("[{$timestamp}] {$message}");
|
||||
break;
|
||||
default:
|
||||
WP_CLI::line("[{$timestamp}] {$message}");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Progress callback for record-level progress
|
||||
$progress_callback = null;
|
||||
if (!$quiet && !$silent) {
|
||||
$progress_callback = function($event, $data = array()) use ($verbose) {
|
||||
if ($verbose) {
|
||||
$this->output_verbose_event($event, $data);
|
||||
} else {
|
||||
switch ($event) {
|
||||
case 'property_created':
|
||||
echo '.';
|
||||
break;
|
||||
case 'property_updated':
|
||||
echo '#';
|
||||
break;
|
||||
case 'property_deleted':
|
||||
echo 'x';
|
||||
break;
|
||||
case 'property_error':
|
||||
echo '!';
|
||||
break;
|
||||
case 'page_complete':
|
||||
echo '|';
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (!$silent) {
|
||||
WP_CLI::line('');
|
||||
WP_CLI::line('=== MLS Smart Sync ===');
|
||||
WP_CLI::line('');
|
||||
}
|
||||
|
||||
// Run smart sync
|
||||
$result = $sync_engine->smart_sync($progress_callback, $status_callback);
|
||||
|
||||
// Handle aborted case (sync already running)
|
||||
if (isset($result['action']) && $result['action'] === 'aborted') {
|
||||
if (!$silent) {
|
||||
WP_CLI::warning('Sync aborted: ' . ($result['reason'] ?? 'Unknown reason'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Output newline after progress dots
|
||||
if (!$quiet && !$silent && !$verbose) {
|
||||
echo "\n";
|
||||
}
|
||||
|
||||
// Output results
|
||||
if (!$silent) {
|
||||
$action_labels = array(
|
||||
'full' => 'Full sync',
|
||||
'incremental' => 'Incremental sync',
|
||||
'resumed' => 'Resumed sync',
|
||||
);
|
||||
$action_label = $action_labels[$result['action']] ?? 'Sync';
|
||||
|
||||
if ($result['success']) {
|
||||
WP_CLI::success("{$action_label} completed successfully!");
|
||||
} else {
|
||||
WP_CLI::warning("{$action_label} failed: " . ($result['error'] ?? 'Unknown error'));
|
||||
WP_CLI::line('The sync can be resumed on the next run.');
|
||||
}
|
||||
|
||||
if (isset($result['stats'])) {
|
||||
$stats = $result['stats'];
|
||||
WP_CLI::line(sprintf(
|
||||
'Processed: %d | Created: %d | Updated: %d | Deleted: %d | Errors: %d',
|
||||
$stats['processed'],
|
||||
$stats['created'],
|
||||
$stats['updated'],
|
||||
$stats['deleted'],
|
||||
$stats['errors']
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Exit with error code if failed (for cron monitoring)
|
||||
if (!$result['success']) {
|
||||
WP_CLI::halt(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Print progress legend
|
||||
*
|
||||
|
||||
@@ -63,7 +63,13 @@ wp mls test auth
|
||||
wp mls status
|
||||
wp mls status rate-limits
|
||||
|
||||
# Run property sync
|
||||
# SMART SYNC (recommended for automation)
|
||||
wp mls run # Auto-detect: full, incremental, or resume
|
||||
wp mls run --quiet # Status messages only
|
||||
wp mls run --silent # For cron (exit code only)
|
||||
wp mls run --verbose # Full details
|
||||
|
||||
# Manual sync commands (for more control)
|
||||
wp mls sync full [--dry-run] [--limit=N] [--verbose] # Initial: Active/Pending only
|
||||
wp mls sync incremental [--dry-run] [--verbose] # Replication: all changes
|
||||
wp mls sync resume --id=<sync_id>
|
||||
@@ -171,7 +177,20 @@ The sync engine saves progress after each page:
|
||||
### Recommended Cron Setup
|
||||
|
||||
```bash
|
||||
# Replication sync every 15 minutes (MLS Grid recommended)
|
||||
# Smart sync every 15 minutes (recommended - handles everything automatically)
|
||||
*/15 * * * * cd /var/www/html && wp mls run --silent --allow-root >> /var/log/mls-sync.log 2>&1
|
||||
```
|
||||
|
||||
The `wp mls run` command automatically:
|
||||
- Runs full sync if no data exists
|
||||
- Runs incremental sync if data exists
|
||||
- Resumes failed/interrupted syncs
|
||||
- Aborts safely if another sync is running
|
||||
|
||||
For manual control, use individual commands:
|
||||
|
||||
```bash
|
||||
# Replication sync every 15 minutes
|
||||
*/15 * * * * cd /var/www/html && wp mls sync incremental --allow-root >> /var/log/mls-sync.log 2>&1
|
||||
|
||||
# Full re-sync weekly (Sunday 3am) - rebuilds from scratch
|
||||
|
||||
@@ -43,6 +43,29 @@ Navigate to **Settings > MLS Settings** to configure:
|
||||
|
||||
### Via WP-CLI
|
||||
|
||||
#### Smart Sync (Recommended)
|
||||
|
||||
The `wp mls run` command is the recommended way to sync. It automatically handles all scenarios:
|
||||
|
||||
```bash
|
||||
wp mls run # Smart sync with progress
|
||||
wp mls run --quiet # Status messages only
|
||||
wp mls run --verbose # Full API details
|
||||
wp mls run --silent # For cron (no output)
|
||||
```
|
||||
|
||||
**What it does automatically:**
|
||||
- If a sync is already running: aborts (prevents duplicates)
|
||||
- If a previous sync failed/interrupted: resumes it
|
||||
- If no data exists: runs full sync
|
||||
- Otherwise: runs incremental sync
|
||||
|
||||
Failed syncs are automatically resumed on the next run - no manual intervention needed.
|
||||
|
||||
#### Manual Sync Commands
|
||||
|
||||
For more control, use the individual sync commands:
|
||||
|
||||
```bash
|
||||
# Test connection first
|
||||
wp mls test connection
|
||||
@@ -54,9 +77,6 @@ wp mls sync full
|
||||
# Run incremental updates
|
||||
wp mls sync incremental
|
||||
|
||||
# Download pending media
|
||||
wp mls sync media
|
||||
|
||||
# Use --verbose for detailed output
|
||||
wp mls sync full --verbose
|
||||
wp mls sync incremental --verbose
|
||||
@@ -80,17 +100,25 @@ Use `--verbose` for detailed timestamped output showing API requests and individ
|
||||
Add to your system crontab (`crontab -e`) for scheduled sync:
|
||||
|
||||
```bash
|
||||
# Incremental sync every hour (recommended for production)
|
||||
# Smart sync every 15 minutes (recommended)
|
||||
# Automatically handles: initial sync, incremental updates, and error recovery
|
||||
*/15 * * * * cd /var/www/html && wp mls run --silent --allow-root >> /var/log/mls-sync.log 2>&1
|
||||
```
|
||||
|
||||
That's it! The `wp mls run` command handles everything automatically:
|
||||
- First run: performs full initial sync
|
||||
- Subsequent runs: performs incremental sync
|
||||
- After failures: resumes from where it left off
|
||||
- Concurrent runs: safely aborts if another sync is running
|
||||
|
||||
**For more control**, use the individual commands:
|
||||
|
||||
```bash
|
||||
# Incremental sync every hour
|
||||
0 * * * * cd /var/www/html && wp mls sync incremental --allow-root >> /var/log/mls-sync.log 2>&1
|
||||
|
||||
# Or every 30 minutes for more frequent updates
|
||||
*/30 * * * * cd /var/www/html && wp mls sync incremental --allow-root >> /var/log/mls-sync.log 2>&1
|
||||
|
||||
# Full sync weekly (Sunday at 3am) to catch any missed records
|
||||
# Full sync weekly (Sunday at 3am) to rebuild from scratch
|
||||
0 3 * * 0 cd /var/www/html && wp mls sync full --allow-root >> /var/log/mls-sync.log 2>&1
|
||||
|
||||
# Download any pending media every 15 minutes
|
||||
*/15 * * * * cd /var/www/html && wp mls sync media --limit=50 --allow-root >> /var/log/mls-sync.log 2>&1
|
||||
```
|
||||
|
||||
**Important Notes:**
|
||||
@@ -98,6 +126,7 @@ Add to your system crontab (`crontab -e`) for scheduled sync:
|
||||
- Redirect output to a log file for debugging
|
||||
- MLS Grid requires refresh at least every 12 hours per IDX rules
|
||||
- The plugin handles rate limits automatically (waits if approaching limits)
|
||||
- Media images are fetched on-demand when properties are viewed (no separate cron needed)
|
||||
|
||||
### Via WP-Cron (Alternative)
|
||||
|
||||
|
||||
@@ -9,6 +9,12 @@ if (!defined('ABSPATH')) {
|
||||
|
||||
class MLS_DB {
|
||||
|
||||
/**
|
||||
* Schema version for index migrations
|
||||
* Increment this when adding new indexes
|
||||
*/
|
||||
const SCHEMA_VERSION = 2;
|
||||
|
||||
/**
|
||||
* Get table name with prefix
|
||||
*
|
||||
@@ -275,6 +281,78 @@ class MLS_DB {
|
||||
) {$charset_collate};";
|
||||
|
||||
dbDelta($sql_media_log);
|
||||
|
||||
// Run index migrations
|
||||
self::run_index_migrations();
|
||||
}
|
||||
|
||||
/**
|
||||
* Run index migrations that dbDelta cannot handle
|
||||
*
|
||||
* dbDelta can create tables and add columns, but cannot add indexes
|
||||
* to existing tables. This method handles incremental index additions.
|
||||
*/
|
||||
public static function run_index_migrations() {
|
||||
global $wpdb;
|
||||
|
||||
$current_schema = (int) get_option('mls_schema_version', 1);
|
||||
|
||||
// Migration to schema version 2: Add search and geo indexes
|
||||
if ($current_schema < 2) {
|
||||
$table_properties = $wpdb->prefix . MLS_TABLE_PROPERTIES;
|
||||
$table_media = $wpdb->prefix . MLS_TABLE_MEDIA;
|
||||
|
||||
// Check and add indexes only if they don't exist
|
||||
$existing_indexes = self::get_existing_indexes($table_properties);
|
||||
|
||||
// Geospatial indexes for bounding box queries
|
||||
if (!isset($existing_indexes['idx_latitude'])) {
|
||||
$wpdb->query("ALTER TABLE {$table_properties} ADD INDEX idx_latitude (latitude)");
|
||||
}
|
||||
if (!isset($existing_indexes['idx_longitude'])) {
|
||||
$wpdb->query("ALTER TABLE {$table_properties} ADD INDEX idx_longitude (longitude)");
|
||||
}
|
||||
|
||||
// Composite index for common search pattern (status + city + price)
|
||||
if (!isset($existing_indexes['idx_status_city_price'])) {
|
||||
$wpdb->query("ALTER TABLE {$table_properties} ADD INDEX idx_status_city_price (standard_status, city, list_price)");
|
||||
}
|
||||
|
||||
// Composite index for status + property_type searches
|
||||
if (!isset($existing_indexes['idx_status_type'])) {
|
||||
$wpdb->query("ALTER TABLE {$table_properties} ADD INDEX idx_status_type (standard_status, property_type)");
|
||||
}
|
||||
|
||||
// Media table: composite index for listing + order (eliminates filesort)
|
||||
$media_indexes = self::get_existing_indexes($table_media);
|
||||
if (!isset($media_indexes['idx_listing_order'])) {
|
||||
$wpdb->query("ALTER TABLE {$table_media} ADD INDEX idx_listing_order (listing_key, media_order)");
|
||||
}
|
||||
|
||||
update_option('mls_schema_version', 2);
|
||||
}
|
||||
|
||||
// Future migrations go here:
|
||||
// if ($current_schema < 3) { ... }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get existing indexes for a table
|
||||
*
|
||||
* @param string $table Full table name
|
||||
* @return array Index names as keys
|
||||
*/
|
||||
private static function get_existing_indexes($table) {
|
||||
global $wpdb;
|
||||
|
||||
$indexes = array();
|
||||
$results = $wpdb->get_results("SHOW INDEX FROM {$table}");
|
||||
|
||||
foreach ($results as $row) {
|
||||
$indexes[$row->Key_name] = true;
|
||||
}
|
||||
|
||||
return $indexes;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -937,4 +937,113 @@ class MLS_Sync_Engine {
|
||||
|
||||
return $this->resume_sync($resumable->id, $progress_callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Smart sync - autonomous self-healing sync that handles all scenarios
|
||||
*
|
||||
* Decision logic:
|
||||
* 1. If a sync is currently running (and not stale), abort
|
||||
* 2. If there's a resumable failed/interrupted sync, resume it
|
||||
* 3. If no data exists, run full sync
|
||||
* 4. Otherwise, run incremental sync
|
||||
*
|
||||
* On failure, the sync state is preserved for future resume.
|
||||
*
|
||||
* @param callable|null $progress_callback Progress callback
|
||||
* @param callable|null $status_callback Callback for status messages: function(string $message, string $level)
|
||||
* @return array Sync results with 'action' key indicating what was done
|
||||
*/
|
||||
public function smart_sync($progress_callback = null, $status_callback = null) {
|
||||
// Helper to emit status messages
|
||||
$status = function($message, $level = 'info') use ($status_callback) {
|
||||
if ($status_callback) {
|
||||
call_user_func($status_callback, $message, $level);
|
||||
}
|
||||
$this->logger->log($level, $message);
|
||||
};
|
||||
|
||||
// Step 1: Clean up stale syncs (running > 1 hour = probably dead)
|
||||
$stale_cleaned = $this->cleanup_stale_syncs();
|
||||
if ($stale_cleaned > 0) {
|
||||
$status("Cleaned up {$stale_cleaned} stale sync(s)", 'info');
|
||||
}
|
||||
|
||||
// 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');
|
||||
return array(
|
||||
'success' => false,
|
||||
'action' => 'aborted',
|
||||
'reason' => 'Sync already running',
|
||||
'running_sync' => $running,
|
||||
);
|
||||
}
|
||||
|
||||
// Step 3: Check for resumable syncs
|
||||
$resumable = $this->get_latest_resumable();
|
||||
if ($resumable) {
|
||||
$status("Found resumable sync #{$resumable->id} ({$resumable->sync_type}), processed {$resumable->records_processed} records", 'info');
|
||||
$status("Resuming...", 'info');
|
||||
|
||||
$result = $this->resume_sync($resumable->id, $progress_callback);
|
||||
$result['action'] = 'resumed';
|
||||
$result['resumed_sync_id'] = $resumable->id;
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Step 4: Check if we have any data
|
||||
$has_data = $this->has_synced_data();
|
||||
|
||||
if (!$has_data) {
|
||||
// No data - need full sync
|
||||
$status("No existing data found, starting full sync", 'info');
|
||||
|
||||
$result = $this->run_full_sync(false, null, $progress_callback);
|
||||
$result['action'] = 'full';
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Step 5: We have data - run incremental sync
|
||||
$last_timestamp = $this->get_last_modification_timestamp();
|
||||
$status("Running incremental sync (changes since {$last_timestamp})", 'info');
|
||||
|
||||
$result = $this->run_incremental_sync(false, $progress_callback);
|
||||
$result['action'] = 'incremental';
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there's a currently running sync (not stale)
|
||||
*
|
||||
* @return object|null Running sync state or null
|
||||
*/
|
||||
public function get_running_sync() {
|
||||
global $wpdb;
|
||||
|
||||
$one_hour_ago = date('Y-m-d H:i:s', strtotime('-1 hour'));
|
||||
|
||||
return $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT * FROM {$this->db->sync_state_table()}
|
||||
WHERE status = 'running' AND updated_at >= %s
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 1",
|
||||
$one_hour_ago
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we have any synced property data
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function has_synced_data() {
|
||||
global $wpdb;
|
||||
|
||||
$count = $wpdb->get_var(
|
||||
"SELECT COUNT(*) FROM {$this->db->properties_table()} WHERE mlg_can_view = 1"
|
||||
);
|
||||
|
||||
return (int) $count > 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,54 +106,53 @@ $view_class = $show_map ? 'is-map-view' : 'is-grid-view';
|
||||
</main>
|
||||
|
||||
<?php
|
||||
// Always load map data for responsive switching
|
||||
$map_properties = new WP_Query(array(
|
||||
'post_type' => 'property',
|
||||
'posts_per_page' => -1,
|
||||
));
|
||||
|
||||
// Load MLS properties for map markers
|
||||
$markers = array();
|
||||
$city_coords = array(
|
||||
'Albert Lea' => array(43.6480, -93.3685),
|
||||
'Austin' => array(43.6666, -92.9746),
|
||||
'Glenville' => array(43.5733, -93.2779),
|
||||
'Emmons' => array(43.5013, -93.4896),
|
||||
'Clarks Grove' => array(43.7627, -93.3196),
|
||||
'Alden' => array(43.6719, -93.5768),
|
||||
'Hartland' => array(43.8030, -93.4846),
|
||||
'Geneva' => array(43.8255, -93.2682),
|
||||
'Owatonna' => array(44.0838, -93.2260),
|
||||
'Faribault' => array(44.2949, -93.2688),
|
||||
'Rochester' => array(44.0234, -92.4699),
|
||||
'Mankato' => array(44.1636, -93.9994),
|
||||
);
|
||||
|
||||
if ($map_properties->have_posts()) :
|
||||
while ($map_properties->have_posts()) :
|
||||
$map_properties->the_post();
|
||||
$city = get_field('city');
|
||||
$price = get_field('property_price');
|
||||
$address = get_field('street_address');
|
||||
if (function_exists('mls_get_properties')) {
|
||||
$mls_properties = mls_get_properties(array(
|
||||
'status' => 'Active',
|
||||
'limit' => 1000, // Reasonable limit for map performance
|
||||
'orderby' => 'modification_timestamp',
|
||||
'order' => 'DESC',
|
||||
));
|
||||
|
||||
// Get coords for city, default to Albert Lea
|
||||
$coords = isset($city_coords[$city]) ? $city_coords[$city] : $city_coords['Albert Lea'];
|
||||
// Add small random offset (seeded by post ID for consistency)
|
||||
srand(get_the_ID());
|
||||
$lat = $coords[0] + (rand(-50, 50) / 10000);
|
||||
$lng = $coords[1] + (rand(-50, 50) / 10000);
|
||||
foreach ($mls_properties as $property) {
|
||||
// Skip properties without coordinates
|
||||
if (empty($property->latitude) || empty($property->longitude)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Format address
|
||||
$address_parts = array();
|
||||
if ($property->street_number) {
|
||||
$address_parts[] = $property->street_number;
|
||||
}
|
||||
if ($property->street_name) {
|
||||
$address_parts[] = $property->street_name;
|
||||
}
|
||||
if ($property->street_suffix) {
|
||||
$address_parts[] = $property->street_suffix;
|
||||
}
|
||||
$street = implode(' ', $address_parts);
|
||||
$full_address = $street ? $street . ', ' . $property->city : $property->city;
|
||||
|
||||
$markers[] = array(
|
||||
'id' => get_the_ID(),
|
||||
'lat' => $lat,
|
||||
'lng' => $lng,
|
||||
'title' => get_the_title(),
|
||||
'price' => '$' . number_format($price),
|
||||
'address' => $address . ', ' . $city,
|
||||
'url' => get_permalink(),
|
||||
'id' => $property->listing_key,
|
||||
'lat' => (float) $property->latitude,
|
||||
'lng' => (float) $property->longitude,
|
||||
'title' => $full_address,
|
||||
'price' => '$' . number_format($property->list_price),
|
||||
'address' => $full_address,
|
||||
'url' => home_url('/properties/?listing=' . urlencode($property->listing_key)),
|
||||
'beds' => $property->bedrooms_total,
|
||||
'baths' => $property->bathrooms_total,
|
||||
'sqft' => $property->living_area,
|
||||
'status' => $property->standard_status,
|
||||
'photo' => null, // Placeholder - photos will be added later
|
||||
);
|
||||
endwhile;
|
||||
wp_reset_postdata();
|
||||
endif;
|
||||
}
|
||||
}
|
||||
?>
|
||||
<!-- Leaflet CSS -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin=""/>
|
||||
|
||||
@@ -11,7 +11,7 @@ if (!defined('ABSPATH')) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle AJAX property filter requests
|
||||
* Handle AJAX property filter requests (MLS-based)
|
||||
*/
|
||||
function homeproz_ajax_filter_properties() {
|
||||
// Verify nonce
|
||||
@@ -19,6 +19,11 @@ function homeproz_ajax_filter_properties() {
|
||||
wp_send_json_error('Invalid nonce');
|
||||
}
|
||||
|
||||
// Check if MLS plugin is available
|
||||
if (!function_exists('mls_get_properties')) {
|
||||
wp_send_json_error('MLS plugin not available');
|
||||
}
|
||||
|
||||
// Get filter values
|
||||
$property_type = isset($_POST['property_type']) ? sanitize_text_field($_POST['property_type']) : '';
|
||||
$property_status = isset($_POST['property_status']) ? sanitize_text_field($_POST['property_status']) : '';
|
||||
@@ -29,100 +34,40 @@ function homeproz_ajax_filter_properties() {
|
||||
$sort = isset($_POST['sort']) ? sanitize_text_field($_POST['sort']) : 'newest';
|
||||
$paged = isset($_POST['paged']) ? intval($_POST['paged']) : 1;
|
||||
|
||||
// Build query args
|
||||
$args = array(
|
||||
'post_type' => 'property',
|
||||
'posts_per_page' => 12,
|
||||
'paged' => $paged,
|
||||
// Build MLS query args
|
||||
$per_page = 12;
|
||||
$mls_args = array(
|
||||
'status' => $property_status ?: 'Active',
|
||||
'limit' => 1000, // Get all for counting, then paginate
|
||||
'orderby' => 'modification_timestamp',
|
||||
'order' => 'DESC',
|
||||
);
|
||||
|
||||
// Taxonomy filters
|
||||
$tax_query = array();
|
||||
|
||||
// Map filter values to MLS query args
|
||||
if ($property_type) {
|
||||
$tax_query[] = array(
|
||||
'taxonomy' => 'property_type',
|
||||
'field' => 'slug',
|
||||
'terms' => $property_type,
|
||||
);
|
||||
$mls_args['property_type'] = $property_type;
|
||||
}
|
||||
|
||||
if ($property_status) {
|
||||
$tax_query[] = array(
|
||||
'taxonomy' => 'property_status',
|
||||
'field' => 'slug',
|
||||
'terms' => $property_status,
|
||||
);
|
||||
}
|
||||
|
||||
if ($property_location) {
|
||||
$tax_query[] = array(
|
||||
'taxonomy' => 'property_location',
|
||||
'field' => 'slug',
|
||||
'terms' => $property_location,
|
||||
);
|
||||
$mls_args['city'] = $property_location;
|
||||
}
|
||||
|
||||
if (!empty($tax_query)) {
|
||||
$args['tax_query'] = $tax_query;
|
||||
if (count($tax_query) > 1) {
|
||||
$args['tax_query']['relation'] = 'AND';
|
||||
}
|
||||
}
|
||||
|
||||
// Meta query for price and bedrooms
|
||||
$meta_query = array();
|
||||
|
||||
if ($min_price) {
|
||||
$meta_query[] = array(
|
||||
'key' => 'property_price',
|
||||
'value' => $min_price,
|
||||
'type' => 'NUMERIC',
|
||||
'compare' => '>=',
|
||||
);
|
||||
$mls_args['min_price'] = $min_price;
|
||||
}
|
||||
|
||||
if ($max_price) {
|
||||
$meta_query[] = array(
|
||||
'key' => 'property_price',
|
||||
'value' => $max_price,
|
||||
'type' => 'NUMERIC',
|
||||
'compare' => '<=',
|
||||
);
|
||||
$mls_args['max_price'] = $max_price;
|
||||
}
|
||||
|
||||
if ($beds) {
|
||||
$meta_query[] = array(
|
||||
'key' => 'bedrooms',
|
||||
'value' => $beds,
|
||||
'type' => 'NUMERIC',
|
||||
'compare' => '>=',
|
||||
);
|
||||
$mls_args['min_beds'] = $beds;
|
||||
}
|
||||
|
||||
if (!empty($meta_query)) {
|
||||
$args['meta_query'] = $meta_query;
|
||||
if (count($meta_query) > 1) {
|
||||
$args['meta_query']['relation'] = 'AND';
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch all matching properties for status-based sorting
|
||||
$args['posts_per_page'] = -1;
|
||||
$args['orderby'] = 'modified';
|
||||
$args['order'] = 'DESC';
|
||||
|
||||
$all_properties = get_posts($args);
|
||||
|
||||
// Sort by status (Active > Pending > Sold) then by modified date
|
||||
$sorted_properties = homeproz_sort_properties_by_status($all_properties);
|
||||
// Fetch all matching properties
|
||||
$all_properties = mls_get_properties($mls_args);
|
||||
|
||||
// Handle pagination manually
|
||||
$per_page = 12;
|
||||
$total = count($sorted_properties);
|
||||
$total = count($all_properties);
|
||||
$max_pages = ceil($total / $per_page);
|
||||
$offset = ($paged - 1) * $per_page;
|
||||
$paged_properties = array_slice($sorted_properties, $offset, $per_page);
|
||||
$paged_properties = array_slice($all_properties, $offset, $per_page);
|
||||
|
||||
ob_start();
|
||||
|
||||
@@ -142,13 +87,11 @@ function homeproz_ajax_filter_properties() {
|
||||
<?php if (!empty($paged_properties)) : ?>
|
||||
<div class="properties-grid">
|
||||
<?php
|
||||
global $post;
|
||||
foreach ($paged_properties as $property_post) :
|
||||
$post = $property_post;
|
||||
setup_postdata($post);
|
||||
get_template_part('template-parts/property/property-card');
|
||||
foreach ($paged_properties as $property) :
|
||||
// Pass MLS property to card template
|
||||
set_query_var('mls_property', $property);
|
||||
get_template_part('template-parts/property/property-card-mls');
|
||||
endforeach;
|
||||
wp_reset_postdata();
|
||||
?>
|
||||
</div>
|
||||
|
||||
@@ -197,43 +140,42 @@ function homeproz_ajax_filter_properties() {
|
||||
<?php
|
||||
$html = ob_get_clean();
|
||||
|
||||
// Build markers data for map view
|
||||
// Build markers data for map view from MLS properties
|
||||
$markers = array();
|
||||
$city_coords = array(
|
||||
'Albert Lea' => array(43.6480, -93.3685),
|
||||
'Austin' => array(43.6666, -92.9746),
|
||||
'Glenville' => array(43.5733, -93.2779),
|
||||
'Emmons' => array(43.5013, -93.4896),
|
||||
'Clarks Grove' => array(43.7627, -93.3196),
|
||||
'Alden' => array(43.6719, -93.5768),
|
||||
'Hartland' => array(43.8030, -93.4846),
|
||||
'Geneva' => array(43.8255, -93.2682),
|
||||
'Owatonna' => array(44.0838, -93.2260),
|
||||
'Faribault' => array(44.2949, -93.2688),
|
||||
'Rochester' => array(44.0234, -92.4699),
|
||||
'Mankato' => array(44.1636, -93.9994),
|
||||
);
|
||||
|
||||
foreach ($sorted_properties as $prop) {
|
||||
$city = get_field('city', $prop->ID);
|
||||
$price = get_field('property_price', $prop->ID);
|
||||
$address = get_field('street_address', $prop->ID);
|
||||
foreach ($all_properties as $property) {
|
||||
// Skip properties without coordinates
|
||||
if (empty($property->latitude) || empty($property->longitude)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get coords for city, default to Albert Lea
|
||||
$coords = isset($city_coords[$city]) ? $city_coords[$city] : $city_coords['Albert Lea'];
|
||||
// Add small random offset to prevent overlapping markers (seeded by post ID for consistency)
|
||||
srand($prop->ID);
|
||||
$lat = $coords[0] + (rand(-50, 50) / 10000);
|
||||
$lng = $coords[1] + (rand(-50, 50) / 10000);
|
||||
// Format address
|
||||
$address_parts = array();
|
||||
if ($property->street_number) {
|
||||
$address_parts[] = $property->street_number;
|
||||
}
|
||||
if ($property->street_name) {
|
||||
$address_parts[] = $property->street_name;
|
||||
}
|
||||
if ($property->street_suffix) {
|
||||
$address_parts[] = $property->street_suffix;
|
||||
}
|
||||
$street = implode(' ', $address_parts);
|
||||
$full_address = $street ? $street . ', ' . $property->city : $property->city;
|
||||
|
||||
$markers[] = array(
|
||||
'id' => $prop->ID,
|
||||
'lat' => $lat,
|
||||
'lng' => $lng,
|
||||
'title' => $prop->post_title,
|
||||
'price' => '$' . number_format($price),
|
||||
'address' => $address . ', ' . $city,
|
||||
'url' => get_permalink($prop->ID),
|
||||
'id' => $property->listing_key,
|
||||
'lat' => (float) $property->latitude,
|
||||
'lng' => (float) $property->longitude,
|
||||
'title' => $full_address,
|
||||
'price' => '$' . number_format($property->list_price),
|
||||
'address' => $full_address,
|
||||
'url' => home_url('/properties/?listing=' . urlencode($property->listing_key)),
|
||||
'beds' => $property->bedrooms_total,
|
||||
'baths' => $property->bathrooms_total,
|
||||
'sqft' => $property->living_area,
|
||||
'status' => $property->standard_status,
|
||||
'photo' => null, // Placeholder - photos will be added later
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
/**
|
||||
* MLS Property Card Template Part
|
||||
*
|
||||
* Displays an MLS property in card format for archive views
|
||||
*
|
||||
* @package HomeProz
|
||||
*/
|
||||
|
||||
// Prevent direct access
|
||||
if (!defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// Get MLS property data from query var
|
||||
$property = get_query_var('mls_property');
|
||||
|
||||
if (!$property) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract property data
|
||||
$listing_key = $property->listing_key;
|
||||
$price = $property->list_price;
|
||||
$bedrooms = $property->bedrooms_total;
|
||||
$bathrooms = $property->bathrooms_total;
|
||||
$square_feet = $property->living_area;
|
||||
$status = $property->standard_status;
|
||||
$public_remarks = $property->public_remarks;
|
||||
|
||||
// Format address
|
||||
$address_parts = array();
|
||||
if ($property->street_number) {
|
||||
$address_parts[] = $property->street_number;
|
||||
}
|
||||
if ($property->street_name) {
|
||||
$address_parts[] = $property->street_name;
|
||||
}
|
||||
if ($property->street_suffix) {
|
||||
$address_parts[] = $property->street_suffix;
|
||||
}
|
||||
$street = implode(' ', $address_parts);
|
||||
|
||||
$full_address = $street;
|
||||
if ($property->city) {
|
||||
$full_address .= ', ' . $property->city;
|
||||
}
|
||||
if ($property->state_or_province) {
|
||||
$full_address .= ', ' . $property->state_or_province;
|
||||
}
|
||||
|
||||
// Property URL (will be updated when single property view is implemented)
|
||||
$property_url = home_url('/properties/?listing=' . urlencode($listing_key));
|
||||
|
||||
// Status class mapping
|
||||
$status_class = 'badge-active';
|
||||
if ($status === 'Pending') {
|
||||
$status_class = 'badge-pending';
|
||||
} elseif ($status === 'Closed' || $status === 'Sold') {
|
||||
$status_class = 'badge-sold';
|
||||
}
|
||||
?>
|
||||
|
||||
<article id="property-<?php echo esc_attr($listing_key); ?>" data-property-id="<?php echo esc_attr($listing_key); ?>" class="property-card card mls-property">
|
||||
<a href="<?php echo esc_url($property_url); ?>" class="property-card-link-overlay" aria-hidden="true" tabindex="-1"></a>
|
||||
<div class="property-card-image">
|
||||
<!-- Photo placeholder - will be implemented later -->
|
||||
<div class="property-card-placeholder">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
||||
<polyline points="9 22 9 12 15 12 15 22"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<?php if ($status) : ?>
|
||||
<span class="property-card-badge badge <?php echo esc_attr($status_class); ?>">
|
||||
<?php echo esc_html($status); ?>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="property-card-content">
|
||||
<div class="property-card-price">
|
||||
<?php echo esc_html('$' . number_format($price)); ?>
|
||||
</div>
|
||||
|
||||
<h3 class="property-card-title">
|
||||
<?php echo esc_html($full_address ?: 'Property ' . $listing_key); ?>
|
||||
</h3>
|
||||
|
||||
<?php if ($bedrooms || $bathrooms || $square_feet) : ?>
|
||||
<ul class="property-card-specs">
|
||||
<?php if ($bedrooms) : ?>
|
||||
<li class="spec-item">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M3 7v11h18V7M3 7V4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v3M3 7h18M7 11h4v4H7zM14 11h3"/>
|
||||
</svg>
|
||||
<span><?php echo esc_html($bedrooms); ?> <?php echo $bedrooms == 1 ? 'Bed' : 'Beds'; ?></span>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($bathrooms) : ?>
|
||||
<li class="spec-item">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M4 12h16M4 12v7a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-7M4 12V6a2 2 0 0 1 2-2h3v2a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1V4"/>
|
||||
</svg>
|
||||
<span><?php echo esc_html($bathrooms); ?> <?php echo $bathrooms == 1 ? 'Bath' : 'Baths'; ?></span>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($square_feet) : ?>
|
||||
<li class="spec-item">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||
<path d="M3 9h18M9 3v18"/>
|
||||
</svg>
|
||||
<span><?php echo esc_html(number_format($square_feet)); ?> sqft</span>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($public_remarks) : ?>
|
||||
<p class="property-card-excerpt">
|
||||
<?php echo esc_html(wp_trim_words($public_remarks, 15, '...')); ?>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<span class="property-card-link">
|
||||
View Details
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M5 12h14M12 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
@@ -2,7 +2,7 @@
|
||||
/**
|
||||
* Property Results Template Part
|
||||
*
|
||||
* Displays property results for archive/search
|
||||
* Displays MLS property results for archive/search
|
||||
*
|
||||
* @package HomeProz
|
||||
*/
|
||||
@@ -12,140 +12,70 @@ if (!defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// Get filter values
|
||||
// Check if MLS plugin is available
|
||||
if (!function_exists('mls_get_properties')) {
|
||||
?>
|
||||
<div class="no-properties">
|
||||
<h3>Properties Unavailable</h3>
|
||||
<p>Property listings are temporarily unavailable. Please try again later.</p>
|
||||
</div>
|
||||
<?php
|
||||
return;
|
||||
}
|
||||
|
||||
// Get filter values from URL
|
||||
$current_type = isset($_GET['property_type']) ? sanitize_text_field($_GET['property_type']) : '';
|
||||
$current_status = isset($_GET['property_status']) ? sanitize_text_field($_GET['property_status']) : '';
|
||||
$current_status = isset($_GET['property_status']) ? sanitize_text_field($_GET['property_status']) : 'Active';
|
||||
$current_location = isset($_GET['property_location']) ? sanitize_text_field($_GET['property_location']) : '';
|
||||
$current_min_price = isset($_GET['min_price']) ? intval($_GET['min_price']) : '';
|
||||
$current_max_price = isset($_GET['max_price']) ? intval($_GET['max_price']) : '';
|
||||
$current_beds = isset($_GET['beds']) ? intval($_GET['beds']) : '';
|
||||
$current_sort = isset($_GET['sort']) ? sanitize_text_field($_GET['sort']) : 'newest';
|
||||
|
||||
// Build query args
|
||||
// Pagination
|
||||
$paged = get_query_var('paged') ? get_query_var('paged') : 1;
|
||||
$args = array(
|
||||
'post_type' => 'property',
|
||||
'posts_per_page' => 12,
|
||||
'paged' => $paged,
|
||||
$per_page = 12;
|
||||
|
||||
// Build MLS query args
|
||||
$mls_args = array(
|
||||
'status' => $current_status ?: 'Active',
|
||||
'limit' => 1000, // Get all for counting, then paginate
|
||||
'orderby' => 'modification_timestamp',
|
||||
'order' => 'DESC',
|
||||
);
|
||||
|
||||
// Taxonomy filters
|
||||
$tax_query = array();
|
||||
|
||||
// Map filter values to MLS query args
|
||||
if ($current_type) {
|
||||
$tax_query[] = array(
|
||||
'taxonomy' => 'property_type',
|
||||
'field' => 'slug',
|
||||
'terms' => $current_type,
|
||||
);
|
||||
$mls_args['property_type'] = $current_type;
|
||||
}
|
||||
|
||||
if ($current_status) {
|
||||
$tax_query[] = array(
|
||||
'taxonomy' => 'property_status',
|
||||
'field' => 'slug',
|
||||
'terms' => $current_status,
|
||||
);
|
||||
}
|
||||
|
||||
if ($current_location) {
|
||||
$tax_query[] = array(
|
||||
'taxonomy' => 'property_location',
|
||||
'field' => 'slug',
|
||||
'terms' => $current_location,
|
||||
);
|
||||
$mls_args['city'] = $current_location;
|
||||
}
|
||||
|
||||
if (!empty($tax_query)) {
|
||||
$args['tax_query'] = $tax_query;
|
||||
if (count($tax_query) > 1) {
|
||||
$args['tax_query']['relation'] = 'AND';
|
||||
}
|
||||
}
|
||||
|
||||
// Meta query for price and bedrooms
|
||||
$meta_query = array();
|
||||
|
||||
if ($current_min_price) {
|
||||
$meta_query[] = array(
|
||||
'key' => 'property_price',
|
||||
'value' => $current_min_price,
|
||||
'type' => 'NUMERIC',
|
||||
'compare' => '>=',
|
||||
);
|
||||
$mls_args['min_price'] = $current_min_price;
|
||||
}
|
||||
|
||||
if ($current_max_price) {
|
||||
$meta_query[] = array(
|
||||
'key' => 'property_price',
|
||||
'value' => $current_max_price,
|
||||
'type' => 'NUMERIC',
|
||||
'compare' => '<=',
|
||||
);
|
||||
$mls_args['max_price'] = $current_max_price;
|
||||
}
|
||||
|
||||
if ($current_beds) {
|
||||
$meta_query[] = array(
|
||||
'key' => 'bedrooms',
|
||||
'value' => $current_beds,
|
||||
'type' => 'NUMERIC',
|
||||
'compare' => '>=',
|
||||
);
|
||||
$mls_args['min_beds'] = $current_beds;
|
||||
}
|
||||
|
||||
if (!empty($meta_query)) {
|
||||
$args['meta_query'] = $meta_query;
|
||||
if (count($meta_query) > 1) {
|
||||
$args['meta_query']['relation'] = 'AND';
|
||||
}
|
||||
}
|
||||
|
||||
// For status-based sorting, we need to fetch all matching properties and sort in PHP
|
||||
// This is efficient for real estate sites with < 1000 properties
|
||||
$args['posts_per_page'] = -1;
|
||||
$args['orderby'] = 'modified';
|
||||
$args['order'] = 'DESC';
|
||||
|
||||
$all_properties = get_posts($args);
|
||||
|
||||
// Sort by status (Active > Pending > Sold) then by modified date
|
||||
$sorted_properties = homeproz_sort_properties_by_status($all_properties);
|
||||
// Fetch all matching properties
|
||||
$all_properties = mls_get_properties($mls_args);
|
||||
|
||||
// Handle pagination manually
|
||||
$per_page = 12;
|
||||
$total = count($sorted_properties);
|
||||
$total = count($all_properties);
|
||||
$max_pages = ceil($total / $per_page);
|
||||
$offset = ($paged - 1) * $per_page;
|
||||
$paged_properties = array_slice($sorted_properties, $offset, $per_page);
|
||||
|
||||
// Create a fake WP_Query-like object for compatibility
|
||||
$properties = (object) array(
|
||||
'posts' => $paged_properties,
|
||||
'found_posts' => $total,
|
||||
'max_num_pages' => $max_pages,
|
||||
);
|
||||
|
||||
// Helper function to check if we have posts
|
||||
$properties->have_posts = function() use (&$paged_properties) {
|
||||
static $index = 0;
|
||||
if ($index < count($paged_properties)) {
|
||||
return true;
|
||||
}
|
||||
$index = 0;
|
||||
return false;
|
||||
};
|
||||
|
||||
// Loop through properties manually
|
||||
global $post;
|
||||
$property_index = 0;
|
||||
$paged_properties = array_slice($all_properties, $offset, $per_page);
|
||||
?>
|
||||
|
||||
<!-- Results Meta -->
|
||||
<div class="properties-meta">
|
||||
<p class="properties-count">
|
||||
<?php if ($properties->found_posts > 0) : ?>
|
||||
Showing <strong><?php echo esc_html($properties->found_posts); ?></strong>
|
||||
<?php echo $properties->found_posts === 1 ? 'property' : 'properties'; ?>
|
||||
<?php if ($total > 0) : ?>
|
||||
Showing <strong><?php echo esc_html($total); ?></strong>
|
||||
<?php echo $total === 1 ? 'property' : 'properties'; ?>
|
||||
<?php else : ?>
|
||||
No properties found
|
||||
<?php endif; ?>
|
||||
@@ -155,12 +85,11 @@ $property_index = 0;
|
||||
<?php if (!empty($paged_properties)) : ?>
|
||||
<div class="properties-grid">
|
||||
<?php
|
||||
foreach ($paged_properties as $property_post) :
|
||||
$post = $property_post;
|
||||
setup_postdata($post);
|
||||
get_template_part('template-parts/property/property-card');
|
||||
foreach ($paged_properties as $property) :
|
||||
// Pass MLS property to card template
|
||||
set_query_var('mls_property', $property);
|
||||
get_template_part('template-parts/property/property-card-mls');
|
||||
endforeach;
|
||||
wp_reset_postdata();
|
||||
?>
|
||||
</div>
|
||||
|
||||
@@ -195,5 +124,3 @@ $property_index = 0;
|
||||
<a href="<?php echo esc_url(get_post_type_archive_link('property')); ?>" class="btn btn-primary">View All Properties</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php wp_reset_postdata(); ?>
|
||||
|
||||
Reference in New Issue
Block a user