Refactor improvements from Fredrik Wendt; and initial LinkedIn and
authorAndrew Flegg <andrew@bleb.org>
Wed, 14 Apr 2010 08:33:15 +0000 (09:33 +0100)
committerAndrew Flegg <andrew@bleb.org>
Wed, 14 Apr 2010 08:35:08 +0000 (09:35 +0100)
Gravatar services.

17 files changed:
package/src/oauth/__init__.py [new file with mode: 0644]
package/src/oauth/example/client.py [new file with mode: 0644]
package/src/oauth/example/server.py [new file with mode: 0644]
package/src/oauth/oauth.py [new file with mode: 0644]
package/src/org/maemo/hermes/engine/contact.py
package/src/org/maemo/hermes/engine/facebook/service.py
package/src/org/maemo/hermes/engine/friend.py [new file with mode: 0644]
package/src/org/maemo/hermes/engine/gravatar/__init__.py [new file with mode: 0644]
package/src/org/maemo/hermes/engine/gravatar/provider.py [new file with mode: 0644]
package/src/org/maemo/hermes/engine/gravatar/service.py [new file with mode: 0644]
package/src/org/maemo/hermes/engine/linkedin/__init__.py [new file with mode: 0644]
package/src/org/maemo/hermes/engine/linkedin/provider.py [new file with mode: 0644]
package/src/org/maemo/hermes/engine/linkedin/service.py [new file with mode: 0644]
package/src/org/maemo/hermes/engine/service.py
package/src/org/maemo/hermes/engine/twitter/service.py
package/src/test.py
package/src/test_cases.txt [new file with mode: 0644]

diff --git a/package/src/oauth/__init__.py b/package/src/oauth/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/package/src/oauth/example/client.py b/package/src/oauth/example/client.py
new file mode 100644 (file)
index 0000000..34f7dcb
--- /dev/null
@@ -0,0 +1,165 @@
+"""
+The MIT License
+
+Copyright (c) 2007 Leah Culver
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+Example consumer. This is not recommended for production.
+Instead, you'll want to create your own subclass of OAuthClient
+or find one that works with your web framework.
+"""
+
+import httplib
+import time
+import oauth.oauth as oauth
+
+# settings for the local test consumer
+SERVER = 'localhost'
+PORT = 8080
+
+# fake urls for the test server (matches ones in server.py)
+REQUEST_TOKEN_URL = 'https://photos.example.net/request_token'
+ACCESS_TOKEN_URL = 'https://photos.example.net/access_token'
+AUTHORIZATION_URL = 'https://photos.example.net/authorize'
+CALLBACK_URL = 'http://printer.example.com/request_token_ready'
+RESOURCE_URL = 'http://photos.example.net/photos'
+
+# key and secret granted by the service provider for this consumer application - same as the MockOAuthDataStore
+CONSUMER_KEY = 'key'
+CONSUMER_SECRET = 'secret'
+
+# example client using httplib with headers
+class SimpleOAuthClient(oauth.OAuthClient):
+
+    def __init__(self, server, port=httplib.HTTP_PORT, request_token_url='', access_token_url='', authorization_url=''):
+        self.server = server
+        self.port = port
+        self.request_token_url = request_token_url
+        self.access_token_url = access_token_url
+        self.authorization_url = authorization_url
+        self.connection = httplib.HTTPConnection("%s:%d" % (self.server, self.port))
+
+    def fetch_request_token(self, oauth_request):
+        # via headers
+        # -> OAuthToken
+        self.connection.request(oauth_request.http_method, self.request_token_url, headers=oauth_request.to_header()) 
+        response = self.connection.getresponse()
+        return oauth.OAuthToken.from_string(response.read())
+
+    def fetch_access_token(self, oauth_request):
+        # via headers
+        # -> OAuthToken
+        self.connection.request(oauth_request.http_method, self.access_token_url, headers=oauth_request.to_header()) 
+        response = self.connection.getresponse()
+        return oauth.OAuthToken.from_string(response.read())
+
+    def authorize_token(self, oauth_request):
+        # via url
+        # -> typically just some okay response
+        self.connection.request(oauth_request.http_method, oauth_request.to_url()) 
+        response = self.connection.getresponse()
+        return response.read()
+
+    def access_resource(self, oauth_request):
+        # via post body
+        # -> some protected resources
+        headers = {'Content-Type' :'application/x-www-form-urlencoded'}
+        self.connection.request('POST', RESOURCE_URL, body=oauth_request.to_postdata(), headers=headers)
+        response = self.connection.getresponse()
+        return response.read()
+
+def run_example():
+
+    # setup
+    print '** OAuth Python Library Example **'
+    client = SimpleOAuthClient(SERVER, PORT, REQUEST_TOKEN_URL, ACCESS_TOKEN_URL, AUTHORIZATION_URL)
+    consumer = oauth.OAuthConsumer(CONSUMER_KEY, CONSUMER_SECRET)
+    signature_method_plaintext = oauth.OAuthSignatureMethod_PLAINTEXT()
+    signature_method_hmac_sha1 = oauth.OAuthSignatureMethod_HMAC_SHA1()
+    pause()
+
+    # get request token
+    print '* Obtain a request token ...'
+    pause()
+    oauth_request = oauth.OAuthRequest.from_consumer_and_token(consumer, callback=CALLBACK_URL, http_url=client.request_token_url)
+    oauth_request.sign_request(signature_method_plaintext, consumer, None)
+    print 'REQUEST (via headers)'
+    print 'parameters: %s' % str(oauth_request.parameters)
+    pause()
+    token = client.fetch_request_token(oauth_request)
+    print 'GOT'
+    print 'key: %s' % str(token.key)
+    print 'secret: %s' % str(token.secret)
+    print 'callback confirmed? %s' % str(token.callback_confirmed)
+    pause()
+
+    print '* Authorize the request token ...'
+    pause()
+    oauth_request = oauth.OAuthRequest.from_token_and_callback(token=token, http_url=client.authorization_url)
+    print 'REQUEST (via url query string)'
+    print 'parameters: %s' % str(oauth_request.parameters)
+    pause()
+    # this will actually occur only on some callback
+    response = client.authorize_token(oauth_request)
+    print 'GOT'
+    print response
+    # sad way to get the verifier
+    import urlparse, cgi
+    query = urlparse.urlparse(response)[4]
+    params = cgi.parse_qs(query, keep_blank_values=False)
+    verifier = params['oauth_verifier'][0]
+    print 'verifier: %s' % verifier
+    pause()
+
+    # get access token
+    print '* Obtain an access token ...'
+    pause()
+    oauth_request = oauth.OAuthRequest.from_consumer_and_token(consumer, token=token, verifier=verifier, http_url=client.access_token_url)
+    oauth_request.sign_request(signature_method_plaintext, consumer, token)
+    print 'REQUEST (via headers)'
+    print 'parameters: %s' % str(oauth_request.parameters)
+    pause()
+    token = client.fetch_access_token(oauth_request)
+    print 'GOT'
+    print 'key: %s' % str(token.key)
+    print 'secret: %s' % str(token.secret)
+    pause()
+
+    # access some protected resources
+    print '* Access protected resources ...'
+    pause()
+    parameters = {'file': 'vacation.jpg', 'size': 'original'} # resource specific params
+    oauth_request = oauth.OAuthRequest.from_consumer_and_token(consumer, token=token, http_method='POST', http_url=RESOURCE_URL, parameters=parameters)
+    oauth_request.sign_request(signature_method_hmac_sha1, consumer, token)
+    print 'REQUEST (via post body)'
+    print 'parameters: %s' % str(oauth_request.parameters)
+    pause()
+    params = client.access_resource(oauth_request)
+    print 'GOT'
+    print 'non-oauth parameters: %s' % params
+    pause()
+
+def pause():
+    print ''
+    time.sleep(1)
+
+if __name__ == '__main__':
+    run_example()
+    print 'Done.'
\ No newline at end of file
diff --git a/package/src/oauth/example/server.py b/package/src/oauth/example/server.py
new file mode 100644 (file)
index 0000000..5986b0e
--- /dev/null
@@ -0,0 +1,195 @@
+"""
+The MIT License
+
+Copyright (c) 2007 Leah Culver
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+"""
+
+from BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer
+import urllib
+
+import oauth.oauth as oauth
+
+# fake urls for the test server
+REQUEST_TOKEN_URL = 'https://photos.example.net/request_token'
+ACCESS_TOKEN_URL = 'https://photos.example.net/access_token'
+AUTHORIZATION_URL = 'https://photos.example.net/authorize'
+CALLBACK_URL = 'http://printer.example.com/request_token_ready'
+RESOURCE_URL = 'http://photos.example.net/photos'
+REALM = 'http://photos.example.net/'
+VERIFIER = 'verifier'
+
+# example store for one of each thing
+class MockOAuthDataStore(oauth.OAuthDataStore):
+
+    def __init__(self):
+        self.consumer = oauth.OAuthConsumer('key', 'secret')
+        self.request_token = oauth.OAuthToken('requestkey', 'requestsecret')
+        self.access_token = oauth.OAuthToken('accesskey', 'accesssecret')
+        self.nonce = 'nonce'
+        self.verifier = VERIFIER
+
+    def lookup_consumer(self, key):
+        if key == self.consumer.key:
+            return self.consumer
+        return None
+
+    def lookup_token(self, token_type, token):
+        token_attrib = getattr(self, '%s_token' % token_type)
+        if token == token_attrib.key:
+            ## HACK
+            token_attrib.set_callback(CALLBACK_URL)
+            return token_attrib
+        return None
+
+    def lookup_nonce(self, oauth_consumer, oauth_token, nonce):
+        if oauth_token and oauth_consumer.key == self.consumer.key and (oauth_token.key == self.request_token.key or oauth_token.key == self.access_token.key) and nonce == self.nonce:
+            return self.nonce
+        return None
+
+    def fetch_request_token(self, oauth_consumer, oauth_callback):
+        if oauth_consumer.key == self.consumer.key:
+            if oauth_callback:
+                # want to check here if callback is sensible
+                # for mock store, we assume it is
+                self.request_token.set_callback(oauth_callback)
+            return self.request_token
+        return None
+
+    def fetch_access_token(self, oauth_consumer, oauth_token, oauth_verifier):
+        if oauth_consumer.key == self.consumer.key and oauth_token.key == self.request_token.key and oauth_verifier == self.verifier:
+            # want to check here if token is authorized
+            # for mock store, we assume it is
+            return self.access_token
+        return None
+
+    def authorize_request_token(self, oauth_token, user):
+        if oauth_token.key == self.request_token.key:
+            # authorize the request token in the store
+            # for mock store, do nothing
+            return self.request_token
+        return None
+
+class RequestHandler(BaseHTTPRequestHandler):
+
+    def __init__(self, *args, **kwargs):
+        self.oauth_server = oauth.OAuthServer(MockOAuthDataStore())
+        self.oauth_server.add_signature_method(oauth.OAuthSignatureMethod_PLAINTEXT())
+        self.oauth_server.add_signature_method(oauth.OAuthSignatureMethod_HMAC_SHA1())
+        BaseHTTPRequestHandler.__init__(self, *args, **kwargs)
+
+    # example way to send an oauth error
+    def send_oauth_error(self, err=None):
+        # send a 401 error
+        self.send_error(401, str(err.message))
+        # return the authenticate header
+        header = oauth.build_authenticate_header(realm=REALM)
+        for k, v in header.iteritems():
+            self.send_header(k, v) 
+
+    def do_GET(self):
+
+        # debug info
+        #print self.command, self.path, self.headers
+        
+        # get the post data (if any)
+        postdata = None
+        if self.command == 'POST':
+            try:
+                length = int(self.headers.getheader('content-length'))
+                postdata = self.rfile.read(length)
+            except:
+                pass
+
+        # construct the oauth request from the request parameters
+        oauth_request = oauth.OAuthRequest.from_request(self.command, self.path, headers=self.headers, query_string=postdata)
+
+        # request token
+        if self.path.startswith(REQUEST_TOKEN_URL):
+            try:
+                # create a request token
+                token = self.oauth_server.fetch_request_token(oauth_request)
+                # send okay response
+                self.send_response(200, 'OK')
+                self.end_headers()
+                # return the token
+                self.wfile.write(token.to_string())
+            except oauth.OAuthError, err:
+                self.send_oauth_error(err)
+            return
+
+        # user authorization
+        if self.path.startswith(AUTHORIZATION_URL):
+            try:
+                # get the request token
+                token = self.oauth_server.fetch_request_token(oauth_request)
+                # authorize the token (kind of does nothing for now)
+                token = self.oauth_server.authorize_token(token, None)
+                token.set_verifier(VERIFIER)
+                # send okay response
+                self.send_response(200, 'OK')
+                self.end_headers()
+                # return the callback url (to show server has it)
+                self.wfile.write(token.get_callback_url())
+            except oauth.OAuthError, err:
+                self.send_oauth_error(err)
+            return
+
+        # access token
+        if self.path.startswith(ACCESS_TOKEN_URL):
+            try:
+                # create an access token
+                token = self.oauth_server.fetch_access_token(oauth_request)
+                # send okay response
+                self.send_response(200, 'OK')
+                self.end_headers()
+                # return the token
+                self.wfile.write(token.to_string())
+            except oauth.OAuthError, err:
+                self.send_oauth_error(err)
+            return
+
+        # protected resources
+        if self.path.startswith(RESOURCE_URL):
+            try:
+                # verify the request has been oauth authorized
+                consumer, token, params = self.oauth_server.verify_request(oauth_request)
+                # send okay response
+                self.send_response(200, 'OK')
+                self.end_headers()
+                # return the extra parameters - just for something to return
+                self.wfile.write(str(params))
+            except oauth.OAuthError, err:
+                self.send_oauth_error(err)
+            return
+
+    def do_POST(self):
+        return self.do_GET()
+
+def main():
+    try:
+        server = HTTPServer(('', 8080), RequestHandler)
+        print 'Test server running...'
+        server.serve_forever()
+    except KeyboardInterrupt:
+        server.socket.close()
+
+if __name__ == '__main__':
+    main()
\ No newline at end of file
diff --git a/package/src/oauth/oauth.py b/package/src/oauth/oauth.py
new file mode 100644 (file)
index 0000000..286de18
--- /dev/null
@@ -0,0 +1,655 @@
+"""
+The MIT License
+
+Copyright (c) 2007 Leah Culver
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+"""
+
+import cgi
+import urllib
+import time
+import random
+import urlparse
+import hmac
+import binascii
+
+
+VERSION = '1.0' # Hi Blaine!
+HTTP_METHOD = 'GET'
+SIGNATURE_METHOD = 'PLAINTEXT'
+
+
+class OAuthError(RuntimeError):
+    """Generic exception class."""
+    def __init__(self, message='OAuth error occured.'):
+        self.message = message
+
+def build_authenticate_header(realm=''):
+    """Optional WWW-Authenticate header (401 error)"""
+    return {'WWW-Authenticate': 'OAuth realm="%s"' % realm}
+
+def escape(s):
+    """Escape a URL including any /."""
+    return urllib.quote(s, safe='~')
+
+def _utf8_str(s):
+    """Convert unicode to utf-8."""
+    if isinstance(s, unicode):
+        return s.encode("utf-8")
+    else:
+        return str(s)
+
+def generate_timestamp():
+    """Get seconds since epoch (UTC)."""
+    return int(time.time())
+
+def generate_nonce(length=8):
+    """Generate pseudorandom number."""
+    return ''.join([str(random.randint(0, 9)) for i in range(length)])
+
+def generate_verifier(length=8):
+    """Generate pseudorandom number."""
+    return ''.join([str(random.randint(0, 9)) for i in range(length)])
+
+
+class OAuthConsumer(object):
+    """Consumer of OAuth authentication.
+
+    OAuthConsumer is a data type that represents the identity of the Consumer
+    via its shared secret with the Service Provider.
+
+    """
+    key = None
+    secret = None
+
+    def __init__(self, key, secret):
+        self.key = key
+        self.secret = secret
+
+
+class OAuthToken(object):
+    """OAuthToken is a data type that represents an End User via either an access
+    or request token.
+    
+    key -- the token
+    secret -- the token secret
+
+    """
+    key = None
+    secret = None
+    callback = None
+    callback_confirmed = None
+    verifier = None
+
+    def __init__(self, key, secret):
+        self.key = key
+        self.secret = secret
+
+    def set_callback(self, callback):
+        self.callback = callback
+        self.callback_confirmed = 'true'
+
+    def set_verifier(self, verifier=None):
+        if verifier is not None:
+            self.verifier = verifier
+        else:
+            self.verifier = generate_verifier()
+
+    def get_callback_url(self):
+        if self.callback and self.verifier:
+            # Append the oauth_verifier.
+            parts = urlparse.urlparse(self.callback)
+            scheme, netloc, path, params, query, fragment = parts[:6]
+            if query:
+                query = '%s&oauth_verifier=%s' % (query, self.verifier)
+            else:
+                query = 'oauth_verifier=%s' % self.verifier
+            return urlparse.urlunparse((scheme, netloc, path, params,
+                query, fragment))
+        return self.callback
+
+    def to_string(self):
+        data = {
+            'oauth_token': self.key,
+            'oauth_token_secret': self.secret,
+        }
+        if self.callback_confirmed is not None:
+            data['oauth_callback_confirmed'] = self.callback_confirmed
+        return urllib.urlencode(data)
+    def from_string(s):
+        """ Returns a token from something like:
+        oauth_token_secret=xxx&oauth_token=xxx
+        """
+        params = cgi.parse_qs(s, keep_blank_values=False)
+        key = params['oauth_token'][0]
+        secret = params['oauth_token_secret'][0]
+        token = OAuthToken(key, secret)
+        try:
+            token.callback_confirmed = params['oauth_callback_confirmed'][0]
+        except KeyError:
+            pass # 1.0, no callback confirmed.
+        return token
+    from_string = staticmethod(from_string)
+
+    def __str__(self):
+        return self.to_string()
+
+
+class OAuthRequest(object):
+    """OAuthRequest represents the request and can be serialized.
+
+    OAuth parameters:
+        - oauth_consumer_key 
+        - oauth_token
+        - oauth_signature_method
+        - oauth_signature 
+        - oauth_timestamp 
+        - oauth_nonce
+        - oauth_version
+        - oauth_verifier
+        ... any additional parameters, as defined by the Service Provider.
+    """
+    parameters = None # OAuth parameters.
+    http_method = HTTP_METHOD
+    http_url = None
+    version = VERSION
+
+    def __init__(self, http_method=HTTP_METHOD, http_url=None, parameters=None):
+        self.http_method = http_method
+        self.http_url = http_url
+        self.parameters = parameters or {}
+
+    def set_parameter(self, parameter, value):
+        self.parameters[parameter] = value
+
+    def get_parameter(self, parameter):
+        try:
+            return self.parameters[parameter]
+        except:
+            raise OAuthError('Parameter not found: %s' % parameter)
+
+    def _get_timestamp_nonce(self):
+        return self.get_parameter('oauth_timestamp'), self.get_parameter(
+            'oauth_nonce')
+
+    def get_nonoauth_parameters(self):
+        """Get any non-OAuth parameters."""
+        parameters = {}
+        for k, v in self.parameters.iteritems():
+            # Ignore oauth parameters.
+            if k.find('oauth_') < 0:
+                parameters[k] = v
+        return parameters
+
+    def to_header(self, realm=''):
+        """Serialize as a header for an HTTPAuth request."""
+        auth_header = 'OAuth realm="%s"' % realm
+        # Add the oauth parameters.
+        if self.parameters:
+            for k, v in self.parameters.iteritems():
+                if k[:6] == 'oauth_':
+                    auth_header += ', %s="%s"' % (k, escape(str(v)))
+        return {'Authorization': auth_header}
+
+    def to_postdata(self):
+        """Serialize as post data for a POST request."""
+        return '&'.join(['%s=%s' % (escape(str(k)), escape(str(v))) \
+            for k, v in self.parameters.iteritems()])
+
+    def to_url(self):
+        """Serialize as a URL for a GET request."""
+        return '%s?%s' % (self.get_normalized_http_url(), self.to_postdata())
+
+    def get_normalized_parameters(self):
+        """Return a string that contains the parameters that must be signed."""
+        params = self.parameters
+        try:
+            # Exclude the signature if it exists.
+            del params['oauth_signature']
+        except:
+            pass
+        # Escape key values before sorting.
+        key_values = [(escape(_utf8_str(k)), escape(_utf8_str(v))) \
+            for k,v in params.items()]
+        # Sort lexicographically, first after key, then after value.
+        key_values.sort()
+        # Combine key value pairs into a string.
+        return '&'.join(['%s=%s' % (k, v) for k, v in key_values])
+
+    def get_normalized_http_method(self):
+        """Uppercases the http method."""
+        return self.http_method.upper()
+
+    def get_normalized_http_url(self):
+        """Parses the URL and rebuilds it to be scheme://host/path."""
+        parts = urlparse.urlparse(self.http_url)
+        scheme, netloc, path = parts[:3]
+        # Exclude default port numbers.
+        if scheme == 'http' and netloc[-3:] == ':80':
+            netloc = netloc[:-3]
+        elif scheme == 'https' and netloc[-4:] == ':443':
+            netloc = netloc[:-4]
+        return '%s://%s%s' % (scheme, netloc, path)
+
+    def sign_request(self, signature_method, consumer, token):
+        """Set the signature parameter to the result of build_signature."""
+        # Set the signature method.
+        self.set_parameter('oauth_signature_method',
+            signature_method.get_name())
+        # Set the signature.
+        self.set_parameter('oauth_signature',
+            self.build_signature(signature_method, consumer, token))
+
+    def build_signature(self, signature_method, consumer, token):
+        """Calls the build signature method within the signature method."""
+        return signature_method.build_signature(self, consumer, token)
+
+    def from_request(http_method, http_url, headers=None, parameters=None,
+            query_string=None):
+        """Combines multiple parameter sources."""
+        if parameters is None:
+            parameters = {}
+
+        # Headers
+        if headers and 'Authorization' in headers:
+            auth_header = headers['Authorization']
+            # Check that the authorization header is OAuth.
+            if auth_header[:6] == 'OAuth ':
+                auth_header = auth_header[6:]
+                try:
+                    # Get the parameters from the header.
+                    header_params = OAuthRequest._split_header(auth_header)
+                    parameters.update(header_params)
+                except:
+                    raise OAuthError('Unable to parse OAuth parameters from '
+                        'Authorization header.')
+
+        # GET or POST query string.
+        if query_string:
+            query_params = OAuthRequest._split_url_string(query_string)
+            parameters.update(query_params)
+
+        # URL parameters.
+        param_str = urlparse.urlparse(http_url)[4] # query
+        url_params = OAuthRequest._split_url_string(param_str)
+        parameters.update(url_params)
+
+        if parameters:
+            return OAuthRequest(http_method, http_url, parameters)
+
+        return None
+    from_request = staticmethod(from_request)
+
+    def from_consumer_and_token(oauth_consumer, token=None,
+            callback=None, verifier=None, http_method=HTTP_METHOD,
+            http_url=None, parameters=None):
+        if not parameters:
+            parameters = {}
+
+        defaults = {
+            'oauth_consumer_key': oauth_consumer.key,
+            'oauth_timestamp': generate_timestamp(),
+            'oauth_nonce': generate_nonce(),
+            'oauth_version': OAuthRequest.version,
+        }
+
+        defaults.update(parameters)
+        parameters = defaults
+
+        if token:
+            parameters['oauth_token'] = token.key
+            if token.callback:
+                parameters['oauth_callback'] = token.callback
+            # 1.0a support for verifier.
+            if verifier:
+                parameters['oauth_verifier'] = verifier
+        elif callback:
+            # 1.0a support for callback in the request token request.
+            parameters['oauth_callback'] = callback
+
+        return OAuthRequest(http_method, http_url, parameters)
+    from_consumer_and_token = staticmethod(from_consumer_and_token)
+
+    def from_token_and_callback(token, callback=None, http_method=HTTP_METHOD,
+            http_url=None, parameters=None):
+        if not parameters:
+            parameters = {}
+
+        parameters['oauth_token'] = token.key
+
+        if callback:
+            parameters['oauth_callback'] = callback
+
+        return OAuthRequest(http_method, http_url, parameters)
+    from_token_and_callback = staticmethod(from_token_and_callback)
+
+    def _split_header(header):
+        """Turn Authorization: header into parameters."""
+        params = {}
+        parts = header.split(',')
+        for param in parts:
+            # Ignore realm parameter.
+            if param.find('realm') > -1:
+                continue
+            # Remove whitespace.
+            param = param.strip()
+            # Split key-value.
+            param_parts = param.split('=', 1)
+            # Remove quotes and unescape the value.
+            params[param_parts[0]] = urllib.unquote(param_parts[1].strip('\"'))
+        return params
+    _split_header = staticmethod(_split_header)
+
+    def _split_url_string(param_str):
+        """Turn URL string into parameters."""
+        parameters = cgi.parse_qs(param_str, keep_blank_values=False)
+        for k, v in parameters.iteritems():
+            parameters[k] = urllib.unquote(v[0])
+        return parameters
+    _split_url_string = staticmethod(_split_url_string)
+
+class OAuthServer(object):
+    """A worker to check the validity of a request against a data store."""
+    timestamp_threshold = 300 # In seconds, five minutes.
+    version = VERSION
+    signature_methods = None
+    data_store = None
+
+    def __init__(self, data_store=None, signature_methods=None):
+        self.data_store = data_store
+        self.signature_methods = signature_methods or {}
+
+    def set_data_store(self, data_store):
+        self.data_store = data_store
+
+    def get_data_store(self):
+        return self.data_store
+
+    def add_signature_method(self, signature_method):
+        self.signature_methods[signature_method.get_name()] = signature_method
+        return self.signature_methods
+
+    def fetch_request_token(self, oauth_request):
+        """Processes a request_token request and returns the
+        request token on success.
+        """
+        try:
+            # Get the request token for authorization.
+            token = self._get_token(oauth_request, 'request')
+        except OAuthError:
+            # No token required for the initial token request.
+            version = self._get_version(oauth_request)
+            consumer = self._get_consumer(oauth_request)
+            try:
+                callback = self.get_callback(oauth_request)
+            except OAuthError:
+                callback = None # 1.0, no callback specified.
+            self._check_signature(oauth_request, consumer, None)
+            # Fetch a new token.
+            token = self.data_store.fetch_request_token(consumer, callback)
+        return token
+
+    def fetch_access_token(self, oauth_request):
+        """Processes an access_token request and returns the
+        access token on success.
+        """
+        version = self._get_version(oauth_request)
+        consumer = self._get_consumer(oauth_request)
+        try:
+            verifier = self._get_verifier(oauth_request)
+        except OAuthError:
+            verifier = None
+        # Get the request token.
+        token = self._get_token(oauth_request, 'request')
+        self._check_signature(oauth_request, consumer, token)
+        new_token = self.data_store.fetch_access_token(consumer, token, verifier)
+        return new_token
+
+    def verify_request(self, oauth_request):
+        """Verifies an api call and checks all the parameters."""
+        # -> consumer and token
+        version = self._get_version(oauth_request)
+        consumer = self._get_consumer(oauth_request)
+        # Get the access token.
+        token = self._get_token(oauth_request, 'access')
+        self._check_signature(oauth_request, consumer, token)
+        parameters = oauth_request.get_nonoauth_parameters()
+        return consumer, token, parameters
+
+    def authorize_token(self, token, user):
+        """Authorize a request token."""
+        return self.data_store.authorize_request_token(token, user)
+
+    def get_callback(self, oauth_request):
+        """Get the callback URL."""
+        return oauth_request.get_parameter('oauth_callback')
+    def build_authenticate_header(self, realm=''):
+        """Optional support for the authenticate header."""
+        return {'WWW-Authenticate': 'OAuth realm="%s"' % realm}
+
+    def _get_version(self, oauth_request):
+        """Verify the correct version request for this server."""
+        try:
+            version = oauth_request.get_parameter('oauth_version')
+        except:
+            version = VERSION
+        if version and version != self.version:
+            raise OAuthError('OAuth version %s not supported.' % str(version))
+        return version
+
+    def _get_signature_method(self, oauth_request):
+        """Figure out the signature with some defaults."""
+        try:
+            signature_method = oauth_request.get_parameter(
+                'oauth_signature_method')
+        except:
+            signature_method = SIGNATURE_METHOD
+        try:
+            # Get the signature method object.
+            signature_method = self.signature_methods[signature_method]
+        except:
+            signature_method_names = ', '.join(self.signature_methods.keys())
+            raise OAuthError('Signature method %s not supported try one of the '
+                'following: %s' % (signature_method, signature_method_names))
+
+        return signature_method
+
+    def _get_consumer(self, oauth_request):
+        consumer_key = oauth_request.get_parameter('oauth_consumer_key')
+        consumer = self.data_store.lookup_consumer(consumer_key)
+        if not consumer:
+            raise OAuthError('Invalid consumer.')
+        return consumer
+
+    def _get_token(self, oauth_request, token_type='access'):
+        """Try to find the token for the provided request token key."""
+        token_field = oauth_request.get_parameter('oauth_token')
+        token = self.data_store.lookup_token(token_type, token_field)
+        if not token:
+            raise OAuthError('Invalid %s token: %s' % (token_type, token_field))
+        return token
+    
+    def _get_verifier(self, oauth_request):
+        return oauth_request.get_parameter('oauth_verifier')
+
+    def _check_signature(self, oauth_request, consumer, token):
+        timestamp, nonce = oauth_request._get_timestamp_nonce()
+        self._check_timestamp(timestamp)
+        self._check_nonce(consumer, token, nonce)
+        signature_method = self._get_signature_method(oauth_request)
+        try:
+            signature = oauth_request.get_parameter('oauth_signature')
+        except:
+            raise OAuthError('Missing signature.')
+        # Validate the signature.
+        valid_sig = signature_method.check_signature(oauth_request, consumer,
+            token, signature)
+        if not valid_sig:
+            key, base = signature_method.build_signature_base_string(
+                oauth_request, consumer, token)
+            raise OAuthError('Invalid signature. Expected signature base '
+                'string: %s' % base)
+        built = signature_method.build_signature(oauth_request, consumer, token)
+
+    def _check_timestamp(self, timestamp):
+        """Verify that timestamp is recentish."""
+        timestamp = int(timestamp)
+        now = int(time.time())
+        lapsed = abs(now - timestamp)
+        if lapsed > self.timestamp_threshold:
+            raise OAuthError('Expired timestamp: given %d and now %s has a '
+                'greater difference than threshold %d' %
+                (timestamp, now, self.timestamp_threshold))
+
+    def _check_nonce(self, consumer, token, nonce):
+        """Verify that the nonce is uniqueish."""
+        nonce = self.data_store.lookup_nonce(consumer, token, nonce)
+        if nonce:
+            raise OAuthError('Nonce already used: %s' % str(nonce))
+
+
+class OAuthClient(object):
+    """OAuthClient is a worker to attempt to execute a request."""
+    consumer = None
+    token = None
+
+    def __init__(self, oauth_consumer, oauth_token):
+        self.consumer = oauth_consumer
+        self.token = oauth_token
+
+    def get_consumer(self):
+        return self.consumer
+
+    def get_token(self):
+        return self.token
+
+    def fetch_request_token(self, oauth_request):
+        """-> OAuthToken."""
+        raise NotImplementedError
+
+    def fetch_access_token(self, oauth_request):
+        """-> OAuthToken."""
+        raise NotImplementedError
+
+    def access_resource(self, oauth_request):
+        """-> Some protected resource."""
+        raise NotImplementedError
+
+
+class OAuthDataStore(object):
+    """A database abstraction used to lookup consumers and tokens."""
+
+    def lookup_consumer(self, key):
+        """-> OAuthConsumer."""
+        raise NotImplementedError
+
+    def lookup_token(self, oauth_consumer, token_type, token_token):
+        """-> OAuthToken."""
+        raise NotImplementedError
+
+    def lookup_nonce(self, oauth_consumer, oauth_token, nonce):
+        """-> OAuthToken."""
+        raise NotImplementedError
+
+    def fetch_request_token(self, oauth_consumer, oauth_callback):
+        """-> OAuthToken."""
+        raise NotImplementedError
+
+    def fetch_access_token(self, oauth_consumer, oauth_token, oauth_verifier):
+        """-> OAuthToken."""
+        raise NotImplementedError
+
+    def authorize_request_token(self, oauth_token, user):
+        """-> OAuthToken."""
+        raise NotImplementedError
+
+
+class OAuthSignatureMethod(object):
+    """A strategy class that implements a signature method."""
+    def get_name(self):
+        """-> str."""
+        raise NotImplementedError
+
+    def build_signature_base_string(self, oauth_request, oauth_consumer, oauth_token):
+        """-> str key, str raw."""
+        raise NotImplementedError
+
+    def build_signature(self, oauth_request, oauth_consumer, oauth_token):
+        """-> str."""
+        raise NotImplementedError
+
+    def check_signature(self, oauth_request, consumer, token, signature):
+        built = self.build_signature(oauth_request, consumer, token)
+        return built == signature
+
+
+class OAuthSignatureMethod_HMAC_SHA1(OAuthSignatureMethod):
+
+    def get_name(self):
+        return 'HMAC-SHA1'
+        
+    def build_signature_base_string(self, oauth_request, consumer, token):
+        sig = (
+            escape(oauth_request.get_normalized_http_method()),
+            escape(oauth_request.get_normalized_http_url()),
+            escape(oauth_request.get_normalized_parameters()),
+        )
+
+        key = '%s&' % escape(consumer.secret)
+        if token:
+            key += escape(token.secret)
+        raw = '&'.join(sig)
+        return key, raw
+
+    def build_signature(self, oauth_request, consumer, token):
+        """Builds the base signature string."""
+        key, raw = self.build_signature_base_string(oauth_request, consumer,
+            token)
+
+        # HMAC object.
+        try:
+            import hashlib # 2.5
+            hashed = hmac.new(key, raw, hashlib.sha1)
+        except:
+            import sha # Deprecated
+            hashed = hmac.new(key, raw, sha)
+
+        # Calculate the digest base 64.
+        return binascii.b2a_base64(hashed.digest())[:-1]
+
+
+class OAuthSignatureMethod_PLAINTEXT(OAuthSignatureMethod):
+
+    def get_name(self):
+        return 'PLAINTEXT'
+
+    def build_signature_base_string(self, oauth_request, consumer, token):
+        """Concatenates the consumer key and secret."""
+        sig = '%s&' % escape(consumer.secret)
+        if token:
+            sig = sig + escape(token.secret)
+        return sig, sig
+
+    def build_signature(self, oauth_request, consumer, token):
+        key, raw = self.build_signature_base_string(oauth_request, consumer,
+            token)
+        return key
\ No newline at end of file
index 8d4a367..0b8cbd4 100644 (file)
@@ -79,33 +79,43 @@ class Contact:
            photo is wider than it is tall, it will be cropped with a bias towards
            the top of the photo."""
         
-        f = urllib.urlopen(url)
-        data = ''
-        while True:
-            read_data = f.read()
-            data += read_data
-            if not read_data:
-                break
-        
-        im = Image.open(StringIO.StringIO(data))
-        (w, h) = im.size
-        if (h > w):
-            print "Shrinking photo for %s as it's %d x %d" % (self._contact.get_name(), w, h)
-            im = ImageOps.fit(im, (w, w), Image.NEAREST, 0, (0, 0.1))
-          
-        print "Updating photo for %s" % (self._contact.get_name())
-        f = StringIO.StringIO()
-        im.save(f, "JPEG")
-        image_data = f.getvalue()
-        photo = EContactPhoto()
-        photo.type = 0
-        photo.data = EContactPhoto_data()
-        photo.data.inlined = EContactPhoto_inlined()
-        photo.data.inlined.mime_type = cast(create_string_buffer("image/jpeg"), c_char_p)
-        photo.data.inlined.length = len(image_data)
-        photo.data.inlined.data = cast(create_string_buffer(image_data), c_void_p)
-        ebook.e_contact_set(hash(self._contact), E_CONTACT_PHOTO, addressof(photo))
-        return True
+        try:
+            f = urllib.urlopen(url)
+            data = ''
+            while True:
+                read_data = f.read()
+                data += read_data
+                if not read_data:
+                    break
+            
+            im = Image.open(StringIO.StringIO(data))
+            (w, h) = im.size
+            if (h > w):
+                ##print u"Shrinking photo for %s as it's %d x %d" % (self._contact.get_name(), w, h)
+                im = ImageOps.fit(im, (w, w), Image.NEAREST, 0, (0, 0.1))
+              
+            ## print u"Updating photo for %s" % (self._contact.get_name())
+            f = StringIO.StringIO()
+            im.save(f, "JPEG")
+            image_data = f.getvalue()
+            photo = EContactPhoto()
+            photo.type = 0
+            photo.data = EContactPhoto_data()
+            photo.data.inlined = EContactPhoto_inlined()
+            photo.data.inlined.mime_type = cast(create_string_buffer("image/jpeg"), c_char_p)
+            photo.data.inlined.length = len(image_data)
+            photo.data.inlined.data = cast(create_string_buffer(image_data), c_void_p)
+            ebook.e_contact_set(hash(self._contact), E_CONTACT_PHOTO, addressof(photo))
+            return True
+        except:
+            print "FAILED to get photo from URL %s" % url
+            return False
+    
+    
+    # -----------------------------------------------------------------------
+    def has_photo(self):
+        # FIXME
+        return False
       
       
     # -----------------------------------------------------------------------
@@ -119,12 +129,21 @@ class Contact:
         birthday.year = year
         birthday.month = month
         birthday.day = day
-        print "Setting birthday for [%s] to %d-%d-%d" % (self._contact.get_name(), year, month, day)
+        print u"Setting birthday for [%s] to %d-%d-%d" % (self._contact.get_name(), year, month, day)
         ebook.e_contact_set(hash(self._contact), E_CONTACT_BIRTHDAY_DATE, addressof(birthday))
         return True
     
     
     # -----------------------------------------------------------------------
+    def set_nickname(self, nickname):
+        """Set the nickname for this contact to the given nickname."""
+        
+        # FIXME does this work?
+        self._contact.set_property('nickname', nickname)
+        #ebook.e_contact_set(hash(self._contact), E_NICKNAME, addressof(nickname))
+    
+    
+    # -----------------------------------------------------------------------
     def get_emails(self):
         """Return the email addresses associated with this contact."""
         
@@ -133,7 +152,7 @@ class Contact:
         while ai.has_next():
             attr = ai.next(as_a = EVCardAttribute)
             if not attr:
-                raise Exception("Unexpected null attribute for [" + self._contact.get_name() + "] with emails " + emails)
+                raise Exception(u"Unexpected null attribute for [" + self._contact.get_name() + "] with emails " + emails)
             emails.append(string_at(attr.value().next()))
           
         return emails
@@ -149,7 +168,7 @@ class Contact:
         while ai.has_next():
             attr = ai.next(as_a = EVCardAttribute)
             if not attr:
-                raise Exception("Unexpected null attribute for [" + self._contact.get_name() + "] with URLs " + urls)
+                raise Exception(u"Unexpected null attribute for [" + self._contact.get_name() + "] with URLs " + urls)
             urls.append(string_at(attr.value().next()))
           
         return urls
@@ -189,7 +208,7 @@ class Contact:
             url_attr.name = 'URL'
         
         val = GList()
-        print "Setting URL for [%s] to [%s]" % (self._contact.get_name(), url)
+        ##print u"Setting URL for [%s] to [%s]" % (self._contact.get_name(), url)
         val.set(create_string_buffer(url))
         ai.set(addressof(url_attr))
         url_attr.values = cast(addressof(val), POINTER(GList))
index 150b345..6f754e9 100644 (file)
@@ -1,8 +1,9 @@
 import gnome.gconf
 import org.maemo.hermes.engine.service
 
-from facebook import Facebook
+from facebook import Facebook,FacebookError
 from org.maemo.hermes.engine.names import canonical
+from org.maemo.hermes.engine.friend import Friend
 
 class Service(org.maemo.hermes.engine.service.Service):
     """Facebook backend for Hermes.
@@ -39,8 +40,16 @@ class Service(org.maemo.hermes.engine.service.Service):
         
         self._friends = None
         self._friends_by_url = {}
+        self._friends_by_contact = {}
         self._should_create = set()
+        
+        self.get_friends()
+
 
+    # -----------------------------------------------------------------------
+    def get_name(self):
+        return "Facebook"
+    
 
     # -----------------------------------------------------------------------
     def _do_fb_login(self):
@@ -90,58 +99,80 @@ class Service(org.maemo.hermes.engine.service.Service):
                 pass
             self._do_fb_login()
             
+            
+        def if_defined(data, key, callback):
+            if key in data and data[key]:
+                callback(data[key])
+        
         self._friends = {}
         attrs = ['uid', 'name', 'pic_big', 'birthday_date', 'profile_url', 'first_name', 'last_name', 'website']
-        for friend in self.fb.users.getInfo(self.fb.friends.get(), attrs):
-            print "#"
-            friend['key']= canonical(friend['name'])
-            self._friends[friend['key']] = friend
-        
-            if 'profile_url' not in friend:
-                friend['profile_url'] = "http://www.facebook.com/profile.php?id=" + str(friend ['uid'])
+        for data in self.fb.users.getInfo(self.fb.friends.get(), attrs):
+            key = canonical(data['name'])
+            friend = Friend(data['name'])
+            self._friends[key] = friend
         
-            self._friends_by_url[friend['profile_url']] = friend
-            friend['pic']  = friend[attrs[2]]
-            friend['account'] = 'Facebook'
+            if 'profile_url' not in data:
+                data['profile_url'] = "http://www.facebook.com/profile.php?id=" + str(data['uid'])
         
-            if 'website' in friend and friend['website']:
-                friend['homepage'] = friend['website']
-                
-            if 'birthday_date' in friend and friend['birthday_date']:
-                self._should_create.add(friend['profile_url'])
+            self._friends_by_url[data['profile_url']] = friend
+            
+
+            if_defined(data, 'website', friend.add_url)
+            if_defined(data, 'profile_url', friend.add_url)
+            if_defined(data, 'birthday_date', friend.set_birthday_date)
+
+            # exception from the rule:
+#            if_defined(data, 'pic_big', friend.set_photo_url)
+            friend.set_photo_url(data[attrs[2]])
+            
+            if friend.has_birthday_date():
+                self._should_create.add(friend)
                 
         return self._friends.values()
 
+
+    # -----------------------------------------------------------------------
+    def pre_process_contact(self, contact):
+        
+        if not self._friends:
+            self.get_friends()
+        
+        for url in contact.get_urls():
+            if url in self._friends_by_url:
+                matched_friend = self._friends_by_url[url]
+                self._friends_by_contact[contact] = matched_friend
+                self._should_create.discard(matched_friend)
+        
     
     # -----------------------------------------------------------------------
-    def process_contact(self, contact, overwrite = False, friend = None):
-        """Called for each contact in the address book. Any friends linked to
-           from the contact should have their matching updated. The backend should 
-           enrich the contact with any meta-data it can; and return 'True' if any
-           information was updated. If 'friend' is provided, the information should
-           be retrieved from friend. This will be one of the entries from 
-           'get_friends' when manual mapping is being done."""
+    def process_contact(self, contact, friend):
            
         if not self._friends:
             self.get_friends()
-            
+
+        if self._friends_by_contact.has_key(contact):
+            friend.update(self._friends_by_contact[contact])
+            return
+        
         matched_friend = None
+        # we might get a hit if the friend has setup a URL with another service 
         for url in contact.get_urls():
             if url in self._friends_by_url:
                 matched_friend = self._friends_by_url[url]
+                self._friends_by_contact[contact] = matched_friend
                 break
 
         if not matched_friend:
             for id in contact.get_identifiers():
                 if id in self._friends:
                     matched_friend = self._friends[id]
+                    self._friends_by_contact[contact] = matched_friend
                     break
                 
         if matched_friend:
-            print contact.get_name(), " -> ", matched_friend['profile_url']
-            self._should_create.discard(matched_friend['profile_url'])
-        else:
-            print contact.get_name(), " no match to Facebook with ", contact.get_identifiers()
+            print contact.get_name(), " -> ", matched_friend
+            self._should_create.discard(matched_friend)
+        
             
     
     
@@ -151,6 +182,6 @@ class Service(org.maemo.hermes.engine.service.Service):
            enrichment. If any contacts are updated at this stage, 'updated' should
            be added to."""
 
-        if self._autocreate:
-            for url in self._should_create:
-                print "Need to autocreate:", self._friends_by_url[url]
+        if True or self._autocreate: # FIXME - remove True or
+            for friend in self._should_create:
+                print "Need to autocreate: %s (%s)" % (friend._attributes, friend._multi_attributes)
diff --git a/package/src/org/maemo/hermes/engine/friend.py b/package/src/org/maemo/hermes/engine/friend.py
new file mode 100644 (file)
index 0000000..0264aaa
--- /dev/null
@@ -0,0 +1,89 @@
+class Friend():
+    
+    def __init__(self, name):
+        self._attributes = { "fn": name }
+        self._multi_attributes = {}
+        
+    def __unicode__(self):
+        return self.__repr__()
+    
+    def __repr__(self):
+        return "Friend %s" % self._attributes['fn']
+    
+    # public accessors -----------------
+    
+    def add_url(self, url):
+        self._add('url', url)
+        
+    def is_empty(self):
+        for a in self._attributes:
+            return False
+        for a in self._multi_attributes:
+            return False
+        return True
+    
+    def has_birthday_date(self):
+        return self._has('bday')
+    
+    def set_name(self, name):
+        self._set('fn', name)
+    
+    def set_nickname(self, nickname):
+        self._set('nickname', nickname)
+        
+    def set_birthday_date(self, date):
+        self._set('bday', date)
+        
+    def set_photo_url(self, url):
+        self._set('photo-url', url)
+    
+    def update(self, other_friend):
+        """
+        Overwrites any attributes in this friend, with attributes from other_friend
+        """
+        
+        self._attributes.update(other_friend._attributes)
+        
+        for key in other_friend._multi_attributes.keys():
+            for value in other_friend._multi_attributes[key]:
+                self._add(key, value)
+     
+    def update_contact(self, contact, overwrite=False):
+        """
+        Updates the contact with information from this object,
+        without overwriting unless overwrite is set to True.
+        """
+        
+        # FIXME: currently we overwrite everything 
+        self._if_defined('photo-url', contact.set_photo)
+        self._if_defined('nickname', contact.set_nickname)
+        if self._multi_attributes.has_key('url'):
+            for url in self._multi_attributes['url']:
+                contact.add_url(url)
+
+        def fixme(arg):
+            pass
+            #print "FIXME - birthday date needs to be parsed/fixed %s before calling contact.set_birthday" % arg
+        self._if_defined('bday', fixme)
+
+    # private helpers -----------------------
+    #
+    def _if_defined(self, key, callback):
+        if self._attributes.has_key(key):
+            callback(self._attributes[key])
+    
+    def _set(self, key, value):
+        if value is not None:
+#            print "%s SET %s to %s" % (self, key, value)
+            self._attributes[key] = value
+    
+    def _add(self, key, value):
+        if value is not None:
+            if not self._multi_attributes.has_key(key):
+                self._multi_attributes[key] = []
+#            print "%s ADD %s to %s" % (self, key, value)
+            self._multi_attributes[key].append(value)
+    
+    def _has(self, key):
+        return self._attributes.has_key(key) or self._multi_attributes.has_key(key)
+
diff --git a/package/src/org/maemo/hermes/engine/gravatar/__init__.py b/package/src/org/maemo/hermes/engine/gravatar/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/package/src/org/maemo/hermes/engine/gravatar/provider.py b/package/src/org/maemo/hermes/engine/gravatar/provider.py
new file mode 100644 (file)
index 0000000..07e82b1
--- /dev/null
@@ -0,0 +1,44 @@
+import gnome.gconf
+import org.maemo.hermes.engine.provider
+import org.maemo.hermes.engine.gravatar.service
+
+class Provider(org.maemo.hermes.engine.provider.Provider):
+    """Gravatar provider for Hermes. 
+
+       Copyright (c) Fredrik Wendt <fredrik@wendt.se> 2010.
+       Released under the Artistic Licence."""
+
+
+    # -----------------------------------------------------------------------
+    def __init__(self):
+        self._gconf = gnome.gconf.client_get_default()
+
+
+    # -----------------------------------------------------------------------
+    def get_name(self):
+        """Return the display name of this service. An icon, of with the lower-case,
+           all-alphabetic version of this name is expected to be provided."""
+           
+        return 'Gravatar'
+    
+    
+    # -----------------------------------------------------------------------
+    def has_preferences(self):
+        """Whether or not this provider has any preferences. If it does not,
+           open_preferences must NOT be called; as the behaviour is undetermined."""
+           
+        return False
+    
+    
+    # -----------------------------------------------------------------------
+    def open_preferences(self, parent):
+        """Open the preferences for this provider as a child of the 'parent' widget."""
+
+        print "Err, open preferences. OK. Err, right. Hmm."
+
+    
+    # -----------------------------------------------------------------------
+    def service(self, gui_callback):
+        """Return the service backend"""
+    
+        return org.maemo.hermes.engine.gravatar.service.Service()
diff --git a/package/src/org/maemo/hermes/engine/gravatar/service.py b/package/src/org/maemo/hermes/engine/gravatar/service.py
new file mode 100644 (file)
index 0000000..2f4c66b
--- /dev/null
@@ -0,0 +1,130 @@
+import urllib, hashlib, xmlrpclib
+import gnome.gconf
+import org.maemo.hermes.engine.service
+
+from org.maemo.hermes.engine.names import canonical
+
+class Service(org.maemo.hermes.engine.service.Service):
+    """Gravatar backend for Hermes.
+       
+       Copyright (c) Fredrik Wendt <fredrik@wendt.se> 2010.
+       Released under the Artistic Licence."""
+       
+    _image_url_base = "http://www.gravatar.com/avatar.php?"
+       
+    # -----------------------------------------------------------------------
+    def __init__(self):
+        """Initialise the Gravatar service"""
+        
+        self._gc = gnome.gconf.client_get_default()
+        self._address_to_contact = {}
+        self._hash_to_address= {}
+        self._hash_has_gravatar = {}
+        self._empty_cache = True
+        
+        self._api_email = self._gc.get_string('/apps/maemo/hermes/gravatar_api_email')
+        self._api_key = self._gc.get_string('/apps/maemo/hermes/gravatar_api_key')
+        self._api_email = 'maemohermes@wendt.se'
+        self._api_key = 'b14ec179822b' # FIXME
+        if self._api_key is None or self._api_email is None:
+            raise Exception('No Gravatar application keys found. Installation error.') 
+        self._api_url = 'https://secure.gravatar.com/xmlrpc?user=' + hashlib.md5(self._api_email).hexdigest()
+   
+
+    # -----------------------------------------------------------------------
+    def get_name(self):
+        return "Gravatar"
+    
+    
+    # -----------------------------------------------------------------------
+    def get_friends(self):
+        """Returns 'None' since manual mapping is not supported."""
+        
+        return None
+
+    
+    # -----------------------------------------------------------------------
+    def pre_process_contact(self, contact):
+        """Extracts addresses from the contact."""
+        for address in contact.get_emails():
+            self._address_to_contact[address] = contact
+    
+    
+    # -----------------------------------------------------------------------
+    def process_contact(self, contact, friend):
+        """On first call (with a contact missing a photo), go get gravatar existense data from the service."""
+        
+        if not self._has_photo(contact):
+            if self._empty_cache:
+                self._lookup_addresses()
+            for address in contact.get_emails():
+                hash = self._get_hash_for_address(address)
+                if (self._hash_has_gravatar[hash]):
+                    friend.set_photo_url(self._get_url_for_email_hash(hash))
+
+    
+    # -----------------------------------------------------------------------
+    def finalise(self, updated, overwrite=True):
+        """We could make a new pass over all updated contacts, in the event that another service
+           added a new e-mail address to a contact (still missing a photo) that has a gravatar setup."""
+
+        pass
+        # It's plausible that info@company.com addresses would be present for several
+        # contacts, but unlikely that there'd be a gravatar set up for such generic
+        # addresses - the point of gravatar is to make it more personal ...
+        
+        # we only look at contacts that has no photo (unless 
+#        for contact in self._contacts:
+#            if not overwrite or not self._has_photo(contact):
+#                for address in contact.get_emails():
+#                    self._address_to_contact[address] = contact
+#        self._lookup_addresses()
+#        for hash in self._hash_has_gravatar:
+#            if self._hash_has_gravatar[hash]:
+#                address = self._hash_to_address[hash]
+#                contact = self._address_to_contact[address]
+#                if not self._has_photo(contact):
+#                    url = self._get_url_for_email_hash(hash)
+#                    contact.set_photo(url)
+#                    updated.add(contact)
+        
+    # -----------------------------------------------------------------------
+    # FIXME
+    def _has_photo(self, contact):
+        return False
+    
+        
+    # -----------------------------------------------------------------------
+    def _lookup_addresses(self):
+        """Constructs hashes for address_to_contact, makes call to the Gravatar.com service and updates
+        self._hash_has_gravatar"""
+        
+        args = { "apikey" : self._api_key}
+        args["hashes"] = list(self._construct_hashes(self._address_to_contact.keys()))
+        service = xmlrpclib.ServerProxy(self._api_url)
+        self._hash_has_gravatar = service.grav.exists(args)
+        self._empty_cache = False
+    
+
+    # -----------------------------------------------------------------------
+    def _get_url_for_email_hash(self, hash, default="404", size="128"):
+        """Assembles the URL to a gravatar, based on a hash of an e-mail address"""
+    
+        return self._image_url_base + urllib.urlencode({'gravatar_id':hash, 'd':default, 'size':size})
+    
+    
+    # -----------------------------------------------------------------------
+    def _construct_hashes(self, addresses):
+        """Creates hashes for all addresses specified, returning a set of hashes"""
+    
+        result = set()
+        for address in addresses:
+            hash = self._get_hash_for_address(address)
+            self._hash_to_address[hash] = address
+            result.add(hash)
+    
+        return result
+
+    # -----------------------------------------------------------------------
+    def _get_hash_for_address(self, address):
+        return hashlib.md5(address.lower()).hexdigest()
\ No newline at end of file
diff --git a/package/src/org/maemo/hermes/engine/linkedin/__init__.py b/package/src/org/maemo/hermes/engine/linkedin/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/package/src/org/maemo/hermes/engine/linkedin/provider.py b/package/src/org/maemo/hermes/engine/linkedin/provider.py
new file mode 100644 (file)
index 0000000..40058ee
--- /dev/null
@@ -0,0 +1,42 @@
+import org.maemo.hermes.engine.provider
+import org.maemo.hermes.engine.linkedin.service
+
+class Provider(org.maemo.hermes.engine.provider.Provider):
+    """LinkedIn provider for Hermes. 
+
+       Copyright (c) Andrew Flegg <andrew@bleb.org> 2010.
+       Released under the Artistic Licence."""
+
+    # -----------------------------------------------------------------------
+    def get_name(self):
+        """Return the display name of this service. An icon, of with the lower-case,
+           all-alphabetic version of this name is expected to be provided."""
+           
+        return 'LinkedIn'
+    
+    
+    # -----------------------------------------------------------------------
+    def has_preferences(self):
+        """Whether or not this provider has any preferences. If it does not,
+           open_preferences must NOT be called; as the behaviour is undetermined."""
+           
+        return True
+    
+    
+    # -----------------------------------------------------------------------
+    def open_preferences(self, parent):
+        """Open the preferences for this provider as a child of the 'parent' widget."""
+
+        print "Err, open preferences. OK. Err, right. Hmm."
+        # FIXME: do auth:
+        # get request token
+        # get auth token
+        # open browser to have user allow data access
+        # user inputs the 5 char  
+
+    
+    # -----------------------------------------------------------------------
+    def service(self, gui_callback):
+        """Return the service backend."""
+           
+        return org.maemo.hermes.engine.linkedin.service.Service()
diff --git a/package/src/org/maemo/hermes/engine/linkedin/service.py b/package/src/org/maemo/hermes/engine/linkedin/service.py
new file mode 100644 (file)
index 0000000..7077ad3
--- /dev/null
@@ -0,0 +1,245 @@
+import httplib
+import gnome.gconf
+from oauth import oauth
+from xml.dom.minidom import parseString
+
+import org.maemo.hermes.engine.service
+from org.maemo.hermes.engine.friend import Friend
+
+from org.maemo.hermes.engine.names import canonical
+
+#    httplib.HTTPSConnection.debuglevel = 1
+
+class Service(org.maemo.hermes.engine.service.Service):
+    """LinkedIn backend for Hermes.
+                
+       This sets up two gconf paths to contain LinkedIn OAuth keys:
+           /apps/maemo/hermes/linkedin_oauth
+           /apps/maemo/hermes/linkedin_verifier
+       
+       Copyright (c) Fredrik Wendt <fredrik@wendt.se> 2010.
+       Released under the Artistic Licence."""
+       
+       
+    LI_SERVER = "api.linkedin.com"
+    LI_API_URL = "https://api.linkedin.com"
+    LI_CONN_API_URL = LI_API_URL + "/v1/people/~/connections"
+
+    REQUEST_TOKEN_URL = LI_API_URL + "/uas/oauth/requestToken"
+    AUTHORIZE_URL = LI_API_URL + "/uas/oauth/authorize"
+    ACCESS_TOKEN_URL = LI_API_URL + "/uas/oauth/accessToken"
+
+
+    # -----------------------------------------------------------------------
+    def __init__(self, autocreate=False, gui_callback=None):
+        """Initialize the LinkedIn service, finding LinkedIn API keys in gconf and
+           having a gui_callback available."""
+        
+        self._gc = gnome.gconf.client_get_default()
+        self._gui = gui_callback
+        self._autocreate = autocreate
+        
+        # -- Check the environment is going to work...
+        #
+        if (self._gc.get_string('/desktop/gnome/url-handlers/http/command') == 'epiphany %s'):
+            raise Exception('Browser in gconf invalid (see NB#136012). Installation error.')
+
+        api_key = self._gc.get_string('/apps/maemo/hermes/linkedin_api_key')
+        secret_key = self._gc.get_string('/apps/maemo/hermes/linkedin_key_secret')
+
+        # FIXME: remove this
+        api_key = '1et4G-VtmtqNfY7gF8PHtxMOf0KNWl9ericlTEtdKJeoA4ubk4wEQwf8lSL8AnYE'
+        secret_key = 'uk--OtmWcxER-Yh6Py5p0VeLPNlDJSMaXj1xfHILoFzrK7fM9eepNo5RbwGdkRo_'
+
+        if api_key is None or secret_key is None:
+            raise Exception('No LinkedIn application keys found. Installation error.')
+
+        self.api_key = api_key
+        self.secret_key = secret_key
+
+        # FIXME: move this
+        token_str = "oauth_token_secret=60f817af-6437-4015-962f-cc3aefee0264&oauth_token=f89c2b7b-1c12-4f83-a469-838e78901716"
+        self.access_token = oauth.OAuthToken.from_string(token_str)
+
+        self.consumer = oauth.OAuthConsumer(api_key, secret_key)
+        self.sig_method = oauth.OAuthSignatureMethod_HMAC_SHA1()
+    
+        self._friends = None
+        self._friends_by_url = {}
+        self._friends_by_contact = {}
+        
+        self.get_friends()
+
+
+    # -----------------------------------------------------------------------
+    def get_name(self):
+        return "LinkedIn"
+
+
+    # -----------------------------------------------------------------------
+    def get_friends(self):
+        """Return a list of friends from this service, or 'None' if manual mapping
+           is not supported."""
+           
+        if self._friends:
+            return self._friends.values()
+
+        # FIXME: Check the available OAuth session is still valid...
+
+        # get data
+        self._friends = {}
+        
+        xml = self._make_api_request(self.LI_CONN_API_URL)
+        dom = parseString(xml)
+        
+        self._parse_dom(dom)
+
+        print "get_friends found", len(self._friends)
+        return self._friends.values()
+
+    
+    
+    # -----------------------------------------------------------------------
+    def pre_process_contact(self, contact):
+        """Makes sure that if the contact has been mapped to a friend before,
+           remove the friend from "new friends" list."""
+        
+        for url in contact.get_urls():
+            if url in self._friends_by_url:
+                matched_friend = self._friends_by_url[url]
+                print "Contact is known as a friend on this service (by URL) %s - %s" % (url, matched_friend)
+                self._friends_by_contact[contact] = matched_friend
+
+    
+    # -----------------------------------------------------------------------
+    def process_contact(self, contact, friend):
+        """Updates friend if the contact can be mapped to a friend on LinkedIn, 
+           either by previous mapping or by identifiers."""
+        
+        if self._friends_by_contact.has_key(contact):
+            friend.update(self._friends_by_contact[contact])
+        else:
+            self._match_contact_by_identifiers(contact, friend)
+    
+
+    # -----------------------------------------------------------------------
+    def finalise(self, updated):
+        if self._autocreate or True:
+            for f in self._friends.values():
+                if f not in self._friends_by_contact.values():
+                    # FIXME: create friends as contact
+                    print "Could/should create contact here for %s" % f
+
+
+    # -----------------------------------------------------------------------
+    def _get_access_token(self, token, verifier):
+        """user provides the verifier, which was displayed in the browser window"""
+        
+        oauth_request = oauth.OAuthRequest.from_consumer_and_token(self.consumer, token=token, verifier=verifier, http_url=self.ACCESS_TOKEN_URL)
+        oauth_request.sign_request(self.sig_method, self.consumer, token)
+
+        connection = httplib.HTTPSConnection(self.LI_SERVER)
+        connection.request(oauth_request.http_method, self.ACCESS_TOKEN_URL, headers=oauth_request.to_header()) 
+        response = connection.getresponse()
+        return oauth.OAuthToken.from_string(response.read())
+
+
+    # -----------------------------------------------------------------------
+    def _get_authorize_url(self, token):
+        """The URL that the user should browse to, in order to authorize the application to acess data"""
+        
+        oauth_request = oauth.OAuthRequest.from_token_and_callback(token=token, http_url=self.AUTHORIZE_URL)
+        return oauth_request.to_url()
+
+
+    # -----------------------------------------------------------------------
+    def _get_request_token(self, callback):
+        """Get a request token from LinkedIn"""
+        
+        oauth_consumer_key = self.api_key
+        oauth_request = oauth.OAuthRequest.from_consumer_and_token(self.consumer, callback=callback, http_url=self.REQUEST_TOKEN_URL)
+        oauth_request.sign_request(self.sig_method, self.consumer, None)
+
+        connection = httplib.HTTPSConnection(self.LI_SERVER)
+        connection.request(oauth_request.http_method, self.REQUEST_TOKEN_URL, headers=oauth_request.to_header())
+        response = self.connection.getresponse().read()
+        
+        token = oauth.OAuthToken.from_string(response)
+        return token
+
+
+    # -----------------------------------------------------------------------
+    def _make_api_request(self, url):
+        print "_make_api_request", url
+        oauth_request = oauth.OAuthRequest.from_consumer_and_token(self.consumer, token=self.access_token, http_url=url)
+        oauth_request.sign_request(self.sig_method, self.consumer, self.access_token)
+        connection = httplib.HTTPSConnection(self.LI_SERVER)
+        try:
+            connection.request(oauth_request.http_method, url, headers=oauth_request.to_header())
+            return connection.getresponse().read()
+        except:
+            raise Exception("Failed to contact LinkedIn at " + url)
+
+
+    # -----------------------------------------------------------------------
+    def _do_li_login(self):
+        """Perform authentication against LinkedIn and store the result in gconf
+             for later use. Uses the 'need_auth' and 'block_for_auth' methods on
+             the callback class. The former allows a message to warn the user
+             about what is about to happen to be shown; the second is to wait
+             for the user to confirm they have logged in."""
+        # FIXME
+
+
+    # -----------------------------------------------------------------------
+    def _match_contact_by_identifiers(self, contact, friend):
+        for id in contact.get_identifiers():
+            if id in self._friends:
+                matched_friend = self._friends[id]
+                if matched_friend in self._friends_by_contact.values():
+                    print "Avoiding assigning same friend to two contacts: %s " % matched_friend
+                else:
+                    self._friends_by_contact[contact] = matched_friend
+                    friend.update(matched_friend)
+
+
+    # -----------------------------------------------------------------------
+    def _parse_dom(self, dom):
+        print "parse_dom", dom
+        def get_first_tag(node, tagName):
+            tags = node.getElementsByTagName(tagName)
+            if tags and len(tags) > 0:
+                return tags[0]
+        
+        def extract(node, tagName):
+            tag = get_first_tag(node, tagName)
+            if tag:
+                return tag.firstChild.nodeValue
+            
+        def extract_public_url(node):
+            tag = get_first_tag(node, 'site-standard-profile-request')
+            if tag:
+                url = extract(tag, 'url')
+                return url.replace("&amp;", "&")
+        
+        # FIXME: look for <error>
+        people = dom.getElementsByTagName('person')
+        for p in people:
+ #           try:
+            if True:
+                fn = extract(p, 'first-name')
+                ln = extract(p, 'last-name')
+                photo_url = extract(p, 'picture-url')
+                id = extract(p, 'id')
+                public_url = extract_public_url(p)
+
+                name = fn + " " + ln
+                friend = Friend(name)
+                friend.add_url(public_url)
+                if photo_url: friend.set_photo_url(photo_url)
+
+                key = canonical(name)
+                self._friends[key] = friend
+                self._friends_by_url[public_url] = friend
+#            except:
+#                continue   
index eb5987b..6059bf6 100644 (file)
@@ -4,29 +4,46 @@ class Service:
        
        Copyright (c) Andrew Flegg <andrew@bleb.org> 2010.
        Released under the Artistic Licence."""
+       
+    def __init__(self):
+        """Should make initial calls to the service and retrieve a list of friends."""
 
 
     # -----------------------------------------------------------------------
-    def get_friends(self):
-        """Return a list of friends from this service, or 'None' if manual mapping
-           is not supported."""
+    def get_name(self):
+        """Should return the same name as the provider class - debugging only - REMOVE/FIXME"""
+        
+        return None
+
+    
+    # -----------------------------------------------------------------------
+    def pre_process_contact(self, contact):
+        """If the contact have an URL (or similar) that proves an existing matching
+           to a friend on this service, then this should be remembered to avoid
+           name collission/mapping the same friend to other contacts with the same 
+           name."""
          
         return None
 
     
     # -----------------------------------------------------------------------
-    def process_contact(self, contact, overwrite = False, friend = None):
+    def process_contact(self, contact, friend):
         """Called for each contact in the address book. Any friends linked to
-           from the contact should have their matching updated. The backend should 
-           enrich the contact with any meta-data it can; and return 'True' if any
-           information was updated. If 'friend' is provided, the information should
-           be retrieved from friend. This will be one of the entries from 
-           'get_friends' when manual mapping is being done."""
+           from the contact should have their matching updated. The back end should 
+           enrich the friend passed with any meta-data it can."""
            
         pass
     
     
     # -----------------------------------------------------------------------
+    def get_unmatched_friends(self):
+        """Return a list of friends not matched to a contact, or 'None' if manual mapping
+           is not supported."""
+         
+        return None
+
+
+    # -----------------------------------------------------------------------
     def finalise(self, updated, overwrite = False):
         """Once all contacts have been processed, allows for any tidy-up/additional
            enrichment. If any contacts are updated at this stage, 'updated' should
index f20384f..a6f4748 100644 (file)
@@ -2,6 +2,7 @@ import org.maemo.hermes.engine.service
 
 import twitter
 from org.maemo.hermes.engine.names import canonical
+from org.maemo.hermes.engine.friend import Friend
 
 class Service(org.maemo.hermes.engine.service.Service):
     """Twitter backend for Hermes.
@@ -21,9 +22,15 @@ class Service(org.maemo.hermes.engine.service.Service):
         
         self._friends = None
         self._friends_by_url = {}
+        self._friends_by_contact = {}
 
    
     # -----------------------------------------------------------------------
+    def get_name(self):
+        return "Twitter"
+    
+    
+    # -----------------------------------------------------------------------
     def get_friends(self):
         """Return a list of friends from this service, or 'None' if manual mapping
            is not supported."""
@@ -37,11 +44,12 @@ class Service(org.maemo.hermes.engine.service.Service):
         for tweeter in api.GetFriends():
             key    = canonical(tweeter.name)
             url    = 'http://twitter.com/%s' % (tweeter.screen_name)
-            friend = {'name':          tweeter.name, 'pic': tweeter.profile_image_url,
-                      'birthday_date': None,         'profile_url': url,
-                      'homepage':      tweeter.url,  'account': 'twitter'}
-            if friend['pic'].find('/default_profile') > -1:
-                friend['pic'] = None
+            friend = Friend(tweeter.name)
+            friend.set_nickname(tweeter.screen_name)
+            friend.add_url(url)
+            friend.add_url(tweeter.url)
+            if '/default_profile' not in tweeter.profile_image_url:
+                friend.set_photo_url(tweeter.profile_image_url)
           
             self._friends[key] = friend
             self._friends_by_url[url] = friend
@@ -50,7 +58,18 @@ class Service(org.maemo.hermes.engine.service.Service):
 
     
     # -----------------------------------------------------------------------
-    def process_contact(self, contact, overwrite = False, friend = None):
+    def pre_process_contact(self, contact):
+        if not self._friends:
+            self.get_friends()
+            
+        for url in contact.get_urls():
+            if url in self._friends_by_url:
+                matched_friend = self._friends_by_url[url]
+                self._friends_by_contact[contact] = matched_friend
+
+
+    # -----------------------------------------------------------------------
+    def process_contact(self, contact, friend):
         """Called for each contact in the address book. Any friends linked to
            from the contact should have their matching updated. The backend should 
            enrich the contact with any meta-data it can; and return 'True' if any
@@ -60,24 +79,22 @@ class Service(org.maemo.hermes.engine.service.Service):
            
         if not self._friends:
             self.get_friends()
-            
-        matched_friend = None
-        for url in contact.get_urls():
-            if url in self._friends_by_url:
-                matched_friend = self._friends_by_url[url]
-                break
-
-        if not matched_friend:
-            for id in contact.get_identifiers():
-                if id in self._friends:
-                    matched_friend = self._friends[id]
-                    break
+        
+        if self._friends_by_contact.has_key(contact):
+            friend.update(self._friends_by_contact[contact])
+            return
+        
+        for id in contact.get_identifiers():
+            if id in self._friends:
+                matched_friend = self._friends[id]
+                print contact.get_name(), " -> ", matched_friend
+                if matched_friend in self._friends_by_contact.values():
+                    print "COLLISSION avoided for %s" % friend
+                else:
+                    print "Match found %s" % friend
+                    self._friends_by_contact[contact] = matched_friend
+                    friend.update(matched_friend)
                 
-        if matched_friend:
-            print contact.get_name(), " -> ", matched_friend['profile_url']
-        else:
-            print contact.get_name(), " no match to Twitter with ", contact.get_identifiers()
-            
     
     
     # -----------------------------------------------------------------------
index 46fbd6e..ffa65d0 100644 (file)
@@ -2,26 +2,59 @@
 
 import evolution
 from org.maemo.hermes.engine.contact import Contact
+from org.maemo.hermes.engine.friend import Friend
 from org.maemo.hermes.gui.console import ConsoleUICallback
 import org.maemo.hermes.engine.facebook.provider
 import org.maemo.hermes.engine.twitter.provider
+import org.maemo.hermes.engine.gravatar.provider
+import org.maemo.hermes.engine.linkedin.provider
 
 
 providers = [
      org.maemo.hermes.engine.facebook.provider.Provider(),
-     org.maemo.hermes.engine.twitter.provider.Provider()
+     org.maemo.hermes.engine.twitter.provider.Provider(),
+     org.maemo.hermes.engine.gravatar.provider.Provider(),
+     org.maemo.hermes.engine.linkedin.provider.Provider(),
 ]
              
 ui = ConsoleUICallback()
+
+# assemble services, least important (prioritized) first
+print "+++ Initialising Services"
 services = []
 for provider in providers:
     print "Using %s" % (provider.get_name())
     services.append(provider.service(ui))
 
+contacts = []
+updated_contacts = set()
+unmatched_contacts = set()
+overwrite = False
+
+print "\n+++ Pre processing"
 addresses = evolution.ebook.open_addressbook('default')
 for econtact in addresses.get_all_contacts():
     contact = Contact(addresses, econtact)
-    print "+++ %s" % (contact.get_name())
+    contacts.append(contact)
+    for service in services:
+        print "    Pre processing %s with %s" % (contact, service.get_name())
+        service.pre_process_contact(contact)
+
+print "\n+++ Processing"
+for contact in contacts:
+    friend = Friend(contact.get_name())
     for service in services:
-        print service.process_contact(contact)
+        print "    Processing %s with %s" % (friend, service.get_name())
+        service.process_contact(contact, friend)
+    if friend.is_empty():
+        print "No service matched %s\n" % friend
+        unmatched_contacts.add(contact)
+    else:
+        print "    Updating contact data for %s\n" % friend
+        friend.update_contact(contact, overwrite)
+        updated_contacts.add(contact)
 
+print "\n+++ Finalising"
+for service in services:
+    print "    Finalising service %s" % service.get_name()
+    service.finalise(updated_contacts)
diff --git a/package/src/test_cases.txt b/package/src/test_cases.txt
new file mode 100644 (file)
index 0000000..be08f73
--- /dev/null
@@ -0,0 +1,59 @@
+given
+       clean addressbook
+       friend "Adam" on linkedin
+       friend "Adam" on twitter
+when
+       full run
+then
+       addressbook should contain one newly created contact, with data from both services
+
+
+given
+       clean addressbook
+       100 friends on linkedin
+       100 friends on twitter, out of which 50 are the same people (have the same name) as on linkedin
+when
+       full run
+then
+       addressbook should contain 150 contacts (not 200)
+
+
+# this will probably not be too uncommon if a person really has an empty address book and runs this the first
+#      time. We don't want that person to end up with many duplicates
+
+
+given
+       contact "Fredrik Wendt"
+       friend "Fredrik L. Wendt" on facebook
+when
+       full processing the contact
+then
+       the contact should be matched with the friend
+
+       
+given
+       clean addressbook
+       friend "twitter" on twitter
+       friend "linkedin" on linkedin
+       friend "facebook" on facebook
+       friend "plaxo" on plaxo
+when
+       full run
+then
+       addressbook should contain 4 contacts
+       
+# process_contact should not rely on pre_process_contact being called 
+
+
+
+given
+       clean addressbook
+       friend "adam" on twitter, with homepage set to "http://facebook/user/adam"
+       friend "adam" on facebook (with ID url "http://facebook/user/adam")
+when
+       twitter is processed
+       and facebook is processed
+then
+       addressbook should contain one contact with two website URLs (twitter and facebook)
+       
+       
\ No newline at end of file