load_api_key(); $this->init_hooks(); } /** * Load API key from secure storage */ private function load_api_key(): void { if (class_exists('HVAC_Secure_Storage')) { $this->api_key = HVAC_Secure_Storage::get_credential('hvac_google_maps_api_key', ''); } } /** * Initialize hooks */ private function init_hooks(): void { // Auto-geocode new venues on save add_action('save_post_tribe_venue', [$this, 'maybe_geocode_venue'], 20, 2); // Register geocoding action for async processing add_action('hvac_geocode_venue', [$this, 'geocode_venue']); // Admin action for batch geocoding add_action('wp_ajax_hvac_batch_geocode_venues', [$this, 'ajax_batch_geocode']); // Clear venue coordinates when address changes add_action('updated_post_meta', [$this, 'on_venue_meta_update'], 10, 4); } /** * Maybe geocode venue on save * * @param int $venue_id Venue post ID * @param WP_Post $post Post object */ public function maybe_geocode_venue(int $venue_id, WP_Post $post): void { // Skip autosaves and revisions if (wp_is_post_autosave($venue_id) || wp_is_post_revision($venue_id)) { return; } // Check if coordinates already exist $has_coords = $this->venue_has_coordinates($venue_id); if (!$has_coords) { // Schedule geocoding to avoid blocking save wp_schedule_single_event(time() + 5, 'hvac_geocode_venue', [$venue_id]); } } /** * Check if venue already has coordinates * * @param int $venue_id Venue post ID * @return bool */ public function venue_has_coordinates(int $venue_id): bool { // Check custom coordinates $lat = get_post_meta($venue_id, 'venue_latitude', true); $lng = get_post_meta($venue_id, 'venue_longitude', true); if (!empty($lat) && !empty($lng)) { return true; } // Check TEC built-in coordinates $tec_lat = get_post_meta($venue_id, '_VenueLat', true); $tec_lng = get_post_meta($venue_id, '_VenueLng', true); return !empty($tec_lat) && !empty($tec_lng); } /** * Geocode a venue * * @param int $venue_id Venue post ID * @return bool Success */ public function geocode_venue(int $venue_id): bool { // Rate limiting check if (!$this->check_rate_limit()) { // Reschedule wp_schedule_single_event(time() + 60, 'hvac_geocode_venue', [$venue_id]); return false; } // Build address $address = $this->build_venue_address($venue_id); if (empty($address)) { update_post_meta($venue_id, '_venue_geocoding_status', 'no_address'); return false; } update_post_meta($venue_id, '_venue_geocoding_attempt', time()); // Check cache first $cache_key = 'venue_geo_' . md5($address); $cached = get_transient($cache_key); if ($cached !== false) { return $this->save_coordinates($venue_id, $cached); } // Make API request $result = $this->geocode_address($address); if ($result && isset($result['lat'], $result['lng'])) { // Cache result set_transient($cache_key, $result, $this->cache_duration); return $this->save_coordinates($venue_id, $result); } // Handle failure $this->handle_geocoding_failure($venue_id, $result); return false; } /** * Build venue address string from TEC meta * * @param int $venue_id Venue post ID * @return string Full address */ private function build_venue_address(int $venue_id): string { $parts = []; $address = get_post_meta($venue_id, '_VenueAddress', true); $city = get_post_meta($venue_id, '_VenueCity', true); $state = get_post_meta($venue_id, '_VenueStateProvince', true) ?: get_post_meta($venue_id, '_VenueState', true); $zip = get_post_meta($venue_id, '_VenueZip', true); $country = get_post_meta($venue_id, '_VenueCountry', true); if (!empty($address)) { $parts[] = $address; } if (!empty($city)) { $parts[] = $city; } if (!empty($state)) { $parts[] = $state; } if (!empty($zip)) { $parts[] = $zip; } if (!empty($country)) { $parts[] = $country; } return implode(', ', $parts); } /** * Make geocoding API request * * @param string $address Address to geocode * @return array|null Result with lat/lng or null on failure */ private function geocode_address(string $address): ?array { if (empty($this->api_key)) { return ['error' => 'No API key configured']; } $url = 'https://maps.googleapis.com/maps/api/geocode/json'; $params = [ 'address' => $address, 'key' => $this->api_key, 'components' => 'country:US|country:CA' // Restrict to North America ]; $response = wp_remote_get($url . '?' . http_build_query($params), [ 'timeout' => 10, 'user-agent' => 'HVAC Training Directory/1.0' ]); if (is_wp_error($response)) { return ['error' => $response->get_error_message()]; } $body = wp_remote_retrieve_body($response); $data = json_decode($body, true); if (!$data || $data['status'] !== 'OK' || empty($data['results'])) { return ['error' => $data['status'] ?? 'Unknown error']; } $location = $data['results'][0]['geometry']['location']; return [ 'lat' => $location['lat'], 'lng' => $location['lng'], 'formatted_address' => $data['results'][0]['formatted_address'], 'place_id' => $data['results'][0]['place_id'] ?? '' ]; } /** * Save coordinates to venue * * @param int $venue_id Venue post ID * @param array $result Geocoding result * @return bool Success */ private function save_coordinates(int $venue_id, array $result): bool { if (!isset($result['lat']) || !isset($result['lng'])) { return false; } // Save to our custom meta update_post_meta($venue_id, 'venue_latitude', $result['lat']); update_post_meta($venue_id, 'venue_longitude', $result['lng']); // Also update TEC's meta for compatibility update_post_meta($venue_id, '_VenueLat', $result['lat']); update_post_meta($venue_id, '_VenueLng', $result['lng']); if (!empty($result['formatted_address'])) { update_post_meta($venue_id, '_venue_formatted_address', $result['formatted_address']); } update_post_meta($venue_id, '_venue_geocoding_status', 'success'); update_post_meta($venue_id, '_venue_geocoding_date', time()); return true; } /** * Handle geocoding failure * * @param int $venue_id Venue post ID * @param array|null $result Error result */ private function handle_geocoding_failure(int $venue_id, ?array $result): void { $error = $result['error'] ?? 'Unknown error'; update_post_meta($venue_id, '_venue_geocoding_status', 'failed'); update_post_meta($venue_id, '_venue_geocoding_error', $error); // Handle specific errors switch ($error) { case 'OVER_QUERY_LIMIT': // Retry in 1 hour wp_schedule_single_event(time() + HOUR_IN_SECONDS, 'hvac_geocode_venue', [$venue_id]); break; case 'ZERO_RESULTS': // Try fallback with less specific address $this->try_fallback_geocoding($venue_id); break; default: // Log error if (defined('WP_DEBUG') && WP_DEBUG) { error_log("HVAC Venue Geocoding failed for venue {$venue_id}: {$error}"); } } } /** * Try fallback geocoding with city/state only * * @param int $venue_id Venue post ID */ private function try_fallback_geocoding(int $venue_id): void { $city = get_post_meta($venue_id, '_VenueCity', true); $state = get_post_meta($venue_id, '_VenueStateProvince', true) ?: get_post_meta($venue_id, '_VenueState', true); $country = get_post_meta($venue_id, '_VenueCountry', true) ?: 'USA'; if (empty($city) && empty($state)) { return; } $address = implode(', ', array_filter([$city, $state, $country])); $result = $this->geocode_address($address); if ($result && isset($result['lat'], $result['lng'])) { $this->save_coordinates($venue_id, $result); update_post_meta($venue_id, '_venue_geocoding_status', 'success_fallback'); } } /** * Check rate limiting * * @return bool Can make request */ private function check_rate_limit(): bool { $rate_key = 'hvac_venue_geocoding_rate_' . gmdate('Y-m-d-H-i'); $current = get_transient($rate_key) ?: 0; if ($current >= $this->rate_limit) { return false; } set_transient($rate_key, $current + 1, 60); return true; } /** * Clear coordinates when venue address changes * * @param int $meta_id Meta ID * @param int $post_id Post ID * @param string $meta_key Meta key * @param mixed $meta_value Meta value */ public function on_venue_meta_update(int $meta_id, int $post_id, string $meta_key, $meta_value): void { if (get_post_type($post_id) !== 'tribe_venue') { return; } $address_fields = ['_VenueAddress', '_VenueCity', '_VenueStateProvince', '_VenueState', '_VenueZip']; if (in_array($meta_key, $address_fields, true)) { // Address changed - clear coordinates to force re-geocoding delete_post_meta($post_id, 'venue_latitude'); delete_post_meta($post_id, 'venue_longitude'); delete_post_meta($post_id, '_venue_geocoding_status'); // Schedule re-geocoding wp_schedule_single_event(time() + 5, 'hvac_geocode_venue', [$post_id]); } } /** * AJAX handler for batch geocoding venues */ public function ajax_batch_geocode(): void { // Check permissions if (!current_user_can('manage_options')) { wp_send_json_error(['message' => 'Permission denied']); return; } // Verify nonce if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'hvac_batch_geocode_venues')) { wp_send_json_error(['message' => 'Invalid security token']); return; } $limit = absint($_POST['limit'] ?? 10); $result = $this->batch_geocode_venues($limit); wp_send_json_success($result); } /** * Batch geocode venues without coordinates * * @param int $limit Maximum venues to process * @return array Results */ public function batch_geocode_venues(int $limit = 10): array { $venues = get_posts([ 'post_type' => 'tribe_venue', 'posts_per_page' => $limit, 'post_status' => 'publish', 'meta_query' => [ 'relation' => 'AND', [ 'key' => 'venue_latitude', 'compare' => 'NOT EXISTS' ], [ 'key' => '_VenueLat', 'compare' => 'NOT EXISTS' ] ] ]); $results = [ 'processed' => 0, 'success' => 0, 'failed' => 0, 'remaining' => 0 ]; foreach ($venues as $venue) { if (!$this->check_rate_limit()) { break; } $results['processed']++; if ($this->geocode_venue($venue->ID)) { $results['success']++; } else { $results['failed']++; } } // Count remaining $remaining_query = new WP_Query([ 'post_type' => 'tribe_venue', 'posts_per_page' => 1, 'post_status' => 'publish', 'fields' => 'ids', 'meta_query' => [ 'relation' => 'AND', [ 'key' => 'venue_latitude', 'compare' => 'NOT EXISTS' ], [ 'key' => '_VenueLat', 'compare' => 'NOT EXISTS' ] ] ]); $results['remaining'] = $remaining_query->found_posts; return $results; } /** * Get geocoding status for a venue * * @param int $venue_id Venue post ID * @return array Status info */ public function get_geocoding_status(int $venue_id): array { return [ 'has_coordinates' => $this->venue_has_coordinates($venue_id), 'latitude' => get_post_meta($venue_id, 'venue_latitude', true) ?: get_post_meta($venue_id, '_VenueLat', true), 'longitude' => get_post_meta($venue_id, 'venue_longitude', true) ?: get_post_meta($venue_id, '_VenueLng', true), 'status' => get_post_meta($venue_id, '_venue_geocoding_status', true), 'error' => get_post_meta($venue_id, '_venue_geocoding_error', true), 'last_attempt' => get_post_meta($venue_id, '_venue_geocoding_attempt', true), 'geocoded_date' => get_post_meta($venue_id, '_venue_geocoding_date', true) ]; } /** * Clear coordinates for a venue * * @param int $venue_id Venue post ID */ public function clear_coordinates(int $venue_id): void { delete_post_meta($venue_id, 'venue_latitude'); delete_post_meta($venue_id, 'venue_longitude'); delete_post_meta($venue_id, '_VenueLat'); delete_post_meta($venue_id, '_VenueLng'); delete_post_meta($venue_id, '_venue_formatted_address'); delete_post_meta($venue_id, '_venue_geocoding_status'); delete_post_meta($venue_id, '_venue_geocoding_error'); delete_post_meta($venue_id, '_venue_geocoding_date'); } }