<?php
declare(strict_types=1);
namespace MeinWeimar;
/**
* Server-side address resolution for editorial saves and the shared API.
*
* Public clients never contact a provider directly. The provider URL is an
* administrator-controlled setting, responses are normalized here, and
* editorial saves only resolve an address when its normalized fingerprint
* changed or a previous geocoder result needs refreshing.
*/
final class Geocoding
{
private const ADDRESS_HASH_META = '_mw_geo_address_hash';
private const CACHE_TTL = 2592000;
/**
* @return array{results: array<int, array{label:string,lat:float,lng:float,precision:string}>, provider:string}|\WP_Error
*/
public static function search(string $query, int $limit = 5): array|\WP_Error
{
$query = self::normalize_text($query);
$limit = max(1, min(10, $limit));
if (mb_strlen($query) < 2) {
return new \WP_Error(
'meinweimar_geocoding_query_invalid',
__('Die Adresssuche benötigt mindestens zwei Zeichen.', 'meinweimar-core'),
['status' => 400]
);
}
$geo = Settings::public_config()['geo'] ?? [];
$endpoint = trim((string) ($geo['geocoding_url'] ?? ''));
if ('' === $endpoint || empty($geo['capabilities']['geocoding'])) {
return new \WP_Error(
'meinweimar_geocoding_unavailable',
__('Die Adresssuche ist derzeit nicht konfiguriert.', 'meinweimar-core'),
['status' => 503]
);
}
$cache_key = 'mw_geocode_' . hash('sha256', strtolower($endpoint) . '|' . strtolower($query) . '|' . $limit);
$cached = get_transient($cache_key);
if (is_array($cached) && isset($cached['results'], $cached['provider'])) {
return $cached;
}
$url = add_query_arg(['q' => $query, 'limit' => $limit], $endpoint);
$contact = trim((string) ($geo['user_agent_contact'] ?? ''));
$user_agent = 'MeinWeimar/' . VERSION . ' (' . ($contact ?: home_url('/')) . ')';
$response = wp_safe_remote_get($url, [
'timeout' => 8,
'redirection' => 2,
'headers' => [
'Accept' => 'application/json',
'User-Agent' => $user_agent,
],
]);
if (is_wp_error($response)) {
return new \WP_Error(
'meinweimar_geocoding_failed',
__('Die Adresssuche konnte nicht erreicht werden.', 'meinweimar-core'),
['status' => 502]
);
}
$status = wp_remote_retrieve_response_code($response);
$decoded = json_decode(wp_remote_retrieve_body($response), true);
if ($status < 200 || $status >= 300 || !is_array($decoded)) {
return new \WP_Error(
'meinweimar_geocoding_invalid_response',
__('Der Geocoding-Dienst hat keine gültige Antwort geliefert.', 'meinweimar-core'),
['status' => 502]
);
}
$results = [];
foreach (self::candidate_rows($decoded) as $candidate) {
if (!is_array($candidate)) {
continue;
}
$properties = is_array($candidate['properties'] ?? null) ? $candidate['properties'] : [];
$geometry = is_array($candidate['geometry'] ?? null) ? $candidate['geometry'] : [];
$coordinates = is_array($geometry['coordinates'] ?? null) ? $geometry['coordinates'] : [];
$lat = $candidate['lat']
?? $candidate['latitude']
?? $geometry['lat']
?? ($coordinates[1] ?? null);
$lng = $candidate['lon']
?? $candidate['lng']
?? $candidate['longitude']
?? $geometry['lng']
?? ($coordinates[0] ?? null);
$label = $candidate['display_name']
?? $candidate['label']
?? $candidate['name']
?? $candidate['place_name']
?? $properties['display_name']
?? $properties['label']
?? $properties['name']
?? $properties['place_name']
?? '';
if (!is_numeric($lat) || !is_numeric($lng) || !is_string($label) || '' === trim($label)) {
continue;
}
$latitude = (float) $lat;
$longitude = (float) $lng;
if ($latitude < -90 || $latitude > 90 || $longitude < -180 || $longitude > 180) {
continue;
}
$precision = $candidate['precision']
?? $candidate['accuracy']
?? $candidate['type']
?? $properties['precision']
?? $properties['accuracy']
?? $properties['type']
?? '';
$results[] = [
'label' => sanitize_text_field($label),
'lat' => $latitude,
'lng' => $longitude,
'precision' => sanitize_key((string) $precision),
];
if (count($results) >= $limit) {
break;
}
}
$payload = [
'results' => $results,
'provider' => 'configured',
];
set_transient($cache_key, $payload, self::CACHE_TTL);
return $payload;
}
/**
* Accept a plain provider list as well as common wrapped or GeoJSON
* responses without coupling the public contract to one provider.
*
* @return array<int, mixed>
*/
private static function candidate_rows(array $decoded): array
{
if (isset($decoded[0]) && is_array($decoded[0])) {
return $decoded;
}
foreach (['results', 'features', 'data'] as $key) {
if (isset($decoded[$key]) && is_array($decoded[$key])) {
return $decoded[$key];
}
}
return [];
}
/**
* Resolve only after an editorial address changed. Existing manual,
* imported, or legacy coordinates remain authoritative and are never
* overwritten by a provider response.
*/
public static function sync_post(int $post_id): void
{
$address = self::address_for_post($post_id);
if ('' === $address) {
delete_post_meta($post_id, self::ADDRESS_HASH_META);
return;
}
$hash = hash('sha256', strtolower($address));
$previous_hash = (string) get_post_meta($post_id, self::ADDRESS_HASH_META, true);
if ($hash === $previous_hash) {
return;
}
$source = (string) get_post_meta($post_id, 'mw_geo_source', true);
$has_coordinates = self::has_coordinates($post_id);
if ($has_coordinates && in_array($source, ['manual', 'import', 'legacy', 'unknown', ''], true)) {
update_post_meta($post_id, self::ADDRESS_HASH_META, $hash);
return;
}
$resolved = self::search($address, 1);
if (is_wp_error($resolved) || empty($resolved['results'][0])) {
return;
}
$result = $resolved['results'][0];
$route = ContentTypes::route_for_post_type((string) get_post_type($post_id));
$latitude = FieldSchemas::sanitize_value('mw_geo_latitude', $result['lat'], $route);
$longitude = FieldSchemas::sanitize_value('mw_geo_longitude', $result['lng'], $route);
if ('' === (string) $latitude || '' === (string) $longitude) {
return;
}
update_post_meta($post_id, 'mw_geo_latitude', $latitude);
update_post_meta($post_id, 'mw_geo_longitude', $longitude);
update_post_meta($post_id, 'mw_geo_source', FieldSchemas::sanitize_value('mw_geo_source', 'geocoder', $route));
update_post_meta($post_id, 'mw_geo_precision', FieldSchemas::sanitize_value('mw_geo_precision', (string) ($result['precision'] ?: 'geocoded'), $route));
if ('' === (string) get_post_meta($post_id, 'mw_geo_privacy', true)) {
update_post_meta($post_id, 'mw_geo_privacy', FieldSchemas::sanitize_value('mw_geo_privacy', 'public', $route));
}
update_post_meta($post_id, self::ADDRESS_HASH_META, $hash);
}
private static function address_for_post(int $post_id): string
{
$parts = [];
foreach (['mw_address_line1', 'mw_address_line2', 'mw_postal_code', 'mw_locality', 'mw_region', 'mw_country'] as $key) {
$value = self::normalize_text((string) get_post_meta($post_id, $key, true));
if ('' !== $value) {
$parts[] = $value;
}
}
return implode(', ', $parts);
}
private static function has_coordinates(int $post_id): bool
{
$lat = get_post_meta($post_id, 'mw_geo_latitude', true);
$lng = get_post_meta($post_id, 'mw_geo_longitude', true);
return is_numeric($lat) && is_numeric($lng)
&& (float) $lat >= -90 && (float) $lat <= 90
&& (float) $lng >= -180 && (float) $lng <= 180;
}
private static function normalize_text(string $value): string
{
$value = sanitize_text_field($value);
return trim((string) preg_replace('/\s+/u', ' ', $value));
}
}
