Merge pull request #30 from captn3m0/python3

Python 3 and v4.0.0
pull/32/head
Nemo 2 years ago committed by GitHub
commit 64cc856ed1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 5
      .gitignore
  2. 15
      .travis.yml
  3. 11
      CHANGELOG.md
  4. 31
      README.md
  5. 109
      hackertray/__init__.py
  6. 23
      hackertray/analytics.py
  7. 14
      hackertray/appindicator_replacement.py
  8. 7
      hackertray/chrome.py
  9. 32
      hackertray/firefox.py
  10. 3
      hackertray/hackernews.py
  11. 10
      hackertray/version.py
  12. 14
      setup.py
  13. 23
      test/firefox_test.py

5
.gitignore vendored

@ -17,4 +17,7 @@ var/
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
pip-delete-this-directory.txt
pyvenv.cfg
bin/

@ -1,13 +1,20 @@
language: python
python:
- "2.7"
# https://endoflife.date/python
# goes away in sep 2020
- "3.5"
# goes away in dec 2021
- "3.6"
# goes away in jun 2023
- "3.7"
# goes away in oct 2024
- "3.8"
# command to install dependencies, e.g. pip install -r requirements.txt --use-mirrors
install:
install:
- pip install requests
- pip install nose
- pip install mixpanel-py
# command to run tests, e.g. python setup.py test
script: nosetests --nocapture
script: nosetests --nocapture
notifications:
email:
on_success: never

@ -1,5 +1,16 @@
This file will only list released and supported versions, usually skipping over very minor updates.
Unreleased
==========
4.0.0
=====
* Adds support for --firefox auto, picks the default firefox profile automatically
* Upgrades to Python 3.0. Python 2 is no longer supported
* Switches from PyGtk to PyGObject.
* AppIndicator is no longer supported, because it is Python 2 only
3.0.0
=====

@ -32,7 +32,7 @@ After that, you can run `hackertray` from anywhere and it will run. You can
now add it to your OS dependent session autostart method. In Ubuntu, you can
access it via:
1. System > Preferences > Sessions
1. System > Preferences > Sessions
(OR)
2. System > Preferences > Startup Applications
@ -59,32 +59,32 @@ HackerTray accepts its various options via the command line. Run `hackertray -h`
1. `-c`: Enables comments support. Clicking on links will also open the comments page on HN. Can be switched off via the UI, but the setting is not remembered.
2. `--chrome PROFILE-PATH`: Specifying a profile path to a chrome directory will make HackerTray read the Chrome History file to mark links as read. Links are checked once every 5 minutes, which is when the History file is copied (to override the lock in case Chrome is open), searched using sqlite and deleted. This feature is still experimental.
3. `--firefox PROFILE-PATH`: Specify path to a firefox profile directory. HackerTray will read your firefox history from this profile, and use it to mark links as read.
4. `--dnt`: Disable analytics. Hackertray will no longer collect any sort of analytics. I'd prefer it if you left out this switch, as it helps me improve hackertray by understanding how its being used.
3. `--firefox PROFILE-PATH`: Specify path to a firefox profile directory. HackerTray will read your firefox history from this profile, and use it to mark links as read. Pass `auto` as PROFILE-PATH to automatically read the default profile and use that.
Note that the `--chrome` and `--firefox` options are independent, and can be used together. However, they cannot be specified multiple times (so reading from 2 chrome profiles is not possible).
### Google Chrome Profile Path
Where your Profile is stored depends on which version of chrome you are using:
Where your Profile is stored depends on [which version of chrome you are using](https://chromium.googlesource.com/chromium/src.git/+/62.0.3202.58/docs/user_data_dir.md#linux):
- `google-chrome-stable`: `~/.config/google-chrome/Default/`
- `google-chrome-unstable`: `~/.config/google-chrome-unstable/Default/`
- `chromium`: `~/.config/chromium/Default/`
- [Chrome Stable] `~/.config/google-chrome/Default`
- [Chrome Beta] `~/.config/google-chrome-beta/Default`
- [Chrome Dev] `~/.config/google-chrome-unstable/Default`
- [Chromium] `~/.config/chromium/Default`
Replace `Default` with `Profile 1`, `Profile 2` or so on if you use multiple profiles on Chrome. Note that the `--chrome` option accepts a `PROFILE-PATH`, not the History file itself. Also note that sometimes `~` might not be set, so you might need to use the complete path (such as `/home/nemo/.config/google-chrome/Default/`).
### Firefox Profile Path
The default firefox profile path is `~/.mozilla/firefox/*.default`, where `*` denotes a random 8 digit string. You can also read `~/.mozilla/firefox/profiles.ini` to get a list of profiles.
The default firefox profile path is `~/.mozilla/firefox/*.default`, where `*` denotes a random 8 digit string. You can also read `~/.mozilla/firefox/profiles.ini` to get a list of profiles. Alternatively, just pass `auto` and HackerTray will pick the default profile automatically.
## Features
1. Minimalist Approach to HN
2. Opens links in your default browser
3. Remembers which links you opened
3. Remembers which links you opened, even if you opened them outside of HackerTray
4. Shows Points/Comment count in a simple format
5. Reads your Google Chrome History file to determine which links you've already read (even if you may not have opened them via HackerTray)
5. Reads your Google Chrome/Firefox History file to determine which links you've already read (even if you may not have opened them via HackerTray)
### Troubleshooting
@ -93,6 +93,8 @@ python-appindicator with
`sudo apt-get install python-appindicator`
Note that appindicator is no longer supported in non-Ubuntu distros, because it only works on Python2.
### Development
To develop on hackertray, or to test out experimental versions, do the following:
@ -103,13 +105,16 @@ To develop on hackertray, or to test out experimental versions, do the following
## Analytics
To help improve the project and learn how its being used, I've added Analytics in hackertray. The `--dnt` switch disables all analytics so that you can opt-out if desired. All data is collected anonymously, with no machine id or user-identifying information being sent back. To learn more, and see which events are being tracked, see the [Analytics](https://github.com/captn3m0/hackertray/wiki/Analytics) wiki page.
On every launch, a request is made to `https://pypi.python.org/pypi/hackertray/json` to check the latest version.
**No more tracking**. All data every collected for this project has been deleted. You can see [the wiki](https://github.com/captn3m0/hackertray/wiki/Analytics) for what all was collected earlier.
## Credits
- Mark Rickert for [Hacker Bar](http://hackerbarapp.com/).
- Mark Rickert for [Hacker Bar](http://hackerbarapp.com/) (No longer active)
- [Giridaran Manivannan](https://github.com/ace03uec) for troubleshooting instructions.
- [@cheeaun](https://github.com/cheeaun) for the [Unofficial Hacker News API](https://github.com/cheeaun/node-hnapi/)
## Licence
Licenced under the [MIT Licence](http://nemo.mit-license.org/).
Licenced under the [MIT Licence](https://nemo.mit-license.org/).

@ -2,42 +2,39 @@
import os
import requests
import platform
import subprocess
if(os.environ.get('TRAVIS')!='true'):
import pygtk
pygtk.require('2.0')
import gtk
if(os.environ.get('TRAVIS') != 'true'):
import gi
gi.require_version('Gtk', '3.0')
from gi.repository import Gtk,GLib
import webbrowser
try:
import appindicator
except ImportError:
import appindicator_replacement as appindicator
from . import appindicator_replacement as appindicator
from appindicator_replacement import get_icon_filename
from .appindicator_replacement import get_icon_filename
import json
import argparse
from os.path import expanduser
import signal
from hackernews import HackerNews
from chrome import Chrome
from firefox import Firefox
from version import Version
from analytics import Analytics
from .hackernews import HackerNews
from .chrome import Chrome
from .firefox import Firefox
from .version import Version
class HackerNewsApp:
HN_URL_PREFIX = "https://news.ycombinator.com/item?id="
UPDATE_URL = "https://github.com/captn3m0/hackertray#upgrade"
ABOUT_URL = "https://github.com/captn3m0/hackertray"
MIXPANEL_TOKEN = "51a04e37dad59393c7371407e84a8050"
def __init__(self, args):
#Load the database
# Load the database
home = expanduser("~")
with open(home + '/.hackertray.json', 'a+') as content_file:
content_file.seek(0)
@ -47,109 +44,91 @@ class HackerNewsApp:
except ValueError:
self.db = set()
# Setup analytics
self.tracker = Analytics(args.dnt, HackerNewsApp.MIXPANEL_TOKEN)
# create an indicator applet
self.ind = appindicator.Indicator("Hacker Tray", "hacker-tray", appindicator.CATEGORY_APPLICATION_STATUS)
self.ind.set_status(appindicator.STATUS_ACTIVE)
self.ind.set_icon(get_icon_filename("hacker-tray.png"))
# create a menu
self.menu = gtk.Menu()
self.menu = Gtk.Menu()
#The default state is false, and it toggles when you click on it
# The default state is false, and it toggles when you click on it
self.commentState = args.comments
# create items for the menu - refresh, quit and a separator
menuSeparator = gtk.SeparatorMenuItem()
menuSeparator = Gtk.SeparatorMenuItem()
menuSeparator.show()
self.menu.append(menuSeparator)
btnComments = gtk.CheckMenuItem("Show Comments")
btnComments = Gtk.CheckMenuItem("Show Comments")
btnComments.show()
btnComments.set_active(args.comments)
btnComments.connect("activate", self.toggleComments)
self.menu.append(btnComments)
btnAbout = gtk.MenuItem("About")
btnAbout = Gtk.MenuItem("About")
btnAbout.show()
btnAbout.connect("activate", self.showAbout)
self.menu.append(btnAbout)
btnRefresh = gtk.MenuItem("Refresh")
btnRefresh = Gtk.MenuItem("Refresh")
btnRefresh.show()
#the last parameter is for not running the timer
# the last parameter is for not running the timer
btnRefresh.connect("activate", self.refresh, True, args.chrome)
self.menu.append(btnRefresh)
if Version.new_available():
btnUpdate = gtk.MenuItem("New Update Available")
btnUpdate = Gtk.MenuItem("New Update Available")
btnUpdate.show()
btnUpdate.connect('activate',self.showUpdate)
btnUpdate.connect('activate', self.showUpdate)
self.menu.append(btnUpdate)
btnQuit = gtk.MenuItem("Quit")
btnQuit = Gtk.MenuItem("Quit")
btnQuit.show()
btnQuit.connect("activate", self.quit)
self.menu.append(btnQuit)
self.menu.show()
self.ind.set_menu(self.menu)
self.refresh(chrome_data_directory=args.chrome, firefox_data_directory=args.firefox)
self.launch_analytics(args)
def launch_analytics(self, args):
# Now that we're all done with the boot, send a beacone home
launch_data = vars(args)
launch_data['version'] = Version.current()
launch_data['platform'] = platform.linux_distribution()
try:
launch_data['browser'] = subprocess.check_output(["xdg-settings","get","default-web-browser"]).strip()
except subprocess.CalledProcessError as e:
launch_data['browser'] = "unknown"
self.tracker.track('launch', launch_data)
if args.firefox == "auto":
args.firefox = Firefox.default_firefox_profile_path()
self.refresh(chrome_data_directory=args.chrome, firefox_data_directory=args.firefox)
def toggleComments(self, widget):
"""Whether comments page is opened or not"""
self.commentState = not self.commentState
def showUpdate(self,widget):
def showUpdate(self, widget):
"""Handle the update button"""
webbrowser.open(HackerNewsApp.UPDATE_URL)
# Remove the update button once clicked
self.menu.remove(widget)
self.tracker.visit(HackerNewsApp.UPDATE_URL)
def showAbout(self, widget):
"""Handle the about btn"""
webbrowser.open(HackerNewsApp.ABOUT_URL)
self.tracker.visit(HackerNewsApp.ABOUT_URL)
#ToDo: Handle keyboard interrupt properly
# ToDo: Handle keyboard interrupt properly
def quit(self, widget, data=None):
""" Handler for the quit button"""
l = list(self.db)
home = expanduser("~")
#truncate the file
# truncate the file
with open(home + '/.hackertray.json', 'w+') as file:
file.write(json.dumps(l))
gtk.main_quit()
self.tracker.track('quit')
Gtk.main_quit()
def run(self):
signal.signal(signal.SIGINT, self.quit)
gtk.main()
Gtk.main()
return 0
def open(self, widget, event=None, data=None):
"""Opens the link in the web browser"""
#We disconnect and reconnect the event in case we have
#to set it to active and we don't want the signal to be processed
# We disconnect and reconnect the event in case we have
# to set it to active and we don't want the signal to be processed
if not widget.get_active():
widget.disconnect(widget.signal_id)
widget.set_active(True)
@ -160,15 +139,14 @@ class HackerNewsApp:
if self.commentState:
webbrowser.open(self.HN_URL_PREFIX + str(widget.hn_id))
self.tracker.visit(widget.url)
def addItem(self, item):
"""Adds an item to the menu"""
#This is in the case of YC Job Postings, which we skip
# This is in the case of YC Job Postings, which we skip
if item['points'] == 0 or item['points'] is None:
return
i = gtk.CheckMenuItem(
i = Gtk.CheckMenuItem(
"(" + str(item['points']).zfill(3) + "/" + str(item['comments_count']).zfill(3) + ") " + item['title'])
visited = item['history'] or item['id'] in self.db
@ -184,7 +162,6 @@ class HackerNewsApp:
i.show()
def refresh(self, widget=None, no_timer=False, chrome_data_directory=None, firefox_data_directory=None):
"""Refreshes the menu """
try:
# Create an array of 20 false to denote matches in History
@ -197,12 +174,12 @@ class HackerNewsApp:
if(firefox_data_directory):
searchResults = self.mergeBoolArray(searchResults, Firefox.search(urls, firefox_data_directory))
#Remove all the current stories
# Remove all the current stories
for i in self.menu.get_children():
if hasattr(i, 'url'):
self.menu.remove(i)
#Add back all the refreshed news
# Add back all the refreshed news
for index, item in enumerate(data):
item['history'] = searchResults[index]
if item['url'].startswith('item?id='):
@ -211,11 +188,11 @@ class HackerNewsApp:
self.addItem(item)
# Catch network errors
except requests.exceptions.RequestException as e:
print "[+] There was an error in fetching news items"
print("[+] There was an error in fetching news items")
finally:
# Call every 10 minutes
if not no_timer:
gtk.timeout_add(10 * 30 * 1000, self.refresh, widget, no_timer, chrome_data_directory)
GLib.timeout_add(10 * 30 * 1000, self.refresh, widget, no_timer, chrome_data_directory)
# Merges two boolean arrays, using OR operation against each pair
def mergeBoolArray(self, original, patch):
@ -223,13 +200,13 @@ class HackerNewsApp:
original[index] = original[index] or patch[index]
return original
def main():
parser = argparse.ArgumentParser(description='Hacker News in your System Tray')
parser.add_argument('-v','--version', action='version', version=Version.current())
parser.add_argument('-c','--comments', dest='comments',action='store_true', help="Load the HN comments link for the article as well")
parser.add_argument('-v', '--version', action='version', version=Version.current())
parser.add_argument('-c', '--comments', dest='comments', action='store_true', help="Load the HN comments link for the article as well")
parser.add_argument('--chrome', dest='chrome', help="Specify a Google Chrome Profile directory to use for matching chrome history")
parser.add_argument('--firefox', dest='firefox', help="Specify a Firefox Profile directory to use for matching firefox history")
parser.add_argument('--dnt', dest='dnt',action='store_true', help="Disable all analytics (Do Not Track)")
parser.add_argument('--firefox', dest='firefox', help="Specify a Firefox Profile directory to use for matching firefox history. Pass auto to automatically pick the default profile")
parser.set_defaults(comments=False)
parser.set_defaults(dnt=False)
args = parser.parse_args()

@ -1,23 +0,0 @@
from mixpanel import Mixpanel
class Analytics:
# Setup analytics.
# dnt - do not track. Disables tracking if True
# token - The mixpanel token
def __init__(self, dnt, token):
self.dnt = dnt
self.tracker = Mixpanel(token)
if(self.dnt == True):
print "[+] Analytics disabled"
# Track an event
# event - string containing the event name
# data - data related to the event, defaults to {}
def track(self, event, data = {}):
if(self.dnt == False):
# All events are tracked anonymously
self.tracker.track("anonymous", event, data)
# Track a visit to a URL
# The url maybe an HN submission or
# some meta-url pertaining to hackertray
def visit(self, url):
self.track('visit', {"link":url})

@ -9,8 +9,7 @@
#=========================
# We require PyGTK
import gtk
import gobject
from gi.repository import Gtk,GLib
# We also need os and sys
import os
@ -35,13 +34,14 @@ def get_icon_filename(icon_name):
# The main class
class Indicator:
# Constructor
def __init__(self, unknown, icon, category):
# Store the settings
self.inactive_icon = get_icon_filename(icon)
self.active_icon = "" # Blank until the user calls set_attention_icon
# Create the status icon
self.icon = gtk.StatusIcon()
self.icon = Gtk.StatusIcon()
# Initialize to the default icon
self.icon.set_from_file(self.inactive_icon)
@ -80,7 +80,7 @@ class Indicator:
def show_menu(self, widget):
# Show the menu
self.menu.popup(None, None, None, 0, 0)
self.menu.popup(None, None, None, 0, 0, Gtk.get_current_event_time())
# Get the location and size of the window
mouse_rect = self.menu.get_window().get_frame_extents()
@ -91,7 +91,7 @@ class Indicator:
self.bottom = self.y + mouse_rect.height
# Set a timer to poll the menu
self.timer = gobject.timeout_add(100, self.check_mouse)
self.timer = GLib.timeout_add(100, self.check_mouse)
def check_mouse(self):
if not self.menu.get_window().is_visible():
@ -100,9 +100,9 @@ class Indicator:
# Now check the global mouse coords
root = self.menu.get_screen().get_root_window()
x, y, z = root.get_pointer()
_,x,y,_ = root.get_pointer()
if x < self.x or x > self.right or y < self.y or y > self.bottom:
if (x < (self.x-10)) or (x > self.right) or (y < (self.y+10)) or (y > self.bottom):
self.hide_menu()
else:
return True

@ -1,11 +1,13 @@
from __future__ import print_function
import sqlite3
import shutil
import os
import sys
class Chrome:
HISTORY_TMP_LOCATION = '/tmp/hackertray.chrome'
@staticmethod
def search(urls, config_folder_path):
Chrome.setup(config_folder_path)
@ -13,13 +15,14 @@ class Chrome:
db = conn.cursor()
result = []
for url in urls:
db_result = db.execute('SELECT url from urls WHERE url=:url',{"url":url})
db_result = db.execute('SELECT url from urls WHERE url=:url', {"url": url})
if(db.fetchone() == None):
result.append(False)
else:
result.append(True)
os.remove(Chrome.HISTORY_TMP_LOCATION)
return result
@staticmethod
def setup(config_folder_path):
file_name = os.path.abspath(config_folder_path+'/History')

@ -1,12 +1,33 @@
from __future__ import print_function
import sqlite3
import shutil
import os
import sys
from pathlib import Path
import configparser
class Firefox:
HISTORY_TMP_LOCATION = '/tmp/hackertray.firefox'
HISTORY_FILE_NAME = '/places.sqlite'
@staticmethod
def default_firefox_profile_path():
profile_file_path = str(Path.home().joinpath(".mozilla/firefox/profiles.ini"))
profile_path = None
if (os.path.exists(profile_file_path)):
parser = configparser.ConfigParser()
parser.read(profile_file_path)
for section in parser.sections():
if parser.has_option(section,"Default") and parser[section]["Default"] == "1":
if parser.has_option(section,"IsRelative") and parser[section]["IsRelative"] == "1":
profile_path = str(Path.home().joinpath(".mozilla/firefox/").joinpath(parser[section]["Path"]))
else:
profile_path = parser[section]["Path"]
if profile_path and Path.is_dir(Path(profile_path)):
return profile_path
else:
raise RuntimeError("Couldn't find default Firefox profile")
@staticmethod
def search(urls, config_folder_path):
Firefox.setup(config_folder_path)
@ -14,17 +35,18 @@ class Firefox:
db = conn.cursor()
result = []
for url in urls:
db_result = db.execute('SELECT url from moz_places WHERE url=:url',{"url":url})
db_result = db.execute('SELECT url from moz_places WHERE url=:url', {"url": url})
if(db.fetchone() == None):
result.append(False)
else:
result.append(True)
os.remove(Firefox.HISTORY_TMP_LOCATION)
return result
@staticmethod
def setup(config_folder_path):
file_name = os.path.abspath(config_folder_path+Firefox.HISTORY_FILE_NAME)
file_name = os.path.abspath(config_folder_path + Firefox.HISTORY_FILE_NAME)
if not os.path.isfile(file_name):
print("ERROR: ", "Could not find Firefox history file", file=sys.stderr)
print("ERROR: Could not find Firefox history file, using %s" % file_name)
sys.exit(1)
shutil.copyfile(file_name, Firefox.HISTORY_TMP_LOCATION)
shutil.copyfile(file_name, Firefox.HISTORY_TMP_LOCATION)

@ -7,6 +7,7 @@ urls = [
class HackerNews:
@staticmethod
def getHomePage():
random.shuffle(urls)
@ -15,4 +16,4 @@ class HackerNews:
try:
return r.json()
except ValueError:
continue
continue

@ -1,8 +1,10 @@
import requests
import pkg_resources
class Version:
PYPI_URL = "https://pypi.python.org/pypi/hackertray/json"
@staticmethod
def latest():
res = requests.get(Version.PYPI_URL).json()
@ -14,14 +16,14 @@ class Version:
@staticmethod
def new_available():
latest = Version.latest()
latest = Version.latest()
current = Version.current()
try:
if pkg_resources.parse_version(latest) > pkg_resources.parse_version(current):
print "[+] New version " + latest + " is available"
print("[+] New version " + latest + " is available")
return True
else:
return False
except requests.exceptions.RequestException as e:
print "[+] There was an error in trying to fetch updates"
return False
print("[+] There was an error in trying to fetch updates")
return False

@ -3,18 +3,15 @@ import sys
from setuptools import setup
from setuptools import find_packages
requirements = ['requests']
if sys.version_info < (2, 7):
requirements.append('argparse')
setup(name='hackertray',
version='3.0.0',
version='4.0.0',
description='Hacker News app that sits in your System Tray',
long_description='HackerTray is a simple Hacker News Linux application that lets you view top HN stories in your System Tray. It relies on appindicator, so it is not guaranteed to work on all systems. It also provides a Gtk StatusIcon fallback in case AppIndicator is not available.',
long_description='HackerTray is a simple Hacker News Linux application that lets you view top HN stories in your System Tray. It supports appindicator and falls back to Gtk StatusIcon otherwise.',
keywords='hacker news hn tray system tray icon hackertray',
url='http://captnemo.in/hackertray',
author='Abhay Rana',
url='https://captnemo.in/hackertray',
author='Abhay Rana (Nemo)',
author_email='me@captnemo.in',
license='MIT',
packages=find_packages(),
@ -22,8 +19,7 @@ setup(name='hackertray',
'hackertray.data': ['hacker-tray.png']
},
install_requires=[
'requests>=2.2.1',
'mixpanel-py>=3.0.0'
'requests>=2.23.0'
],
entry_points={
'console_scripts': ['hackertray = hackertray:main'],

@ -1,10 +1,12 @@
import unittest
import os
import pathlib
from pathlib import Path
from hackertray import Firefox
class ChromeTest(unittest.TestCase):
def runTest(self):
class FirefoxTest(unittest.TestCase):
def test_history(self):
config_folder_path = os.getcwd()+'/test/'
data = Firefox.search([
"http://www.hckrnews.com/",
@ -12,4 +14,19 @@ class ChromeTest(unittest.TestCase):
"http://wiki.ubuntu.com/",
"http://invalid_url/"],
config_folder_path)
self.assertTrue(data == [True,True,True,False])
self.assertTrue(data == [True,True,True,False])
def test_default(self):
test_default_path = Path.home().joinpath(".mozilla/firefox/x0ran0o9.default")
if(os.environ.get('TRAVIS') == 'true'):
if not os.path.exists(str(test_default_path)):
os.makedirs(str(test_default_path))
with open(str(Path.home().joinpath('.mozilla/firefox/profiles.ini')), 'w') as f:
f.write("""
[Profile1]
Name=default
IsRelative=1
Path=x0ran0o9.default
Default=1
""")
self.assertTrue(Firefox.default_firefox_profile_path()==str(Path.home().joinpath(".mozilla/firefox/x0ran0o9.default")))

Loading…
Cancel
Save