├── .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
--------------------------------------------------------------------------------