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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
root
2026-04-29 15:32:23 +00:00
parent 57b752f54e
commit b6df4dbb92
5385 changed files with 838580 additions and 2416 deletions
+4 -1
View File
@@ -70,7 +70,10 @@
"Bash(git -C /var/www/html add:*)",
"Bash(git -C /var/www/html diff --cached --stat)",
"Bash(git -C /var/www/html commit -m \"$\\(cat <<''EOF''\nAdd 6 reusable page templates with ACF integration\n\nIntroduces layout-focused templates for marketing pages:\n- Content with Sidebar: 70/30 grid with callout boxes\n- Alternating Blocks: Zigzag image/text sections\n- Service Detail: Hero + features grid + FAQ accordion\n- Card Grid: Configurable 2/3/4 column card layouts\n- Long-Form Article: Clean reading layout with related links\n- Landing Page: Conversion-focused with benefits and testimonial\n\nEach template has corresponding ACF field groups for content\nmanagement. Sample pages created under /page-template-examples/.\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(cat:*)"
"Bash(cat:*)",
"Bash(rsync -av /var/www/vhosts/homeprozrealestate.com/httpdocs/wp-content/plugins/mls-by-hansonxyz/includes/class-mls-query.php /var/www/vhosts/homeprozrealestate.com/staging/wp-content/plugins/mls-by-hansonxyz/includes/class-mls-query.php && rsync -av /var/www/vhosts/homeprozrealestate.com/httpdocs/wp-content/plugins/mls-by-hansonxyz/includes/class-mls-cluster.php /var/www/vhosts/homeprozrealestate.com/staging/wp-content/plugins/mls-by-hansonxyz/includes/class-mls-cluster.php)",
"Bash(crontab:*)",
"Bash(rsync:*)"
],
"deny": [],
"ask": []
+9
View File
@@ -29,6 +29,15 @@ Thumbs.db
# Debug
wp-content/debug.log
wp-content/debug.log.*
# Runtime image caches (regenerable, capped by mls-image-cache-cap)
wp-content/cache/transformed-images/
wp-content/uploads/mls-thumbnails/
wp-content/uploads/mls-listings/
# Local Claude CLI tooling
node_modules/claude-cli/
# Vite
*.local
+32
View File
@@ -13,6 +13,18 @@ Custom WordPress theme for HomeProz Real Estate (Albert Lea, MN). Dark/rust bran
7. **No custom animations** - keep it static and fast
8. **ASK before architectural decisions**
9. **No git commits unless asked** - commits are for checkpoints before major work or major milestones, not for small single-file changes
10. **Sync to staging** - after modifying theme or plugin files, sync them to `/var/www/vhosts/homeprozrealestate.com/staging/` using rsync
## Version Control Policy
Git is a snapshot tool and the historical record of what changed on the site. **If it's not in git, it may as well not exist.**
When asked to commit, commit **everything except build artifacts** — do not pick a "scope" or hand-select files. The point is the snapshot.
- **Commit:** all source (PHP, SCSS, JS), configs, `package.json`/`package-lock.json`, `db_content_updates/`, `node_modules/`, `dist/`, DB snapshots (`*.sql.gz`), plugins, themes, `CLAUDE.md`. `node_modules/` and `dist/` are tracked intentionally — see comments in `.gitignore`.
- **Do not commit:** runtime caches (`wp-content/cache/transformed-images/`), log files (`wp-content/debug.log*`), and other transient/regenerable runtime output. These belong in `.gitignore`.
If you find untracked files in scope of a commit, include them. If something looks ambiguous, default to committing it — under-tracking loses history; over-tracking is reversible.
## Build
@@ -73,3 +85,23 @@ $url = add_query_arg('property', urlencode($title), home_url('/contact/'));
## Page Classes
`Home_Page`, `Properties_Archive`, `Single_Property`, `About_Page`, `Contact_Page`, `Blog_Archive`, `Single_Post`, `Agents_Archive`, `Single_Agent`, `Search_Page`, `Error_404`
## MLS Property Overrides
### Force a non-HomeProz listing to appear as HomeProz
Some properties (e.g., LandProz listings) may need to appear on the HomeProz site. The `is_homeproz` flag overrides the office name check.
1. Find the property's `listing_key`:
```bash
wp --allow-root db query "SELECT listing_key, listing_id, street_name, city, list_office_name, is_homeproz FROM wp_mls_properties WHERE listing_id LIKE '%<MLS_ID>%'"
```
2. Set the override flag:
```bash
wp --allow-root db query "UPDATE wp_mls_properties SET is_homeproz = 1 WHERE listing_key = '<LISTING_KEY>'"
```
3. Document the change in `db_content_updates/` per the Database Content Changes policy.
**Example**: 121 Main St, Glenville (NST7785198) is listed under "LandProz Real Estate, LLC" but appears on HomeProz via `is_homeproz = 1`.
View File
View File
View File
View File
@@ -0,0 +1,138 @@
# Property Inquiry Form Field Reorder
**Date:** 2026-01-12
**Purpose:** Reorder Contact Form 7 "Property Inquiry Form" fields so Additional Comments appears after Name/Email/Phone
**Status:** Applied to dev on 2026-01-12
## Background
The property inquiry form should have this field order:
1. Your Inquiry (readonly display message)
2. Your Name
3. Email / Phone (side by side)
4. Additional Comments
5. Submit button
Previously, Additional Comments was above the contact fields.
## Changes Required
### Via WP Admin (Recommended)
1. Go to WP Admin > Contact > Forms
2. Edit "Property Inquiry Form"
3. Reorder the form fields as shown below
4. Save the form
### New Form Structure
```
<div class="form-group">
<label>Your Inquiry</label>
<div class="readonly-message-display readonly-message"></div>
</div>
[hidden listing-key]
[hidden listing-id]
[hidden property-address]
[hidden property-price]
[hidden property-url]
[hidden default-message]
[hidden agent-email]
[hidden agent-name]
<div class="form-row">
<div class="form-group">
<label>Your Name <span class="required">*</span></label>
[text* your-name]
</div>
</div>
<div class="form-row form-row-2col">
<div class="form-group">
<label>Email <span class="required">*</span></label>
[email* your-email]
</div>
<div class="form-group">
<label>Phone <span class="required">*</span></label>
[tel* your-phone]
</div>
</div>
<div class="form-group">
<label>Additional Comments</label>
[textarea comments placeholder "Any specific questions or information you'd like to know..."]
</div>
[submit class:btn class:btn-primary class:btn-lg "Send Inquiry"]
```
### Via SQL (Alternative)
**Important:** CF7 stores the form structure in `wp_postmeta._form`, not `wp_posts.post_content`.
First, find the form ID:
```sql
SELECT ID, post_title FROM wp_posts
WHERE post_type = 'wpcf7_contact_form'
AND post_title = 'Property Inquiry Form';
-- Result: ID = 156 (in dev)
```
Then update the `_form` meta with the new form structure:
```sql
UPDATE wp_postmeta
SET meta_value = '<div class="form-group">
<label>Your Inquiry</label>
<div class="readonly-message-display readonly-message"></div>
</div>
[hidden listing-key]
[hidden listing-id]
[hidden property-address]
[hidden property-price]
[hidden property-url]
[hidden default-message]
[hidden agent-email]
[hidden agent-name]
<div class="form-row">
<div class="form-group">
<label>Your Name <span class="required">*</span></label>
[text* your-name]
</div>
</div>
<div class="form-row form-row-2col">
<div class="form-group">
<label>Email <span class="required">*</span></label>
[email* your-email]
</div>
<div class="form-group">
<label>Phone <span class="required">*</span></label>
[tel* your-phone]
</div>
</div>
<div class="form-group">
<label>Additional Comments</label>
[textarea comments placeholder "Any specific questions or information you would like to know..."]
</div>
[submit class:btn class:btn-primary class:btn-lg "Send Inquiry"]'
WHERE post_id = (SELECT ID FROM wp_posts WHERE post_type = 'wpcf7_contact_form' AND post_title = 'Property Inquiry Form')
AND meta_key = '_form';
```
## Related Code Changes
The following theme files were also updated to support this change:
- `page-property-inquiry.php` - Updated fallback form field order, added agent lookup, split display vs submission messages
- `inc/wpcf7-hooks.php` - Updated HomeProz listing detection to use office name
- `template-parts/property/property-agent.php` - Updated to use listing key for inquiry URL
## Why
- Better UX: Users should enter their contact info before typing additional comments
- The display message shown to users no longer includes MLS# (cleaner)
- The submitted message includes MLS#, property URL, and user comments for agent reference
+35
View File
@@ -0,0 +1,35 @@
# Agent MLS ID Assignments
**Date:** 2026-01-12
**Purpose:** Assign HomeProz agent MLS IDs to agent profiles
## Changes Made
Updated `agent_mls_id` ACF field for agents to match MLS Grid listing agent IDs.
## SQL Commands
```sql
-- Assign NST503517070 to Davy Villarreal (ID 129) and Jordan Mullenbach (ID 130)
UPDATE wp_postmeta
SET meta_value = 'NST503517070'
WHERE post_id IN (129, 130)
AND meta_key = 'agent_mls_id';
```
## Final State
| Agent ID | Agent Name | MLS ID |
|----------|------------|--------|
| 128 | Anna Rahn | NST503517068 |
| 129 | Davy Villarreal | NST503517070 |
| 130 | Jordan Mullenbach | NST503517070 |
| 131 | Lily Dulitz | NST503517068 |
## Why
HomeProz has 2 listing agent MLS IDs in the MLS Grid data:
- NST503517068 (2 properties)
- NST503517070 (2 properties)
These were assigned to agents so property cards can display the correct agent information.
@@ -0,0 +1,65 @@
# Remove Legacy Property Posts
**Date**: 2026-01-20
**Type**: Content Deletion
**Priority**: Required before or after code deployment
## Summary
The legacy manual property entry system has been deprecated. All properties now come exclusively from the MLS sync. This requires deleting the manually-created property posts and their associated data.
## What Changed
The following legacy system components were removed from the codebase:
- `property` custom post type registration
- `single-property.php` template
- `property-card.php` template part
- ACF "Property Details" field group
- Agent listings section from `single-agent.php`
## Database Changes Required on Production
### 1. Delete Legacy Property Posts
Run this WP-CLI command to delete the legacy property posts:
```bash
wp --allow-root post delete 24 35 60 85 125 --force
```
These were the legacy posts:
- ID 24: "Geneva, MN - Double Lot with Shop"
- ID 35: "115 N Newton Ave, Albert Lea, MN"
- ID 60: "411 Court St, Albert Lea, MN"
- ID 85: "1224 Saint Joseph Ave, Albert Lea, MN"
- ID 125: "15131 800th Ave, Glenville, MN"
### 2. Clean Up Orphaned Post Meta
After deleting the posts, clean up any orphaned meta data:
```bash
wp --allow-root db query "DELETE pm FROM wp_postmeta pm LEFT JOIN wp_posts p ON pm.post_id = p.ID WHERE p.ID IS NULL"
```
### 3. Flush Rewrite Rules
After code deployment, flush rewrite rules to remove the property slug:
```bash
wp --allow-root rewrite flush
```
## Verification
After completing these steps, verify:
1. The `/properties/` archive page still works (MLS-based)
2. Agent profile pages no longer show a "Current Listings" section (expected - removed)
3. No 404 errors from old property URLs (will naturally 404 since posts deleted)
## Notes
- The `/properties/` URL now exclusively serves MLS data via `archive-property.php`
- Individual property URLs use the `?listing=XXXXX` query parameter for MLS listings
- Agent pages no longer display properties; this was intentional as the legacy linking system is deprecated
@@ -0,0 +1,130 @@
# Split About and Team Pages + Blog Menu
**Date**: 2026-01-21
**Type**: Page Template Split + New Page Creation + Menu Updates
**Status**: IMPLEMENTED IN DEV
## Summary
The About page has been split into two separate pages:
- **About Page** - Company story, additional WYSIWYG content area, and CTA
- **Team Page** - Agent grid, broker info, and CTA
Additionally:
- **Blog** added to navigation menu
- Blog templates updated to use consistent archive-hero styling
## Code Changes
- Created `page-team.php` - New Team Page template
- Modified `page-about.php` - Removed team/broker sections, added WYSIWYG content section
- Created `template-parts/content/content-team.scss` - Team page styles
- Updated `template-parts/content/content-about.scss` - Added styles for additional content section
- Updated `inc/template-functions.php` - Added Team_Page body class
- Updated `src/main.scss` - Added team SCSS import
- Updated `home.php` - Blog index uses archive-hero for consistency
- Updated `archive.php` - Category/tag archives use archive-hero for consistency
- Created `page-results.php` - Results Page template for sold properties
- Created `template-parts/content/content-results.scss` - Results page styles
## Database Changes (Already Applied in Dev)
The following changes were made directly to the dev database and need to be replicated in production:
### 1. Team Page Created
**Page ID**: 256
**Title**: Our Team
**Slug**: team
**Template**: page-team.php
```sql
-- Create page
INSERT INTO wp_posts (post_author, post_date, post_date_gmt, post_content, post_title, post_excerpt, post_status, comment_status, ping_status, post_name, post_type, post_modified, post_modified_gmt, to_ping, pinged, post_content_filtered)
VALUES (1, NOW(), UTC_TIMESTAMP(), '', 'Our Team', '', 'publish', 'closed', 'closed', 'team', 'page', NOW(), UTC_TIMESTAMP(), '', '', '');
-- Set page template (use the actual page ID from above)
INSERT INTO wp_postmeta (post_id, meta_key, meta_value) VALUES (LAST_INSERT_ID(), '_wp_page_template', 'page-team.php');
```
### 2. Menu Item Added to Primary Menu
Team added after About in the Primary Menu:
**Menu Order**:
1. Properties (0)
2. About (3)
3. Team (4) - NEW
4. Results (5) - NEW
5. Resources (6)
6. Blog (7) - NEW
7. Contact (8)
```sql
-- Shift existing items down (adjust IDs for production)
-- Resources menu item: update menu_order from 4 to 5
-- Contact menu item: update menu_order from 5 to 6
-- Create nav_menu_item for Team (adjust page ID for production)
INSERT INTO wp_posts (post_author, post_date, post_date_gmt, post_content, post_title, post_excerpt, post_status, comment_status, ping_status, post_name, post_type, menu_order, post_modified, post_modified_gmt, to_ping, pinged, post_content_filtered)
VALUES (1, NOW(), UTC_TIMESTAMP(), '', 'Team', '', 'publish', 'closed', 'closed', 'team-menu', 'nav_menu_item', 4, NOW(), UTC_TIMESTAMP(), '', '', '');
-- Add to Primary Menu term relationship
-- Add required meta: _menu_item_type, _menu_item_object_id, _menu_item_object, etc.
```
### 3. Blog Menu Item Added
Blog page (ID 9, already exists) added to Primary Menu.
### 4. Results Page Created
**Page ID**: 259
**Title**: Our Results
**Slug**: results
**Template**: page-results.php
Displays sold/closed properties from MLS sync and manual entries, sorted by close date (most recent first).
## Production Deployment Steps
1. Deploy code changes (theme files)
2. Run `npm run build` in theme directory
3. Either:
- **Option A**: Manually create Team page in WordPress admin, assign template, add to menu
- **Option B**: Run SQL statements above with adjusted IDs
### Manual Steps (Option A - Recommended)
1. Go to **Pages > Add New**
2. Title: `Our Team`
3. Slug: `team`
4. Template: **Team Page**
5. Publish
6. Go to **Appearance > Menus**
7. Select Primary Menu
8. Add "Our Team" page (set custom label to "Team")
9. Add "Blog" page
10. Order: Properties, About, Team, Resources, Blog, Contact
11. Save Menu
## Optional ACF Fields for Team Page
| Field | Default Value |
|-------|---------------|
| `hero_title` | "Our Team" |
| `hero_subtitle` | "A dedicated group of real estate professionals committed to your success." |
| `hero_background` | (none) |
| `broker_title` | "Broker Information" |
| `broker_text` | HomeProz broker details |
| `cta_title` | "Ready to Work With Us?" |
| `cta_text` | "Contact our team today..." |
| `cta_button_text` | "Get in Touch" |
| `cta_button_url` | /contact/ |
## Notes
- Agent ordering on Team page respects the `menu_order` field (drag-drop sortable in admin)
- Disabled agents are automatically excluded
- The About page retains its existing ACF fields
+67
View File
@@ -0,0 +1,67 @@
# Blog Enhancements
**Date**: 2026-01-21
**Type**: Template Updates + ACF Field Addition
**Status**: IMPLEMENTED IN DEV
## Summary
Enhanced single blog post pages with additional sections and added Agent author capability.
## Code Changes
### Templates
- `single.php` - Added three new sections below post content:
1. Enhanced Next/Prev navigation with thumbnails
2. Related Posts section (always shows, falls back to recent posts)
3. Featured Properties section (3 active MLS listings)
- `template-parts/content/content-single.php` - Added agent author display in post meta
- `template-parts/content/content-single.scss` - Added styles for:
- Post navigation with thumbnails
- Featured properties grid
- Author meta styling
### ACF Fields
- Added "Post Author" field group for blog posts:
- Field: `post_agent_author` (post_object linking to Agent CPT)
- Location: Sidebar of post edit screen
- Only shows active (non-disabled) agents in dropdown
### Functions
- Added `homeproz_filter_agent_post_object()` filter to exclude disabled agents from author dropdown
- Removed comments functionality site-wide
## Features
### Agent Author on Blog Posts
- In post editor sidebar, select an agent as the post author
- Author name displays in post meta with link to agent profile
- If agent is later disabled, name still shows but link is removed
- Only active agents appear in the dropdown selector
### Enhanced Post Navigation
- Shows Previous/Next post with thumbnail images
- Falls back to placeholder icon if no featured image
- Responsive grid layout
### Related Posts
- Shows 3 posts from same category
- Falls back to 3 recent posts if no category matches
- Always displays something (unless only 1 post exists)
### Featured Properties
- Shows 3 active MLS listings below blog content
- "View All Properties" button links to /properties/
- Helps drive traffic from blog to listings
## Agent Disabled Flag Behavior
The existing `agent_disabled` ACF field controls:
1. **Team Page**: Disabled agents hidden from grid
2. **Agent Archive**: Disabled agents excluded
3. **Agent Profile**: Returns 404 for disabled agents
4. **Property Cards**: Shows office contact instead of disabled agent
5. **Blog Author Dropdown**: Only active agents shown (NEW)
6. **Blog Author Display**: Disabled agent names show without link (NEW)
+77
View File
@@ -0,0 +1,77 @@
# Manual Property Entry System
**Date**: 2026-01-21
**Type**: Schema Change + New Feature
## Summary
Added the ability to manually enter properties that integrate seamlessly with MLS-synced listings. Manual properties can override MLS listings (same MLS ID), support cloning from existing MLS data, and include geocoding for addresses.
## Schema Changes
New table `wp_mls_properties_manual` created automatically by the MLS plugin on activation/upgrade.
The table will be created automatically when the plugin runs - no manual SQL needed.
## New Custom Post Type
**CPT**: `manual_property`
**Menu**: "Manual Properties" in WordPress admin
## ACF Field Group
ACF fields are registered programmatically (no JSON import needed). Fields will appear automatically on the Manual Property edit screen.
## How to Use
### Add a Manual Property
1. Go to **Manual Properties > Add Property** in WordPress admin
2. Fill in property details across the tabs:
- Basic Info: Status, Price, Type, HomeProz checkbox, Featured toggle
- Location: Address (geocoded automatically), City, State, ZIP
- Details: Beds, Baths, Square Feet, etc.
- Description: Public remarks
- Media: Upload property photos
- Agent: Select from Agent CPT
- Dates: List date, close date, etc.
3. Publish the property
### Clone from MLS
1. Go to **Manual Properties > Add Property**
2. Enter an MLS # in the "Clone from MLS Listing" box
3. Click "Clone Listing"
4. Images will be downloaded and fields pre-populated
5. Review and modify as needed, then publish
### Override an MLS Listing
1. Create a manual property
2. Set the MLS # field to the MLS ID you want to override
3. Publish
4. The manual version will appear in search results instead of the MLS-synced version
## Dependencies
- ACF Pro (already installed)
- MLS by HansonXyz plugin (updated in this release)
## Files Changed
Plugin files (MLS plugin):
- `mls-by-hansonxyz.php` - Added table constant, class includes
- `includes/class-mls-db.php` - Added manual properties table schema
- `includes/class-mls-query.php` - Modified to include manual properties in queries
- `includes/class-mls-image-endpoint.php` - Handle manual property images
- `includes/class-mls-manual-property.php` - NEW: CPT, ACF fields, sync logic
- `includes/class-mls-geocoder.php` - NEW: Address geocoding via Nominatim
- `admin/js/manual-property.js` - NEW: Clone from MLS functionality
- `admin/css/manual-property.css` - NEW: Admin styles
## Notes
- Manual properties use WordPress Media Library for images (not MLS media cache)
- Geocoding uses free Nominatim API (rate limited, cached for 30 days)
- Manual property listing keys follow format: `MANUAL-{post_id}`
- Properties with `listing_id` set will override MLS listings with matching ID
@@ -0,0 +1,37 @@
# About Page ACF Fields Update
**Date:** 2026-01-23
**Author:** Claude Code
## Summary
Added a new ACF WYSIWYG field to the About page to separate the second content section from the main page body. Previously, the page body content was appearing in both sections (duplicate content).
## Changes
### Content Structure (After)
- **Top Story Section** (next to image): Uses standard WordPress page body content (`the_content()`)
- **Additional Content Section** (below): Uses new ACF field `about_additional_content`
### ACF Field Added
**Additional Content** - `about_additional_content`
- WYSIWYG field
- Displayed in a separate section below the story section
- For extended company information
## Production Sync Instructions
1. Deploy the updated files:
- `wp-content/themes/homeproz/inc/acf-fields.php`
- `wp-content/themes/homeproz/page-about.php`
2. In WordPress Admin, edit the About page:
- Keep the main story content in the standard WordPress page body editor
- Add any additional content to the new "Additional Content" ACF field below
## Files Modified
- `wp-content/themes/homeproz/inc/acf-fields.php` - Added `about_additional_content` field
- `wp-content/themes/homeproz/page-about.php` - Top section now uses `the_content()`, bottom section uses ACF field
Regular → Executable
View File
+8 -1
View File
@@ -1,5 +1,5 @@
{
"name": "html",
"name": "httpdocs",
"lockfileVersion": 3,
"requires": true,
"packages": {
@@ -19,6 +19,13 @@
"node": ">=18"
}
},
"node_modules/claude-cli": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/claude-cli/-/claude-cli-1.0.5.tgz",
"integrity": "sha512-v/8f6BKedfus1npGTyX8AIYp0ldRo5FMwzRbPCen0zv2m+00Qrna46czEoYK6tupJcrbnzVwrQYRbKOxmhv36A==",
"deprecated": "The official Claude Code package is available at @anthropic-ai/claude-code",
"license": "ISC"
},
"node_modules/playwright": {
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz",
+11 -1
View File
@@ -1,9 +1,12 @@
{
"name": "html",
"name": "httpdocs",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"claude-cli": "^1.0.5"
},
"devDependencies": {
"@playwright/test": "^1.57.0"
}
@@ -24,6 +27,13 @@
"node": ">=18"
}
},
"node_modules/claude-cli": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/claude-cli/-/claude-cli-1.0.5.tgz",
"integrity": "sha512-v/8f6BKedfus1npGTyX8AIYp0ldRo5FMwzRbPCen0zv2m+00Qrna46czEoYK6tupJcrbnzVwrQYRbKOxmhv36A==",
"deprecated": "The official Claude Code package is available at @anthropic-ai/claude-code",
"license": "ISC"
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+3
View File
@@ -1,5 +1,8 @@
{
"devDependencies": {
"@playwright/test": "^1.57.0"
},
"dependencies": {
"claude-cli": "^1.0.5"
}
}
Regular → Executable
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 973 KiB

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

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

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

Some files were not shown because too many files have changed in this diff Show More