Browse Source

fixed conflicts (LOL merge -s ours; push -f)

master
John ShaggyTwoDope Jenkins 5 years ago
parent
commit
ad2c2c2b1c
49 changed files with 2129 additions and 0 deletions
  1. 17
    0
      rtv/PKGBUILD
  2. BIN
      rtv/pkg/rtv/.MTREE
  3. 27
    0
      rtv/pkg/rtv/.PKGINFO
  4. 10
    0
      rtv/pkg/rtv/usr/bin/rtv
  5. 149
    0
      rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv-1.2-py3.4.egg-info/PKG-INFO
  6. 23
    0
      rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv-1.2-py3.4.egg-info/SOURCES.txt
  7. 1
    0
      rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv-1.2-py3.4.egg-info/dependency_links.txt
  8. 3
    0
      rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv-1.2-py3.4.egg-info/entry_points.txt
  9. 3
    0
      rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv-1.2-py3.4.egg-info/requires.txt
  10. 1
    0
      rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv-1.2-py3.4.egg-info/top_level.txt
  11. 6
    0
      rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/__init__.py
  12. 133
    0
      rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/__main__.py
  13. BIN
      rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/__pycache__/__init__.cpython-34.pyc
  14. BIN
      rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/__pycache__/__init__.cpython-34.pyo
  15. BIN
      rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/__pycache__/__main__.cpython-34.pyc
  16. BIN
      rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/__pycache__/__main__.cpython-34.pyo
  17. BIN
      rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/__pycache__/__version__.cpython-34.pyc
  18. BIN
      rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/__pycache__/__version__.cpython-34.pyo
  19. BIN
      rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/__pycache__/config.cpython-34.pyc
  20. BIN
      rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/__pycache__/config.cpython-34.pyo
  21. BIN
      rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/__pycache__/content.cpython-34.pyc
  22. BIN
      rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/__pycache__/content.cpython-34.pyo
  23. BIN
      rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/__pycache__/curses_helpers.cpython-34.pyc
  24. BIN
      rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/__pycache__/curses_helpers.cpython-34.pyo
  25. BIN
      rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/__pycache__/docs.cpython-34.pyc
  26. BIN
      rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/__pycache__/docs.cpython-34.pyo
  27. BIN
      rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/__pycache__/exceptions.cpython-34.pyc
  28. BIN
      rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/__pycache__/exceptions.cpython-34.pyo
  29. BIN
      rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/__pycache__/helpers.cpython-34.pyc
  30. BIN
      rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/__pycache__/helpers.cpython-34.pyo
  31. BIN
      rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/__pycache__/page.cpython-34.pyc
  32. BIN
      rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/__pycache__/page.cpython-34.pyo
  33. BIN
      rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/__pycache__/submission.cpython-34.pyc
  34. BIN
      rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/__pycache__/submission.cpython-34.pyo
  35. BIN
      rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/__pycache__/subreddit.cpython-34.pyc
  36. BIN
      rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/__pycache__/subreddit.cpython-34.pyo
  37. 1
    0
      rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/__version__.py
  38. 5
    0
      rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/config.py
  39. 322
    0
      rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/content.py
  40. 289
    0
      rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/curses_helpers.py
  41. 68
    0
      rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/docs.py
  42. 31
    0
      rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/exceptions.py
  43. 164
    0
      rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/helpers.py
  44. 399
    0
      rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/page.py
  45. 268
    0
      rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/submission.py
  46. 209
    0
      rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/subreddit.py
  47. BIN
      rtv/rtv-1.2-1-any.pkg.tar.xz
  48. BIN
      rtv/rtv-1.2-1.src.tar.gz
  49. BIN
      rtv/v1.2.tar.gz

+ 17
- 0
rtv/PKGBUILD View File

@@ -0,0 +1,17 @@
# Maintainer: John Jenkins twodopeshaggy@gmail.com

pkgname=rtv
pkgver=1.2
pkgrel=1
pkgdesc="Browse Reddit from your terminal"
arch=('any')
url="https://github.com/michael-lazar/rtv"
license=('MIT')
depends=('ncurses' 'python' 'python-six' 'python-requests' 'python-praw' 'python-setuptools')
source=(https://github.com/michael-lazar/rtv/archive/v$pkgver.tar.gz)
md5sums=('b67388a428ae06e45e8522b0f56037b1')

package() {
cd "$srcdir/$pkgname-$pkgver"
python setup.py install --root="$pkgdir/" --optimize=1
}

BIN
rtv/pkg/rtv/.MTREE View File


+ 27
- 0
rtv/pkg/rtv/.PKGINFO View File

@@ -0,0 +1,27 @@
# Generated by makepkg 4.2.1
# using fakeroot version 1.20.2
# Mon Apr 6 02:40:49 UTC 2015
pkgname = rtv
pkgver = 1.2-1
pkgdesc = Browse Reddit from your terminal
url = https://github.com/michael-lazar/rtv
builddate = 1428288049
packager = Unknown Packager
size = 217088
arch = any
license = MIT
depend = ncurses
depend = python
depend = python-six
depend = python-requests
depend = python-praw
depend = python-setuptools
makepkgopt = strip
makepkgopt = docs
makepkgopt = !libtool
makepkgopt = !staticlibs
makepkgopt = emptydirs
makepkgopt = zipman
makepkgopt = purge
makepkgopt = !upx
makepkgopt = !debug

+ 10
- 0
rtv/pkg/rtv/usr/bin/rtv View File

@@ -0,0 +1,10 @@
#!/bin/python
# EASY-INSTALL-ENTRY-SCRIPT: 'rtv==1.2','console_scripts','rtv'
__requires__ = 'rtv==1.2'
import sys
from pkg_resources import load_entry_point

if __name__ == '__main__':
sys.exit(
load_entry_point('rtv==1.2', 'console_scripts', 'rtv')()
)

+ 149
- 0
rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv-1.2-py3.4.egg-info/PKG-INFO View File

@@ -0,0 +1,149 @@
Metadata-Version: 1.1
Name: rtv
Version: 1.2
Summary: A simple terminal viewer for Reddit (Reddit Terminal Viewer)
Home-page: https://github.com/michael-lazar/rtv
Author: Michael Lazar
Author-email: lazar.michael22@gmail.com
License: MIT
Description: .. image:: https://pypip.in/version/rtv/badge.svg?text=version&style=flat
:target: https://pypi.python.org/pypi/rtv/
:alt: Latest Version
.. image:: https://pypip.in/py_versions/rtv/badge.svg?style=flat
:target: https://pypi.python.org/pypi/rtv/
:alt: Supported Python versions
======================
Reddit Terminal Viewer
======================
Browse Reddit from your terminal
.. image:: http://i.imgur.com/W1hxqCt.png
RTV is built in **python** using the **curses** library, and is compatible with *most* terminal emulators on Linux and OS X.
-------------
Update (v1.1)
-------------
Users can now post comments!
.. image:: http://i.imgur.com/twls7iM.png
------------
Installation
------------
Install using pip
.. code-block:: bash
$ sudo pip install rtv
Or clone the repository
.. code-block:: bash
$ git clone https://github.com/michael-lazar/rtv.git
$ cd rtv
$ sudo python setup.py install
The installation will place a script in the system path
.. code-block:: bash
$ rtv
$ rtv --help
-----
Usage
-----
RTV currently supports browsing both subreddits and individual submissions. In each mode the controls are slightly different.
**Global Commands**
:``▲``/``▼`` or ``j``/``k``: Scroll to the prev/next item
:``a``/``z``: Upvote/downvote the selected item
:``ENTER`` or ``o``: Open the selected item in the default web browser
:``r``: Refresh the current page
:``u``: Login and logout of your user account
:``?``: Show the help screen
:``q``: Quit
**Subreddit Mode**
In subreddit mode you can browse through the top submissions on either the front page or a specific subreddit.
:``►`` or ``l``: View comments for the selected submission
:``/``: Open a prompt to switch subreddits
:``f``: Open a prompt to search the current subreddit
:``p``: Post a new submission to the current subreddit
The ``/`` prompt accepts subreddits in the following formats
* ``/r/python``
* ``/r/python/new``
* ``/r/python+linux`` supports multireddits
* ``/r/front`` will redirect to the front page
* ``/r/me`` will display your submissions
**Submission Mode**
In submission mode you can view the self text for a submission and browse comments.
:``◄`` or ``h``: Return to subreddit mode
:``►`` or ``l``: Fold the selected comment, or load additional comments
:``c``: Post a new comment on the selected item
-------------
Configuration
-------------
RTV will read a configuration file located at ``$XDG_CONFIG_HOME/rtv/rtv.cfg`` or ``~/.config/rtv/rtv.cfg`` if ``$XDG_CONFIG_HOME`` is not set.
This can be used to avoid having to re-enter login credentials every time the program is launched.
Each line in the file will replace the corresponding default argument in the launch script.
Example config:
.. code-block:: ini
[rtv]
username=MyUsername
password=MySecretPassword
# Log file location
log=/tmp/rtv.log
# Default subreddit
subreddit=CollegeBasketball
# Default submission link - will be opened every time the program starts
# link=http://www.reddit.com/r/CollegeBasketball/comments/31irjq
# Enable unicode characters (experimental)
# This is known to be unstable with east asian wide character sets
# unicode=true
RTV allows users to compose comments and replys using their preferred text editor (**vi**, **nano**, **gedit**, etc).
Set the environment variable ``RTV_EDITOR`` to specify which editor the program should use.
.. code-block:: bash
$ export RTV_EDITOR=gedit
Keywords: reddit terminal praw curses
Platform: UNKNOWN
Classifier: Intended Audience :: End Users/Desktop
Classifier: Environment :: Console :: Curses
Classifier: Operating System :: MacOS :: MacOS X
Classifier: Operating System :: POSIX
Classifier: Natural Language :: English
Classifier: Programming Language :: Python :: 2.7
Classifier: Programming Language :: Python :: 3.4
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Terminals
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content :: Message Boards
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content :: News/Diary

+ 23
- 0
rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv-1.2-py3.4.egg-info/SOURCES.txt View File

@@ -0,0 +1,23 @@
MANIFEST.in
README.rst
setup.cfg
setup.py
version.py
rtv/__init__.py
rtv/__main__.py
rtv/__version__.py
rtv/config.py
rtv/content.py
rtv/curses_helpers.py
rtv/docs.py
rtv/exceptions.py
rtv/helpers.py
rtv/page.py
rtv/submission.py
rtv/subreddit.py
rtv.egg-info/PKG-INFO
rtv.egg-info/SOURCES.txt
rtv.egg-info/dependency_links.txt
rtv.egg-info/entry_points.txt
rtv.egg-info/requires.txt
rtv.egg-info/top_level.txt

+ 1
- 0
rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv-1.2-py3.4.egg-info/dependency_links.txt View File

@@ -0,0 +1 @@


+ 3
- 0
rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv-1.2-py3.4.egg-info/entry_points.txt View File

@@ -0,0 +1,3 @@
[console_scripts]
rtv = rtv.__main__:main


+ 3
- 0
rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv-1.2-py3.4.egg-info/requires.txt View File

@@ -0,0 +1,3 @@
praw>=2.1.6
six
requests

+ 1
- 0
rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv-1.2-py3.4.egg-info/top_level.txt View File

@@ -0,0 +1 @@
rtv

+ 6
- 0
rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/__init__.py View File

@@ -0,0 +1,6 @@
from .__version__ import __version__

__title__ = 'Reddit Terminal Viewer'
__author__ = 'Michael Lazar'
__license__ = 'The MIT License (MIT)'
__copyright__ = '(c) 2015 Michael Lazar'

+ 133
- 0
rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/__main__.py View File

@@ -0,0 +1,133 @@
import os
import sys
import argparse
import locale
import logging

import requests
import praw
import praw.errors
from six.moves import configparser

from . import config
from .exceptions import SubmissionError, SubredditError, ProgramError
from .curses_helpers import curses_session
from .submission import SubmissionPage
from .subreddit import SubredditPage
from .docs import *
from .__version__ import __version__

__all__ = []


def load_config():
"""
Search for a configuration file at the location ~/.rtv and attempt to load
saved settings for things like the username and password.
"""

config = configparser.ConfigParser()

HOME = os.path.expanduser('~')
XDG_CONFIG_HOME = os.getenv('XDG_CONFIG_HOME', os.path.join(HOME, '.config'))
config_paths = [
os.path.join(XDG_CONFIG_HOME, 'rtv', 'rtv.cfg'),
os.path.join(HOME, '.rtv')
]

# read only the first existing config file
for config_path in config_paths:
if os.path.exists(config_path):
config.read(config_path)
break

defaults = {}
if config.has_section('rtv'):
defaults = dict(config.items('rtv'))

if 'unicode' in defaults:
defaults['unicode'] = config.getboolean('rtv', 'unicode')

return defaults


def command_line():

parser = argparse.ArgumentParser(
prog='rtv', description=SUMMARY,
epilog=CONTROLS + HELP,
formatter_class=argparse.RawDescriptionHelpFormatter)

parser.add_argument('-s', dest='subreddit', help='subreddit name')
parser.add_argument('-l', dest='link', help='full link to a submission')
parser.add_argument('--unicode', action='store_true',
help='enable unicode (experimental)')
parser.add_argument('--log', metavar='FILE', action='store',
help='Log HTTP requests')

group = parser.add_argument_group('authentication (optional)', AUTH)
group.add_argument('-u', dest='username', help='reddit username')
group.add_argument('-p', dest='password', help='reddit password')

args = parser.parse_args()

return args


def main():
"Main entry point"

# logging.basicConfig(level=logging.DEBUG, filename='rtv.log')
locale.setlocale(locale.LC_ALL, '')

args = command_line()
local_config = load_config()

# set the terminal title
title = 'rtv {0}'.format(__version__)
if os.name == 'nt':
os.system('title {0}'.format(title))
else:
sys.stdout.write("\x1b]2;{0}\x07".format(title))

# Fill in empty arguments with config file values. Paramaters explicitly
# typed on the command line will take priority over config file params.
for key, val in local_config.items():
if getattr(args, key, None) is None:
setattr(args, key, val)

config.unicode = args.unicode

if args.log:
logging.basicConfig(level=logging.DEBUG, filename=args.log)

try:
print('Connecting...')
reddit = praw.Reddit(user_agent=AGENT)
reddit.config.decode_html_entities = True
if args.username:
# PRAW will prompt for password if it is None
reddit.login(args.username, args.password)
with curses_session() as stdscr:
if args.link:
page = SubmissionPage(stdscr, reddit, url=args.link)
page.loop()
page = SubredditPage(stdscr, reddit, args.subreddit)
page.loop()
except praw.errors.InvalidUserPass:
print('Invalid password for username: {}'.format(args.username))
except requests.ConnectionError:
print('Connection timeout')
except requests.HTTPError:
print('HTTP Error: 404 Not Found')
except SubmissionError as e:
print('Could not reach submission URL: {}'.format(e.url))
except SubredditError as e:
print('Could not reach subreddit: {}'.format(e.name))
except ProgramError as e:
print('Error: could not open file with program "{}", '
'try setting RTV_EDITOR'.format(e.name))
except KeyboardInterrupt:
return

sys.exit(main())

BIN
rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/__pycache__/__init__.cpython-34.pyc View File


BIN
rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/__pycache__/__init__.cpython-34.pyo View File


BIN
rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/__pycache__/__main__.cpython-34.pyc View File


BIN
rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/__pycache__/__main__.cpython-34.pyo View File


BIN
rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/__pycache__/__version__.cpython-34.pyc View File


BIN
rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/__pycache__/__version__.cpython-34.pyo View File


BIN
rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/__pycache__/config.cpython-34.pyc View File


BIN
rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/__pycache__/config.cpython-34.pyo View File


BIN
rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/__pycache__/content.cpython-34.pyc View File


BIN
rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/__pycache__/content.cpython-34.pyo View File


BIN
rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/__pycache__/curses_helpers.cpython-34.pyc View File


BIN
rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/__pycache__/curses_helpers.cpython-34.pyo View File


BIN
rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/__pycache__/docs.cpython-34.pyc View File


BIN
rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/__pycache__/docs.cpython-34.pyo View File


BIN
rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/__pycache__/exceptions.cpython-34.pyc View File


BIN
rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/__pycache__/exceptions.cpython-34.pyo View File


BIN
rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/__pycache__/helpers.cpython-34.pyc View File


BIN
rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/__pycache__/helpers.cpython-34.pyo View File


BIN
rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/__pycache__/page.cpython-34.pyc View File


BIN
rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/__pycache__/page.cpython-34.pyo View File


BIN
rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/__pycache__/submission.cpython-34.pyc View File


BIN
rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/__pycache__/submission.cpython-34.pyo View File


BIN
rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/__pycache__/subreddit.cpython-34.pyc View File


BIN
rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/__pycache__/subreddit.cpython-34.pyo View File


+ 1
- 0
rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/__version__.py View File

@@ -0,0 +1 @@
__version__ = '1.2'

+ 5
- 0
rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/config.py View File

@@ -0,0 +1,5 @@
"""
Global configuration settings
"""

unicode = False

+ 322
- 0
rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/content.py View File

@@ -0,0 +1,322 @@
import textwrap

import praw
import requests

from .exceptions import SubmissionError, SubredditError, AccountError
from .helpers import humanize_timestamp, wrap_text, strip_subreddit_url

__all__ = ['SubredditContent', 'SubmissionContent']


class BaseContent(object):

def get(self, index, n_cols):
raise NotImplementedError

def iterate(self, index, step, n_cols):

while True:
if step < 0 and index < 0:
# Hack to prevent displaying negative indicies if iterating in
# the negative direction.
break
try:
yield self.get(index, n_cols=n_cols)
except IndexError:
break
index += step

@staticmethod
def flatten_comments(comments, root_level=0):
"""
Flatten a PRAW comment tree while preserving the nested level of each
comment via the `nested_level` attribute.
"""

stack = comments[:]
for item in stack:
item.nested_level = root_level

retval = []
while stack:
item = stack.pop(0)
if isinstance(item, praw.objects.MoreComments) and (
item.count == 0):
continue
nested = getattr(item, 'replies', None)
if nested:
for n in nested:
n.nested_level = item.nested_level + 1
stack[0:0] = nested
retval.append(item)
return retval

@staticmethod
def strip_praw_comment(comment):
"""
Parse through a submission comment and return a dict with data ready to
be displayed through the terminal.
"""

data = {}
data['object'] = comment
data['level'] = comment.nested_level

if isinstance(comment, praw.objects.MoreComments):
data['type'] = 'MoreComments'
data['count'] = comment.count
data['body'] = 'More comments'.format(comment.count)
else:
data['type'] = 'Comment'
data['body'] = comment.body
data['created'] = humanize_timestamp(comment.created_utc)
data['score'] = '{} pts'.format(comment.score)
author = getattr(comment, 'author')
data['author'] = (author.name if author else '[deleted]')
sub_author = getattr(comment.submission.author, 'name')
data['is_author'] = (data['author'] == sub_author)
flair = comment.author_flair_text
data['flair'] = (flair if flair else '')
data['likes'] = comment.likes
data['gold'] = comment.gilded > 0

return data

@staticmethod
def strip_praw_submission(sub):
"""
Parse through a submission and return a dict with data ready to be
displayed through the terminal.
"""

is_selfpost = lambda s: s.startswith('http://www.reddit.com/r/')

data = {}
data['object'] = sub
data['type'] = 'Submission'
data['title'] = sub.title
data['text'] = sub.selftext
data['created'] = humanize_timestamp(sub.created_utc)
data['comments'] = '{} comments'.format(sub.num_comments)
data['score'] = '{} pts'.format(sub.score)
author = getattr(sub, 'author')
data['author'] = (author.name if author else '[deleted]')
data['permalink'] = sub.permalink
data['subreddit'] = strip_subreddit_url(sub.permalink)
data['flair'] = (sub.link_flair_text if sub.link_flair_text else '')
data['url_full'] = sub.url
data['url'] = ('selfpost' if is_selfpost(sub.url) else sub.url)
data['likes'] = sub.likes
data['gold'] = sub.gilded > 0

return data


class SubmissionContent(BaseContent):
"""
Grab a submission from PRAW and lazily store comments to an internal
list for repeat access.
"""

def __init__(self, submission, loader, indent_size=2, max_indent_level=4):

self.indent_size = indent_size
self.max_indent_level = max_indent_level
self._loader = loader
self._submission = submission

self._submission_data = self.strip_praw_submission(self._submission)
self.name = self._submission_data['permalink']
comments = self.flatten_comments(self._submission.comments)
self._comment_data = [self.strip_praw_comment(c) for c in comments]

@classmethod
def from_url(cls, reddit, url, loader, indent_size=2, max_indent_level=4):

try:
with loader():
submission = reddit.get_submission(url, comment_sort='hot')
except praw.errors.APIException:
raise SubmissionError(url)

return cls(submission, loader, indent_size, max_indent_level)

def get(self, index, n_cols=70):
"""
Grab the `i`th submission, with the title field formatted to fit inside
of a window of width `n`
"""

if index < -1:
raise IndexError

elif index == -1:
data = self._submission_data
data['split_title'] = textwrap.wrap(data['title'], width=n_cols -2)
data['split_text'] = wrap_text(data['text'], width=n_cols - 2)
data['n_rows'] = len(data['split_title'] + data['split_text']) + 5
data['offset'] = 0

else:
data = self._comment_data[index]
indent_level = min(data['level'], self.max_indent_level)
data['offset'] = indent_level * self.indent_size

if data['type'] == 'Comment':
width = n_cols - data['offset']
data['split_body'] = wrap_text(data['body'], width=width)
data['n_rows'] = len(data['split_body']) + 1
else:
data['n_rows'] = 1

return data

def toggle(self, index, n_cols=70):
"""
Toggle the state of the object at the given index.

If it is a comment, pack it into a hidden comment.
If it is a hidden comment, unpack it.
If it is more comments, load the comments.
"""
data = self.get(index)

if data['type'] == 'Submission':
# Can't hide the submission!
pass

elif data['type'] == 'Comment':
cache = [data]
count = 1
for d in self.iterate(index + 1, 1, n_cols):
if d['level'] <= data['level']:
break

count += d.get('count', 1)
cache.append(d)

comment = {}
comment['type'] = 'HiddenComment'
comment['cache'] = cache
comment['count'] = count
comment['level'] = data['level']
comment['body'] = 'Hidden'.format(count)
self._comment_data[index:index + len(cache)] = [comment]

elif data['type'] == 'HiddenComment':
self._comment_data[index:index + 1] = data['cache']

elif data['type'] == 'MoreComments':
with self._loader():
comments = data['object'].comments(update=False)
comments = self.flatten_comments(comments,
root_level=data['level'])
comment_data = [self.strip_praw_comment(c) for c in comments]
self._comment_data[index:index + 1] = comment_data

else:
raise ValueError('% type not recognized' % data['type'])


class SubredditContent(BaseContent):
"""
Grab a subreddit from PRAW and lazily stores submissions to an internal
list for repeat access.
"""

def __init__(self, name, submissions, loader):

self.name = name
self._loader = loader
self._submissions = submissions
self._submission_data = []

# Verify that content exists for the given submission generator.
# This is necessary because PRAW loads submissions lazily, and
# there is is no other way to check things like multireddits that
# don't have a real corresponding subreddit object.
try:
self.get(0)
except (praw.errors.APIException, requests.HTTPError,
praw.errors.RedirectException):
raise SubredditError(display_name)

@classmethod
def from_name(cls, reddit, name, loader, order='hot', query=None):

if order not in ['hot', 'top', 'rising', 'new', 'controversial']:
raise SubredditError(display_name)

name = name.strip(' /') # Strip leading and trailing backslashes
if name.startswith('r/'):
name = name[2:]

# Grab the display order e.g. "python/new"
if '/' in name:
name, order = name.split('/')

display_name = display_name = '/r/{}'.format(name)
if order != 'hot':
display_name += '/{}'.format(order)

if name == 'me':
if not reddit.is_logged_in():
raise AccountError
else:
submissions = reddit.user.get_submitted(sort=order)

elif query:
if name == 'front':
submissions = reddit.search(query, subreddit=None, sort=order)
else:
submissions = reddit.search(query, subreddit=name, sort=order)

else:
if name == 'front':
dispatch = {
'hot': reddit.get_front_page,
'top': reddit.get_top,
'rising': reddit.get_rising,
'new': reddit.get_new,
'controversial': reddit.get_controversial,
}
else:
subreddit = reddit.get_subreddit(name)
dispatch = {
'hot': subreddit.get_hot,
'top': subreddit.get_top,
'rising': subreddit.get_rising,
'new': subreddit.get_new,
'controversial': subreddit.get_controversial,
}
submissions = dispatch[order](limit=None)

return cls(display_name, submissions, loader)

def get(self, index, n_cols=70):
"""
Grab the `i`th submission, with the title field formatted to fit inside
of a window of width `n_cols`
"""

if index < 0:
raise IndexError

while index >= len(self._submission_data):
try:
with self._loader():
submission = next(self._submissions)
except StopIteration:
raise IndexError
else:
data = self.strip_praw_submission(submission)
self._submission_data.append(data)

# Modifies the original dict, faster than copying
data = self._submission_data[index]
data['split_title'] = textwrap.wrap(data['title'], width=n_cols)
data['n_rows'] = len(data['split_title']) + 3
data['offset'] = 0

return data

+ 289
- 0
rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/curses_helpers.py View File

@@ -0,0 +1,289 @@
import os
import time
import threading
import curses
from curses import textpad, ascii
from contextlib import contextmanager

from .docs import HELP
from .helpers import strip_textpad
from .exceptions import EscapeInterrupt

__all__ = ['ESCAPE', 'UARROW', 'DARROW', 'BULLET', 'show_notification',
'show_help', 'LoadScreen', 'Color', 'text_input', 'curses_session']

ESCAPE = 27

# Curses does define constants for these (e.g. curses.ACS_BULLET)
# However, they rely on using the curses.addch() function, which has been
# found to be buggy and a PITA to work with. By defining them as unicode
# points they can be added via the more reliable curses.addstr().
# http://bugs.python.org/issue21088
UARROW = u'\u25b2'.encode('utf-8')
DARROW = u'\u25bc'.encode('utf-8')
BULLET = u'\u2022'.encode('utf-8')
GOLD = u'\u272A'.encode('utf-8')


def show_notification(stdscr, message):
"""
Overlay a message box on the center of the screen and wait for user input.

Params:
message (list): List of strings, one per line.
"""

n_rows, n_cols = stdscr.getmaxyx()

box_width = max(map(len, message)) + 2
box_height = len(message) + 2

# Cut off the lines of the message that don't fit on the screen
box_width = min(box_width, n_cols)
box_height = min(box_height, n_rows)
message = message[:box_height-2]

s_row = (n_rows - box_height) // 2
s_col = (n_cols - box_width) // 2

window = stdscr.derwin(box_height, box_width, s_row, s_col)
window.erase()
window.border()

for index, line in enumerate(message, start=1):
window.addnstr(index, 1, line, box_width - 2)
window.refresh()
ch = stdscr.getch()

window.clear()
window = None
stdscr.refresh()

return ch


def show_help(stdscr):
"""
Overlay a message box with the help screen.
"""

show_notification(stdscr, HELP.splitlines())


class LoadScreen(object):

"""
Display a loading dialog while waiting for a blocking action to complete.

This class spins off a seperate thread to animate the loading screen in the
background.

Usage:
#>>> loader = LoadScreen(stdscr)
#>>> with loader(...):
#>>> blocking_request(...)
"""

def __init__(self, stdscr):

self._stdscr = stdscr

self._args = None
self._animator = None
self._is_running = None

def __call__(
self,
delay=0.5,
interval=0.4,
message='Downloading',
trail='...'):
"""
Params:
delay (float): Length of time that the loader will wait before
printing on the screen. Used to prevent flicker on pages that
load very fast.
interval (float): Length of time between each animation frame.
message (str): Message to display
trail (str): Trail of characters that will be animated by the
loading screen.
"""

self._args = (delay, interval, message, trail)
return self

def __enter__(self):

self._animator = threading.Thread(target=self.animate, args=self._args)
self._animator.daemon = True

self._is_running = True
self._animator.start()

def __exit__(self, exc_type, exc_val, exc_tb):

self._is_running = False
self._animator.join()

def animate(self, delay, interval, message, trail):

start = time.time()
while (time.time() - start) < delay:
if not self._is_running:
return

message_len = len(message) + len(trail)
n_rows, n_cols = self._stdscr.getmaxyx()
s_row = (n_rows - 3) // 2
s_col = (n_cols - message_len - 1) // 2
window = self._stdscr.derwin(3, message_len + 2, s_row, s_col)

while True:
for i in range(len(trail) + 1):

if not self._is_running:
window.clear()
window = None
self._stdscr.refresh()
return

window.erase()
window.border()
window.addstr(1, 1, message + trail[:i])
window.refresh()
time.sleep(interval)


class Color(object):

"""
Color attributes for curses.
"""

_colors = {
'RED': (curses.COLOR_RED, -1),
'GREEN': (curses.COLOR_GREEN, -1),
'YELLOW': (curses.COLOR_YELLOW, -1),
'BLUE': (curses.COLOR_BLUE, -1),
'MAGENTA': (curses.COLOR_MAGENTA, -1),
'CYAN': (curses.COLOR_CYAN, -1),
'WHITE': (curses.COLOR_WHITE, -1),
}

@classmethod
def init(cls):
"""
Initialize color pairs inside of curses using the default background.

This should be called once during the curses initial setup. Afterwards,
curses color pairs can be accessed directly through class attributes.
"""

# Assign the terminal's default (background) color to code -1
curses.use_default_colors()

for index, (attr, code) in enumerate(cls._colors.items(), start=1):
curses.init_pair(index, code[0], code[1])
setattr(cls, attr, curses.color_pair(index))

@classmethod
def get_level(cls, level):

levels = [cls.MAGENTA, cls.CYAN, cls.GREEN, cls.YELLOW]
return levels[level % len(levels)]


def text_input(window, allow_resize=True):
"""
Transform a window into a text box that will accept user input and loop
until an escape sequence is entered.

If enter is pressed, return the input text as a string.
If escape is pressed, return None.
"""

window.clear()

# Set cursor mode to 1 because 2 doesn't display on some terminals
curses.curs_set(1)

# Turn insert_mode off to avoid the recursion error described here
# http://bugs.python.org/issue13051
textbox = textpad.Textbox(window, insert_mode=False)
textbox.stripspaces = 0

def validate(ch):
"Filters characters for special key sequences"
if ch == ESCAPE:
raise EscapeInterrupt
if (not allow_resize) and (ch == curses.KEY_RESIZE):
raise EscapeInterrupt
# Fix backspace for iterm
if ch == ascii.DEL:
ch = curses.KEY_BACKSPACE
return ch

# Wrapping in an exception block so that we can distinguish when the user
# hits the return character from when the user tries to back out of the
# input.
try:
out = textbox.edit(validate=validate)
except EscapeInterrupt:
out = None

curses.curs_set(0)
return strip_textpad(out)


@contextmanager
def curses_session():
"""
Setup terminal and initialize curses.
"""

try:
# Curses must wait for some time after the Escape key is pressed to
# check if it is the beginning of an escape sequence indicating a
# special key. The default wait time is 1 second, which means that
# getch() will not return the escape key (27) until a full second
# after it has been pressed.
# Turn this down to 25 ms, which is close to what VIM uses.
# http://stackoverflow.com/questions/27372068
os.environ['ESCDELAY'] = '25'

# Initialize curses
stdscr = curses.initscr()

# Turn off echoing of keys, and enter cbreak mode,
# where no buffering is performed on keyboard input
curses.noecho()
curses.cbreak()

# In keypad mode, escape sequences for special keys
# (like the cursor keys) will be interpreted and
# a special value like curses.KEY_LEFT will be returned
stdscr.keypad(1)

# Start color, too. Harmless if the terminal doesn't have
# color; user can test with has_color() later on. The try/catch
# works around a minor bit of over-conscientiousness in the curses
# module -- the error return from C start_color() is ignorable.
try:
curses.start_color()
except:
pass

Color.init()

# Hide blinking cursor
curses.curs_set(0)

yield stdscr

finally:

if stdscr is not None:
stdscr.keypad(0)
curses.echo()
curses.nocbreak()
curses.endwin()

+ 68
- 0
rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/docs.py View File

@@ -0,0 +1,68 @@
from .__version__ import __version__

__all__ = ['AGENT', 'SUMMARY', 'AUTH', 'CONTROLS', 'HELP', 'COMMENT_FILE',
'SUBMISSION_FILE']

AGENT = """\
desktop:https://github.com/michael-lazar/rtv:{} (by /u/civilization_phaze_3)\
""".format(__version__)

SUMMARY = """
Reddit Terminal Viewer is a lightweight browser for www.reddit.com built into a
terminal window.
"""

AUTH = """\
Authenticating is required to vote and leave comments. If only a username is
given, the program will display a secure prompt to enter a password.
"""

CONTROLS = """
Controls
--------
RTV currently supports browsing both subreddits and individual submissions.
In each mode the controls are slightly different. In subreddit mode you can
browse through the top submissions on either the front page or a specific
subreddit. In submission mode you can view the self text for a submission and
browse comments.
"""

HELP = """
Global Commands
`UP/DOWN` or `j/k` : Scroll to the prev/next item
`a/z` : Upvote/downvote the selected item
`ENTER` or `o` : Open the selected item in the default web browser
`r` : Refresh the current page
`u` : Login/logout of your user account
`?` : Show this help message
`q` : Quit the program

Subreddit Mode
`RIGHT` or `l` : View comments for the selected submission
`/` : Open a prompt to switch subreddits
`f` : Open a prompt to search the current subreddit
`p` : Post a new submission to the current subreddit

Submission Mode
`LEFT` or `h` : Return to subreddit mode
`RIGHT` or `l` : Fold the selected comment, or load additional comments
`c` : Post a new comment on the selected item
"""

COMMENT_FILE = """
# Please enter a comment. Lines starting with '#' will be ignored,
# and an empty message aborts the comment.
#
# Replying to {author}'s {type}
{content}
"""

SUBMISSION_FILE = """
# Please enter your submission. Lines starting with '#' will be ignored,
# and an empty field aborts the submission.
#
# The first line will be interpreted as the title
# The following lines will be interpreted as the content
#
# Posting to /r/{name}
"""

+ 31
- 0
rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/exceptions.py View File

@@ -0,0 +1,31 @@
class EscapeInterrupt(Exception):
"Signal that the ESC key has been pressed"


class RTVError(Exception):
"Base RTV error class"


class AccountError(RTVError):
"Could not access user account"


class SubmissionError(RTVError):
"Submission could not be loaded"

def __init__(self, url):
self.url = url


class SubredditError(RTVError):
"Subreddit could not be reached"

def __init__(self, name):
self.name = name


class ProgramError(RTVError):
"Problem executing an external program"

def __init__(self, name):
self.name = name

+ 164
- 0
rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/helpers.py View File

@@ -0,0 +1,164 @@
import sys
import os
import textwrap
import subprocess
from datetime import datetime
from tempfile import NamedTemporaryFile

from . import config
from .exceptions import ProgramError

__all__ = ['open_browser', 'clean', 'wrap_text', 'strip_textpad',
'strip_subreddit_url', 'humanize_timestamp', 'open_editor']


def open_editor(data=''):
"""
Open a temporary file using the system's default editor.

The data string will be written to the file before opening. This function
will block until the editor has closed. At that point the file will be
read and and lines starting with '#' will be stripped.
"""

with NamedTemporaryFile(prefix='rtv-', suffix='.txt', mode='w') as fp:
fp.write(data)
fp.flush()
editor = os.getenv('RTV_EDITOR') or os.getenv('EDITOR') or 'nano'

try:
subprocess.Popen([editor, fp.name]).wait()
except OSError as e:
raise ProgramError(editor)

# Open a second file object to read. This appears to be necessary in
# order to read the changes made by some editors (gedit). w+ mode does
# not work!
with open(fp.name) as fp2:
text = ''.join(line for line in fp2 if not line.startswith('#'))
text = text.rstrip()

return text


def open_browser(url):
"""
Call webbrowser.open_new_tab(url) and redirect stdout/stderr to devnull.

This is a workaround to stop firefox from spewing warning messages to the
console. See http://bugs.python.org/issue22277 for a better description
of the problem.
"""
command = "import webbrowser; webbrowser.open_new_tab('%s')" % url
args = [sys.executable, '-c', command]
with open(os.devnull, 'ab+', 0) as null:
subprocess.check_call(args, stdout=null, stderr=null)


def clean(string):
"""
Required reading!
http://nedbatchelder.com/text/unipain.html

Python 2 input string will be a unicode type (unicode code points). Curses
will accept unicode if all of the points are in the ascii range. However, if
any of the code points are not valid ascii curses will throw a
UnicodeEncodeError: 'ascii' codec can't encode character, ordinal not in
range(128). If we encode the unicode to a utf-8 byte string and pass that to
curses, it will render correctly.

Python 3 input string will be a string type (unicode code points). Curses
will accept that in all cases. However, the n character count in addnstr
will not be correct. If code points are passed to addnstr, curses will treat
each code point as one character and will not account for wide characters.
If utf-8 is passed in, addnstr will treat each 'byte' as a single character.
"""

encoding = 'utf-8' if config.unicode else 'ascii'
string = string.encode(encoding, 'replace')
return string


def wrap_text(text, width):
"""
Wrap text paragraphs to the given character width while preserving newlines.
"""
out = []
for paragraph in text.splitlines():
# Wrap returns an empty list when paragraph is a newline. In order to
# preserve newlines we substitute a list containing an empty string.
lines = textwrap.wrap(paragraph, width=width) or ['']
out.extend(lines)
return out


def strip_textpad(text):
"""
Attempt to intelligently strip excess whitespace from the output of a
curses textpad.
"""

if text is None:
return text

# Trivial case where the textbox is only one line long.
if '\n' not in text:
return text.rstrip()

# Allow one space at the end of the line. If there is more than one space,
# assume that a newline operation was intended by the user
stack, current_line = [], ''
for line in text.split('\n'):
if line.endswith(' '):
stack.append(current_line + line.rstrip())
current_line = ''
else:
current_line += line
stack.append(current_line)

# Prune empty lines at the bottom of the textbox.
for item in stack[::-1]:
if len(item) == 0:
stack.pop()
else:
break

out = '\n'.join(stack)
return out


def strip_subreddit_url(permalink):
"""
Strip a subreddit name from the subreddit's permalink.

This is used to avoid submission.subreddit.url making a seperate API call.
"""

subreddit = permalink.split('/')[4]
return '/r/{}'.format(subreddit)


def humanize_timestamp(utc_timestamp, verbose=False):
"""
Convert a utc timestamp into a human readable relative-time.
"""

timedelta = datetime.utcnow() - datetime.utcfromtimestamp(utc_timestamp)

seconds = int(timedelta.total_seconds())
if seconds < 60:
return 'moments ago' if verbose else '0min'
minutes = seconds // 60
if minutes < 60:
return ('%d minutes ago' % minutes) if verbose else ('%dmin' % minutes)
hours = minutes // 60
if hours < 24:
return ('%d hours ago' % hours) if verbose else ('%dhr' % hours)
days = hours // 24
if days < 30:
return ('%d days ago' % days) if verbose else ('%dday' % days)
months = days // 30.4
if months < 12:
return ('%d months ago' % months) if verbose else ('%dmonth' % months)
years = months // 12
return ('%d years ago' % years) if verbose else ('%dyr' % years)

+ 399
- 0
rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/page.py View File

@@ -0,0 +1,399 @@
import curses
import six
import sys

import praw.errors

from .helpers import clean
from .curses_helpers import Color, show_notification, show_help, text_input
from .docs import AGENT

__all__ = ['Navigator']


class Navigator(object):
"""
Handles math behind cursor movement and screen paging.
"""

def __init__(
self,
valid_page_cb,
page_index=0,
cursor_index=0,
inverted=False):

self.page_index = page_index
self.cursor_index = cursor_index
self.inverted = inverted
self._page_cb = valid_page_cb
self._header_window = None
self._content_window = None

@property
def step(self):
return 1 if not self.inverted else -1

@property
def position(self):
return (self.page_index, self.cursor_index, self.inverted)

@property
def absolute_index(self):
return self.page_index + (self.step * self.cursor_index)

def move(self, direction, n_windows):
"Move the cursor down (positive direction) or up (negative direction)"

valid, redraw = True, False

forward = ((direction * self.step) > 0)

if forward:
if self.page_index < 0:
if self._is_valid(0):
# Special case - advance the page index if less than zero
self.page_index = 0
self.cursor_index = 0
redraw = True
else:
valid = False
else:
self.cursor_index += 1
if not self._is_valid(self.absolute_index):
# Move would take us out of bounds
self.cursor_index -= 1
valid = False
elif self.cursor_index >= (n_windows - 1):
# Flip the orientation and reset the cursor
self.flip(self.cursor_index)
self.cursor_index = 0
redraw = True
else:
if self.cursor_index > 0:
self.cursor_index -= 1
else:
self.page_index -= self.step
if self._is_valid(self.absolute_index):
# We have reached the beginning of the page - move the
# index
redraw = True
else:
self.page_index += self.step
valid = False # Revert

return valid, redraw

def flip(self, n_windows):
"Flip the orientation of the page"

self.page_index += (self.step * n_windows)
self.cursor_index = n_windows
self.inverted = not self.inverted

def _is_valid(self, page_index):
"Check if a page index will cause entries to fall outside valid range"

try:
self._page_cb(page_index)
except IndexError:
return False
else:
return True


class BaseController(object):
"""
Event handler for triggering functions with curses keypresses.

Register a keystroke to a class method using the @egister decorator.
#>>> @Controller.register('a', 'A')
#>>> def func(self, *args)

Register a default behavior by using `None`.
#>>> @Controller.register(None)
#>>> def default_func(self, *args)

Bind the controller to a class instance and trigger a key. Additional
arguments will be passed to the function.
#>>> controller = Controller(self)
#>>> controller.trigger('a', *args)
"""

character_map = {None: (lambda *args, **kwargs: None)}

def __init__(self, instance):
self.instance = instance

def trigger(self, char, *args, **kwargs):

if isinstance(char, six.string_types) and len(char) == 1:
char = ord(char)

func = self.character_map.get(char)
if func is None:
func = BaseController.character_map.get(char)
if func is None:
func = self.character_map.get(None)
if func is None:
func = BaseController.character_map.get(None)
return func(self.instance, *args, **kwargs)

@classmethod
def register(cls, *chars):
def wrap(f):
for char in chars:
if isinstance(char, six.string_types) and len(char) == 1:
cls.character_map[ord(char)] = f
else:
cls.character_map[char] = f
return f
return wrap


class BasePage(object):
"""
Base terminal viewer incorperates a cursor to navigate content
"""

MIN_HEIGHT = 10
MIN_WIDTH = 20

def __init__(self, stdscr, reddit, content, **kwargs):

self.stdscr = stdscr
self.reddit = reddit
self.content = content
self.nav = Navigator(self.content.get, **kwargs)

self._header_window = None
self._content_window = None
self._subwindows = None

@BaseController.register('q')
def exit(self):
sys.exit()

@BaseController.register('?')
def help(self):
show_help(self._content_window)

@BaseController.register(curses.KEY_UP, 'k')
def move_cursor_up(self):
self._move_cursor(-1)
self.clear_input_queue()

@BaseController.register(curses.KEY_DOWN, 'j')
def move_cursor_down(self):
self._move_cursor(1)
self.clear_input_queue()

def clear_input_queue(self):
"Clear excessive input caused by the scroll wheel or holding down a key"

self.stdscr.nodelay(1)
while self.stdscr.getch() != -1:
continue
self.stdscr.nodelay(0)

@BaseController.register('a')
def upvote(self):
data = self.content.get(self.nav.absolute_index)
try:
if 'likes' not in data:
pass
elif data['likes']:
data['object'].clear_vote()
data['likes'] = None
else:
data['object'].upvote()
data['likes'] = True
except praw.errors.LoginOrScopeRequired:
show_notification(self.stdscr, ['Not logged in'])

@BaseController.register('z')
def downvote(self):
data = self.content.get(self.nav.absolute_index)
try:
if 'likes' not in data:
pass
if data['likes'] is False:
data['object'].clear_vote()
data['likes'] = None
else:
data['object'].downvote()
data['likes'] = False
except praw.errors.LoginOrScopeRequired:
show_notification(self.stdscr, ['Not logged in'])

@BaseController.register('u')
def login(self):
"""
Prompt to log into the user's account, or log out of the current
account.
"""

if self.reddit.is_logged_in():
self.logout()
return

username = self.prompt_input('Enter username:')
password = self.prompt_input('Enter password:', hide=True)
if not username or not password:
curses.flash()
return

try:
with self.loader():
self.reddit.login(username, password)
except praw.errors.InvalidUserPass:
show_notification(self.stdscr, ['Invalid user/pass'])
else:
show_notification(self.stdscr, ['Welcome {}'.format(username)])

def logout(self):
"Prompt to log out of the user's account."

ch = self.prompt_input("Log out? (y/n):")
if ch == 'y':
self.reddit.clear_authentication()
show_notification(self.stdscr, ['Logged out'])
elif ch != 'n':
curses.flash()

def prompt_input(self, prompt, hide=False):
"Prompt the user for input"

attr = curses.A_BOLD | Color.CYAN
n_rows, n_cols = self.stdscr.getmaxyx()

if hide:
prompt += ' ' * (n_cols - len(prompt) - 1)
self.stdscr.addstr(n_rows-1, 0, prompt, attr)
out = self.stdscr.getstr(n_rows-1, 1)
else:
self.stdscr.addstr(n_rows - 1, 0, prompt, attr)
self.stdscr.refresh()
window = self.stdscr.derwin(1, n_cols - len(prompt),
n_rows - 1, len(prompt))
window.attrset(attr)
out = text_input(window)

return out

def draw(self):

n_rows, n_cols = self.stdscr.getmaxyx()
if n_rows < self.MIN_HEIGHT or n_cols < self.MIN_WIDTH:
return

# Note: 2 argument form of derwin breaks PDcurses on Windows 7!
self._header_window = self.stdscr.derwin(1, n_cols, 0, 0)
self._content_window = self.stdscr.derwin(n_rows - 1, n_cols, 1, 0)

self.stdscr.erase()
self._draw_header()
self._draw_content()
self._add_cursor()

@staticmethod
def draw_item(window, data, inverted):
raise NotImplementedError

def _draw_header(self):

n_rows, n_cols = self._header_window.getmaxyx()

self._header_window.erase()
attr = curses.A_REVERSE | curses.A_BOLD | Color.CYAN
self._header_window.bkgd(' ', attr)

sub_name = self.content.name.replace('/r/front', 'Front Page ')
self._header_window.addnstr(0, 0, clean(sub_name), n_cols - 1)

if self.reddit.user is not None:
username = self.reddit.user.name
s_col = (n_cols - len(username) - 1)
# Only print the username if it fits in the empty space on the
# right
if (s_col - 1) >= len(sub_name):
n = (n_cols - s_col - 1)
self._header_window.addnstr(0, s_col, clean(username), n)

self._header_window.refresh()

def _draw_content(self):
"""
Loop through submissions and fill up the content page.
"""

n_rows, n_cols = self._content_window.getmaxyx()
self._content_window.erase()
self._subwindows = []

page_index, cursor_index, inverted = self.nav.position
step = self.nav.step

# If not inverted, align the first submission with the top and draw
# downwards. If inverted, align the first submission with the bottom
# and draw upwards.
current_row = (n_rows - 1) if inverted else 0
available_rows = (n_rows - 1) if inverted else n_rows
for data in self.content.iterate(page_index, step, n_cols - 2):
window_rows = min(available_rows, data['n_rows'])
window_cols = n_cols - data['offset']
start = current_row - window_rows if inverted else current_row
subwindow = self._content_window.derwin(
window_rows, window_cols, start, data['offset'])
attr = self.draw_item(subwindow, data, inverted)
self._subwindows.append((subwindow, attr))
available_rows -= (window_rows + 1) # Add one for the blank line
current_row += step * (window_rows + 1)
if available_rows <= 0:
break
else:
# If the page is not full we need to make sure that it is NOT
# inverted. Unfortunately, this currently means drawing the whole
# page over again. Could not think of a better way to pre-determine
# if the content will fill up the page, given that it is dependent
# on the size of the terminal.
if self.nav.inverted:
self.nav.flip((len(self._subwindows) - 1))
self._draw_content()

self._content_window.refresh()

def _add_cursor(self):
self._edit_cursor(curses.A_REVERSE)

def _remove_cursor(self):
self._edit_cursor(curses.A_NORMAL)

def _move_cursor(self, direction):

self._remove_cursor()

valid, redraw = self.nav.move(direction, len(self._subwindows))
if not valid:
curses.flash()

# Note: ACS_VLINE doesn't like changing the attribute, so always redraw.
# if redraw: self._draw_content()
self._draw_content()
self._add_cursor()

def _edit_cursor(self, attribute=None):

# Don't allow the cursor to go below page index 0
if self.nav.absolute_index < 0:
return

window, attr = self._subwindows[self.nav.cursor_index]
if attr is not None:
attribute |= attr

n_rows, _ = window.getmaxyx()
for row in range(n_rows):
window.chgat(row, 0, 1, attribute)

window.refresh()

+ 268
- 0
rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/submission.py View File

@@ -0,0 +1,268 @@
import curses
import sys
import time
import logging

import praw.errors

from .content import SubmissionContent
from .page import BasePage, Navigator, BaseController
from .helpers import clean, open_browser, open_editor
from .curses_helpers import (BULLET, UARROW, DARROW, GOLD, Color, LoadScreen,
show_notification, text_input)
from .docs import COMMENT_FILE

__all__ = ['SubmissionController', 'SubmissionPage']

_logger = logging.getLogger(__name__)

class SubmissionController(BaseController):
character_map = {}


class SubmissionPage(BasePage):

def __init__(self, stdscr, reddit, url=None, submission=None):

self.controller = SubmissionController(self)
self.loader = LoadScreen(stdscr)
if url:
content = SubmissionContent.from_url(reddit, url, self.loader)
elif submission:
content = SubmissionContent(submission, self.loader)
else:
raise ValueError('Must specify url or submission')

super(SubmissionPage, self).__init__(stdscr, reddit,
content, page_index=-1)

def loop(self):
"Main control loop"

self.active = True
while self.active:
self.draw()
cmd = self.stdscr.getch()
self.controller.trigger(cmd)

@SubmissionController.register(curses.KEY_RIGHT, 'l')
def toggle_comment(self):
"Toggle the selected comment tree between visible and hidden"

current_index = self.nav.absolute_index
self.content.toggle(current_index)
if self.nav.inverted:
# Reset the page so that the bottom is at the cursor position.
# This is a workaround to handle if folding the causes the
# cursor index to go out of bounds.
self.nav.page_index, self.nav.cursor_index = current_index, 0

@SubmissionController.register(curses.KEY_LEFT, 'h')
def exit_submission(self):
"Close the submission and return to the subreddit page"

self.active = False

@SubmissionController.register(curses.KEY_F5, 'r')
def refresh_content(self):
"Re-download comments reset the page index"

self.content = SubmissionContent.from_url(
self.reddit,
self.content.name,
self.loader)
self.nav = Navigator(self.content.get, page_index=-1)

@SubmissionController.register(curses.KEY_ENTER, 10, 'o')
def open_link(self):
"Open the current submission page with the webbrowser"

# May want to expand at some point to open comment permalinks
url = self.content.get(-1)['permalink']
open_browser(url)

@SubmissionController.register('c')
def add_comment(self):
"""
Add a top-level comment if the submission is selected, or reply to the
selected comment.
"""

if not self.reddit.is_logged_in():
show_notification(self.stdscr, ['Login to post'])
return

data = self.content.get(self.nav.absolute_index)
if data['type'] == 'Submission':
content = data['text']
elif data['type'] == 'Comment':
content = data['body']
else:
curses.flash()
return

# Comment out every line of the content
content = '\n'.join(['# |' + line for line in content.split('\n')])
comment_info = COMMENT_FILE.format(
author=data['author'],
type=data['type'].lower(),
content=content)

curses.endwin()
comment_text = open_editor(comment_info)
curses.doupdate()
if not comment_text:
show_notification(self.stdscr, ['Comment canceled'])
return

try:
if data['type'] == 'Submission':
data['object'].add_comment(comment_text)
else:
data['object'].reply(comment_text)
except praw.errors.APIException:
message = ['Error: {}'.format(e.error_type), e.message]
show_notification(self.stdscr, message)
_logger.exception(e)
except requests.HTTPError as e:
show_notification(self.stdscr, ['Unexpected Error'])
_logger.exception(e)
else:
with self.loader(delay=0, message='Posting'):
time.sleep(2.0)
self.refresh_content()

def draw_item(self, win, data, inverted=False):

if data['type'] == 'MoreComments':
return self.draw_more_comments(win, data)
elif data['type'] == 'HiddenComment':
return self.draw_more_comments(win, data)
elif data['type'] == 'Comment':
return self.draw_comment(win, data, inverted=inverted)
else:
return self.draw_submission(win, data)

@staticmethod
def draw_comment(win, data, inverted=False):

n_rows, n_cols = win.getmaxyx()
n_cols -= 1

# Handle the case where the window is not large enough to fit the text.
valid_rows = range(0, n_rows)
offset = 0 if not inverted else -(data['n_rows'] - n_rows)

row = offset
if row in valid_rows:

text = clean(u'{author} '.format(**data))
attr = curses.A_BOLD
attr |= (Color.BLUE if not data['is_author'] else Color.GREEN)
win.addnstr(row, 1, text, n_cols - 1, attr)

if data['flair']:
text = clean(u'{flair} '.format(**data))
attr = curses.A_BOLD | Color.YELLOW
win.addnstr(text, n_cols - win.getyx()[1], attr)

if data['likes'] is None:
text, attr = BULLET, curses.A_BOLD
elif data['likes']:
text, attr = UARROW, (curses.A_BOLD | Color.GREEN)
else:
text, attr = DARROW, (curses.A_BOLD | Color.RED)
win.addnstr(text, n_cols - win.getyx()[1], attr)

text = clean(u' {score} {created} '.format(**data))
win.addnstr(text, n_cols - win.getyx()[1])

if data['gold']:
text, attr = GOLD, (curses.A_BOLD | Color.YELLOW)
win.addnstr(text, n_cols - win.getyx()[1], attr)

n_body = len(data['split_body'])
for row, text in enumerate(data['split_body'], start=offset + 1):
if row in valid_rows:
text = clean(text)
win.addnstr(row, 1, text, n_cols - 1)

# Unfortunately vline() doesn't support custom color so we have to
# build it one segment at a time.
attr = Color.get_level(data['level'])
for y in range(n_rows):
x = 0
# http://bugs.python.org/issue21088
if (sys.version_info.major,
sys.version_info.minor,
sys.version_info.micro) == (3, 4, 0):
x, y = y, x

win.addch(y, x, curses.ACS_VLINE, attr)

return (attr | curses.ACS_VLINE)

@staticmethod
def draw_more_comments(win, data):

n_rows, n_cols = win.getmaxyx()
n_cols -= 1

text = clean(u'{body}'.format(**data))
win.addnstr(0, 1, text, n_cols - 1)
text = clean(u' [{count}]'.format(**data))
win.addnstr(text, n_cols - win.getyx()[1], curses.A_BOLD)

# Unfortunately vline() doesn't support custom color so we have to
# build it one segment at a time.
attr = Color.get_level(data['level'])
win.addch(0, 0, curses.ACS_VLINE, attr)

return (attr | curses.ACS_VLINE)

@staticmethod
def draw_submission(win, data):

n_rows, n_cols = win.getmaxyx()
n_cols -= 3 # one for each side of the border + one for offset

for row, text in enumerate(data['split_title'], start=1):
text = clean(text)
win.addnstr(row, 1, text, n_cols, curses.A_BOLD)

row = len(data['split_title']) + 1
attr = curses.A_BOLD | Color.GREEN
text = clean(u'{author}'.format(**data))
win.addnstr(row, 1, text, n_cols, attr)
attr = curses.A_BOLD | Color.YELLOW
text = clean(u' {flair}'.format(**data))
win.addnstr(text, n_cols - win.getyx()[1], attr)
text = clean(u' {created} {subreddit}'.format(**data))
win.addnstr(text, n_cols - win.getyx()[1])

row = len(data['split_title']) + 2
attr = curses.A_UNDERLINE | Color.BLUE
text = clean(u'{url}'.format(**data))
win.addnstr(row, 1, text, n_cols, attr)
offset = len(data['split_title']) + 3

# Cut off text if there is not enough room to display the whole post
split_text = data['split_text']
if data['n_rows'] > n_rows:
cutoff = data['n_rows'] - n_rows + 1
split_text = split_text[:-cutoff]
split_text.append('(Not enough space to display)')

for row, text in enumerate(split_text, start=offset):
text = clean(text)
win.addnstr(row, 1, text, n_cols)

row = len(data['split_title']) + len(split_text) + 3
text = clean(u'{score} {comments} '.format(**data))
win.addnstr(row, 1, text, n_cols, curses.A_BOLD)

if data['gold']:
text, attr = GOLD, (curses.A_BOLD | Color.YELLOW)
win.addnstr(text, n_cols - win.getyx()[1], attr)

win.border()

+ 209
- 0
rtv/pkg/rtv/usr/lib/python3.4/site-packages/rtv/subreddit.py View File

@@ -0,0 +1,209 @@
import curses
import time
import logging

import requests
import praw

from .exceptions import SubredditError, AccountError
from .page import BasePage, Navigator, BaseController
from .submission import SubmissionPage
from .content import SubredditContent
from .helpers import clean, open_browser, open_editor
from .docs import SUBMISSION_FILE
from .curses_helpers import (BULLET, UARROW, DARROW, GOLD, Color,
LoadScreen, show_notification)

__all__ = ['opened_links', 'SubredditController', 'SubredditPage']

_logger = logging.getLogger(__name__)

# Used to keep track of browsing history across the current session
opened_links = set()


class SubredditController(BaseController):
character_map = {}


class SubredditPage(BasePage):

def __init__(self, stdscr, reddit, name):

self.controller = SubredditController(self)
self.loader = LoadScreen(stdscr)

content = SubredditContent.from_name(reddit, name, self.loader)
super(SubredditPage, self).__init__(stdscr, reddit, content)

def loop(self):
"Main control loop"

while True:
self.draw()
cmd = self.stdscr.getch()
self.controller.trigger(cmd)

@SubredditController.register(curses.KEY_F5, 'r')
def refresh_content(self, name=None):
"Re-download all submissions and reset the page index"

name = name or self.content.name
try:
self.content = SubredditContent.from_name(
self.reddit, name, self.loader)
except AccountError:
show_notification(self.stdscr, ['Not logged in'])
except SubredditError:
show_notification(self.stdscr, ['Invalid subreddit'])
except requests.HTTPError:
show_notification(self.stdscr, ['Could not reach subreddit'])
else:
self.nav = Navigator(self.content.get)

@SubredditController.register('f')
def search_subreddit(self, name=None):
"Open a prompt to search the given subreddit"

name = name or self.content.name
prompt = 'Search {}:'.format(name)
query = self.prompt_input(prompt)
if query is None:
return

try:
self.content = SubredditContent.from_name(
self.reddit, name, self.loader, query=query)
except IndexError: # if there are no submissions
show_notification(self.stdscr, ['No results found'])
else:
self.nav = Navigator(self.content.get)

@SubredditController.register('/')
def prompt_subreddit(self):
"Open a prompt to navigate to a different subreddit"
prompt = 'Enter Subreddit: /r/'
name = self.prompt_input(prompt)
if name is not None:
self.refresh_content(name=name)

@SubredditController.register(curses.KEY_RIGHT, 'l')
def open_submission(self):
"Select the current submission to view posts"

data = self.content.get(self.nav.absolute_index)
page = SubmissionPage(self.stdscr, self.reddit, url=data['permalink'])
page.loop()

if data['url'] == 'selfpost':
global opened_links
opened_links.add(data['url_full'])

@SubredditController.register(curses.KEY_ENTER, 10, 'o')
def open_link(self):
"Open a link with the webbrowser"

url = self.content.get(self.nav.absolute_index)['url_full']
open_browser(url)

global opened_links
opened_links.add(url)

@SubredditController.register('p')
def post_submission(self):
"Post a new submission to the given subreddit"

if not self.reddit.is_logged_in():
show_notification(self.stdscr, ['Login to post'])
return

# Strips the subreddit to just the name
# Make sure it is a valid subreddit for submission
subreddit = self.reddit.get_subreddit(self.content.name)
sub = str(subreddit).split('/')[2]
if '+' in sub or sub in ('all', 'front', 'me'):
show_notification(self.stdscr, ['Invalid subreddit'])