more logical code reorganization in a few places
[minibook.git] / minibook.py
blob7318a98302cc231ec87e9ad5c0fa1390ebb74f6e
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'
8 MIT = """
9 Copyright (c) 2009 Gergely Imreh <imrehg@gmail.com>
11 Permission is hereby granted, free of charge, to any person obtaining a copy
12 of this software and associated documentation files (the "Software"), to deal
13 in the Software without restriction, including without limitation the rights
14 to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
15 copies of the Software, and to permit persons to whom the Software is
16 furnished to do so, subject to the following conditions:
18 The above copyright notice and this permission notice shall be included in
19 all copies or substantial portions of the Software.
21 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
23 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
24 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
25 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
26 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
27 THE SOFTWARE.
28 """
29 MAX_MESSAGE_LENGTH = 255
31 import pygtk
32 pygtk.require('2.0')
33 import gtk
34 import gobject
35 try:
36 from facebook import Facebook
37 except:
38 print "Pyfacebook is not available, cannot run."
39 exit(1)
41 import time
42 import re
43 import threading
45 gobject.threads_init()
46 gtk.gdk.threads_init()
47 gtk.gdk.threads_enter()
49 try:
50 import gtkspell
51 spelling_support = True
52 except:
53 spelling_support = False
55 import logging
56 import sys
57 import timesince
58 import urllib2
60 LEVELS = {'debug': logging.DEBUG,
61 'info': logging.INFO,
62 'warning': logging.WARNING,
63 'error': logging.ERROR,
64 'critical': logging.CRITICAL}
66 if len(sys.argv) > 1:
67 level_name = sys.argv[1]
68 level = LEVELS.get(level_name, logging.CRITICAL)
69 logging.basicConfig(level=level)
70 else:
71 logging.basicConfig(level=logging.CRITICAL)
73 _log = logging.getLogger('minibook')
76 class Columns:
77 (STATUSID, UID, STATUS, DATETIME, COMMENTS, LIKES) = range(6)
80 #-------------------------------------------------
81 # Threading related objects.
82 # Info http://edsiper.linuxchile.cl/blog/?p=152
83 # to mitigate TreeView + threads problems
84 # These classes are based on the code available at http://gist.github.com/51686
85 # (c) 2008, John Stowers <john.stowers@gmail.com>
86 #-------------------------------------------------
88 class _IdleObject(gobject.GObject):
89 """
90 Override gobject.GObject to always emit signals in the main thread
91 by emmitting on an idle handler
92 """
94 def __init__(self):
95 gobject.GObject.__init__(self)
97 def emit(self, *args):
98 gobject.idle_add(gobject.GObject.emit, self, *args)
101 #-------------------------------------------------
102 # Thread support
103 #-------------------------------------------------
105 class _WorkerThread(threading.Thread, _IdleObject):
107 A single working thread.
110 __gsignals__ = {
111 "completed": (
112 gobject.SIGNAL_RUN_LAST,
113 gobject.TYPE_NONE,
114 (gobject.TYPE_PYOBJECT, )),
115 "exception": (
116 gobject.SIGNAL_RUN_LAST,
117 gobject.TYPE_NONE,
118 (gobject.TYPE_PYOBJECT, ))}
120 def __init__(self, function, *args, **kwargs):
121 threading.Thread.__init__(self)
122 _IdleObject.__init__(self)
123 self._function = function
124 self._args = args
125 self._kwargs = kwargs
127 def run(self):
128 # call the function
129 _log.debug('Thread %s calling %s' % (self.name, str(self._function)))
131 args = self._args
132 kwargs = self._kwargs
134 try:
135 result = self._function(*args, **kwargs)
136 except Exception, exc:
137 self.emit("exception", exc)
138 return
140 _log.debug('Thread %s completed' % (self.name))
142 self.emit("completed", result)
143 return
146 class _ThreadManager(object):
148 Manager to add new threads and remove finished ones from queue
151 def __init__(self, max_threads=4):
153 Start the thread pool. The number of threads in the pool is defined
154 by `pool_size`, defaults to 4
157 self._max_threads = max_threads
158 self._thread_pool = []
159 self._running = []
160 self._thread_id = 0
162 return
164 def _remove_thread(self, widget, arg=None):
166 Called when the thread completes. Remove it from the thread list
167 and start the next thread (if there is any)
170 # not actually a widget. It's the object that emitted the signal, in
171 # this case, the _WorkerThread object.
172 thread_id = widget.name
174 _log.debug('Thread %s completed, %d threads in the queue' % (thread_id,
175 len(self._thread_pool)))
177 self._running.remove(thread_id)
179 if self._thread_pool:
180 if len(self._running) < self._max_threads:
181 next = self._thread_pool.pop()
182 _log.debug('Dequeuing thread %s', next.name)
183 self._running.append(next.name)
184 next.start()
186 return
188 def add_work(self, complete_cb, exception_cb, func, *args, **kwargs):
190 Add a work to the thread list
191 complete_cb: function to call when 'func' finishes
192 exception_cb: function to call when 'func' rises an exception
193 func: function to do the main part of the work
194 *args, **kwargs: arguments for func
197 thread = _WorkerThread(func, *args, **kwargs)
198 thread_id = '%s' % (self._thread_id)
200 thread.connect('completed', complete_cb)
201 thread.connect('completed', self._remove_thread)
202 thread.connect('exception', exception_cb)
203 thread.setName(thread_id)
205 if len(self._running) < self._max_threads:
206 self._running.append(thread_id)
207 thread.start()
208 else:
209 running_names = ', '.join(self._running)
210 _log.debug('Threads %s running, adding %s to the queue',
211 running_names, thread_id)
212 self._thread_pool.append(thread)
214 self._thread_id += 1
215 return
218 class MainWindow:
220 The main application interface, GUI and Facebook interfacing functions
224 #------------------------------
225 # Information sending functions
226 #------------------------------
227 def sendupdate(self):
229 Sending status update to FB, if the user entered any
232 textfield = self.entry.get_buffer()
233 start = textfield.get_start_iter()
234 end = textfield.get_end_iter()
235 entry_text = textfield.get_text(start, end)
236 if entry_text != "":
237 # Warn user if status message is too long. If insist, send text
238 if len(entry_text) > MAX_MESSAGE_LENGTH:
239 warning_message = ("Your message is longer than %d " \
240 "characters and if submitted it is likely to be " \
241 "truncated by Facebook as:\n\"%s...\"\nInstead of " \
242 "sending this update, do you want to return to editing?" \
243 % (MAX_MESSAGE_LENGTH, entry_text[0:251]))
244 warning_dialog = gtk.MessageDialog(parent=self.window,
245 type=gtk.MESSAGE_WARNING,
246 flags=gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT,
247 message_format="Your status update is too long.",
248 buttons=gtk.BUTTONS_YES_NO)
249 warning_dialog.format_secondary_text(warning_message)
250 response = warning_dialog.run()
251 warning_dialog.destroy()
252 # If user said yes, don't send just return to editing
253 if response == gtk.RESPONSE_YES:
254 return
256 _log.info('Sending status update: %s\n' % entry_text)
257 self.statusbar.pop(self.statusbar_context)
258 self.statusbar.push(self.statusbar_context, \
259 "Sending your status update")
260 self._facebook.status.set([entry_text], [self._facebook.uid])
262 # Empty entry field and status bar
263 textfield.set_text("")
264 self.statusbar.pop(self.statusbar_context)
266 # wait a little before getting new updates, so FB can catch up
267 time.sleep(2)
268 self.refresh()
270 #------------------------------
271 # Information pulling functions
272 #------------------------------
273 def get_friends_list(self):
275 Fetching list of friends' names (and the current user's) to
276 store and use when status updates are displayed
277 Threading callbacks: post_get_friends_list, except_get_friends_list
280 self.statusbar.pop(self.statusbar_context)
281 self.statusbar.push(self.statusbar_context, \
282 "Fetching list of friends")
283 query = ("SELECT uid, name, pic_square FROM user "\
284 "WHERE (uid IN (SELECT uid2 FROM friend WHERE uid1 = %d) "\
285 "OR uid = %d)" % (self._facebook.uid, self._facebook.uid))
286 _log.debug("Friend list query: %s" % (query))
287 friends = self._facebook.fql.query([query])
288 return friends
290 def post_get_friends_list(self, widget, results):
292 Callback function when friends list is successfully pulled
293 Makes dictionary of uid->friendsname, and pulls new statuses
296 friends = results
297 for friend in friends:
298 # In "friend" table UID is integer
299 self.friendsname[str(friend['uid'])] = friend['name']
300 self.friendsprofilepic[str(friend['uid'])] = \
301 friend['pic_square']
303 # Not all friends can be pulled, depends on their privacy settings
304 _log.info('%s has altogether %d friends in the database.' \
305 % (self.friendsname[str(self._facebook.uid)],
306 len(self.friendsname.keys())))
307 self.statusbar.pop(self.statusbar_context)
309 self.refresh()
310 return
312 def except_get_friends_list(self, widget, exception):
314 Callback if there's an exception raised while fetching friends' names
317 _log.error("Get friends exception: %s" % (str(exception)))
318 self.statusbar.pop(self.statusbar_context)
319 self.statusbar.push(self.statusbar_context, \
320 "Error while fetching friends' list")
322 def get_status_list(self):
324 Fetching new statuses using FQL query for user's friends (and
325 their own) between last fetch and now
326 Threading callbacks: post_get_status_list, except_get_status_list
329 # Halt point, only one status update may proceed at a time
330 # .release() is called at all 3 possible update finish:
331 # except_get_status_list, _post_get_cl_list, _except_get_cl_list
332 self.update_sema.acquire()
334 self.statusbar.pop(self.statusbar_context)
335 self.statusbar.push(self.statusbar_context, \
336 "Fetching status updates")
338 # If not first update then get new statuses since then
339 # otherwise get them since 5 days ago (i.e. long time ago)
340 if self._last_update > 0:
341 since = self._last_update
342 else:
343 now = int(time.time())
344 since = now - 5*24*60*60
345 till = int(time.time())
347 _log.info("Fetching status updates published between %s and %s" \
348 % (time.strftime("%c", time.localtime(since)),
349 time.strftime("%c", time.localtime(till))))
351 # User "stream" table to get status updates because the older "status"
352 # has too many bugs and limitations
353 # Status update is a post that has no attachment nor target
354 query = ("SELECT source_id, created_time, post_id, message " \
355 "FROM stream "\
356 "WHERE ((source_id IN (SELECT uid2 FROM friend WHERE uid1 = %d) "\
357 "OR source_id = %d) "\
358 "AND created_time > %d AND created_time < %d "\
359 "AND attachment = '' AND target_id = '') "\
360 "ORDER BY created_time DESC "\
361 "LIMIT 100"
362 % (self._facebook.uid, self._facebook.uid, since, till))
363 _log.debug('Status list query: %s' % (query))
365 status = self._facebook.fql.query([query])
367 _log.info('Received %d new status' % (len(status)))
368 return [status, till]
370 def post_get_status_list(self, widget, results):
372 Callback function when new status updates are successfully pulled
373 Adds statuses to listview and initiates pulling comments & likes
374 restults: [status_updates_array, till_time]
377 _log.debug('Status updates successfully pulled.')
378 updates = results[0]
379 till = results[1]
381 # There are new updates
382 if len(updates) > 0:
383 updates.reverse()
384 for up in updates:
385 # source_id is the UID, and in "stream" it is string, not int
386 self.liststore.prepend((up['post_id'],
387 up['source_id'],
388 up['message'],
389 up['created_time'],
390 '0',
391 '0'))
392 # Scroll to newest status in view
393 model = self.treeview.get_model()
394 first_iter = model.get_iter_first()
395 first_path = model.get_path(first_iter)
396 self.treeview.scroll_to_cell(first_path)
398 self.statusbar.pop(self.statusbar_context)
400 # pull comments and likes too
401 self._threads.add_work(self._post_get_cl_list,
402 self._except_get_cl_list,
403 self._get_cl_list,
404 till)
405 return
407 def except_get_status_list(self, widget, exception):
409 Callback if there's an exception raised while fetching status list
412 _log.error("Get status list exception: %s" % (str(exception)))
413 self.statusbar.pop(self.statusbar_context)
414 self.statusbar.push(self.statusbar_context, \
415 "Error while fetching status updates")
416 # Finish, give semaphore back in case anyone's waiting
417 self.update_sema.release()
419 ### image download function
420 def _dl_profile_pic(self, uid, url):
422 Download user profile pictures
423 Threading callbacks: _post_dl_profile_pic, _exception_dl_profile_pic
424 url: picture's url
427 request = urllib2.Request(url=url)
428 _log.debug('Starting request of %s' % (url))
429 response = urllib2.urlopen(request)
430 data = response.read()
431 _log.debug('Request completed')
433 return (uid, data)
435 ### Results from the picture request
436 def _post_dl_profile_pic(self, widget, data):
438 Callback when profile picture is successfully downloaded
439 Replaces default picture with the users profile pic in status list
442 (uid, data) = data
444 loader = gtk.gdk.PixbufLoader()
445 loader.write(data)
446 loader.close()
448 user_pic = loader.get_pixbuf()
449 # Replace default picture
450 self._profilepics[uid] = user_pic
452 # Redraw to get new picture
453 self.treeview.queue_draw()
454 return
456 def _exception_dl_profile_pic(self, widget, exception):
458 Callback when there's an excetion during downloading profile picture
461 _log.debug('Exception trying to get a profile picture.')
462 _log.debug(str(exception))
463 return
465 ### get comments and likes
466 def _get_cl_list(self, till):
468 Fetch comments & likes for the listed statuses
469 Threading callbacks: _post_get_cl_list, _except_get_cl_list
470 till: time between self.last_update and till
473 _log.info('Pulling comments & likes for listed status updates')
474 self.statusbar.pop(self.statusbar_context)
475 self.statusbar.push(self.statusbar_context, \
476 "Fetching comments & likes")
478 # Preparing list of status update post_id for FQL query
479 post_id = []
480 for row in self.liststore:
481 post_id.append('post_id = "%s"' % (row[Columns.STATUSID]))
482 all_id = ' OR '.join(post_id)
484 query = ('SELECT post_id, comments, likes FROM stream WHERE ((%s) ' \
485 'AND updated_time > %d AND updated_time < %d)' % \
486 (all_id, self._last_update, till))
487 _log.debug('Comments & Likes query: %s' % (query))
489 cl_list = self._facebook.fql.query([query])
491 return (cl_list, till)
493 ### Results from the picture request
494 def _post_get_cl_list(self, widget, data):
496 Callback when successfully fetched new comments and likes
497 Ends up here if complete 'refresh' is successfull
500 list = data[0]
501 till = data[1]
503 likes_list = {}
504 comments_list = {}
506 for item in list:
507 status_id = item['post_id']
508 likes_list[status_id] = str(item['likes']['count'])
509 comments_list[status_id] = str(item['comments']['count'])
511 for row in self.liststore:
512 rowstatus = row[Columns.STATUSID]
513 # have to check if post really exists, deleted post still
514 # show up in "status" table sometimes, not sure in "stream"
515 if rowstatus in likes_list.keys():
516 row[Columns.LIKES] = likes_list[rowstatus]
517 row[Columns.COMMENTS] = comments_list[rowstatus]
518 else:
519 _log.debug("Possible deleted status update: " \
520 "uid: %s, status_id: %s, user: %s, text: %s, time: %s" \
521 % (row[Columns.UID], rowstatus, \
522 self.friendsname[str(row[Columns.UID])], \
523 row[Columns.STATUS], row[Columns.DATETIME]))
525 # Update time of last update since this finished just fine
526 self._last_update = till
527 _log.info('Finished updating status messages, comments and likes.')
528 self.statusbar.pop(self.statusbar_context)
530 # Last update time in human readable format
531 update_time = time.strftime("%H:%M", time.localtime(till))
532 self.statusbar.push(self.statusbar_context, \
533 "Last update at %s" % (update_time))
535 # Finish, give semaphore back in case anyone's waiting
536 self.update_sema.release()
537 return
539 def _except_get_cl_list(self, widget, exception):
541 Callback if there' an exception during comments and likes fetch
544 _log.error('Exception while getting comments and likes')
545 _log.error(str(exception))
546 self.statusbar.pop(self.statusbar_context)
547 self.statusbar.push(self.statusbar_context, \
548 "Error while fetching comments & likes")
549 # Finish, give semaphore back in case anyone's waiting
550 self.update_sema.release()
551 return
553 #-----------------
554 # Helper functions
555 #-----------------
556 def count(self, text):
558 Count remaining characters in status update text
561 start = text.get_start_iter()
562 end = text.get_end_iter()
563 thetext = text.get_text(start, end)
564 self.count_label.set_text('(%d)' \
565 % (MAX_MESSAGE_LENGTH - len(thetext)))
566 return True
568 def set_auto_refresh(self):
570 Enable auto refresh statuses in pre-defined intervals
573 if self._refresh_id:
574 gobject.source_remove(self._refresh_id)
576 self._refresh_id = gobject.timeout_add(
577 self._prefs['auto_refresh_interval']*60*1000,
578 self.refresh)
579 _log.info("Auto-refresh enabled: %d minutes" \
580 % (self._prefs['auto_refresh_interval']))
582 def refresh(self, widget=None):
584 Queueing refresh in thread pool, subject to semaphores
587 _log.info('Queueing refresh now at %s' % (time.strftime('%H:%M:%S')))
588 self._threads.add_work(self.post_get_status_list,
589 self.except_get_status_list,
590 self.get_status_list)
591 return True
593 def status_format(self, column, cell, store, position):
595 Format how status update should look in list
598 uid = store.get_value(position, Columns.UID)
599 name = self.friendsname[uid]
600 status = store.get_value(position, Columns.STATUS)
601 posttime = store.get_value(position, Columns.DATETIME)
603 #replace characters that would choke the markup
604 status = re.sub(r'&', r'&amp;', status)
605 status = re.sub(r'<', r'&lt;', status)
606 status = re.sub(r'>', r'&gt;', status)
607 markup = ('<b>%s</b> %s\n(%s ago)' % \
608 (name, status, timesince.timesince(posttime)))
609 _log.debug('Marked up text: %s' % (markup))
610 cell.set_property('markup', markup)
611 return
613 def open_url(self, source, url):
615 Open url as new browser tab
618 _log.debug('Opening url: %s' % url)
619 import webbrowser
620 webbrowser.open_new_tab(url)
621 self.window.set_focus(self.entry)
623 def copy_status_to_clipboard(self, source, text):
625 Copy current status (together with poster name but without time)
626 to the clipboard
629 clipboard = gtk.Clipboard()
630 _log.debug('Copying to clipboard: %s' % (text))
631 clipboard.set_text(text)
633 #--------------------
634 # Interface functions
635 #--------------------
636 def quit(self, widget):
638 Finish program
640 gtk.main_quit()
642 def systray_click(self, widget, user_param=None):
644 Callback when systray icon receives left-click
647 # Toggle visibility of main window
648 if self.window.get_property('visible'):
649 _log.debug('Hiding window')
650 x, y = self.window.get_position()
651 self._prefs['window_pos_x'] = x
652 self._prefs['window_pos_y'] = y
653 self.window.hide()
654 else:
655 x = self._prefs['window_pos_x']
656 y = self._prefs['window_pos_y']
657 _log.debug('Restoring window at (%d, %d)' % (x, y))
658 self.window.move(x, y)
659 self.window.deiconify()
660 self.window.present()
662 def create_grid(self):
664 Create list where each line consist of:
665 profile pic, status update, comments and likes count
668 # List for storing all relevant info
669 self.liststore = gtk.ListStore(gobject.TYPE_STRING,
670 gobject.TYPE_STRING,
671 gobject.TYPE_STRING,
672 gobject.TYPE_INT,
673 gobject.TYPE_STRING,
674 gobject.TYPE_STRING)
676 # Short items by time, newest first
677 self.sorter = gtk.TreeModelSort(self.liststore)
678 self.sorter.set_sort_column_id(Columns.DATETIME, gtk.SORT_DESCENDING)
679 self.treeview = gtk.TreeView(self.sorter)
681 # No headers
682 self.treeview.set_property('headers-visible', False)
683 # Alternating background colours for lines
684 self.treeview.set_rules_hint(True)
686 # Column showing profile picture
687 profilepic_renderer = gtk.CellRendererPixbuf()
688 profilepic_column = gtk.TreeViewColumn('Profilepic', \
689 profilepic_renderer)
690 profilepic_column.set_fixed_width(55)
691 profilepic_column.set_sizing(gtk.TREE_VIEW_COLUMN_FIXED)
692 profilepic_column.set_cell_data_func(profilepic_renderer,
693 self._cell_renderer_profilepic)
694 self.treeview.append_column(profilepic_column)
696 # Column showing status text
697 self.status_renderer = gtk.CellRendererText()
698 # wrapping: pango.WRAP_WORD == 0, don't need to import pango for that
699 self.status_renderer.set_property('wrap-mode', 0)
700 self.status_renderer.set_property('wrap-width', 320)
701 self.status_renderer.set_property('width', 320)
702 self.status_column = gtk.TreeViewColumn('Message', \
703 self.status_renderer, text=1)
704 self.status_column.set_cell_data_func(self.status_renderer, \
705 self.status_format)
706 self.treeview.append_column(self.status_column)
708 # Showing the number of comments
709 comments_renderer = gtk.CellRendererText()
710 comments_column = gtk.TreeViewColumn('Comments', \
711 comments_renderer, text=1)
712 comments_column.set_cell_data_func(comments_renderer, \
713 self._cell_renderer_comments)
714 self.treeview.append_column(comments_column)
716 # Showing the comments icon
717 commentspic_renderer = gtk.CellRendererPixbuf()
718 commentspic_column = gtk.TreeViewColumn('CommentsPic', \
719 commentspic_renderer)
720 commentspic_column.set_cell_data_func(commentspic_renderer, \
721 self._cell_renderer_commentspic)
722 commentspic_column.set_fixed_width(28)
723 commentspic_column.set_sizing(gtk.TREE_VIEW_COLUMN_FIXED)
724 self.treeview.append_column(commentspic_column)
726 # Showing the number of likes
727 likes_renderer = gtk.CellRendererText()
728 likes_column = gtk.TreeViewColumn('Likes', \
729 likes_renderer, text=1)
730 likes_column.set_cell_data_func(likes_renderer, \
731 self._cell_renderer_likes)
732 self.treeview.append_column(likes_column)
734 # Showing the likes icon
735 likespic_renderer = gtk.CellRendererPixbuf()
736 likespic_column = gtk.TreeViewColumn('Likespic', \
737 likespic_renderer)
738 likespic_column.set_cell_data_func(likespic_renderer, \
739 self._cell_renderer_likespic)
740 likespic_column.set_fixed_width(28)
741 likespic_column.set_sizing(gtk.TREE_VIEW_COLUMN_FIXED)
742 self.treeview.append_column(likespic_column)
744 self.treeview.set_resize_mode(gtk.RESIZE_IMMEDIATE)
746 self.treeview.connect('row-activated', self.open_status_web)
747 self.treeview.connect('button-press-event', self.click_status)
749 def create_menubar(self):
751 Showing the app's (very basic) menubar
754 refresh_action = gtk.Action('Refresh', '_Refresh',
755 'Get new status updates', gtk.STOCK_REFRESH)
756 refresh_action.connect('activate', self.refresh)
758 quit_action = gtk.Action('Quit', '_Quit',
759 'Exit %s' % (APPNAME), gtk.STOCK_QUIT)
760 quit_action.connect('activate', self.quit)
762 about_action = gtk.Action('About', '_About', 'About %s' % (APPNAME),
763 gtk.STOCK_ABOUT)
764 about_action.connect('activate', self.show_about)
766 self.action_group = gtk.ActionGroup('MainMenu')
767 self.action_group.add_action_with_accel(refresh_action, 'F5')
768 # accel = None to use the default acceletator
769 self.action_group.add_action_with_accel(quit_action, None)
770 self.action_group.add_action(about_action)
772 uimanager = gtk.UIManager()
773 uimanager.insert_action_group(self.action_group, 0)
774 ui = '''
775 <ui>
776 <menubar name="MainMenu">
777 <menuitem action="Quit" />
778 <separator />
779 <menuitem action="Refresh" />
780 <separator />
781 <menuitem action="About" />
782 </menubar>
783 </ui>
785 uimanager.add_ui_from_string(ui)
786 self.main_menu = uimanager.get_widget('/MainMenu')
787 return
789 def show_about(self, widget):
791 Show the about dialog
794 about_window = gtk.AboutDialog()
795 about_window.set_program_name(APPNAME)
796 about_window.set_version(VERSION)
797 about_window.set_copyright('2009 Gergely Imreh <imrehg@gmail.com>')
798 about_window.set_license(MIT)
799 about_window.set_website('http://imrehg.github.com/minibook/')
800 about_window.set_website_label('%s on GitHub' % (APPNAME))
801 about_window.connect('close', self.close_dialog)
802 about_window.run()
803 about_window.hide()
805 def close_dialog(self, user_data=None):
807 Hide the dialog window
810 return True
812 def open_status_web(self, treeview, path, view_column, user_data=None):
814 Callback to open status update in web browser when received left click
817 model = treeview.get_model()
818 if not model:
819 return
821 iter = model.get_iter(path)
822 uid = model.get_value(iter, Columns.UID)
823 status_id = model.get_value(iter, Columns.STATUSID).split("_")[1]
824 status_url = ('http://www.facebook.com/profile.php?' \
825 'id=%s&v=feed&story_fbid=%s' % (uid, status_id))
826 self.open_url(path, status_url)
827 return
829 def click_status(self, treeview, event, user_data=None):
831 Callback when a mouse click event occurs on one of the rows
834 _log.debug('Clicked on status list')
835 if event.button != 3:
836 # Only right clicks are processed
837 return False
838 _log.debug('right-click received')
840 x = int(event.x)
841 y = int(event.y)
843 pth = treeview.get_path_at_pos(x, y)
844 if not pth:
845 return False
847 path, col, cell_x, cell_y = pth
848 treeview.grab_focus()
849 treeview.set_cursor(path, col, 0)
851 self.show_status_popup(treeview, event)
852 return True
854 def show_status_popup(self, treeview, event, user_data=None):
856 Show popup menu relevant to the clicked status update
859 _log.debug('Show popup menu')
860 cursor = treeview.get_cursor()
861 if not cursor:
862 return
863 model = treeview.get_model()
864 if not model:
865 return
867 path = cursor[0]
868 iter = model.get_iter(path)
870 popup_menu = gtk.Menu()
871 popup_menu.set_screen(self.window.get_screen())
873 open_menu_items = []
875 # Open this status update in browser
876 uid = model.get_value(iter, Columns.UID)
877 status_id = model.get_value(iter, Columns.STATUSID).split("_")[1]
878 url = ('http://www.facebook.com/profile.php?' \
879 'id=%s&v=feed&story_fbid=%s' % (uid, status_id))
880 item_name = 'This status'
881 item = gtk.MenuItem(item_name)
882 item.connect('activate', self.open_url, url)
883 open_menu_items.append(item)
885 # Open user's wall in browser
886 url = ('http://www.facebook.com/profile.php?' \
887 'id=%s' % (uid))
888 item_name = 'User wall'
889 item = gtk.MenuItem(item_name)
890 item.connect('activate', self.open_url, url)
891 open_menu_items.append(item)
893 # Open user's info in browser
894 url = ('http://www.facebook.com/profile.php?' \
895 'id=%s&v=info' % (uid))
896 item_name = 'User info'
897 item = gtk.MenuItem(item_name)
898 item.connect('activate', self.open_url, url)
899 open_menu_items.append(item)
901 # Open user's photos in browser
902 url = ('http://www.facebook.com/profile.php?' \
903 'id=%s&v=photos' % (uid))
904 item_name = 'User photos'
905 item = gtk.MenuItem(item_name)
906 item.connect('activate', self.open_url, url)
907 open_menu_items.append(item)
909 open_menu = gtk.Menu()
910 for item in open_menu_items:
911 open_menu.append(item)
913 # Menu item for "open in browser" menu
914 open_item = gtk.ImageMenuItem('Open in browser')
915 open_item.get_image().set_from_stock(gtk.STOCK_GO_FORWARD, \
916 gtk.ICON_SIZE_MENU)
917 open_item.set_submenu(open_menu)
918 popup_menu.append(open_item)
920 # Menu item to copy status message to clipboard
921 message = model.get_value(iter, Columns.STATUS)
922 name = self.friendsname[str(uid)]
923 text = ("%s %s" % (name, message))
924 copy_item = gtk.ImageMenuItem('Copy status')
925 copy_item.get_image().set_from_stock(gtk.STOCK_COPY, \
926 gtk.ICON_SIZE_MENU)
927 copy_item.connect('activate', self.copy_status_to_clipboard, text)
928 popup_menu.append(copy_item)
930 popup_menu.show_all()
932 if event:
933 b = event.button
934 t = event.time
935 else:
936 b = 1
937 t = 0
939 popup_menu.popup(None, None, None, b, t)
941 return True
943 def _cell_renderer_profilepic(self, column, cell, store, position):
945 Showing profile picture in status update list
946 Use default picture if we don't (can't) have the user's
947 If first time trying to display it try to download and display default
950 uid = str(store.get_value(position, Columns.UID))
951 if not uid in self._profilepics:
952 profilepicurl = self.friendsprofilepic[uid]
953 if profilepicurl:
954 _log.debug('%s does not have profile picture stored, ' \
955 'queuing fetch from %s' % (uid, profilepicurl))
956 self._threads.add_work(self._post_dl_profile_pic,
957 self._exception_dl_profile_pic,
958 self._dl_profile_pic,
959 uid,
960 profilepicurl)
961 else:
962 _log.debug('%s does not have profile picture set, ' % (uid))
964 self._profilepics[uid] = self._default_profilepic
966 cell.set_property('pixbuf', self._profilepics[uid])
968 return
970 def _cell_renderer_comments(self, column, cell, store, position):
972 Cell renderer for the number of comments
975 comments = int(store.get_value(position, Columns.COMMENTS))
976 if comments > 0:
977 cell.set_property('text', str(comments))
978 else:
979 cell.set_property('text', '')
981 def _cell_renderer_commentspic(self, column, cell, store, position):
983 Cell renderer for comments picture if there are any comments
986 comments = int(store.get_value(position, Columns.COMMENTS))
987 if comments > 0:
988 cell.set_property('pixbuf', self.commentspic)
989 else:
990 cell.set_property('pixbuf', None)
992 def _cell_renderer_likes(self, column, cell, store, position):
994 Cell renderer for number of likes
997 likes = int(store.get_value(position, Columns.LIKES))
998 if likes > 0:
999 cell.set_property('text', str(likes))
1000 else:
1001 cell.set_property('text', '')
1003 def _cell_renderer_likespic(self, column, cell, store, position):
1005 Cell renderer for likess picture if there are any likes
1008 likes = int(store.get_value(position, Columns.LIKES))
1009 if likes > 0:
1010 cell.set_property('pixbuf', self.likespic)
1011 else:
1012 cell.set_property('pixbuf', None)
1014 #------------------
1015 # Main Window start
1016 #------------------
1017 def __init__(self, facebook):
1019 Creating main window and setting up relevant variables
1022 global spelling_support
1024 # Connect to facebook object
1025 self._facebook = facebook
1027 # Picture shown if cannot get a user's own profile picture
1028 unknown_user = 'pixmaps/unknown_user.png'
1029 if unknown_user:
1030 self._default_profilepic = gtk.gdk.pixbuf_new_from_file(
1031 unknown_user)
1032 else:
1033 self._default_profilepic = gtk.gdk.Pixbuf(gtk.gdk.COLORSPACE_RGB,
1034 has_alpha=False, bits_per_sample=8, width=50, height=50)
1036 # Icons for "comments" and "likes"
1037 self.commentspic = gtk.gdk.pixbuf_new_from_file('pixmaps/comments.png')
1038 self.likespic = gtk.gdk.pixbuf_new_from_file('pixmaps/likes.png')
1040 self.friendsname = {}
1041 self.friendsprofilepic = {}
1042 self._profilepics = {}
1043 # Semaphore to let only one status update proceed at a time
1044 self.update_sema = threading.BoundedSemaphore(value=1)
1046 # create a new window
1047 self.window = gtk.Window(gtk.WINDOW_TOPLEVEL)
1048 self.window.set_size_request(480, 250)
1049 self.window.set_title("Minibook")
1050 self.window.connect("delete_event", lambda w, e: gtk.main_quit())
1052 vbox = gtk.VBox(False, 0)
1053 self.window.add(vbox)
1055 # Menubar
1056 self.create_menubar()
1057 vbox.pack_start(self.main_menu, False, True, 0)
1059 # Status update display window
1060 self.create_grid()
1061 self.statuslist_window = gtk.ScrolledWindow()
1062 self.statuslist_window.set_policy(gtk.POLICY_NEVER, gtk.POLICY_ALWAYS)
1063 self.statuslist_window.add(self.treeview)
1064 vbox.pack_start(self.statuslist_window, True, True, 0)
1066 # Area around the status update entry box with labels and button
1067 label_box = gtk.HBox(False, 0)
1068 label = gtk.Label("What's on your mind?")
1069 self.count_label = gtk.Label("(%d)" % (MAX_MESSAGE_LENGTH))
1070 label_box.pack_start(label)
1071 label_box.pack_start(self.count_label)
1073 self.entry = gtk.TextView()
1074 text = self.entry.get_buffer()
1075 text.connect('changed', self.count)
1076 text_box = gtk.VBox(True, 0)
1077 text_box.pack_start(label_box)
1078 text_box.pack_start(self.entry, True, True, 4)
1080 update_button = gtk.Button(stock=gtk.STOCK_ADD)
1081 update_button.connect("clicked", lambda w: self.sendupdate())
1083 update_box = gtk.HBox(False, 0)
1084 update_box.pack_start(text_box, expand=True, fill=True,
1085 padding=0)
1086 update_box.pack_start(update_button, expand=False, fill=False,
1087 padding=0)
1089 vbox.pack_start(update_box, False, True, 0)
1091 # Statusbar
1092 self.statusbar = gtk.Statusbar()
1093 vbox.pack_start(self.statusbar, False, False, 0)
1094 self.statusbar_context = self.statusbar.get_context_id(
1095 '%s is here.' % (APPNAME))
1097 # Set up spell checking if it is available
1098 if spelling_support:
1099 try:
1100 spelling = gtkspell.Spell(self.entry, 'en')
1101 except:
1102 spelling_support = False
1104 # Show window
1105 self.window.show_all()
1107 # Set up systray icon
1108 self._app_icon = 'pixmaps/minibook.png'
1109 self._systray = gtk.StatusIcon()
1110 self._systray.set_from_file(self._app_icon)
1111 self._systray.set_tooltip('%s\n' \
1112 'Left-click: toggle window hiding' % (APPNAME))
1113 self._systray.connect('activate', self.systray_click)
1114 self._systray.set_visible(True)
1116 self.window.set_icon_from_file(self._app_icon)
1118 # Enable thread manager
1119 self._threads = _ThreadManager()
1121 # Start to set up preferences
1122 self._prefs = {}
1123 x, y = self.window.get_position()
1124 self._prefs['window_pos_x'] = x
1125 self._prefs['window_pos_y'] = y
1126 self._prefs['auto_refresh_interval'] = 5
1128 # Last update: never, start first one
1129 self._last_update = 0
1130 self._threads.add_work(self.post_get_friends_list,
1131 self.except_get_friends_list,
1132 self.get_friends_list)
1134 # Enable auto-refresh
1135 self._refresh_id = None
1136 self.set_auto_refresh()
1139 def main(facebook):
1141 Main function
1144 gtk.main()
1145 gtk.gdk.threads_leave()
1146 _log.debug('Exiting')
1147 return 0
1149 if __name__ == "__main__":
1151 Set up facebook object, login and start main window
1154 # Currently cannot include the registered app's
1155 # api_key and secret_key, thus have to save them separately
1156 # Here those keys are loaded
1157 try:
1158 config_file = open("config", "r")
1159 api_key = config_file.readline()[:-1]
1160 secret_key = config_file.readline()[:-1]
1161 _log.debug('Config file loaded successfully')
1162 except Exception, e:
1163 _log.critical('Error while loading config file: %s' % (str(e)))
1164 exit(1)
1166 facebook = Facebook(api_key, secret_key)
1167 try:
1168 facebook.auth.createToken()
1169 except:
1170 # Like catch errors like
1171 # http://bugs.developers.facebook.com/show_bug.cgi?id=5474
1172 # and http://bugs.developers.facebook.com/show_bug.cgi?id=5472
1173 _log.critical("Error on Facebook's side, " \
1174 "try starting application later")
1175 exit(1)
1177 _log.debug('Showing Facebook login page in default browser.')
1178 facebook.login()
1180 # Delay dialog to allow for login in browser
1181 got_session = False
1182 while not got_session:
1183 dia = gtk.Dialog('minibook: login',
1184 None,
1185 gtk.DIALOG_MODAL | \
1186 gtk.DIALOG_DESTROY_WITH_PARENT | \
1187 gtk.DIALOG_NO_SEPARATOR,
1188 ("Logged in", gtk.RESPONSE_OK, \
1189 gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL))
1190 label = gtk.Label("%s is opening your web browser to " \
1191 "log in Facebook.\nWhen finished, click 'Logged in', " \
1192 "or you can cancel now." % (APPNAME))
1193 dia.vbox.pack_start(label, True, True, 10)
1194 label.show()
1195 dia.show()
1196 result = dia.run()
1197 # Cancel login and close app
1198 if result == gtk.RESPONSE_CANCEL:
1199 _log.debug('Exiting before Facebook login.')
1200 exit(0)
1201 dia.destroy()
1202 try:
1203 facebook.auth.getSession()
1204 got_session = True
1205 except:
1206 # Likely clicked "logged in" but not logged in yet, start over
1207 pass
1209 _log.info('Session Key: %s' % (facebook.session_key))
1210 _log.info('User\'s UID: %d' % (facebook.uid))
1212 # Start main window and app
1213 MainWindow(facebook)
1214 main(facebook)