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 LOCATION_NS = 1;
53 public $host = 'ws.geonames.org';
54 public $username = null;
56 public $expiry = 7776000; // 90-day expiry
57 public $timeout = 2; // Web service timeout in seconds.
58 public $timeoutWindow = 60; // Further lookups in this process will be disabled for N seconds after a timeout.
59 public $cachePrefix = null; // Optional shared memcache prefix override
60 // to share lookups between local instances.
62 protected $lastTimeout = null; // timestamp of last web service timeout
65 * convert a name into a Location object
67 * @param string $name Name to convert
68 * @param string $language ISO code for anguage the name is in
69 * @param Location &$location Location object (may be null)
71 * @return boolean whether to continue (results in $location)
73 function onLocationFromName($name, $language, &$location)
75 $loc = $this->getCache(array('name' => $name,
76 'language' => $language));
84 $geonames = $this->getGeonames('search',
89 } catch (Exception $e) {
90 $this->log(LOG_WARNING, "Error for $name: " . $e->getMessage());
94 if (count($geonames) == 0) {
96 $this->setCache(array('name' => $name,
97 'language' => $language),
104 $location = new Location();
106 $location->lat = $this->canonical($n->lat);
107 $location->lon = $this->canonical($n->lng);
108 $location->names[$language] = (string)$n->name;
109 $location->location_id = (string)$n->geonameId;
110 $location->location_ns = self::LOCATION_NS;
112 $this->setCache(array('name' => $name,
113 'language' => $language),
116 // handled, don't continue processing!
121 * convert an id into a Location object
123 * @param string $id Name to convert
124 * @param string $ns Name to convert
125 * @param string $language ISO code for language for results
126 * @param Location &$location Location object (may be null)
128 * @return boolean whether to continue (results in $location)
130 function onLocationFromId($id, $ns, $language, &$location)
132 if ($ns != self::LOCATION_NS) {
133 // It's not one of our IDs... keep processing
137 $loc = $this->getCache(array('id' => $id));
139 if ($loc !== false) {
145 $geonames = $this->getGeonames('hierarchy',
146 array('geonameId' => $id,
147 'lang' => $language));
148 } catch (Exception $e) {
149 $this->log(LOG_WARNING, "Error for ID $id: " . $e->getMessage());
155 foreach ($geonames as $level) {
156 if (in_array($level->fcode, array('PCLI', 'ADM1', 'PPL'))) {
157 $parts[] = (string)$level->name;
161 $last = $geonames[count($geonames)-1];
163 if (!in_array($level->fcode, array('PCLI', 'ADM1', 'PPL'))) {
164 $parts[] = (string)$last->name;
167 $location = new Location();
169 $location->location_id = (string)$last->geonameId;
170 $location->location_ns = self::LOCATION_NS;
171 $location->lat = $this->canonical($last->lat);
172 $location->lon = $this->canonical($last->lng);
174 $location->names[$language] = implode(', ', array_reverse($parts));
176 $this->setCache(array('id' => (string)$last->geonameId),
179 // We're responsible for this namespace; nobody else
186 * convert a lat/lon pair into a Location object
188 * Given a lat/lon, we try to find a Location that's around
189 * it or nearby. We prefer populated places (cities, towns, villages).
191 * @param string $lat Latitude
192 * @param string $lon Longitude
193 * @param string $language ISO code for language for results
194 * @param Location &$location Location object (may be null)
196 * @return boolean whether to continue (results in $location)
198 function onLocationFromLatLon($lat, $lon, $language, &$location)
200 // Make sure they're canonical
202 $lat = $this->canonical($lat);
203 $lon = $this->canonical($lon);
205 $loc = $this->getCache(array('lat' => $lat,
208 if ($loc !== false) {
214 $geonames = $this->getGeonames('findNearbyPlaceName',
217 'lang' => $language));
218 } catch (Exception $e) {
219 $this->log(LOG_WARNING, "Error for coords $lat, $lon: " . $e->getMessage());
223 if (count($geonames) == 0) {
225 $this->setCache(array('lat' => $lat,
235 $location = new Location();
237 $parts[] = (string)$n->name;
239 if (!empty($n->adminName1)) {
240 $parts[] = (string)$n->adminName1;
243 if (!empty($n->countryName)) {
244 $parts[] = (string)$n->countryName;
247 $location->location_id = (string)$n->geonameId;
248 $location->location_ns = self::LOCATION_NS;
249 $location->lat = $this->canonical($n->lat);
250 $location->lon = $this->canonical($n->lng);
252 $location->names[$language] = implode(', ', $parts);
254 $this->setCache(array('lat' => $lat,
258 // Success! We handled it, so no further processing
264 * Human-readable name for a location
266 * Given a location, we try to retrieve a human-readable name
267 * in the target language.
269 * @param Location $location Location to get the name for
270 * @param string $language ISO code for language to find name in
271 * @param string &$name Place to put the name
273 * @return boolean whether to continue
275 function onLocationNameLanguage($location, $language, &$name)
277 if ($location->location_ns != self::LOCATION_NS) {
278 // It's not one of our IDs... keep processing
282 $id = $location->location_id;
284 $n = $this->getCache(array('id' => $id,
285 'language' => $language));
293 $geonames = $this->getGeonames('hierarchy',
294 array('geonameId' => $id,
295 'lang' => $language));
296 } catch (Exception $e) {
297 $this->log(LOG_WARNING, "Error for ID $id: " . $e->getMessage());
301 if (count($geonames) == 0) {
302 $this->setCache(array('id' => $id,
303 'language' => $language),
310 foreach ($geonames as $level) {
311 if (in_array($level->fcode, array('PCLI', 'ADM1', 'PPL'))) {
312 $parts[] = (string)$level->name;
316 $last = $geonames[count($geonames)-1];
318 if (!in_array($level->fcode, array('PCLI', 'ADM1', 'PPL'))) {
319 $parts[] = (string)$last->name;
323 $name = implode(', ', array_reverse($parts));
324 $this->setCache(array('id' => $id,
325 'language' => $language),
333 * Human-readable URL for a location
335 * Given a location, we try to retrieve a geonames.org URL.
337 * @param Location $location Location to get the url for
338 * @param string &$url Place to put the url
340 * @return boolean whether to continue
342 function onLocationUrl($location, &$url)
344 if ($location->location_ns != self::LOCATION_NS) {
345 // It's not one of our IDs... keep processing
349 $url = 'http://www.geonames.org/' . $location->location_id;
351 // it's been filled, so don't process further.
356 * Machine-readable name for a location
358 * Given a location, we try to retrieve a geonames.org URL.
360 * @param Location $location Location to get the url for
361 * @param string &$url Place to put the url
363 * @return boolean whether to continue
365 function onLocationRdfUrl($location, &$url)
367 if ($location->location_ns != self::LOCATION_NS) {
368 // It's not one of our IDs... keep processing
372 $url = 'http://sws.geonames.org/' . $location->location_id . '/';
374 // it's been filled, so don't process further.
378 function getCache($attrs)
380 $c = Cache::instance();
386 $key = $this->cacheKey($attrs);
388 $value = $c->get($key);
393 function setCache($attrs, $loc)
395 $c = Cache::instance();
401 $key = $this->cacheKey($attrs);
403 $result = $c->set($key, $loc, 0, time() + $this->expiry);
408 function cacheKey($attrs)
411 implode(',', array_keys($attrs)) . ':'.
412 Cache::keyize(implode(',', array_values($attrs)));
413 if ($this->cachePrefix) {
414 return $this->cachePrefix . ':' . $key;
416 return Cache::key($key);
420 function wsUrl($method, $params)
422 if (!empty($this->username)) {
423 $params['username'] = $this->username;
426 if (!empty($this->token)) {
427 $params['token'] = $this->token;
430 $str = http_build_query($params, null, '&');
432 return 'http://'.$this->host.'/'.$method.'?'.$str;
435 function getGeonames($method, $params)
437 if ($this->lastTimeout && (time() - $this->lastTimeout < $this->timeoutWindow)) {
438 // TRANS: Exception thrown when a geo names service is not used because of a recent timeout.
439 throw new Exception(_m('Skipping due to recent web service timeout.'));
442 $client = HTTPClient::start();
443 $client->setConfig('connect_timeout', $this->timeout);
444 $client->setConfig('timeout', $this->timeout);
447 $result = $client->get($this->wsUrl($method, $params));
448 } catch (Exception $e) {
449 common_log(LOG_ERR, __METHOD__ . ": " . $e->getMessage());
450 $this->lastTimeout = time();
454 if (!$result->isOk()) {
455 // TRANS: Exception thrown when a geo names service does not return an expected response.
456 // TRANS: %s is an HTTP error code.
457 throw new Exception(sprintf(_m('HTTP error code %s.'),$result->getStatus()));
460 $body = $result->getBody();
463 // TRANS: Exception thrown when a geo names service returns an empty body.
464 throw new Exception(_m('Empty HTTP body in response.'));
467 // This will throw an exception if the XML is mal-formed
469 $document = new SimpleXMLElement($body);
471 // No children, usually no results
473 $children = $document->children();
475 if (count($children) == 0) {
479 if (isset($document->status)) {
480 // TRANS: Exception thrown when a geo names service return a specific error number and error text.
481 // TRANS: %1$s is an error code, %2$s is an error message.
482 throw new Exception(sprintf(_m('Error #%1$s ("%2$s").'),$document->status['value'],$document->status['message']));
485 // Array of elements, >0 elements
487 return $document->geoname;
490 function onPluginVersion(&$versions)
492 $versions[] = array('name' => 'Geonames',
493 'version' => STATUSNET_VERSION,
494 'author' => 'Evan Prodromou',
495 'homepage' => 'http://status.net/wiki/Plugin:Geonames',
497 // TRANS: Plugin description.
498 _m('Uses <a href="http://geonames.org/">Geonames</a> service to get human-readable '.
499 'names for locations based on user-provided lat/long pairs.'));
503 function canonical($coord)
505 $coord = rtrim($coord, "0");
506 $coord = rtrim($coord, ".");