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); } /** * Get media log table name */ public function media_log_table() { return $this->get_table_name(MLS_TABLE_MEDIA_LOG); } /** * Get geo cities table name */ public function geo_cities_table() { return $this->get_table_name(MLS_TABLE_GEO_CITIES); } /** * Get geo zipcodes table name */ public function geo_zipcodes_table() { return $this->get_table_name(MLS_TABLE_GEO_ZIPCODES); } /** * 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, is_homeproz TINYINT(1) NOT NULL DEFAULT 0, 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, retry_after DATETIME DEFAULT NULL, queued_at DATETIME DEFAULT NULL, download_status VARCHAR(20) DEFAULT 'pending', 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), KEY download_status (download_status), KEY retry_after (retry_after), KEY queued_at (queued_at) ) {$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); // Media download log table $table_media_log = $wpdb->prefix . MLS_TABLE_MEDIA_LOG; $sql_media_log = "CREATE TABLE {$table_media_log} ( id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, media_id BIGINT(20) UNSIGNED NOT NULL, listing_key VARCHAR(50) NOT NULL, media_key VARCHAR(100) NOT NULL, action VARCHAR(30) NOT NULL, status_code INT(5) DEFAULT NULL, response_time_ms INT(11) DEFAULT NULL, error_message TEXT DEFAULT NULL, url VARCHAR(1000) DEFAULT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY media_id (media_id), KEY listing_key (listing_key), KEY action (action), KEY created_at (created_at) ) {$charset_collate};"; dbDelta($sql_media_log); // Geo cities table $table_geo_cities = $wpdb->prefix . MLS_TABLE_GEO_CITIES; $sql_geo_cities = "CREATE TABLE {$table_geo_cities} ( id INT(11) UNSIGNED NOT NULL AUTO_INCREMENT, state_code VARCHAR(2) NOT NULL, state_name VARCHAR(50) NOT NULL, city VARCHAR(100) NOT NULL, county VARCHAR(100) DEFAULT NULL, latitude DECIMAL(10,6) NOT NULL, longitude DECIMAL(10,6) NOT NULL, PRIMARY KEY (id), KEY state_code (state_code), KEY city (city), KEY state_city (state_code, city), KEY lat_lng (latitude, longitude) ) {$charset_collate};"; dbDelta($sql_geo_cities); // Geo zipcodes table $table_geo_zipcodes = $wpdb->prefix . MLS_TABLE_GEO_ZIPCODES; $sql_geo_zipcodes = "CREATE TABLE {$table_geo_zipcodes} ( id INT(11) UNSIGNED NOT NULL AUTO_INCREMENT, zipcode VARCHAR(10) NOT NULL, latitude DECIMAL(10,6) NOT NULL, longitude DECIMAL(10,6) NOT NULL, PRIMARY KEY (id), UNIQUE KEY zipcode (zipcode), KEY lat_lng (latitude, longitude) ) {$charset_collate};"; dbDelta($sql_geo_zipcodes); // Run index migrations self::run_index_migrations(); } /** * Run index migrations that dbDelta cannot handle * * dbDelta can create tables and add columns, but cannot add indexes * to existing tables. This method handles incremental index additions. */ public static function run_index_migrations() { global $wpdb; $current_schema = (int) get_option('mls_schema_version', 1); // Migration to schema version 2: Add search and geo indexes if ($current_schema < 2) { $table_properties = $wpdb->prefix . MLS_TABLE_PROPERTIES; $table_media = $wpdb->prefix . MLS_TABLE_MEDIA; // Check and add indexes only if they don't exist $existing_indexes = self::get_existing_indexes($table_properties); // Geospatial indexes for bounding box queries if (!isset($existing_indexes['idx_latitude'])) { $wpdb->query("ALTER TABLE {$table_properties} ADD INDEX idx_latitude (latitude)"); } if (!isset($existing_indexes['idx_longitude'])) { $wpdb->query("ALTER TABLE {$table_properties} ADD INDEX idx_longitude (longitude)"); } // Composite index for common search pattern (status + city + price) if (!isset($existing_indexes['idx_status_city_price'])) { $wpdb->query("ALTER TABLE {$table_properties} ADD INDEX idx_status_city_price (standard_status, city, list_price)"); } // Composite index for status + property_type searches if (!isset($existing_indexes['idx_status_type'])) { $wpdb->query("ALTER TABLE {$table_properties} ADD INDEX idx_status_type (standard_status, property_type)"); } // Media table: composite index for listing + order (eliminates filesort) $media_indexes = self::get_existing_indexes($table_media); if (!isset($media_indexes['idx_listing_order'])) { $wpdb->query("ALTER TABLE {$table_media} ADD INDEX idx_listing_order (listing_key, media_order)"); } update_option('mls_schema_version', 2); } // Migration to schema version 3: Add is_homeproz column and index if ($current_schema < 3) { $table_properties = $wpdb->prefix . MLS_TABLE_PROPERTIES; // Check if column exists $column_exists = $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND COLUMN_NAME = 'is_homeproz'", DB_NAME, $table_properties )); if (!$column_exists) { $wpdb->query("ALTER TABLE {$table_properties} ADD COLUMN is_homeproz TINYINT(1) NOT NULL DEFAULT 0 AFTER list_office_name"); } // Add index if not exists $existing_indexes = self::get_existing_indexes($table_properties); if (!isset($existing_indexes['idx_is_homeproz'])) { $wpdb->query("ALTER TABLE {$table_properties} ADD INDEX idx_is_homeproz (is_homeproz)"); } // Update existing HomeProz listings if (defined('MLS_HOMEPROZ_OFFICE_ID')) { $wpdb->query($wpdb->prepare( "UPDATE {$table_properties} SET is_homeproz = 1 WHERE list_office_mls_id = %s", MLS_HOMEPROZ_OFFICE_ID )); } update_option('mls_schema_version', 3); } // Future migrations go here: // if ($current_schema < 4) { ... } } /** * Get existing indexes for a table * * @param string $table Full table name * @return array Index names as keys */ private static function get_existing_indexes($table) { global $wpdb; $indexes = array(); $results = $wpdb->get_results("SHOW INDEX FROM {$table}"); foreach ($results as $row) { $indexes[$row->Key_name] = true; } return $indexes; } /** * 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, MLS_TABLE_MEDIA_LOG, MLS_TABLE_GEO_CITIES, MLS_TABLE_GEO_ZIPCODES, ); 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; } /** * Import geo data from CSV files * Only imports if tables are empty * * @return array Import results with counts */ public static function import_geo_data() { global $wpdb; $results = array( 'cities_imported' => 0, 'zipcodes_imported' => 0, 'cities_skipped' => false, 'zipcodes_skipped' => false, ); $cities_table = $wpdb->prefix . MLS_TABLE_GEO_CITIES; $zipcodes_table = $wpdb->prefix . MLS_TABLE_GEO_ZIPCODES; // Check if tables already have data $cities_count = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$cities_table}"); $zipcodes_count = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$zipcodes_table}"); // Import cities if table is empty if ($cities_count === 0) { $cities_file = MLS_PLUGIN_DIR . 'data/us_cities.csv'; if (file_exists($cities_file)) { $results['cities_imported'] = self::import_cities_csv($cities_file, $cities_table); } } else { $results['cities_skipped'] = true; } // Import zipcodes if table is empty if ($zipcodes_count === 0) { $zipcodes_file = MLS_PLUGIN_DIR . 'data/us_zipcodes.csv'; if (file_exists($zipcodes_file)) { $results['zipcodes_imported'] = self::import_zipcodes_csv($zipcodes_file, $zipcodes_table); } } else { $results['zipcodes_skipped'] = true; } return $results; } /** * Import cities CSV file * CSV format: ID,STATE_CODE,STATE_NAME,CITY,COUNTY,LATITUDE,LONGITUDE * * @param string $file Path to CSV file * @param string $table Table name * @return int Number of rows imported */ private static function import_cities_csv($file, $table) { global $wpdb; $handle = fopen($file, 'r'); if (!$handle) { return 0; } // Skip header row fgetcsv($handle); $count = 0; $batch = array(); $batch_size = 500; while (($row = fgetcsv($handle)) !== false) { if (count($row) < 7) continue; // ID,STATE_CODE,STATE_NAME,CITY,COUNTY,LATITUDE,LONGITUDE $batch[] = $wpdb->prepare( "(%s, %s, %s, %s, %f, %f)", $row[1], // state_code $row[2], // state_name $row[3], // city $row[4], // county (float) $row[5], // latitude (float) $row[6] // longitude ); if (count($batch) >= $batch_size) { $values = implode(',', $batch); $wpdb->query("INSERT INTO {$table} (state_code, state_name, city, county, latitude, longitude) VALUES {$values}"); $count += count($batch); $batch = array(); } } // Insert remaining rows if (!empty($batch)) { $values = implode(',', $batch); $wpdb->query("INSERT INTO {$table} (state_code, state_name, city, county, latitude, longitude) VALUES {$values}"); $count += count($batch); } fclose($handle); return $count; } /** * Import zipcodes CSV file * CSV format: ZIP,LAT,LNG * * @param string $file Path to CSV file * @param string $table Table name * @return int Number of rows imported */ private static function import_zipcodes_csv($file, $table) { global $wpdb; $handle = fopen($file, 'r'); if (!$handle) { return 0; } // Skip header row fgetcsv($handle); $count = 0; $batch = array(); $batch_size = 500; while (($row = fgetcsv($handle)) !== false) { if (count($row) < 3) continue; // ZIP,LAT,LNG $batch[] = $wpdb->prepare( "(%s, %f, %f)", trim($row[0]), // zipcode (float) trim($row[1]), // latitude (float) trim($row[2]) // longitude ); if (count($batch) >= $batch_size) { $values = implode(',', $batch); $wpdb->query("INSERT INTO {$table} (zipcode, latitude, longitude) VALUES {$values}"); $count += count($batch); $batch = array(); } } // Insert remaining rows if (!empty($batch)) { $values = implode(',', $batch); $wpdb->query("INSERT INTO {$table} (zipcode, latitude, longitude) VALUES {$values}"); $count += count($batch); } fclose($handle); return $count; } }