diff options
-rw-r--r-- | actions/profilesettings.php | 10 | ||||
-rw-r--r-- | classes/Location_namespace.php | 46 | ||||
-rw-r--r-- | classes/Notice.php | 31 | ||||
-rw-r--r-- | classes/Profile.php | 8 | ||||
-rw-r--r-- | classes/User.php | 9 | ||||
-rw-r--r-- | classes/statusnet.ini | 18 | ||||
-rw-r--r-- | db/location_namespace.sql | 5 | ||||
-rw-r--r-- | db/statusnet.sql | 19 | ||||
-rw-r--r-- | lib/default.php | 2 | ||||
-rw-r--r-- | lib/location.php | 160 | ||||
-rw-r--r-- | plugins/GeonamesPlugin.php | 281 | ||||
-rw-r--r-- | tests/LocationTest.php | 87 |
12 files changed, 673 insertions, 3 deletions
diff --git a/actions/profilesettings.php b/actions/profilesettings.php index 5445d9bb2..0a0cc5997 100644 --- a/actions/profilesettings.php +++ b/actions/profilesettings.php @@ -306,6 +306,16 @@ class ProfilesettingsAction extends AccountSettingsAction $profile->homepage = $homepage; $profile->bio = $bio; $profile->location = $location; + + $loc = Location::fromName($location); + + if (!empty($loc)) { + $profile->lat = $loc->lat; + $profile->lon = $loc->lon; + $profile->location_id = $loc->location_id; + $profile->location_ns = $loc->location_ns; + } + $profile->profileurl = common_profile_url($nickname); common_debug('Old profile: ' . common_log_objstring($orig_profile), __FILE__); diff --git a/classes/Location_namespace.php b/classes/Location_namespace.php new file mode 100644 index 000000000..5ab95d9ef --- /dev/null +++ b/classes/Location_namespace.php @@ -0,0 +1,46 @@ +<?php +/* + * StatusNet - the distributed open-source microblogging tool + * Copyright (C) 2009, StatusNet, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +if (!defined('STATUSNET') && !defined('LACONICA')) { exit(1); } + +/** + * Table Definition for location_namespace + */ + +require_once INSTALLDIR.'/classes/Memcached_DataObject.php'; + +class Location_namespace extends Memcached_DataObject +{ + ###START_AUTOCODE + /* the code below is auto generated do not remove the above tag */ + + public $__table = 'location_namespace'; // table name + public $id; // int(4) primary_key not_null + public $description; // varchar(255) + public $created; // datetime() not_null + public $modified; // timestamp() not_null default_CURRENT_TIMESTAMP + + /* Static get */ + function staticGet($k,$v=NULL) { + return Memcached_DataObject::staticGet('Location_namespace',$k,$v); + } + + /* the code above is auto generated do not remove the tag below */ + ###END_AUTOCODE +} diff --git a/classes/Notice.php b/classes/Notice.php index cd3906ca1..7478f945d 100644 --- a/classes/Notice.php +++ b/classes/Notice.php @@ -66,9 +66,15 @@ class Notice extends Memcached_DataObject public $is_local; // tinyint(1) public $source; // varchar(32) public $conversation; // int(4) + public $lat; // decimal(10,7) + public $lon; // decimal(10,7) + public $location_id; // int(4) + public $location_ns; // int(4) /* Static get */ - function staticGet($k,$v=NULL) { return Memcached_DataObject::staticGet('Notice',$k,$v); } + function staticGet($k,$v=NULL) { + return Memcached_DataObject::staticGet('Notice',$k,$v); + } /* the code above is auto generated do not remove the tag below */ ###END_AUTOCODE @@ -162,7 +168,8 @@ class Notice extends Memcached_DataObject } static function saveNew($profile_id, $content, $source=null, - $is_local=Notice::LOCAL_PUBLIC, $reply_to=null, $uri=null, $created=null) { + $is_local=Notice::LOCAL_PUBLIC, $reply_to=null, $uri=null, $created=null, + $lat=null, $lon=null, $location_id=null, $location_ns=null) { $profile = Profile::staticGet($profile_id); @@ -228,6 +235,26 @@ class Notice extends Memcached_DataObject $notice->conversation = $reply->conversation; } + if (!empty($lat) && !empty($lon)) { + $notice->lat = $lat; + $notice->lon = $lon; + $notice->location_id = $location_id; + $notice->location_ns = $location_ns; + } else if (!empty($location_ns) && !empty($location_id)) { + $location = Location::fromId($location_id, $location_ns); + if (!empty($location)) { + $notice->lat = $location->lat; + $notice->lon = $location->lon; + $notice->location_id = $location_id; + $notice->location_ns = $location_ns; + } + } else { + $notice->lat = $profile->lat; + $notice->lon = $profile->lon; + $notice->location_id = $profile->location_id; + $notice->location_ns = $profile->location_ns; + } + if (Event::handle('StartNoticeSave', array(&$notice))) { // XXX: some of these functions write to the DB diff --git a/classes/Profile.php b/classes/Profile.php index a78a27f4a..53d07fb2f 100644 --- a/classes/Profile.php +++ b/classes/Profile.php @@ -37,11 +37,17 @@ class Profile extends Memcached_DataObject public $homepage; // varchar(255) multiple_key public $bio; // text() multiple_key public $location; // varchar(255) multiple_key + public $lat; // decimal(10,7) + public $lon; // decimal(10,7) + public $location_id; // int(4) + public $location_ns; // int(4) public $created; // datetime() not_null public $modified; // timestamp() not_null default_CURRENT_TIMESTAMP /* Static get */ - function staticGet($k,$v=NULL) { return Memcached_DataObject::staticGet('Profile',$k,$v); } + function staticGet($k,$v=NULL) { + return Memcached_DataObject::staticGet('Profile',$k,$v); + } /* the code above is auto generated do not remove the tag below */ ###END_AUTOCODE diff --git a/classes/User.php b/classes/User.php index 0a70c9801..b300c9edb 100644 --- a/classes/User.php +++ b/classes/User.php @@ -198,6 +198,15 @@ class User extends Memcached_DataObject } if (!empty($location)) { $profile->location = $location; + + $loc = Location::fromName($location); + + if (!empty($loc)) { + $profile->lat = $loc->lat; + $profile->lon = $loc->lon; + $profile->location_id = $loc->location_id; + $profile->location_ns = $loc->location_ns; + } } $profile->created = common_sql_now(); diff --git a/classes/statusnet.ini b/classes/statusnet.ini index 453981cd6..7931c7bcd 100644 --- a/classes/statusnet.ini +++ b/classes/statusnet.ini @@ -1,3 +1,4 @@ + [avatar] profile_id = 129 original = 17 @@ -243,6 +244,15 @@ created = 142 [invitation__keys] code = K +[location_namespace] +id = 129 +description = 2 +created = 142 +modified = 384 + +[location_namespace__keys] +id = K + [message] id = 129 uri = 2 @@ -284,6 +294,10 @@ reply_to = 1 is_local = 17 source = 2 conversation = 1 +lat = 1 +lon = 1 +location_id = 1 +location_ns = 1 [notice__keys] id = N @@ -325,6 +339,10 @@ profileurl = 2 homepage = 2 bio = 34 location = 2 +lat = 1 +lon = 1 +location_id = 1 +location_ns = 1 created = 142 modified = 384 diff --git a/db/location_namespace.sql b/db/location_namespace.sql new file mode 100644 index 000000000..59b2ce67c --- /dev/null +++ b/db/location_namespace.sql @@ -0,0 +1,5 @@ +insert into location_namespace + (id, description, created) +values + (1, 'Geonames', now()), + (2, 'Where on Earth', now()); diff --git a/db/statusnet.sql b/db/statusnet.sql index dfcddb643..1524d8395 100644 --- a/db/statusnet.sql +++ b/db/statusnet.sql @@ -1,6 +1,7 @@ /* local and remote users have profiles */ create table profile ( + id integer auto_increment primary key comment 'unique identifier', nickname varchar(64) not null comment 'nickname or username', fullname varchar(255) comment 'display name', @@ -8,6 +9,11 @@ create table profile ( homepage varchar(255) comment 'identifying URL', bio text comment 'descriptive biography', location varchar(255) comment 'physical location', + lat decimal(10,7) comment 'latitude', + lon decimal(10,7) comment 'longitude', + location_id integer comment 'location id if possible', + location_ns integer comment 'namespace for location', + created datetime not null comment 'date this record was created', modified timestamp comment 'date this record was modified', @@ -119,6 +125,10 @@ create table notice ( is_local tinyint default 0 comment 'notice was generated by a user', source varchar(32) comment 'source of comment, like "web", "im", or "clientname"', conversation integer comment 'id of root notice in this conversation' references notice (id), + lat decimal(10,7) comment 'latitude', + lon decimal(10,7) comment 'longitude', + location_id integer comment 'location id if possible', + location_ns integer comment 'namespace for location', index notice_profile_id_idx (profile_id), index notice_conversation_idx (conversation), @@ -556,3 +566,12 @@ create table user_role ( constraint primary key (user_id, role) ) ENGINE=InnoDB CHARACTER SET utf8 COLLATE utf8_bin; + +create table location_namespace ( + + id integer primary key comment 'identity for this namespace', + description varchar(255) comment 'description of the namespace', + created datetime not null comment 'date the record was created', + modified timestamp comment 'date this record was modified' + +) ENGINE=InnoDB CHARACTER SET utf8 COLLATE utf8_bin; diff --git a/lib/default.php b/lib/default.php index 30e43eefb..c896d5408 100644 --- a/lib/default.php +++ b/lib/default.php @@ -229,4 +229,6 @@ $default = array('contentlimit' => null), 'http' => array('client' => 'curl'), // XXX: should this be the default? + 'location' => + array('namespace' => 1), // 1 = geonames, 2 = Yahoo Where on Earth ); diff --git a/lib/location.php b/lib/location.php new file mode 100644 index 000000000..048554f0f --- /dev/null +++ b/lib/location.php @@ -0,0 +1,160 @@ +<?php +/** + * StatusNet, the distributed open-source microblogging tool + * + * Class for locations + * + * PHP version 5 + * + * LICENCE: This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + * @category Location + * @package StatusNet + * @author Evan Prodromou <evan@status.net> + * @copyright 2009 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 + * @link http://status.net/ + */ + +if (!defined('STATUSNET') && !defined('LACONICA')) { + exit(1); +} + +/** + * class for locations + * + * These are stored in the DB as part of notice and profile records, + * but since they're about the same in both, we have a separate class + * for them. + * + * @category Location + * @package StatusNet + * @author Evan Prodromou <evan@status.net> + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 + * @link http://status.net/ + */ + +class Location +{ + public $lat; + public $lon; + public $location_id; + public $location_ns; + + var $names = array(); + + /** + * Constructor that makes a Location from a string name + * + * @param string $name Human-readable name (any kind) + * @param string $language Language, default = common_language() + * + * @return Location Location with that name (or null if not found) + */ + + static function fromName($name, $language=null) + { + if (is_null($language)) { + $language = common_language(); + } + + $location = null; + + // Let a third-party handle it + + Event::handle('LocationFromName', array($name, $language, &$location)); + + return $location; + } + + /** + * Constructor that makes a Location from an ID + * + * @param integer $id Identifier ID + * @param integer $ns Namespace of the identifier + * @param string $language Language to return name in (default is common) + * + * @return Location The location with this ID (or null if none) + */ + + static function fromId($id, $ns, $language=null) + { + $location = null; + + // Let a third-party handle it + + Event::handle('LocationFromId', array($id, $ns, $language, &$location)); + + return $location; + } + + /** + * Constructor that finds the nearest location to a lat/lon pair + * + * @param float $lat Latitude + * @param float $lon Longitude + * @param string $language Language for results, default = current + * + * @return Location the location found, or null if none found + */ + + static function fromLatLon($lat, $lon, $language=null) + { + if (is_null($language)) { + $language = common_language(); + } + + $location = null; + + // Let a third-party handle it + + if (Event::handle('LocationFromLatLon', + array($lat, $lon, $language, &$location))) { + // Default is just the lat/lon pair + + $location = new Location(); + + $location->lat = $lat; + $location->lon = $lon; + } + + return $location; + } + + /** + * Get the name for this location in the given language + * + * @param string $language language to use, default = current + * + * @return string location name or null if not found + */ + + function getName($language=null) + { + if (is_null($language)) { + $language = common_language(); + } + + if (array_key_exists($language, $this->names)) { + return $this->names[$language]; + } else { + $name = null; + Event::handle('LocationNameLanguage', array($this, $language, &$name)); + if (!empty($name)) { + $this->names[$language] = $name; + return $name; + } + } + } +} diff --git a/plugins/GeonamesPlugin.php b/plugins/GeonamesPlugin.php new file mode 100644 index 000000000..745cd4126 --- /dev/null +++ b/plugins/GeonamesPlugin.php @@ -0,0 +1,281 @@ +<?php +/** + * StatusNet, the distributed open-source microblogging tool + * + * Plugin to convert string locations to Geonames IDs and vice versa + * + * PHP version 5 + * + * LICENCE: This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + * @category Action + * @package StatusNet + * @author Evan Prodromou <evan@status.net> + * @copyright 2009 StatusNet Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 + * @link http://status.net/ + */ + +if (!defined('STATUSNET')) { + exit(1); +} + +/** + * Plugin to convert string locations to Geonames IDs and vice versa + * + * This handles most of the events that Location class emits. It uses + * the geonames.org Web service to convert names like 'Montreal, Quebec, Canada' + * into IDs and lat/lon pairs. + * + * @category Plugin + * @package StatusNet + * @author Evan Prodromou <evan@status.net> + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 + * @link http://status.net/ + * + * @seeAlso Location + */ + +class GeonamesPlugin extends Plugin +{ + const NAMESPACE = 1; + + /** + * convert a name into a Location object + * + * @param string $name Name to convert + * @param string $language ISO code for anguage the name is in + * @param Location &$location Location object (may be null) + * + * @return boolean whether to continue (results in $location) + */ + + function onLocationFromName($name, $language, &$location) + { + $client = HTTPClient::start(); + + // XXX: break down a name by commas, narrow by each + + $str = http_build_query(array('maxRows' => 1, + 'q' => $name, + 'lang' => $language, + 'type' => 'json')); + + $result = $client->get('http://ws.geonames.org/search?'.$str); + + if ($result->code == "200") { + $rj = json_decode($result->body); + if (count($rj->geonames) > 0) { + $n = $rj->geonames[0]; + + $location = new Location(); + + $location->lat = $n->lat; + $location->lon = $n->lng; + $location->names[$language] = $n->name; + $location->location_id = $n->geonameId; + $location->location_ns = self::NAMESPACE; + + // handled, don't continue processing! + return false; + } + } + + // Continue processing; we don't have the answer + return true; + } + + /** + * convert an id into a Location object + * + * @param string $id Name to convert + * @param string $ns Name to convert + * @param string $language ISO code for language for results + * @param Location &$location Location object (may be null) + * + * @return boolean whether to continue (results in $location) + */ + + function onLocationFromId($id, $ns, $language, &$location) + { + if ($ns != self::NAMESPACE) { + // It's not one of our IDs... keep processing + return true; + } + + $client = HTTPClient::start(); + + $str = http_build_query(array('geonameId' => $id, + 'lang' => $language)); + + $result = $client->get('http://ws.geonames.org/hierarchyJSON?'.$str); + + if ($result->code == "200") { + + $rj = json_decode($result->body); + + if (count($rj->geonames) > 0) { + + $parts = array(); + + foreach ($rj->geonames as $level) { + if (in_array($level->fcode, array('PCLI', 'ADM1', 'PPL'))) { + $parts[] = $level->name; + } + } + + $last = $rj->geonames[count($rj->geonames)-1]; + + if (!in_array($level->fcode, array('PCLI', 'ADM1', 'PPL'))) { + $parts[] = $last->name; + } + + $location = new Location(); + + $location->location_id = $last->geonameId; + $location->location_ns = self::NAMESPACE; + $location->lat = $last->lat; + $location->lon = $last->lng; + $location->names[$language] = implode(', ', array_reverse($parts)); + } + } + + // We're responsible for this NAMESPACE; nobody else + // can resolve it + + return false; + } + + /** + * convert a lat/lon pair into a Location object + * + * Given a lat/lon, we try to find a Location that's around + * it or nearby. We prefer populated places (cities, towns, villages). + * + * @param string $lat Latitude + * @param string $lon Longitude + * @param string $language ISO code for language for results + * @param Location &$location Location object (may be null) + * + * @return boolean whether to continue (results in $location) + */ + + function onLocationFromLatLon($lat, $lon, $language, &$location) + { + $client = HTTPClient::start(); + + $str = http_build_query(array('lat' => $lat, + 'lng' => $lon, + 'lang' => $language)); + + $result = + $client->get('http://ws.geonames.org/findNearbyPlaceNameJSON?'.$str); + + if ($result->code == "200") { + + $rj = json_decode($result->body); + + if (count($rj->geonames) > 0) { + + $n = $rj->geonames[0]; + + $parts = array(); + + $location = new Location(); + + $parts[] = $n->name; + + if (!empty($n->adminName1)) { + $parts[] = $n->adminName1; + } + + if (!empty($n->countryName)) { + $parts[] = $n->countryName; + } + + $location->location_id = $n->geonameId; + $location->location_ns = self::NAMESPACE; + $location->lat = $lat; + $location->lon = $lon; + + $location->names[$language] = implode(', ', $parts); + + // Success! We handled it, so no further processing + + return false; + } + } + + // For some reason we don't know, so pass. + + return true; + } + + /** + * Human-readable name for a location + * + * Given a location, we try to retrieve a human-readable name + * in the target language. + * + * @param Location $location Location to get the name for + * @param string $language ISO code for language to find name in + * @param string &$name Place to put the name + * + * @return boolean whether to continue + */ + + function onLocationNameLanguage($location, $language, &$name) + { + if ($location->location_ns != self::NAMESPACE) { + // It's not one of our IDs... keep processing + return true; + } + + $client = HTTPClient::start(); + + $str = http_build_query(array('geonameId' => $id, + 'lang' => $language)); + + $result = $client->get('http://ws.geonames.org/hierarchyJSON?'.$str); + + if ($result->code == "200") { + + $rj = json_decode($result->body); + + if (count($rj->geonames) > 0) { + + $parts = array(); + + foreach ($rj->geonames as $level) { + if (in_array($level->fcode, array('PCLI', 'ADM1', 'PPL'))) { + $parts[] = $level->name; + } + } + + $last = $rj->geonames[count($rj->geonames)-1]; + + if (!in_array($level->fcode, array('PCLI', 'ADM1', 'PPL'))) { + $parts[] = $last->name; + } + + if (count($parts)) { + $name = implode(', ', array_reverse($parts)); + return false; + } + } + } + + return true; + } +} diff --git a/tests/LocationTest.php b/tests/LocationTest.php new file mode 100644 index 000000000..62849eb9f --- /dev/null +++ b/tests/LocationTest.php @@ -0,0 +1,87 @@ +<?php + +if (isset($_SERVER) && array_key_exists('REQUEST_METHOD', $_SERVER)) { + print "This script must be run from the command line\n"; + exit(); +} + +define('INSTALLDIR', realpath(dirname(__FILE__) . '/..')); +define('STATUSNET', true); + +require_once INSTALLDIR . '/lib/common.php'; + +// Make sure this is loaded +// XXX: how to test other plugins...? + +addPlugin('Geonames'); + +class LocationTest extends PHPUnit_Framework_TestCase +{ + + /** + * @dataProvider locationNames + */ + + public function testLocationFromName($name, $language, $location) + { + $result = Location::fromName($name, $language); + $this->assertEquals($result, $location); + } + + static public function locationNames() + { + return array(array('Montreal', 'en', null), + array('San Francisco, CA', 'en', null), + array('Paris, France', 'en', null), + array('Paris, Texas', 'en', null)); + } + + /** + * @dataProvider locationIds + */ + + public function testLocationFromId($id, $ns, $language, $location) + { + $result = Location::fromId($id, $ns, $language); + $this->assertEquals($result, $location); + } + + static public function locationIds() + { + return array(array(6077243, GeonamesPlugin::NAMESPACE, 'en', null), + array(5391959, GeonamesPlugin::NAMESPACE, 'en', null)); + } + + /** + * @dataProvider locationLatLons + */ + + public function testLocationFromLatLon($lat, $lon, $language, $location) + { + $result = Location::fromLatLon($lat, $lon, $language); + $this->assertEquals($result, $location); + } + + static public function locationLatLons() + { + return array(array(37.77493, -122.41942, 'en', null), + array(45.509, -73.588, 'en', null)); + } + + /** + * @dataProvider nameOfLocation + */ + + public function testLocationGetName($location, $language, $name) + { + $result = $location->getName($language); + $this->assertEquals($result, $name); + } + + static public function nameOfLocation() + { + return array(array(new Location(), 'en', 'Montreal'), + array(new Location(), 'fr', 'Montréal')); + } +} + |