2 """ Minibook: the Facebook(TM) status updater
3 (C) 2009 Gergely Imreh <imrehg@gmail.com>
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
29 MAX_MESSAGE_LENGTH
= 255
36 from facebook
import Facebook
38 print "Pyfacebook is not available, cannot run."
45 gobject
.threads_init()
46 gtk
.gdk
.threads_init()
47 gtk
.gdk
.threads_enter()
51 spelling_support
= True
53 spelling_support
= False
60 LEVELS
= {'debug': logging
.DEBUG
,
62 'warning': logging
.WARNING
,
63 'error': logging
.ERROR
,
64 'critical': logging
.CRITICAL
}
67 level_name
= sys
.argv
[1]
68 level
= LEVELS
.get(level_name
, logging
.CRITICAL
)
69 logging
.basicConfig(level
=level
)
71 logging
.basicConfig(level
=logging
.CRITICAL
)
73 _log
= logging
.getLogger('minibook')
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
):
90 Override gobject.GObject to always emit signals in the main thread
91 by emmitting on an idle handler
95 gobject
.GObject
.__init
__(self
)
97 def emit(self
, *args
):
98 gobject
.idle_add(gobject
.GObject
.emit
, self
, *args
)
101 #-------------------------------------------------
103 #-------------------------------------------------
105 class _WorkerThread(threading
.Thread
, _IdleObject
):
107 A single working thread.
112 gobject
.SIGNAL_RUN_LAST
,
114 (gobject
.TYPE_PYOBJECT
, )),
116 gobject
.SIGNAL_RUN_LAST
,
118 (gobject
.TYPE_PYOBJECT
, ))}
120 def __init__(self
, function
, *args
, **kwargs
):
121 threading
.Thread
.__init
__(self
)
122 _IdleObject
.__init
__(self
)
123 self
._function
= function
125 self
._kwargs
= kwargs
129 _log
.debug('Thread %s calling %s' % (self
.name
, str(self
._function
)))
132 kwargs
= self
._kwargs
135 result
= self
._function
(*args
, **kwargs
)
136 except Exception, exc
:
137 self
.emit("exception", exc
)
140 _log
.debug('Thread %s completed' % (self
.name
))
142 self
.emit("completed", result
)
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
= []
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
)
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
)
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
)
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
)
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
:
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
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
])
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
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'])] = \
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
)
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
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 " \
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 "\
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.')
381 # There are new updates
385 # source_id is the UID, and in "stream" it is string, not int
386 self
.liststore
.prepend((up
['post_id'],
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
)
397 self
.new_status_notify()
399 self
.statusbar
.pop(self
.statusbar_context
)
401 # pull comments and likes too
402 self
._threads
.add_work(self
._post
_get
_cl
_list
,
403 self
._except
_get
_cl
_list
,
408 def except_get_status_list(self
, widget
, exception
):
410 Callback if there's an exception raised while fetching status list
413 _log
.error("Get status list exception: %s" % (str(exception
)))
414 self
.statusbar
.pop(self
.statusbar_context
)
415 self
.statusbar
.push(self
.statusbar_context
, \
416 "Error while fetching status updates")
417 # Finish, give semaphore back in case anyone's waiting
418 self
.update_sema
.release()
420 ### image download function
421 def _dl_profile_pic(self
, uid
, url
):
423 Download user profile pictures
424 Threading callbacks: _post_dl_profile_pic, _exception_dl_profile_pic
428 request
= urllib2
.Request(url
=url
)
429 _log
.debug('Starting request of %s' % (url
))
430 response
= urllib2
.urlopen(request
)
431 data
= response
.read()
432 _log
.debug('Request completed')
436 ### Results from the picture request
437 def _post_dl_profile_pic(self
, widget
, data
):
439 Callback when profile picture is successfully downloaded
440 Replaces default picture with the users profile pic in status list
445 loader
= gtk
.gdk
.PixbufLoader()
449 user_pic
= loader
.get_pixbuf()
450 # Replace default picture
451 self
._profilepics
[uid
] = user_pic
453 # Redraw to get new picture
454 self
.treeview
.queue_draw()
457 def _exception_dl_profile_pic(self
, widget
, exception
):
459 Callback when there's an excetion during downloading profile picture
462 _log
.debug('Exception trying to get a profile picture.')
463 _log
.debug(str(exception
))
466 ### get comments and likes
467 def _get_cl_list(self
, till
):
469 Fetch comments & likes for the listed statuses
470 Threading callbacks: _post_get_cl_list, _except_get_cl_list
471 till: time between self.last_update and till
474 _log
.info('Pulling comments & likes for listed status updates')
475 self
.statusbar
.pop(self
.statusbar_context
)
476 self
.statusbar
.push(self
.statusbar_context
, \
477 "Fetching comments & likes")
479 # Preparing list of status update post_id for FQL query
481 for row
in self
.liststore
:
482 post_id
.append('post_id = "%s"' % (row
[Columns
.STATUSID
]))
483 all_id
= ' OR '.join(post_id
)
485 query
= ('SELECT post_id, comments, likes FROM stream WHERE ((%s) ' \
486 'AND updated_time > %d AND updated_time <= %d)' % \
487 (all_id
, self
._last
_update
, till
))
488 _log
.debug('Comments & Likes query: %s' % (query
))
490 cl_list
= self
._facebook
.fql
.query([query
])
492 return (cl_list
, till
)
494 ### Results from the picture request
495 def _post_get_cl_list(self
, widget
, data
):
497 Callback when successfully fetched new comments and likes
498 Ends up here if complete 'refresh' is successfull
508 status_id
= item
['post_id']
509 likes_list
[status_id
] = str(item
['likes']['count'])
510 comments_list
[status_id
] = str(item
['comments']['count'])
512 for row
in self
.liststore
:
513 rowstatus
= row
[Columns
.STATUSID
]
514 # have to check if post really exists, deleted post still
515 # show up in "status" table sometimes, not sure in "stream"
516 if rowstatus
in likes_list
.keys():
517 row
[Columns
.LIKES
] = likes_list
[rowstatus
]
518 row
[Columns
.COMMENTS
] = comments_list
[rowstatus
]
520 _log
.debug("Possible deleted status update: " \
521 "uid: %s, status_id: %s, user: %s, text: %s, time: %s" \
522 % (row
[Columns
.UID
], rowstatus
, \
523 self
.friendsname
[str(row
[Columns
.UID
])], \
524 row
[Columns
.STATUS
], row
[Columns
.DATETIME
]))
526 # Update time of last update since this finished just fine
527 self
._last
_update
= till
528 _log
.info('Finished updating status messages, comments and likes.')
529 self
.statusbar
.pop(self
.statusbar_context
)
531 # Last update time in human readable format
532 update_time
= time
.strftime("%H:%M", time
.localtime(till
))
533 self
.statusbar
.push(self
.statusbar_context
, \
534 "Last update at %s" % (update_time
))
536 # Finish, give semaphore back in case anyone's waiting
537 self
.update_sema
.release()
540 def _except_get_cl_list(self
, widget
, exception
):
542 Callback if there' an exception during comments and likes fetch
545 _log
.error('Exception while getting comments and likes')
546 _log
.error(str(exception
))
547 self
.statusbar
.pop(self
.statusbar_context
)
548 self
.statusbar
.push(self
.statusbar_context
, \
549 "Error while fetching comments & likes")
550 # Finish, give semaphore back in case anyone's waiting
551 self
.update_sema
.release()
557 def count(self
, text
):
559 Count remaining characters in status update text
562 start
= text
.get_start_iter()
563 end
= text
.get_end_iter()
564 thetext
= text
.get_text(start
, end
)
565 self
.count_label
.set_text('(%d)' \
566 % (MAX_MESSAGE_LENGTH
- len(thetext
)))
569 def set_auto_refresh(self
):
571 Enable auto refresh statuses in pre-defined intervals
575 gobject
.source_remove(self
._refresh
_id
)
577 self
._refresh
_id
= gobject
.timeout_add(
578 self
._prefs
['auto_refresh_interval']*60*1000,
580 _log
.info("Auto-refresh enabled: %d minutes" \
581 % (self
._prefs
['auto_refresh_interval']))
583 def refresh(self
, widget
=None):
585 Queueing refresh in thread pool, subject to semaphores
588 _log
.info('Queueing refresh now at %s' % (time
.strftime('%H:%M:%S')))
589 self
._threads
.add_work(self
.post_get_status_list
,
590 self
.except_get_status_list
,
591 self
.get_status_list
)
594 def status_format(self
, column
, cell
, store
, position
):
596 Format how status update should look in list
599 uid
= store
.get_value(position
, Columns
.UID
)
600 name
= self
.friendsname
[uid
]
601 status
= store
.get_value(position
, Columns
.STATUS
)
602 posttime
= store
.get_value(position
, Columns
.DATETIME
)
604 #replace characters that would choke the markup
605 status
= re
.sub(r
'&', r
'&', status
)
606 status
= re
.sub(r
'<', r
'<', status
)
607 status
= re
.sub(r
'>', r
'>', status
)
608 markup
= ('<b>%s</b> %s\n(%s ago)' % \
609 (name
, status
, timesince
.timesince(posttime
)))
610 _log
.debug('Marked up text: %s' % (markup
))
611 cell
.set_property('markup', markup
)
614 def open_url(self
, source
, url
):
616 Open url as new browser tab
619 _log
.debug('Opening url: %s' % url
)
621 webbrowser
.open_new_tab(url
)
622 self
.window
.set_focus(self
.entry
)
624 def copy_status_to_clipboard(self
, source
, text
):
626 Copy current status (together with poster name but without time)
630 clipboard
= gtk
.Clipboard()
631 _log
.debug('Copying to clipboard: %s' % (text
))
632 clipboard
.set_text(text
)
634 def new_status_notify(self
):
636 Handle notification upon new status updates
639 # Announce to the rest of the program
640 self
.new_notify
= True
642 # Set system tray icon to the notification version, if
643 # user is not looking at the window at the moment.
644 if not self
.window
.get_property('is-active'):
645 self
._systray
.set_from_pixbuf(self
._app
_icon
_notify
)
647 def expose_notify(self
, widget
, event
, user
=None):
649 Called when the window is shown
652 # Remove systray notification if there's any and
653 # user checks window: expose_event AND is-active = true
654 if self
.new_notify
and self
.window
.get_property('is-active'):
655 self
.new_notify
= False
656 self
._systray
.set_from_pixbuf(self
._app
_icon
)
658 #--------------------
659 # Interface functions
660 #--------------------
661 def quit(self
, widget
):
667 def systray_click(self
, widget
, user_param
=None):
669 Callback when systray icon receives left-click
672 # Toggle visibility of main window
673 if self
.window
.get_property('visible'):
674 _log
.debug('Hiding window')
675 x
, y
= self
.window
.get_position()
676 self
._prefs
['window_pos_x'] = x
677 self
._prefs
['window_pos_y'] = y
680 x
= self
._prefs
['window_pos_x']
681 y
= self
._prefs
['window_pos_y']
682 _log
.debug('Restoring window at (%d, %d)' % (x
, y
))
683 self
.window
.move(x
, y
)
684 self
.window
.deiconify()
685 self
.window
.present()
687 def create_grid(self
):
689 Create list where each line consist of:
690 profile pic, status update, comments and likes count
693 # List for storing all relevant info
694 self
.liststore
= gtk
.ListStore(gobject
.TYPE_STRING
,
701 # Short items by time, newest first
702 self
.sorter
= gtk
.TreeModelSort(self
.liststore
)
703 self
.sorter
.set_sort_column_id(Columns
.DATETIME
, gtk
.SORT_DESCENDING
)
704 self
.treeview
= gtk
.TreeView(self
.sorter
)
707 self
.treeview
.set_property('headers-visible', False)
708 # Alternating background colours for lines
709 self
.treeview
.set_rules_hint(True)
711 # Column showing profile picture
712 profilepic_renderer
= gtk
.CellRendererPixbuf()
713 profilepic_column
= gtk
.TreeViewColumn('Profilepic', \
715 profilepic_column
.set_fixed_width(55)
716 profilepic_column
.set_sizing(gtk
.TREE_VIEW_COLUMN_FIXED
)
717 profilepic_column
.set_cell_data_func(profilepic_renderer
,
718 self
._cell
_renderer
_profilepic
)
719 self
.treeview
.append_column(profilepic_column
)
721 # Column showing status text
722 self
.status_renderer
= gtk
.CellRendererText()
723 # wrapping: pango.WRAP_WORD == 0, don't need to import pango for that
724 self
.status_renderer
.set_property('wrap-mode', 0)
725 self
.status_renderer
.set_property('wrap-width', 320)
726 self
.status_renderer
.set_property('width', 320)
727 self
.status_column
= gtk
.TreeViewColumn('Message', \
728 self
.status_renderer
, text
=1)
729 self
.status_column
.set_cell_data_func(self
.status_renderer
, \
731 self
.treeview
.append_column(self
.status_column
)
733 # Showing the number of comments
734 comments_renderer
= gtk
.CellRendererText()
735 comments_column
= gtk
.TreeViewColumn('Comments', \
736 comments_renderer
, text
=1)
737 comments_column
.set_cell_data_func(comments_renderer
, \
738 self
._cell
_renderer
_comments
)
739 self
.treeview
.append_column(comments_column
)
741 # Showing the comments icon
742 commentspic_renderer
= gtk
.CellRendererPixbuf()
743 commentspic_column
= gtk
.TreeViewColumn('CommentsPic', \
744 commentspic_renderer
)
745 commentspic_column
.set_cell_data_func(commentspic_renderer
, \
746 self
._cell
_renderer
_commentspic
)
747 commentspic_column
.set_fixed_width(28)
748 commentspic_column
.set_sizing(gtk
.TREE_VIEW_COLUMN_FIXED
)
749 self
.treeview
.append_column(commentspic_column
)
751 # Showing the number of likes
752 likes_renderer
= gtk
.CellRendererText()
753 likes_column
= gtk
.TreeViewColumn('Likes', \
754 likes_renderer
, text
=1)
755 likes_column
.set_cell_data_func(likes_renderer
, \
756 self
._cell
_renderer
_likes
)
757 self
.treeview
.append_column(likes_column
)
759 # Showing the likes icon
760 likespic_renderer
= gtk
.CellRendererPixbuf()
761 likespic_column
= gtk
.TreeViewColumn('Likespic', \
763 likespic_column
.set_cell_data_func(likespic_renderer
, \
764 self
._cell
_renderer
_likespic
)
765 likespic_column
.set_fixed_width(28)
766 likespic_column
.set_sizing(gtk
.TREE_VIEW_COLUMN_FIXED
)
767 self
.treeview
.append_column(likespic_column
)
769 self
.treeview
.set_resize_mode(gtk
.RESIZE_IMMEDIATE
)
771 self
.treeview
.connect('row-activated', self
.open_status_web
)
772 self
.treeview
.connect('button-press-event', self
.click_status
)
774 def create_menubar(self
):
776 Showing the app's (very basic) menubar
779 refresh_action
= gtk
.Action('Refresh', '_Refresh',
780 'Get new status updates', gtk
.STOCK_REFRESH
)
781 refresh_action
.connect('activate', self
.refresh
)
783 quit_action
= gtk
.Action('Quit', '_Quit',
784 'Exit %s' % (APPNAME
), gtk
.STOCK_QUIT
)
785 quit_action
.connect('activate', self
.quit
)
787 about_action
= gtk
.Action('About', '_About', 'About %s' % (APPNAME
),
789 about_action
.connect('activate', self
.show_about
)
791 self
.action_group
= gtk
.ActionGroup('MainMenu')
792 self
.action_group
.add_action_with_accel(refresh_action
, 'F5')
793 # accel = None to use the default acceletator
794 self
.action_group
.add_action_with_accel(quit_action
, None)
795 self
.action_group
.add_action(about_action
)
797 uimanager
= gtk
.UIManager()
798 uimanager
.insert_action_group(self
.action_group
, 0)
801 <menubar name="MainMenu">
802 <menuitem action="Quit" />
804 <menuitem action="Refresh" />
806 <menuitem action="About" />
810 uimanager
.add_ui_from_string(ui
)
811 self
.main_menu
= uimanager
.get_widget('/MainMenu')
814 def show_about(self
, widget
):
816 Show the about dialog
819 about_window
= gtk
.AboutDialog()
820 about_window
.set_program_name(APPNAME
)
821 about_window
.set_version(VERSION
)
822 about_window
.set_copyright('2009 Gergely Imreh <imrehg@gmail.com>')
823 about_window
.set_license(MIT
)
824 about_window
.set_website('http://imrehg.github.com/minibook/')
825 about_window
.set_website_label('%s on GitHub' % (APPNAME
))
826 about_window
.connect('close', self
.close_dialog
)
830 def close_dialog(self
, user_data
=None):
832 Hide the dialog window
837 def open_status_web(self
, treeview
, path
, view_column
, user_data
=None):
839 Callback to open status update in web browser when received left click
842 model
= treeview
.get_model()
846 iter = model
.get_iter(path
)
847 uid
= model
.get_value(iter, Columns
.UID
)
848 status_id
= model
.get_value(iter, Columns
.STATUSID
).split("_")[1]
849 status_url
= ('http://www.facebook.com/profile.php?' \
850 'id=%s&v=feed&story_fbid=%s' % (uid
, status_id
))
851 self
.open_url(path
, status_url
)
854 def click_status(self
, treeview
, event
, user_data
=None):
856 Callback when a mouse click event occurs on one of the rows
859 _log
.debug('Clicked on status list')
860 if event
.button
!= 3:
861 # Only right clicks are processed
863 _log
.debug('right-click received')
868 pth
= treeview
.get_path_at_pos(x
, y
)
872 path
, col
, cell_x
, cell_y
= pth
873 treeview
.grab_focus()
874 treeview
.set_cursor(path
, col
, 0)
876 self
.show_status_popup(treeview
, event
)
879 def show_status_popup(self
, treeview
, event
, user_data
=None):
881 Show popup menu relevant to the clicked status update
884 _log
.debug('Show popup menu')
885 cursor
= treeview
.get_cursor()
888 model
= treeview
.get_model()
893 iter = model
.get_iter(path
)
895 popup_menu
= gtk
.Menu()
896 popup_menu
.set_screen(self
.window
.get_screen())
900 # Open this status update in browser
901 uid
= model
.get_value(iter, Columns
.UID
)
902 status_id
= model
.get_value(iter, Columns
.STATUSID
).split("_")[1]
903 url
= ('http://www.facebook.com/profile.php?' \
904 'id=%s&v=feed&story_fbid=%s' % (uid
, status_id
))
905 item_name
= 'This status'
906 item
= gtk
.MenuItem(item_name
)
907 item
.connect('activate', self
.open_url
, url
)
908 open_menu_items
.append(item
)
910 # Open user's wall in browser
911 url
= ('http://www.facebook.com/profile.php?' \
913 item_name
= 'User wall'
914 item
= gtk
.MenuItem(item_name
)
915 item
.connect('activate', self
.open_url
, url
)
916 open_menu_items
.append(item
)
918 # Open user's info in browser
919 url
= ('http://www.facebook.com/profile.php?' \
920 'id=%s&v=info' % (uid
))
921 item_name
= 'User info'
922 item
= gtk
.MenuItem(item_name
)
923 item
.connect('activate', self
.open_url
, url
)
924 open_menu_items
.append(item
)
926 # Open user's photos in browser
927 url
= ('http://www.facebook.com/profile.php?' \
928 'id=%s&v=photos' % (uid
))
929 item_name
= 'User photos'
930 item
= gtk
.MenuItem(item_name
)
931 item
.connect('activate', self
.open_url
, url
)
932 open_menu_items
.append(item
)
934 open_menu
= gtk
.Menu()
935 for item
in open_menu_items
:
936 open_menu
.append(item
)
938 # Menu item for "open in browser" menu
939 open_item
= gtk
.ImageMenuItem('Open in browser')
940 open_item
.get_image().set_from_stock(gtk
.STOCK_GO_FORWARD
, \
942 open_item
.set_submenu(open_menu
)
943 popup_menu
.append(open_item
)
945 # Menu item to copy status message to clipboard
946 message
= model
.get_value(iter, Columns
.STATUS
)
947 name
= self
.friendsname
[str(uid
)]
948 text
= ("%s %s" % (name
, message
))
949 copy_item
= gtk
.ImageMenuItem('Copy status')
950 copy_item
.get_image().set_from_stock(gtk
.STOCK_COPY
, \
952 copy_item
.connect('activate', self
.copy_status_to_clipboard
, text
)
953 popup_menu
.append(copy_item
)
955 popup_menu
.show_all()
964 popup_menu
.popup(None, None, None, b
, t
)
968 def _cell_renderer_profilepic(self
, column
, cell
, store
, position
):
970 Showing profile picture in status update list
971 Use default picture if we don't (can't) have the user's
972 If first time trying to display it try to download and display default
975 uid
= str(store
.get_value(position
, Columns
.UID
))
976 if not uid
in self
._profilepics
:
977 profilepicurl
= self
.friendsprofilepic
[uid
]
979 _log
.debug('%s does not have profile picture stored, ' \
980 'queuing fetch from %s' % (uid
, profilepicurl
))
981 self
._threads
.add_work(self
._post
_dl
_profile
_pic
,
982 self
._exception
_dl
_profile
_pic
,
983 self
._dl
_profile
_pic
,
987 _log
.debug('%s does not have profile picture set, ' % (uid
))
989 self
._profilepics
[uid
] = self
._default
_profilepic
991 cell
.set_property('pixbuf', self
._profilepics
[uid
])
995 def _cell_renderer_comments(self
, column
, cell
, store
, position
):
997 Cell renderer for the number of comments
1000 comments
= int(store
.get_value(position
, Columns
.COMMENTS
))
1002 cell
.set_property('text', str(comments
))
1004 cell
.set_property('text', '')
1006 def _cell_renderer_commentspic(self
, column
, cell
, store
, position
):
1008 Cell renderer for comments picture if there are any comments
1011 comments
= int(store
.get_value(position
, Columns
.COMMENTS
))
1013 cell
.set_property('pixbuf', self
.commentspic
)
1015 cell
.set_property('pixbuf', None)
1017 def _cell_renderer_likes(self
, column
, cell
, store
, position
):
1019 Cell renderer for number of likes
1022 likes
= int(store
.get_value(position
, Columns
.LIKES
))
1024 cell
.set_property('text', str(likes
))
1026 cell
.set_property('text', '')
1028 def _cell_renderer_likespic(self
, column
, cell
, store
, position
):
1030 Cell renderer for likess picture if there are any likes
1033 likes
= int(store
.get_value(position
, Columns
.LIKES
))
1035 cell
.set_property('pixbuf', self
.likespic
)
1037 cell
.set_property('pixbuf', None)
1042 def __init__(self
, facebook
):
1044 Creating main window and setting up relevant variables
1047 global spelling_support
1049 # Connect to facebook object
1050 self
._facebook
= facebook
1052 # Picture shown if cannot get a user's own profile picture
1053 unknown_user
= 'pixmaps/unknown_user.png'
1055 self
._default
_profilepic
= gtk
.gdk
.pixbuf_new_from_file(
1058 self
._default
_profilepic
= gtk
.gdk
.Pixbuf(gtk
.gdk
.COLORSPACE_RGB
,
1059 has_alpha
=False, bits_per_sample
=8, width
=50, height
=50)
1061 # Icons for "comments" and "likes"
1062 self
.commentspic
= gtk
.gdk
.pixbuf_new_from_file('pixmaps/comments.png')
1063 self
.likespic
= gtk
.gdk
.pixbuf_new_from_file('pixmaps/likes.png')
1065 self
.friendsname
= {}
1066 self
.friendsprofilepic
= {}
1067 self
._profilepics
= {}
1068 # Semaphore to let only one status update proceed at a time
1069 self
.update_sema
= threading
.BoundedSemaphore(value
=1)
1071 # create a new window
1072 self
.window
= gtk
.Window(gtk
.WINDOW_TOPLEVEL
)
1073 self
.window
.set_size_request(480, 250)
1074 self
.window
.set_title("Minibook")
1075 self
.window
.connect("delete_event", lambda w
, e
: gtk
.main_quit())
1077 vbox
= gtk
.VBox(False, 0)
1078 self
.window
.add(vbox
)
1081 self
.create_menubar()
1082 vbox
.pack_start(self
.main_menu
, False, True, 0)
1084 # Status update display window
1086 self
.statuslist_window
= gtk
.ScrolledWindow()
1087 self
.statuslist_window
.set_policy(gtk
.POLICY_NEVER
, gtk
.POLICY_ALWAYS
)
1088 self
.statuslist_window
.add(self
.treeview
)
1089 vbox
.pack_start(self
.statuslist_window
, True, True, 0)
1091 # Area around the status update entry box with labels and button
1092 label_box
= gtk
.HBox(False, 0)
1093 label
= gtk
.Label("What's on your mind?")
1094 self
.count_label
= gtk
.Label("(%d)" % (MAX_MESSAGE_LENGTH
))
1095 label_box
.pack_start(label
)
1096 label_box
.pack_start(self
.count_label
)
1098 self
.entry
= gtk
.TextView()
1099 text
= self
.entry
.get_buffer()
1100 text
.connect('changed', self
.count
)
1101 text_box
= gtk
.VBox(True, 0)
1102 text_box
.pack_start(label_box
)
1103 text_box
.pack_start(self
.entry
, True, True, 4)
1105 update_button
= gtk
.Button(stock
=gtk
.STOCK_ADD
)
1106 update_button
.connect("clicked", lambda w
: self
.sendupdate())
1108 update_box
= gtk
.HBox(False, 0)
1109 update_box
.pack_start(text_box
, expand
=True, fill
=True,
1111 update_box
.pack_start(update_button
, expand
=False, fill
=False,
1114 vbox
.pack_start(update_box
, False, True, 0)
1117 self
.statusbar
= gtk
.Statusbar()
1118 vbox
.pack_start(self
.statusbar
, False, False, 0)
1119 self
.statusbar_context
= self
.statusbar
.get_context_id(
1120 '%s is here.' % (APPNAME
))
1122 # Set up spell checking if it is available
1123 if spelling_support
:
1125 spelling
= gtkspell
.Spell(self
.entry
, 'en')
1127 spelling_support
= False
1130 self
.window
.show_all()
1132 # Set up systray icon
1133 _app_icon_file
= 'pixmaps/minibook.png'
1134 _app_icon_notify_file
= 'pixmaps/minibook_notify.png'
1135 self
._app
_icon
= gtk
.gdk
.pixbuf_new_from_file(_app_icon_file
)
1136 self
._app
_icon
_notify
= \
1137 gtk
.gdk
.pixbuf_new_from_file(_app_icon_notify_file
)
1138 self
._systray
= gtk
.StatusIcon()
1139 self
._systray
.set_from_pixbuf(self
._app
_icon
)
1140 self
._systray
.set_tooltip('%s\n' \
1141 'Left-click: toggle window hiding' % (APPNAME
))
1142 self
._systray
.connect('activate', self
.systray_click
)
1143 self
._systray
.set_visible(True)
1145 self
.window
.set_icon(self
._app
_icon
)
1147 # Enable thread manager
1148 self
._threads
= _ThreadManager()
1150 # Start to set up preferences
1152 x
, y
= self
.window
.get_position()
1153 self
._prefs
['window_pos_x'] = x
1154 self
._prefs
['window_pos_y'] = y
1155 self
._prefs
['auto_refresh_interval'] = 5
1157 # Last update: never, start first one
1158 self
._last
_update
= 0
1159 self
._threads
.add_work(self
.post_get_friends_list
,
1160 self
.except_get_friends_list
,
1161 self
.get_friends_list
)
1163 # Enable auto-refresh
1164 self
._refresh
_id
= None
1165 self
.set_auto_refresh()
1167 # Storing notification state
1168 self
.new_notify
= False
1169 # Used to remove systray notification on window show
1170 self
.window
.connect("expose-event", self
.expose_notify
)
1178 gtk
.gdk
.threads_leave()
1179 _log
.debug('Exiting')
1182 if __name__
== "__main__":
1184 Set up facebook object, login and start main window
1187 # Currently cannot include the registered app's
1188 # api_key and secret_key, thus have to save them separately
1189 # Here those keys are loaded
1191 config_file
= open("config", "r")
1192 api_key
= config_file
.readline()[:-1]
1193 secret_key
= config_file
.readline()[:-1]
1194 _log
.debug('Config file loaded successfully')
1195 except Exception, e
:
1196 _log
.critical('Error while loading config file: %s' % (str(e
)))
1199 facebook
= Facebook(api_key
, secret_key
)
1201 facebook
.auth
.createToken()
1203 # Like catch errors like
1204 # http://bugs.developers.facebook.com/show_bug.cgi?id=5474
1205 # and http://bugs.developers.facebook.com/show_bug.cgi?id=5472
1206 _log
.critical("Error on Facebook's side, " \
1207 "try starting application later")
1210 _log
.debug('Showing Facebook login page in default browser.')
1213 # Delay dialog to allow for login in browser
1215 while not got_session
:
1216 dia
= gtk
.Dialog('minibook: login',
1218 gtk
.DIALOG_MODAL | \
1219 gtk
.DIALOG_DESTROY_WITH_PARENT | \
1220 gtk
.DIALOG_NO_SEPARATOR
,
1221 ("Logged in", gtk
.RESPONSE_OK
, \
1222 gtk
.STOCK_CANCEL
, gtk
.RESPONSE_CANCEL
))
1223 label
= gtk
.Label("%s is opening your web browser to " \
1224 "log in Facebook.\nWhen finished, click 'Logged in', " \
1225 "or you can cancel now." % (APPNAME
))
1226 dia
.vbox
.pack_start(label
, True, True, 10)
1230 # Cancel login and close app
1231 if result
== gtk
.RESPONSE_CANCEL
:
1232 _log
.debug('Exiting before Facebook login.')
1236 facebook
.auth
.getSession()
1239 # Likely clicked "logged in" but not logged in yet, start over
1242 _log
.info('Session Key: %s' % (facebook
.session_key
))
1243 _log
.info('User\'s UID: %d' % (facebook
.uid
))
1245 # Start main window and app
1246 MainWindow(facebook
)