Use icons in popup menu
[minibook.git] / minibook.py
blobaf6fec0c1f97b4b11c55cb1a295c49b653176491
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
36 import urllib2
38 LEVELS = {'debug': logging.DEBUG,
39 'info': logging.INFO,
40 'warning': logging.WARNING,
41 'error': logging.ERROR,
42 'critical': logging.CRITICAL}
44 if len(sys.argv) > 1:
45 level_name = sys.argv[1]
46 level = LEVELS.get(level_name, logging.CRITICAL)
47 logging.basicConfig(level=level)
48 else:
49 logging.basicConfig(level=logging.CRITICAL)
51 _log = logging.getLogger('minibook')
54 class Columns:
55 (STATUSID, UID, STATUS, DATETIME, REPLIES, LIKES) = range(6)
58 #-------------------------------------------------
59 # Threading related objects.
60 # Info http://edsiper.linuxchile.cl/blog/?p=152
61 # to mitigate TreeView + threads problems
62 # These classes are based on the code available at http://gist.github.com/51686
63 # (c) 2008, John Stowers <john.stowers@gmail.com>
64 #-------------------------------------------------
66 class _IdleObject(gobject.GObject):
67 """
68 Override gobject.GObject to always emit signals in the main thread
69 by emmitting on an idle handler
70 """
72 def __init__(self):
73 gobject.GObject.__init__(self)
75 def emit(self, *args):
76 gobject.idle_add(gobject.GObject.emit, self, *args)
79 #-------------------------------------------------
80 # Thread support
81 #-------------------------------------------------
83 class _WorkerThread(threading.Thread, _IdleObject):
84 """A single working thread."""
86 __gsignals__ = {
87 "completed": (
88 gobject.SIGNAL_RUN_LAST,
89 gobject.TYPE_NONE,
90 (gobject.TYPE_PYOBJECT, )),
91 "exception": (
92 gobject.SIGNAL_RUN_LAST,
93 gobject.TYPE_NONE,
94 (gobject.TYPE_PYOBJECT, ))}
96 def __init__(self, function, *args, **kwargs):
97 threading.Thread.__init__(self)
98 _IdleObject.__init__(self)
99 self._function = function
100 self._args = args
101 self._kwargs = kwargs
103 def run(self):
104 # call the function
105 _log.debug('Thread %s calling %s' % (self.name, str(self._function)))
107 args = self._args
108 kwargs = self._kwargs
110 try:
111 result = self._function(*args, **kwargs)
112 except Exception, exc:
113 _log.error('Exception %s' % str(exc))
114 self.emit("exception", exc)
115 return
117 _log.debug('Thread %s completed' % (self.name))
119 self.emit("completed", result)
120 return
123 class _ThreadManager(object):
124 """Manages the threads."""
126 def __init__(self, max_threads=2):
127 """Start the thread pool. The number of threads in the pool is defined
128 by `pool_size`, defaults to 2."""
129 self._max_threads = max_threads
130 self._thread_pool = []
131 self._running = []
132 self._thread_id = 0
134 return
136 def _remove_thread(self, widget, arg=None):
137 """Called when the thread completes. We remove it from the thread list
138 (dictionary, actually) and start the next thread (if there is one)."""
140 # not actually a widget. It's the object that emitted the signal, in
141 # this case, the _WorkerThread object.
142 thread_id = widget.name
144 _log.debug('Thread %s completed, %d threads in the queue' % (thread_id,
145 len(self._thread_pool)))
147 self._running.remove(thread_id)
149 if self._thread_pool:
150 if len(self._running) < self._max_threads:
151 next = self._thread_pool.pop()
152 _log.debug('Dequeuing thread %s', next.name)
153 self._running.append(next.name)
154 next.start()
156 return
158 def add_work(self, complete_cb, exception_cb, func, *args, **kwargs):
159 """Add a work to the thread list."""
161 thread = _WorkerThread(func, *args, **kwargs)
162 thread_id = '%s' % (self._thread_id)
164 thread.connect('completed', complete_cb)
165 thread.connect('completed', self._remove_thread)
166 thread.connect('exception', exception_cb)
167 thread.setName(thread_id)
169 if len(self._running) < self._max_threads:
170 self._running.append(thread_id)
171 thread.start()
172 else:
173 running_names = ', '.join(self._running)
174 _log.debug('Threads %s running, adding %s to the queue',
175 running_names, thread_id)
176 self._thread_pool.append(thread)
178 self._thread_id += 1
179 return
182 class MainWindow:
183 """The main application interface"""
186 #------------------------------
187 # Information sending functions
188 #------------------------------
189 def sendupdate(self):
190 textfield = self.entry.get_buffer()
191 start = textfield.get_start_iter()
192 end = textfield.get_end_iter()
193 entry_text = textfield.get_text(start, end)
194 if entry_text != "":
195 _log.info('Sent status update: %s\n' % entry_text)
196 self._facebook.status.set([entry_text], [self._facebook.uid])
198 textfield.set_text("")
199 # wait a little before getting new updates, so FB can catch up
200 time.sleep(2)
201 self.refresh()
203 #------------------------------
204 # Information pulling functions
205 #------------------------------
206 def get_friends_list(self):
207 query = ("SELECT uid, name, pic_square FROM user \
208 WHERE (uid IN (SELECT uid2 FROM friend WHERE uid1 = %d) \
209 OR uid = %d)" % (self._facebook.uid, self._facebook.uid))
210 friends = self._facebook.fql.query([query])
211 return friends
213 def post_get_friends_list(self, widget, results):
214 friends = results
215 for friend in friends:
216 self.friendsname[str(friend['uid'])] = friend['name']
217 self.friendsprofilepic[str(friend['uid'])] = \
218 friend['pic_square']
220 _log.info('%s has altogether %d friends in the database.' \
221 % (self.friendsname[str(self._facebook.uid)],
222 len(self.friendsname.keys())))
223 self.refresh()
224 return
226 def except_get_friends_list(self, widget, exception):
227 _log.error("Get friends exception: %s" % (str(exception)))
229 def get_status_list(self):
230 if self._last_update > 0:
231 since = self._last_update
232 else:
233 now = int(time.time())
234 since = now - 5*24*60*60
235 till = int(time.time())
237 _log.info("Fetching status updates published between %s and %s" \
238 % (time.strftime("%c", time.localtime(since)),
239 time.strftime("%c", time.localtime(till))))
241 query = ('SELECT uid, time, status_id, message FROM status \
242 WHERE (uid IN (SELECT uid2 FROM friend WHERE uid1 = %d) \
243 OR uid = %d) \
244 AND time > %d AND time < %d\
245 ORDER BY time DESC\
246 LIMIT 60' \
247 % (self._facebook.uid, self._facebook.uid, since, till))
248 _log.debug('Status list query: %s' % (query))
250 status = self._facebook.fql.query([query])
252 _log.info('Received %d new status' % (len(status)))
253 return [status, till]
255 def post_get_status_list(self, widget, results):
256 _log.debug('Status updates successfully pulled.')
257 updates = results[0]
258 self._last_update = results[1]
260 # There are no updates
261 if len(updates) == 0:
262 return
264 # There are new updates
265 updates.reverse()
266 for up in updates:
267 self.liststore.prepend((up['status_id'],
268 up['uid'],
269 up['message'],
270 up['time'],
271 '0',
272 '0'))
273 # Scroll to latest status in view
274 model = self.treeview.get_model()
275 first_iter = model.get_iter_first()
276 first_path = model.get_path(first_iter)
277 self.treeview.scroll_to_cell(first_path)
278 return
280 def except_get_status_list(self, widget, exception):
281 _log.error("Get status list exception: %s" % (str(exception)))
283 ### image download function
284 def _dl_profile_pic(self, uid, url):
285 request = urllib2.Request(url=url)
286 _log.debug('Starting request of %s' % (url))
287 response = urllib2.urlopen(request)
288 data = response.read()
289 _log.debug('Request completed')
291 return (uid, data)
293 ### Results from the picture request
294 def _post_dl_profile_pic(self, widget, data):
295 (uid, data) = data
297 loader = gtk.gdk.PixbufLoader()
298 loader.write(data)
299 loader.close()
301 user_pic = loader.get_pixbuf()
302 self._profilepics[uid] = user_pic
304 self.treeview.queue_draw()
305 return
307 def _exception_dl_profile_pic(self, widget, exception):
308 _log.debug('Exception trying to get a profile picture.')
309 _log.debug(str(exception))
310 return
312 #-----------------
313 # Helper functions
314 #-----------------
315 def count(self, text):
316 start = text.get_start_iter()
317 end = text.get_end_iter()
318 thetext = text.get_text(start, end)
319 self.count_label.set_text('(%d)' % (160 - len(thetext)))
320 return True
322 def set_auto_refresh(self):
323 if self._refresh_id:
324 gobject.source_remove(self._refresh_id)
326 self._refresh_id = gobject.timeout_add(
327 self._prefs['auto_refresh_interval']*60*1000,
328 self.refresh)
329 _log.info("Auto-refresh enabled: %d minutes" \
330 % (self._prefs['auto_refresh_interval']))
332 def refresh(self):
333 _log.info('Refreshing now at %s' % (time.strftime('%H:%M:%S')))
334 self._threads.add_work(self.post_get_status_list,
335 self.except_get_status_list,
336 self.get_status_list)
337 return True
339 def status_format(self, column, cell, store, position):
340 uid = store.get_value(position, Columns.UID)
341 name = self.friendsname[str(uid)]
342 status = store.get_value(position, Columns.STATUS)
343 posttime = store.get_value(position, Columns.DATETIME)
345 #replace characters that would choke the markup
346 status = re.sub(r'&', r'&amp;', status)
347 status = re.sub(r'<', r'&lt;', status)
348 status = re.sub(r'>', r'&gt;', status)
349 markup = ('<b>%s</b> %s\n(%s ago)' % \
350 (name, status, timesince.timesince(posttime)))
351 _log.debug('Marked up text: %s' % (markup))
352 cell.set_property('markup', markup)
353 return
355 def open_url(self, source, url):
356 """Open url as new browser tab."""
357 _log.debug('Opening url: %s' % url)
358 import webbrowser
359 webbrowser.open_new_tab(url)
360 self.window.set_focus(self.entry)
362 def copy_status_to_clipboard(self, source, text):
363 clipboard = gtk.Clipboard()
364 _log.debug('Copying to clipboard: %s' % (text))
365 clipboard.set_text(text)
367 #--------------------
368 # Interface functions
369 #--------------------
370 def systray_click(self, widget, user_param=None):
371 if self.window.get_property('visible'):
372 _log.debug('Hiding window')
373 x, y = self.window.get_position()
374 self._prefs['window_pos_x'] = x
375 self._prefs['window_pos_y'] = y
376 self.window.hide()
377 else:
378 x = self._prefs['window_pos_x']
379 y = self._prefs['window_pos_y']
380 _log.debug('Restoring window at (%d, %d)' % (x, y))
381 self.window.move(x, y)
382 self.window.deiconify()
383 self.window.present()
385 def create_grid(self):
386 self.liststore = gtk.ListStore(gobject.TYPE_STRING,
387 gobject.TYPE_INT,
388 gobject.TYPE_STRING,
389 gobject.TYPE_STRING,
390 gobject.TYPE_STRING,
391 gobject.TYPE_STRING)
393 self.sorter = gtk.TreeModelSort(self.liststore)
394 self.sorter.set_sort_column_id(Columns.DATETIME, gtk.SORT_DESCENDING)
395 self.treeview = gtk.TreeView(self.sorter)
397 self.treeview.set_property('headers-visible', False)
398 self.treeview.set_rules_hint(True)
400 profilepic_renderer = gtk.CellRendererPixbuf()
401 profilepic_column = gtk.TreeViewColumn('Profilepic', \
402 profilepic_renderer)
403 profilepic_column.set_fixed_width(55)
404 profilepic_column.set_sizing(gtk.TREE_VIEW_COLUMN_FIXED)
405 profilepic_column.set_cell_data_func(profilepic_renderer,
406 self._cell_renderer_profilepic)
407 self.treeview.append_column(profilepic_column)
409 self.status_renderer = gtk.CellRendererText()
410 # wrapping: pango.WRAP_WORD = 0, don't need to import pango for that
411 self.status_renderer.set_property('wrap-mode', 0)
412 self.status_renderer.set_property('wrap-width', 350)
413 self.status_renderer.set_property('width', 10)
415 self.status_column = gtk.TreeViewColumn('Message', \
416 self.status_renderer, text=1)
417 self.status_column.set_cell_data_func(self.status_renderer, \
418 self.status_format)
419 self.treeview.append_column(self.status_column)
420 self.treeview.set_resize_mode(gtk.RESIZE_IMMEDIATE)
422 self.treeview.connect('row-activated', self.open_status_web)
423 self.treeview.connect('button-press-event', self.click_status)
425 def open_status_web(self, treeview, path, view_column, user_data=None):
426 """ Callback to open status update in web browser when received
427 left click.
429 model = treeview.get_model()
430 if not model:
431 return
433 iter = model.get_iter(path)
434 uid = model.get_value(iter, Columns.UID)
435 status_id = model.get_value(iter, Columns.STATUSID)
436 status_url = ('http://www.facebook.com/profile.php?' \
437 'id=%d&v=feed&story_fbid=%s' % (uid, status_id))
438 self.open_url(path, status_url)
439 return
441 def click_status(self, treeview, event, user_data=None):
442 """Callback when a mouse click event occurs on one of the rows."""
443 _log.debug('clicked on status list')
444 if event.button != 3:
445 # Only right clicks are processed
446 return False
447 _log.debug('right-click received')
449 x = int(event.x)
450 y = int(event.y)
452 pth = treeview.get_path_at_pos(x, y)
453 if not pth:
454 return False
456 path, col, cell_x, cell_y = pth
457 treeview.grab_focus()
458 treeview.set_cursor(path, col, 0)
460 self.show_status_popup(treeview, event)
461 return True
463 def show_status_popup(self, treeview, event, user_data=None):
464 _log.debug('show popup menu')
465 cursor = treeview.get_cursor()
466 if not cursor:
467 return
468 model = treeview.get_model()
469 if not model:
470 return
472 path = cursor[0]
473 iter = model.get_iter(path)
475 popup_menu = gtk.Menu()
476 popup_menu.set_screen(self.window.get_screen())
478 open_menu_items = []
480 uid = model.get_value(iter, Columns.UID)
481 status_id = model.get_value(iter, Columns.STATUSID)
482 url = ('http://www.facebook.com/profile.php?' \
483 'id=%d&v=feed&story_fbid=%s' % (uid, status_id))
484 item_name = 'This status'
485 item = gtk.MenuItem(item_name)
486 item.connect('activate', self.open_url, url)
487 open_menu_items.append(item)
489 url = ('http://www.facebook.com/profile.php?' \
490 'id=%d' % (uid))
491 item_name = 'User wall'
492 item = gtk.MenuItem(item_name)
493 item.connect('activate', self.open_url, url)
494 open_menu_items.append(item)
496 url = ('http://www.facebook.com/profile.php?' \
497 'id=%d&v=info' % (uid))
498 item_name = 'User info'
499 item = gtk.MenuItem(item_name)
500 item.connect('activate', self.open_url, url)
501 open_menu_items.append(item)
503 url = ('http://www.facebook.com/profile.php?' \
504 'id=%d&v=photos' % (uid))
505 item_name = 'User photos'
506 item = gtk.MenuItem(item_name)
507 item.connect('activate', self.open_url, url)
508 open_menu_items.append(item)
510 open_menu = gtk.Menu()
511 for item in open_menu_items:
512 open_menu.append(item)
514 # Menu item to open different pages connected to status in browser
515 open_item = gtk.ImageMenuItem('Open in browser')
516 open_item.get_image().set_from_stock(gtk.STOCK_GO_FORWARD, \
517 gtk.ICON_SIZE_MENU)
518 open_item.set_submenu(open_menu)
519 popup_menu.append(open_item)
521 # Menu item to copy status message to clipboard
522 message = model.get_value(iter, Columns.STATUS)
523 name = self.friendsname[str(uid)]
524 text = ("%s %s" % (name, message))
525 copy_item = gtk.ImageMenuItem('Copy status')
526 copy_item.get_image().set_from_stock(gtk.STOCK_COPY, \
527 gtk.ICON_SIZE_MENU)
528 copy_item.connect('activate', self.copy_status_to_clipboard, text)
529 popup_menu.append(copy_item)
531 popup_menu.show_all()
533 if event:
534 b = event.button
535 t = event.time
536 else:
537 b = 1
538 t = 0
540 popup_menu.popup(None, None, None, b, t)
542 return True
544 def _cell_renderer_profilepic(self, column, cell, store, position):
545 uid = str(store.get_value(position, Columns.UID))
546 if not uid in self._profilepics:
547 profilepicurl = self.friendsprofilepic[uid]
548 if profilepicurl:
549 _log.debug('%s does not have profile picture stored, ' \
550 'queuing fetch from %s' % (uid, profilepicurl))
551 self._threads.add_work(self._post_dl_profile_pic,
552 self._exception_dl_profile_pic,
553 self._dl_profile_pic,
554 uid,
555 profilepicurl)
556 else:
557 _log.debug('%s does not have profile picture set, ' % (uid))
559 self._profilepics[uid] = self._default_profilepic
561 cell.set_property('pixbuf', self._profilepics[uid])
563 return
565 #------------------
566 # Main Window start
567 #------------------
568 def __init__(self, facebook):
569 global spelling_support
571 unknown_user = 'pixmaps/unknown_user.png'
572 if unknown_user:
573 self._default_profilepic = gtk.gdk.pixbuf_new_from_file(
574 unknown_user)
575 else:
576 self._default_profilepic = gtk.gdk.Pixbuf(gtk.gdk.COLORSPACE_RGB,
577 has_alpha=False, bits_per_sample=8, width=50, height=50)
579 # create a new window
580 self.window = gtk.Window(gtk.WINDOW_TOPLEVEL)
581 self.window.set_size_request(425, 250)
582 self.window.set_title("Minibook")
583 self.window.connect("delete_event", lambda w, e: gtk.main_quit())
585 vbox = gtk.VBox(False, 0)
586 self.window.add(vbox)
587 vbox.show()
589 self.friendsname = {}
590 self.friendsprofilepic = {}
591 self._profilepics = {}
593 self.create_grid()
594 self.statuslist_window = gtk.ScrolledWindow()
595 self.statuslist_window.set_policy(gtk.POLICY_NEVER, gtk.POLICY_ALWAYS)
596 self.statuslist_window.add(self.treeview)
597 self.treeview.show()
598 self.statuslist_window.show()
599 vbox.add(self.statuslist_window)
601 hbox = gtk.HBox(False, 0)
603 label = gtk.Label("What's on your mind?")
604 hbox.pack_start(label, True, True, 0)
605 label.show()
606 self.count_label = gtk.Label("(160)")
607 hbox.pack_start(self.count_label, True, True, 0)
608 self.count_label.show()
609 vbox.add(hbox)
610 hbox.show()
612 self.entry = gtk.TextView()
613 text = self.entry.get_buffer()
614 text.connect('changed', self.count)
615 vbox.pack_start(self.entry, True, True, 0)
616 self.entry.show()
618 hbox = gtk.HBox(False, 0)
619 vbox.add(hbox)
620 hbox.show()
622 button = gtk.Button(stock=gtk.STOCK_CLOSE)
623 button.connect("clicked", lambda w: gtk.main_quit())
624 hbox.pack_start(button, True, True, 0)
625 button.set_flags(gtk.CAN_DEFAULT)
626 button.grab_default()
627 button.show()
629 button = gtk.Button(stock=gtk.STOCK_ADD)
630 button.connect("clicked", lambda w: self.sendupdate())
631 hbox.pack_start(button, True, True, 0)
632 button.set_flags(gtk.CAN_DEFAULT)
633 button.grab_default()
634 button.show()
636 if spelling_support:
637 try:
638 spelling = gtkspell.Spell(self.entry, 'en')
639 except:
640 spelling_support = False
642 self.window.show()
643 self._facebook = facebook
645 self._app_icon = 'pixmaps/minibook.png'
646 self._systray = gtk.StatusIcon()
647 self._systray.set_from_file(self._app_icon)
648 self._systray.set_tooltip('%s\n' \
649 'Left-click: toggle window hiding' % (APPNAME))
650 self._systray.connect('activate', self.systray_click)
651 self._systray.set_visible(True)
653 self.window.set_icon_from_file(self._app_icon)
655 self._threads = _ThreadManager()
657 self.userinfo = self._facebook.users.getInfo([self._facebook.uid], \
658 ['name'])[0]
659 self._last_update = 0
660 self._threads.add_work(self.post_get_friends_list,
661 self.except_get_friends_list,
662 self.get_friends_list)
664 self._prefs = {}
665 x, y = self.window.get_position()
666 self._prefs['window_pos_x'] = x
667 self._prefs['window_pos_y'] = y
668 self._prefs['auto_refresh_interval'] = 5
670 self._refresh_id = None
671 self.set_auto_refresh()
674 def main(facebook):
675 gtk.main()
676 gtk.gdk.threads_leave()
677 _log.debug('Exiting')
678 return 0
680 if __name__ == "__main__":
681 try:
682 config_file = open("config", "r")
683 api_key = config_file.readline()[:-1]
684 secret_key = config_file.readline()[:-1]
685 _log.debug('Config file loaded successfully')
686 except Exception, e:
687 _log.error('Error while loading config file: %s' % (str(e)))
688 exit(1)
690 facebook = Facebook(api_key, secret_key)
691 facebook.auth.createToken()
692 facebook.login()
693 _log.debug('Showing Facebook login page in default browser.')
695 # Delay dialog to allow for login in browser
696 dia = gtk.Dialog('minibook: login',
697 None,
698 gtk.DIALOG_MODAL | \
699 gtk.DIALOG_DESTROY_WITH_PARENT | \
700 gtk.DIALOG_NO_SEPARATOR,
701 ("Logged In", gtk.RESPONSE_OK, gtk.STOCK_CANCEL, gtk.RESPONSE_CLOSE))
702 label = gtk.Label("Click after logging in to Facebook in your browser:")
703 dia.vbox.pack_start(label, True, True, 10)
704 label.show()
705 dia.show()
706 result = dia.run()
707 if result == gtk.RESPONSE_CLOSE:
708 _log.debug('Exiting before Facebook login.')
709 exit(0)
710 dia.destroy()
712 facebook.auth.getSession()
713 _log.info('Session Key: %s' % (facebook.session_key))
714 _log.info('User\'s UID: %d' % (facebook.uid))
716 MainWindow(facebook)
717 main(facebook)