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