From 655647941739cc2339562eb8a292b12e77b74f8f Mon Sep 17 00:00:00 2001 From: "Hanson.xyz Dev" Date: Sun, 14 Dec 2025 21:24:38 -0600 Subject: [PATCH] Add MLS by HansonXyz plugin for MLS Grid API integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Features: - Full sync of NorthStar MLS properties via MLS Grid API v2 - Incremental sync using ModificationTimestamp - Local media download and storage - Rate limit compliance (2 req/sec, 7200/hr, 40000/day) - Sync state tracking with resume capability - WP-CLI commands: test, sync, status, stats, cache - Admin settings page with manual sync triggers - Public API functions: mls_get_properties, mls_get_property, etc. Database tables: - mls_properties: Listing data with full field mapping - mls_media: Downloaded images - mls_sync_state: Sync progress tracking - mls_rate_limits: API usage tracking - mls_sync_log: Debug logging Documentation: - docs/CLAUDE.md: AI development guide - docs/API.md: MLS Grid API reference - docs/USAGE.md: User documentation Tested: Connection, auth, sync 10 records, media download verified 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .beads/issues.jsonl | 28 +- .../admin/class-mls-admin.php | 386 ++++++++++ .../mls-by-hansonxyz/cli/class-mls-cli.php | 478 ++++++++++++ .../plugins/mls-by-hansonxyz/docs/API.md | 298 ++++++++ .../plugins/mls-by-hansonxyz/docs/CLAUDE.md | 140 ++++ .../plugins/mls-by-hansonxyz/docs/USAGE.md | 244 ++++++ .../includes/class-mls-activator.php | 137 ++++ .../includes/class-mls-api-client.php | 469 ++++++++++++ .../includes/class-mls-db.php | 326 ++++++++ .../includes/class-mls-deactivator.php | 50 ++ .../includes/class-mls-logger.php | 198 +++++ .../includes/class-mls-media-handler.php | 499 +++++++++++++ .../includes/class-mls-options.php | 207 ++++++ .../includes/class-mls-query.php | 484 ++++++++++++ .../includes/class-mls-rate-limiter.php | 323 ++++++++ .../includes/class-mls-sync-engine.php | 694 ++++++++++++++++++ .../mls-by-hansonxyz/mls-by-hansonxyz.php | 325 ++++++++ .../plugins/mls-by-hansonxyz/uninstall.php | 48 ++ 18 files changed, 5324 insertions(+), 10 deletions(-) create mode 100644 wp-content/plugins/mls-by-hansonxyz/admin/class-mls-admin.php create mode 100644 wp-content/plugins/mls-by-hansonxyz/cli/class-mls-cli.php create mode 100644 wp-content/plugins/mls-by-hansonxyz/docs/API.md create mode 100644 wp-content/plugins/mls-by-hansonxyz/docs/CLAUDE.md create mode 100644 wp-content/plugins/mls-by-hansonxyz/docs/USAGE.md create mode 100644 wp-content/plugins/mls-by-hansonxyz/includes/class-mls-activator.php create mode 100644 wp-content/plugins/mls-by-hansonxyz/includes/class-mls-api-client.php create mode 100644 wp-content/plugins/mls-by-hansonxyz/includes/class-mls-db.php create mode 100644 wp-content/plugins/mls-by-hansonxyz/includes/class-mls-deactivator.php create mode 100644 wp-content/plugins/mls-by-hansonxyz/includes/class-mls-logger.php create mode 100644 wp-content/plugins/mls-by-hansonxyz/includes/class-mls-media-handler.php create mode 100644 wp-content/plugins/mls-by-hansonxyz/includes/class-mls-options.php create mode 100644 wp-content/plugins/mls-by-hansonxyz/includes/class-mls-query.php create mode 100644 wp-content/plugins/mls-by-hansonxyz/includes/class-mls-rate-limiter.php create mode 100644 wp-content/plugins/mls-by-hansonxyz/includes/class-mls-sync-engine.php create mode 100644 wp-content/plugins/mls-by-hansonxyz/mls-by-hansonxyz.php create mode 100644 wp-content/plugins/mls-by-hansonxyz/uninstall.php 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

+ +
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
API URL + + +

Set via MLSGRID_API_URL in wp-config.php

+ + + +
API Token + + +

Set via MLSGRID_ACCESS_TOKEN in wp-config.php

+ + + +
Originating System + +

MLS system identifier (e.g., northstar)

+
Auto Sync + +
Sync Interval + +
Sync Media + +
+ + +
+ +
+ +

Manual Actions

+

+ + +

+

+ + +

+ +
+ + +
+
+

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 +} +*/