'%( track['link'], track['title'] )
77 | html += u'
%s
'%( track['artist'] )
78 |
79 | html += ''
80 |
81 | return html
82 |
83 |
84 | def timeout(self):
85 | return 180
86 |
87 |
88 |
89 |
90 |
91 |
92 | class LastFmProvider( Provider ):
93 |
94 | def atomClass(self):
95 | return LastFmAtom
96 |
97 | def provide( self ):
98 | urls = self.clue.takeUrls(r'last\.fm/user/.')
99 | self.atoms = [ self.atomClass()( self, url ) for url in urls ]
100 |
--------------------------------------------------------------------------------
/Sparkle.framework/Versions/A/Headers/SUUpdater.h:
--------------------------------------------------------------------------------
1 | //
2 | // SUUpdater.h
3 | // Sparkle
4 | //
5 | // Created by Andy Matuschak on 1/4/06.
6 | // Copyright 2006 Andy Matuschak. All rights reserved.
7 | //
8 |
9 | #ifndef SUUPDATER_H
10 | #define SUUPDATER_H
11 |
12 | #import
13 |
14 | @class SUUpdateDriver, SUAppcastItem, SUHost, SUAppcast;
15 | @interface SUUpdater : NSObject {
16 | NSTimer *checkTimer;
17 | SUUpdateDriver *driver;
18 |
19 | SUHost *host;
20 | IBOutlet id delegate;
21 | }
22 |
23 | + (SUUpdater *)sharedUpdater;
24 | + (SUUpdater *)updaterForBundle:(NSBundle *)bundle;
25 | - (NSBundle *)hostBundle;
26 |
27 | - (void)setDelegate:(id)delegate;
28 | - delegate;
29 |
30 | - (void)setAutomaticallyChecksForUpdates:(BOOL)automaticallyChecks;
31 | - (BOOL)automaticallyChecksForUpdates;
32 |
33 | - (void)setUpdateCheckInterval:(NSTimeInterval)interval;
34 | - (NSTimeInterval)updateCheckInterval;
35 |
36 | - (void)setFeedURL:(NSURL *)feedURL;
37 | - (NSURL *)feedURL;
38 |
39 | - (void)setSendsSystemProfile:(BOOL)sendsSystemProfile;
40 | - (BOOL)sendsSystemProfile;
41 |
42 | - (void)setAutomaticallyDownloadsUpdates:(BOOL)automaticallyDownloadsUpdates;
43 | - (BOOL)automaticallyDownloadsUpdates;
44 |
45 | // This IBAction is meant for a main menu item. Hook up any menu item to this action,
46 | // and Sparkle will check for updates and report back its findings verbosely.
47 | - (IBAction)checkForUpdates:sender;
48 |
49 | // This kicks off an update meant to be programmatically initiated. That is, it will display no UI unless it actually finds an update,
50 | // in which case it proceeds as usual. If the fully automated updating is turned on, however, this will invoke that behavior, and if an
51 | // update is found, it will be downloaded and prepped for installation.
52 | - (void)checkForUpdatesInBackground;
53 |
54 | // Date of last update check. Returns null if no check has been performed.
55 | - (NSDate*)lastUpdateCheckDate;
56 |
57 | // This begins a "probing" check for updates which will not actually offer to update to that version. The delegate methods, though,
58 | // (up to updater:didFindValidUpdate: and updaterDidNotFindUpdate:), are called, so you can use that information in your UI.
59 | - (void)checkForUpdateInformation;
60 |
61 | // Call this to appropriately schedule or cancel the update checking timer according to the preferences for time interval and automatic checks. This call does not change the date of the next check, but only the internal NSTimer.
62 | - (void)resetUpdateCycle;
63 |
64 | - (BOOL)updateInProgress;
65 | @end
66 |
67 | @interface NSObject (SUUpdaterDelegateInformalProtocol)
68 | // This method allows you to add extra parameters to the appcast URL, potentially based on whether or not Sparkle will also be sending along the system profile. This method should return an array of dictionaries with keys: "key", "value", "displayKey", "displayValue", the latter two being specifically for display to the user.
69 | - (NSArray *)feedParametersForUpdater:(SUUpdater *)updater sendingSystemProfile:(BOOL)sendingProfile;
70 |
71 | // Use this to override the default behavior for Sparkle prompting the user about automatic update checks.
72 | - (BOOL)updaterShouldPromptForPermissionToCheckForUpdates:(SUUpdater *)bundle;
73 |
74 | // Implement this if you want to do some special handling with the appcast once it finishes loading.
75 | - (void)updater:(SUUpdater *)updater didFinishLoadingAppcast:(SUAppcast *)appcast;
76 |
77 | // If you're using special logic or extensions in your appcast, implement this to use your own logic for finding
78 | // a valid update, if any, in the given appcast.
79 | - (SUAppcastItem *)bestValidUpdateInAppcast:(SUAppcast *)appcast forUpdater:(SUUpdater *)bundle;
80 |
81 | // Sent when a valid update is found by the update driver.
82 | - (void)updater:(SUUpdater *)updater didFindValidUpdate:(SUAppcastItem *)update;
83 |
84 | // Sent when a valid update is not found.
85 | - (void)updaterDidNotFindUpdate:(SUUpdater *)update;
86 |
87 | // Sent immediately before installing the specified update.
88 | - (void)updater:(SUUpdater *)updater willInstallUpdate:(SUAppcastItem *)update;
89 |
90 | // Return YES to delay the relaunch until you do some processing; invoke the given NSInvocation to continue.
91 | - (BOOL)updater:(SUUpdater *)updater shouldPostponeRelaunchForUpdate:(SUAppcastItem *)update untilInvoking:(NSInvocation *)invocation;
92 |
93 | // Called immediately before relaunching.
94 | - (void)updaterWillRelaunchApplication:(SUUpdater *)updater;
95 |
96 | // This method allows you to provide a custom version comparator.
97 | // If you don't implement this method or return nil, the standard version comparator will be used.
98 | - (id )versionComparatorForUpdater:(SUUpdater *)updater;
99 |
100 | // Returns the path which is used to relaunch the client after the update is installed. By default, the path of the host bundle.
101 | - (NSString *)pathToRelaunchForUpdater:(SUUpdater *)updater;
102 |
103 | @end
104 |
105 | // Define some minimum intervals to avoid DOS-like checking attacks. These are in seconds.
106 | #ifdef DEBUG
107 | #define SU_MIN_CHECK_INTERVAL 60
108 | #else
109 | #define SU_MIN_CHECK_INTERVAL 60*60
110 | #endif
111 |
112 | #ifdef DEBUG
113 | #define SU_DEFAULT_CHECK_INTERVAL 60
114 | #else
115 | #define SU_DEFAULT_CHECK_INTERVAL 60*60*24
116 | #endif
117 |
118 | #endif
119 |
--------------------------------------------------------------------------------
/Sparkle.framework/Versions/A/Resources/SUModelTranslation.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | ADP2,1
6 | Developer Transition Kit
7 | MacBook1,1
8 | MacBook (Core Duo)
9 | MacBook2,1
10 | MacBook (Core 2 Duo)
11 | MacBook4,1
12 | MacBook (Core 2 Duo Feb 2008)
13 | MacBookAir1,1
14 | MacBook Air (January 2008)
15 | MacBookPro1,1
16 | MacBook Pro Core Duo (15-inch)
17 | MacBookPro1,2
18 | MacBook Pro Core Duo (17-inch)
19 | MacBookPro2,1
20 | MacBook Pro Core 2 Duo (17-inch)
21 | MacBookPro2,2
22 | MacBook Pro Core 2 Duo (15-inch)
23 | MacBookPro3,1
24 | MacBook Pro Core 2 Duo (15-inch LED, Core 2 Duo)
25 | MacBookPro3,2
26 | MacBook Pro Core 2 Duo (17-inch HD, Core 2 Duo)
27 | MacBookPro4,1
28 | MacBook Pro (Core 2 Duo Feb 2008)
29 | MacPro1,1
30 | Mac Pro (four-core)
31 | MacPro2,1
32 | Mac Pro (eight-core)
33 | MacPro3,1
34 | Mac Pro (January 2008 4- or 8- core "Harpertown")
35 | Macmini1,1
36 | Mac Mini (Core Solo/Duo)
37 | PowerBook1,1
38 | PowerBook G3
39 | PowerBook2,1
40 | iBook G3
41 | PowerBook2,2
42 | iBook G3 (FireWire)
43 | PowerBook2,3
44 | iBook G3
45 | PowerBook2,4
46 | iBook G3
47 | PowerBook3,1
48 | PowerBook G3 (FireWire)
49 | PowerBook3,2
50 | PowerBook G4
51 | PowerBook3,3
52 | PowerBook G4 (Gigabit Ethernet)
53 | PowerBook3,4
54 | PowerBook G4 (DVI)
55 | PowerBook3,5
56 | PowerBook G4 (1GHz / 867MHz)
57 | PowerBook4,1
58 | iBook G3 (Dual USB, Late 2001)
59 | PowerBook4,2
60 | iBook G3 (16MB VRAM)
61 | PowerBook4,3
62 | iBook G3 Opaque 16MB VRAM, 32MB VRAM, Early 2003)
63 | PowerBook5,1
64 | PowerBook G4 (17 inch)
65 | PowerBook5,2
66 | PowerBook G4 (15 inch FW 800)
67 | PowerBook5,3
68 | PowerBook G4 (17-inch 1.33GHz)
69 | PowerBook5,4
70 | PowerBook G4 (15 inch 1.5/1.33GHz)
71 | PowerBook5,5
72 | PowerBook G4 (17-inch 1.5GHz)
73 | PowerBook5,6
74 | PowerBook G4 (15 inch 1.67GHz/1.5GHz)
75 | PowerBook5,7
76 | PowerBook G4 (17-inch 1.67GHz)
77 | PowerBook5,8
78 | PowerBook G4 (Double layer SD, 15 inch)
79 | PowerBook5,9
80 | PowerBook G4 (Double layer SD, 17 inch)
81 | PowerBook6,1
82 | PowerBook G4 (12 inch)
83 | PowerBook6,2
84 | PowerBook G4 (12 inch, DVI)
85 | PowerBook6,3
86 | iBook G4
87 | PowerBook6,4
88 | PowerBook G4 (12 inch 1.33GHz)
89 | PowerBook6,5
90 | iBook G4 (Early-Late 2004)
91 | PowerBook6,7
92 | iBook G4 (Mid 2005)
93 | PowerBook6,8
94 | PowerBook G4 (12 inch 1.5GHz)
95 | PowerMac1,1
96 | Power Macintosh G3 (Blue & White)
97 | PowerMac1,2
98 | Power Macintosh G4 (PCI Graphics)
99 | PowerMac10,1
100 | Mac Mini G4
101 | PowerMac10,2
102 | Mac Mini (Late 2005)
103 | PowerMac11,2
104 | Power Macintosh G5 (Late 2005)
105 | PowerMac12,1
106 | iMac G5 (iSight)
107 | PowerMac2,1
108 | iMac G3 (Slot-loading CD-ROM)
109 | PowerMac2,2
110 | iMac G3 (Summer 2000)
111 | PowerMac3,1
112 | Power Macintosh G4 (AGP Graphics)
113 | PowerMac3,2
114 | Power Macintosh G4 (AGP Graphics)
115 | PowerMac3,3
116 | Power Macintosh G4 (Gigabit Ethernet)
117 | PowerMac3,4
118 | Power Macintosh G4 (Digital Audio)
119 | PowerMac3,5
120 | Power Macintosh G4 (Quick Silver)
121 | PowerMac3,6
122 | Power Macintosh G4 (Mirrored Drive Door)
123 | PowerMac4,1
124 | iMac G3 (Early/Summer 2001)
125 | PowerMac4,2
126 | iMac G4 (Flat Panel)
127 | PowerMac4,4
128 | eMac
129 | PowerMac4,5
130 | iMac G4 (17-inch Flat Panel)
131 | PowerMac5,1
132 | Power Macintosh G4 Cube
133 | PowerMac6,1
134 | iMac G4 (USB 2.0)
135 | PowerMac6,3
136 | iMac G4 (20-inch Flat Panel)
137 | PowerMac6,4
138 | eMac (USB 2.0, 2005)
139 | PowerMac7,2
140 | Power Macintosh G5
141 | PowerMac7,3
142 | Power Macintosh G5
143 | PowerMac8,1
144 | iMac G5
145 | PowerMac8,2
146 | iMac G5 (Ambient Light Sensor)
147 | PowerMac9,1
148 | Power Macintosh G5 (Late 2005)
149 | RackMac1,1
150 | Xserve G4
151 | RackMac1,2
152 | Xserve G4 (slot-loading, cluster node)
153 | RackMac3,1
154 | Xserve G5
155 | Xserve1,1
156 | Xserve (Intel Xeon)
157 | Xserve2,1
158 | Xserve (January 2008 quad-core)
159 | iMac1,1
160 | iMac G3 (Rev A-D)
161 | iMac4,1
162 | iMac (Core Duo)
163 | iMac4,2
164 | iMac for Education (17-inch, Core Duo)
165 | iMac5,1
166 | iMac (Core 2 Duo, 17 or 20 inch, SuperDrive)
167 | iMac5,2
168 | iMac (Core 2 Duo, 17 inch, Combo Drive)
169 | iMac6,1
170 | iMac (Core 2 Duo, 24 inch, SuperDrive)
171 | iMac8,1
172 | iMac (April 2008)
173 |
174 |
175 |
--------------------------------------------------------------------------------
/Utilities.py:
--------------------------------------------------------------------------------
1 | from Foundation import *
2 | from AppKit import *
3 | from ScriptingBridge import *
4 | from AddressBook import *
5 |
6 | import re
7 |
8 | def as_dump( obj ):
9 | methods = dir(obj)
10 | for x in dir(object()) + dir(NSObject.alloc().init()):
11 | if x in methods: methods.remove(x)
12 | methods.sort()
13 | print( obj.__class__.__name__ )
14 | print( "\n".join(map(lambda x: " - %s"%x, methods) ) )
15 |
16 | def as_app(bundle):
17 | return SBApplication.applicationWithBundleIdentifier_(bundle)
18 |
19 | def print_info(stuff):
20 | if NSUserDefaults.standardUserDefaults().boolForKey_("debug"):
21 | print(stuff)
22 |
23 | def html_escape( s ):
24 | s = re.sub(r"&", "&", s)
25 | s = re.sub(r"<", "<", s)
26 | s = re.sub(r">", ">", s)
27 | return s
28 |
29 | # this is a HACK to normalize urls. not that it's not required to return
30 | # something that's still a valid url! (though it currently does) Don't assume
31 | # that it does
32 | def normalize_url( url ):
33 | url = re.sub(r'/$', '', url) # trailing slash
34 | url = re.sub(r'^\w+://', '', url) # protocol
35 | url = re.sub(r'^www.flickr.', 'flickr.', url) # flickr special casing
36 | return url.lower() # ewwww
37 |
38 |
39 |
40 |
41 |
42 |
43 | # from http://pylonshq.com/WebHelpers/webhelpers/rails/date.py.html on 2008-02-11
44 |
45 | """Date/Time Helpers"""
46 | # Last synced with Rails copy at Revision 6080 on Feb 8th, 2007.
47 | # Note that the select_ tags are purposely not ported as they're very totally useless
48 | # and inefficient beyond comprehension.
49 |
50 | from datetime import datetime
51 | import time
52 |
53 | DEFAULT_PREFIX = 'date'
54 |
55 | def distance_of_time_in_words(from_time, to_time=0, include_seconds=False):
56 | """
57 | Reports the approximate distance in time between two datetime objects or
58 | integers as seconds.
59 |
60 | Set ``include_seconds`` to True for more more detailed approximations when
61 | distance < 1 min, 29 secs
62 |
63 | Distances are reported based on the following table:
64 |
65 | 0 <-> 29 secs => less than a minute
66 | 30 secs <-> 1 min, 29 secs => 1 minute
67 | 1 min, 30 secs <-> 44 mins, 29 secs => [2..44] minutes
68 | 44 mins, 30 secs <-> 89 mins, 29 secs => about 1 hour
69 | 89 mins, 29 secs <-> 23 hrs, 59 mins, 29 secs => about [2..24] hours
70 | 23 hrs, 59 mins, 29 secs <-> 47 hrs, 59 mins, 29 secs => 1 day
71 | 47 hrs, 59 mins, 29 secs <-> 29 days, 23 hrs, 59 mins, 29 secs => [2..29] days
72 | 29 days, 23 hrs, 59 mins, 30 secs <-> 59 days, 23 hrs, 59 mins, 29 secs => about 1 month
73 | 59 days, 23 hrs, 59 mins, 30 secs <-> 1 yr minus 31 secs => [2..12] months
74 | 1 yr minus 30 secs <-> 2 yrs minus 31 secs => about 1 year
75 | 2 yrs minus 30 secs <-> max time or date => over [2..X] years
76 |
77 | With ``include_seconds`` set to True and the difference < 1 minute 29
78 | seconds:
79 |
80 | 0-4 secs => less than 5 seconds
81 | 5-9 secs => less than 10 seconds
82 | 10-19 secs => less than 20 seconds
83 | 20-39 secs => half a minute
84 | 40-59 secs => less than a minute
85 | 60-89 secs => 1 minute
86 |
87 | Examples:
88 |
89 | >>> from datetime import datetime, timedelta
90 | >>> from_time = datetime.now()
91 | >>> distance_of_time_in_words(from_time, from_time + timedelta(minutes=50))
92 | 'about 1 hour'
93 | >>> distance_of_time_in_words(from_time, from_time + timedelta(seconds=15))
94 | 'less than a minute'
95 | >>> distance_of_time_in_words(from_time, from_time + timedelta(seconds=15), include_seconds=True)
96 | 'less than 20 seconds'
97 |
98 | Note: ``distance_of_time_in_words`` calculates one year as 365.25 days.
99 | """
100 | if isinstance(from_time, int):
101 | from_time = time.time()+from_time
102 | elif isinstance( from_time, time.struct_time ):
103 | from_time = time.mktime(from_time)
104 | else:
105 | from_time = time.mktime(from_time.timetuple())
106 | if isinstance(to_time, int):
107 | to_time = time.time()+to_time
108 | else:
109 | to_time = time.mktime(to_time.timetuple())
110 |
111 | distance_in_minutes = int(round(abs(to_time-from_time)/60))
112 | distance_in_seconds = int(round(abs(to_time-from_time)))
113 |
114 | if distance_in_minutes <= 1:
115 | if include_seconds:
116 | for remainder in [5, 10, 20]:
117 | if distance_in_seconds < remainder:
118 | return "less than %s seconds" % remainder
119 | if distance_in_seconds < 40:
120 | return "half a minute"
121 | elif distance_in_seconds < 60:
122 | return "less than a minute"
123 | else:
124 | return "1 minute"
125 | else:
126 | if distance_in_minutes == 0:
127 | return "less than a minute"
128 | else:
129 | return "1 minute"
130 | elif distance_in_minutes < 45:
131 | return "%s minutes" % distance_in_minutes
132 | elif distance_in_minutes < 90:
133 | return "about 1 hour"
134 | elif distance_in_minutes < 1440:
135 | return "about %d hours" % (round(distance_in_minutes / 60.0))
136 | elif distance_in_minutes < 2880:
137 | return "1 day"
138 | elif distance_in_minutes < 43220:
139 | return "%d days" % (round(distance_in_minutes / 1440))
140 | elif distance_in_minutes < 86400:
141 | return "about 1 month"
142 | elif distance_in_minutes < 525600:
143 | return "%d months" % (round(distance_in_minutes / 43200))
144 | elif distance_in_minutes < 1051200:
145 | return "about 1 year"
146 | else:
147 | return "over %d years" % (round(distance_in_minutes / 525600))
148 |
149 | def time_ago_in_words(from_time, include_seconds=False):
150 | """
151 | Like distance_of_time_in_words, but where ``to_time`` is fixed to ``datetime.now()``.
152 | """
153 | ago = distance_of_time_in_words(from_time, datetime.utcnow(), include_seconds)
154 | # TODO - think about these. The output from the function is Too Damn Long, is all.
155 | ago = re.sub(r'^about ', '~', ago )
156 | ago = re.sub(r'minute', 'min', ago )
157 | ago = re.sub(r'second', 'sec', ago )
158 | return ago
159 |
160 | __all__ = ['as_dump', 'as_app', 'print_info', 'html_escape', 'normalize_url','distance_of_time_in_words', 'time_ago_in_words']
161 |
--------------------------------------------------------------------------------
/Cache.py:
--------------------------------------------------------------------------------
1 | from Foundation import *
2 | from AppKit import *
3 | from WebKit import *
4 |
5 | from time import time, sleep
6 | import base64
7 | import urllib
8 | import os
9 | import os.path
10 | import hashlib
11 | import re
12 |
13 | from Utilities import *
14 |
15 | def keyForUrlUsernamePassword( url, username, password ):
16 | return "%s:::%s:::%s"%( url, username, password )
17 |
18 |
19 | def filenameForKey( key ):
20 | folder = os.path.join( os.environ['HOME'], "Library", "Application Support", "Shelf", "cache" )
21 | try:
22 | os.makedirs( folder )
23 | except OSError:
24 | pass
25 | filename = urllib.quote( key, '' )
26 | # this can make filenames that are waaaay too long
27 | hasher = hashlib.md5()
28 | hasher.update(filename)
29 | return os.path.join( folder, hasher.hexdigest() )
30 |
31 |
32 | #LAST_CACHE_CLEAN = 0
33 | def cleanCache():
34 | #if time() - LAST_CACHE_CLEAN < 60:
35 | # return
36 | folder = os.path.join( os.environ['HOME'], "Library", "Application Support", "Shelf", "cache" )
37 | try:
38 | files = os.listdir(folder)
39 | except OSError:
40 | return
41 |
42 | for file in os.listdir( folder ):
43 | filename = os.path.join( folder, file )
44 | # file not looked at in a day
45 | if time() - os.path.getatime( filename ) > 24 * 3600:
46 | print_info("Removing old cache file %s"%filename)
47 | os.unlink( filename )
48 | #LAST_CACHE_CLEAN = time()
49 |
50 |
51 | # ask Cocoa to query Spotlight and get back to us.
52 | def querySpotlightAndCallback(**params):
53 | cleanCache()
54 | emails = params['emails']
55 | # exclude image, text and html files that are sometimes wrongly attached to emails
56 | exclusions = ['public.image','public.text']
57 | query = NSMetadataQuery.alloc().init()
58 | # The easy bit - all e-mails where these addresses are seen
59 | predicate = "((kMDItemContentType = 'com.apple.mail.emlx') && (" + \
60 | '||'.join(["((kMDItemAuthorEmailAddresses = '%s') || (kMDItemRecipientEmailAddresses = '%s'))" % (m, m) for m in emails]) + \
61 | ")"
62 | predicate += "|| (" + \
63 | '&&'.join(["(kMDItemContentTypeTree != '%s')" % e for e in exclusions]) + \
64 | ") && (" + \
65 | '||'.join(["(kMDItemWhereFroms like '*%s*')" % m for m in emails]) + \
66 | '))'
67 | print predicate
68 | query.setPredicate_(NSPredicate.predicateWithFormat_(predicate))
69 | query.setSortDescriptors_(NSArray.arrayWithObject_(NSSortDescriptor.alloc().initWithKey_ascending_('kMDItemContentCreationDate',False)))
70 | NSNotificationCenter.defaultCenter().addObserver_selector_name_object_(params['observer'], params['callback'], NSMetadataQueryDidFinishGatheringNotification, query)
71 | query.startQuery()
72 |
73 |
74 | # ask Cocoa to download an url and get back to us. It pulls the file to disk
75 | # locally, and uses this as a cache, using mtime. The callback should be a
76 | # function that will be called at some time in the future, with 2 params -
77 | # the data, and a true/false if the data is stale or not.
78 | #
79 | # Calling with wantstale of true will call the callback function right away
80 | # if here is _any_ data, even if it's old, then will fetch the data and call
81 | # the callback _again_.
82 | #
83 | def getContentOfUrlAndCallback( **params ):
84 | cleanCache()
85 | # I have address book entries that are just 'www.foo.com'
86 | if not re.match(r'^\w+://', params['url']):
87 | params['url'] = "http://%s" % params['url']
88 |
89 | delegate = DownloadDelegate.alloc().init()
90 | for key in params:
91 | setattr(delegate, key, params[key] )
92 |
93 | delay = 0.1
94 | if 'delay' in params: delay = params['delay']
95 | delegate.performSelector_withObject_afterDelay_('start', None, delay )
96 |
97 |
98 | class DownloadDelegate( NSObject ):
99 |
100 | def init(self):
101 | self = super(DownloadDelegate, self).init()
102 | if not self: return
103 | # these are set above via setattr
104 | self.callback = None
105 | self.failure = None
106 | self.url = None
107 | self.username = None
108 | self.password = None
109 | self.timeout = None
110 | self.wantStale = None
111 | return self
112 |
113 | # this is a seperate function so I can call it after a delay
114 | def start(self):
115 | filename = filenameForKey( keyForUrlUsernamePassword( self.url, self.username, self.password ) )
116 | if os.path.exists(filename):
117 | if time() - os.path.getmtime( filename ) < self.timeout:
118 | self.callback( file( filename ).read(), False )
119 | return # no need to get the URL
120 | elif self.wantStale:
121 | # call the callback immediately with the stale data.
122 | print_info("We have stale data")
123 | self.callback( file( filename ).read(), True )
124 | # don't return - we still want to fetch the file
125 | # TODO - if we're already fetching the file on behalf of someone
126 | # else, it would be nice to do the Right Thing here.
127 |
128 | req = NSMutableURLRequest.requestWithURL_( NSURL.URLWithString_( self.url ) )
129 | if self.username or self.password:
130 | base64string = base64.encodestring('%s:%s' % (self.username, self.password))[:-1]
131 | req.setValue_forHTTPHeaderField_("Basic %s"%base64string, "Authorization")
132 | # Send the right User-Agent. TODO - get the bundle version properly, don't hard-code
133 | req.setValue_forHTTPHeaderField_("Shelf/git.HEAD +https://github.com/rcarmo/shelf/", "User-Agent")
134 | downloader = NSURLDownload.alloc().initWithRequest_delegate_( req, self )
135 | downloader.setDestination_allowOverwrite_( filename, True )
136 |
137 | def downloadDidBegin_(self, downloader):
138 | print_info("Begun download of %s"%downloader.request())
139 |
140 | def download_didCreateDestination_(self, downloader, filename):
141 | self.filename = filename
142 | os.utime( self.filename, None )
143 |
144 | def downloadDidFinish_(self, downloader):
145 | # the downloader sets the mtime to be the web server's idea of
146 | # when the file was last updated. Which is cute. But useless to us.
147 | # I want to know when I fetched it.
148 | os.utime( self.filename, None )
149 | data = file( self.filename ).read()
150 | self.callback( data, False )
151 |
152 | def download_didFailWithError_(self, downloader, error):
153 | #print("error downloading %s: %s"%( downloader.request(), error ))
154 | if self.failure:
155 | self.failure( error )
156 |
157 |
158 | # incredibly evil - ignore https cert errors (doesn't work!)
159 | #from objc import Category
160 | #class NSURLRequest(Category(NSURLRequest)):
161 | # @classmethod
162 | # def allowsAnyHTTPSCertificateForHost_(cls, host):
163 | # return True
164 |
--------------------------------------------------------------------------------
/ChangeLog:
--------------------------------------------------------------------------------
1 | ### 0.0.15 (2011-02-28)
2 |
3 | Made work under Snow Leopard, Python 2.6, etc (Anil Madhavapeddy)
4 |
5 | Crazy experimental Google Reader support (Safari only)
6 |
7 | Support more versions of Chrome, hopefully. They keep changing the AS support.
8 |
9 | Added a Spotlight provider that attempts to grab e-mail messages and
10 | attachments exchanged with a particular contact (Rui Carmo)
11 |
12 | Tightened up indents and removed blank lines in some files to make it
13 | easier to work on a somewhat cramped screen.
14 |
15 | ### 0.0.14
16 |
17 | last.fm changed their XML order? Anyway, I now explicitly order the tracks
18 | in the client.
19 |
20 | Replaces simlpejson with json - requires Python 2.6, but Snow Leopard has that
21 | and I'm not really interested in supporting anything else.
22 |
23 | ### 0.0.13 (released 2008-03-28)
24 |
25 | All blocks are now sorted by date, rather than just Feed blocks. This means
26 | that if a contact has blogged something more recently than they've twittered
27 | something, the blog block will be above the twitter block.
28 |
29 | The Dopplr provider is back and working again. It now properly uses the
30 | Dopplr auth-bounce code to get itself a token from Dopplr, so should actually
31 | be usable by people other than me.
32 |
33 | Lots of trivial little encoding fixes and catches for annoying 'other apps
34 | suck' failure cases.
35 |
36 | RSS feed items without titles no longer break the Feed provider.
37 |
38 | If you live in a non-GMT timezone, dates now work. Hurrah for going to
39 | conferences and having to suffer under my own bugs.
40 |
41 | ### 0.0.12 (released 2008-02-14)
42 |
43 | Shelf now has a preference to only poll for context and display the window if
44 | a global shortcut key is pressed (currently, this is hard-coded to
45 | Command-Ctrl-J). Turning this on will stop Shelf from polling, it'll manually
46 | display the window when you press the hotkey. BUG - turning this preference
47 | off will require a restart of Shelf before the polling starts again. And yes,
48 | I'll make the key configurable soon.
49 |
50 | Feed entries now display about how long ago they were, rather than absolute
51 | times. Do we like this? Maybe this should be a preference.
52 |
53 | Messed with the flickr photo display style to include titles, and have smaller
54 | thumbnails. I think I prefer this way.
55 |
56 | Special casing for last.fm urls is now in, so you'll see your contact's
57 | recently-played tracks and coverart, if available.
58 |
59 | Special-casing for the few pages I know of that have
60 | [totally broken RSS feeds](http://jerakeen.org/blog/2008/02/irritating-rss-feed-links/)
61 | to supress them from the feedlists.
62 |
63 | Feeds in the display are now ordered with the most recently updated feed at
64 | the top of the list. 'Special' feeds (twitter, flickr, lastfm) are still
65 | ordered above all others. I'll change this soon.
66 |
67 | We now correctly strip CSS styling from feed contents.
68 |
69 | The file cache now cleans itself up. The first run of the app may take a
70 | while... Sorry.
71 |
72 | The feedprovider de-dupes its blocks based on the feed url, rather than
73 | the page url. No more repeated flickr photos blocks because they have more than
74 | one different url to their photos.
75 |
76 | If there are rel="me" links in the current page, and you're using Safari,
77 | I can use them directly to resolve context rather than relying on google
78 | to have spidered the page.
79 |
80 | Blocks that have come from the Google Social Graph or have otherwise been
81 | guessed now have a little 'G' in their title bar. Ugly, but I want a way of
82 | distinguishing them a little.
83 |
84 | Shelf now sends a proper User-Agent to remote servers when fetching feeds.
85 |
86 | Documented large chunks of the source much better.
87 |
88 | Removed the xmltramp library, using simplejson to parse the Dopplr API files
89 | instead. Not that the Dopplr module works. But if it did, I would.
90 |
91 |
92 |
93 | ### 0.0.11 (released 2008-02-06)
94 |
95 | Feed provider now displays the first 10 words of the blog entry under the
96 | heading, and the entry date.
97 |
98 | Add 'birthday' to the list of simple information displayed about a user.
99 |
100 | I now use the Google Social Graph to try to derive a person from a page you're
101 | looking at, if that page isn't already in your address book. Likewise, I use
102 | the Google Social Graph to find out more urls from someone once I have one URL
103 | for them. So if your homepage links to your flickr page, and only your
104 | homepage is in my address book, I'll still see your flickr photos when I visit
105 | your home page, and I'll see you if I visit your flickr page. Magic.
106 |
107 | The Google Social stuff is off by default - there are two preferences for
108 | turning it on. This is because it's a privacy _nightmare_ - it sends every URL
109 | you visit, and every URL belonging to anyone you IM with, to Google.
110 |
111 | There are now some trivial preferences for how you want Shelf to display - the
112 | old default behaviours of 'always come to the foreground when I have something
113 | to tell you' and 'window is always on top if there is context' are now
114 | options.
115 |
116 | #### Known bugs
117 |
118 | The Dopplr module is currently broken, due to the odd SSL certificate Dopplr
119 | use. Sorry. Working on it.
120 |
121 | The RSS feed fetcher shares a cookie store with Safari. This may or may not
122 | be a bug or a feature, depending on your point of view. Anyone care?
123 |
124 | #### Technical changes:
125 |
126 | Converted build process to use py2app rather than xcode, because I know how to
127 | make py2app dance to my tunes. Specifically, I know how to make it pull in
128 | external libraries and bundle them properly. Reorganised source files into
129 | folders so I can keep track of where things are.
130 |
131 | _Huge_ great internal re-write. Many more code-paths are now much more
132 | message-passing based, and can do things in the background. The Google Social
133 | stuff uses this, so no longer blocks the foreground thread.
134 |
135 |
136 |
137 | ### 0.0.10 (released 2008-01-11)
138 |
139 | I've taken a random stab at CSS styling - the thing is a lot prettier now,
140 | though that's not really saying much. Input welcome. 'themes' (spit) are
141 | planned, of course. But till then, at least it isn't just a default webkit
142 | display now.
143 |
144 | The webkit view now has crude click-through, which is useful as the window
145 | is always on top, so of course you want to follow that link. But it _doesn't_
146 | have hover-through, so you won't see link mouseover effects just yet.
147 | Annoying.
148 |
149 | Companies in the address book are recognised as such, and display the company
150 | name first. They have a different default icon.
151 |
152 | The NetNewsWire clue provider now tries to find a person based on the target of
153 | the currently viewed headline first, then falls back to the feed URL, rather
154 | than just using the feed URL. It also looks for Microformats in the body of
155 | the feed item before considering URLs. I may reconsider the ordering here,
156 | though.
157 |
158 | Hopefully better URL disambiguation now - www. on the beginning of addresses
159 | can be considered optional.
160 |
161 | The display will hang around a little longer if it thinks you're no longer
162 | looking at someone interesting, to try to bridge the gap if you're navigating
163 | to a resource about the same person.
164 |
165 | Mattb had an odd crashing bug that I don't understand, where there seemed to be
166 | no 'active application'. I'm now guarding against this case.
167 |
168 | Shelf now stops correctly when quit. oops.
169 |
170 |
171 |
172 | ### 0.0.8 (released 2008-01-10)
173 |
174 | New icon, thanks Rui.
175 |
176 | I'm sure there was something else. I decide I should start keeping a ChangeLog.
177 |
178 |
--------------------------------------------------------------------------------
/providers/FeedProvider.py:
--------------------------------------------------------------------------------
1 | from Provider import *
2 | from urllib import quote
3 |
4 | import feedparser
5 | from autorss import getRSSLinkFromHTMLSource
6 | import urllib, urlparse
7 | import time
8 |
9 | from Utilities import *
10 | import Cache
11 |
12 | class FeedAtom(ProviderAtom):
13 | def __init__(self, *stuff):
14 | ProviderAtom.__init__( self, *stuff )
15 | self.feed = None
16 | self.feed_url = None
17 | self.refresh( False )
18 |
19 | def refresh( self, force ):
20 | # TODO - force-refresh should blow the cache
21 | self.getFeedUrl()
22 |
23 | def sortOrder(self):
24 | if not self.feed:
25 | return MIN_SORT_ORDER
26 | if len(self.feed.entries) == 0:
27 | return MIN_SORT_ORDER
28 | if 'updated_parsed' in self.feed.entries[0] and self.feed.entries[0].updated_parsed:
29 | return time.mktime(self.feed.entries[0].updated_parsed)
30 | if "published_parsed" in self.feed.entries[0] and self.feed.entries[0].published_parsed:
31 | return time.mktime(self.feed.entries[0].published_parsed)
32 | return MIN_SORT_ORDER
33 |
34 | def getFeedUrl(self):
35 | # it's very unlikely that the feed source will move
36 | # TODO - check stale cache first. Man, the feed provider is too complicated.
37 | special = self.specialCaseFeedUrl( self.url )
38 | # return None to mean 'no special case', blank string to mean "no feed here"
39 | if special != None:
40 | if len(special) > 0:
41 | print_info("special-case feed url %s"%special)
42 | self.getFeed( special )
43 | else:
44 | # bad feed
45 | self.dead = True
46 | self.changed()
47 | return
48 |
49 | Cache.getContentOfUrlAndCallback( callback = self.gotMainPage, url = self.url, timeout = self.timeout() * 10, wantStale = False, failure = self.failed ) # TODO - use stale version somehow
50 |
51 | def specialCaseFeedUrl( self, url ):
52 | print_info("trying to special-case url %s"%url)
53 | if re.match(r'http://search\.cpan\.org/~', url):
54 | print_info("RSS feed is known-bad (search.cpan)")
55 | return "" # bad feed
56 | if re.match(r'http://use\.perl\.org/~\w+/?$', url): # /journal is ok
57 | print_info("RSS feed is known-bad (use.perl)")
58 | return "" # bad feed
59 |
60 | print_info("No special case")
61 | return None
62 |
63 | def gotMainPage( self, data, stale ):
64 | rss = getRSSLinkFromHTMLSource(data)
65 | if rss:
66 | feed_url = urlparse.urljoin( self.url, rss )
67 | self.getFeed( feed_url )
68 | else:
69 | self.dead = True
70 | self.changed()
71 |
72 | def username(self): return None
73 | def password(self): return None
74 |
75 | def getFeed(self, feed_url ):
76 | if not self.feed_url and self.provider.isDuplicateFeed( feed_url ):
77 | self.dead = True
78 | self.changed()
79 | else:
80 | self.feed_url = feed_url
81 | # if we have a feed object, then I'm not interested in re-parsing a stale file.
82 | wantStale = not self.feed
83 | Cache.getContentOfUrlAndCallback( callback = self.gotFeed, url = feed_url, username = self.username(), password = self.password(), timeout = self.timeout(), wantStale = wantStale, failure = self.failed )
84 |
85 | def gotFeed( self, data, stale ):
86 | feed = feedparser.parse( data )
87 | if feed and 'feed' in feed and 'title' in feed.feed:
88 | self.feed = feed
89 | self.stale = stale
90 | self.name = feed.feed.title
91 | self.changed()
92 | else:
93 | self.dead = True
94 | self.changed()
95 |
96 | def failed( self, error ):
97 | if self.feed:
98 | # never mind, we have _something_
99 | self.stale = False
100 | else:
101 | # no old feed, just display error
102 | self.error = error
103 | self.stale = False
104 | self.changed()
105 |
106 | def body(self):
107 | if self.feed and self.feed.entries:
108 | return self.htmlForFeed( url = self.url, feed = self.feed, stale = self.stale )
109 | elif self.feed:
110 | return "" # no entries
111 | else:
112 | return self.htmlForPending( url = self.url, stale = self.stale )
113 |
114 | def timeout(self):
115 | return 60 * 20
116 |
117 | def htmlForPending( self, url, stale = False ):
118 | return ""
119 |
120 | def htmlForFeed( self, url, feed, stale = False ):
121 | html = u""
122 | entries = feed.entries
123 | for item in filter( lambda item: "link" in item, entries )[0:4]:
124 | if 'published_parsed' in item: date = item.published_parsed
125 | elif 'updated_parsed' in item: date = item.updated_parsed
126 | else: date = None
127 |
128 | if date:
129 | #html += u'%s'%( time.strftime("%b %d", date ) )
130 | ago = time_ago_in_words(date) + " ago"
131 | html += u'%s'%ago
132 | title = 'title' in item and item.title or "untitled"
133 |
134 | try:
135 | html += u'
'
153 | return html
154 |
155 |
156 | class FeedProvider( Provider ):
157 |
158 | def atomClass(self):
159 | return FeedAtom
160 |
161 | def provide( self ):
162 | todo = self.urls() # if we're claiming from boring_urls, do it first
163 |
164 | # sync atoms to urls
165 | for atom in [x for x in self.atoms]:
166 | if atom.url in todo:
167 | todo.remove( atom.url )
168 | atom.refresh( False )
169 | else:
170 | self.atoms.remove(atom)
171 |
172 | for url in todo:
173 | atom = self.atomClass()( self, url )
174 | atom.guessed = url in self.clue.extra_urls
175 | self.atoms.append( atom )
176 |
177 | def isDuplicateFeed(self, url):
178 | # called once an atom has a feed url for itself. Rather than
179 | # removing the atom, just mark it as dead, so that the proide()
180 | # function above doesn't re-add it
181 | if not url: return False
182 | return normalize_url(url) in [ normalize_url(a.feed_url) for a in filter(lambda a: a.feed_url, self.atoms) ]
183 |
184 | # override these
185 | def urls(self):
186 | return self.clue.boring_urls
187 |
188 |
189 |
--------------------------------------------------------------------------------
/lib/microformatparser.py:
--------------------------------------------------------------------------------
1 | # from http://phildawes.net/microformats/microformatparser.html
2 | # changed by tom to understand multiple classes ina single class="foo bar baz" stanza
3 |
4 |
5 |
6 | #!/usr/bin/env python
7 | #
8 | # Microformat parser hack
9 | # - My lame attempt to build a generic microformat parser engine
10 | # (C) Phil Dawes 2005
11 | # Distributed under a New BSD style license:
12 | # See: http://www.opensource.org/licenses/bsd-license.php
13 | #
14 | # Usage: python ./
15 |
16 | import sys
17 | import urlparse
18 | from HTMLParser import HTMLParser
19 | import re
20 | import urllib2
21 |
22 | class MicroformatSchema:
23 |
24 | def __init__(self,props,parentprops):
25 | self.props = props
26 | self.parentprops = parentprops
27 |
28 | def isValidProperty(self,prop):
29 | if prop in self.props + self.parentprops:
30 | return True
31 | return False
32 |
33 | def isParentProperty(self,prop):
34 | return prop in self.parentprops
35 |
36 | vcardprops = MicroformatSchema(['fn','family-name', 'given-name', 'additional-name', 'honorific-prefix', 'honorific-suffix', 'nickname', 'sort-string','url','email','type','tel','post-office-box', 'extended-address', 'street-address', 'locality', 'region', 'postal-code', 'country-name', 'label', 'latitude', 'longitude', 'tz', 'photo', 'logo', 'sound', 'bday','title', 'role','organization-name', 'organization-unit','category', 'note','class', 'key', 'mailer', 'uid', 'rev'],['n','email','adr','geo','org','tel'])
37 |
38 | veventprops = MicroformatSchema(["summary","url","dtstart","dtend","location"],[])
39 |
40 | SCHEMAS= {'vcard':vcardprops,'vevent':veventprops}
41 |
42 | class nodeitem:
43 | def __init__(self,id,tag,predicates,attrs,nested):
44 | self.tag = tag
45 | self.id = id
46 | self.predicates = predicates
47 | self.attrs = attrs
48 | self.nested = nested
49 |
50 | def __repr__(self):
51 | return ""%(self.tag, self.id, self.predicates,
52 | self.attrs,self.nested)
53 |
54 | class MicroformatToStmts(HTMLParser):
55 | def __init__(self,url):
56 | self.url = url
57 | HTMLParser.__init__(self)
58 | self.nodestack = []
59 | self.nodemap = {}
60 | self.chars = ""
61 | self.tree = []
62 | self.treestack = []
63 |
64 | def _getattr(self,name,attrs):
65 | for attr in attrs:
66 | if name == attr[0]: return attr[1]
67 |
68 | def predicateIsAParent(self,pred):
69 | if SCHEMAS[self.currentCompoundFormat].isParentProperty(pred):
70 | return True
71 | return False
72 |
73 | def handle_starttag(self, elementtag, attrs):
74 | self.chars=""
75 | if self.currentlyInAMicroformat():
76 | try:
77 | preds = self._getattr("class",attrs).split()
78 | except AttributeError:
79 | self.nodestack.append(nodeitem(1,elementtag,None,attrs,False))
80 | return
81 |
82 | prevpreds = []
83 | #while 1:
84 | nested = False
85 | while 1:
86 | if prevpreds == preds:
87 | break
88 | prevpreds = preds
89 | if self.predicateIsAParent(preds[0]):
90 | self.openParentProperty(preds[0])
91 | nested = True
92 |
93 | if elementtag == "img":
94 | self.emitAttributeAsPropertyIfExists('src',attrs, preds)
95 | elif elementtag == "a":
96 | self.emitAttributeAsPropertyIfExists('href',attrs, preds)
97 | self.emitAttributeAsPropertyIfExists('title',attrs, preds)
98 | elif elementtag == "abbr":
99 | self.emitAttributeAsPropertyIfExists('title',attrs, preds)
100 |
101 | self.nodestack.append(nodeitem(1,elementtag,preds,attrs,nested))
102 |
103 | elif self.nodeStartsAMicroformat(attrs):
104 |
105 | classattrs = self._getattr('class',attrs).split()
106 | for classattr in classattrs:
107 | if classattr in SCHEMAS.keys():
108 | self.currentCompoundFormat = classattr
109 | break
110 | self.nodestack.append(nodeitem(1,elementtag,[self._getattr('class',attrs)],attrs,True))
111 | self.tree.append([])
112 | self.treestack = [self.tree[-1]] # opening tree stack frame
113 | self.openParentProperty(self.currentCompoundFormat)
114 |
115 | def openParentProperty(self,prop):
116 | self.treestack[-1].append((prop,[]))
117 | self.treestack.append(self.treestack[-1][-1][1])
118 |
119 | def currentlyInAMicroformat(self):
120 | return self.nodestack != []
121 |
122 | def nodeStartsAMicroformat(self, attrs):
123 | class_attr = self._getattr('class',attrs)
124 | if not class_attr: return False
125 | for a in class_attr.split():
126 | if a in SCHEMAS.keys(): return True
127 | return False
128 |
129 | def emitAttributeAsPropertyIfExists(self, attrname, attrs, preds):
130 | obj = self._getattr(attrname,attrs)
131 | if obj is not None:
132 | try:
133 | pred = preds[0]
134 | if SCHEMAS[self.currentCompoundFormat].isValidProperty(pred):
135 | if attrname in ("href","src"):
136 | obj = urlparse.urljoin(self.url,obj)
137 | obj = self.makeDatesParsable(pred,obj)
138 | self.addPropertyValueToOutput(pred,obj)
139 | del preds[0]
140 | except IndexError:
141 | pass
142 |
143 | def addPropertyValueToOutput(self,prop,val):
144 | self.treestack[-1].append((prop,val))
145 |
146 | def handle_endtag(self,tag):
147 | if self.currentlyInAMicroformat():
148 | while 1:
149 | try:
150 | item = self.nodestack.pop()
151 | except IndexError:
152 | return # no more elements
153 | if item.tag == tag:
154 | break # found it!
155 |
156 | # if there's still predicates, then output the text as object
157 | if item.predicates and item.predicates != [] and self.chars.strip() != "":
158 | #if item.tag == 'a':
159 | # print "ITEM:a",self.treestack
160 | preds = item.predicates
161 | self.treestack[-1].append((preds[0],self.chars))
162 | del preds[0]
163 | if item.nested == 1:
164 | self.treestack.pop()
165 | self.chars = ""
166 |
167 | # HTMLPARSER interface
168 | def handle_data(self,content):
169 | if self.hasPredicatesPending():
170 | content = content.strip()
171 | if content == "":
172 | return
173 | self.chars += content
174 |
175 | def hasPredicatesPending(self):
176 | for n in self.nodestack:
177 | if n.predicates != []:
178 | return 1
179 | return 0
180 |
181 | # hack to stop dates like '20051005' being interpreted as floats downstream
182 | def makeDatesParsable(self,p,o):
183 | if p in ["dtstart","dtend"]:
184 | try:
185 | float(o) # can it be interpreted as a float?
186 | o = "%s-%s-%s"%(o[:4],o[4:6],o[6:])
187 | except ValueError:
188 | pass
189 | return o
190 |
191 |
192 | def printTree(tree,tab=""):
193 | for p,v in tree:
194 | if isinstance(v,list):
195 | print tab + p
196 | printTree(v,tab+" ")
197 | else:
198 | print tab + unicode(p),":",v
199 |
200 | def printTreeStack(treestack,tab=""):
201 | for t in treestack:
202 | if isinstance(t,list):
203 | printTreeStack(t,tab+" ")
204 | else:
205 | print t
206 |
207 | def parse(f,url="http://dummyurl.com/"):
208 | m = MicroformatToStmts(url)
209 | try:
210 | s = f.read()
211 | except AttributeError:
212 | s = f
213 | m.feed(s)
214 | m.close()
215 |
216 | return m.tree
217 |
218 |
219 | if __name__ == "__main__":
220 | import urllib
221 | if len(sys.argv) == 1:
222 | print "Usage:",sys.argv[0],""
223 | sys.exit(0)
224 | else:
225 | for url in sys.argv[1:]:
226 | trees = parse(urllib.urlopen(url),url)
227 | for tree in trees:
228 | printTree(tree)
229 |
--------------------------------------------------------------------------------
/extractors/Extractor.py:
--------------------------------------------------------------------------------
1 | from Foundation import *
2 | from AppKit import *
3 | from WebKit import *
4 | from AddressBook import *
5 | from ScriptingBridge import *
6 |
7 | import re
8 | from email.utils import parseaddr
9 | import microformatparser
10 | import relmeparser
11 | import sgmllib
12 | from HTMLParser import HTMLParseError
13 | import urllib, urlparse, urllib2
14 | from urllib import quote
15 | import json
16 |
17 | import Cache
18 | from Utilities import *
19 | from Clue import *
20 |
21 | class Extractor(object):
22 |
23 | def __init__(self):
24 | #NSLog("** Extractor '%s' init"%self.__class__.__name__)
25 | super( Extractor, self ).__init__()
26 | self.addressBook = ABAddressBook.sharedAddressBook()
27 |
28 | def getClue( self, caller ):
29 | self.done = False
30 | NSObject.cancelPreviousPerformRequestsWithTarget_( self )
31 | self.caller = caller
32 | self.clues() # implemented in subclasses. Calls addClues
33 |
34 | def addClues( self, clues, more_urls = [] ):
35 | print_info("addClues: %s %s" % (str(clues), str(more_urls)))
36 | if clues and self.caller:
37 | print_info("found a clue!")
38 | clues[0].addExtraUrls( more_urls )
39 | self.caller.gotClue( clues[0] )
40 | self.caller = None
41 | self.done = True
42 | NSObject.cancelPreviousPerformRequestsWithTarget_( self )
43 |
44 | def clues_from_email( self, email, more_urls = [] ):
45 | if self.done: return
46 | # email look like 'Name ' sometimes.
47 | name, email = parseaddr( email )
48 | print_info("Looking for people with email '%s'"%email)
49 | self.addClues( self._search_for( email, "Email" ), more_urls )
50 |
51 | def clues_from_url( self, url, more_urls = [] ):
52 | if not url: return
53 | if self.done: return
54 | original = url # preserve
55 |
56 | if re.match(r'xmpp:', url):
57 | self.clues_from_jabber( re.sub(r'xmpp:', '', url) )
58 | return
59 |
60 | if re.match(r'email:', url):
61 | self.clues_from_email( re.sub(r'email:', '', url) )
62 | return
63 |
64 | if re.match(r'\w+:', url) and not re.match(r'http', url):
65 | # has a protocol, but isn't http
66 | return
67 |
68 | clues = self._search_for_url( url )
69 |
70 | while not clues and re.search(r'//', url):
71 | url = re.sub(r'/[^/]*$','',url)
72 | clues += self._search_for_url( url )
73 |
74 | if clues:
75 | self.addClues( clues, more_urls )
76 |
77 | elif NSUserDefaults.standardUserDefaults().boolForKey_("googleSocial"):
78 | # this is a background process, calls us back later
79 | # order is a little sensitive for now, as if the cache is good,
80 | # the clues are updated _Before_ this function returns.
81 | # I consider this a bug in the implementation.
82 | self.getSocialGraphFor( original )
83 |
84 |
85 | def _search_for_url( self, url ):
86 | url = normalize_url( url )
87 |
88 | print_info("Looking for people with URL '%s'"%url)
89 |
90 | previous = Clue.forUrl( url )
91 | if previous: return [ previous ]
92 |
93 | # search for url, plus url with trailing slash
94 | clues = self._search_for( url, "URLs", kABSuffixMatchCaseInsensitive )
95 | clues += self._search_for( url + "/", "URLs", kABSuffixMatchCaseInsensitive )
96 | return clues
97 |
98 | def clues_from_aim( self, username, more_urls = [] ):
99 | if self.done: return
100 | print_info("Looking for people with AIM %s"%username)
101 | self.addClues( self._search_for( username, kABAIMInstantProperty ), more_urls )
102 |
103 | def clues_from_jabber( self, username, more_urls = [] ):
104 | if self.done: return
105 | print_info("Looking for people with Jabber %s"%username)
106 | self.addClues( self._search_for( username, kABJabberInstantProperty ), more_urls )
107 |
108 | def clues_from_yahoo( self, username, more_urls = [] ):
109 | if self.done: return
110 | print_info("Looking for people with Yahoo! %s"%username)
111 | self.addClues( self._search_for( username, kABYahooInstantProperty ), more_urls )
112 |
113 | def clues_from_name( self, name, more_urls = [] ):
114 | if self.done: return
115 | names = re.split(r'\s+', name)
116 | self.addClues( self.clues_from_names( names[0], names[-1], more_urls ) )
117 |
118 | def clues_from_names( self, forename, surname, more_urls = [] ):
119 | if self.done: return
120 | print_info("Looking for people called '%s' '%s'"%( forename, surname ))
121 | forename_search = ABPerson.searchElementForProperty_label_key_value_comparison_( kABFirstNameProperty, None, None, forename, kABPrefixMatchCaseInsensitive )
122 | surname_search = ABPerson.searchElementForProperty_label_key_value_comparison_( kABLastNameProperty, None, None, surname, kABEqualCaseInsensitive )
123 | se = ABSearchElement.searchElementForConjunction_children_( kABSearchAnd, [ forename_search, surname_search ] )
124 | self.addClues( map(lambda a: Clue.forPerson(a), self.addressBook.recordsMatchingSearchElement_( se )), more_urls )
125 |
126 |
127 | def _search_for( self, thing, type, method = kABEqualCaseInsensitive ):
128 | if not thing or len(thing) == 0:
129 | return []
130 |
131 | se = ABPerson.searchElementForProperty_label_key_value_comparison_( type, None, None, thing, method )
132 | return map(lambda a: Clue.forPerson(a), self.addressBook.recordsMatchingSearchElement_( se ))
133 |
134 |
135 | def clues_from_html( self, source, url ):
136 | if self.done: return
137 | try:
138 | feeds = microformatparser.parse( source, url )
139 | except HTMLParseError:
140 | feeds = []
141 | except UnicodeDecodeError:
142 | feeds = []
143 | except TypeError:
144 | feeds = []
145 |
146 | try:
147 | relmes = relmeparser.parse( source, url )
148 | except HTMLParseError:
149 | relmes = []
150 | except UnicodeDecodeError:
151 | relmes = []
152 |
153 | # try all rel="me" links for urls we can deal with.
154 | for relurl in relmes:
155 | self.clues_from_url( relurl, relmes )
156 | if self.done: return
157 |
158 | if not feeds: return
159 |
160 | # I'm going to assume that the _first_ microformat on the page
161 | # is the person the page is about. I can't really do better
162 | # than that, can I?
163 | # TODO - yes, I can. Look for 'rel="me"'
164 | feed = feeds[0]
165 |
166 | # look for vcard microformats
167 | vcards = [ tree for name, tree in feed if name =='vcard']
168 | if not vcards: return []
169 |
170 | card = dict(vcards[0])
171 | clues = []
172 |
173 | if 'url' in card:
174 | self.clues_from_url( card['url'], [url] + relmes )
175 |
176 | if 'email' in card:
177 | if isinstance(card['email'], str) or isinstance(card['email'], unicode):
178 | addrs = [ card['email'] ]
179 | else:
180 | addrs = [ e[1] for e in card['email'] ]
181 |
182 | for addr in addrs:
183 | # bloody flickr
184 | e = re.sub(r'\s*\[\s*at\s*\]\s*', '@', addr)
185 | self.clues_from_email( e, [url] + relmes )
186 |
187 | if 'family-name' in card and 'given-name' in card:
188 | # TODO - check ordering here for .jp issues? Gah.
189 | self.clues_from_names( card['given-name'], card['family-name'], [url] + relmes )
190 |
191 | if 'fn' in card:
192 | self.clues_from_name( card['fn'], [url] + relmes )
193 |
194 |
195 | SOCIAL_GRAPH_CACHE = {}
196 | def getSocialGraphFor( self, url, more_urls = [] ):
197 | if not re.match(r'http', url): return
198 |
199 | if url in Extractor.SOCIAL_GRAPH_CACHE:
200 | print_info("using cached social graph data")
201 | self.addClues( Extractor.SOCIAL_GRAPH_CACHE[url], more_urls )
202 | return
203 | api = "http://socialgraph.apis.google.com/lookup?pretty=1&fme=1&edo=1&edi=1"
204 | api += "&q=" + quote( url, '' )
205 | print_info("Social graph API call to " + api )
206 | # TODO _ respect more_urls here
207 | Cache.getContentOfUrlAndCallback( callback = self.gotSocialGraphData, url = api, timeout = 3600 * 48 ) # huge timeout here
208 |
209 | def gotSocialGraphData( self, raw, isStale ):
210 | try:
211 | data = json.loads( raw )
212 | except ValueError:
213 | return # meh
214 | original_url = data['canonical_mapping'].keys()[0]
215 | urls = filter( lambda u: len(u) > 4 and re.match(r'http', u), data['nodes'].keys() ) # sometimes it returns '/' as a node.
216 | extra = []
217 | for u in urls:
218 | if 'unverified_claiming_nodes' in data['nodes'][u]:
219 | extra += data['nodes'][u]['unverified_claiming_nodes']
220 | urls += extra # TODO _ weed dupes
221 |
222 | for graph_url in urls:
223 | print_info("Google Social Graph URL '%s'"%graph_url)
224 | clues = self._search_for_url( graph_url )
225 | self.addClues( clues )
226 | if clues:
227 | Extractor.SOCIAL_GRAPH_CACHE[ original_url ] = clues
228 | return # done
229 |
230 | Extractor.SOCIAL_GRAPH_CACHE[ original_url ] = []
231 |
--------------------------------------------------------------------------------
/dev_appserver_login.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | #
3 | # Copyright 2007 Google Inc.
4 | #
5 | # Licensed under the Apache License, Version 2.0 (the "License");
6 | # you may not use this file except in compliance with the License.
7 | # You may obtain a copy of the License at
8 | #
9 | # http://www.apache.org/licenses/LICENSE-2.0
10 | #
11 | # Unless required by applicable law or agreed to in writing, software
12 | # distributed under the License is distributed on an "AS IS" BASIS,
13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 | # See the License for the specific language governing permissions and
15 | # limitations under the License.
16 | #
17 | """Helper CGI for logins/logout in the development application server.
18 |
19 | This CGI has these parameters:
20 |
21 | continue: URL to redirect to after a login or logout has completed.
22 | email: Email address to set for the client.
23 | admin: If 'True', the client should be logged in as an admin.
24 | action: What action to take ('Login' or 'Logout').
25 |
26 | To view the current user information and a form for logging in and out,
27 | supply no parameters.
28 | """
29 |
30 |
31 | import cgi
32 | import Cookie
33 | import md5
34 | import os
35 | import sys
36 | import urllib
37 |
38 |
39 | CONTINUE_PARAM = 'continue'
40 | EMAIL_PARAM = 'email'
41 | ADMIN_PARAM = 'admin'
42 | ACTION_PARAM = 'action'
43 |
44 | LOGOUT_ACTION = 'Logout'
45 | LOGIN_ACTION = 'Login'
46 |
47 | LOGOUT_PARAM = 'action=%s' % LOGOUT_ACTION
48 |
49 | COOKIE_NAME = 'dev_appserver_login'
50 |
51 |
52 | def GetUserInfo(http_cookie, cookie_name=COOKIE_NAME):
53 | """Get the requestor's user info from the HTTP cookie in the CGI environment.
54 |
55 | Args:
56 | http_cookie: Value of the HTTP_COOKIE environment variable.
57 | cookie_name: Name of the cookie that stores the user info.
58 |
59 | Returns:
60 | Tuple (email, admin) where:
61 | email: The user's email address, if any.
62 | admin: True if the user is an admin; False otherwise.
63 | """
64 | cookie = Cookie.SimpleCookie(http_cookie)
65 |
66 | cookie_value = ''
67 | if cookie_name in cookie:
68 | cookie_value = cookie[cookie_name].value
69 |
70 | email, admin, user_id = (cookie_value.split(':') + ['', '', ''])[:3]
71 | return email, (admin == 'True'), user_id
72 |
73 |
74 | def CreateCookieData(email, admin):
75 | """Creates cookie payload data.
76 |
77 | Args:
78 | email, admin: Parameters to incorporate into the cookie.
79 |
80 | Returns:
81 | String containing the cookie payload.
82 | """
83 | admin_string = 'False'
84 | if admin:
85 | admin_string = 'True'
86 | if email:
87 | user_id_digest = md5.new(email.lower()).digest()
88 | user_id = '1' + ''.join(['%02d' % ord(x) for x in user_id_digest])[:20]
89 | else:
90 | user_id = ''
91 | return '%s:%s:%s' % (email, admin_string, user_id)
92 |
93 |
94 | def SetUserInfoCookie(email, admin, cookie_name=COOKIE_NAME):
95 | """Creates a cookie to set the user information for the requestor.
96 |
97 | Args:
98 | email: Email to set for the user.
99 | admin: True if the user should be admin; False otherwise.
100 | cookie_name: Name of the cookie that stores the user info.
101 |
102 | Returns:
103 | 'Set-Cookie' header for setting the user info of the requestor.
104 | """
105 | cookie_value = CreateCookieData(email, admin)
106 | set_cookie = Cookie.SimpleCookie()
107 | set_cookie[cookie_name] = cookie_value
108 | set_cookie[cookie_name]['path'] = '/'
109 | return '%s\r\n' % set_cookie
110 |
111 |
112 | def ClearUserInfoCookie(cookie_name=COOKIE_NAME):
113 | """Clears the user info cookie from the requestor, logging them out.
114 |
115 | Args:
116 | cookie_name: Name of the cookie that stores the user info.
117 |
118 | Returns:
119 | 'Set-Cookie' header for clearing the user info of the requestor.
120 | """
121 | set_cookie = Cookie.SimpleCookie()
122 | set_cookie[cookie_name] = ''
123 | set_cookie[cookie_name]['path'] = '/'
124 | set_cookie[cookie_name]['max-age'] = '0'
125 | return '%s\r\n' % set_cookie
126 |
127 |
128 | LOGIN_TEMPLATE = """
129 |
130 | Login
131 |
132 |
133 |
134 |
160 |
161 |
162 |
163 | """
164 |
165 |
166 | def RenderLoginTemplate(login_url, continue_url, email, admin):
167 | """Renders the login page.
168 |
169 | Args:
170 | login_url, continue_url, email, admin: Parameters passed to
171 | LoginCGI.
172 |
173 | Returns:
174 | String containing the contents of the login page.
175 | """
176 | login_message = 'Not logged in'
177 | if email:
178 | login_message = 'Logged in'
179 | admin_checked = ''
180 | if admin:
181 | admin_checked = 'checked'
182 |
183 | template_dict = {
184 |
185 |
186 | 'email': email or 'test\x40example.com',
187 | 'admin_checked': admin_checked,
188 | 'login_message': login_message,
189 | 'login_url': login_url,
190 | 'continue_url': continue_url
191 | }
192 |
193 | return LOGIN_TEMPLATE % template_dict
194 |
195 |
196 | def LoginRedirect(login_url,
197 | hostname,
198 | port,
199 | relative_url,
200 | outfile):
201 | """Writes a login redirection URL to a user.
202 |
203 | Args:
204 | login_url: Relative URL which should be used for handling user logins.
205 | hostname: Name of the host on which the webserver is running.
206 | port: Port on which the webserver is running.
207 | relative_url: String containing the URL accessed.
208 | outfile: File-like object to which the response should be written.
209 | """
210 | dest_url = "http://%s:%s%s" % (hostname, port, relative_url)
211 | redirect_url = 'http://%s:%s%s?%s=%s' % (hostname,
212 | port,
213 | login_url,
214 | CONTINUE_PARAM,
215 | urllib.quote(dest_url))
216 | outfile.write('Status: 302 Requires login\r\n')
217 | outfile.write('Location: %s\r\n\r\n' % redirect_url)
218 |
219 |
220 | def LoginCGI(login_url,
221 | email,
222 | admin,
223 | action,
224 | set_email,
225 | set_admin,
226 | continue_url,
227 | outfile):
228 | """Runs the login CGI.
229 |
230 | This CGI does not care about the method at all. For both POST and GET the
231 | client will be redirected to the continue URL.
232 |
233 | Args:
234 | login_url: URL used to run the CGI.
235 | email: Current email address of the requesting user.
236 | admin: True if the requesting user is an admin; False otherwise.
237 | action: The action used to run the CGI; 'Login' for a login action, 'Logout'
238 | for when a logout should occur.
239 | set_email: Email to set for the user; Empty if no email should be set.
240 | set_admin: True if the user should be an admin; False otherwise.
241 | continue_url: URL to which the user should be redirected when the CGI
242 | finishes loading; defaults to the login_url with no parameters (showing
243 | current status) if not supplied.
244 | outfile: File-like object to which all output data should be written.
245 | """
246 | redirect_url = ''
247 | output_headers = []
248 |
249 | if action:
250 | if action.lower() == LOGOUT_ACTION.lower():
251 | output_headers.append(ClearUserInfoCookie())
252 | elif set_email:
253 | output_headers.append(SetUserInfoCookie(set_email, set_admin))
254 |
255 | redirect_url = continue_url or login_url
256 |
257 | if redirect_url:
258 | outfile.write('Status: 302 Redirecting to continue URL\r\n')
259 | for header in output_headers:
260 | outfile.write(header)
261 | outfile.write('Location: %s\r\n' % redirect_url)
262 | outfile.write('\r\n')
263 | else:
264 | outfile.write('Status: 200\r\n')
265 | outfile.write('Content-Type: text/html\r\n')
266 | outfile.write('\r\n')
267 | outfile.write(RenderLoginTemplate(login_url,
268 | continue_url,
269 | email,
270 | admin))
271 |
272 |
273 | def main():
274 | """Runs the login and logout CGI script."""
275 | form = cgi.FieldStorage()
276 | login_url = os.environ['PATH_INFO']
277 | email = os.environ.get('USER_EMAIL', '')
278 | admin = os.environ.get('USER_IS_ADMIN', '0') == '1'
279 |
280 | action = form.getfirst(ACTION_PARAM)
281 | set_email = form.getfirst(EMAIL_PARAM, '')
282 | set_admin = form.getfirst(ADMIN_PARAM, '') == 'True'
283 | continue_url = form.getfirst(CONTINUE_PARAM, '')
284 |
285 | LoginCGI(login_url,
286 | email,
287 | admin,
288 | action,
289 | set_email,
290 | set_admin,
291 | continue_url,
292 | sys.stdout)
293 | return 0
294 |
295 |
296 | if __name__ == '__main__':
297 | main()
298 |
--------------------------------------------------------------------------------
/Clue.py:
--------------------------------------------------------------------------------
1 | import objc
2 | from AddressBook import *
3 |
4 | import re
5 | from time import time, gmtime
6 | from urllib import quote
7 | import json
8 |
9 | from Utilities import *
10 |
11 | from Provider import *
12 |
13 | Provider.addProvider( "BasicProvider" )
14 | Provider.addProvider( "SpotlightProvider" )
15 | Provider.addProvider( "TwitterProvider" )
16 | Provider.addProvider( "DopplrProvider" )
17 | Provider.addProvider( "LastFmProvider" )
18 | Provider.addProvider( "FlickrProvider" )
19 | # Order is important - FeedProvider must be _last_, because it uses all
20 | # urls in the address book card not claimed by another provider
21 | Provider.addProvider( "FeedProvider" )
22 |
23 |
24 | class Clue(object):
25 |
26 | # Make Clue objects singletons - one Person in the address book, one Clue.
27 | # This way clues can retain their local providers and content, so a clue
28 | # you've seen before will display more quickly.
29 | CACHE = {}
30 | @classmethod
31 | def forPerson( cls, person ):
32 | if person.uniqueId() in Clue.CACHE:
33 | print_info("person is cached")
34 | return Clue.CACHE[ person.uniqueId() ]
35 | print_info("creating new person")
36 | Clue.CACHE[ person.uniqueId() ] = Clue( person )
37 | return Clue.CACHE[ person.uniqueId() ]
38 |
39 | # sometimes, google will suggest a page for a person because I have
40 | # page A, that lnks to page B, that links to page C. Shelf suggests
41 | # page C as a page for this person. When I visit page C, Google will only
42 | # tell me about page B, and I won't be able to tie it back to that person.
43 | # To work around this, I'll just remember every url I get for a person.
44 | @classmethod
45 | def forUrl( cls, url ):
46 | for clue in Clue.CACHE.values():
47 | if normalize_url( url) in [ normalize_url(u) for u in clue.urls() ]:
48 | return clue
49 |
50 | def __init__(self, person):
51 | # for now, clues are tied to AddressBook person objects.
52 | # But nothing outside the Clue object knows this - names, etc are
53 | # all extractd from the Clue object using methods on the Clue, not
54 | # on the ABPerson.
55 | # Clues are constructed using Clue.forPerson() from everywhere. Eventually
56 | # I'd like clues to be a little more flexible.
57 | self.person = person
58 | self.delegate = None
59 | self.extra_urls = [] # Urls from google social
60 |
61 | # on the first inflate of this person, ask google for more urls.
62 | if NSUserDefaults.standardUserDefaults().boolForKey_("googleSocialContext"):
63 | self.getMoreUrls()
64 |
65 | # create providers
66 | self.providers = [ cls(self) for cls in Provider.providers() ]
67 |
68 |
69 | def setDelegate_(self, delegate):
70 | self.delegate = delegate
71 |
72 | # Kick off all the providers to start getting information on the person.
73 | # providers call back to this object when they have something.
74 | def start(self):
75 |
76 | # the 'interesting' providers - flickr, twitter, etc - extract urls
77 | # from the boring_urls list based on regular expressions. The FeedProvider
78 | # wakes up right at the end, and turns everything left over into feeds.
79 | self.boring_urls = self.urls()
80 |
81 | # tell every provider to look for clues.
82 | # TODO - we really don't need to do this _incredibly_ often. a 30 second
83 | # timeout would make Shelf a lot less twitchy in terms of looking for feeds
84 | for provider in self.providers:
85 | provider.provide()
86 |
87 | self.changed()
88 |
89 | # use Google Social - ask it to tell us which urls are linked to using
90 | # rel="me" links from any of the urls that we already have for this person.
91 | def getMoreUrls( self ):
92 | if not self.ab_urls(): return # no point
93 | api = "http://socialgraph.apis.google.com/lookup?pretty=1&fme=1&q=" + ",".join([ quote(url) for url in self.ab_urls() ])
94 | print_info("Social graph API call to " + api )
95 | Cache.getContentOfUrlAndCallback( callback = self.gotSocialGraphData, url = api, timeout = 3600 * 48, wantStale = False, delay = 2 ) # huge timeout here
96 |
97 | # callback from Google Social call
98 | def gotSocialGraphData( self, raw, isStale ):
99 | try:
100 | data = json.loads( raw )
101 | except ValueError:
102 | return # meh
103 | urls = data['nodes'].keys()
104 | self.addExtraUrls( urls )
105 | self.start() # TODO - this kicks everything off again. Too heavy?
106 |
107 | def addExtraUrls( self, urls ):
108 | if not urls: return
109 |
110 | # build hash to dedupe - keys are the normalized url form,
111 | # values are the URLs as they came in.
112 | dedupe = {}
113 | for url in self.extra_urls + urls:
114 | if re.match(r'http', url): # HTTP only
115 | # respect existing normalizartion forms
116 | if not normalize_url( url ) in dedupe:
117 | dedupe[ normalize_url( url ) ] = url
118 | self.extra_urls = dedupe.values()
119 |
120 | # remove all the urls we already know about from the Address Book
121 | known_urls = [ normalize_url( url ) for url in self.ab_urls() ]
122 | for url in [ u for u in self.extra_urls ]: # cheap copy, as we're mutating the array and python doesn't like looping over an array you're changing in place
123 | if normalize_url( url ) in known_urls:
124 | self.extra_urls.remove(url)
125 |
126 | print_info("I have the Address Book urls '%s'"%(", ".join(self.ab_urls())))
127 | print_info("Google gave me the extra urls '%s'"%(", ".join(self.extra_urls)))
128 |
129 |
130 | # the providers callback to this function when they have something new to say.
131 | # We just pass the message upwards
132 | def changed(self):
133 | if self.delegate:
134 | self.delegate.updateWebContent_fromClue_( self.content(), self )
135 |
136 | # this method returns the HTML content that should be in the webview for this clue.
137 | def content(self):
138 | # jut cat together the content of our providers.
139 | # TODO - long term, I'd like providers to return smarter objects, with
140 | # contents and date and headings, so the front-end can group them by
141 | # source, or URL, or date, and get a date-sorted list of everything a
142 | # person has done.
143 | atoms = []
144 | for p in self.providers:
145 | atoms += p.atoms
146 |
147 | atoms.sort(lambda a,b: cmp(b.sortOrder(), a.sortOrder()))
148 | content = "".join([ atom.content() for atom in atoms ])
149 | if content: return content
150 |
151 | return "
thinking..
"
152 |
153 | # stop this clue from thinking soon. Tell all the providers to stop.
154 | def stop(self):
155 | NSObject.cancelPreviousPerformRequestsWithTarget_( self )
156 | for current in self.providers:
157 | current.stop()
158 |
159 |
160 | # strip urls matching the passed regexp from the boring_urls list, and
161 | # return them. This lets providers 'take posession of' urls so the FeedProvider
162 | # doesn't see them
163 | def takeUrls(self,pattern):
164 | interesting = []
165 | for u in self.urls():
166 | if re.search(pattern, u):
167 | interesting.append(u)
168 | for u in interesting:
169 | if u in self.boring_urls:
170 | self.boring_urls.remove(u)
171 | return interesting
172 |
173 |
174 | ######################################
175 | # These methods represent the properties of the underlying person
176 |
177 | # Used for == comparison
178 | def __eq__(self, other):
179 | if not other: return False
180 | return self.uniqueId() == other.uniqueId()
181 |
182 | # Stringify to something readable
183 | def __str__(self):
184 | return ""%self.displayName()
185 |
186 | # must be globally unique
187 | def uniqueId(self):
188 | return self.person.uniqueId()
189 |
190 | # returns an NSImage for this person. Falls back to a nice default if there's
191 | # nothing in the Address Book
192 | def image(self):
193 | if self.person.imageData():
194 | return NSImage.alloc().initWithData_( self.person.imageData() )
195 | if self.isCompany():
196 | return NSImage.imageNamed_("NSUserGroup")
197 | else:
198 | return NSImage.imageNamed_("NSUser")
199 |
200 | def forename(self):
201 | return self.person.valueForProperty_(kABFirstNameProperty)
202 |
203 | def surname(self):
204 | return self.person.valueForProperty_(kABLastNameProperty)
205 |
206 | def isCompany(self):
207 | return ( self.person.valueForProperty_(kABPersonFlags) or 0 ) & kABShowAsCompany
208 |
209 | def displayName(self):
210 | if self.isCompany():
211 | return self.person.valueForProperty_(kABOrganizationProperty)
212 | f = self.forename()
213 | s = self.surname()
214 | if s and f: return f + " " + s
215 | if s: return s
216 | if f: return f
217 | return ""
218 |
219 | def companyName(self):
220 | if self.isCompany(): return ""
221 | c = self.person.valueForProperty_(kABOrganizationProperty)
222 | if c: return c
223 | return ""
224 |
225 | def addresses(self):
226 | return self._multi_to_list( self.person.valueForProperty_(kABAddressProperty) )
227 |
228 | def emails(self):
229 | return self._multi_to_list( self.person.valueForProperty_(kABEmailProperty) )
230 |
231 | # Address Book urls. Rather than urls we have from all sources.
232 | def ab_urls(self):
233 | return self._multi_to_list( self.person.valueForProperty_(kABURLsProperty) )
234 |
235 | def urls(self):
236 | return self.ab_urls() + self.extra_urls
237 |
238 | def email(self):
239 | return self.emails()[0]
240 |
241 | def birthday(self):
242 | try:
243 | if self.person.valueForProperty_( kABBirthdayProperty ):
244 | return gmtime( self.person.valueForProperty_( kABBirthdayProperty ).timeIntervalSince1970() )
245 | except ValueError: # too old.. TODO - Um, I know people born <1970. Must fix.
246 | pass
247 | return None
248 |
249 | # utility method for dealing with the Cocoa Address Book interface.
250 | def _multi_to_list(self, multi):
251 | if not multi: return []
252 | output = []
253 | for i in range(0, multi.count() ):
254 | output.append( multi.valueAtIndex_( i ) )
255 | return output
256 |
--------------------------------------------------------------------------------
/PyShelfWindowController.py:
--------------------------------------------------------------------------------
1 | from Foundation import *
2 | from AppKit import *
3 | from WebKit import *
4 | from AddressBook import *
5 | from ScriptingBridge import *
6 |
7 | from Carbon.AppleEvents import kAEISGetURL, kAEInternetSuite
8 | import struct
9 |
10 | import objc
11 | import re
12 | import traceback
13 | import os
14 | from time import time as epoch_time
15 |
16 | from Utilities import *
17 | from Clue import *
18 |
19 | class ShelfController (NSWindowController):
20 | companyView = objc.IBOutlet()
21 | imageView = objc.IBOutlet()
22 | nameView = objc.IBOutlet()
23 | webView = objc.IBOutlet()
24 | prefsWindow = objc.IBOutlet()
25 |
26 | # first-cut init goes here - we've been woken up, and all the GUI
27 | # component objects exist. Don't spend too long here, though, I think
28 | # the app icon is still bouncing.
29 | def awakeFromNib(self):
30 | self.handlers = {}
31 | self.current_clue = None
32 |
33 | # get the RGB hex code of the system 'background' color.
34 | bg = self.window().backgroundColor().colorUsingColorSpaceName_( NSCalibratedRGBColorSpace )
35 | rgb = "%x%x%x"%(
36 | bg.redComponent() * 255.999999,
37 | bg.greenComponent() * 255.999999,
38 | bg.blueComponent() * 255.999999
39 | )
40 | # TODO - ok. Now do something with this information. Specifically, get it into the CSS.
41 |
42 | # evil. Alter the webkit view object so that it'll accept a clickthrough
43 | # - this is very handy, as the window is on top and full of context.
44 | # Alas, right now, the hover doesn't percolate through, so you don't
45 | # get mouseover effects. But clicks work.
46 | objc.classAddMethod( WebHTMLView, "acceptsFirstMouse:", lambda a,b: 1 )
47 | # ps - when I say 'evil', I mean it. Really, _really_ evil. TODO -
48 | # subclass the thing and do it properly.
49 |
50 | # create application support folder. The cache goes here. I suppose
51 | # I should really keep it in a Cache folder somewhere
52 | folder = os.path.join( os.environ['HOME'], "Library", "Application Support", "Shelf" )
53 | if not os.path.exists( folder ):
54 | os.mkdir( folder )
55 |
56 |
57 |
58 | # Add a handler for the event GURL/GURL. One might think that
59 | # Carbon.AppleEvents.kEISInternetSuite/kAEISGetURL would work,
60 | # but the system headers (and hence the Python wrapper for those)
61 | # are wrong.
62 | manager = NSAppleEventManager.sharedAppleEventManager()
63 |
64 | manager.setEventHandler_andSelector_forEventClass_andEventID_(
65 | self, 'handleURLEvent:withReplyEvent:', fourCharToInt( "GURL" ), fourCharToInt( "GURL" ))
66 |
67 | # this is called once we're all launched. Bouncing all over now.
68 | def applicationDidFinishLaunching_(self, sender):
69 | # There's no initial context.
70 | self.fade()
71 |
72 | # start polling right away
73 | self.performSelector_withObject_afterDelay_( 'poll', None, 0 )
74 |
75 |
76 | # we've been told to close
77 | def applicationWillTerminate_(self, sender):
78 | # if we're doing anything in the background, stop it.
79 | if self.current_clue:
80 | self.current_clue.stop()
81 |
82 | # kill the poller and any other long-running things
83 | NSObject.cancelPreviousPerformRequestsWithTarget_( self )
84 |
85 |
86 | # This is the callback from the little right-pointing arrow on the main
87 | # window, to the right of the person icon. Means 'open in address book'
88 | def openRecord_(self, thing):
89 | if self.current_clue:
90 | NSWorkspace.sharedWorkspace().openURL_(
91 | NSURL.URLWithString_("addressbook://%s"%self.current_clue.uniqueId())
92 | )
93 |
94 | def hotKeyPressed(self):
95 | # TODO - this list here is a vage grab-bag of things I want to happen.
96 | # Should think about how we want to feedback on a deliberate poll,
97 | # and how to fade the window.
98 | self.current_clue = None
99 | self.fade()
100 | self.deferFade(3) # cause the window to vanish again if we don'e find anything
101 | self.window().setHidesOnDeactivate_( False )
102 | self.showWindow_(self)
103 | self.window().display()
104 | self.window().orderFrontRegardless()
105 | self.poll()
106 |
107 | # return an Extractor class instance (confusingly called 'handler' for now. Must fix..)
108 | # for the app with the passed bundle name.
109 | def handler_for( self, bundle ):
110 | if not bundle: return None
111 |
112 | # haven't seen this application before? Look for a file on disk with
113 | # the right name and load it.
114 | if not bundle in self.handlers:
115 | # convert bundlename to classname like 'ComAppleMail'
116 | classname = re.sub(r'\.(\w)', lambda m: m.group(1).upper(), bundle )
117 | classname = re.sub(r'^(\w)', lambda m: m.group(1).upper(), classname )
118 |
119 | print_info("** importing file for class %s"%( classname ))
120 | try:
121 | # this imports the module with the name 'clasname'.py as the local variable mod
122 | mod = __import__(classname, globals(), locals(), [''])
123 | # then get the single class attribute from that module object
124 | cls = getattr( mod, classname )
125 | # instantiate the class, and remember it so we don't do this again
126 | self.handlers[ bundle ] = cls()
127 | except ImportError:
128 | import traceback
129 | print_info( "** Couldn't import file for %s"%( classname ) )
130 | print_info( traceback.format_exc () )
131 | self.handlers[ bundle ] = None
132 |
133 | return self.handlers[ bundle ]
134 |
135 | # the main poll loop. Called regularly.
136 | def poll(self):
137 | print_info( "\n---- poll start ----" )
138 |
139 | # First thing I do, schedule the next poll event, so that I can just return with impunity from this function
140 | if not NSUserDefaults.standardUserDefaults().boolForKey_("useHotkey"):
141 | self.performSelector_withObject_afterDelay_( 'poll', None, 4 )
142 |
143 | # get bundle name of active application
144 | try:
145 | bundle = NSWorkspace.sharedWorkspace().activeApplication()['NSApplicationBundleIdentifier']
146 | except KeyError:
147 | # have seen this in the real world. Can't explain it.
148 | print( "Inexplicable lack of 'NSApplicationBundleIdentifier' for %s"%repr( NSWorkspace.sharedWorkspace().activeApplication() ) )
149 | return
150 |
151 | print_info( "current app is %s"%bundle )
152 |
153 | # this app has no effect on the current context, otherwise activating
154 | # the app drops the current context. TODO - don't hard-code bundle name
155 | if bundle.lower() in ["org.jerakeen.pyshelf"]:
156 | print_info("Ignoring myself")
157 | self.deferFade()
158 | return
159 |
160 | handler = self.handler_for( bundle )
161 | if not handler:
162 | print_info("Don't know how to get clues from %s"%bundle)
163 | return
164 |
165 | self.performSelector_withObject_afterDelay_("lookForCluesWith:", handler, 0)
166 |
167 | def lookForCluesWith_( self, handler ):
168 | # tell the handler to look for clues. Pass it 'self' so that it
169 | # can call us back
170 | handler.getClue(self)
171 |
172 |
173 | # callback from getClue on the handler function
174 | def gotClue(self, clue):
175 | self.deferFade() # put off the context fade
176 |
177 | if self.current_clue and self.current_clue == clue:
178 | # the context hasn't changed. Don't do anything.
179 | return
180 |
181 | # clue has changed
182 | print_info("New context - %s"%clue)
183 | if self.current_clue:
184 | self.current_clue.stop()
185 | self.current_clue = clue
186 | self.performSelector_withObject_afterDelay_('updateInfo', None, 0 )
187 |
188 |
189 | # fade the active context if we don't recieve any context for a while.
190 | # call this method every time something interesting happens, and it'll
191 | # stop the window going away for another few seconds. That way, I don't
192 | # have to explicitly watch for 'nothing happened'.
193 | def deferFade(self, count = 5):
194 | NSObject.cancelPreviousPerformRequestsWithTarget_selector_object_( self, "fade", None )
195 | self.performSelector_withObject_afterDelay_('fade', None, count )
196 |
197 | # Put window into 'no context, fall to background' state, clear current state
198 | def fade(self):
199 | if NSUserDefaults.standardUserDefaults().boolForKey_("useHotkey") and self.current_clue:
200 | # if we're hotkey driven, don't passively fade ever
201 | return
202 |
203 | print_info("fading...")
204 | if self.current_clue:
205 | self.current_clue.stop()
206 | self.current_clue = None
207 |
208 | self.window().setLevel_( NSNormalWindowLevel ) # unstuff from 'on top'
209 | self.window().setHidesOnDeactivate_( True ) # hide window if we have nothing
210 |
211 | self.nameView.setStringValue_( "" )
212 | self.companyView.setStringValue_( "" )
213 | self.imageView.setImage_( NSImage.imageNamed_("NSUser") )
214 | base = NSURL.fileURLWithPath_( NSBundle.mainBundle().resourcePath() )
215 | self.setWebContent_( "" )
216 |
217 | # put the window into 'I have context' state, display the header, and
218 | # tell the Clue object to start fetching state about itself.
219 | def updateInfo(self):
220 | clue = self.current_clue
221 | if not clue: return
222 | clue.setDelegate_(self) # so the clue can send us 'I have updated' messages
223 |
224 | self.nameView.setStringValue_( clue.displayName() )
225 | self.companyView.setStringValue_( clue.companyName() )
226 | self.imageView.setImage_( clue.image() ) # does this leak?
227 | base = NSURL.fileURLWithPath_( NSBundle.mainBundle().resourcePath() )
228 | self.setWebContent_( clue.content() ) # will initially be 'thinking..'
229 |
230 | # always safe
231 | self.window().setHidesOnDeactivate_( False )
232 |
233 | if NSUserDefaults.standardUserDefaults().boolForKey_("bringAppForward"):
234 | # slightly voodoo, this. But otherwise it doesn't seem 100% reliable
235 | self.showWindow_(self)
236 | self.window().orderFrontRegardless()
237 |
238 | if NSUserDefaults.standardUserDefaults().boolForKey_("alwaysOnTop"):
239 | self.window().setLevel_( NSFloatingWindowLevel ) # 'on top'
240 |
241 | self.window().display()
242 |
243 | # do this so we can return to the main runloop ASAP, so the
244 | # webview has a chance to display something.
245 | self.performSelector_withObject_afterDelay_('kickClue', None, 0 )
246 |
247 |
248 | def kickClue(self):
249 | if self.current_clue:
250 | self.current_clue.start()
251 |
252 | # called from the clue when it's updated itself and wants to add to the webview
253 | def updateWebContent_fromClue_(self, content, clue):
254 | # old clues may still expect to be able to update the data.
255 | if clue == self.current_clue:
256 | self.setWebContent_( content )
257 |
258 | def setWebContent_(self, html):
259 | # the base path of the webview is the resource folder, so I can use
260 | # relative paths to refer to the CSS.
261 | base = NSURL.fileURLWithPath_( NSBundle.mainBundle().resourcePath() )
262 | self.webView.mainFrame().loadHTMLString_baseURL_( """
263 |
264 |
265 |
266 |
267 |
268 | %s
269 |
270 |
271 | """%(
272 | #"file:///Users/tomi/svn/Projects/Shelf/style.css?%s"%int(epoch_time()), # dev
273 | "style.css", # live
274 | html
275 | ), base )
276 |
277 | # supress the 'reload' item from the right-click menu - it makes no sense
278 | def webView_contextMenuItemsForElement_defaultMenuItems_( self, webview, element, items ):
279 | return filter( lambda i: i.title() != "Reload", items )
280 |
281 | # stolen from djangokit. When the webview wants to fetch a resource,
282 | # it means either 'I want a file off disk to serve this page' (ok, then)
283 | # or 'I want to follow this link' (no, I'll just get the system to do that).
284 | def webView_decidePolicyForNavigationAction_request_frame_decisionListener_( self, webview, action, request, frame, listener):
285 | url = request.URL()
286 |
287 | # serve files
288 | if url.scheme() == 'file' or url.scheme() == 'about': # local files
289 | listener.use()
290 | return
291 |
292 | # everything else can be ignored, and opened by the system
293 | listener.ignore()
294 | NSWorkspace.sharedWorkspace().openURL_( url )
295 |
296 | def getDopplrToken_(self, sender):
297 | url = "https://www.dopplr.com/api/AuthSubRequest?scope=http://www.dopplr.com&next=shelf://shelf/&session=1"
298 | NSWorkspace.sharedWorkspace().openURL_( NSURL.URLWithString_(url) )
299 |
300 | def handleURLEvent_withReplyEvent_(self, event, replyEvent):
301 | theURL = event.descriptorForKeyword_(fourCharToInt('----'))
302 |
303 | # hack this to check for file: URLs
304 | matches = re.search('^shelf:(file://.+)', theURL.stringValue())
305 | if matches:
306 | filename = matches.group(1)
307 | print filename
308 | os.popen('open %s' % filename)
309 | return
310 |
311 | matches = re.search(r'token=(.*)', theURL.stringValue())
312 | if matches:
313 | token = matches.group(1)
314 | url = "https://www.dopplr.com/api/AuthSubSessionToken?token=%s"%( token )
315 | Cache.getContentOfUrlAndCallback( callback = self.gotDopplrToken, url = url, timeout = 3600, wantStale = False )
316 | return
317 | else:
318 | alert = NSAlert.alertWithMessageText_defaultButton_alternateButton_otherButton_informativeTextWithFormat_(
319 | "Shelf", "Continue", None, None, "Failed to get token - sorry.")
320 |
321 | self.prefsWindow.display()
322 | self.prefsWindow.makeKeyAndOrderFront_(self)
323 | alert.beginSheetModalForWindow_modalDelegate_didEndSelector_contextInfo_(
324 | self.prefsWindow, self, None, None)
325 |
326 |
327 | def gotDopplrToken(self, data, stale):
328 | matches = re.search(r'Token=(.*)', data)
329 | if matches:
330 | token = matches.group(1)
331 | NSUserDefaults.standardUserDefaults().setObject_forKey_(token, "dopplrToken")
332 | NSUserDefaults.standardUserDefaults().synchronize()
333 | alert = NSAlert.alertWithMessageText_defaultButton_alternateButton_otherButton_informativeTextWithFormat_(
334 | "Shelf", "Continue", None, None, "Got a Dopplr token!")
335 | else:
336 | alert = NSAlert.alertWithMessageText_defaultButton_alternateButton_otherButton_informativeTextWithFormat_(
337 | "Shelf", "Continue", None, None, "Failed to get token - sorry.")
338 |
339 | self.prefsWindow.display()
340 | self.prefsWindow.makeKeyAndOrderFront_(self)
341 | alert.beginSheetModalForWindow_modalDelegate_didEndSelector_contextInfo_(
342 | self.prefsWindow, self, None, None)
343 |
344 |
345 | def fourCharToInt(code):
346 | return struct.unpack('>l', code)[0]
347 |
348 |
--------------------------------------------------------------------------------