3 * StatusNet, the distributed open-source microblogging tool
5 * Plugin to convert string locations to Geonames IDs and vice versa
9 * LICENCE: This program is free software: you can redistribute it and/or modify
10 * it under the terms of the GNU Affero General Public License as published by
11 * the Free Software Foundation, either version 3 of the License, or
12 * (at your option) any later version.
14 * This program is distributed in the hope that it will be useful,
15 * but WITHOUT ANY WARRANTY; without even the implied warranty of
16 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17 * GNU Affero General Public License for more details.
19 * You should have received a copy of the GNU Affero General Public License
20 * along with this program. If not, see <http://www.gnu.org/licenses/>.
24 * @author Evan Prodromou <evan@status.net>
25 * @copyright 2009 StatusNet Inc.
26 * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0
27 * @link http://status.net/
30 if (!defined('STATUSNET')) {
35 * Plugin to convert string locations to Geonames IDs and vice versa
37 * This handles most of the events that Location class emits. It uses
38 * the geonames.org Web service to convert names like 'Montreal, Quebec, Canada'
39 * into IDs and lat/lon pairs.
43 * @author Evan Prodromou <evan@status.net>
44 * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0
45 * @link http://status.net/
49 class GeonamesPlugin extends Plugin
51 const PLUGIN_VERSION = '2.0.0';
53 const LOCATION_NS = 1;
55 public $host = 'ws.geonames.org';
56 public $username = null;
58 public $expiry = 7776000; // 90-day expiry
59 public $timeout = 2; // Web service timeout in seconds.
60 public $timeoutWindow = 60; // Further lookups in this process will be disabled for N seconds after a timeout.
61 public $cachePrefix = null; // Optional shared memcache prefix override
62 // to share lookups between local instances.
64 protected $lastTimeout = null; // timestamp of last web service timeout
67 * convert a name into a Location object
69 * @param string $name Name to convert
70 * @param string $language ISO code for anguage the name is in
71 * @param Location &$location Location object (may be null)
73 * @return boolean whether to continue (results in $location)
75 function onLocationFromName($name, $language, &$location)
77 $loc = $this->getCache(array('name' => $name,
78 'language' => $language));
86 $geonames = $this->getGeonames('search',
91 } catch (Exception $e) {
92 $this->log(LOG_WARNING, "Error for $name: " . $e->getMessage());
96 if (count($geonames) == 0) {
98 $this->setCache(array('name' => $name,
99 'language' => $language),
106 $location = new Location();
108 $location->lat = $this->canonical($n->lat);
109 $location->lon = $this->canonical($n->lng);
110 $location->names[$language] = (string)$n->name;
111 $location->location_id = (string)$n->geonameId;
112 $location->location_ns = self::LOCATION_NS;
114 $this->setCache(array('name' => $name,
115 'language' => $language),
118 // handled, don't continue processing!
123 * convert an id into a Location object
125 * @param string $id Name to convert
126 * @param string $ns Name to convert
127 * @param string $language ISO code for language for results
128 * @param Location &$location Location object (may be null)
130 * @return boolean whether to continue (results in $location)
132 function onLocationFromId($id, $ns, $language, &$location)
134 if ($ns != self::LOCATION_NS) {
135 // It's not one of our IDs... keep processing
139 $loc = $this->getCache(array('id' => $id));
141 if ($loc !== false) {
147 $geonames = $this->getGeonames('hierarchy',
148 array('geonameId' => $id,
149 'lang' => $language));
150 } catch (Exception $e) {
151 $this->log(LOG_WARNING, "Error for ID $id: " . $e->getMessage());
157 foreach ($geonames as $level) {
158 if (in_array($level->fcode, array('PCLI', 'ADM1', 'PPL'))) {
159 $parts[] = (string)$level->name;
163 $last = $geonames[count($geonames)-1];
165 if (!in_array($level->fcode, array('PCLI', 'ADM1', 'PPL'))) {
166 $parts[] = (string)$last->name;
169 $location = new Location();
171 $location->location_id = (string)$last->geonameId;
172 $location->location_ns = self::LOCATION_NS;
173 $location->lat = $this->canonical($last->lat);
174 $location->lon = $this->canonical($last->lng);
176 $location->names[$language] = implode(', ', array_reverse($parts));
178 $this->setCache(array('id' => (string)$last->geonameId),
181 // We're responsible for this namespace; nobody else
188 * convert a lat/lon pair into a Location object
190 * Given a lat/lon, we try to find a Location that's around
191 * it or nearby. We prefer populated places (cities, towns, villages).
193 * @param string $lat Latitude
194 * @param string $lon Longitude
195 * @param string $language ISO code for language for results
196 * @param Location &$location Location object (may be null)
198 * @return boolean whether to continue (results in $location)
200 function onLocationFromLatLon($lat, $lon, $language, &$location)
202 // Make sure they're canonical
204 $lat = $this->canonical($lat);
205 $lon = $this->canonical($lon);
207 $loc = $this->getCache(array('lat' => $lat,
210 if ($loc !== false) {
216 $geonames = $this->getGeonames('findNearbyPlaceName',
219 'lang' => $language));
220 } catch (Exception $e) {
221 $this->log(LOG_WARNING, "Error for coords $lat, $lon: " . $e->getMessage());
225 if (count($geonames) == 0) {
227 $this->setCache(array('lat' => $lat,
237 $location = new Location();
239 $parts[] = (string)$n->name;
241 if (!empty($n->adminName1)) {
242 $parts[] = (string)$n->adminName1;
245 if (!empty($n->countryName)) {
246 $parts[] = (string)$n->countryName;
249 $location->location_id = (string)$n->geonameId;
250 $location->location_ns = self::LOCATION_NS;
251 $location->lat = $this->canonical($n->lat);
252 $location->lon = $this->canonical($n->lng);
254 $location->names[$language] = implode(', ', $parts);
256 $this->setCache(array('lat' => $lat,
260 // Success! We handled it, so no further processing
266 * Human-readable name for a location
268 * Given a location, we try to retrieve a human-readable name
269 * in the target language.
271 * @param Location $location Location to get the name for
272 * @param string $language ISO code for language to find name in
273 * @param string &$name Place to put the name
275 * @return boolean whether to continue
277 function onLocationNameLanguage($location, $language, &$name)
279 if ($location->location_ns != self::LOCATION_NS) {
280 // It's not one of our IDs... keep processing
284 $id = $location->location_id;
286 $n = $this->getCache(array('id' => $id,
287 'language' => $language));
295 $geonames = $this->getGeonames('hierarchy',
296 array('geonameId' => $id,
297 'lang' => $language));
298 } catch (Exception $e) {
299 $this->log(LOG_WARNING, "Error for ID $id: " . $e->getMessage());
303 if (count($geonames) == 0) {
304 $this->setCache(array('id' => $id,
305 'language' => $language),
312 foreach ($geonames as $level) {
313 if (in_array($level->fcode, array('PCLI', 'ADM1', 'PPL'))) {
314 $parts[] = (string)$level->name;
318 $last = $geonames[count($geonames)-1];
320 if (!in_array($level->fcode, array('PCLI', 'ADM1', 'PPL'))) {
321 $parts[] = (string)$last->name;
325 $name = implode(', ', array_reverse($parts));
326 $this->setCache(array('id' => $id,
327 'language' => $language),
335 * Human-readable URL for a location
337 * Given a location, we try to retrieve a geonames.org URL.
339 * @param Location $location Location to get the url for
340 * @param string &$url Place to put the url
342 * @return boolean whether to continue
344 function onLocationUrl($location, &$url)
346 if ($location->location_ns != self::LOCATION_NS) {
347 // It's not one of our IDs... keep processing
351 $url = 'http://www.geonames.org/' . $location->location_id;
353 // it's been filled, so don't process further.
358 * Machine-readable name for a location
360 * Given a location, we try to retrieve a geonames.org URL.
362 * @param Location $location Location to get the url for
363 * @param string &$url Place to put the url
365 * @return boolean whether to continue
367 function onLocationRdfUrl($location, &$url)
369 if ($location->location_ns != self::LOCATION_NS) {
370 // It's not one of our IDs... keep processing
374 $url = 'http://sws.geonames.org/' . $location->location_id . '/';
376 // it's been filled, so don't process further.
380 function getCache($attrs)
382 $c = Cache::instance();
388 $key = $this->cacheKey($attrs);
390 $value = $c->get($key);
395 function setCache($attrs, $loc)
397 $c = Cache::instance();
403 $key = $this->cacheKey($attrs);
405 $result = $c->set($key, $loc, 0, time() + $this->expiry);
410 function cacheKey($attrs)
413 implode(',', array_keys($attrs)) . ':'.
414 Cache::keyize(implode(',', array_values($attrs)));
415 if ($this->cachePrefix) {
416 return $this->cachePrefix . ':' . $key;
418 return Cache::key($key);
422 function wsUrl($method, $params)
424 if (!empty($this->username)) {
425 $params['username'] = $this->username;
428 if (!empty($this->token)) {
429 $params['token'] = $this->token;
432 $str = http_build_query($params, null, '&');
434 return 'http://'.$this->host.'/'.$method.'?'.$str;
437 function getGeonames($method, $params)
439 if ($this->lastTimeout && (time() - $this->lastTimeout < $this->timeoutWindow)) {
440 // TRANS: Exception thrown when a geo names service is not used because of a recent timeout.
441 throw new Exception(_m('Skipping due to recent web service timeout.'));
444 $client = HTTPClient::start();
445 $client->setConfig('connect_timeout', $this->timeout);
446 $client->setConfig('timeout', $this->timeout);
449 $result = $client->get($this->wsUrl($method, $params));
450 } catch (Exception $e) {
451 common_log(LOG_ERR, __METHOD__ . ": " . $e->getMessage());
452 $this->lastTimeout = time();
456 if (!$result->isOk()) {
457 // TRANS: Exception thrown when a geo names service does not return an expected response.
458 // TRANS: %s is an HTTP error code.
459 throw new Exception(sprintf(_m('HTTP error code %s.'),$result->getStatus()));
462 $body = $result->getBody();
465 // TRANS: Exception thrown when a geo names service returns an empty body.
466 throw new Exception(_m('Empty HTTP body in response.'));
469 // This will throw an exception if the XML is mal-formed
471 $document = new SimpleXMLElement($body);
473 // No children, usually no results
475 $children = $document->children();
477 if (count($children) == 0) {
481 if (isset($document->status)) {
482 // TRANS: Exception thrown when a geo names service return a specific error number and error text.
483 // TRANS: %1$s is an error code, %2$s is an error message.
484 throw new Exception(sprintf(_m('Error #%1$s ("%2$s").'),$document->status['value'],$document->status['message']));
487 // Array of elements, >0 elements
489 return $document->geoname;
492 function onPluginVersion(array &$versions)
494 $versions[] = array('name' => 'Geonames',
495 'version' => self::PLUGIN_VERSION,
496 'author' => 'Evan Prodromou',
497 'homepage' => 'https://git.gnu.io/gnu/gnu-social/tree/master/plugins/Geonames',
499 // TRANS: Plugin description.
500 _m('Uses <a href="http://geonames.org/">Geonames</a> service to get human-readable '.
501 'names for locations based on user-provided lat/long pairs.'));
505 function canonical($coord)
507 $coord = rtrim($coord, "0");
508 $coord = rtrim($coord, ".");