diff --git a/README.MD b/README.MD index ec2e066..af34490 100644 --- a/README.MD +++ b/README.MD @@ -43,6 +43,12 @@ Google's Geocoding API does not support HTTP referrer restrictions. Make sure th #### API Errors When `devMode` is enabled, any errors returned by Google's API will show an exception so you can clearly see what's going wrong. With `devMode` disabled, any errors will be logged to Craft's `web.log`. + +### GeoCoding for Craft Address elements +Since Craft 5, Craft has a built-in element for Addresses, but no way to get coordinates for addresses out of the box. +When you install Easy Address Field, you can enable GeoCoding for Craft Address elements. This will add a `latitude` and `longitude` values to each Address element. +You can enable this feature in the plugin settings. + ## Template variables ### Printing address values @@ -60,6 +66,25 @@ field.longitude field.getDirectionsUrl() // get a directions link to the given address ```` + +## Custom GeoCoding services +Out of the box, the plugin comes with support for geocoding with OpenStreetMap's Nominatim service and Google. +If you'd like to use a different service, you can create a custom service by creating a new class that implements the `studioespresso\easyaddressfield\services\geocoders\BaseGeoCoder` interface. + +Once you created your geocoder, register it with the following event: + +````php +use studioespresso\easyaddressfield\events\RegisterGeocoderEvent; +use studioespresso\easyaddressfield\services\GeoLocationService; + +Event::on( + GeoLocationService::class, + GeoLocationService::EVENT_REGISTER_GEOCODERS, + function (RegisterGeocoderEvent $event) { + $event->geoCoders['your-service'] = YourGeoCoder::class; +}); +```` + ## Upgrading from Craft 4 to Craft 5 ### getDirectionsUrl() If you're using the ``getDirectionsUrl()`` function on `craft.address`, you'll now need to call the function on the field itself instead of the on the plugin's Twig variable. diff --git a/src/EasyAddressField.php b/src/EasyAddressField.php index 87abbd6..8f13ccc 100644 --- a/src/EasyAddressField.php +++ b/src/EasyAddressField.php @@ -6,19 +6,26 @@ namespace studioespresso\easyaddressfield; use Craft; +use craft\base\Element; use craft\base\Model; use craft\base\Plugin; +use craft\elements\Address; +use craft\events\ModelEvent; use craft\events\RegisterComponentTypesEvent; use craft\feedme\events\RegisterFeedMeFieldsEvent; +use craft\helpers\ElementHelper; use craft\helpers\UrlHelper; use craft\services\Fields; use craft\web\twig\variables\CraftVariable; use markhuot\CraftQL\Events\GetFieldSchema; +use studioespresso\easyaddressfield\events\RegisterGeocoderEvent; use studioespresso\easyaddressfield\fields\EasyAddressFieldFeedMe; use studioespresso\easyaddressfield\fields\EasyAddressFieldField; use studioespresso\easyaddressfield\models\EasyAddressFieldSettingsModel; use studioespresso\easyaddressfield\services\CountriesService; use studioespresso\easyaddressfield\services\FieldService; +use studioespresso\easyaddressfield\services\geocoders\GoogleGeoCoder; +use studioespresso\easyaddressfield\services\geocoders\NomanatimGeoCoder; use studioespresso\easyaddressfield\services\GeoLocationService; use studioespresso\easyaddressfield\web\twig\variables\AddressVariable; use yii\base\Event; @@ -62,6 +69,19 @@ public function init() $event->types[] = EasyAddressFieldField::class; }); + Event::on(Address::class, Element::EVENT_BEFORE_SAVE, function(ModelEvent $event) { + /* @var Address $element */ + $element = $event->sender; + if (ElementHelper::isDraftOrRevision($element)) { + return; + } + if ($this->getSettings()->enableGeoCodingForCraftElements) { + $event->sender = $this->geoLocation()->locateElement($element); + } + }); + + + // Register our twig functions Event::on(CraftVariable::class, CraftVariable::EVENT_INIT, function(Event $event) { $variable = $event->sender; @@ -93,6 +113,11 @@ public function init() $e->fields[] = EasyAddressFieldFeedMe::class; }); } + + Event::on(GeoLocationService::class, GeoLocationService::EVENT_REGISTER_GEOCODERS, function(RegisterGeocoderEvent $event) { + $event->geoCoders['nomanatim'] = NomanatimGeoCoder::class; + $event->geoCoders['google'] = GoogleGeoCoder::class; + }); } // Components @@ -116,13 +141,15 @@ protected function createSettingsModel(): Model */ protected function settingsHtml(): string { + $geoCoders = EasyAddressField::getInstance()->geoLocation->geoCoders; + $geoCoders = $geoCoders->map(function($item) { + return $item->name; + }); + return Craft::$app->getView()->renderTemplate( 'easy-address-field/_settings', [ - 'services' => [ - 'nominatim' => 'Nominatim', - 'google' => 'Google Maps', - ], + 'geoCoders' => $geoCoders->toArray(), 'settings' => $this->getSettings(), ] ); diff --git a/src/events/RegisterGeocoderEvent.php b/src/events/RegisterGeocoderEvent.php new file mode 100644 index 0000000..7b5bd6e --- /dev/null +++ b/src/events/RegisterGeocoderEvent.php @@ -0,0 +1,10 @@ +settings = EasyAddressField::getInstance()->getSettings(); - parent::init(); // TODO: Change the autogenerated stub + + $event = new RegisterGeocoderEvent(); + Event::trigger(self::class, self::EVENT_REGISTER_GEOCODERS, $event); + + $this->geoCoders = collect(array_merge($this->geoCoders, $event->geoCoders))->map(function($geoCoder) { + return \Craft::createObject($geoCoder); + }); + + parent::init(); } /** @@ -26,15 +35,11 @@ public function init(): void * * @return EasyAddressFieldModel */ - public function locate(EasyAddressFieldModel $model) + public function locate(EasyAddressFieldModel $model): EasyAddressFieldModel { try { if (!$model->latitude && !$model->longitude and strlen($model->toString()) >= 2) { - if ($this->settings->geoCodingService === 'google') { - $model = $this->geocodeGoogle($model); - } else { - $model = $this->geocodeOSM($model); - } + return $this->geoCoders[$this->settings->geoCodingService]->geocodeModel($model); } return $model; } catch (\Throwable $exception) { @@ -43,71 +48,20 @@ public function locate(EasyAddressFieldModel $model) } } - private function geocodeGoogle(EasyAddressFieldModel $model) + /** + * @param Address $element + * @return Address + */ + public function locateElement(Address $element): Address { - if (!$this->settings->googleApiKey) { - return $model; - } - - if (!$model->latitude && !$model->longitude and strlen($model->toString()) >= 2) { - $client = new Client(['base_uri' => 'https://maps.googleapis.com']); - $request = $client->request('GET', - 'maps/api/geocode/json?address=' . urlencode($model->toString()) . '&key=' . Craft::parseEnv($this->settings->googleApiKey) . '', - ['allow_redirects' => false] - ); - $json = Json::decodeIfJson($request->getBody()->getContents()); - - if ($json['status'] !== 'OK' && $json['error_message']) { - if (Craft::$app->getConfig()->general->devMode) { - throw new InvalidConfigException('Google API error: ' . $json['error_message']); - } - Craft::error($json['error_message'], 'easy-address-field'); - } - - if ($json['status'] === 'OK') { - if ($json['results'][0]['geometry']['location']) { - $model->latitude = $json['results'][0]['geometry']['location']['lat']; - $model->longitude = $json['results'][0]['geometry']['location']['lng']; - } + try { + if (!$element->latitude && !$element->longitude && $element->countryCode) { + return $this->geoCoders[$this->settings->geoCodingService]->geocodeElement($element); } + return $element; + } catch (\Throwable $exception) { + \Craft::error($exception->getMessage(), 'easy-address-field'); + return $element; } - - return $model; - } - - private function geocodeOSM(EasyAddressFieldModel $model) - { - // url encode the address - $url = "http://nominatim.openstreetmap.org/"; - $nominatim = new Nominatim($url); - $search = $nominatim->newSearch() - ->countryCode($model->country) - ->state($model->state ?? '') - ->city($model->city ?? '') - ->postalCode($model->postalCode ?? '') - ->street($model->street . ' ' . $model->street2) - ->limit(1) - ->polygon('geojson') - ->addressDetails(); - - $result = $nominatim->find($search); - if (empty($result)) { - return $model; - } - - if (isset($result[0]['lat']) && isset($result[0]['lon'])) { - $model->longitude = $result[0]['lon']; - $model->latitude = $result[0]['lat']; - } elseif (is_array($result[0]['geojson']['coordinates'][0]) && is_array($result[0]['geojson']['coordinates'][0][0])) { - $model->longitude = $result[0]['geojson']['coordinates'][0][0][0]; - $model->latitude = $result[0]['geojson']['coordinates'][0][0][1]; - } elseif (is_array($result[0]['geojson']['coordinates'][0])) { - $model->longitude = $result[0]['geojson']['coordinates'][0][0]; - $model->latitude = $result[0]['geojson']['coordinates'][0][1]; - } else { - $model->longitude = $result[0]['geojson']['coordinates'][0]; - $model->latitude = $result[0]['geojson']['coordinates'][1]; - } - return $model; } } diff --git a/src/services/geocoders/BaseGeoCoder.php b/src/services/geocoders/BaseGeoCoder.php new file mode 100644 index 0000000..a508c58 --- /dev/null +++ b/src/services/geocoders/BaseGeoCoder.php @@ -0,0 +1,36 @@ +settings = EasyAddressField::getInstance()->getSettings(); + parent::init(); + } + + /** + * This function is used to geocode the model from the EasyAddressField + * @param EasyAddressFieldModel $model + * @return mixed + */ + abstract public function geocodeModel(EasyAddressFieldModel $model): EasyAddressFieldModel; + + /** + * This function is used to geocode a Craft Address element + * @param Address $element + * @return mixed + */ + abstract public function geocodeElement(Address $element): Address; +} diff --git a/src/services/geocoders/GoogleGeoCoder.php b/src/services/geocoders/GoogleGeoCoder.php new file mode 100644 index 0000000..2d53c40 --- /dev/null +++ b/src/services/geocoders/GoogleGeoCoder.php @@ -0,0 +1,102 @@ +settings->googleApiKey) { + return $model; + } + + if (!$model->latitude && !$model->longitude and strlen($model->toString()) >= 2) { + $result = $this->makeApiCall($model->toString()); + if ($result === false) { + return $model; + } + + if ($result) { + $model->latitude = $result['latitude']; + $model->longitude = $result['longitude']; + return $model; + } + } + + return $model; + } + + /** + * This function is used to geocode a Craft Address element + * @param Address $element + * @return mixed + */ + public function geocodeElement(Address $element): Address + { + $fields = [ + $element->addressLine1, + $element->addressLine2, + $element->addressLine3, + $element->postalCode, + $element->locality, + $element->countryCode, + ]; + $fields = array_filter($fields); + $data = implode('+', $fields); + $result = $this->makeApiCall($data); + if ($result) { + $element->setAttributes([ + 'longitude' => $result['longitude'], + 'latitude' => $result['latitude'], + ]); + } + return $element; + } + + private function makeApiCall($data): array|false + { + $client = new Client(['base_uri' => 'https://maps.googleapis.com']); + $request = $client->request('GET', + 'maps/api/geocode/json?address=' . urlencode($data) . '&key=' . Craft::parseEnv($this->settings->googleApiKey) . '', + ['allow_redirects' => false] + ); + $result = Json::decodeIfJson($request->getBody()->getContents()); + + if ($result['status'] !== 'OK' && $result['error_message']) { + if (Craft::$app->getConfig()->general->devMode) { + throw new InvalidConfigException('Google API error: ' . $result['error_message']); + } + Craft::error($result['error_message'], 'easy-address-field'); + } + if ($result['status'] === 'OK') { + if ($result['status'] === 'OK') { + if ($result['results'][0]['geometry']['location']) { + $data = [ + 'latitude' => $result['results'][0]['geometry']['location']['lat'], + 'longitude' => $result['results'][0]['geometry']['location']['lng'], + ]; + return $data; + } + } + } + return false; + } +} diff --git a/src/services/geocoders/NomanatimGeoCoder.php b/src/services/geocoders/NomanatimGeoCoder.php new file mode 100644 index 0000000..ab37ac8 --- /dev/null +++ b/src/services/geocoders/NomanatimGeoCoder.php @@ -0,0 +1,121 @@ + $model->countryCode, + 'state' => $model->state, + 'city' => $model->city, + 'postalCode' => $model->postalCode, + 'street' => $model->street, + 'street2' => $model->street2, + ]; + + $result = $this->makeApiCall($data); + if (empty($result)) { + return $model; + } + + $model->latitude = $result['latitude']; + $model->longitude = $result['longitude']; + + return $model; + } + + /** + * This function is used to geocode a Craft Address element + * @param Address $element + * @return mixed + */ + public function geocodeElement(Address $element): Address + { + $data = [ + 'country' => $element->countryCode, + 'state' => $element->state, + 'city' => '', + 'postalCode' => $element->postalCode, + 'street' => $element->addressLine1, + 'street2' => $element->addressLine2 . ' ' . $element->addressLine3, + ]; + + $result = $this->makeApiCall($data); + if (empty($result)) { + return $element; + } + + if ($result) { + $element->setAttributes([ + 'longitude' => $result['longitude'], + 'latitude' => $result['latitude'], + ]); + } + + return $element; + } + + private function makeApiCall($data): array|false + { + // url encode the address + $url = "http://nominatim.openstreetmap.org/"; + $nominatim = new Nominatim($url); + $search = $nominatim->newSearch() + ->countryCode($data['country']) + ->state($data['state'] ?? '') + ->city($data['city'] ?? '') + ->postalCode($data['postalCode'] ?? '') + ->street($data['street'] . ' ' . $data['street2']) + ->limit(1) + ->polygon('geojson') + ->addressDetails(); + + $result = $nominatim->find($search); + if (empty($result)) { + return []; + } + + if (isset($result[0]['lat']) && isset($result[0]['lon'])) { + return [ + 'latitude' => $result[0]['lat'], + 'longitude' => $result[0]['lon'], + ]; + } + + if (is_array($result[0]['geojson']['coordinates'][0]) && is_array($result[0]['geojson']['coordinates'][0][0])) { + return [ + 'latitude' => $result[0]['geojson']['coordinates'][0][0][1], + 'longitude' => $result[0]['geojson']['coordinates'][0][0][0], + ]; + } + + if (is_array($result[0]['geojson']['coordinates'][0])) { + return [ + 'latitude' => $result[0]['geojson']['coordinates'][0][1], + 'longitude' => $result[0]['geojson']['coordinates'][0][0], + ]; + } + + return [ + 'latitude' => $result[0]['geojson']['coordinates'][1], + 'longitude' => $result[0]['geojson']['coordinates'][0], + ]; + } +} diff --git a/src/templates/_settings.twig b/src/templates/_settings.twig index 53c11fd..2ea6903 100644 --- a/src/templates/_settings.twig +++ b/src/templates/_settings.twig @@ -1,17 +1,16 @@ {% import "_includes/forms" as forms %} + {{ forms.selectField({ label: 'Geocoding service'|t, instructions: 'The geocoding service used, defaults to Nomanatim', id: 'geoCodingService', name: 'geoCodingService', value: settings['geoCodingService'], - options: services, + options: geoCoders, errors: settings.getErrors('geoCodingService'), }) }} - - {{ forms.autosuggestField({ label: 'Google API Key for geocoding requests'|t, instructions: 'Only needed if you are using the geocoding functions in your templates'|t, @@ -23,3 +22,11 @@ suggestAliases: true, }) }} +
+ +{{ forms.lightswitchField({ + label: 'Enable geocoding for Craft Address fields/elements'|t('easy-address-field'), + name: 'enableGeoCodingForCraftElements', + on: settings['enableGeoCodingForCraftElements'] +}) }} +