diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl
index f2631c50..97054eb4 100644
--- a/.beads/issues.jsonl
+++ b/.beads/issues.jsonl
@@ -1,10 +1,18 @@
-{"id":"html-2fp","title":"Separate Residential and Commercial listings on homepage","description":"","status":"closed","priority":1,"issue_type":"task","created_at":"2025-11-30T02:24:09.984594683-06:00","updated_at":"2025-11-30T02:33:12.32537052-06:00","closed_at":"2025-11-30T02:33:12.32537052-06:00","close_reason":"Separated Featured Homes and Commercial/Land into distinct homepage sections"}
-{"id":"html-3nq","title":"Enhance footer with office hours, professional logos, license numbers","description":"","status":"closed","priority":3,"issue_type":"task","created_at":"2025-11-30T02:24:30.889106857-06:00","updated_at":"2025-11-30T02:46:52.35661921-06:00","closed_at":"2025-11-30T02:46:52.35661921-06:00","close_reason":"Enhanced footer with office hours, professional logos (REALTOR, Equal Housing), and license number"}
-{"id":"html-5bw","title":"Add service cards section (Buy/Rent/Sell) to homepage","description":"","status":"closed","priority":1,"issue_type":"task","created_at":"2025-11-30T02:24:04.779591681-06:00","updated_at":"2025-11-30T02:32:12.064316318-06:00","closed_at":"2025-11-30T02:32:12.064316318-06:00","close_reason":"Added service cards section with Buy/Rent/Sell options"}
-{"id":"html-7jz","title":"Add Communities section to navigation and create community pages structure","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-11-30T02:24:15.204568226-06:00","updated_at":"2025-11-30T02:43:22.075934867-06:00","closed_at":"2025-11-30T02:43:22.075934867-06:00","close_reason":"Created Communities landing page, community page template, 3 community pages, and added to navigation"}
-{"id":"html-98b","title":"Add location search dropdown to homepage hero","description":"","status":"closed","priority":1,"issue_type":"task","created_at":"2025-11-30T02:23:59.555310037-06:00","updated_at":"2025-11-30T02:30:59.92891882-06:00","closed_at":"2025-11-30T02:30:59.92891882-06:00","close_reason":"Added location search dropdown to hero section with community taxonomy"}
-{"id":"html-bfd","title":"Update DESIGN-DOCUMENT.md and IMPLEMENTATION-PLAN.md with RHR structural changes","description":"","status":"closed","priority":0,"issue_type":"task","created_at":"2025-11-30T02:24:40.504170573-06:00","updated_at":"2025-11-30T02:28:50.551587345-06:00","closed_at":"2025-11-30T02:28:50.551587345-06:00","close_reason":"Updated DESIGN-DOCUMENT.md and IMPLEMENTATION-PLAN.md with RHR structural changes"}
-{"id":"html-clv","title":"Analyze Robert Hoffman Realty site structure for HomeProz redesign","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-11-30T02:11:43.511290155-06:00","updated_at":"2025-11-30T02:21:47.665340956-06:00","closed_at":"2025-11-30T02:21:47.665340956-06:00","close_reason":"Completed site analysis comparing RHR to HomeProz design"}
-{"id":"html-cpd","title":"Add map view to property listings archive page","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-11-30T02:24:20.442584472-06:00","updated_at":"2025-11-30T02:48:58.865691376-06:00","closed_at":"2025-11-30T02:48:58.865691376-06:00","close_reason":"Added map view to property archive with Grid/Map toggle using Leaflet, city-based property markers, and split layout"}
-{"id":"html-lci","title":"Scrape homeprozrealestate.com property listings and import to WordPress","description":"","status":"closed","priority":1,"issue_type":"task","created_at":"2025-11-30T17:37:41.948645374-06:00","updated_at":"2025-11-30T18:06:33.347607321-06:00","closed_at":"2025-11-30T18:06:33.347607321-06:00","close_reason":"Imported 5 properties with images, ACF fields, and external listing URLs"}
-{"id":"html-t8u","title":"Add Resources section to navigation and create resource pages","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-11-30T02:24:25.662824938-06:00","updated_at":"2025-11-30T02:45:31.13972652-06:00","closed_at":"2025-11-30T02:45:31.13972652-06:00","close_reason":"Created Resources landing page, resource page template, Buyer's Guide, Seller's Guide, and added to navigation"}
+{"id":"html-2fp","title":"Separate Residential and Commercial listings on homepage","description":"","status":"closed","priority":1,"issue_type":"task","created_at":"2025-11-30T02:24:09.984594683-06:00","updated_at":"2025-11-30T02:33:12.32537052-06:00","closed_at":"2025-11-30T02:33:12.32537052-06:00"}
+{"id":"html-3fb","title":"MLS by HansonXyz Plugin - Phase 5: Public API","description":"Query class with filter support, global helper functions (mls_get_properties, etc), integration hooks for themes","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-14T21:02:45.05131653-06:00","updated_at":"2025-12-14T21:21:46.940975819-06:00","closed_at":"2025-12-14T21:21:46.940975819-06:00","dependencies":[{"issue_id":"html-3fb","depends_on_id":"html-5j7","type":"blocks","created_at":"2025-12-14T21:04:05.308661828-06:00","created_by":"unknown"}]}
+{"id":"html-3nq","title":"Enhance footer with office hours, professional logos, license numbers","description":"","status":"closed","priority":3,"issue_type":"task","created_at":"2025-11-30T02:24:30.889106857-06:00","updated_at":"2025-11-30T02:46:52.35661921-06:00","closed_at":"2025-11-30T02:46:52.35661921-06:00"}
+{"id":"html-4q8","title":"MLS by HansonXyz Plugin - Phase 6: Admin Interface","description":"Settings page under Settings menu, API token configuration, sync status display, manual sync triggers","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-14T21:02:50.276941526-06:00","updated_at":"2025-12-14T21:21:56.543615901-06:00","closed_at":"2025-12-14T21:21:56.543615901-06:00","dependencies":[{"issue_id":"html-4q8","depends_on_id":"html-3fb","type":"blocks","created_at":"2025-12-14T21:04:10.388690618-06:00","created_by":"unknown"}]}
+{"id":"html-4za","title":"MLS by HansonXyz Plugin - Phase 2: API Client","description":"API Client class with auth, gzip, error handling. Rate Limiter class. CLI test commands (wp mls test connection/auth)","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-14T21:02:29.352944416-06:00","updated_at":"2025-12-14T21:21:36.854872754-06:00","closed_at":"2025-12-14T21:21:36.854872754-06:00","dependencies":[{"issue_id":"html-4za","depends_on_id":"html-ha4","type":"blocks","created_at":"2025-12-14T21:03:50.090841814-06:00","created_by":"unknown"}]}
+{"id":"html-5bw","title":"Add service cards section (Buy/Rent/Sell) to homepage","description":"","status":"closed","priority":1,"issue_type":"task","created_at":"2025-11-30T02:24:04.779591681-06:00","updated_at":"2025-11-30T02:32:12.064316318-06:00","closed_at":"2025-11-30T02:32:12.064316318-06:00"}
+{"id":"html-5j7","title":"MLS by HansonXyz Plugin - Phase 3: Sync Engine","description":"Sync Engine class, full sync with pagination, incremental sync with ModificationTimestamp, sync state tracking for resume, CLI sync commands","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-14T21:02:34.581442915-06:00","updated_at":"2025-12-14T21:21:46.933578124-06:00","closed_at":"2025-12-14T21:21:46.933578124-06:00","dependencies":[{"issue_id":"html-5j7","depends_on_id":"html-4za","type":"blocks","created_at":"2025-12-14T21:03:55.163778736-06:00","created_by":"unknown"}]}
+{"id":"html-7jz","title":"Add Communities section to navigation and create community pages structure","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-11-30T02:24:15.204568226-06:00","updated_at":"2025-11-30T02:43:22.075934867-06:00","closed_at":"2025-11-30T02:43:22.075934867-06:00"}
+{"id":"html-98b","title":"Add location search dropdown to homepage hero","description":"","status":"closed","priority":1,"issue_type":"task","created_at":"2025-11-30T02:23:59.555310037-06:00","updated_at":"2025-11-30T02:30:59.92891882-06:00","closed_at":"2025-11-30T02:30:59.92891882-06:00"}
+{"id":"html-bfd","title":"Update DESIGN-DOCUMENT.md and IMPLEMENTATION-PLAN.md with RHR structural changes","description":"","status":"closed","priority":0,"issue_type":"task","created_at":"2025-11-30T02:24:40.504170573-06:00","updated_at":"2025-11-30T02:28:50.551587345-06:00","closed_at":"2025-11-30T02:28:50.551587345-06:00"}
+{"id":"html-clv","title":"Analyze Robert Hoffman Realty site structure for HomeProz redesign","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-11-30T02:11:43.511290155-06:00","updated_at":"2025-11-30T02:21:47.665340956-06:00","closed_at":"2025-11-30T02:21:47.665340956-06:00"}
+{"id":"html-cpd","title":"Add map view to property listings archive page","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-11-30T02:24:20.442584472-06:00","updated_at":"2025-11-30T02:48:58.865691376-06:00","closed_at":"2025-11-30T02:48:58.865691376-06:00"}
+{"id":"html-ha4","title":"MLS by HansonXyz Plugin - Phase 1: Foundation","description":"Create plugin structure, main file, activator/deactivator, database schema with dbDelta, options handling, logger class","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-14T21:02:24.104655824-06:00","updated_at":"2025-12-14T21:21:36.848348568-06:00","closed_at":"2025-12-14T21:21:36.848348568-06:00"}
+{"id":"html-k37","title":"MLS by HansonXyz Plugin - Phase 8: Documentation \u0026 Testing","description":"CLAUDE.md, API.md, USAGE.md documentation. Full CLI test sequence. Final verification.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-14T21:03:00.728802408-06:00","updated_at":"2025-12-14T21:22:16.755253009-06:00","closed_at":"2025-12-14T21:22:16.755253009-06:00","dependencies":[{"issue_id":"html-k37","depends_on_id":"html-x03","type":"blocks","created_at":"2025-12-14T21:04:20.543019628-06:00","created_by":"unknown"}]}
+{"id":"html-lci","title":"Scrape homeprozrealestate.com property listings and import to WordPress","description":"","status":"closed","priority":1,"issue_type":"task","created_at":"2025-11-30T17:37:41.948645374-06:00","updated_at":"2025-11-30T18:06:33.347607321-06:00","closed_at":"2025-11-30T18:06:33.347607321-06:00"}
+{"id":"html-sbh","title":"MLS by HansonXyz Plugin - Phase 4: Media Handler","description":"Media Handler class, download and organize media files, PhotosChangeTimestamp detection, CLI media commands","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-14T21:02:39.793324508-06:00","updated_at":"2025-12-14T21:21:46.94012466-06:00","closed_at":"2025-12-14T21:21:46.94012466-06:00","dependencies":[{"issue_id":"html-sbh","depends_on_id":"html-5j7","type":"blocks","created_at":"2025-12-14T21:04:00.24472923-06:00","created_by":"unknown"}]}
+{"id":"html-t8u","title":"Add Resources section to navigation and create resource pages","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-11-30T02:24:25.662824938-06:00","updated_at":"2025-11-30T02:45:31.13972652-06:00","closed_at":"2025-11-30T02:45:31.13972652-06:00"}
+{"id":"html-x03","title":"MLS by HansonXyz Plugin - Phase 7: Cron \u0026 Automation","description":"WP Cron scheduling (configurable interval), standalone cron script for Unix cron","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-14T21:02:55.483012095-06:00","updated_at":"2025-12-14T21:22:06.519310981-06:00","closed_at":"2025-12-14T21:22:06.519310981-06:00","dependencies":[{"issue_id":"html-x03","depends_on_id":"html-4q8","type":"blocks","created_at":"2025-12-14T21:04:15.476790917-06:00","created_by":"unknown"}]}
diff --git a/wp-content/plugins/mls-by-hansonxyz/admin/class-mls-admin.php b/wp-content/plugins/mls-by-hansonxyz/admin/class-mls-admin.php
new file mode 100644
index 00000000..abd499b1
--- /dev/null
+++ b/wp-content/plugins/mls-by-hansonxyz/admin/class-mls-admin.php
@@ -0,0 +1,386 @@
+plugin = $plugin;
+
+ add_action('admin_menu', array($this, 'add_menu'));
+ add_action('admin_init', array($this, 'register_settings'));
+ add_action('wp_ajax_mls_test_connection', array($this, 'ajax_test_connection'));
+ add_action('wp_ajax_mls_run_sync', array($this, 'ajax_run_sync'));
+ }
+
+ /**
+ * Add admin menu
+ */
+ public function add_menu() {
+ add_options_page(
+ 'MLS Settings',
+ 'MLS Settings',
+ 'manage_options',
+ 'mls-settings',
+ array($this, 'render_settings_page')
+ );
+ }
+
+ /**
+ * Register settings
+ */
+ public function register_settings() {
+ register_setting('mls_settings', MLS_Options::OPTION_KEY, array(
+ 'sanitize_callback' => array($this, 'sanitize_options'),
+ ));
+ }
+
+ /**
+ * Sanitize options
+ */
+ public function sanitize_options($input) {
+ $sanitized = array();
+
+ if (isset($input['api_url'])) {
+ $sanitized['api_url'] = esc_url_raw($input['api_url']);
+ }
+
+ if (isset($input['api_token'])) {
+ $sanitized['api_token'] = sanitize_text_field($input['api_token']);
+ }
+
+ if (isset($input['originating_system'])) {
+ $sanitized['originating_system'] = sanitize_text_field($input['originating_system']);
+ }
+
+ $sanitized['auto_sync_enabled'] = !empty($input['auto_sync_enabled']);
+ $sanitized['sync_media'] = !empty($input['sync_media']);
+
+ if (isset($input['sync_interval'])) {
+ $allowed = array('every_30_minutes', 'hourly', 'every_2_hours', 'every_6_hours', 'every_12_hours', 'daily');
+ $sanitized['sync_interval'] = in_array($input['sync_interval'], $allowed)
+ ? $input['sync_interval']
+ : 'hourly';
+ }
+
+ // Preserve timestamps
+ $existing = get_option(MLS_Options::OPTION_KEY, array());
+ if (isset($existing['last_full_sync'])) {
+ $sanitized['last_full_sync'] = $existing['last_full_sync'];
+ }
+ if (isset($existing['last_incremental_sync'])) {
+ $sanitized['last_incremental_sync'] = $existing['last_incremental_sync'];
+ }
+
+ return $sanitized;
+ }
+
+ /**
+ * Render settings page
+ */
+ public function render_settings_page() {
+ if (!current_user_can('manage_options')) {
+ return;
+ }
+
+ $options = $this->plugin->get_options();
+ $db = $this->plugin->get_db();
+ $stats = $db->get_stats();
+ $sync_engine = $this->plugin->get_sync_engine();
+ $sync_status = $sync_engine->get_status();
+ $rate_limiter = $this->plugin->get_rate_limiter();
+ $rate_status = $rate_limiter->get_status();
+
+ // Check if using wp-config constants
+ $using_config_url = defined('MLSGRID_API_URL') && MLSGRID_API_URL;
+ $using_config_token = defined('MLSGRID_ACCESS_TOKEN') && MLSGRID_ACCESS_TOKEN;
+
+ ?>
+
+
MLS Settings
+
+
+
+
+
+
+
+
+
+
Database Statistics
+
+
+ | Total Properties |
+ |
+
+
+ | Active |
+ |
+
+
+ | Pending |
+ |
+
+
+ | Sold/Closed |
+ |
+
+
+ | Media Files |
+ / |
+
+
+
+
+
+
Last Sync
+
+
+ Type: sync_type); ?>
+ Completed: completed_at); ?>
+ Records: records_processed); ?> processed
+
+
+
No sync completed yet.
+
+
+
+
+
Rate Limits
+
+
+ | Hourly |
+ / |
+
+
+ | Daily |
+ / |
+
+
+ | Data This Hour |
+ |
+
+
+
+
+
+
+
+
+
+ plugin->get_api_client();
+ $result = $api_client->test_connection();
+
+ if ($result['success']) {
+ wp_send_json_success($result);
+ } else {
+ wp_send_json_error($result['error']);
+ }
+ }
+
+ /**
+ * AJAX: Run sync
+ */
+ public function ajax_run_sync() {
+ check_ajax_referer('mls_admin', '_wpnonce');
+
+ if (!current_user_can('manage_options')) {
+ wp_send_json_error('Permission denied');
+ }
+
+ $sync_type = isset($_POST['sync_type']) ? sanitize_text_field($_POST['sync_type']) : 'incremental';
+ $sync_engine = $this->plugin->get_sync_engine();
+
+ // Run sync with a reasonable limit for AJAX
+ if ($sync_type === 'full') {
+ $result = $sync_engine->run_full_sync(false, 500);
+ } else {
+ $result = $sync_engine->run_incremental_sync(false);
+ }
+
+ if ($result['success']) {
+ wp_send_json_success(array(
+ 'message' => sprintf(
+ '%d processed, %d created, %d updated',
+ $result['stats']['processed'],
+ $result['stats']['created'],
+ $result['stats']['updated']
+ ),
+ 'stats' => $result['stats'],
+ ));
+ } else {
+ wp_send_json_error($result['error']);
+ }
+ }
+}
diff --git a/wp-content/plugins/mls-by-hansonxyz/cli/class-mls-cli.php b/wp-content/plugins/mls-by-hansonxyz/cli/class-mls-cli.php
new file mode 100644
index 00000000..a589b44b
--- /dev/null
+++ b/wp-content/plugins/mls-by-hansonxyz/cli/class-mls-cli.php
@@ -0,0 +1,478 @@
+plugin = $plugin;
+ }
+
+ /**
+ * Register CLI commands
+ */
+ public static function register($plugin) {
+ $instance = new self($plugin);
+
+ WP_CLI::add_command('mls test', array($instance, 'test'));
+ WP_CLI::add_command('mls status', array($instance, 'status'));
+ WP_CLI::add_command('mls sync', array($instance, 'sync'));
+ WP_CLI::add_command('mls stats', array($instance, 'stats'));
+ WP_CLI::add_command('mls cache', array($instance, 'cache'));
+ }
+
+ /**
+ * Test API connection and authentication.
+ *
+ * ## OPTIONS
+ *
+ *
+ * : Test type: connection or auth
+ *
+ * [--verbose]
+ * : Show detailed information
+ *
+ * ## EXAMPLES
+ *
+ * wp mls test connection
+ * wp mls test auth
+ * wp mls test connection --verbose
+ *
+ * @subcommand test
+ */
+ public function test($args, $assoc_args) {
+ $type = isset($args[0]) ? $args[0] : 'connection';
+ $verbose = isset($assoc_args['verbose']);
+
+ $api_client = $this->plugin->get_api_client();
+
+ switch ($type) {
+ case 'connection':
+ WP_CLI::line('Testing connection to MLS Grid API...');
+
+ $result = $api_client->test_connection();
+
+ if ($result['success']) {
+ WP_CLI::success('Connection successful!');
+ WP_CLI::line(sprintf('Response time: %dms', $result['response_time']));
+
+ if ($verbose && !empty($result['endpoints'])) {
+ WP_CLI::line('Available endpoints:');
+ foreach ($result['endpoints'] as $endpoint) {
+ WP_CLI::line(' - ' . $endpoint);
+ }
+ }
+ } else {
+ WP_CLI::error('Connection failed: ' . $result['error']);
+ }
+ break;
+
+ case 'auth':
+ WP_CLI::line('Testing API authentication...');
+
+ $options = $this->plugin->get_options();
+ if (!$options->get_api_token()) {
+ WP_CLI::error('No API token configured. Set MLSGRID_ACCESS_TOKEN in wp-config.php');
+ }
+
+ $result = $api_client->test_auth();
+
+ if ($result['success']) {
+ WP_CLI::success('Authentication successful!');
+ WP_CLI::line('Originating System: ' . $result['originating_system']);
+ } else {
+ WP_CLI::error('Authentication failed: ' . $result['error']);
+ }
+ break;
+
+ default:
+ WP_CLI::error("Unknown test type: {$type}. Use 'connection' or 'auth'.");
+ }
+ }
+
+ /**
+ * Show sync status and rate limits.
+ *
+ * ## OPTIONS
+ *
+ * []
+ * : Status type: sync, rate-limits, or all (default)
+ *
+ * ## EXAMPLES
+ *
+ * wp mls status
+ * wp mls status sync
+ * wp mls status rate-limits
+ *
+ * @subcommand status
+ */
+ public function status($args, $assoc_args) {
+ $type = isset($args[0]) ? $args[0] : 'all';
+
+ if ($type === 'all' || $type === 'sync') {
+ $this->show_sync_status();
+ }
+
+ if ($type === 'all' || $type === 'rate-limits') {
+ $this->show_rate_limits();
+ }
+ }
+
+ /**
+ * Show sync status
+ */
+ private function show_sync_status() {
+ $sync_engine = $this->plugin->get_sync_engine();
+ $status = $sync_engine->get_status();
+
+ WP_CLI::line('');
+ WP_CLI::line('=== Sync Status ===');
+
+ if ($status['running_sync']) {
+ WP_CLI::warning('Sync currently running');
+ WP_CLI::line(sprintf(
+ ' Type: %s | Started: %s | Processed: %d',
+ $status['running_sync']->sync_type,
+ $status['running_sync']->started_at,
+ $status['running_sync']->records_processed
+ ));
+ }
+
+ if ($status['last_sync']) {
+ WP_CLI::line('Last completed sync:');
+ WP_CLI::line(sprintf(
+ ' Type: %s | Completed: %s',
+ $status['last_sync']->sync_type,
+ $status['last_sync']->completed_at
+ ));
+ WP_CLI::line(sprintf(
+ ' Records: %d processed, %d created, %d updated, %d deleted',
+ $status['last_sync']->records_processed,
+ $status['last_sync']->records_created,
+ $status['last_sync']->records_updated,
+ $status['last_sync']->records_deleted
+ ));
+ } else {
+ WP_CLI::line('No completed syncs found.');
+ }
+
+ if ($status['last_failed']) {
+ WP_CLI::warning('Last failed sync:');
+ WP_CLI::line(' Error: ' . $status['last_failed']->last_error);
+ }
+
+ WP_CLI::line('');
+ }
+
+ /**
+ * Show rate limit status
+ */
+ private function show_rate_limits() {
+ $rate_limiter = $this->plugin->get_rate_limiter();
+ $status = $rate_limiter->get_status();
+
+ WP_CLI::line('=== Rate Limits ===');
+ WP_CLI::line(sprintf(
+ 'Hourly: %d / %d requests (%d remaining)',
+ $status['hourly']['used'],
+ $status['hourly']['limit'],
+ $status['hourly']['remaining']
+ ));
+ WP_CLI::line(sprintf(
+ 'Daily: %d / %d requests (%d remaining)',
+ $status['daily']['used'],
+ $status['daily']['limit'],
+ $status['daily']['remaining']
+ ));
+ WP_CLI::line(sprintf(
+ 'Data: %s / 4GB this hour',
+ size_format($status['bytes_this_hour'])
+ ));
+ WP_CLI::line('');
+ }
+
+ /**
+ * Run property sync.
+ *
+ * ## OPTIONS
+ *
+ *
+ * : Sync type: full, incremental, media, or resume
+ *
+ * [--dry-run]
+ * : Show what would be synced without making changes
+ *
+ * [--limit=]
+ * : Limit number of records to process
+ *
+ * [--id=]
+ * : Sync state ID to resume (for resume command)
+ *
+ * [--force]
+ * : Force re-download of media (for media command)
+ *
+ * ## EXAMPLES
+ *
+ * wp mls sync full
+ * wp mls sync full --dry-run --limit=10
+ * wp mls sync incremental
+ * wp mls sync media --limit=100
+ * wp mls sync resume --id=5
+ *
+ * @subcommand sync
+ */
+ public function sync($args, $assoc_args) {
+ $type = isset($args[0]) ? $args[0] : 'incremental';
+ $dry_run = isset($assoc_args['dry-run']);
+ $limit = isset($assoc_args['limit']) ? (int) $assoc_args['limit'] : null;
+
+ $sync_engine = $this->plugin->get_sync_engine();
+
+ // Progress callback for CLI
+ $progress = null;
+ $progress_callback = function($stats) use (&$progress) {
+ if ($progress) {
+ $progress->tick();
+ }
+ };
+
+ switch ($type) {
+ case 'full':
+ WP_CLI::line('Starting full sync...');
+ if ($dry_run) {
+ WP_CLI::line('DRY RUN - No changes will be made');
+ }
+
+ $result = $sync_engine->run_full_sync($dry_run, $limit, $progress_callback);
+ $this->output_sync_result($result);
+ break;
+
+ case 'incremental':
+ WP_CLI::line('Starting incremental sync...');
+ if ($dry_run) {
+ WP_CLI::line('DRY RUN - No changes will be made');
+ }
+
+ $result = $sync_engine->run_incremental_sync($dry_run, $progress_callback);
+ $this->output_sync_result($result);
+ break;
+
+ case 'media':
+ WP_CLI::line('Downloading pending media...');
+
+ $media_handler = $this->plugin->get_media_handler();
+ $result = $media_handler->download_pending($limit ?: 100);
+
+ WP_CLI::line(sprintf(
+ 'Media download complete: %d success, %d failed out of %d total',
+ $result['success'],
+ $result['failed'],
+ $result['total']
+ ));
+
+ if ($result['failed'] === 0 && $result['total'] > 0) {
+ WP_CLI::success('All media downloaded successfully!');
+ } elseif ($result['total'] === 0) {
+ WP_CLI::success('No pending media to download.');
+ } else {
+ WP_CLI::warning('Some media failed to download.');
+ }
+ break;
+
+ case 'resume':
+ $sync_id = isset($assoc_args['id']) ? (int) $assoc_args['id'] : null;
+ if (!$sync_id) {
+ WP_CLI::error('Please specify --id= to resume');
+ }
+
+ WP_CLI::line("Resuming sync #{$sync_id}...");
+
+ $result = $sync_engine->resume_sync($sync_id, $progress_callback);
+ $this->output_sync_result($result);
+ break;
+
+ default:
+ WP_CLI::error("Unknown sync type: {$type}. Use 'full', 'incremental', 'media', or 'resume'.");
+ }
+ }
+
+ /**
+ * Output sync result
+ */
+ private function output_sync_result($result) {
+ if ($result['success']) {
+ WP_CLI::success('Sync completed successfully!');
+ } else {
+ WP_CLI::error('Sync failed: ' . $result['error']);
+ }
+
+ $stats = $result['stats'];
+ WP_CLI::line(sprintf(
+ 'Processed: %d | Created: %d | Updated: %d | Deleted: %d | Errors: %d',
+ $stats['processed'],
+ $stats['created'],
+ $stats['updated'],
+ $stats['deleted'],
+ $stats['errors']
+ ));
+ }
+
+ /**
+ * Show database statistics.
+ *
+ * ## EXAMPLES
+ *
+ * wp mls stats
+ *
+ * @subcommand stats
+ */
+ public function stats($args, $assoc_args) {
+ $db = $this->plugin->get_db();
+ $stats = $db->get_stats();
+
+ WP_CLI::line('');
+ WP_CLI::line('=== MLS Database Statistics ===');
+ WP_CLI::line('');
+ WP_CLI::line('Properties:');
+ WP_CLI::line(sprintf(' Total: %d', $stats['total_properties']));
+ WP_CLI::line(sprintf(' Active: %d', $stats['active_properties']));
+ WP_CLI::line(sprintf(' Pending: %d', $stats['pending_properties']));
+ WP_CLI::line(sprintf(' Sold: %d', $stats['sold_properties']));
+ WP_CLI::line('');
+ WP_CLI::line('Media:');
+ WP_CLI::line(sprintf(' Total records: %d', $stats['total_media']));
+ WP_CLI::line(sprintf(' Downloaded: %d', $stats['downloaded_media']));
+ WP_CLI::line(sprintf(' Pending: %d', $stats['total_media'] - $stats['downloaded_media']));
+ WP_CLI::line('');
+
+ // Show distinct values
+ $query = $this->plugin->get_query();
+
+ $cities = $query->get_distinct_cities('Active');
+ WP_CLI::line('Cities with active listings: ' . count($cities));
+ if (count($cities) <= 20) {
+ WP_CLI::line(' ' . implode(', ', $cities));
+ }
+
+ WP_CLI::line('');
+ }
+
+ /**
+ * Manage cache.
+ *
+ * ## OPTIONS
+ *
+ *
+ * : Action: clear, clear-listing, cleanup
+ *
+ * [--confirm]
+ * : Confirm destructive operations
+ *
+ * [--listing=]
+ * : Listing key for clear-listing
+ *
+ * ## EXAMPLES
+ *
+ * wp mls cache clear --confirm
+ * wp mls cache clear-listing --listing=NST123456
+ * wp mls cache cleanup
+ *
+ * @subcommand cache
+ */
+ public function cache($args, $assoc_args) {
+ $action = isset($args[0]) ? $args[0] : null;
+
+ switch ($action) {
+ case 'clear':
+ if (!isset($assoc_args['confirm'])) {
+ WP_CLI::error('This will delete ALL synced data. Add --confirm to proceed.');
+ }
+
+ WP_CLI::line('Clearing all MLS data...');
+
+ $db = $this->plugin->get_db();
+ $db->truncate_data();
+
+ // Also clear media files
+ $media_handler = $this->plugin->get_media_handler();
+ $upload_dir = $media_handler->get_upload_dir();
+
+ if (is_dir($upload_dir)) {
+ WP_CLI::line('Clearing media files...');
+ // Note: This is a simplified clear - in production you'd want more careful deletion
+ $this->recursive_delete($upload_dir);
+ wp_mkdir_p($upload_dir);
+ }
+
+ WP_CLI::success('Cache cleared successfully.');
+ break;
+
+ case 'clear-listing':
+ $listing_key = isset($assoc_args['listing']) ? $assoc_args['listing'] : null;
+ if (!$listing_key) {
+ WP_CLI::error('Please specify --listing=');
+ }
+
+ $media_handler = $this->plugin->get_media_handler();
+ $media_handler->delete_property_media($listing_key);
+
+ global $wpdb;
+ $db = $this->plugin->get_db();
+ $wpdb->delete($db->properties_table(), array('listing_key' => $listing_key));
+
+ WP_CLI::success("Listing {$listing_key} cleared.");
+ break;
+
+ case 'cleanup':
+ WP_CLI::line('Cleaning up orphaned media files...');
+
+ $media_handler = $this->plugin->get_media_handler();
+ $deleted = $media_handler->cleanup_orphaned_files();
+
+ WP_CLI::success("Cleaned up {$deleted} orphaned directories.");
+ break;
+
+ default:
+ WP_CLI::error("Unknown action: {$action}. Use 'clear', 'clear-listing', or 'cleanup'.");
+ }
+ }
+
+ /**
+ * Recursively delete a directory
+ */
+ private function recursive_delete($dir) {
+ if (!is_dir($dir)) {
+ return;
+ }
+
+ $files = array_diff(scandir($dir), array('.', '..'));
+
+ foreach ($files as $file) {
+ $path = $dir . '/' . $file;
+ if (is_dir($path)) {
+ $this->recursive_delete($path);
+ } else {
+ unlink($path);
+ }
+ }
+
+ rmdir($dir);
+ }
+}
diff --git a/wp-content/plugins/mls-by-hansonxyz/docs/API.md b/wp-content/plugins/mls-by-hansonxyz/docs/API.md
new file mode 100644
index 00000000..54ec7ee2
--- /dev/null
+++ b/wp-content/plugins/mls-by-hansonxyz/docs/API.md
@@ -0,0 +1,298 @@
+# MLS Grid API Reference
+
+Documentation for the MLS Grid API v2 used by this plugin.
+
+## Base URL
+
+```
+https://api.mlsgrid.com/v2
+```
+
+## Authentication
+
+Bearer token authentication via HTTP header:
+
+```
+Authorization: Bearer {access_token}
+```
+
+**Required Header:**
+```
+Accept-Encoding: gzip
+```
+
+The API requires gzip compression and will return error 400 without it.
+
+## Rate Limits
+
+| Limit | Value |
+|-------|-------|
+| Per Second | 2 requests |
+| Per Hour | 7,200 requests |
+| Per Day | 40,000 requests |
+| Data Per Hour | 4GB |
+
+Exceeding limits returns HTTP 429 and temporarily suspends access.
+
+## Endpoints
+
+### Property
+
+```
+GET /Property
+```
+
+Main endpoint for listing data.
+
+**Query Parameters:**
+- `$filter` - OData filter expression (required)
+- `$expand` - Include related resources: Media, Rooms, UnitTypes
+- `$top` - Records per page (max 5000, max 1000 with $expand)
+- `$select` - Specific fields to return
+- `$orderby` - Sort order
+
+**Example:**
+```
+/Property?$filter=OriginatingSystemName eq 'northstar' and MlgCanView eq true&$expand=Media&$top=1000
+```
+
+### Member
+
+```
+GET /Member
+```
+
+Agent/member records.
+
+### Office
+
+```
+GET /Office
+```
+
+Brokerage office records.
+
+### OpenHouse
+
+```
+GET /OpenHouse
+```
+
+Open house event records.
+
+### Lookup
+
+```
+GET /Lookup
+```
+
+Field value definitions. Query no more than once per day.
+
+## OData Filter Syntax
+
+### Operators
+
+| Operator | Description | Example |
+|----------|-------------|---------|
+| eq | Equals | `City eq 'Austin'` |
+| ne | Not equals | `Status ne 'Sold'` |
+| gt | Greater than | `ListPrice gt 200000` |
+| ge | Greater or equal | `BedroomsTotal ge 3` |
+| lt | Less than | `ListPrice lt 500000` |
+| le | Less or equal | `YearBuilt le 2020` |
+| and | Logical AND | `City eq 'Austin' and BedroomsTotal ge 3` |
+| or | Logical OR | Limited to 5 per query |
+| in | In list | `City in ('Austin', 'Dallas')` |
+
+### Required Filters
+
+Every Property request MUST include:
+
+```
+OriginatingSystemName eq 'northstar'
+```
+
+For initial import, add:
+```
+MlgCanView eq true
+```
+
+### Timestamp Filters
+
+For incremental sync:
+```
+ModificationTimestamp gt 2024-01-15T00:00:00.000Z
+```
+
+## Pagination
+
+Responses include `@odata.nextLink` field containing URL for next page.
+
+```json
+{
+ "@odata.context": "...",
+ "value": [...],
+ "@odata.nextLink": "https://api.mlsgrid.com/v2/Property?$filter=...&$skip=1000"
+}
+```
+
+Continue fetching until `@odata.nextLink` is absent.
+
+## Property Fields
+
+### Core Fields
+
+| Field | Type | Description |
+|-------|------|-------------|
+| ListingKey | string | Unique identifier |
+| ListingId | string | MLS listing number |
+| StandardStatus | string | Active, Pending, Closed, etc. |
+| ListPrice | decimal | Listing price |
+| ClosePrice | decimal | Sold price |
+
+### Address Fields
+
+| Field | Type | Description |
+|-------|------|-------------|
+| StreetNumber | string | Street number |
+| StreetName | string | Street name |
+| StreetSuffix | string | St, Ave, Blvd, etc. |
+| UnitNumber | string | Unit/apt number |
+| City | string | City name |
+| StateOrProvince | string | State abbreviation |
+| PostalCode | string | ZIP code |
+| CountyOrParish | string | County name |
+| Latitude | decimal | GPS latitude |
+| Longitude | decimal | GPS longitude |
+
+### Property Details
+
+| Field | Type | Description |
+|-------|------|-------------|
+| PropertyType | string | Residential, Land, Commercial, etc. |
+| PropertySubType | string | Single Family, Condo, etc. |
+| BedroomsTotal | integer | Total bedrooms |
+| BathroomsTotalInteger | integer | Total bathrooms |
+| BathroomsFull | integer | Full bathrooms |
+| BathroomsHalf | integer | Half bathrooms |
+| LivingArea | integer | Square feet |
+| LotSizeArea | decimal | Lot size |
+| LotSizeUnits | string | Acres, SqFt |
+| YearBuilt | integer | Year built |
+| GarageSpaces | integer | Garage spaces |
+
+### Description Fields
+
+| Field | Type | Description |
+|-------|------|-------------|
+| PublicRemarks | string | Property description |
+| Directions | string | Driving directions |
+
+### Agent/Office Fields
+
+| Field | Type | Description |
+|-------|------|-------------|
+| ListAgentKey | string | Listing agent ID |
+| ListAgentMlsId | string | Agent MLS ID |
+| ListOfficeKey | string | Listing office ID |
+| ListOfficeName | string | Office name |
+| ListOfficeMlsId | string | Office MLS ID |
+
+### Timestamps
+
+| Field | Type | Description |
+|-------|------|-------------|
+| ModificationTimestamp | datetime | Last modified (use for sync) |
+| PhotosChangeTimestamp | datetime | Media last changed |
+| ListingContractDate | date | Listed date |
+| CloseDate | date | Sold date |
+| DaysOnMarket | integer | DOM count |
+
+### MLS Grid Fields
+
+| Field | Type | Description |
+|-------|------|-------------|
+| MlgCanView | boolean | OK to display (false = delete) |
+| MlgCanUse | array | Permitted use cases (IDX, VOW, etc.) |
+| OriginatingSystemName | string | Source MLS identifier |
+
+## Media (via $expand)
+
+When using `$expand=Media`, each property includes Media array:
+
+```json
+{
+ "Media": [
+ {
+ "MediaKey": "abc123",
+ "MediaURL": "https://media.mlsgrid.com/...",
+ "Order": 1,
+ "ImageWidth": 1200,
+ "ImageHeight": 800,
+ "MediaModificationTimestamp": "2024-01-15T10:30:00Z"
+ }
+ ]
+}
+```
+
+**Important:** MediaURL is for downloading only. Store images locally.
+
+## Sync Strategy
+
+### Initial Import
+
+1. Query with `MlgCanView eq true` to get viewable records
+2. Follow `@odata.nextLink` for pagination
+3. Store `ModificationTimestamp` from last record
+
+### Incremental Sync
+
+1. Query with `ModificationTimestamp gt {last_timestamp}`
+2. Do NOT filter by MlgCanView (need to see deletions)
+3. If `MlgCanView = false`, delete local record
+
+### Media Sync
+
+1. Check `PhotosChangeTimestamp` on each property
+2. If changed, replace all media for that listing
+3. Match by `MediaKey`, download via `MediaURL`
+4. Delete media where `MediaKey` no longer exists
+
+### Error Recovery
+
+Store `@odata.nextLink` after each page. On failure, resume from that URL.
+
+## Best Practices
+
+1. **Sequential requests only** - Do not parallelize API calls
+2. **Respect rate limits** - 2 req/sec max, pause if approaching limits
+3. **Use $expand wisely** - Reduces per-page limit from 5000 to 1000
+4. **Store raw JSON** - Keep original response for debugging
+5. **Query Lookup sparingly** - Once per day maximum
+6. **Don't hotlink media** - Download and serve from local storage
+
+## Error Responses
+
+```json
+{
+ "error": {
+ "code": 400,
+ "message": "Error description",
+ "target": "misc",
+ "details": []
+ }
+}
+```
+
+| Code | Meaning |
+|------|---------|
+| 400 | Bad request (check filters, missing gzip) |
+| 401 | Unauthorized (invalid token) |
+| 429 | Rate limited (wait and retry) |
+| 500+ | Server error (retry with backoff) |
+
+## Resources
+
+- [MLS Grid Documentation](https://docs.mlsgrid.com/)
+- [API v2 Reference](https://docs.mlsgrid.com/api-documentation/api-version-2.0)
+- [Best Practices Guide](https://www.mlsgrid.com/resources)
diff --git a/wp-content/plugins/mls-by-hansonxyz/docs/CLAUDE.md b/wp-content/plugins/mls-by-hansonxyz/docs/CLAUDE.md
new file mode 100644
index 00000000..2899d307
--- /dev/null
+++ b/wp-content/plugins/mls-by-hansonxyz/docs/CLAUDE.md
@@ -0,0 +1,140 @@
+# MLS by HansonXyz Plugin
+
+WordPress plugin for syncing MLS Grid API data (NorthStar MLS) into local database.
+
+## Development Rules
+
+1. **No emojis** - nowhere in code, commits, docs, or conversation
+2. **PHP 7.4+** compatible code
+3. **WordPress Coding Standards**
+4. Follow patterns from existing HomeProz theme
+
+## Quick Reference
+
+### Database Tables
+
+All tables use `{$wpdb->prefix}mls_` prefix:
+
+| Table | Purpose |
+|-------|---------|
+| `mls_properties` | Listing data |
+| `mls_media` | Media files |
+| `mls_sync_state` | Sync progress tracking |
+| `mls_rate_limits` | API usage tracking |
+| `mls_sync_log` | Debug logging |
+
+### API Configuration
+
+Credentials in wp-config.php:
+```php
+define('MLSGRID_API_URL', 'https://api.mlsgrid.com/v2');
+define('MLSGRID_ACCESS_TOKEN', 'your-token-here');
+```
+
+### MLS Grid API Rate Limits
+
+MUST comply with these limits:
+- 2 requests/second
+- 7,200 requests/hour
+- 40,000 requests/day
+- 4GB data/hour
+
+### Key Files
+
+| File | Purpose |
+|------|---------|
+| `includes/class-mls-api-client.php` | API communication, auth, gzip |
+| `includes/class-mls-sync-engine.php` | Sync orchestration |
+| `includes/class-mls-media-handler.php` | Media download/storage |
+| `includes/class-mls-query.php` | Public query API |
+| `includes/class-mls-rate-limiter.php` | Rate limit compliance |
+| `cli/class-mls-cli.php` | WP-CLI commands |
+
+### WP-CLI Commands
+
+```bash
+# Test connectivity
+wp mls test connection
+wp mls test auth
+
+# Show status
+wp mls status
+wp mls status rate-limits
+
+# Run sync
+wp mls sync full [--dry-run] [--limit=N]
+wp mls sync incremental [--dry-run]
+wp mls sync media [--limit=N]
+wp mls sync resume --id=
+
+# Statistics
+wp mls stats
+
+# Cache management
+wp mls cache clear --confirm
+wp mls cache cleanup
+```
+
+### Public API Functions
+
+Available for themes/plugins:
+
+```php
+// Get properties with filters
+$properties = mls_get_properties([
+ 'status' => 'Active',
+ 'city' => 'Albert Lea',
+ 'min_price' => 100000,
+ 'limit' => 20,
+]);
+
+// Get single property
+$property = mls_get_property('NST123456');
+
+// Get media
+$media = mls_get_property_media('NST123456');
+$image_url = mls_get_property_image('NST123456');
+
+// Get distinct values
+$cities = mls_get_cities('Active');
+
+// Check data availability
+if (mls_is_available()) { ... }
+```
+
+### Sync Strategy
+
+1. **Initial Import**: Full sync downloads all viewable properties
+2. **Incremental**: Uses ModificationTimestamp to fetch only changes
+3. **Delete Handling**: MlgCanView=false triggers local deletion
+4. **Media**: Downloads to wp-content/uploads/mls-listings/
+5. **Recovery**: Stores last_next_link for resume on failure
+
+### Testing After Changes
+
+```bash
+wp mls test connection
+wp mls test auth
+wp mls sync full --dry-run --limit=10
+wp mls stats
+```
+
+### Property Data Mapping
+
+Key fields from API to database:
+
+| API Field | DB Column |
+|-----------|-----------|
+| ListingKey | listing_key |
+| ListingId | listing_id |
+| ListPrice | list_price |
+| StandardStatus | standard_status |
+| BedroomsTotal | bedrooms_total |
+| BathroomsTotalInteger | bathrooms_total |
+| LivingArea | living_area |
+| City | city |
+| ModificationTimestamp | modification_timestamp |
+| PhotosChangeTimestamp | photos_change_timestamp |
+| MlgCanView | mlg_can_view |
+
+Full API response stored in `raw_data` column as JSON.
diff --git a/wp-content/plugins/mls-by-hansonxyz/docs/USAGE.md b/wp-content/plugins/mls-by-hansonxyz/docs/USAGE.md
new file mode 100644
index 00000000..8bdbbcf8
--- /dev/null
+++ b/wp-content/plugins/mls-by-hansonxyz/docs/USAGE.md
@@ -0,0 +1,244 @@
+# MLS by HansonXyz - User Documentation
+
+## Overview
+
+This plugin syncs property listing data from MLS Grid (NorthStar MLS) into your WordPress database, making it available for use by themes and other plugins.
+
+## Installation
+
+1. Upload the `mls-by-hansonxyz` folder to `/wp-content/plugins/`
+2. Activate the plugin through the WordPress admin
+3. Configure API credentials (see below)
+4. Run initial sync
+
+## Configuration
+
+### API Credentials
+
+Add to your `wp-config.php`:
+
+```php
+define('MLSGRID_API_URL', 'https://api.mlsgrid.com/v2');
+define('MLSGRID_ACCESS_TOKEN', 'your-access-token-here');
+```
+
+Alternatively, configure via Settings > MLS Settings in WordPress admin.
+
+### Settings
+
+Navigate to **Settings > MLS Settings** to configure:
+
+- **Originating System**: MLS identifier (default: `northstar`)
+- **Auto Sync**: Enable automatic background sync
+- **Sync Interval**: How often to sync (30min to daily)
+- **Sync Media**: Whether to download listing photos
+
+## Running Sync
+
+### Via Admin Panel
+
+1. Go to Settings > MLS Settings
+2. Click "Run Incremental Sync" or "Run Full Sync"
+3. Wait for completion
+
+### Via WP-CLI
+
+```bash
+# Test connection first
+wp mls test connection
+wp mls test auth
+
+# Run initial full sync
+wp mls sync full
+
+# Run incremental updates
+wp mls sync incremental
+
+# Download pending media
+wp mls sync media
+```
+
+### Via Cron
+
+Add to your system crontab for scheduled sync:
+
+```bash
+# Run incremental sync every hour
+0 * * * * cd /var/www/html && wp mls sync incremental --allow-root
+```
+
+## Checking Status
+
+### Via Admin
+
+Settings > MLS Settings shows:
+- Database statistics (property counts by status)
+- Last sync time and results
+- Rate limit usage
+
+### Via CLI
+
+```bash
+# Full status
+wp mls status
+
+# Just rate limits
+wp mls status rate-limits
+
+# Database statistics
+wp mls stats
+```
+
+## Using the Data
+
+### For Theme Developers
+
+The plugin provides global helper functions:
+
+```php
+// Get active properties in a city
+$properties = mls_get_properties([
+ 'status' => 'Active',
+ 'city' => 'Albert Lea',
+ 'limit' => 20,
+]);
+
+foreach ($properties as $property) {
+ echo $property->list_price;
+ echo $property->bedrooms_total;
+ echo $property->city;
+}
+
+// Get a single property
+$property = mls_get_property('NST123456');
+
+// Get property images
+$media = mls_get_property_media($property->listing_key);
+$primary_image = mls_get_property_image($property->listing_key);
+
+// Get cities with active listings
+$cities = mls_get_cities('Active');
+
+// Check if data is available
+if (mls_is_available()) {
+ // Show property search
+}
+```
+
+### Query Parameters
+
+`mls_get_properties()` accepts these filters:
+
+| Parameter | Type | Description |
+|-----------|------|-------------|
+| status | string | Active, Pending, Closed |
+| property_type | string | Residential, Land, etc. |
+| city | string | City name |
+| county | string | County name |
+| postal_code | string | ZIP code |
+| min_price | int | Minimum price |
+| max_price | int | Maximum price |
+| min_beds | int | Minimum bedrooms |
+| max_beds | int | Maximum bedrooms |
+| min_baths | int | Minimum bathrooms |
+| min_sqft | int | Minimum square feet |
+| max_sqft | int | Maximum square feet |
+| search | string | Search address/remarks |
+| limit | int | Results per page |
+| offset | int | Pagination offset |
+| orderby | string | Sort field |
+| order | string | ASC or DESC |
+| include_media | bool | Include media array |
+
+### Property Object Fields
+
+Each property object includes:
+
+```php
+$property->listing_key // Unique ID
+$property->listing_id // MLS number
+$property->list_price // Price
+$property->standard_status // Active, Pending, Closed
+$property->street_number
+$property->street_name
+$property->street_suffix
+$property->city
+$property->state_or_province
+$property->postal_code
+$property->county
+$property->latitude
+$property->longitude
+$property->property_type
+$property->property_sub_type
+$property->bedrooms_total
+$property->bathrooms_total
+$property->living_area // Square feet
+$property->lot_size_area
+$property->year_built
+$property->garage_spaces
+$property->public_remarks // Description
+$property->directions
+$property->list_office_name
+$property->photos_count
+$property->days_on_market
+$property->modification_timestamp
+```
+
+## Media Storage
+
+Downloaded images are stored in:
+```
+wp-content/uploads/mls-listings/{prefix}/{listing_key}/
+```
+
+Images are named by order: `1.jpg`, `2.jpg`, etc.
+
+Access via:
+```php
+$media = mls_get_property_media($listing_key);
+foreach ($media as $image) {
+ echo '
';
+}
+```
+
+## Troubleshooting
+
+### Connection Failed
+
+1. Verify API token is correct in wp-config.php
+2. Check that MLSGRID_API_URL is set
+3. Run `wp mls test connection` for details
+
+### No Data After Sync
+
+1. Check `wp mls status` for errors
+2. Review rate limits - may need to wait
+3. Check WordPress debug log for API errors
+
+### Media Not Downloading
+
+1. Verify `sync_media` is enabled in settings
+2. Check upload directory is writable
+3. Run `wp mls sync media` manually
+
+### Rate Limit Exceeded
+
+The plugin automatically waits when approaching limits. If suspended:
+1. Wait for the rate limit window to reset
+2. Reduce sync frequency
+3. Contact MLS Grid support if persistent
+
+### Clearing Data
+
+To start fresh:
+```bash
+wp mls cache clear --confirm
+```
+
+This removes all synced data but keeps settings.
+
+## Support
+
+For plugin issues: Check logs at Settings > MLS Settings
+
+For API issues: Contact MLS Grid support at support@mlsgrid.com
diff --git a/wp-content/plugins/mls-by-hansonxyz/includes/class-mls-activator.php b/wp-content/plugins/mls-by-hansonxyz/includes/class-mls-activator.php
new file mode 100644
index 00000000..b2c50b09
--- /dev/null
+++ b/wp-content/plugins/mls-by-hansonxyz/includes/class-mls-activator.php
@@ -0,0 +1,137 @@
+ defined('MLSGRID_API_URL') ? MLSGRID_API_URL : 'https://api.mlsgrid.com/v2',
+ 'originating_system' => 'northstar',
+ 'auto_sync_enabled' => false,
+ 'sync_interval' => 'hourly',
+ 'sync_media' => true,
+ );
+
+ $existing = get_option(MLS_Options::OPTION_KEY, array());
+ $merged = wp_parse_args($existing, $defaults);
+ update_option(MLS_Options::OPTION_KEY, $merged);
+ }
+
+ /**
+ * Schedule cron events
+ */
+ private static function schedule_cron() {
+ // Register custom cron intervals
+ add_filter('cron_schedules', array(__CLASS__, 'add_cron_intervals'));
+
+ // Only schedule if auto-sync is enabled
+ $options = get_option(MLS_Options::OPTION_KEY, array());
+ if (empty($options['auto_sync_enabled'])) {
+ return;
+ }
+
+ $interval = !empty($options['sync_interval']) ? $options['sync_interval'] : 'hourly';
+
+ if (!wp_next_scheduled('mls_sync_properties')) {
+ wp_schedule_event(time(), $interval, 'mls_sync_properties');
+ }
+
+ if (!wp_next_scheduled('mls_sync_media')) {
+ wp_schedule_event(time() + 1800, $interval, 'mls_sync_media');
+ }
+ }
+
+ /**
+ * Add custom cron intervals
+ *
+ * @param array $schedules Existing schedules
+ * @return array Modified schedules
+ */
+ public static function add_cron_intervals($schedules) {
+ $schedules['every_30_minutes'] = array(
+ 'interval' => 1800,
+ 'display' => 'Every 30 Minutes',
+ );
+
+ $schedules['every_2_hours'] = array(
+ 'interval' => 7200,
+ 'display' => 'Every 2 Hours',
+ );
+
+ $schedules['every_6_hours'] = array(
+ 'interval' => 21600,
+ 'display' => 'Every 6 Hours',
+ );
+
+ $schedules['every_12_hours'] = array(
+ 'interval' => 43200,
+ 'display' => 'Every 12 Hours',
+ );
+
+ return $schedules;
+ }
+
+ /**
+ * Create upload directory for MLS media
+ */
+ private static function create_upload_dir() {
+ $upload_dir = wp_upload_dir();
+ $mls_dir = $upload_dir['basedir'] . '/mls-listings';
+
+ if (!file_exists($mls_dir)) {
+ wp_mkdir_p($mls_dir);
+
+ // Create .htaccess to prevent directory listing
+ $htaccess = $mls_dir . '/.htaccess';
+ if (!file_exists($htaccess)) {
+ file_put_contents($htaccess, "Options -Indexes\n");
+ }
+
+ // Create index.php for extra protection
+ $index = $mls_dir . '/index.php';
+ if (!file_exists($index)) {
+ file_put_contents($index, "options = $options;
+ $this->rate_limiter = $rate_limiter;
+ $this->logger = $logger;
+ }
+
+ /**
+ * Test API connection
+ *
+ * @return array Result with success status and message
+ */
+ public function test_connection() {
+ $start_time = microtime(true);
+
+ $response = $this->request('');
+
+ $elapsed = round((microtime(true) - $start_time) * 1000);
+
+ if (is_wp_error($response)) {
+ return array(
+ 'success' => false,
+ 'error' => $response->get_error_message(),
+ 'response_time' => $elapsed,
+ );
+ }
+
+ return array(
+ 'success' => true,
+ 'message' => 'Connection successful',
+ 'response_time' => $elapsed,
+ 'endpoints' => isset($response['value']) ? array_column($response['value'], 'name') : array(),
+ );
+ }
+
+ /**
+ * Test API authentication
+ *
+ * @return array Result with success status
+ */
+ public function test_auth() {
+ // Try to fetch a single property to verify auth
+ $response = $this->get_properties(null, null, 1);
+
+ if (is_wp_error($response)) {
+ $error_code = $response->get_error_code();
+ $error_message = $response->get_error_message();
+
+ if (strpos($error_message, '401') !== false || strpos($error_message, 'Unauthorized') !== false) {
+ return array(
+ 'success' => false,
+ 'error' => 'Authentication failed. Please check your API token.',
+ );
+ }
+
+ return array(
+ 'success' => false,
+ 'error' => $error_message,
+ );
+ }
+
+ return array(
+ 'success' => true,
+ 'message' => 'Authentication successful',
+ 'originating_system' => $this->options->get_originating_system(),
+ );
+ }
+
+ /**
+ * Make an API request
+ *
+ * @param string $endpoint API endpoint (relative to base URL)
+ * @param array $params Query parameters
+ * @param int $retry Current retry attempt
+ * @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);
+
+ $url = $this->build_url($endpoint, $params);
+
+ $this->logger->debug('API Request', array(
+ 'url' => $url,
+ 'retry' => $retry,
+ ));
+
+ $args = array(
+ 'method' => 'GET',
+ 'timeout' => self::TIMEOUT,
+ 'headers' => array(
+ 'Authorization' => 'Bearer ' . $this->options->get_api_token(),
+ 'Accept' => 'application/json',
+ 'Accept-Encoding' => 'gzip',
+ 'User-Agent' => 'MLS-by-HansonXyz/' . MLS_PLUGIN_VERSION . ' WordPress/' . get_bloginfo('version'),
+ ),
+ );
+
+ $response = wp_remote_get($url, $args);
+
+ // Track the request
+ $bytes = 0;
+ if (!is_wp_error($response)) {
+ $body = wp_remote_retrieve_body($response);
+ $bytes = strlen($body);
+ }
+ $this->rate_limiter->record_request($bytes);
+
+ // Handle errors
+ if (is_wp_error($response)) {
+ $this->logger->error('API Request Failed', array(
+ 'error' => $response->get_error_message(),
+ 'url' => $url,
+ ));
+
+ // Retry on transient errors
+ if ($retry < self::MAX_RETRIES) {
+ sleep(pow(2, $retry)); // Exponential backoff
+ return $this->request($endpoint, $params, $retry + 1);
+ }
+
+ return $response;
+ }
+
+ $status_code = wp_remote_retrieve_response_code($response);
+ $body = wp_remote_retrieve_body($response);
+
+ // Handle HTTP errors
+ if ($status_code >= 400) {
+ $error_message = $this->parse_error_response($body, $status_code);
+
+ $this->logger->error('API HTTP Error', array(
+ 'status' => $status_code,
+ 'error' => $error_message,
+ 'url' => $url,
+ ));
+
+ // Retry on 429 (rate limit) or 5xx errors
+ 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 new WP_Error('api_error', $error_message, array('status' => $status_code));
+ }
+
+ // Parse JSON response
+ $data = json_decode($body, true);
+
+ if (json_last_error() !== JSON_ERROR_NONE) {
+ $this->logger->error('API JSON Parse Error', array(
+ 'error' => json_last_error_msg(),
+ 'body_preview' => substr($body, 0, 500),
+ ));
+ return new WP_Error('json_error', 'Failed to parse API response: ' . json_last_error_msg());
+ }
+
+ $this->logger->debug('API Response', array(
+ 'status' => $status_code,
+ 'record_count' => isset($data['value']) ? count($data['value']) : 0,
+ 'has_next' => isset($data['@odata.nextLink']),
+ ));
+
+ return $data;
+ }
+
+ /**
+ * Build full URL with parameters
+ *
+ * @param string $endpoint Endpoint
+ * @param array $params Parameters
+ * @return string Full URL
+ */
+ private function build_url($endpoint, $params = array()) {
+ $base_url = rtrim($this->options->get_api_url(), '/');
+
+ if (!empty($endpoint)) {
+ $url = $base_url . '/' . ltrim($endpoint, '/');
+ } else {
+ $url = $base_url . '/';
+ }
+
+ if (!empty($params)) {
+ $query_string = http_build_query($params, '', '&', PHP_QUERY_RFC3986);
+ // OData uses $ prefix which gets encoded, decode it
+ $query_string = str_replace('%24', '$', $query_string);
+ $url .= '?' . $query_string;
+ }
+
+ return $url;
+ }
+
+ /**
+ * Parse error response
+ *
+ * @param string $body Response body
+ * @param int $status_code HTTP status code
+ * @return string Error message
+ */
+ private function parse_error_response($body, $status_code) {
+ $data = json_decode($body, true);
+
+ if (isset($data['error']['message'])) {
+ return $data['error']['message'];
+ }
+
+ if (isset($data['message'])) {
+ return $data['message'];
+ }
+
+ return "HTTP Error {$status_code}";
+ }
+
+ /**
+ * Get properties from API
+ *
+ * @param string|null $filter OData filter
+ * @param string|null $expand Expand parameter (Media, Rooms, UnitTypes)
+ * @param int|null $top Number of records to fetch
+ * @return array|WP_Error Response data or error
+ */
+ public function get_properties($filter = null, $expand = null, $top = null) {
+ $params = array();
+
+ // Build filter - always include originating system
+ $system = $this->options->get_originating_system();
+ $base_filter = "OriginatingSystemName eq '{$system}'";
+
+ if ($filter) {
+ $params['$filter'] = $base_filter . ' and ' . $filter;
+ } else {
+ $params['$filter'] = $base_filter . ' and MlgCanView eq true';
+ }
+
+ // Expand for media
+ if ($expand) {
+ $params['$expand'] = $expand;
+ }
+
+ // Records per page
+ if ($top) {
+ $params['$top'] = min($top, $expand ? self::MAX_TOP_WITH_EXPAND : self::MAX_TOP_NO_EXPAND);
+ } else {
+ $params['$top'] = $expand ? self::MAX_TOP_WITH_EXPAND : self::DEFAULT_TOP;
+ }
+
+ return $this->request('Property', $params);
+ }
+
+ /**
+ * Get properties modified since timestamp
+ *
+ * @param string $timestamp ISO 8601 timestamp
+ * @param string|null $expand Expand parameter
+ * @param int|null $top Number of records
+ * @return array|WP_Error Response data or error
+ */
+ public function get_properties_since($timestamp, $expand = null, $top = null) {
+ $filter = "ModificationTimestamp gt {$timestamp}";
+ return $this->get_properties($filter, $expand, $top);
+ }
+
+ /**
+ * Get properties including those marked for deletion (for sync)
+ *
+ * @param string|null $timestamp Optional modification timestamp filter
+ * @param string|null $expand Expand parameter
+ * @param int|null $top Number of records
+ * @return array|WP_Error Response data or error
+ */
+ public function get_properties_for_sync($timestamp = null, $expand = null, $top = null) {
+ // Don't filter by MlgCanView for sync - we need to see deleted records
+ $params = array();
+
+ $system = $this->options->get_originating_system();
+
+ if ($timestamp) {
+ $params['$filter'] = "OriginatingSystemName eq '{$system}' and ModificationTimestamp gt {$timestamp}";
+ } else {
+ // Initial sync - only get viewable records
+ $params['$filter'] = "OriginatingSystemName eq '{$system}' and MlgCanView eq true";
+ }
+
+ if ($expand) {
+ $params['$expand'] = $expand;
+ }
+
+ if ($top) {
+ $params['$top'] = min($top, $expand ? self::MAX_TOP_WITH_EXPAND : self::MAX_TOP_NO_EXPAND);
+ } else {
+ $params['$top'] = $expand ? self::MAX_TOP_WITH_EXPAND : self::DEFAULT_TOP;
+ }
+
+ return $this->request('Property', $params);
+ }
+
+ /**
+ * Get next page of results
+ *
+ * @param string $next_link The @odata.nextLink URL
+ * @return array|WP_Error Response data or error
+ */
+ public function get_next_page($next_link) {
+ // Check and wait for rate limits
+ $this->rate_limiter->check_and_wait(true);
+
+ $this->logger->debug('API Next Page Request', array(
+ 'url' => $next_link,
+ ));
+
+ $args = array(
+ 'method' => 'GET',
+ 'timeout' => self::TIMEOUT,
+ 'headers' => array(
+ 'Authorization' => 'Bearer ' . $this->options->get_api_token(),
+ 'Accept' => 'application/json',
+ 'Accept-Encoding' => 'gzip',
+ 'User-Agent' => 'MLS-by-HansonXyz/' . MLS_PLUGIN_VERSION . ' WordPress/' . get_bloginfo('version'),
+ ),
+ );
+
+ $response = wp_remote_get($next_link, $args);
+
+ // Track the request
+ $bytes = 0;
+ if (!is_wp_error($response)) {
+ $body = wp_remote_retrieve_body($response);
+ $bytes = strlen($body);
+ }
+ $this->rate_limiter->record_request($bytes);
+
+ if (is_wp_error($response)) {
+ $this->logger->error('API Next Page Failed', array(
+ 'error' => $response->get_error_message(),
+ ));
+ return $response;
+ }
+
+ $status_code = wp_remote_retrieve_response_code($response);
+ $body = wp_remote_retrieve_body($response);
+
+ if ($status_code >= 400) {
+ $error_message = $this->parse_error_response($body, $status_code);
+ return new WP_Error('api_error', $error_message, array('status' => $status_code));
+ }
+
+ $data = json_decode($body, true);
+
+ if (json_last_error() !== JSON_ERROR_NONE) {
+ return new WP_Error('json_error', 'Failed to parse API response');
+ }
+
+ return $data;
+ }
+
+ /**
+ * Get members (agents) from API
+ *
+ * @param string|null $filter OData filter
+ * @return array|WP_Error Response data or error
+ */
+ public function get_members($filter = null) {
+ $params = array();
+
+ $system = $this->options->get_originating_system();
+ $base_filter = "OriginatingSystemName eq '{$system}'";
+
+ if ($filter) {
+ $params['$filter'] = $base_filter . ' and ' . $filter;
+ } else {
+ $params['$filter'] = $base_filter;
+ }
+
+ return $this->request('Member', $params);
+ }
+
+ /**
+ * Get offices from API
+ *
+ * @param string|null $filter OData filter
+ * @return array|WP_Error Response data or error
+ */
+ public function get_offices($filter = null) {
+ $params = array();
+
+ $system = $this->options->get_originating_system();
+ $base_filter = "OriginatingSystemName eq '{$system}'";
+
+ if ($filter) {
+ $params['$filter'] = $base_filter . ' and ' . $filter;
+ } else {
+ $params['$filter'] = $base_filter;
+ }
+
+ return $this->request('Office', $params);
+ }
+
+ /**
+ * Get lookup values (field definitions)
+ * Note: Should not be called more than once per day
+ *
+ * @return array|WP_Error Response data or error
+ */
+ public function get_lookups() {
+ $system = $this->options->get_originating_system();
+ $params = array(
+ '$filter' => "OriginatingSystemName eq '{$system}'",
+ );
+
+ return $this->request('Lookup', $params);
+ }
+}
diff --git a/wp-content/plugins/mls-by-hansonxyz/includes/class-mls-db.php b/wp-content/plugins/mls-by-hansonxyz/includes/class-mls-db.php
new file mode 100644
index 00000000..c8255364
--- /dev/null
+++ b/wp-content/plugins/mls-by-hansonxyz/includes/class-mls-db.php
@@ -0,0 +1,326 @@
+prefix . $table;
+ }
+
+ /**
+ * Get properties table name
+ */
+ public function properties_table() {
+ return $this->get_table_name(MLS_TABLE_PROPERTIES);
+ }
+
+ /**
+ * Get media table name
+ */
+ public function media_table() {
+ return $this->get_table_name(MLS_TABLE_MEDIA);
+ }
+
+ /**
+ * Get sync state table name
+ */
+ public function sync_state_table() {
+ return $this->get_table_name(MLS_TABLE_SYNC_STATE);
+ }
+
+ /**
+ * Get rate limits table name
+ */
+ public function rate_limits_table() {
+ return $this->get_table_name(MLS_TABLE_RATE_LIMITS);
+ }
+
+ /**
+ * Get sync log table name
+ */
+ public function sync_log_table() {
+ return $this->get_table_name(MLS_TABLE_SYNC_LOG);
+ }
+
+ /**
+ * Create all database tables
+ */
+ public static function create_tables() {
+ global $wpdb;
+
+ $charset_collate = $wpdb->get_charset_collate();
+
+ require_once ABSPATH . 'wp-admin/includes/upgrade.php';
+
+ // Properties table
+ $table_properties = $wpdb->prefix . MLS_TABLE_PROPERTIES;
+ $sql_properties = "CREATE TABLE {$table_properties} (
+ id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
+ listing_key VARCHAR(50) NOT NULL,
+ listing_id VARCHAR(50) NOT NULL,
+ originating_system VARCHAR(50) DEFAULT 'northstar',
+
+ standard_status VARCHAR(30) NOT NULL,
+ mls_status VARCHAR(50) DEFAULT NULL,
+ mlg_can_view TINYINT(1) DEFAULT 1,
+
+ 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,
+ city VARCHAR(100) NOT 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 NULL,
+ year_built INT(4) DEFAULT NULL,
+ garage_spaces INT(3) DEFAULT NULL,
+
+ public_remarks TEXT DEFAULT NULL,
+ directions TEXT DEFAULT NULL,
+
+ list_agent_key VARCHAR(50) DEFAULT NULL,
+ list_agent_mls_id VARCHAR(50) DEFAULT NULL,
+ list_agent_name VARCHAR(150) DEFAULT NULL,
+ list_office_key VARCHAR(50) DEFAULT NULL,
+ list_office_mls_id VARCHAR(50) DEFAULT NULL,
+ list_office_name VARCHAR(150) DEFAULT NULL,
+
+ photos_count INT(5) DEFAULT 0,
+ modification_timestamp DATETIME NOT NULL,
+ photos_change_timestamp DATETIME DEFAULT NULL,
+ listing_contract_date DATE DEFAULT NULL,
+ close_date DATE DEFAULT NULL,
+ days_on_market INT(5) DEFAULT NULL,
+
+ raw_data LONGTEXT DEFAULT NULL,
+
+ 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),
+ KEY listing_id (listing_id),
+ KEY standard_status (standard_status),
+ KEY city (city),
+ KEY property_type (property_type),
+ KEY modification_timestamp (modification_timestamp),
+ KEY list_price (list_price),
+ KEY mlg_can_view (mlg_can_view),
+ KEY bedrooms_total (bedrooms_total),
+ KEY county (county)
+ ) {$charset_collate};";
+
+ dbDelta($sql_properties);
+
+ // Media table
+ $table_media = $wpdb->prefix . MLS_TABLE_MEDIA;
+ $sql_media = "CREATE TABLE {$table_media} (
+ id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
+ listing_key VARCHAR(50) NOT NULL,
+ media_key VARCHAR(100) NOT NULL,
+
+ media_type VARCHAR(30) DEFAULT 'Photo',
+ media_order INT(5) DEFAULT 0,
+ media_url VARCHAR(1000) DEFAULT NULL,
+
+ local_path VARCHAR(500) DEFAULT NULL,
+ local_url VARCHAR(500) DEFAULT NULL,
+ file_size INT(11) DEFAULT NULL,
+ mime_type VARCHAR(50) DEFAULT NULL,
+ image_width INT(5) DEFAULT NULL,
+ image_height INT(5) DEFAULT NULL,
+
+ media_modification_timestamp DATETIME DEFAULT NULL,
+ downloaded_at DATETIME DEFAULT NULL,
+ download_attempts INT(3) DEFAULT 0,
+ download_error TEXT DEFAULT NULL,
+
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+
+ PRIMARY KEY (id),
+ UNIQUE KEY listing_media (listing_key, media_key),
+ KEY listing_key (listing_key),
+ KEY media_order (media_order)
+ ) {$charset_collate};";
+
+ dbDelta($sql_media);
+
+ // Sync state table
+ $table_sync_state = $wpdb->prefix . MLS_TABLE_SYNC_STATE;
+ $sql_sync_state = "CREATE TABLE {$table_sync_state} (
+ id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
+ sync_type VARCHAR(30) NOT NULL,
+ entity_type VARCHAR(30) NOT NULL DEFAULT 'Property',
+
+ status VARCHAR(20) DEFAULT 'pending',
+ started_at DATETIME DEFAULT NULL,
+ completed_at DATETIME DEFAULT NULL,
+
+ last_modification_timestamp DATETIME DEFAULT NULL,
+ last_next_link VARCHAR(2000) DEFAULT NULL,
+ records_processed INT(11) DEFAULT 0,
+ records_created INT(11) DEFAULT 0,
+ records_updated INT(11) DEFAULT 0,
+ records_deleted INT(11) DEFAULT 0,
+
+ error_count INT(11) DEFAULT 0,
+ last_error TEXT DEFAULT NULL,
+
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+
+ PRIMARY KEY (id),
+ KEY sync_type_entity (sync_type, entity_type),
+ KEY status (status)
+ ) {$charset_collate};";
+
+ dbDelta($sql_sync_state);
+
+ // Rate limits table
+ $table_rate_limits = $wpdb->prefix . MLS_TABLE_RATE_LIMITS;
+ $sql_rate_limits = "CREATE TABLE {$table_rate_limits} (
+ id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
+ window_type VARCHAR(20) NOT NULL,
+ window_start DATETIME NOT NULL,
+ request_count INT(11) DEFAULT 0,
+ bytes_transferred BIGINT(20) DEFAULT 0,
+
+ PRIMARY KEY (id),
+ UNIQUE KEY window_type_start (window_type, window_start),
+ KEY window_start (window_start)
+ ) {$charset_collate};";
+
+ dbDelta($sql_rate_limits);
+
+ // Sync log table
+ $table_sync_log = $wpdb->prefix . MLS_TABLE_SYNC_LOG;
+ $sql_sync_log = "CREATE TABLE {$table_sync_log} (
+ id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
+ sync_state_id BIGINT(20) UNSIGNED DEFAULT NULL,
+ level VARCHAR(20) DEFAULT 'info',
+ message TEXT NOT NULL,
+ context LONGTEXT DEFAULT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+
+ PRIMARY KEY (id),
+ KEY sync_state_id (sync_state_id),
+ KEY level (level),
+ KEY created_at (created_at)
+ ) {$charset_collate};";
+
+ dbDelta($sql_sync_log);
+ }
+
+ /**
+ * Drop all tables (for uninstall)
+ */
+ public static function drop_tables() {
+ global $wpdb;
+
+ $tables = array(
+ MLS_TABLE_PROPERTIES,
+ MLS_TABLE_MEDIA,
+ MLS_TABLE_SYNC_STATE,
+ MLS_TABLE_RATE_LIMITS,
+ MLS_TABLE_SYNC_LOG,
+ );
+
+ foreach ($tables as $table) {
+ $table_name = $wpdb->prefix . $table;
+ $wpdb->query("DROP TABLE IF EXISTS {$table_name}");
+ }
+ }
+
+ /**
+ * Truncate all data tables (keep structure)
+ */
+ public function truncate_data() {
+ global $wpdb;
+
+ $wpdb->query("TRUNCATE TABLE {$this->properties_table()}");
+ $wpdb->query("TRUNCATE TABLE {$this->media_table()}");
+ $wpdb->query("TRUNCATE TABLE {$this->sync_state_table()}");
+ $wpdb->query("TRUNCATE TABLE {$this->sync_log_table()}");
+ }
+
+ /**
+ * Get database statistics
+ */
+ public function get_stats() {
+ global $wpdb;
+
+ $stats = array(
+ 'total_properties' => 0,
+ 'active_properties' => 0,
+ 'pending_properties' => 0,
+ 'sold_properties' => 0,
+ 'total_media' => 0,
+ 'downloaded_media' => 0,
+ );
+
+ // Property counts
+ $stats['total_properties'] = (int) $wpdb->get_var(
+ "SELECT COUNT(*) FROM {$this->properties_table()} WHERE mlg_can_view = 1"
+ );
+
+ $status_counts = $wpdb->get_results(
+ "SELECT standard_status, COUNT(*) as count
+ FROM {$this->properties_table()}
+ WHERE mlg_can_view = 1
+ GROUP BY standard_status",
+ OBJECT_K
+ );
+
+ if (isset($status_counts['Active'])) {
+ $stats['active_properties'] = (int) $status_counts['Active']->count;
+ }
+ if (isset($status_counts['Pending'])) {
+ $stats['pending_properties'] = (int) $status_counts['Pending']->count;
+ }
+ if (isset($status_counts['Closed']) || isset($status_counts['Sold'])) {
+ $stats['sold_properties'] = (int) ($status_counts['Closed']->count ?? 0)
+ + (int) ($status_counts['Sold']->count ?? 0);
+ }
+
+ // Media counts
+ $stats['total_media'] = (int) $wpdb->get_var(
+ "SELECT COUNT(*) FROM {$this->media_table()}"
+ );
+
+ $stats['downloaded_media'] = (int) $wpdb->get_var(
+ "SELECT COUNT(*) FROM {$this->media_table()} WHERE local_path IS NOT NULL"
+ );
+
+ return $stats;
+ }
+}
diff --git a/wp-content/plugins/mls-by-hansonxyz/includes/class-mls-deactivator.php b/wp-content/plugins/mls-by-hansonxyz/includes/class-mls-deactivator.php
new file mode 100644
index 00000000..87395928
--- /dev/null
+++ b/wp-content/plugins/mls-by-hansonxyz/includes/class-mls-deactivator.php
@@ -0,0 +1,50 @@
+db = $db;
+ $this->write_to_debug_log = defined('WP_DEBUG') && WP_DEBUG;
+ }
+
+ /**
+ * Set current sync state ID for associating logs
+ *
+ * @param int|null $sync_state_id
+ */
+ public function set_sync_state($sync_state_id) {
+ $this->sync_state_id = $sync_state_id;
+ }
+
+ /**
+ * Log a message
+ *
+ * @param string $level Log level
+ * @param string $message Message
+ * @param array $context Additional context
+ */
+ public function log($level, $message, $context = array()) {
+ global $wpdb;
+
+ // Insert into database
+ $wpdb->insert(
+ $this->db->sync_log_table(),
+ array(
+ 'sync_state_id' => $this->sync_state_id,
+ 'level' => $level,
+ 'message' => $message,
+ 'context' => !empty($context) ? wp_json_encode($context) : null,
+ 'created_at' => current_time('mysql'),
+ ),
+ array('%d', '%s', '%s', '%s', '%s')
+ );
+
+ // Also write to WP debug log if enabled
+ if ($this->write_to_debug_log) {
+ $log_message = sprintf(
+ '[MLS] [%s] %s',
+ strtoupper($level),
+ $message
+ );
+ if (!empty($context)) {
+ $log_message .= ' | Context: ' . wp_json_encode($context);
+ }
+ error_log($log_message);
+ }
+ }
+
+ /**
+ * Debug log
+ *
+ * @param string $message
+ * @param array $context
+ */
+ public function debug($message, $context = array()) {
+ $this->log(self::DEBUG, $message, $context);
+ }
+
+ /**
+ * Info log
+ *
+ * @param string $message
+ * @param array $context
+ */
+ public function info($message, $context = array()) {
+ $this->log(self::INFO, $message, $context);
+ }
+
+ /**
+ * Warning log
+ *
+ * @param string $message
+ * @param array $context
+ */
+ public function warning($message, $context = array()) {
+ $this->log(self::WARNING, $message, $context);
+ }
+
+ /**
+ * Error log
+ *
+ * @param string $message
+ * @param array $context
+ */
+ public function error($message, $context = array()) {
+ $this->log(self::ERROR, $message, $context);
+ }
+
+ /**
+ * Get recent logs
+ *
+ * @param int $limit Number of logs to retrieve
+ * @param string|null $level Filter by level
+ * @param int|null $sync_state_id Filter by sync state
+ * @return array
+ */
+ public function get_logs($limit = 100, $level = null, $sync_state_id = null) {
+ global $wpdb;
+
+ $where = array('1=1');
+ $values = array();
+
+ if ($level) {
+ $where[] = 'level = %s';
+ $values[] = $level;
+ }
+
+ if ($sync_state_id) {
+ $where[] = 'sync_state_id = %d';
+ $values[] = $sync_state_id;
+ }
+
+ $where_sql = implode(' AND ', $where);
+ $values[] = $limit;
+
+ $sql = "SELECT * FROM {$this->db->sync_log_table()}
+ WHERE {$where_sql}
+ ORDER BY created_at DESC
+ LIMIT %d";
+
+ if (!empty($values)) {
+ $sql = $wpdb->prepare($sql, $values);
+ }
+
+ return $wpdb->get_results($sql);
+ }
+
+ /**
+ * Clear old logs
+ *
+ * @param int $days_to_keep Keep logs from last N days
+ * @return int Number of deleted rows
+ */
+ public function clear_old_logs($days_to_keep = 30) {
+ global $wpdb;
+
+ $cutoff = gmdate('Y-m-d H:i:s', strtotime("-{$days_to_keep} days"));
+
+ return $wpdb->query(
+ $wpdb->prepare(
+ "DELETE FROM {$this->db->sync_log_table()} WHERE created_at < %s",
+ $cutoff
+ )
+ );
+ }
+
+ /**
+ * Clear all logs
+ *
+ * @return bool
+ */
+ public function clear_all() {
+ global $wpdb;
+ return false !== $wpdb->query("TRUNCATE TABLE {$this->db->sync_log_table()}");
+ }
+}
diff --git a/wp-content/plugins/mls-by-hansonxyz/includes/class-mls-media-handler.php b/wp-content/plugins/mls-by-hansonxyz/includes/class-mls-media-handler.php
new file mode 100644
index 00000000..6daecdd9
--- /dev/null
+++ b/wp-content/plugins/mls-by-hansonxyz/includes/class-mls-media-handler.php
@@ -0,0 +1,499 @@
+db = $db;
+ $this->logger = $logger;
+ }
+
+ /**
+ * Get base upload directory for MLS media
+ *
+ * @return string Absolute path
+ */
+ public function get_upload_dir() {
+ $upload_dir = wp_upload_dir();
+ return $upload_dir['basedir'] . '/' . self::UPLOAD_SUBDIR;
+ }
+
+ /**
+ * Get base upload URL for MLS media
+ *
+ * @return string URL
+ */
+ public function get_upload_url() {
+ $upload_dir = wp_upload_dir();
+ return $upload_dir['baseurl'] . '/' . self::UPLOAD_SUBDIR;
+ }
+
+ /**
+ * Get storage directory for a specific listing
+ *
+ * @param string $listing_key Listing key
+ * @return string Absolute path
+ */
+ public function get_listing_dir($listing_key) {
+ // Use first 2 characters as subdirectory to prevent too many files in one folder
+ $prefix = substr($listing_key, 0, 2);
+ return $this->get_upload_dir() . '/' . $prefix . '/' . $listing_key;
+ }
+
+ /**
+ * Sync media for a property
+ *
+ * @param string $listing_key Listing key
+ * @param array $media_array Media array from API
+ * @param bool $force Force re-download all media
+ */
+ public function sync_property_media($listing_key, $media_array, $force = false) {
+ global $wpdb;
+
+ if (empty($media_array)) {
+ return;
+ }
+
+ $received_keys = array();
+
+ foreach ($media_array as $media) {
+ $media_key = $media['MediaKey'] ?? null;
+ if (!$media_key) {
+ continue;
+ }
+
+ $received_keys[] = $media_key;
+
+ // Check if media record exists
+ $existing = $wpdb->get_row($wpdb->prepare(
+ "SELECT * FROM {$this->db->media_table()}
+ WHERE listing_key = %s AND media_key = %s",
+ $listing_key,
+ $media_key
+ ));
+
+ $data = array(
+ 'listing_key' => $listing_key,
+ 'media_key' => $media_key,
+ 'media_type' => $media['MediaType'] ?? 'Photo',
+ 'media_order' => $media['Order'] ?? 0,
+ 'media_url' => $media['MediaURL'] ?? null,
+ 'image_width' => $media['ImageWidth'] ?? null,
+ 'image_height' => $media['ImageHeight'] ?? null,
+ 'media_modification_timestamp' => isset($media['MediaModificationTimestamp'])
+ ? date('Y-m-d H:i:s', strtotime($media['MediaModificationTimestamp']))
+ : null,
+ 'updated_at' => current_time('mysql'),
+ );
+
+ if ($existing) {
+ // Update existing record
+ $wpdb->update(
+ $this->db->media_table(),
+ $data,
+ array('id' => $existing->id)
+ );
+
+ // Check if we need to re-download
+ if ($force || $this->needs_download($existing, $media)) {
+ $this->download_media($existing->id);
+ }
+ } else {
+ // Insert new record
+ $data['created_at'] = current_time('mysql');
+ $wpdb->insert($this->db->media_table(), $data);
+
+ // Queue download
+ $this->download_media($wpdb->insert_id);
+ }
+ }
+
+ // Delete media that no longer exists
+ if (!empty($received_keys)) {
+ $placeholders = implode(',', array_fill(0, count($received_keys), '%s'));
+ $values = array_merge(array($listing_key), $received_keys);
+
+ $orphaned = $wpdb->get_results($wpdb->prepare(
+ "SELECT id, local_path FROM {$this->db->media_table()}
+ WHERE listing_key = %s AND media_key NOT IN ({$placeholders})",
+ $values
+ ));
+
+ foreach ($orphaned as $record) {
+ // Delete file if exists
+ if ($record->local_path) {
+ $file_path = $this->get_upload_dir() . '/' . $record->local_path;
+ if (file_exists($file_path)) {
+ unlink($file_path);
+ }
+ }
+
+ // Delete record
+ $wpdb->delete($this->db->media_table(), array('id' => $record->id));
+ }
+ }
+ }
+
+ /**
+ * Check if media needs to be downloaded
+ *
+ * @param object $existing Existing media record
+ * @param array $new_data New media data from API
+ * @return bool
+ */
+ private function needs_download($existing, $new_data) {
+ // No local file
+ if (empty($existing->local_path)) {
+ return true;
+ }
+
+ // File doesn't exist
+ $file_path = $this->get_upload_dir() . '/' . $existing->local_path;
+ if (!file_exists($file_path)) {
+ return true;
+ }
+
+ // Media URL changed
+ if ($existing->media_url !== ($new_data['MediaURL'] ?? null)) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Download a media file
+ *
+ * @param int $media_id Media record ID
+ * @return bool Success
+ */
+ public function download_media($media_id) {
+ global $wpdb;
+
+ $media = $wpdb->get_row($wpdb->prepare(
+ "SELECT * FROM {$this->db->media_table()} WHERE id = %d",
+ $media_id
+ ));
+
+ if (!$media || empty($media->media_url)) {
+ return false;
+ }
+
+ // Increment attempt counter
+ $wpdb->update(
+ $this->db->media_table(),
+ array('download_attempts' => $media->download_attempts + 1),
+ array('id' => $media_id)
+ );
+
+ // Download file
+ $response = wp_remote_get($media->media_url, array(
+ 'timeout' => 60,
+ 'stream' => false,
+ ));
+
+ if (is_wp_error($response)) {
+ $this->logger->warning('Media download failed', array(
+ 'media_id' => $media_id,
+ 'error' => $response->get_error_message(),
+ ));
+
+ $wpdb->update(
+ $this->db->media_table(),
+ array('download_error' => $response->get_error_message()),
+ array('id' => $media_id)
+ );
+
+ return false;
+ }
+
+ $status_code = wp_remote_retrieve_response_code($response);
+ if ($status_code !== 200) {
+ $wpdb->update(
+ $this->db->media_table(),
+ array('download_error' => "HTTP {$status_code}"),
+ array('id' => $media_id)
+ );
+ return false;
+ }
+
+ $body = wp_remote_retrieve_body($response);
+ if (empty($body)) {
+ $wpdb->update(
+ $this->db->media_table(),
+ array('download_error' => 'Empty response'),
+ array('id' => $media_id)
+ );
+ return false;
+ }
+
+ // Determine file extension from content type or URL
+ $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);
+ if (!file_exists($listing_dir)) {
+ wp_mkdir_p($listing_dir);
+ }
+
+ // Save file
+ $filename = $media->media_order . '.' . $extension;
+ $file_path = $listing_dir . '/' . $filename;
+
+ if (file_put_contents($file_path, $body) === false) {
+ $wpdb->update(
+ $this->db->media_table(),
+ array('download_error' => 'Failed to write file'),
+ array('id' => $media_id)
+ );
+ return false;
+ }
+
+ // Calculate relative path
+ $prefix = substr($media->listing_key, 0, 2);
+ $relative_path = $prefix . '/' . $media->listing_key . '/' . $filename;
+ $local_url = $this->get_upload_url() . '/' . $relative_path;
+
+ // Update record
+ $wpdb->update(
+ $this->db->media_table(),
+ array(
+ 'local_path' => $relative_path,
+ 'local_url' => $local_url,
+ 'file_size' => strlen($body),
+ 'mime_type' => $content_type,
+ 'downloaded_at' => current_time('mysql'),
+ 'download_error' => null,
+ ),
+ array('id' => $media_id)
+ );
+
+ return true;
+ }
+
+ /**
+ * Get file extension from content type
+ *
+ * @param string $content_type Content type header
+ * @param string $url Original URL as fallback
+ * @return string File extension
+ */
+ private function get_extension_from_content_type($content_type, $url) {
+ // Extract main type from content-type header
+ $content_type = strtolower(explode(';', $content_type)[0]);
+
+ $map = array(
+ 'image/jpeg' => 'jpg',
+ 'image/jpg' => 'jpg',
+ 'image/png' => 'png',
+ 'image/gif' => 'gif',
+ 'image/webp' => 'webp',
+ );
+
+ if (isset($map[$content_type])) {
+ return $map[$content_type];
+ }
+
+ // Fallback to URL extension
+ $path = parse_url($url, PHP_URL_PATH);
+ $ext = pathinfo($path, PATHINFO_EXTENSION);
+
+ return $ext ?: 'jpg';
+ }
+
+ /**
+ * Delete all media for a property
+ *
+ * @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);
+ }
+
+ // Delete records
+ $wpdb->delete(
+ $this->db->media_table(),
+ array('listing_key' => $listing_key)
+ );
+ }
+
+ /**
+ * Recursively delete a directory
+ *
+ * @param string $dir Directory path
+ */
+ private function recursive_delete($dir) {
+ if (!is_dir($dir)) {
+ return;
+ }
+
+ $files = array_diff(scandir($dir), array('.', '..'));
+
+ foreach ($files as $file) {
+ $path = $dir . '/' . $file;
+ if (is_dir($path)) {
+ $this->recursive_delete($path);
+ } else {
+ unlink($path);
+ }
+ }
+
+ rmdir($dir);
+ }
+
+ /**
+ * Get media for a listing
+ *
+ * @param string $listing_key Listing key
+ * @return array Media records
+ */
+ public function get_listing_media($listing_key) {
+ global $wpdb;
+
+ return $wpdb->get_results($wpdb->prepare(
+ "SELECT * FROM {$this->db->media_table()}
+ WHERE listing_key = %s
+ ORDER BY media_order ASC",
+ $listing_key
+ ));
+ }
+
+ /**
+ * Get primary image URL for a listing
+ *
+ * @param string $listing_key Listing key
+ * @return string|null Image URL
+ */
+ public function get_primary_image($listing_key) {
+ global $wpdb;
+
+ $media = $wpdb->get_row($wpdb->prepare(
+ "SELECT local_url, media_url FROM {$this->db->media_table()}
+ WHERE listing_key = %s AND local_path IS NOT NULL
+ ORDER BY media_order ASC
+ LIMIT 1",
+ $listing_key
+ ));
+
+ if ($media && $media->local_url) {
+ return $media->local_url;
+ }
+
+ return null;
+ }
+
+ /**
+ * Download pending media (for batch processing)
+ *
+ * @param int $limit Max media to download
+ * @return array Stats
+ */
+ public function download_pending($limit = 100) {
+ global $wpdb;
+
+ $pending = $wpdb->get_results($wpdb->prepare(
+ "SELECT id FROM {$this->db->media_table()}
+ WHERE local_path IS NULL AND media_url IS NOT NULL
+ AND download_attempts < 3
+ LIMIT %d",
+ $limit
+ ));
+
+ $stats = array(
+ 'total' => count($pending),
+ 'success' => 0,
+ 'failed' => 0,
+ );
+
+ foreach ($pending as $media) {
+ if ($this->download_media($media->id)) {
+ $stats['success']++;
+ } else {
+ $stats['failed']++;
+ }
+ }
+
+ return $stats;
+ }
+
+ /**
+ * Clean up orphaned media (files without database records)
+ *
+ * @return int Number of files deleted
+ */
+ public function cleanup_orphaned_files() {
+ $deleted = 0;
+ $base_dir = $this->get_upload_dir();
+
+ if (!is_dir($base_dir)) {
+ return 0;
+ }
+
+ // Iterate through prefix directories
+ foreach (scandir($base_dir) as $prefix) {
+ if ($prefix === '.' || $prefix === '..' || !is_dir($base_dir . '/' . $prefix)) {
+ continue;
+ }
+
+ $prefix_dir = $base_dir . '/' . $prefix;
+
+ // Iterate through listing directories
+ foreach (scandir($prefix_dir) as $listing_key) {
+ if ($listing_key === '.' || $listing_key === '..') {
+ continue;
+ }
+
+ $listing_dir = $prefix_dir . '/' . $listing_key;
+ if (!is_dir($listing_dir)) {
+ continue;
+ }
+
+ // Check if listing exists in database
+ global $wpdb;
+ $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++;
+ }
+ }
+ }
+
+ return $deleted;
+ }
+}
diff --git a/wp-content/plugins/mls-by-hansonxyz/includes/class-mls-options.php b/wp-content/plugins/mls-by-hansonxyz/includes/class-mls-options.php
new file mode 100644
index 00000000..b2a61fbc
--- /dev/null
+++ b/wp-content/plugins/mls-by-hansonxyz/includes/class-mls-options.php
@@ -0,0 +1,207 @@
+ '',
+ 'api_token' => '',
+ 'originating_system' => 'northstar',
+ 'auto_sync_enabled' => false,
+ 'sync_interval' => 'hourly',
+ 'sync_media' => true,
+ 'last_full_sync' => null,
+ 'last_incremental_sync' => null,
+ );
+
+ /**
+ * Get all options
+ *
+ * @return array
+ */
+ public function get_all() {
+ $options = get_option(self::OPTION_KEY, array());
+ return wp_parse_args($options, $this->defaults);
+ }
+
+ /**
+ * Get a single option
+ *
+ * @param string $key Option key
+ * @param mixed $default Default value if not set
+ * @return mixed
+ */
+ public function get($key, $default = null) {
+ $options = $this->get_all();
+
+ if (isset($options[$key])) {
+ return $options[$key];
+ }
+
+ if (null !== $default) {
+ return $default;
+ }
+
+ return isset($this->defaults[$key]) ? $this->defaults[$key] : null;
+ }
+
+ /**
+ * Set a single option
+ *
+ * @param string $key Option key
+ * @param mixed $value Option value
+ * @return bool
+ */
+ public function set($key, $value) {
+ $options = $this->get_all();
+ $options[$key] = $value;
+ return update_option(self::OPTION_KEY, $options);
+ }
+
+ /**
+ * Set multiple options
+ *
+ * @param array $values Key-value pairs
+ * @return bool
+ */
+ public function set_multiple($values) {
+ $options = $this->get_all();
+ foreach ($values as $key => $value) {
+ $options[$key] = $value;
+ }
+ return update_option(self::OPTION_KEY, $options);
+ }
+
+ /**
+ * Delete a single option
+ *
+ * @param string $key Option key
+ * @return bool
+ */
+ public function delete($key) {
+ $options = $this->get_all();
+ if (isset($options[$key])) {
+ unset($options[$key]);
+ return update_option(self::OPTION_KEY, $options);
+ }
+ return true;
+ }
+
+ /**
+ * Delete all options
+ *
+ * @return bool
+ */
+ public function delete_all() {
+ return delete_option(self::OPTION_KEY);
+ }
+
+ /**
+ * Get API URL from wp-config or options
+ *
+ * @return string
+ */
+ public function get_api_url() {
+ // Check wp-config constant first
+ if (defined('MLSGRID_API_URL') && MLSGRID_API_URL) {
+ return MLSGRID_API_URL;
+ }
+
+ return $this->get('api_url', 'https://api.mlsgrid.com/v2');
+ }
+
+ /**
+ * Get API token from wp-config or options
+ *
+ * @return string
+ */
+ public function get_api_token() {
+ // Check wp-config constant first
+ if (defined('MLSGRID_ACCESS_TOKEN') && MLSGRID_ACCESS_TOKEN) {
+ return MLSGRID_ACCESS_TOKEN;
+ }
+
+ return $this->get('api_token', '');
+ }
+
+ /**
+ * Get originating system name
+ *
+ * @return string
+ */
+ public function get_originating_system() {
+ return $this->get('originating_system', 'northstar');
+ }
+
+ /**
+ * Check if API is configured
+ *
+ * @return bool
+ */
+ public function is_configured() {
+ return !empty($this->get_api_url()) && !empty($this->get_api_token());
+ }
+
+ /**
+ * Check if auto sync is enabled
+ *
+ * @return bool
+ */
+ public function is_auto_sync_enabled() {
+ return (bool) $this->get('auto_sync_enabled', false);
+ }
+
+ /**
+ * Get sync interval
+ *
+ * @return string WordPress cron interval name
+ */
+ public function get_sync_interval() {
+ return $this->get('sync_interval', 'hourly');
+ }
+
+ /**
+ * Check if media sync is enabled
+ *
+ * @return bool
+ */
+ public function is_media_sync_enabled() {
+ return (bool) $this->get('sync_media', true);
+ }
+
+ /**
+ * Update last sync timestamps
+ *
+ * @param string $type 'full' or 'incremental'
+ * @return bool
+ */
+ public function update_last_sync($type = 'incremental') {
+ $key = 'full' === $type ? 'last_full_sync' : 'last_incremental_sync';
+ return $this->set($key, current_time('mysql'));
+ }
+
+ /**
+ * Get last sync time
+ *
+ * @param string $type 'full' or 'incremental'
+ * @return string|null MySQL datetime or null
+ */
+ public function get_last_sync($type = 'incremental') {
+ $key = 'full' === $type ? 'last_full_sync' : 'last_incremental_sync';
+ return $this->get($key);
+ }
+}
diff --git a/wp-content/plugins/mls-by-hansonxyz/includes/class-mls-query.php b/wp-content/plugins/mls-by-hansonxyz/includes/class-mls-query.php
new file mode 100644
index 00000000..5a52a31b
--- /dev/null
+++ b/wp-content/plugins/mls-by-hansonxyz/includes/class-mls-query.php
@@ -0,0 +1,484 @@
+db = $db;
+ }
+
+ /**
+ * Get properties matching criteria
+ *
+ * @param array $args Query arguments
+ * @return array Property objects
+ */
+ public function get_properties($args = array()) {
+ global $wpdb;
+
+ $defaults = array(
+ 'status' => null, // Active, Pending, Closed
+ 'property_type' => null, // Residential, Land, Commercial
+ 'city' => null,
+ 'county' => null,
+ 'postal_code' => null,
+ 'min_price' => null,
+ 'max_price' => null,
+ 'min_beds' => null,
+ 'max_beds' => null,
+ 'min_baths' => null,
+ 'min_sqft' => null,
+ 'max_sqft' => null,
+ 'year_built_min' => null,
+ 'year_built_max' => null,
+ 'listing_key' => null,
+ 'listing_id' => null,
+ 'search' => null, // Search in address/remarks
+ 'limit' => 20,
+ 'offset' => 0,
+ 'orderby' => 'modification_timestamp',
+ 'order' => 'DESC',
+ 'include_media' => false,
+ 'fields' => '*', // Specific fields or *
+ );
+
+ $args = wp_parse_args($args, $defaults);
+
+ // Build query
+ $table = $this->db->properties_table();
+
+ // Fields
+ if ($args['fields'] === '*') {
+ $select = '*';
+ } else {
+ $fields = array_map('sanitize_key', (array) $args['fields']);
+ $select = implode(', ', $fields);
+ }
+
+ $sql = "SELECT {$select} FROM {$table}";
+
+ // WHERE conditions
+ $where = array('mlg_can_view = 1');
+ $values = array();
+
+ if ($args['status']) {
+ $where[] = 'standard_status = %s';
+ $values[] = $args['status'];
+ }
+
+ if ($args['property_type']) {
+ $where[] = 'property_type = %s';
+ $values[] = $args['property_type'];
+ }
+
+ if ($args['city']) {
+ $where[] = 'city = %s';
+ $values[] = $args['city'];
+ }
+
+ if ($args['county']) {
+ $where[] = 'county = %s';
+ $values[] = $args['county'];
+ }
+
+ if ($args['postal_code']) {
+ $where[] = 'postal_code = %s';
+ $values[] = $args['postal_code'];
+ }
+
+ if ($args['min_price']) {
+ $where[] = 'list_price >= %d';
+ $values[] = (int) $args['min_price'];
+ }
+
+ if ($args['max_price']) {
+ $where[] = 'list_price <= %d';
+ $values[] = (int) $args['max_price'];
+ }
+
+ if ($args['min_beds']) {
+ $where[] = 'bedrooms_total >= %d';
+ $values[] = (int) $args['min_beds'];
+ }
+
+ if ($args['max_beds']) {
+ $where[] = 'bedrooms_total <= %d';
+ $values[] = (int) $args['max_beds'];
+ }
+
+ if ($args['min_baths']) {
+ $where[] = 'bathrooms_total >= %d';
+ $values[] = (int) $args['min_baths'];
+ }
+
+ if ($args['min_sqft']) {
+ $where[] = 'living_area >= %d';
+ $values[] = (int) $args['min_sqft'];
+ }
+
+ if ($args['max_sqft']) {
+ $where[] = 'living_area <= %d';
+ $values[] = (int) $args['max_sqft'];
+ }
+
+ if ($args['year_built_min']) {
+ $where[] = 'year_built >= %d';
+ $values[] = (int) $args['year_built_min'];
+ }
+
+ if ($args['year_built_max']) {
+ $where[] = 'year_built <= %d';
+ $values[] = (int) $args['year_built_max'];
+ }
+
+ if ($args['listing_key']) {
+ $where[] = 'listing_key = %s';
+ $values[] = $args['listing_key'];
+ }
+
+ if ($args['listing_id']) {
+ $where[] = 'listing_id = %s';
+ $values[] = $args['listing_id'];
+ }
+
+ if ($args['search']) {
+ $search_term = '%' . $wpdb->esc_like($args['search']) . '%';
+ $where[] = '(street_name LIKE %s OR city LIKE %s OR public_remarks LIKE %s OR listing_id LIKE %s)';
+ $values[] = $search_term;
+ $values[] = $search_term;
+ $values[] = $search_term;
+ $values[] = $search_term;
+ }
+
+ $sql .= ' WHERE ' . implode(' AND ', $where);
+
+ // ORDER BY
+ $allowed_orderby = array(
+ 'modification_timestamp',
+ 'list_price',
+ 'bedrooms_total',
+ 'bathrooms_total',
+ 'living_area',
+ 'year_built',
+ 'days_on_market',
+ 'city',
+ 'created_at',
+ );
+
+ $orderby = in_array($args['orderby'], $allowed_orderby) ? $args['orderby'] : 'modification_timestamp';
+ $order = strtoupper($args['order']) === 'ASC' ? 'ASC' : 'DESC';
+ $sql .= " ORDER BY {$orderby} {$order}";
+
+ // LIMIT/OFFSET
+ $sql .= ' LIMIT %d OFFSET %d';
+ $values[] = (int) $args['limit'];
+ $values[] = (int) $args['offset'];
+
+ // Execute
+ $results = $wpdb->get_results($wpdb->prepare($sql, $values));
+
+ // Include media if requested
+ if ($args['include_media'] && $results) {
+ foreach ($results as &$property) {
+ $property->media = $this->get_property_media($property->listing_key);
+ }
+ }
+
+ return $results;
+ }
+
+ /**
+ * Get a single property
+ *
+ * @param string $identifier Listing key or listing ID
+ * @return object|null Property object
+ */
+ public function get_property($identifier) {
+ global $wpdb;
+
+ $table = $this->db->properties_table();
+
+ // Try listing_key first
+ $property = $wpdb->get_row($wpdb->prepare(
+ "SELECT * FROM {$table} WHERE listing_key = %s AND mlg_can_view = 1",
+ $identifier
+ ));
+
+ // Try listing_id if not found
+ if (!$property) {
+ $property = $wpdb->get_row($wpdb->prepare(
+ "SELECT * FROM {$table} WHERE listing_id = %s AND mlg_can_view = 1",
+ $identifier
+ ));
+ }
+
+ return $property;
+ }
+
+ /**
+ * Get media for a property
+ *
+ * @param string $listing_key Listing key
+ * @return array Media objects
+ */
+ public function get_property_media($listing_key) {
+ global $wpdb;
+
+ return $wpdb->get_results($wpdb->prepare(
+ "SELECT * FROM {$this->db->media_table()}
+ WHERE listing_key = %s
+ ORDER BY media_order ASC",
+ $listing_key
+ ));
+ }
+
+ /**
+ * Get primary image URL
+ *
+ * @param string $listing_key Listing key
+ * @return string|null Image URL
+ */
+ public function get_primary_image($listing_key) {
+ global $wpdb;
+
+ return $wpdb->get_var($wpdb->prepare(
+ "SELECT local_url FROM {$this->db->media_table()}
+ WHERE listing_key = %s AND local_url IS NOT NULL
+ ORDER BY media_order ASC
+ LIMIT 1",
+ $listing_key
+ ));
+ }
+
+ /**
+ * Get distinct cities
+ *
+ * @param string|null $status Optional status filter
+ * @return array City names
+ */
+ public function get_distinct_cities($status = null) {
+ global $wpdb;
+
+ $table = $this->db->properties_table();
+
+ if ($status) {
+ $cities = $wpdb->get_col($wpdb->prepare(
+ "SELECT DISTINCT city FROM {$table}
+ WHERE mlg_can_view = 1 AND standard_status = %s AND city IS NOT NULL
+ ORDER BY city ASC",
+ $status
+ ));
+ } else {
+ $cities = $wpdb->get_col(
+ "SELECT DISTINCT city FROM {$table}
+ WHERE mlg_can_view = 1 AND city IS NOT NULL
+ ORDER BY city ASC"
+ );
+ }
+
+ return $cities;
+ }
+
+ /**
+ * Get distinct counties
+ *
+ * @param string|null $status Optional status filter
+ * @return array County names
+ */
+ public function get_distinct_counties($status = null) {
+ global $wpdb;
+
+ $table = $this->db->properties_table();
+
+ if ($status) {
+ $counties = $wpdb->get_col($wpdb->prepare(
+ "SELECT DISTINCT county FROM {$table}
+ WHERE mlg_can_view = 1 AND standard_status = %s AND county IS NOT NULL
+ ORDER BY county ASC",
+ $status
+ ));
+ } else {
+ $counties = $wpdb->get_col(
+ "SELECT DISTINCT county FROM {$table}
+ WHERE mlg_can_view = 1 AND county IS NOT NULL
+ ORDER BY county ASC"
+ );
+ }
+
+ return $counties;
+ }
+
+ /**
+ * Get property count
+ *
+ * @param array $args Filter arguments (same as get_properties)
+ * @return int Count
+ */
+ public function get_count($args = array()) {
+ global $wpdb;
+
+ $table = $this->db->properties_table();
+
+ $where = array('mlg_can_view = 1');
+ $values = array();
+
+ if (!empty($args['status'])) {
+ $where[] = 'standard_status = %s';
+ $values[] = $args['status'];
+ }
+
+ if (!empty($args['property_type'])) {
+ $where[] = 'property_type = %s';
+ $values[] = $args['property_type'];
+ }
+
+ if (!empty($args['city'])) {
+ $where[] = 'city = %s';
+ $values[] = $args['city'];
+ }
+
+ $sql = "SELECT COUNT(*) FROM {$table} WHERE " . implode(' AND ', $where);
+
+ if (!empty($values)) {
+ return (int) $wpdb->get_var($wpdb->prepare($sql, $values));
+ }
+
+ return (int) $wpdb->get_var($sql);
+ }
+
+ /**
+ * Check if data exists
+ *
+ * @return bool
+ */
+ public function has_data() {
+ global $wpdb;
+
+ $count = $wpdb->get_var(
+ "SELECT COUNT(*) FROM {$this->db->properties_table()} WHERE mlg_can_view = 1"
+ );
+
+ return (int) $count > 0;
+ }
+
+ /**
+ * Get property types with counts
+ *
+ * @param string|null $status Optional status filter
+ * @return array Property types with counts
+ */
+ public function get_property_types($status = null) {
+ global $wpdb;
+
+ $table = $this->db->properties_table();
+
+ if ($status) {
+ return $wpdb->get_results($wpdb->prepare(
+ "SELECT property_type, COUNT(*) as count
+ FROM {$table}
+ WHERE mlg_can_view = 1 AND standard_status = %s AND property_type IS NOT NULL
+ GROUP BY property_type
+ ORDER BY count DESC",
+ $status
+ ));
+ }
+
+ return $wpdb->get_results(
+ "SELECT property_type, COUNT(*) as count
+ FROM {$table}
+ WHERE mlg_can_view = 1 AND property_type IS NOT NULL
+ GROUP BY property_type
+ ORDER BY count DESC"
+ );
+ }
+
+ /**
+ * Get price range
+ *
+ * @param string|null $status Optional status filter
+ * @return object Min and max prices
+ */
+ public function get_price_range($status = null) {
+ global $wpdb;
+
+ $table = $this->db->properties_table();
+
+ if ($status) {
+ return $wpdb->get_row($wpdb->prepare(
+ "SELECT MIN(list_price) as min_price, MAX(list_price) as max_price
+ FROM {$table}
+ WHERE mlg_can_view = 1 AND standard_status = %s AND list_price > 0",
+ $status
+ ));
+ }
+
+ return $wpdb->get_row(
+ "SELECT MIN(list_price) as min_price, MAX(list_price) as max_price
+ FROM {$table}
+ WHERE mlg_can_view = 1 AND list_price > 0"
+ );
+ }
+
+ /**
+ * Get formatted address for a property
+ *
+ * @param object $property Property object
+ * @return string Formatted address
+ */
+ public function format_address($property) {
+ $parts = array();
+
+ if ($property->street_number) {
+ $parts[] = $property->street_number;
+ }
+
+ if ($property->street_name) {
+ $parts[] = $property->street_name;
+ }
+
+ if ($property->street_suffix) {
+ $parts[] = $property->street_suffix;
+ }
+
+ if ($property->unit_number) {
+ $parts[] = '#' . $property->unit_number;
+ }
+
+ $street = implode(' ', $parts);
+
+ $location_parts = array();
+ if ($property->city) {
+ $location_parts[] = $property->city;
+ }
+ if ($property->state_or_province) {
+ $location_parts[] = $property->state_or_province;
+ }
+ if ($property->postal_code) {
+ $location_parts[] = $property->postal_code;
+ }
+
+ $location = implode(', ', $location_parts);
+
+ if ($street && $location) {
+ return $street . ', ' . $location;
+ }
+
+ return $street ?: $location;
+ }
+}
diff --git a/wp-content/plugins/mls-by-hansonxyz/includes/class-mls-rate-limiter.php b/wp-content/plugins/mls-by-hansonxyz/includes/class-mls-rate-limiter.php
new file mode 100644
index 00000000..ba55d3e8
--- /dev/null
+++ b/wp-content/plugins/mls-by-hansonxyz/includes/class-mls-rate-limiter.php
@@ -0,0 +1,323 @@
+db = $db;
+ }
+
+ /**
+ * Check if we can make a request (and wait if needed)
+ *
+ * @param bool $wait Whether to wait if rate limited
+ * @return bool True if request can proceed
+ */
+ public function check_and_wait($wait = true) {
+ // Check per-second limit (most restrictive)
+ $this->enforce_per_second_limit();
+
+ // Check hourly limit
+ if (!$this->check_limit(self::WINDOW_HOUR, self::LIMIT_PER_HOUR)) {
+ if ($wait) {
+ $this->wait_for_window(self::WINDOW_HOUR);
+ } else {
+ return false;
+ }
+ }
+
+ // Check daily limit
+ if (!$this->check_limit(self::WINDOW_DAY, self::LIMIT_PER_DAY)) {
+ if ($wait) {
+ $this->wait_for_window(self::WINDOW_DAY);
+ } else {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Enforce per-second rate limit
+ */
+ private function enforce_per_second_limit() {
+ $now = microtime(true);
+ $min_interval = 1.0 / self::LIMIT_PER_SECOND; // 0.5 seconds
+
+ 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
+ *
+ * @param string $window_type Window type
+ * @param int $limit Limit for this window
+ * @return bool True if under limit
+ */
+ private function check_limit($window_type, $limit) {
+ $count = $this->get_window_count($window_type);
+ return $count < $limit;
+ }
+
+ /**
+ * Get current count for a window
+ *
+ * @param string $window_type Window type
+ * @return int Current request count
+ */
+ private function get_window_count($window_type) {
+ global $wpdb;
+
+ $window_start = $this->get_window_start($window_type);
+
+ $count = $wpdb->get_var($wpdb->prepare(
+ "SELECT request_count FROM {$this->db->rate_limits_table()}
+ WHERE window_type = %s AND window_start = %s",
+ $window_type,
+ $window_start
+ ));
+
+ return $count ? (int) $count : 0;
+ }
+
+ /**
+ * Get window start time
+ *
+ * @param string $window_type Window type
+ * @return string MySQL datetime
+ */
+ private function get_window_start($window_type) {
+ $now = current_time('timestamp');
+
+ switch ($window_type) {
+ case self::WINDOW_SECOND:
+ return gmdate('Y-m-d H:i:s', $now);
+
+ case self::WINDOW_HOUR:
+ return gmdate('Y-m-d H:00:00', $now);
+
+ case self::WINDOW_DAY:
+ return gmdate('Y-m-d 00:00:00', $now);
+
+ default:
+ return gmdate('Y-m-d H:i:s', $now);
+ }
+ }
+
+ /**
+ * Record a request
+ *
+ * @param int $bytes_transferred Optional bytes transferred
+ */
+ public function record_request($bytes_transferred = 0) {
+ global $wpdb;
+
+ // Record for hourly window
+ $this->increment_window(self::WINDOW_HOUR, $bytes_transferred);
+
+ // Record for daily window
+ $this->increment_window(self::WINDOW_DAY, $bytes_transferred);
+
+ // Clean up old records
+ $this->cleanup_old_records();
+ }
+
+ /**
+ * Increment count for a window
+ *
+ * @param string $window_type Window type
+ * @param int $bytes_transferred Bytes transferred
+ */
+ private function increment_window($window_type, $bytes_transferred = 0) {
+ global $wpdb;
+
+ $window_start = $this->get_window_start($window_type);
+
+ // Try to update existing record
+ $updated = $wpdb->query($wpdb->prepare(
+ "UPDATE {$this->db->rate_limits_table()}
+ SET request_count = request_count + 1,
+ bytes_transferred = bytes_transferred + %d
+ WHERE window_type = %s AND window_start = %s",
+ $bytes_transferred,
+ $window_type,
+ $window_start
+ ));
+
+ // If no record existed, insert new one
+ if (0 === $updated) {
+ $wpdb->insert(
+ $this->db->rate_limits_table(),
+ array(
+ 'window_type' => $window_type,
+ 'window_start' => $window_start,
+ 'request_count' => 1,
+ 'bytes_transferred' => $bytes_transferred,
+ ),
+ array('%s', '%s', '%d', '%d')
+ );
+ }
+ }
+
+ /**
+ * Wait for a rate limit window to reset
+ *
+ * @param string $window_type Window type
+ */
+ private function wait_for_window($window_type) {
+ $now = current_time('timestamp');
+
+ switch ($window_type) {
+ case self::WINDOW_HOUR:
+ // Wait until next hour
+ $next_hour = strtotime('+1 hour', strtotime(gmdate('Y-m-d H:00:00', $now)));
+ $wait_seconds = $next_hour - $now;
+ break;
+
+ case self::WINDOW_DAY:
+ // Wait until next day
+ $next_day = strtotime('+1 day', strtotime(gmdate('Y-m-d 00:00:00', $now)));
+ $wait_seconds = $next_day - $now;
+ break;
+
+ default:
+ $wait_seconds = 1;
+ }
+
+ if ($wait_seconds > 0) {
+ sleep(min($wait_seconds, 60)); // Max 60 second wait per call
+ }
+ }
+
+ /**
+ * Clean up old rate limit records
+ */
+ private function cleanup_old_records() {
+ global $wpdb;
+
+ // Delete records older than 48 hours
+ $cutoff = gmdate('Y-m-d H:i:s', strtotime('-48 hours'));
+
+ $wpdb->query($wpdb->prepare(
+ "DELETE FROM {$this->db->rate_limits_table()} WHERE window_start < %s",
+ $cutoff
+ ));
+ }
+
+ /**
+ * Get current rate limit status
+ *
+ * @return array Rate limit status
+ */
+ public function get_status() {
+ return array(
+ 'hourly' => array(
+ 'used' => $this->get_window_count(self::WINDOW_HOUR),
+ 'limit' => self::LIMIT_PER_HOUR,
+ 'remaining' => max(0, self::LIMIT_PER_HOUR - $this->get_window_count(self::WINDOW_HOUR)),
+ ),
+ 'daily' => array(
+ 'used' => $this->get_window_count(self::WINDOW_DAY),
+ 'limit' => self::LIMIT_PER_DAY,
+ 'remaining' => max(0, self::LIMIT_PER_DAY - $this->get_window_count(self::WINDOW_DAY)),
+ ),
+ 'bytes_this_hour' => $this->get_bytes_this_hour(),
+ 'bytes_limit' => self::LIMIT_BYTES_PER_HOUR,
+ );
+ }
+
+ /**
+ * Get bytes transferred this hour
+ *
+ * @return int Bytes
+ */
+ private function get_bytes_this_hour() {
+ global $wpdb;
+
+ $window_start = $this->get_window_start(self::WINDOW_HOUR);
+
+ $bytes = $wpdb->get_var($wpdb->prepare(
+ "SELECT bytes_transferred FROM {$this->db->rate_limits_table()}
+ WHERE window_type = %s AND window_start = %s",
+ self::WINDOW_HOUR,
+ $window_start
+ ));
+
+ return $bytes ? (int) $bytes : 0;
+ }
+
+ /**
+ * Check if we're approaching rate limits
+ *
+ * @param float $threshold Percentage threshold (0.0 - 1.0)
+ * @return bool True if approaching limits
+ */
+ public function is_approaching_limit($threshold = 0.9) {
+ $status = $this->get_status();
+
+ $hourly_pct = $status['hourly']['used'] / $status['hourly']['limit'];
+ $daily_pct = $status['daily']['used'] / $status['daily']['limit'];
+
+ return $hourly_pct >= $threshold || $daily_pct >= $threshold;
+ }
+
+ /**
+ * Reset all rate limit counters (for testing)
+ */
+ public function reset() {
+ global $wpdb;
+ $wpdb->query("TRUNCATE TABLE {$this->db->rate_limits_table()}");
+ $this->last_request_time = 0;
+ }
+}
diff --git a/wp-content/plugins/mls-by-hansonxyz/includes/class-mls-sync-engine.php b/wp-content/plugins/mls-by-hansonxyz/includes/class-mls-sync-engine.php
new file mode 100644
index 00000000..e49b7539
--- /dev/null
+++ b/wp-content/plugins/mls-by-hansonxyz/includes/class-mls-sync-engine.php
@@ -0,0 +1,694 @@
+ 0,
+ 'created' => 0,
+ 'updated' => 0,
+ 'deleted' => 0,
+ 'errors' => 0,
+ );
+
+ /**
+ * Constructor
+ */
+ public function __construct(MLS_DB $db, MLS_API_Client $api_client, MLS_Media_Handler $media_handler, MLS_Logger $logger) {
+ $this->db = $db;
+ $this->api_client = $api_client;
+ $this->media_handler = $media_handler;
+ $this->logger = $logger;
+ }
+
+ /**
+ * Run full sync
+ *
+ * @param bool $dry_run If true, don't make changes
+ * @param int|null $limit Max records to process
+ * @param callable|null $progress_callback Callback for progress updates
+ * @return array Sync results
+ */
+ public function run_full_sync($dry_run = false, $limit = null, $progress_callback = null) {
+ $this->logger->info('Starting full sync', array('dry_run' => $dry_run, 'limit' => $limit));
+
+ // Create sync state record
+ if (!$dry_run) {
+ $this->sync_state_id = $this->create_sync_state(self::TYPE_FULL);
+ $this->logger->set_sync_state($this->sync_state_id);
+ }
+
+ $this->stats = array(
+ 'processed' => 0,
+ 'created' => 0,
+ 'updated' => 0,
+ 'deleted' => 0,
+ 'errors' => 0,
+ );
+
+ try {
+ // Get first page of properties with media
+ $response = $this->api_client->get_properties_for_sync(null, 'Media', $limit ? min($limit, 1000) : null);
+
+ if (is_wp_error($response)) {
+ throw new Exception($response->get_error_message());
+ }
+
+ // Process pages
+ $continue = true;
+ while ($continue && isset($response['value'])) {
+ foreach ($response['value'] as $property) {
+ if ($limit && $this->stats['processed'] >= $limit) {
+ $continue = false;
+ break;
+ }
+
+ $this->process_property($property, $dry_run);
+
+ if ($progress_callback) {
+ call_user_func($progress_callback, $this->stats);
+ }
+ }
+
+ // Check for next page
+ if ($continue && isset($response['@odata.nextLink'])) {
+ // Save progress
+ if (!$dry_run) {
+ $this->update_sync_state(array(
+ 'last_next_link' => $response['@odata.nextLink'],
+ 'records_processed' => $this->stats['processed'],
+ 'records_created' => $this->stats['created'],
+ 'records_updated' => $this->stats['updated'],
+ ));
+ }
+
+ $response = $this->api_client->get_next_page($response['@odata.nextLink']);
+
+ if (is_wp_error($response)) {
+ throw new Exception($response->get_error_message());
+ }
+ } else {
+ $continue = false;
+ }
+ }
+
+ // 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_created' => $this->stats['created'],
+ 'records_updated' => $this->stats['updated'],
+ 'records_deleted' => $this->stats['deleted'],
+ ));
+
+ // Update last sync time
+ $options = mls_plugin()->get_options();
+ $options->update_last_sync('full');
+ }
+
+ $this->logger->info('Full sync completed', $this->stats);
+
+ } catch (Exception $e) {
+ $this->logger->error('Full sync failed', array('error' => $e->getMessage()));
+
+ if (!$dry_run) {
+ $this->update_sync_state(array(
+ 'status' => self::STATUS_FAILED,
+ 'last_error' => $e->getMessage(),
+ 'error_count' => $this->stats['errors'] + 1,
+ ));
+ }
+
+ return array(
+ 'success' => false,
+ 'error' => $e->getMessage(),
+ 'stats' => $this->stats,
+ );
+ }
+
+ return array(
+ 'success' => true,
+ 'stats' => $this->stats,
+ );
+ }
+
+ /**
+ * Run incremental sync
+ *
+ * @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_incremental_sync($dry_run = false, $progress_callback = null) {
+ // Get last modification timestamp
+ $last_timestamp = $this->get_last_modification_timestamp();
+
+ if (!$last_timestamp) {
+ $this->logger->info('No previous sync found, running full sync instead');
+ return $this->run_full_sync($dry_run, null, $progress_callback);
+ }
+
+ $this->logger->info('Starting incremental sync', array(
+ 'since' => $last_timestamp,
+ 'dry_run' => $dry_run,
+ ));
+
+ if (!$dry_run) {
+ $this->sync_state_id = $this->create_sync_state(self::TYPE_INCREMENTAL);
+ $this->logger->set_sync_state($this->sync_state_id);
+ }
+
+ $this->stats = array(
+ 'processed' => 0,
+ 'created' => 0,
+ 'updated' => 0,
+ 'deleted' => 0,
+ 'errors' => 0,
+ );
+
+ try {
+ // Get modified properties (including those marked for deletion)
+ $response = $this->api_client->get_properties_since($last_timestamp, 'Media');
+
+ if (is_wp_error($response)) {
+ throw new Exception($response->get_error_message());
+ }
+
+ // Process pages
+ while (isset($response['value'])) {
+ foreach ($response['value'] as $property) {
+ $this->process_property($property, $dry_run);
+
+ if ($progress_callback) {
+ call_user_func($progress_callback, $this->stats);
+ }
+ }
+
+ // Check for next page
+ if (isset($response['@odata.nextLink'])) {
+ $response = $this->api_client->get_next_page($response['@odata.nextLink']);
+
+ if (is_wp_error($response)) {
+ throw new Exception($response->get_error_message());
+ }
+ } else {
+ break;
+ }
+ }
+
+ // 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_created' => $this->stats['created'],
+ 'records_updated' => $this->stats['updated'],
+ 'records_deleted' => $this->stats['deleted'],
+ ));
+
+ $options = mls_plugin()->get_options();
+ $options->update_last_sync('incremental');
+ }
+
+ $this->logger->info('Incremental sync completed', $this->stats);
+
+ } catch (Exception $e) {
+ $this->logger->error('Incremental 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
+ *
+ * @param int $sync_state_id Sync state ID to resume
+ * @param callable|null $progress_callback Progress callback
+ * @return array Sync results
+ */
+ public function resume_sync($sync_state_id, $progress_callback = null) {
+ global $wpdb;
+
+ $state = $wpdb->get_row($wpdb->prepare(
+ "SELECT * FROM {$this->db->sync_state_table()} WHERE id = %d",
+ $sync_state_id
+ ));
+
+ if (!$state) {
+ return array(
+ 'success' => false,
+ 'error' => 'Sync state not found',
+ );
+ }
+
+ if ($state->status === self::STATUS_COMPLETED) {
+ return array(
+ 'success' => false,
+ 'error' => 'Sync already completed',
+ );
+ }
+
+ $this->sync_state_id = $sync_state_id;
+ $this->logger->set_sync_state($sync_state_id);
+ $this->logger->info('Resuming sync', array('sync_state_id' => $sync_state_id));
+
+ // Load existing stats
+ $this->stats = array(
+ 'processed' => (int) $state->records_processed,
+ 'created' => (int) $state->records_created,
+ 'updated' => (int) $state->records_updated,
+ 'deleted' => (int) $state->records_deleted,
+ 'errors' => (int) $state->error_count,
+ );
+
+ // Update status to running
+ $this->update_sync_state(array('status' => self::STATUS_RUNNING));
+
+ try {
+ // Resume from last next_link
+ if ($state->last_next_link) {
+ $response = $this->api_client->get_next_page($state->last_next_link);
+ } else {
+ // Start fresh
+ $response = $this->api_client->get_properties_for_sync(
+ $state->last_modification_timestamp,
+ 'Media'
+ );
+ }
+
+ if (is_wp_error($response)) {
+ throw new Exception($response->get_error_message());
+ }
+
+ // Process remaining pages
+ while (isset($response['value'])) {
+ foreach ($response['value'] as $property) {
+ $this->process_property($property, false);
+
+ if ($progress_callback) {
+ call_user_func($progress_callback, $this->stats);
+ }
+ }
+
+ if (isset($response['@odata.nextLink'])) {
+ $this->update_sync_state(array(
+ 'last_next_link' => $response['@odata.nextLink'],
+ 'records_processed' => $this->stats['processed'],
+ ));
+
+ $response = $this->api_client->get_next_page($response['@odata.nextLink']);
+
+ if (is_wp_error($response)) {
+ throw new Exception($response->get_error_message());
+ }
+ } else {
+ break;
+ }
+ }
+
+ $this->update_sync_state(array(
+ 'status' => self::STATUS_COMPLETED,
+ 'completed_at' => current_time('mysql'),
+ 'records_processed' => $this->stats['processed'],
+ 'records_created' => $this->stats['created'],
+ 'records_updated' => $this->stats['updated'],
+ 'records_deleted' => $this->stats['deleted'],
+ ));
+
+ $this->logger->info('Resume sync completed', $this->stats);
+
+ } catch (Exception $e) {
+ $this->logger->error('Resume sync failed', array('error' => $e->getMessage()));
+
+ $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,
+ );
+ }
+
+ /**
+ * Process a single property record
+ *
+ * @param array $property Property data from API
+ * @param bool $dry_run If true, don't make changes
+ */
+ private function process_property($property, $dry_run = false) {
+ global $wpdb;
+
+ $this->stats['processed']++;
+
+ $listing_key = $property['ListingKey'] ?? null;
+ if (!$listing_key) {
+ $this->stats['errors']++;
+ $this->logger->warning('Property missing ListingKey', array('property' => $property));
+ return;
+ }
+
+ // Check MlgCanView - if false, delete the record
+ $can_view = $property['MlgCanView'] ?? true;
+
+ if (!$can_view) {
+ if (!$dry_run) {
+ $this->delete_property($listing_key);
+ }
+ $this->stats['deleted']++;
+ return;
+ }
+
+ // Check if property exists
+ $existing = $wpdb->get_var($wpdb->prepare(
+ "SELECT id FROM {$this->db->properties_table()} WHERE listing_key = %s",
+ $listing_key
+ ));
+
+ // Prepare data for insert/update
+ $data = $this->map_property_data($property);
+
+ if ($dry_run) {
+ if ($existing) {
+ $this->stats['updated']++;
+ } else {
+ $this->stats['created']++;
+ }
+ return;
+ }
+
+ if ($existing) {
+ // Update existing
+ $wpdb->update(
+ $this->db->properties_table(),
+ $data,
+ array('listing_key' => $listing_key)
+ );
+ $this->stats['updated']++;
+ } else {
+ // Insert new
+ $data['listing_key'] = $listing_key;
+ $data['created_at'] = current_time('mysql');
+ $wpdb->insert($this->db->properties_table(), $data);
+ $this->stats['created']++;
+ }
+
+ // Process media if present
+ if (isset($property['Media']) && is_array($property['Media'])) {
+ $this->media_handler->sync_property_media($listing_key, $property['Media']);
+ }
+ }
+
+ /**
+ * Map API property data to database columns
+ *
+ * @param array $property API property data
+ * @return array Mapped data for database
+ */
+ private function map_property_data($property) {
+ return array(
+ 'listing_id' => $property['ListingId'] ?? null,
+ 'originating_system' => $property['OriginatingSystemName'] ?? 'northstar',
+ 'standard_status' => $property['StandardStatus'] ?? null,
+ 'mls_status' => $property['MlsStatus'] ?? null,
+ 'mlg_can_view' => isset($property['MlgCanView']) ? ($property['MlgCanView'] ? 1 : 0) : 1,
+
+ 'list_price' => $property['ListPrice'] ?? null,
+ 'original_list_price' => $property['OriginalListPrice'] ?? null,
+ 'close_price' => $property['ClosePrice'] ?? null,
+
+ 'street_number' => $property['StreetNumber'] ?? null,
+ 'street_name' => $property['StreetName'] ?? null,
+ 'street_suffix' => $property['StreetSuffix'] ?? null,
+ 'unit_number' => $property['UnitNumber'] ?? null,
+ 'city' => $property['City'] ?? null,
+ 'state_or_province' => $property['StateOrProvince'] ?? 'MN',
+ 'postal_code' => $property['PostalCode'] ?? null,
+ 'county' => $property['CountyOrParish'] ?? null,
+ 'latitude' => $property['Latitude'] ?? null,
+ 'longitude' => $property['Longitude'] ?? null,
+
+ 'property_type' => $property['PropertyType'] ?? null,
+ 'property_sub_type' => $property['PropertySubType'] ?? null,
+ 'bedrooms_total' => $property['BedroomsTotal'] ?? null,
+ 'bathrooms_total' => $property['BathroomsTotalInteger'] ?? null,
+ 'bathrooms_full' => $property['BathroomsFull'] ?? null,
+ 'bathrooms_half' => $property['BathroomsHalf'] ?? null,
+ 'living_area' => $property['LivingArea'] ?? null,
+ 'lot_size_area' => $property['LotSizeArea'] ?? null,
+ 'lot_size_units' => $property['LotSizeUnits'] ?? null,
+ 'year_built' => $property['YearBuilt'] ?? null,
+ 'garage_spaces' => $property['GarageSpaces'] ?? null,
+
+ 'public_remarks' => $property['PublicRemarks'] ?? null,
+ 'directions' => $property['Directions'] ?? null,
+
+ 'list_agent_key' => $property['ListAgentKey'] ?? null,
+ 'list_agent_mls_id' => $property['ListAgentMlsId'] ?? null,
+ 'list_office_key' => $property['ListOfficeKey'] ?? null,
+ 'list_office_mls_id' => $property['ListOfficeMlsId'] ?? null,
+ 'list_office_name' => $property['ListOfficeName'] ?? null,
+
+ 'photos_count' => $property['PhotosCount'] ?? 0,
+ 'modification_timestamp' => $this->format_timestamp($property['ModificationTimestamp'] ?? null),
+ 'photos_change_timestamp' => $this->format_timestamp($property['PhotosChangeTimestamp'] ?? null),
+ 'listing_contract_date' => $this->format_date($property['ListingContractDate'] ?? null),
+ 'close_date' => $this->format_date($property['CloseDate'] ?? null),
+ 'days_on_market' => $property['DaysOnMarket'] ?? null,
+
+ 'raw_data' => wp_json_encode($property),
+ 'updated_at' => current_time('mysql'),
+ );
+ }
+
+ /**
+ * Format ISO 8601 timestamp to MySQL datetime
+ *
+ * @param string|null $timestamp ISO 8601 timestamp
+ * @return string|null MySQL datetime
+ */
+ private function format_timestamp($timestamp) {
+ if (!$timestamp) {
+ return null;
+ }
+ $dt = new DateTime($timestamp);
+ return $dt->format('Y-m-d H:i:s');
+ }
+
+ /**
+ * Format date string to MySQL date
+ *
+ * @param string|null $date Date string
+ * @return string|null MySQL date
+ */
+ private function format_date($date) {
+ if (!$date) {
+ return null;
+ }
+ return date('Y-m-d', strtotime($date));
+ }
+
+ /**
+ * Delete a property and its media
+ *
+ * @param string $listing_key Listing key
+ */
+ private function delete_property($listing_key) {
+ global $wpdb;
+
+ // Delete media files
+ $this->media_handler->delete_property_media($listing_key);
+
+ // Delete from database
+ $wpdb->delete(
+ $this->db->properties_table(),
+ array('listing_key' => $listing_key)
+ );
+
+ $this->logger->debug('Deleted property', array('listing_key' => $listing_key));
+ }
+
+ /**
+ * Create a sync state record
+ *
+ * @param string $type Sync type
+ * @return int Sync state ID
+ */
+ private function create_sync_state($type) {
+ global $wpdb;
+
+ $wpdb->insert(
+ $this->db->sync_state_table(),
+ array(
+ 'sync_type' => $type,
+ 'entity_type' => 'Property',
+ 'status' => self::STATUS_RUNNING,
+ 'started_at' => current_time('mysql'),
+ 'created_at' => current_time('mysql'),
+ )
+ );
+
+ return $wpdb->insert_id;
+ }
+
+ /**
+ * Update sync state record
+ *
+ * @param array $data Data to update
+ */
+ private function update_sync_state($data) {
+ global $wpdb;
+
+ if (!$this->sync_state_id) {
+ return;
+ }
+
+ $data['updated_at'] = current_time('mysql');
+
+ $wpdb->update(
+ $this->db->sync_state_table(),
+ $data,
+ array('id' => $this->sync_state_id)
+ );
+ }
+
+ /**
+ * Get last modification timestamp from synced data
+ *
+ * @return string|null ISO 8601 timestamp
+ */
+ private function get_last_modification_timestamp() {
+ global $wpdb;
+
+ $timestamp = $wpdb->get_var(
+ "SELECT MAX(modification_timestamp) FROM {$this->db->properties_table()}"
+ );
+
+ if ($timestamp) {
+ $dt = new DateTime($timestamp);
+ return $dt->format('Y-m-d\TH:i:s.v\Z');
+ }
+
+ return null;
+ }
+
+ /**
+ * Get sync status
+ *
+ * @return array Sync status
+ */
+ public function get_status() {
+ global $wpdb;
+
+ $last_sync = $wpdb->get_row(
+ "SELECT * FROM {$this->db->sync_state_table()}
+ WHERE status = 'completed'
+ ORDER BY completed_at DESC
+ LIMIT 1"
+ );
+
+ $running_sync = $wpdb->get_row(
+ "SELECT * FROM {$this->db->sync_state_table()}
+ WHERE status = 'running'
+ ORDER BY started_at DESC
+ LIMIT 1"
+ );
+
+ $failed_sync = $wpdb->get_row(
+ "SELECT * FROM {$this->db->sync_state_table()}
+ WHERE status = 'failed'
+ ORDER BY updated_at DESC
+ LIMIT 1"
+ );
+
+ return array(
+ 'last_sync' => $last_sync,
+ 'running_sync' => $running_sync,
+ 'last_failed' => $failed_sync,
+ 'stats' => $this->db->get_stats(),
+ );
+ }
+}
diff --git a/wp-content/plugins/mls-by-hansonxyz/mls-by-hansonxyz.php b/wp-content/plugins/mls-by-hansonxyz/mls-by-hansonxyz.php
new file mode 100644
index 00000000..5e292006
--- /dev/null
+++ b/wp-content/plugins/mls-by-hansonxyz/mls-by-hansonxyz.php
@@ -0,0 +1,325 @@
+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-query.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->sync_engine = new MLS_Sync_Engine(
+ $this->db,
+ $this->api_client,
+ $this->media_handler,
+ $this->logger
+ );
+ $this->query = new MLS_Query($this->db);
+
+ // 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 Query instance
+ */
+ public function get_query() {
+ return $this->query;
+ }
+}
+
+/**
+ * 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) {
+ $plugin = mls_plugin();
+ if (!$plugin->get_query()) {
+ return null;
+ }
+ return $plugin->get_query()->get_property($identifier);
+}
+
+/**
+ * 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
+ *
+ * @param string $listing_key The listing key
+ * @return string|null Image URL or null
+ */
+function mls_get_property_image($listing_key) {
+ $plugin = mls_plugin();
+ if (!$plugin->get_query()) {
+ return null;
+ }
+ return $plugin->get_query()->get_primary_image($listing_key);
+}
+
+/**
+ * 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);
+}
diff --git a/wp-content/plugins/mls-by-hansonxyz/uninstall.php b/wp-content/plugins/mls-by-hansonxyz/uninstall.php
new file mode 100644
index 00000000..acf89f8c
--- /dev/null
+++ b/wp-content/plugins/mls-by-hansonxyz/uninstall.php
@@ -0,0 +1,48 @@
+prefix . 'mls_properties',
+ $wpdb->prefix . 'mls_media',
+ $wpdb->prefix . 'mls_sync_state',
+ $wpdb->prefix . 'mls_rate_limits',
+ $wpdb->prefix . 'mls_sync_log',
+);
+
+foreach ($tables as $table) {
+ $wpdb->query("DROP TABLE IF EXISTS {$table}");
+}
+
+// Clear scheduled cron events
+wp_clear_scheduled_hook('mls_sync_properties');
+wp_clear_scheduled_hook('mls_sync_media');
+wp_clear_scheduled_hook('mls_cleanup');
+
+// Optionally delete media files
+// Note: Uncomment this if you want to delete all downloaded media on uninstall
+/*
+$upload_dir = wp_upload_dir();
+$mls_dir = $upload_dir['basedir'] . '/mls-listings';
+
+if (is_dir($mls_dir)) {
+ // Recursive delete would go here
+}
+*/