b6df4dbb92
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>
503 lines
15 KiB
PHP
Executable File
503 lines
15 KiB
PHP
Executable File
<?php
|
|
/**
|
|
* Plugin Name: MLS by HansonXyz
|
|
* Plugin URI: https://hanson.xyz
|
|
* Description: Syncs MLS Grid API data (NorthStar MLS) into local database with CLI tools and public API for themes/plugins.
|
|
* Version: 1.0.0
|
|
* Author: HansonXyz
|
|
* Author URI: https://hanson.xyz
|
|
* License: GPL-2.0+
|
|
* License URI: http://www.gnu.org/licenses/gpl-2.0.txt
|
|
* Text Domain: mls-by-hansonxyz
|
|
*/
|
|
|
|
// Prevent direct access
|
|
if (!defined('ABSPATH')) {
|
|
exit;
|
|
}
|
|
|
|
// Plugin constants
|
|
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__));
|
|
define('MLS_PLUGIN_BASENAME', plugin_basename(__FILE__));
|
|
define('MLS_DB_VERSION', '1.1.0');
|
|
|
|
// Database table names (without prefix)
|
|
define('MLS_TABLE_PROPERTIES', 'mls_properties');
|
|
define('MLS_TABLE_MEDIA', 'mls_media');
|
|
define('MLS_TABLE_SYNC_STATE', 'mls_sync_state');
|
|
define('MLS_TABLE_RATE_LIMITS', 'mls_rate_limits');
|
|
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'));
|
|
|
|
/**
|
|
* Main plugin class
|
|
*/
|
|
final class MLS_Plugin {
|
|
|
|
/**
|
|
* Single instance
|
|
*/
|
|
private static $instance = null;
|
|
|
|
/**
|
|
* Plugin components
|
|
*/
|
|
private $db;
|
|
private $options;
|
|
private $logger;
|
|
private $rate_limiter;
|
|
private $api_client;
|
|
private $sync_engine;
|
|
private $media_handler;
|
|
private $image_endpoint;
|
|
private $query;
|
|
private $cluster;
|
|
private $garbage_collector;
|
|
private $manual_property;
|
|
|
|
/**
|
|
* Get single instance
|
|
*/
|
|
public static function get_instance() {
|
|
if (null === self::$instance) {
|
|
self::$instance = new self();
|
|
}
|
|
return self::$instance;
|
|
}
|
|
|
|
/**
|
|
* Constructor
|
|
*/
|
|
private function __construct() {
|
|
$this->load_dependencies();
|
|
$this->init_hooks();
|
|
}
|
|
|
|
/**
|
|
* Load required files
|
|
*/
|
|
private function load_dependencies() {
|
|
// Core classes
|
|
require_once MLS_PLUGIN_DIR . 'includes/class-mls-db.php';
|
|
require_once MLS_PLUGIN_DIR . 'includes/class-mls-options.php';
|
|
require_once MLS_PLUGIN_DIR . 'includes/class-mls-logger.php';
|
|
require_once MLS_PLUGIN_DIR . 'includes/class-mls-rate-limiter.php';
|
|
require_once MLS_PLUGIN_DIR . 'includes/class-mls-api-client.php';
|
|
require_once MLS_PLUGIN_DIR . 'includes/class-mls-sync-engine.php';
|
|
require_once MLS_PLUGIN_DIR . 'includes/class-mls-media-handler.php';
|
|
require_once MLS_PLUGIN_DIR . 'includes/class-mls-image-endpoint.php';
|
|
require_once MLS_PLUGIN_DIR . 'includes/class-mls-query.php';
|
|
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';
|
|
require_once MLS_PLUGIN_DIR . 'includes/class-mls-deactivator.php';
|
|
|
|
// Admin
|
|
if (is_admin()) {
|
|
require_once MLS_PLUGIN_DIR . 'admin/class-mls-admin.php';
|
|
}
|
|
|
|
// WP-CLI
|
|
if (defined('WP_CLI') && WP_CLI) {
|
|
require_once MLS_PLUGIN_DIR . 'cli/class-mls-cli.php';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize hooks
|
|
*/
|
|
private function init_hooks() {
|
|
// Activation/Deactivation hooks
|
|
register_activation_hook(MLS_PLUGIN_FILE, array('MLS_Activator', 'activate'));
|
|
register_deactivation_hook(MLS_PLUGIN_FILE, array('MLS_Deactivator', 'deactivate'));
|
|
|
|
// Initialize components after plugins loaded
|
|
add_action('plugins_loaded', array($this, 'init_components'));
|
|
|
|
// Check for database updates
|
|
add_action('plugins_loaded', array($this, 'check_db_updates'));
|
|
}
|
|
|
|
/**
|
|
* Initialize plugin components
|
|
*/
|
|
public function init_components() {
|
|
$this->db = new MLS_DB();
|
|
$this->options = new MLS_Options();
|
|
$this->logger = new MLS_Logger($this->db);
|
|
$this->rate_limiter = new MLS_Rate_Limiter($this->db);
|
|
$this->api_client = new MLS_API_Client($this->options, $this->rate_limiter, $this->logger);
|
|
$this->media_handler = new MLS_Media_Handler($this->db, $this->logger);
|
|
$this->image_endpoint = new MLS_Image_Endpoint($this->media_handler, $this->logger);
|
|
$this->image_endpoint->init();
|
|
$this->sync_engine = new MLS_Sync_Engine(
|
|
$this->db,
|
|
$this->api_client,
|
|
$this->media_handler,
|
|
$this->logger
|
|
);
|
|
$this->query = new MLS_Query($this->db);
|
|
$this->cluster = new MLS_Cluster($this->db);
|
|
$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'));
|
|
add_action('wp_ajax_nopriv_mls_get_clusters', array($this, 'ajax_get_clusters'));
|
|
|
|
// Initialize admin
|
|
if (is_admin()) {
|
|
new MLS_Admin($this);
|
|
}
|
|
|
|
// Initialize CLI
|
|
if (defined('WP_CLI') && WP_CLI) {
|
|
MLS_CLI::register($this);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check and run database updates
|
|
*/
|
|
public function check_db_updates() {
|
|
$current_version = get_option('mls_db_version', '0');
|
|
|
|
if (version_compare($current_version, MLS_DB_VERSION, '<')) {
|
|
MLS_Activator::create_tables();
|
|
update_option('mls_db_version', MLS_DB_VERSION);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get DB instance
|
|
*/
|
|
public function get_db() {
|
|
return $this->db;
|
|
}
|
|
|
|
/**
|
|
* Get Options instance
|
|
*/
|
|
public function get_options() {
|
|
return $this->options;
|
|
}
|
|
|
|
/**
|
|
* Get Logger instance
|
|
*/
|
|
public function get_logger() {
|
|
return $this->logger;
|
|
}
|
|
|
|
/**
|
|
* Get Rate Limiter instance
|
|
*/
|
|
public function get_rate_limiter() {
|
|
return $this->rate_limiter;
|
|
}
|
|
|
|
/**
|
|
* Get API Client instance
|
|
*/
|
|
public function get_api_client() {
|
|
return $this->api_client;
|
|
}
|
|
|
|
/**
|
|
* Get Sync Engine instance
|
|
*/
|
|
public function get_sync_engine() {
|
|
return $this->sync_engine;
|
|
}
|
|
|
|
/**
|
|
* Get Media Handler instance
|
|
*/
|
|
public function get_media_handler() {
|
|
return $this->media_handler;
|
|
}
|
|
|
|
/**
|
|
* Get Image Endpoint instance
|
|
*/
|
|
public function get_image_endpoint() {
|
|
return $this->image_endpoint;
|
|
}
|
|
|
|
/**
|
|
* Get Query instance
|
|
*/
|
|
public function get_query() {
|
|
return $this->query;
|
|
}
|
|
|
|
/**
|
|
* Get Cluster instance
|
|
*/
|
|
public function get_cluster() {
|
|
return $this->cluster;
|
|
}
|
|
|
|
/**
|
|
* Get Garbage Collector instance
|
|
*/
|
|
public function get_garbage_collector() {
|
|
return $this->garbage_collector;
|
|
}
|
|
|
|
/**
|
|
* Get Manual Property instance
|
|
*/
|
|
public function get_manual_property() {
|
|
return $this->manual_property;
|
|
}
|
|
|
|
/**
|
|
* AJAX handler for getting map clusters
|
|
*/
|
|
public function ajax_get_clusters() {
|
|
// Parse input
|
|
$zoom = isset($_REQUEST['zoom']) ? (int) $_REQUEST['zoom'] : 10;
|
|
$bounds = isset($_REQUEST['bounds']) ? array_map('floatval', (array) $_REQUEST['bounds']) : null;
|
|
$status = isset($_REQUEST['status']) ? sanitize_text_field($_REQUEST['status']) : 'Active';
|
|
$property_type = isset($_REQUEST['property_type']) ? sanitize_text_field($_REQUEST['property_type']) : null;
|
|
$city = isset($_REQUEST['city']) ? sanitize_text_field($_REQUEST['city']) : null;
|
|
// Parse "City, SS" format - extract just the city name
|
|
if ($city && preg_match('/^(.+),\s*([A-Z]{2})$/', $city, $matches)) {
|
|
$city = $matches[1];
|
|
}
|
|
$min_price = isset($_REQUEST['min_price']) ? (int) $_REQUEST['min_price'] : null;
|
|
$max_price = isset($_REQUEST['max_price']) ? (int) $_REQUEST['max_price'] : null;
|
|
$min_beds = isset($_REQUEST['min_beds']) ? (int) $_REQUEST['min_beds'] : null;
|
|
|
|
$args = array(
|
|
'zoom' => $zoom,
|
|
'bounds' => $bounds,
|
|
'status' => $status,
|
|
'property_type' => $property_type ?: null,
|
|
'city' => $city ?: null,
|
|
'min_price' => $min_price ?: null,
|
|
'max_price' => $max_price ?: null,
|
|
'min_beds' => $min_beds ?: null,
|
|
);
|
|
|
|
$result = $this->cluster->get_clusters($args);
|
|
|
|
wp_send_json_success($result);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize the plugin
|
|
*/
|
|
function mls_plugin() {
|
|
return MLS_Plugin::get_instance();
|
|
}
|
|
|
|
// Start the plugin
|
|
add_action('plugins_loaded', 'mls_plugin', 0);
|
|
|
|
/**
|
|
* Global helper functions for themes/plugins
|
|
*/
|
|
|
|
/**
|
|
* Get MLS properties
|
|
*
|
|
* @param array $args Query arguments
|
|
* @return array Array of property objects
|
|
*/
|
|
function mls_get_properties($args = array()) {
|
|
$plugin = mls_plugin();
|
|
if (!$plugin->get_query()) {
|
|
return array();
|
|
}
|
|
return $plugin->get_query()->get_properties($args);
|
|
}
|
|
|
|
/**
|
|
* Get a single MLS property
|
|
*
|
|
* @param string $identifier Listing key or MLS ID
|
|
* @return object|null Property object or null
|
|
*/
|
|
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, $skip_manual_override);
|
|
}
|
|
|
|
/**
|
|
* Get media for a listing
|
|
*
|
|
* @param string $listing_key The listing key
|
|
* @return array Array of media objects
|
|
*/
|
|
function mls_get_property_media($listing_key) {
|
|
$plugin = mls_plugin();
|
|
if (!$plugin->get_query()) {
|
|
return array();
|
|
}
|
|
return $plugin->get_query()->get_property_media($listing_key);
|
|
}
|
|
|
|
/**
|
|
* Get primary image URL for a listing (on-demand fetching)
|
|
*
|
|
* 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;
|
|
}
|
|
$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;
|
|
}
|
|
|
|
/**
|
|
* Get distinct cities with listings
|
|
*
|
|
* @param string|null $status Optional status filter
|
|
* @return array Array of city names
|
|
*/
|
|
function mls_get_cities($status = null) {
|
|
$plugin = mls_plugin();
|
|
if (!$plugin->get_query()) {
|
|
return array();
|
|
}
|
|
return $plugin->get_query()->get_distinct_cities($status);
|
|
}
|
|
|
|
/**
|
|
* Check if MLS data is available
|
|
*
|
|
* @return bool True if synced data exists
|
|
*/
|
|
function mls_is_available() {
|
|
$plugin = mls_plugin();
|
|
if (!$plugin->get_query()) {
|
|
return false;
|
|
}
|
|
return $plugin->get_query()->has_data();
|
|
}
|
|
|
|
/**
|
|
* Get property count
|
|
*
|
|
* @param array $args Optional filter arguments
|
|
* @return int Property count
|
|
*/
|
|
function mls_get_property_count($args = array()) {
|
|
$plugin = mls_plugin();
|
|
if (!$plugin->get_query()) {
|
|
return 0;
|
|
}
|
|
return $plugin->get_query()->get_count($args);
|
|
}
|
|
|
|
/**
|
|
* Get geographic bounds for filtered properties
|
|
*
|
|
* @param array $args Filter arguments
|
|
* @return array|null Bounds with sw_lat, sw_lng, ne_lat, ne_lng
|
|
*/
|
|
function mls_get_filter_bounds($args = array()) {
|
|
$plugin = mls_plugin();
|
|
if (!$plugin->get_query()) {
|
|
return null;
|
|
}
|
|
return $plugin->get_query()->get_filter_bounds($args);
|
|
}
|
|
|
|
/**
|
|
* Get all images for a listing (on-demand fetching)
|
|
*
|
|
* Returns all media records with local_url populated where cached.
|
|
* Can optionally fetch first N uncached images on-demand.
|
|
*
|
|
* @param string $listing_key The listing key
|
|
* @param int $fetch_limit Max images to fetch on-demand (default: 1, 0 = none)
|
|
* @return array Array of media objects
|
|
*/
|
|
function mls_get_property_images($listing_key, $fetch_limit = 1) {
|
|
$plugin = mls_plugin();
|
|
if (!$plugin->get_media_handler()) {
|
|
return array();
|
|
}
|
|
return $plugin->get_media_handler()->get_listing_images($listing_key, $fetch_limit);
|
|
}
|
|
|
|
/**
|
|
* Get media cache statistics
|
|
*
|
|
* @return array Stats with total_media, cached, uncached counts
|
|
*/
|
|
function mls_get_cache_stats() {
|
|
$plugin = mls_plugin();
|
|
if (!$plugin->get_media_handler()) {
|
|
return array('total_media' => 0, 'cached' => 0, 'uncached' => 0);
|
|
}
|
|
return $plugin->get_media_handler()->get_cache_stats();
|
|
}
|
|
|
|
/**
|
|
* Get WebP thumbnail URL for a listing image
|
|
*
|
|
* Returns a URL to the WebP thumbnail endpoint which will:
|
|
* - Fetch the image from MLS Grid if not cached
|
|
* - Convert to WebP format using ImageMagick
|
|
* - Resize to requested dimension (800px thumb or 1800px full)
|
|
* - Cache the result for future requests
|
|
*
|
|
* @param string $listing_key The listing key
|
|
* @param int $index Image index (1-based, default: 1 for primary image)
|
|
* @param string $size 'thumb' (800px) or 'full' (1800px), default: 'thumb'
|
|
* @return string Image URL
|
|
*/
|
|
function mls_get_image_url($listing_key, $index = 1, $size = 'thumb') {
|
|
return MLS_Image_Endpoint::get_url($listing_key, $index, $size);
|
|
}
|