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."
46 gobject
.threads_init()
47 gtk
.gdk
.threads_init()
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 self
.count_label
.set_text('(%d)' \
563 % (MAX_MESSAGE_LENGTH
- text
.get_char_count()))
566 def insert_cb(self
, textbuffer
, iter, text
, length
, *args
):
568 Callback when text is typed/inserted into status update box
571 # Without this garbage comes in with text
573 # Stop default emission
574 textbuffer
.emit_stop_by_name("insert_text")
575 # For some reason cannot pass on iter properly, send pos instead
576 pos
= iter.get_offset()
577 self
.insert(textbuffer
, text
, pos
)
579 def insert(self
, textbuffer
, text
, pos
):
581 Text replacement function upon type into status update box
582 On receiving an "enter" initiate status update send
585 # If "Enter" pressed, submit status
592 text
= string
.replace(text
, "\t", "")
593 # convert multiple lines into a single line (e.g. when text pasted)
594 text
= string
.replace(text
, "\n", " ")
597 start
= textbuffer
.get_start_iter()
598 end
= textbuffer
.get_end_iter()
599 orig_text
= textbuffer
.get_text(start
, end
)
600 new_text
= orig_text
[:pos
] + text
+ orig_text
[pos
:]
602 # Avoid recursive calls triggered by set_text
603 textbuffer
.handler_block(self
.insert_sig
)
604 # Insert new text and move cursor to end if insert
605 textbuffer
.set_text(new_text
)
606 iter = textbuffer
.get_iter_at_offset(pos
+len(text
))
607 textbuffer
.place_cursor(iter)
609 textbuffer
.handler_unblock(self
.insert_sig
)
612 def set_auto_refresh(self
):
614 Enable auto refresh statuses in pre-defined intervals
618 gobject
.source_remove(self
._refresh
_id
)
620 self
._refresh
_id
= gobject
.timeout_add(
621 self
._prefs
['auto_refresh_interval']*60*1000,
623 _log
.info("Auto-refresh enabled: %d minutes" \
624 % (self
._prefs
['auto_refresh_interval']))
626 def refresh(self
, widget
=None):
628 Queueing refresh in thread pool, subject to semaphores
631 _log
.info('Queueing refresh now at %s' % (time
.strftime('%H:%M:%S')))
632 self
._threads
.add_work(self
.post_get_status_list
,
633 self
.except_get_status_list
,
634 self
.get_status_list
)
637 def status_format(self
, column
, cell
, store
, position
):
639 Format how status update should look in list
642 uid
= store
.get_value(position
, Columns
.UID
)
643 name
= self
.friendsname
[uid
]
644 status
= store
.get_value(position
, Columns
.STATUS
)
645 posttime
= store
.get_value(position
, Columns
.DATETIME
)
647 #replace characters that would choke the markup
648 status
= re
.sub(r
'&', r
'&', status
)
649 status
= re
.sub(r
'<', r
'<', status
)
650 status
= re
.sub(r
'>', r
'>', status
)
651 markup
= ('<b>%s</b> %s\n(%s ago)' % \
652 (name
, status
, timesince
.timesince(posttime
)))
653 _log
.debug('Marked up text: %s' % (markup
))
654 cell
.set_property('markup', markup
)
657 def open_url(self
, source
, url
):
659 Open url as new browser tab
662 _log
.debug('Opening url: %s' % url
)
664 webbrowser
.open_new_tab(url
)
665 self
.window
.set_focus(self
.entry
)
667 def copy_status_to_clipboard(self
, source
, text
):
669 Copy current status (together with poster name but without time)
673 clipboard
= gtk
.Clipboard()
674 _log
.debug('Copying to clipboard: %s' % (text
))
675 clipboard
.set_text(text
)
677 def new_status_notify(self
):
679 Handle notification upon new status updates
682 # Announce to the rest of the program
683 self
.new_notify
= True
685 # Set system tray icon to the notification version, if
686 # user is not looking at the window at the moment.
687 if not self
.window
.get_property('is-active'):
688 self
._systray
.set_from_pixbuf(self
._app
_icon
_notify
)
690 def remove_notify(self
, widget
, event
, user
=None):
692 Called when the window is shown to remove systray notification
695 # Remove systray notification if there's any and
696 # user checks window: expose_event AND is-active = true
697 if self
.new_notify
and self
.window
.is_active():
698 self
.new_notify
= False
699 self
._systray
.set_from_pixbuf(self
._app
_icon
)
701 #--------------------
702 # Interface functions
703 #--------------------
704 def quit(self
, widget
):
710 def systray_click(self
, widget
, user_param
=None):
712 Callback when systray icon receives left-click
715 # Toggle visibility of main window
716 if self
.window
.get_property('visible'):
717 _log
.debug('Hiding window')
718 x
, y
= self
.window
.get_position()
719 self
._prefs
['window_pos_x'] = x
720 self
._prefs
['window_pos_y'] = y
723 x
= self
._prefs
['window_pos_x']
724 y
= self
._prefs
['window_pos_y']
725 _log
.debug('Restoring window at (%d, %d)' % (x
, y
))
726 self
.window
.move(x
, y
)
727 self
.window
.deiconify()
728 self
.window
.present()
730 def create_grid(self
):
732 Create list where each line consist of:
733 profile pic, status update, comments and likes count
736 # List for storing all relevant info
737 self
.liststore
= gtk
.ListStore(gobject
.TYPE_STRING
,
744 # Short items by time, newest first
745 self
.sorter
= gtk
.TreeModelSort(self
.liststore
)
746 self
.sorter
.set_sort_column_id(Columns
.DATETIME
, gtk
.SORT_DESCENDING
)
747 self
.treeview
= gtk
.TreeView(self
.sorter
)
750 self
.treeview
.set_property('headers-visible', False)
751 # Alternating background colours for lines
752 self
.treeview
.set_rules_hint(True)
754 # Column showing profile picture
755 profilepic_renderer
= gtk
.CellRendererPixbuf()
756 profilepic_column
= gtk
.TreeViewColumn('Profilepic', \
758 profilepic_column
.set_fixed_width(55)
759 profilepic_column
.set_sizing(gtk
.TREE_VIEW_COLUMN_FIXED
)
760 profilepic_column
.set_cell_data_func(profilepic_renderer
,
761 self
._cell
_renderer
_profilepic
)
762 self
.treeview
.append_column(profilepic_column
)
764 # Column showing status text
765 self
.status_renderer
= gtk
.CellRendererText()
766 # wrapping: pango.WRAP_WORD == 0, don't need to import pango for that
767 self
.status_renderer
.set_property('wrap-mode', 0)
768 self
.status_renderer
.set_property('wrap-width', 320)
769 self
.status_renderer
.set_property('width', 320)
770 self
.status_column
= gtk
.TreeViewColumn('Message', \
771 self
.status_renderer
, text
=1)
772 self
.status_column
.set_cell_data_func(self
.status_renderer
, \
774 self
.treeview
.append_column(self
.status_column
)
776 # Showing the number of comments
777 comments_renderer
= gtk
.CellRendererText()
778 comments_column
= gtk
.TreeViewColumn('Comments', \
779 comments_renderer
, text
=1)
780 comments_column
.set_cell_data_func(comments_renderer
, \
781 self
._cell
_renderer
_comments
)
782 self
.treeview
.append_column(comments_column
)
784 # Showing the comments icon
785 commentspic_renderer
= gtk
.CellRendererPixbuf()
786 commentspic_column
= gtk
.TreeViewColumn('CommentsPic', \
787 commentspic_renderer
)
788 commentspic_column
.set_cell_data_func(commentspic_renderer
, \
789 self
._cell
_renderer
_commentspic
)
790 commentspic_column
.set_fixed_width(28)
791 commentspic_column
.set_sizing(gtk
.TREE_VIEW_COLUMN_FIXED
)
792 self
.treeview
.append_column(commentspic_column
)
794 # Showing the number of likes
795 likes_renderer
= gtk
.CellRendererText()
796 likes_column
= gtk
.TreeViewColumn('Likes', \
797 likes_renderer
, text
=1)
798 likes_column
.set_cell_data_func(likes_renderer
, \
799 self
._cell
_renderer
_likes
)
800 self
.treeview
.append_column(likes_column
)
802 # Showing the likes icon
803 likespic_renderer
= gtk
.CellRendererPixbuf()
804 likespic_column
= gtk
.TreeViewColumn('Likespic', \
806 likespic_column
.set_cell_data_func(likespic_renderer
, \
807 self
._cell
_renderer
_likespic
)
808 likespic_column
.set_fixed_width(28)
809 likespic_column
.set_sizing(gtk
.TREE_VIEW_COLUMN_FIXED
)
810 self
.treeview
.append_column(likespic_column
)
812 self
.treeview
.set_resize_mode(gtk
.RESIZE_IMMEDIATE
)
814 self
.treeview
.connect('row-activated', self
.open_status_web
)
815 self
.treeview
.connect('button-press-event', self
.click_status
)
817 def create_menubar(self
):
819 Showing the app's (very basic) menubar
822 refresh_action
= gtk
.Action('Refresh', '_Refresh',
823 'Get new status updates', gtk
.STOCK_REFRESH
)
824 refresh_action
.connect('activate', self
.refresh
)
826 quit_action
= gtk
.Action('Quit', '_Quit',
827 'Exit %s' % (APPNAME
), gtk
.STOCK_QUIT
)
828 quit_action
.connect('activate', self
.quit
)
830 about_action
= gtk
.Action('About', '_About', 'About %s' % (APPNAME
),
832 about_action
.connect('activate', self
.show_about
)
834 self
.action_group
= gtk
.ActionGroup('MainMenu')
835 self
.action_group
.add_action_with_accel(refresh_action
, 'F5')
836 # accel = None to use the default acceletator
837 self
.action_group
.add_action_with_accel(quit_action
, None)
838 self
.action_group
.add_action(about_action
)
840 uimanager
= gtk
.UIManager()
841 uimanager
.insert_action_group(self
.action_group
, 0)
844 <menubar name="MainMenu">
845 <menuitem action="Quit" />
847 <menuitem action="Refresh" />
849 <menuitem action="About" />
853 uimanager
.add_ui_from_string(ui
)
854 self
.main_menu
= uimanager
.get_widget('/MainMenu')
857 def show_about(self
, widget
):
859 Show the about dialog
862 about_window
= gtk
.AboutDialog()
863 about_window
.set_program_name(APPNAME
)
864 about_window
.set_version(VERSION
)
865 about_window
.set_copyright('2009 Gergely Imreh <imrehg@gmail.com>')
866 about_window
.set_license(MIT
)
867 about_window
.set_website('http://imrehg.github.com/minibook/')
868 about_window
.set_website_label('%s on GitHub' % (APPNAME
))
869 about_window
.connect('close', self
.close_dialog
)
873 def close_dialog(self
, user_data
=None):
875 Hide the dialog window
880 def open_status_web(self
, treeview
, path
, view_column
, user_data
=None):
882 Callback to open status update in web browser when received left click
885 model
= treeview
.get_model()
889 iter = model
.get_iter(path
)
890 uid
= model
.get_value(iter, Columns
.UID
)
891 status_id
= model
.get_value(iter, Columns
.STATUSID
).split("_")[1]
892 status_url
= ('http://www.facebook.com/profile.php?' \
893 'id=%s&v=feed&story_fbid=%s' % (uid
, status_id
))
894 self
.open_url(path
, status_url
)
897 def click_status(self
, treeview
, event
, user_data
=None):
899 Callback when a mouse click event occurs on one of the rows
902 _log
.debug('Clicked on status list')
903 if event
.button
!= 3:
904 # Only right clicks are processed
906 _log
.debug('right-click received')
911 pth
= treeview
.get_path_at_pos(x
, y
)
915 path
, col
, cell_x
, cell_y
= pth
916 treeview
.grab_focus()
917 treeview
.set_cursor(path
, col
, 0)
919 self
.show_status_popup(treeview
, event
)
922 def show_status_popup(self
, treeview
, event
, user_data
=None):
924 Show popup menu relevant to the clicked status update
927 _log
.debug('Show popup menu')
928 cursor
= treeview
.get_cursor()
931 model
= treeview
.get_model()
936 iter = model
.get_iter(path
)
938 popup_menu
= gtk
.Menu()
939 popup_menu
.set_screen(self
.window
.get_screen())
943 # Open this status update in browser
944 uid
= model
.get_value(iter, Columns
.UID
)
945 status_id
= model
.get_value(iter, Columns
.STATUSID
).split("_")[1]
946 url
= ('http://www.facebook.com/profile.php?' \
947 'id=%s&v=feed&story_fbid=%s' % (uid
, status_id
))
948 item_name
= 'This status'
949 item
= gtk
.MenuItem(item_name
)
950 item
.connect('activate', self
.open_url
, url
)
951 open_menu_items
.append(item
)
953 # Open user's wall in browser
954 url
= ('http://www.facebook.com/profile.php?' \
956 item_name
= 'User wall'
957 item
= gtk
.MenuItem(item_name
)
958 item
.connect('activate', self
.open_url
, url
)
959 open_menu_items
.append(item
)
961 # Open user's info in browser
962 url
= ('http://www.facebook.com/profile.php?' \
963 'id=%s&v=info' % (uid
))
964 item_name
= 'User info'
965 item
= gtk
.MenuItem(item_name
)
966 item
.connect('activate', self
.open_url
, url
)
967 open_menu_items
.append(item
)
969 # Open user's photos in browser
970 url
= ('http://www.facebook.com/profile.php?' \
971 'id=%s&v=photos' % (uid
))
972 item_name
= 'User photos'
973 item
= gtk
.MenuItem(item_name
)
974 item
.connect('activate', self
.open_url
, url
)
975 open_menu_items
.append(item
)
977 open_menu
= gtk
.Menu()
978 for item
in open_menu_items
:
979 open_menu
.append(item
)
981 # Menu item for "open in browser" menu
982 open_item
= gtk
.ImageMenuItem('Open in browser')
983 open_item
.get_image().set_from_stock(gtk
.STOCK_GO_FORWARD
, \
985 open_item
.set_submenu(open_menu
)
986 popup_menu
.append(open_item
)
988 # Menu item to copy status message to clipboard
989 message
= model
.get_value(iter, Columns
.STATUS
)
990 name
= self
.friendsname
[str(uid
)]
991 text
= ("%s %s" % (name
, message
))
992 copy_item
= gtk
.ImageMenuItem('Copy status')
993 copy_item
.get_image().set_from_stock(gtk
.STOCK_COPY
, \
995 copy_item
.connect('activate', self
.copy_status_to_clipboard
, text
)
996 popup_menu
.append(copy_item
)
998 popup_menu
.show_all()
1007 popup_menu
.popup(None, None, None, b
, t
)
1011 def _cell_renderer_profilepic(self
, column
, cell
, store
, position
):
1013 Showing profile picture in status update list
1014 Use default picture if we don't (can't) have the user's
1015 If first time trying to display it try to download and display default
1018 uid
= str(store
.get_value(position
, Columns
.UID
))
1019 if not uid
in self
._profilepics
:
1020 profilepicurl
= self
.friendsprofilepic
[uid
]
1022 _log
.debug('%s does not have profile picture stored, ' \
1023 'queuing fetch from %s' % (uid
, profilepicurl
))
1024 self
._threads
.add_work(self
._post
_dl
_profile
_pic
,
1025 self
._exception
_dl
_profile
_pic
,
1026 self
._dl
_profile
_pic
,
1030 _log
.debug('%s does not have profile picture set, ' % (uid
))
1032 self
._profilepics
[uid
] = self
._default
_profilepic
1034 cell
.set_property('pixbuf', self
._profilepics
[uid
])
1038 def _cell_renderer_comments(self
, column
, cell
, store
, position
):
1040 Cell renderer for the number of comments
1043 comments
= int(store
.get_value(position
, Columns
.COMMENTS
))
1045 cell
.set_property('text', str(comments
))
1047 cell
.set_property('text', '')
1049 def _cell_renderer_commentspic(self
, column
, cell
, store
, position
):
1051 Cell renderer for comments picture if there are any comments
1054 comments
= int(store
.get_value(position
, Columns
.COMMENTS
))
1056 cell
.set_property('pixbuf', self
.commentspic
)
1058 cell
.set_property('pixbuf', None)
1060 def _cell_renderer_likes(self
, column
, cell
, store
, position
):
1062 Cell renderer for number of likes
1065 likes
= int(store
.get_value(position
, Columns
.LIKES
))
1067 cell
.set_property('text', str(likes
))
1069 cell
.set_property('text', '')
1071 def _cell_renderer_likespic(self
, column
, cell
, store
, position
):
1073 Cell renderer for likess picture if there are any likes
1076 likes
= int(store
.get_value(position
, Columns
.LIKES
))
1078 cell
.set_property('pixbuf', self
.likespic
)
1080 cell
.set_property('pixbuf', None)
1085 def __init__(self
, facebook
):
1087 Creating main window and setting up relevant variables
1090 global spelling_support
1092 # Connect to facebook object
1093 self
._facebook
= facebook
1095 # Picture shown if cannot get a user's own profile picture
1096 unknown_user
= 'pixmaps/unknown_user.png'
1098 self
._default
_profilepic
= gtk
.gdk
.pixbuf_new_from_file(
1101 self
._default
_profilepic
= gtk
.gdk
.Pixbuf(gtk
.gdk
.COLORSPACE_RGB
,
1102 has_alpha
=False, bits_per_sample
=8, width
=50, height
=50)
1104 # Icons for "comments" and "likes"
1105 self
.commentspic
= gtk
.gdk
.pixbuf_new_from_file('pixmaps/comments.png')
1106 self
.likespic
= gtk
.gdk
.pixbuf_new_from_file('pixmaps/likes.png')
1108 self
.friendsname
= {}
1109 self
.friendsprofilepic
= {}
1110 self
._profilepics
= {}
1111 # Semaphore to let only one status update proceed at a time
1112 self
.update_sema
= threading
.BoundedSemaphore(value
=1)
1114 # create a new window
1115 self
.window
= gtk
.Window(gtk
.WINDOW_TOPLEVEL
)
1116 self
.window
.set_size_request(480, 250)
1117 self
.window
.set_title("Minibook")
1118 self
.window
.connect("delete_event", lambda w
, e
: gtk
.main_quit())
1120 vbox
= gtk
.VBox(False, 0)
1121 self
.window
.add(vbox
)
1124 self
.create_menubar()
1125 vbox
.pack_start(self
.main_menu
, False, True, 0)
1127 # Status update display window
1129 self
.statuslist_window
= gtk
.ScrolledWindow()
1130 self
.statuslist_window
.set_policy(gtk
.POLICY_NEVER
, gtk
.POLICY_ALWAYS
)
1131 self
.statuslist_window
.add(self
.treeview
)
1132 vbox
.pack_start(self
.statuslist_window
, True, True, 0)
1134 # Area around the status update entry box with labels and button
1135 label_box
= gtk
.HBox(False, 0)
1136 label
= gtk
.Label("What's on your mind?")
1137 self
.count_label
= gtk
.Label("(%d)" % (MAX_MESSAGE_LENGTH
))
1138 label_box
.pack_start(label
)
1139 label_box
.pack_start(self
.count_label
)
1141 self
.entry
= gtk
.TextView()
1142 text
= self
.entry
.get_buffer()
1143 text
.connect('changed', self
.count
)
1144 self
.insert_sig
= text
.connect("insert_text", self
.insert_cb
)
1145 text_box
= gtk
.VBox(True, 0)
1146 text_box
.pack_start(label_box
)
1147 text_box
.pack_start(self
.entry
, True, True, 4)
1149 update_button
= gtk
.Button(stock
=gtk
.STOCK_ADD
)
1150 update_button
.connect("clicked", lambda w
: self
.sendupdate())
1152 update_box
= gtk
.HBox(False, 0)
1153 update_box
.pack_start(text_box
, expand
=True, fill
=True,
1155 update_box
.pack_start(update_button
, expand
=False, fill
=False,
1158 vbox
.pack_start(update_box
, False, True, 0)
1161 self
.statusbar
= gtk
.Statusbar()
1162 vbox
.pack_start(self
.statusbar
, False, False, 0)
1163 self
.statusbar_context
= self
.statusbar
.get_context_id(
1164 '%s is here.' % (APPNAME
))
1166 # Set up spell checking if it is available
1167 if spelling_support
:
1169 spelling
= gtkspell
.Spell(self
.entry
, 'en')
1171 spelling_support
= False
1174 self
.window
.show_all()
1176 # Set up systray icon
1177 _app_icon_file
= 'pixmaps/minibook.png'
1178 _app_icon_notify_file
= 'pixmaps/minibook_notify.png'
1179 self
._app
_icon
= gtk
.gdk
.pixbuf_new_from_file(_app_icon_file
)
1180 self
._app
_icon
_notify
= \
1181 gtk
.gdk
.pixbuf_new_from_file(_app_icon_notify_file
)
1182 self
._systray
= gtk
.StatusIcon()
1183 self
._systray
.set_from_pixbuf(self
._app
_icon
)
1184 self
._systray
.set_tooltip('%s\n' \
1185 'Left-click: toggle window hiding' % (APPNAME
))
1186 self
._systray
.connect('activate', self
.systray_click
)
1187 self
._systray
.set_visible(True)
1189 self
.window
.set_icon(self
._app
_icon
)
1191 # Enable thread manager
1192 self
._threads
= _ThreadManager()
1194 # Start to set up preferences
1196 x
, y
= self
.window
.get_position()
1197 self
._prefs
['window_pos_x'] = x
1198 self
._prefs
['window_pos_y'] = y
1199 self
._prefs
['auto_refresh_interval'] = 5
1201 # Last update: never, start first one
1202 self
._last
_update
= 0
1203 self
._threads
.add_work(self
.post_get_friends_list
,
1204 self
.except_get_friends_list
,
1205 self
.get_friends_list
)
1207 # Enable auto-refresh
1208 self
._refresh
_id
= None
1209 self
.set_auto_refresh()
1211 # Storing notification state
1212 self
.new_notify
= False
1213 # Used to remove systray notification on window show
1214 self
.window
.connect("event", self
.remove_notify
)
1222 gtk
.gdk
.threads_enter()
1224 gtk
.gdk
.threads_leave()
1225 _log
.debug('Exiting')
1228 if __name__
== "__main__":
1230 Set up facebook object, login and start main window
1233 # Currently cannot include the registered app's
1234 # api_key and secret_key, thus have to save them separately
1235 # Here those keys are loaded
1237 config_file
= open("config", "r")
1238 api_key
= config_file
.readline()[:-1]
1239 secret_key
= config_file
.readline()[:-1]
1240 _log
.debug('Config file loaded successfully')
1241 except Exception, e
:
1242 _log
.critical('Error while loading config file: %s' % (str(e
)))
1245 facebook
= Facebook(api_key
, secret_key
)
1247 facebook
.auth
.createToken()
1249 # Like catch errors like
1250 # http://bugs.developers.facebook.com/show_bug.cgi?id=5474
1251 # and http://bugs.developers.facebook.com/show_bug.cgi?id=5472
1252 _log
.critical("Error on Facebook's side, " \
1253 "try starting application later")
1256 _log
.debug('Showing Facebook login page in default browser.')
1259 # Delay dialog to allow for login in browser
1261 while not got_session
:
1262 dia
= gtk
.Dialog('minibook: login',
1264 gtk
.DIALOG_MODAL | \
1265 gtk
.DIALOG_DESTROY_WITH_PARENT | \
1266 gtk
.DIALOG_NO_SEPARATOR
,
1267 ("Logged in", gtk
.RESPONSE_OK
, \
1268 gtk
.STOCK_CANCEL
, gtk
.RESPONSE_CANCEL
))
1269 label
= gtk
.Label("%s is opening your web browser to " \
1270 "log in Facebook.\nWhen finished, click 'Logged in', " \
1271 "or you can cancel now." % (APPNAME
))
1272 dia
.vbox
.pack_start(label
, True, True, 10)
1275 gtk
.gdk
.threads_enter()
1277 gtk
.gdk
.threads_leave()
1278 # Cancel login and close app
1279 if result
== gtk
.RESPONSE_CANCEL
:
1280 _log
.debug('Exiting before Facebook login.')
1284 facebook
.auth
.getSession()
1287 # Likely clicked "logged in" but not logged in yet, start over
1290 _log
.info('Session Key: %s' % (facebook
.session_key
))
1291 _log
.info('User\'s UID: %d' % (facebook
.uid
))
1293 # Start main window and app
1294 MainWindow(facebook
)