add popup menu to status updates to show various things in browser
[minibook.git] / minibook.py
blobe0f438c4b82a802d561d3fb4b65b37bb91c317cf
1 #!/usr/bin/env python
2 """ Minibook: the Facebook(TM) status updater
3 (C) 2009 Gergely Imreh <imrehg@gmail.com>
4 """
6 VERSION = '0.1.0'
7 APPNAME = 'minibook'
9 import pygtk
10 pygtk.require('2.0')
11 import gtk
12 import gobject
13 try:
14 from facebook import Facebook
15 except:
16 print "Pyfacebook is not available, cannot run."
17 exit(1)
19 import time
20 import re
21 import threading
23 gobject.threads_init()
24 gtk.gdk.threads_init()
25 gtk.gdk.threads_enter()
27 try:
28 import gtkspell
29 spelling_support = True
30 except:
31 spelling_support = False
33 import logging
34 import sys
35 import timesince
37 LEVELS = {'debug': logging.DEBUG,
38 'info': logging.INFO,
39 'warning': logging.WARNING,
40 'error': logging.ERROR,
41 'critical': logging.CRITICAL}
43 if len(sys.argv) > 1:
44 level_name = sys.argv[1]
45 level = LEVELS.get(level_name, logging.NOTSET)
46 logging.basicConfig(level=level)
48 _log = logging.getLogger('minibook')
51 class Columns:
52 (STATUSID, UID, STATUS, DATETIME, REPLIES, LIKES) = range(6)
55 #-------------------------------------------------
56 # From http://edsiper.linuxchile.cl/blog/?p=152
57 # to mitigate TreeView + threads problems
58 #-------------------------------------------------
60 class _IdleObject(gobject.GObject):
61 """
62 Override gobject.GObject to always emit signals in the main thread
63 by emmitting on an idle handler
64 """
66 def __init__(self):
67 gobject.GObject.__init__(self)
69 def emit(self, *args):
70 gobject.idle_add(gobject.GObject.emit, self, *args)
73 #-------------------------------------------------
74 # Thread support
75 #-------------------------------------------------
77 class _WorkerThread(threading.Thread, _IdleObject):
78 """A single working thread."""
80 __gsignals__ = {
81 "completed": (
82 gobject.SIGNAL_RUN_LAST,
83 gobject.TYPE_NONE,
84 (gobject.TYPE_PYOBJECT, )),
85 "exception": (
86 gobject.SIGNAL_RUN_LAST,
87 gobject.TYPE_NONE,
88 (gobject.TYPE_PYOBJECT, ))}
90 def __init__(self, function, *args, **kwargs):
91 threading.Thread.__init__(self)
92 _IdleObject.__init__(self)
93 self._function = function
94 self._args = args
95 self._kwargs = kwargs
97 def run(self):
98 # call the function
99 _log.debug('Thread %s calling %s' % (self.name, str(self._function)))
101 args = self._args
102 kwargs = self._kwargs
104 try:
105 result = self._function(*args, **kwargs)
106 except Exception, exc:
107 _log.error('Exception %s' % str(exc))
108 self.emit("exception", exc)
109 return
111 _log.debug('Thread %s completed' % (self.name))
113 self.emit("completed", result)
114 return
117 class _ThreadManager(object):
118 """Manages the threads."""
120 def __init__(self, max_threads=2):
121 """Start the thread pool. The number of threads in the pool is defined
122 by `pool_size`, defaults to 2."""
123 self._max_threads = max_threads
124 self._thread_pool = []
125 self._running = []
126 self._thread_id = 0
128 return
130 def _remove_thread(self, widget, arg=None):
131 """Called when the thread completes. We remove it from the thread list
132 (dictionary, actually) and start the next thread (if there is one)."""
134 # not actually a widget. It's the object that emitted the signal, in
135 # this case, the _WorkerThread object.
136 thread_id = widget.name
138 _log.debug('Thread %s completed, %d threads in the queue' % (thread_id,
139 len(self._thread_pool)))
141 self._running.remove(thread_id)
143 if self._thread_pool:
144 if len(self._running) < self._max_threads:
145 next = self._thread_pool.pop()
146 _log.debug('Dequeuing thread %s', next.name)
147 self._running.append(next.name)
148 next.start()
150 return
152 def add_work(self, complete_cb, exception_cb, func, *args, **kwargs):
153 """Add a work to the thread list."""
155 thread = _WorkerThread(func, *args, **kwargs)
156 thread_id = '%s' % (self._thread_id)
158 thread.connect('completed', complete_cb)
159 thread.connect('completed', self._remove_thread)
160 thread.connect('exception', exception_cb)
161 thread.setName(thread_id)
163 if len(self._running) < self._max_threads:
164 self._running.append(thread_id)
165 thread.start()
166 else:
167 running_names = ', '.join(self._running)
168 _log.debug('Threads %s running, adding %s to the queue',
169 running_names, thread_id)
170 self._thread_pool.append(thread)
172 self._thread_id += 1
173 return
176 class MainWindow:
177 """The main application interface"""
180 #------------------------------
181 # Information sending functions
182 #------------------------------
183 def sendupdate(self):
184 textfield = self.entry.get_buffer()
185 start = textfield.get_start_iter()
186 end = textfield.get_end_iter()
187 entry_text = textfield.get_text(start, end)
188 if entry_text != "":
189 _log.info('Sent status update: %s\n' % entry_text)
190 self._facebook.status.set([entry_text], [self._facebook.uid])
192 textfield.set_text("")
193 self.refresh()
195 #------------------------------
196 # Information pulling functions
197 #------------------------------
198 def get_friends_list(self):
199 query = ("SELECT uid, name FROM user \
200 WHERE (uid IN (SELECT uid2 FROM friend WHERE uid1 = %d) \
201 OR uid = %d)" % (self._facebook.uid, self._facebook.uid))
202 friends = self._facebook.fql.query([query])
203 self.friendsname = {}
204 for friend in friends:
205 self.friendsname[str(friend['uid'])] = friend['name']
207 def post_get_friends_list(self, widget, results):
208 _log.info('%s has altogether %d friends in the database.' \
209 % (self.friendsname[str(self._facebook.uid)],
210 len(self.friendsname.keys())))
211 self.refresh()
212 return
214 def except_get_friends_list(self, widget, exception):
215 _log.error("Get friends exception: %s" % (str(exception)))
217 def get_status_list(self):
218 if self._last_update > 0:
219 since = self._last_update
220 else:
221 now = int(time.time())
222 since = now - 5*24*60*60
223 till = int(time.time())
225 _log.info("Fetching status updates published between %s and %s" \
226 % (time.strftime("%c", time.localtime(since)),
227 time.strftime("%c", time.localtime(since))))
229 query = ('SELECT uid, time, status_id, message FROM status \
230 WHERE (uid IN (SELECT uid2 FROM friend WHERE uid1 = %d) \
231 OR uid = %d) \
232 AND time > %d AND time < %d\
233 ORDER BY time DESC\
234 LIMIT 60' \
235 % (self._facebook.uid, self._facebook.uid, since, till))
236 _log.debug('Status list query: %s' % (query))
238 status = self._facebook.fql.query([query])
240 _log.info('Received %d new status' % (len(status)))
241 return [status, till]
243 def post_get_status_list(self, widget, results):
244 _log.debug('Status updates successfully pulled.')
245 updates = results[0]
246 for up in updates:
247 self.liststore.prepend((up['status_id'],
248 up['uid'],
249 up['message'],
250 up['time'],
251 '0',
252 '0'))
253 self._last_update = results[1]
254 return
256 def except_get_status_list(self, widget, exception):
257 _log.error("Get status list exception: %s" % (str(exception)))
259 #-----------------
260 # Helper functions
261 #-----------------
262 def count(self, text):
263 start = text.get_start_iter()
264 end = text.get_end_iter()
265 thetext = text.get_text(start, end)
266 self.count_label.set_text('(%d)' % (160 - len(thetext)))
267 return True
269 def set_auto_refresh(self):
270 if self._refresh_id:
271 gobject.source_remove(self._refresh_id)
273 self._refresh_id = gobject.timeout_add(
274 self._prefs['auto_refresh_interval']*60*1000,
275 self.refresh)
276 _log.info("Auto-refresh enabled: %d minutes" \
277 % (self._prefs['auto_refresh_interval']))
279 def refresh(self):
280 _log.info('Refreshing now at %s' % (time.strftime('%H:%M:%S')))
281 self._threads.add_work(self.post_get_status_list,
282 self.except_get_status_list,
283 self.get_status_list)
284 return True
286 def status_format(self, column, cell, store, position):
287 uid = store.get_value(position, Columns.UID)
288 name = self.friendsname[str(uid)]
289 status = store.get_value(position, Columns.STATUS)
290 posttime = store.get_value(position, Columns.DATETIME)
292 #replace characters that would choke the markup
293 status = re.sub(r'&', r'&amp;', status)
294 status = re.sub(r'<', r'&lt;', status)
295 status = re.sub(r'>', r'&gt;', status)
296 markup = ('<b>%s</b> %s\n(%s ago)' % \
297 (name, status, timesince.timesince(posttime)))
298 _log.debug('Marked up text: %s' % (markup))
299 cell.set_property('markup', markup)
300 return
302 def open_url(self, source, url):
303 """Open url as new browser tab."""
304 _log.debug('Opening url: %s' % url)
305 import webbrowser
306 webbrowser.open_new_tab(url)
307 self.window.set_focus(self.entry)
309 #--------------------
310 # Interface functions
311 #--------------------
312 def systray_click(self, widget, user_param=None):
313 if self.window.get_property('visible'):
314 _log.debug('Hiding window')
315 x, y = self.window.get_position()
316 self._prefs['window_pos_x'] = x
317 self._prefs['window_pos_y'] = y
318 self.window.hide()
319 else:
320 x = self._prefs['window_pos_x']
321 y = self._prefs['window_pos_y']
322 _log.debug('Restoring window at (%d, %d)' % (x, y))
323 self.window.move(x, y)
324 self.window.deiconify()
325 self.window.present()
327 def create_grid(self):
328 self.liststore = gtk.ListStore(gobject.TYPE_STRING,
329 gobject.TYPE_INT,
330 gobject.TYPE_STRING,
331 gobject.TYPE_STRING,
332 gobject.TYPE_STRING,
333 gobject.TYPE_STRING)
334 self.liststore.set_sort_column_id(Columns.DATETIME, \
335 gtk.SORT_DESCENDING)
336 self.liststore.set_sort_func(Columns.DATETIME, \
337 self._order_datetime)
339 self.treeview = gtk.TreeView(self.liststore)
340 self.treeview.set_property('headers-visible', False)
341 self.treeview.set_rules_hint(True)
343 self.status_renderer = gtk.CellRendererText()
344 #~ self.status_renderer.set_property('wrap-mode', gtk.WRAP_WORD)
345 self.status_renderer.set_property('wrap-width', 350)
346 self.status_renderer.set_property('width', 10)
348 self.status_column = gtk.TreeViewColumn('Message', \
349 self.status_renderer, text=1)
350 self.status_column.set_cell_data_func(self.status_renderer, \
351 self.status_format)
352 self.treeview.append_column(self.status_column)
353 self.treeview.set_resize_mode(gtk.RESIZE_IMMEDIATE)
355 self.treeview.connect('row-activated', self.open_status_web)
356 self.treeview.connect('button-press-event', self.click_status)
358 def open_status_web(self, treeview, path, view_column, user_data=None):
359 """ Callback to open status update in web browser when received
360 left click.
362 model = treeview.get_model()
363 if model:
364 iter = model.get_iter(path)
365 uid = model.get_value(iter, Columns.UID)
366 status_id = model.get_value(iter, Columns.STATUSID)
367 status_url = ('http://www.facebook.com/profile.php?' \
368 'id=%d&v=feed&story_fbid=%s' % (uid, status_id))
369 self.open_url(path, status_url)
370 return
372 def click_status(self, treeview, event, user_data=None):
373 """Callback when a mouse click event occurs on one of the rows."""
374 _log.debug('clicked on status list')
375 if event.button != 3:
376 # Only right clicks are processed
377 return False
378 _log.debug('right-click received')
380 x = int(event.x)
381 y = int(event.y)
383 pth = treeview.get_path_at_pos(x, y)
384 if not pth:
385 # The click wasn't on a row
386 return False
388 path, col, cell_x, cell_y = pth
389 treeview.grab_focus()
390 treeview.set_cursor(path, col, 0)
392 self.show_status_popup(treeview, event)
393 return True
395 def show_status_popup(self, treeview, event, user_data=None):
396 _log.debug('show popup menu')
397 cursor = treeview.get_cursor()
398 if not cursor:
399 return
401 path = cursor[0]
402 model = treeview.get_model()
403 if not model:
404 return
405 iter = model.get_iter(path)
407 popup_menu = gtk.Menu()
408 popup_menu.set_screen(self.window.get_screen())
410 # An open submenu with various choices underneath
411 open_menu_items = []
413 uid = model.get_value(iter, Columns.UID)
414 status_id = model.get_value(iter, Columns.STATUSID)
415 url = ('http://www.facebook.com/profile.php?' \
416 'id=%d&v=feed&story_fbid=%s' % (uid, status_id))
417 item_name = 'This status'
418 item = gtk.MenuItem(item_name)
419 item.connect('activate', self.open_url, url)
420 open_menu_items.append(item)
422 url = ('http://www.facebook.com/profile.php?' \
423 'id=%d' % (uid))
424 item_name = 'User wall'
425 item = gtk.MenuItem(item_name)
426 item.connect('activate', self.open_url, url)
427 open_menu_items.append(item)
429 url = ('http://www.facebook.com/profile.php?' \
430 'id=%d&v=info' % (uid))
431 item_name = 'User info'
432 item = gtk.MenuItem(item_name)
433 item.connect('activate', self.open_url, url)
434 open_menu_items.append(item)
436 url = ('http://www.facebook.com/profile.php?' \
437 'id=%d&v=photos' % (uid))
438 item_name = 'User photos'
439 item = gtk.MenuItem(item_name)
440 item.connect('activate', self.open_url, url)
441 open_menu_items.append(item)
443 open_menu = gtk.Menu()
444 for item in open_menu_items:
445 open_menu.append(item)
447 open_item = gtk.MenuItem("Open in browser")
448 open_item.set_submenu(open_menu)
450 popup_menu.append(open_item)
451 popup_menu.show_all()
453 if event:
454 b = event.button
455 t = event.time
456 else:
457 b = 1
458 t = 0
460 popup_menu.popup(None, None, None, b, t)
462 return True
464 def _order_datetime(self, model, iter1, iter2, user_data=None):
465 """Used by the ListStore to sort the columns (in our case, "column")
466 by date."""
467 time1 = model.get_value(iter1, Columns.DATETIME)
468 time2 = model.get_value(iter2, Columns.DATETIME)
470 if (not time1) or \
471 (time1 < time2):
472 return -1
474 if (not time2) or \
475 (time1 > time2):
476 return 1
477 return 0
480 #------------------
481 # Main Window start
482 #------------------
483 def __init__(self, facebook):
484 global spelling_support
486 # create a new window
487 self.window = gtk.Window(gtk.WINDOW_TOPLEVEL)
488 self.window.set_size_request(400, 250)
489 self.window.set_title("Minibook")
490 self.window.connect("delete_event", lambda w, e: gtk.main_quit())
492 vbox = gtk.VBox(False, 0)
493 self.window.add(vbox)
494 vbox.show()
496 self.create_grid()
497 self.statuslist_window = gtk.ScrolledWindow()
498 self.statuslist_window.set_policy(gtk.POLICY_NEVER, gtk.POLICY_ALWAYS)
499 self.statuslist_window.add(self.treeview)
500 self.treeview.show()
501 self.statuslist_window.show()
502 vbox.add(self.statuslist_window)
504 hbox = gtk.HBox(False, 0)
506 label = gtk.Label("What's on your mind?")
507 hbox.pack_start(label, True, True, 0)
508 label.show()
509 self.count_label = gtk.Label("(160)")
510 hbox.pack_start(self.count_label, True, True, 0)
511 self.count_label.show()
512 vbox.add(hbox)
513 hbox.show()
515 self.entry = gtk.TextView()
516 text = self.entry.get_buffer()
517 text.connect('changed', self.count)
518 vbox.pack_start(self.entry, True, True, 0)
519 self.entry.show()
521 hbox = gtk.HBox(False, 0)
522 vbox.add(hbox)
523 hbox.show()
525 button = gtk.Button(stock=gtk.STOCK_CLOSE)
526 button.connect("clicked", lambda w: gtk.main_quit())
527 hbox.pack_start(button, True, True, 0)
528 button.set_flags(gtk.CAN_DEFAULT)
529 button.grab_default()
530 button.show()
532 button = gtk.Button(stock=gtk.STOCK_ADD)
533 button.connect("clicked", lambda w: self.sendupdate())
534 hbox.pack_start(button, True, True, 0)
535 button.set_flags(gtk.CAN_DEFAULT)
536 button.grab_default()
537 button.show()
539 if spelling_support:
540 try:
541 spelling = gtkspell.Spell(self.entry, 'en')
542 except:
543 spelling_support = False
545 self.window.show()
546 self._facebook = facebook
548 self._app_icon = 'minibook.png'
549 self._systray = gtk.StatusIcon()
550 self._systray.set_from_file(self._app_icon)
551 self._systray.set_tooltip('%s\n' \
552 'Left-click: toggle window hiding' % (APPNAME))
553 self._systray.connect('activate', self.systray_click)
554 self._systray.set_visible(True)
556 self.window.set_icon_from_file(self._app_icon)
558 self._threads = _ThreadManager()
560 self.userinfo = self._facebook.users.getInfo([self._facebook.uid], \
561 ['name'])[0]
562 self._last_update = 0
563 self._threads.add_work(self.post_get_friends_list,
564 self.except_get_friends_list,
565 self.get_friends_list)
567 self._prefs = {}
568 x, y = self.window.get_position()
569 self._prefs['window_pos_x'] = x
570 self._prefs['window_pos_y'] = y
571 self._prefs['auto_refresh_interval'] = 5
573 self._refresh_id = None
574 self.set_auto_refresh()
577 def main(facebook):
578 gtk.main()
579 gtk.gdk.threads_leave()
580 _log.debug('Exiting')
581 return 0
583 if __name__ == "__main__":
584 try:
585 config_file = open("config", "r")
586 api_key = config_file.readline()[:-1]
587 secret_key = config_file.readline()[:-1]
588 _log.debug('Config file loaded successfully')
589 except Exception, e:
590 _log.error('Error while loading config file: %s' % (str(e)))
591 exit(1)
593 facebook = Facebook(api_key, secret_key)
594 facebook.auth.createToken()
595 facebook.login()
596 _log.debug('Showing Facebook login page in default browser.')
598 # Delay dialog to allow for login in browser
599 dia = gtk.Dialog('minibook: login',
600 None,
601 gtk.DIALOG_MODAL | \
602 gtk.DIALOG_DESTROY_WITH_PARENT | \
603 gtk.DIALOG_NO_SEPARATOR,
604 ("Logged In", gtk.RESPONSE_OK, gtk.STOCK_CANCEL, gtk.RESPONSE_CLOSE))
605 label = gtk.Label("Click after logging in to Facebook in your browser:")
606 dia.vbox.pack_start(label, True, True, 10)
607 label.show()
608 dia.show()
609 result = dia.run()
610 if result == gtk.RESPONSE_CLOSE:
611 _log.debug('Exiting before Facebook login.')
612 exit(0)
613 dia.destroy()
615 facebook.auth.getSession()
616 _log.info('Session Key: %s' % (facebook.session_key))
617 _log.info('User\'s UID: %d' % (facebook.uid))
619 MainWindow(facebook)
620 main(facebook)