├── .gitignore ├── LICENSE ├── README ├── README.markdown ├── StackTracker.py ├── TODO ├── img ├── default.png ├── metastackoverflow_logo.png ├── serverfault_logo.png ├── st_logo.png ├── stackapps_logo.png ├── stackoverflow_logo.png └── superuser_logo.png ├── setup-mac.py ├── setup.py ├── st.icns └── st.ico /.gitignore: -------------------------------------------------------------------------------- 1 | *.sw* 2 | *.pyc 3 | tracking.json 4 | settings.json 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | StackTracker, a desktop notifier for StackOverflow.com 2 | Copyright (C) 2010, Matt Swanson 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | 17 | -------------------------------------------------------------------------------- /README: -------------------------------------------------------------------------------- 1 | #StackTracker 2 | A desktop notifier for StackOverflow.com built with PyQt4 3 | 4 | The application will display a task tray notification when someone has posted an answer or a comment to a question you are tracking on StackOverflow.com. Clicking the notification will open the question in your browser. 5 | 6 | ##Windows Installer 7 | http://github.com/downloads/swanson/stacktracker/StackTracker.zip 8 | 9 | ##Project Details 10 | License: GPL 11 | Contact: mdswanso@purdue.edu 12 | -------------------------------------------------------------------------------- /README.markdown: -------------------------------------------------------------------------------- 1 | ![alt text][2] 2 | 3 | ##Screenshot / Code Snippet 4 | Current Version: Beta build v1.0 5 | 6 | ![alt text][1] 7 | ![alt text][3] 8 | 9 | ##About 10 | 11 | StackTracker, a cross-platform desktop notifier for the StackExchange API built with PyQt4 12 | 13 | The application displays a task tray notification when someone has posted an answer or a comment to a question you 14 | are tracking on any of the StackExchange sites. Clicking the notification will open the corresponding question in your browser. 15 | 16 | ###License 17 | 18 | GPL - Full LICENSE file available in the repo (below) 19 | 20 | ###Download 21 | 22 | Linux build: [Download Linux ZIP][4] (Requires Python 2.6 and PyQt4 to be installed) 23 | 24 | Run `>> python StackTracker.py` from the StackTracker folder 25 | 26 | ---------- 27 | 28 | Windows build: [Download Windows ZIP][5] (May need Microsoft VC++ DLL installed) 29 | 30 | Launch the `StackTracker.exe`. 31 | 32 | ---------- 33 | 34 | Mac OS X build: [Download Mac OS X tarball][6] (Requires Growl to be installed) 35 | 36 | Launch `StackTracker.app`. Only tested in Leopard/Snow Leopard on Intel-based Macs. 37 | 38 | ##Contact 39 | 40 | Matt Swanson, mdswanso@purdue.edu 41 | 42 | ##Code 43 | 44 | Tools/Frameworks/Etc Used: Python, PyQt4, gVim 45 | 46 | Repo: `git clone git@github.com:swanson/stacktracker.git` 47 | 48 | [http://github.com/swanson/stacktracker][7] 49 | 50 | ##Release Notes 51 | Please post feature requests or bugs in the answer section. Patches or pull requests are more than welcome. 52 | 53 | ###Beta Builds 54 | 55 | **v1.0** (July 9) 56 | 57 | - StackTracker has now entered Beta status! 58 | - Support for API v1.0 release 59 | - Fixed bug in Mac OS X build involving exiting from the tray icon 60 | - Added Mac OS X build icon 61 | - Added default logo for all new Area51/StackExchange sites 62 | - Economized API calls 63 | - Added better handling of multiple alerts overwriting each other 64 | - Added notification when a question is autoremoved 65 | - Removed option to autoremove on accept answers 66 | - Code clean-up and refactoring 67 | 68 | ###Alpha Builds 69 | 70 | **v0.4.1** (June 24) 71 | 72 | - Updating app to API version 0.9 73 | 74 | **v0.4** (June 23) 75 | 76 | - Fixed bug with gzipped API response that broke 77 | nearly all functionality :) 78 | - Added Mac OS X build 79 | 80 | **v0.3** (June 8) 81 | 82 | - Major UI changes 83 | - Windows build released and tested 84 | - Added settings for auto-removing questions 85 | and changing update interval 86 | - Shifted application design from a single window 87 | to a system tray icon 88 | - Added answer count and asked by fields to question list 89 | - Clicking on a question title in the window will now open 90 | the question in the browser 91 | - Throttling API calls to adhere to new "conscientious use" policy 92 | - Changed application icon 93 | - Adding error dialogs for bad input to question URL field 94 | - Added support for Python 2.5 JSON library 95 | - Fixed bug related to local time vs GMT 96 | - Fixed bug where the same question could be tracked multiple times 97 | - Code clean-up and refactoring 98 | 99 | 100 | **v0.2** (May 28) 101 | 102 | - Added support for other 'Trilogy' 103 | sites 104 | - Questions in the list are colored 105 | based on which site they are from 106 | - Changed input from question ID to 107 | question URL 108 | - Fixed Segmentation Fault when closing 109 | program 110 | - Fixed bug where invalid system clock 111 | could cause multiple notifications 112 | for same answer/comment 113 | - Various refactoring and code clean-up 114 | 115 | **v0.1** (May 26) 116 | 117 | - Initial build 118 | 119 | 120 | [1]: http://i.imgur.com/FWcvp.png 121 | [2]: http://i.imgur.com/9D3k1.png 122 | [3]: http://i.imgur.com/HCIgF.png 123 | [4]: http://github.com/swanson/stacktracker/archives/v1.0 124 | [5]: http://github.com/downloads/swanson/stacktracker/StackTracker%20v1.0.zip 125 | [6]: http://github.com/downloads/swanson/stacktracker/StackTracker%20v1.0%20-%20OS%20X.tar.gz 126 | [7]: http://github.com/swanson/stacktracker 127 | -------------------------------------------------------------------------------- /StackTracker.py: -------------------------------------------------------------------------------- 1 | from PyQt4 import QtCore, QtGui, QtWebKit, QtNetwork 2 | from datetime import timedelta, datetime, date 3 | try: 4 | import json 5 | except ImportError: 6 | import simplejson as json 7 | import urllib2 8 | import os 9 | import copy 10 | import re 11 | import time 12 | import calendar 13 | import sip 14 | import StringIO, gzip 15 | from Queue import Queue 16 | 17 | class QLineEditWithPlaceholder(QtGui.QLineEdit): 18 | """ 19 | Custom Qt widget that is required since PyQt does not yet 20 | support Qt4.7 -- which adds native placeholder text functionality to 21 | QLineEdits 22 | """ 23 | def __init__(self, parent = None): 24 | QtGui.QLineEdit.__init__(self, parent) 25 | self.placeholder = None 26 | 27 | def setPlaceholderText(self, text): 28 | self.placeholder = text 29 | self.update() 30 | 31 | def paintEvent(self, event): 32 | """Overload paintEvent to draw placeholder text under certain conditions""" 33 | QtGui.QLineEdit.paintEvent(self, event) 34 | if self.placeholder and not self.hasFocus() and not self.text(): 35 | painter = QtGui.QPainter(self) 36 | painter.setPen(QtGui.QPen(QtCore.Qt.darkGray)) 37 | painter.drawText(QtCore.QRect(8, 1, self.width(), self.height()), \ 38 | QtCore.Qt.AlignVCenter, self.placeholder) 39 | painter.end() 40 | 41 | class QuestionDisplayWidget(QtGui.QWidget): 42 | """Custom Qt Widget to display pretty representations of Question objects""" 43 | def __init__(self, question, parent = None): 44 | QtGui.QWidget.__init__(self, parent) 45 | 46 | SITE_LOGOS = {'stackoverflow.com':'stackoverflow_logo.png', 47 | 'serverfault.com':'serverfault_logo.png', 48 | 'superuser.com':'superuser_logo.png', 49 | 'meta.stackoverflow.com':'metastackoverflow_logo.png', 50 | 'stackapps.com':'stackapps_logo.png' 51 | } 52 | 53 | self.setGeometry(QtCore.QRect(0,0,320,80)) 54 | self.setStyleSheet('QLabel {color: #cccccc;}') 55 | self.frame = QtGui.QFrame(self) 56 | self.frame.setObjectName('mainFrame') 57 | self.frame.setStyleSheet('#mainFrame {background-color: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 #333333, stop: 1 #4d4d4d);}') 58 | 59 | self.question = question 60 | 61 | font = QtGui.QFont() 62 | font.setPointSize(14) 63 | 64 | self.question_label = QtGui.QLabel(self.frame) 65 | self.question_label.setGeometry(QtCore.QRect(10, 7, 280, 50)) 66 | self.question_label.setWordWrap(True) 67 | self.question_label.setFont(font) 68 | self.question_label.setText(question.title) 69 | self.question_label.setObjectName('question_label') 70 | self.question_label.setStyleSheet("#question_label{color: #83ceea;text-decoration:underline} #question_label:hover{color: #b9eafc;}") 71 | self.question_label.mousePressEvent = self.launchUrl 72 | 73 | self.remove_button = QtGui.QPushButton(self.frame) 74 | self.remove_button.setGeometry(QtCore.QRect(295, 7, 25, 25)) 75 | self.remove_button.setText('X') 76 | self.remove_button.setFont(font) 77 | self.remove_button.setStyleSheet("QPushButton{background: #818185; border: 3px solid black; color: white; text-align: center;} QPushButton:hover{background: #c03434;}") 78 | self.remove_button.clicked.connect(self.remove) 79 | 80 | if question.site in SITE_LOGOS: 81 | path = os.path.join('img', SITE_LOGOS[question.site]) 82 | self.site_icon = QtGui.QLabel(self.frame) 83 | self.site_icon.setGeometry(QtCore.QRect(10, 60, 25, 25)) 84 | self.site_icon.setStyleSheet("image: url(img/" + SITE_LOGOS[question.site] + "); background-repeat:no-repeat;") 85 | else: 86 | self.site_icon = QtGui.QLabel(self.frame) 87 | self.site_icon.setGeometry(QtCore.QRect(10, 60, 25, 25)) 88 | self.site_icon.setStyleSheet("image: url(img/default.png); background-repeat:no-repeat;") 89 | 90 | 91 | self.answers_label = QtGui.QLabel(self.frame) 92 | self.answers_label.setText('%s answer(s)' % question.answer_count) 93 | self.answers_label.setGeometry(QtCore.QRect(40, 65, 100, 20)) 94 | 95 | if question.submitter is not None: 96 | self.submitted_label = QtGui.QLabel(self.frame) 97 | self.submitted_label.setText('asked by ' + question.submitter) 98 | self.submitted_label.setAlignment(QtCore.Qt.AlignRight) 99 | self.submitted_label.setGeometry(QtCore.QRect(120, 65, 200, 20)) 100 | 101 | def remove(self): 102 | self.emit(QtCore.SIGNAL('removeQuestion'), self.question) 103 | 104 | def launchUrl(self, event): 105 | QtGui.QDesktopServices.openUrl(QtCore.QUrl(self.question.url)) 106 | 107 | class Question(): 108 | """Application specific representation of a StackExchange question""" 109 | def __init__(self, question_id, site, title = None, created = None, \ 110 | last_queried = None, already_answered = None, \ 111 | answer_count = None, submitter = None): 112 | self.id = question_id 113 | self.site = site 114 | 115 | api_base = 'http://api.%s/%s' \ 116 | % (self.site, APIHelper.API_VER) 117 | base = 'http://%s/questions/' % (self.site) 118 | self.url = base + self.id 119 | 120 | self.json_url = '%s/questions/%s/%s' \ 121 | % (api_base, self.id, APIHelper.API_KEY) 122 | 123 | if title is None or answer_count is None or submitter is None or already_answered is None: 124 | so_data = APIHelper.callAPI(self.json_url) 125 | 126 | if title is None: 127 | self.title = so_data['questions'][0]['title'] 128 | else: 129 | self.title = title 130 | 131 | if already_answered is None: 132 | self.already_answered = 'accepted_answer_id' in so_data['questions'][0] 133 | else: 134 | self.already_answered = already_answered 135 | 136 | if answer_count is None: 137 | self.answer_count = so_data['questions'][0]['answer_count'] 138 | else: 139 | self.answer_count = answer_count 140 | 141 | if submitter is None: 142 | try: 143 | self.submitter = so_data['questions'][0]['owner']['display_name'] 144 | except KeyError: 145 | self.submitter = None 146 | else: 147 | self.submitter = submitter 148 | 149 | if len(self.title) > 45: 150 | self.title = self.title[:43] + '...' 151 | 152 | if last_queried is None: 153 | self.last_queried = datetime.utcnow() 154 | else: 155 | self.last_queried = datetime.utcfromtimestamp(last_queried) 156 | 157 | if created is None: 158 | self.created = datetime.utcnow() 159 | else: 160 | self.created = datetime.utcfromtimestamp(created) 161 | 162 | self.answers_url = '%s/questions/%s/answers%s&min=%s' \ 163 | % (api_base, self.id, APIHelper.API_KEY, 164 | int(calendar.timegm(self.created.timetuple()))) 165 | 166 | self.comments_url = '%s/questions/%s/comments%s&min=%s' \ 167 | % (api_base, self.id, APIHelper.API_KEY, 168 | int(calendar.timegm(self.created.timetuple()))) 169 | 170 | def __repr__(self): 171 | return "%s: %s" % (self.id, self.title) 172 | 173 | def __eq__(self, other): 174 | return ((self.site == other.site) and (self.id == other.id)) 175 | 176 | class QSpinBoxRadioButton(QtGui.QRadioButton): 177 | """ 178 | Custom Qt Widget that allows for a spinbox to be used in 179 | conjunction with a radio button 180 | """ 181 | def __init__(self, prefix = '', suffix = '', parent = None): 182 | QtGui.QRadioButton.__init__(self, parent) 183 | self.prefix = QtGui.QLabel(prefix) 184 | self.prefix.mousePressEvent = self.labelClicked 185 | self.suffix = QtGui.QLabel(suffix) 186 | self.suffix.mousePressEvent = self.labelClicked 187 | 188 | self.spinbox = QtGui.QSpinBox() 189 | self.spinbox.setEnabled(self.isDown()) 190 | self.toggled.connect(self.spinbox.setEnabled) 191 | 192 | self.layout = QtGui.QHBoxLayout() 193 | self.layout.addWidget(self.prefix) 194 | self.layout.addWidget(self.spinbox) 195 | self.layout.addWidget(self.suffix) 196 | self.layout.addStretch(2) 197 | self.layout.setContentsMargins(25, 0, 0, 0) 198 | 199 | self.setLayout(self.layout) 200 | 201 | def labelClicked(self, event): 202 | self.toggle() 203 | 204 | def setPrefix(self, p): 205 | self.prefix.setText(p) 206 | 207 | def setSuffix(self, s): 208 | self.suffix.setText(s) 209 | 210 | def setSpinBoxSuffix(self, text): 211 | self.spinbox.setSuffix(" %s" % text) 212 | 213 | def setMinimum(self, value): 214 | self.spinbox.setMinimum(value) 215 | 216 | def setMaximum(self, value): 217 | self.spinbox.setMaximum(value) 218 | 219 | def setSingleStep(self, step): 220 | self.spinbox.setSingleStep(step) 221 | 222 | def value(self): 223 | return self.spinbox.value() 224 | 225 | def setValue(self, value): 226 | self.spinbox.setValue(value) 227 | 228 | class SettingsDialog(QtGui.QDialog): 229 | """ 230 | Settings window that allows the user to customize the application 231 | 232 | Currently supports auto-removing questions and changing the refresh 233 | interval. 234 | """ 235 | def __init__(self, parent = None): 236 | QtGui.QDialog.__init__(self, parent) 237 | self.setFixedSize(QtCore.QSize(400,250)) 238 | self.setWindowTitle('Settings') 239 | 240 | self.layout = QtGui.QVBoxLayout() 241 | 242 | self.auto_remove = QtGui.QGroupBox("Automatically remove questions?", self) 243 | self.auto_remove.setCheckable(True) 244 | self.auto_remove.setChecked(False) 245 | 246 | self.time_option = QSpinBoxRadioButton('After','of being added') 247 | self.time_option.setMinimum(1) 248 | self.time_option.setMaximum(1000) 249 | self.time_option.setSingleStep(1) 250 | self.time_option.setSpinBoxSuffix(" hour(s)") 251 | 252 | self.inactivity_option = QSpinBoxRadioButton('After', 'of inactivity') 253 | self.inactivity_option.setMinimum(1) 254 | self.inactivity_option.setMaximum(1000) 255 | self.inactivity_option.setSingleStep(1) 256 | self.inactivity_option.setSpinBoxSuffix(" hour(s)") 257 | 258 | self.auto_layout = QtGui.QVBoxLayout() 259 | self.auto_layout.addWidget(self.time_option) 260 | self.auto_layout.addWidget(self.inactivity_option) 261 | 262 | self.auto_remove.setLayout(self.auto_layout) 263 | 264 | self.update_interval = QtGui.QGroupBox("Update Interval", self) 265 | self.update_input = QtGui.QSpinBox() 266 | self.update_input.setMinimum(60) 267 | self.update_input.setMaximum(86400) 268 | self.update_input.setSingleStep(15) 269 | self.update_input.setSuffix(" seconds") 270 | self.update_input.setPrefix("Check for updates every ") 271 | 272 | self.update_layout = QtGui.QVBoxLayout() 273 | self.update_layout.addWidget(self.update_input) 274 | 275 | self.update_interval.setLayout(self.update_layout) 276 | 277 | self.buttons = QtGui.QDialogButtonBox(QtGui.QDialogButtonBox.Cancel | QtGui.QDialogButtonBox.Save) 278 | self.buttons.accepted.connect(self.accept) 279 | self.buttons.rejected.connect(self.reject) 280 | 281 | self.layout.addWidget(self.auto_remove) 282 | self.layout.addWidget(self.update_interval) 283 | self.layout.addStretch(2) 284 | self.layout.addWidget(self.buttons) 285 | 286 | self.setLayout(self.layout) 287 | 288 | def updateSettings(self, settings): 289 | """Restore saved settings from a dictionary""" 290 | #todo throw this in a try block 291 | self.auto_remove.setChecked(settings['auto_remove']) 292 | if settings['on_time']: 293 | self.time_option.setValue(settings['on_time']) 294 | self.time_option.setChecked(True) 295 | if settings['on_inactivity']: 296 | self.inactivity_option.setValue(settings['on_inactivity']) 297 | self.inactivity_option.setChecked(True) 298 | self.update_input.setValue(settings['on_time']) 299 | 300 | def getSettings(self): 301 | """Returns a dictionary of currently selected settings""" 302 | settings = {} 303 | settings['auto_remove'] = self.auto_remove.isChecked() 304 | settings['on_time'] = self.time_option.value() if self.time_option.isChecked() else False 305 | settings['on_inactivity'] = self.inactivity_option.value() if self.inactivity_option.isChecked() else False 306 | settings['update_interval'] = self.update_input.value() 307 | 308 | return settings 309 | 310 | class Notification(object): 311 | def __init__(self, msg, url = None): 312 | self.msg = msg 313 | self.url = url 314 | 315 | class StackTracker(QtGui.QDialog): 316 | """ 317 | The 'main' dialog window for the application. Displays 318 | the list of tracked questions and has the input controls for 319 | adding new questions. 320 | """ 321 | 322 | 323 | def __init__(self, parent = None): 324 | QtGui.QDialog.__init__(self) 325 | self.parent = parent 326 | self.setWindowTitle("StackTracker") 327 | self.closeEvent = self.cleanUp 328 | self.setStyleSheet("QDialog{background: #f0ebe2;}") 329 | 330 | self.settings_dialog = SettingsDialog(self) 331 | self.settings_dialog.accepted.connect(self.serializeSettings) 332 | self.settings_dialog.accepted.connect(self.applySettings) 333 | self.settings_dialog.rejected.connect(self.deserializeSettings) 334 | self.deserializeSettings() 335 | 336 | self.setGeometry(QtCore.QRect(0, 0, 325, 400)) 337 | self.setFixedSize(QtCore.QSize(350,400)) 338 | 339 | self.display_list = QtGui.QListWidget(self) 340 | self.display_list.resize(QtCore.QSize(350, 350)) 341 | self.display_list.setStyleSheet("QListWidget{show-decoration-selected: 0; background: black;}") 342 | self.display_list.setSelectionMode(QtGui.QAbstractItemView.NoSelection) 343 | self.display_list.setVerticalScrollMode(QtGui.QAbstractItemView.ScrollPerPixel) 344 | self.display_list.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOn) 345 | self.display_list.clear() 346 | 347 | self.question_input = QLineEditWithPlaceholder(self) 348 | self.question_input.setGeometry(QtCore.QRect(15, 360, 240, 30)) 349 | self.question_input.setPlaceholderText("Enter Question URL...") 350 | 351 | path = os.getcwd() 352 | icon = QtGui.QIcon(path + '/img/st_logo.png') 353 | self.setWindowIcon(icon) 354 | 355 | font = QtGui.QFont() 356 | font.setPointSize(12) 357 | font.setBold(True) 358 | font.setFamily("Arial") 359 | 360 | self.track_button = QtGui.QPushButton(self) 361 | self.track_button.setGeometry(QtCore.QRect(265, 360, 65, 30)) 362 | self.track_button.setText("Track") 363 | self.track_button.clicked.connect(self.addQuestion) 364 | self.track_button.setFont(font) 365 | self.track_button.setStyleSheet("QPushButton{background: #e2e2e2; border: 1px solid #888888; color: black;} QPushButton:hover{background: #d6d6d6;}") 366 | 367 | self.tracking_list = [] 368 | 369 | self.deserializeQuestions() #load persisted questions from tracking.json 370 | 371 | self.displayQuestions() 372 | 373 | self.queue_timer = QtCore.QTimer(self) 374 | self.queue_timer.timeout.connect(self.processQueue) 375 | self.notify_queue = Queue() 376 | 377 | 378 | self.notifier = QtGui.QSystemTrayIcon(icon, self) 379 | self.notifier.messageClicked.connect(self.popupClicked) 380 | self.notifier.activated.connect(self.trayClicked) 381 | self.notifier.setToolTip('StackTracker') 382 | 383 | self.tray_menu = QtGui.QMenu() 384 | self.show_action = QtGui.QAction('Show', None) 385 | self.show_action.triggered.connect(self.showWindow) 386 | 387 | self.settings_action = QtGui.QAction('Settings', None) 388 | self.settings_action.triggered.connect(self.showSettings) 389 | 390 | self.about_action = QtGui.QAction('About', None) 391 | self.about_action.triggered.connect(self.showAbout) 392 | 393 | self.exit_action = QtGui.QAction('Exit', None) 394 | self.exit_action.triggered.connect(self.exitFromTray) 395 | 396 | self.tray_menu.addAction(self.show_action) 397 | self.tray_menu.addAction(self.settings_action) 398 | self.tray_menu.addAction(self.about_action) 399 | self.tray_menu.addSeparator() 400 | self.tray_menu.addAction(self.exit_action) 401 | 402 | self.notifier.setContextMenu(self.tray_menu) 403 | self.notifier.show() 404 | 405 | self.worker = WorkerThread(self) 406 | self.connect(self.worker, QtCore.SIGNAL('updateQuestion'), self.updateQuestion) 407 | self.connect(self.worker, QtCore.SIGNAL('autoRemove'), self.removeQuestion) 408 | self.connect(self.worker, QtCore.SIGNAL('done'), self.startQueueProcess) 409 | 410 | self.applySettings() 411 | 412 | self.worker.start() 413 | 414 | def applySettings(self): 415 | """Send new settings to worker thread""" 416 | settings = self.settings_dialog.getSettings() 417 | interval = settings['update_interval'] * 1000 #convert to milliseconds 418 | self.worker.setInterval(interval) 419 | self.worker.applySettings(settings) 420 | 421 | def trayClicked(self, event): 422 | """Shortcut to show list of question, not supported in Mac OS X""" 423 | if event == QtGui.QSystemTrayIcon.DoubleClick: 424 | self.showWindow() 425 | 426 | def showWindow(self): 427 | """Show the list of tracked questions""" 428 | self.show() 429 | self.showMaximized() 430 | self.displayQuestions() 431 | 432 | def showSettings(self): 433 | """Show the settings dialog""" 434 | self.settings_dialog.show() 435 | 436 | def showAbout(self): 437 | """Show About Page, as if anyone actually cares about who made this...""" 438 | s = """ 439 |

StackTracker

440 |

A desktop notifier using the StackExchange API built with PyQt4

441 |

Get alerts when answers or comments are posted to questions you are tracking.

442 |

Created by Matt Swanson

443 | """ 444 | QtGui.QMessageBox(QtGui.QMessageBox.Information, "About", s).exec_() 445 | 446 | def showError(self, text): 447 | """ 448 | Pop-up an error box with a message 449 | 450 | params: 451 | text => msg to display 452 | """ 453 | QtGui.QMessageBox(QtGui.QMessageBox.Critical, "Error!", text).exec_() 454 | 455 | def exitFromTray(self): 456 | """Event handler for 'Exit' menu option""" 457 | self.serializeQuestions() 458 | self.serializeSettings() 459 | self.parent.exit() 460 | 461 | def cleanUp(self, event): 462 | """Perform last-minute operations before exiting""" 463 | self.serializeQuestions() 464 | self.serializeSettings() 465 | 466 | def serializeQuestions(self): 467 | """Persist currently tracked questions in external JSON file""" 468 | a = [] 469 | for q in self.tracking_list: 470 | a.append(q.__dict__) 471 | 472 | #handler to convert datetime objects into epoch timestamps 473 | datetime_to_json = lambda obj: calendar.timegm(obj.utctimetuple()) if isinstance(obj, datetime) else None 474 | with open('tracking.json', 'w') as fp: 475 | json.dump({'questions':a}, fp, default = datetime_to_json, indent = 4) 476 | 477 | def deserializeQuestions(self): 478 | """Restore saved tracked questions from external JSON file""" 479 | try: 480 | with open('tracking.json', 'r') as fp: 481 | data = fp.read() 482 | except EnvironmentError: 483 | #no tracking.json file, return silently 484 | return 485 | 486 | question_data = json.loads(data) 487 | for q in question_data['questions']: 488 | rebuilt_question = Question(q['id'], q['site'], q['title'], q['created'], \ 489 | q['last_queried'], q['already_answered'], \ 490 | q['answer_count'], q['submitter']) 491 | self.tracking_list.append(rebuilt_question) 492 | 493 | def serializeSettings(self): 494 | """Persist application settings in external JSON file""" 495 | settings = self.settings_dialog.getSettings() 496 | with open('settings.json', 'w') as fp: 497 | json.dump(settings, fp, indent = 4) 498 | 499 | def deserializeSettings(self): 500 | """Restore saved application settings from external JSON file""" 501 | try: 502 | with open('settings.json', 'r') as fp: 503 | data = fp.read() 504 | except EnvironmentError: 505 | #no saved settings, return silently 506 | return 507 | 508 | self.settings_dialog.updateSettings(json.loads(data)) 509 | 510 | def updateQuestion(self, question, most_recent, answer_count, new_answer, new_comment): 511 | """Update questions in the tracking list with data fetched from worker thread""" 512 | tracked = None 513 | for q in self.tracking_list: 514 | if q == question: 515 | tracked = q 516 | break 517 | 518 | if tracked: 519 | tracked.last_queried = most_recent 520 | tracked.answer_count = answer_count 521 | 522 | if new_answer and new_comment: 523 | self.addToNotificationQueue(Notification("New comment(s) and answer(s): %s" \ 524 | % tracked.title, tracked.url)) 525 | elif new_answer: 526 | self.addToNotificationQueue(Notification("New answer(s): %s" \ 527 | % tracked.title, tracked.url)) 528 | elif new_comment: 529 | self.addToNotificationQueue(Notification("New comment(s): %s" \ 530 | % tracked.title, tracked.url)) 531 | 532 | self.displayQuestions() 533 | 534 | def popupClicked(self): 535 | """Open the question in user's default browser""" 536 | if self.popupUrl: 537 | QtGui.QDesktopServices.openUrl(QtCore.QUrl(self.popupUrl)) 538 | 539 | def displayQuestions(self): 540 | """Render the currently tracked questions in the display list""" 541 | #hack to fix random disappearing questions 542 | self.display_list = QtGui.QListWidget(self) 543 | self.display_list.resize(QtCore.QSize(350, 350)) 544 | self.display_list.setStyleSheet("QListWidget{show-decoration-selected: 0; background: black;}") 545 | self.display_list.setSelectionMode(QtGui.QAbstractItemView.NoSelection) 546 | self.display_list.setVerticalScrollMode(QtGui.QAbstractItemView.ScrollPerPixel) 547 | self.display_list.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOn) 548 | self.display_list.clear() 549 | #/end hack 550 | 551 | n = 0 552 | for question in self.tracking_list: 553 | item = QtGui.QListWidgetItem(self.display_list) 554 | item.setSizeHint(QtCore.QSize(320, 95)) 555 | self.display_list.addItem(item) 556 | qitem = QuestionDisplayWidget(question) 557 | self.connect(qitem, QtCore.SIGNAL('removeQuestion'), self.removeQuestion) 558 | self.display_list.setItemWidget(item, qitem) 559 | del item 560 | n = n + 1 561 | 562 | self.display_list.show() 563 | 564 | def removeQuestion(self, q, notify = False): 565 | """ 566 | Remove a question from the tracking list 567 | 568 | params: 569 | notify => indicate if the user should be alerted that the 570 | question is no longer being tracked, useful for 571 | auto-removing 572 | """ 573 | for question in self.tracking_list[:]: 574 | if question == q: 575 | self.tracking_list.remove(question) 576 | if notify: 577 | self.addToNotificationQueue(Notification("No longer tracking: %s" \ 578 | % question.title)) 579 | break 580 | self.displayQuestions() 581 | 582 | def extractDetails(self, url): 583 | """Strip out the site domain from given URL""" 584 | #todo: consider using StackAuth 585 | regex = re.compile("""(?:http://)?(?:www\.)? 586 | (?P(?:[A-Za-z\.])*\.[A-Za-z]*) 587 | /.*? 588 | (?P[0-9]+) 589 | /?.*""", re.VERBOSE) 590 | match = regex.match(url) 591 | if match is None: 592 | return None 593 | try: 594 | site = match.group('site') 595 | id = match.group('id') 596 | except IndexError: 597 | return None 598 | return id, site 599 | 600 | def addQuestion(self): 601 | """ 602 | Add a new question to the list of tracked questions and render 603 | it on the display list 604 | """ 605 | url = self.question_input.text() 606 | self.question_input.clear() 607 | details = self.extractDetails(str(url)) 608 | if details: 609 | id, site = details 610 | else: 611 | self.showError("Invalid URL format, please try again.") 612 | return 613 | q = Question(id, site) 614 | if q not in self.tracking_list: 615 | q = Question(id, site) 616 | self.tracking_list.append(q) 617 | self.displayQuestions() 618 | else: 619 | self.showError("This question is already being tracked.") 620 | return 621 | 622 | def addToNotificationQueue(self, notification): 623 | self.notify_queue.put(notification) 624 | 625 | def startQueueProcess(self): 626 | if not self.queue_timer.isActive(): 627 | self.queue_timer.start(5000) 628 | self.processQueue() 629 | 630 | def processQueue(self): 631 | if self.notify_queue.empty(): 632 | if self.queue_timer.isActive(): 633 | self.queue_timer.stop() 634 | else: 635 | self.notify(self.notify_queue.get()) 636 | 637 | def notify(self, notification): 638 | self.popupUrl = notification.url 639 | self.notifier.showMessage("StackTracker", notification.msg, 20000) 640 | 641 | class APIHelper(object): 642 | """Helper class for API related functionality""" 643 | 644 | API_KEY = '?key=Jv8tIPTrRUOqRe-5lk4myw' 645 | API_VER = '1.0' 646 | 647 | @staticmethod 648 | def callAPI(url): 649 | """Make an API call, decompress the gzipped response, return json object""" 650 | request = urllib2.Request(url, headers={'Accept-Encoding': 'gzip'}) 651 | req_open = urllib2.build_opener() 652 | gzipped_data = req_open.open(request).read() 653 | buffer = StringIO.StringIO(gzipped_data) 654 | gzipper = gzip.GzipFile(fileobj=buffer) 655 | return json.loads(gzipper.read()) 656 | 657 | 658 | class WorkerThread(QtCore.QThread): 659 | def __init__(self, tracker, parent = None): 660 | QtCore.QThread.__init__(self, parent) 661 | self.tracker = tracker 662 | self.interval = 60000 663 | self.settings = {} 664 | 665 | def run(self): 666 | self.timer = QtCore.QTimer() 667 | self.connect(self.timer, QtCore.SIGNAL('timeout()'), self.fetch, QtCore.Qt.DirectConnection) 668 | self.timer.start(self.interval) 669 | self.fetch() 670 | self.exec_() 671 | 672 | def __del__(self): 673 | self.exit() 674 | self.terminate() 675 | 676 | def setInterval(self, value): 677 | self.interval = value 678 | 679 | def applySettings(self, settings): 680 | self.settings = settings 681 | 682 | def fetch(self): 683 | for question in self.tracker.tracking_list[:]: 684 | new_answers = False 685 | new_comments = False 686 | most_recent = question.last_queried 687 | so_data = APIHelper.callAPI(question.answers_url) 688 | answer_count = so_data['total'] 689 | for answer in so_data['answers']: 690 | updated = datetime.utcfromtimestamp(answer['creation_date']) 691 | if updated > question.last_queried: 692 | new_answers = True 693 | if updated > most_recent: 694 | most_recent = updated 695 | 696 | so_data = APIHelper.callAPI(question.comments_url) 697 | for comment in so_data['comments']: 698 | updated = datetime.utcfromtimestamp(comment['creation_date']) 699 | if updated > question.last_queried: 700 | new_comments = True 701 | if updated > most_recent: 702 | most_recent = updated 703 | 704 | self.emit(QtCore.SIGNAL('updateQuestion'), question, most_recent, answer_count, \ 705 | new_answers, new_comments) 706 | 707 | self.autoRemoveQuestions() 708 | self.emit(QtCore.SIGNAL('done')) 709 | 710 | def autoRemoveQuestions(self): 711 | if self.settings['auto_remove']: 712 | if self.settings['on_inactivity']: 713 | threshold = timedelta(hours = self.settings['on_inactivity']) 714 | for question in self.tracker.tracking_list[:]: 715 | if datetime.utcnow() - question.last_queried > threshold: 716 | self.emit(QtCore.SIGNAL('autoRemove'), question, True) 717 | elif self.settings['on_time']: 718 | threshold = timedelta(hours = self.settings['on_time']) 719 | for question in self.tracker.tracking_list[:]: 720 | if datetime.utcnow() - question.created > threshold: 721 | self.emit(QtCore.SIGNAL('autoRemove'), question, True) 722 | 723 | if __name__ == "__main__": 724 | import sys 725 | 726 | app = QtGui.QApplication(sys.argv) 727 | app.setQuitOnLastWindowClosed(False) 728 | st = StackTracker(app) 729 | app.exec_() 730 | del st 731 | sys.exit() 732 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | By July 9 - BETA RELEASE 2 | - add error handling to API call method 3 | DONE - add placeholder question icon graphic 4 | DONE - try out improved api urls, activity and timestamps 5 | DONE - add Mac icon 6 | DONE - multi alert queue -- ugh 7 | - logo, write-up and video 8 | -------------------------------------------------------------------------------- /img/default.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swanson/stacktracker/f0200b1e1f34b6ab68eb3bd045cb6d575e115839/img/default.png -------------------------------------------------------------------------------- /img/metastackoverflow_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swanson/stacktracker/f0200b1e1f34b6ab68eb3bd045cb6d575e115839/img/metastackoverflow_logo.png -------------------------------------------------------------------------------- /img/serverfault_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swanson/stacktracker/f0200b1e1f34b6ab68eb3bd045cb6d575e115839/img/serverfault_logo.png -------------------------------------------------------------------------------- /img/st_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swanson/stacktracker/f0200b1e1f34b6ab68eb3bd045cb6d575e115839/img/st_logo.png -------------------------------------------------------------------------------- /img/stackapps_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swanson/stacktracker/f0200b1e1f34b6ab68eb3bd045cb6d575e115839/img/stackapps_logo.png -------------------------------------------------------------------------------- /img/stackoverflow_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swanson/stacktracker/f0200b1e1f34b6ab68eb3bd045cb6d575e115839/img/stackoverflow_logo.png -------------------------------------------------------------------------------- /img/superuser_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swanson/stacktracker/f0200b1e1f34b6ab68eb3bd045cb6d575e115839/img/superuser_logo.png -------------------------------------------------------------------------------- /setup-mac.py: -------------------------------------------------------------------------------- 1 | """ 2 | This is a setup.py script generated by py2applet 3 | 4 | Usage: 5 | python setup.py py2app 6 | """ 7 | 8 | from setuptools import setup 9 | 10 | APP = ['StackTracker.py'] 11 | DATA_FILES = [('img', ['img/st_logo.png', 12 | 'img/stackoverflow_logo.png', 13 | 'img/metastackoverflow_logo.png', 14 | 'img/serverfault_logo.png', 15 | 'img/superuser_logo.png', 16 | 'img/stackapps_logo.png', 17 | 'img/default.png' 18 | ] 19 | )] 20 | 21 | OPTIONS = {'argv_emulation': True, 'iconfile': 'st.icns'} 22 | 23 | setup( 24 | app=APP, 25 | data_files=DATA_FILES, 26 | options={'py2app': OPTIONS}, 27 | setup_requires=['py2app'], 28 | ) 29 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from distutils.core import setup 2 | import py2exe 3 | import os, sys 4 | 5 | def find_file_in_path(filename): 6 | for include_path in sys.path: 7 | file_path = os.path.join(include_path, filename) 8 | if os.path.exists(file_path): 9 | return file_path 10 | 11 | setup(name="StackTracker", 12 | version="0.3", 13 | author="Matt Swanson", 14 | author_email="mdswanso@purdue.edu", 15 | url="http://stackapps.com/questions/290/", 16 | license="GNU General Public License (GPL)", 17 | windows=[{"script": "StackTracker.py", 18 | "icon_resources": [(1, "st.ico")]}], 19 | data_files = [('img', ['img/st_logo.png', 20 | 'img/stackoverflow_logo.png', 21 | 'img/metastackoverflow_logo.png', 22 | 'img/serverfault_logo.png', 23 | 'img/superuser_logo.png', 24 | 'img/stackapps_logo.png', 25 | 'img/default.png' 26 | ] 27 | ), 28 | ('imageformats', [ 29 | find_file_in_path("PyQt4/plugins/imageformats/qsvg4.dll"), 30 | find_file_in_path("PyQt4/plugins/imageformats/qjpeg4.dll"), 31 | find_file_in_path("PyQt4/plugins/imageformats/qico4.dll"), 32 | find_file_in_path("PyQt4/plugins/imageformats/qmng4.dll"), 33 | find_file_in_path("PyQt4/plugins/imageformats/qtiff4.dll"), 34 | find_file_in_path("PyQt4/plugins/imageformats/qgif4.dll"),] 35 | ) 36 | ], 37 | options={"py2exe": {"skip_archive": True, 38 | "includes": ["sip"], 39 | } 40 | } 41 | ) 42 | -------------------------------------------------------------------------------- /st.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swanson/stacktracker/f0200b1e1f34b6ab68eb3bd045cb6d575e115839/st.icns -------------------------------------------------------------------------------- /st.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swanson/stacktracker/f0200b1e1f34b6ab68eb3bd045cb6d575e115839/st.ico --------------------------------------------------------------------------------