first commit
|
@ -0,0 +1,5 @@
|
||||||
|
[run]
|
||||||
|
source = tvr
|
||||||
|
omit =
|
||||||
|
*/__main__.py
|
||||||
|
*/packages/praw/*
|
|
@ -0,0 +1 @@
|
||||||
|
tests/cassettes/* binary
|
|
@ -0,0 +1,14 @@
|
||||||
|
.*
|
||||||
|
!.travis.yml
|
||||||
|
!.pylintrc
|
||||||
|
!.gitignore
|
||||||
|
!.gitattributes
|
||||||
|
!.coveragerc
|
||||||
|
*~
|
||||||
|
*.pyc
|
||||||
|
*.log
|
||||||
|
build
|
||||||
|
dist
|
||||||
|
rtv.egg-info
|
||||||
|
tests/refresh-token
|
||||||
|
venv/
|
|
@ -0,0 +1,378 @@
|
||||||
|
[MASTER]
|
||||||
|
|
||||||
|
# Specify a configuration file.
|
||||||
|
#rcfile=
|
||||||
|
|
||||||
|
# Python code to execute, usually for sys.path manipulation such as
|
||||||
|
# pygtk.require().
|
||||||
|
#init-hook=
|
||||||
|
|
||||||
|
# Add files or directories to the blacklist. They should be base names, not
|
||||||
|
# paths.
|
||||||
|
ignore=praw
|
||||||
|
|
||||||
|
# Pickle collected data for later comparisons.
|
||||||
|
persistent=yes
|
||||||
|
|
||||||
|
# List of plugins (as comma separated values of python modules names) to load,
|
||||||
|
# usually to register additional checkers.
|
||||||
|
load-plugins=
|
||||||
|
|
||||||
|
# Use multiple processes to speed up Pylint.
|
||||||
|
jobs=1
|
||||||
|
|
||||||
|
# Allow loading of arbitrary C extensions. Extensions are imported into the
|
||||||
|
# active Python interpreter and may run arbitrary code.
|
||||||
|
unsafe-load-any-extension=no
|
||||||
|
|
||||||
|
# A comma-separated list of package or module names from where C extensions may
|
||||||
|
# be loaded. Extensions are loading into the active Python interpreter and may
|
||||||
|
# run arbitrary code
|
||||||
|
extension-pkg-whitelist=
|
||||||
|
|
||||||
|
# Allow optimization of some AST trees. This will activate a peephole AST
|
||||||
|
# optimizer, which will apply various small optimizations. For instance, it can
|
||||||
|
# be used to obtain the result of joining multiple strings with the addition
|
||||||
|
# operator. Joining a lot of strings can lead to a maximum recursion error in
|
||||||
|
# Pylint and this flag can prevent that. It has one side effect, the resulting
|
||||||
|
# AST will be different than the one from reality.
|
||||||
|
optimize-ast=no
|
||||||
|
|
||||||
|
|
||||||
|
[MESSAGES CONTROL]
|
||||||
|
|
||||||
|
# Only show warnings with the listed confidence levels. Leave empty to show
|
||||||
|
# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED
|
||||||
|
confidence=
|
||||||
|
|
||||||
|
# Enable the message, report, category or checker with the given id(s). You can
|
||||||
|
# either give multiple identifier separated by comma (,) or put this option
|
||||||
|
# multiple time. See also the "--disable" option for examples.
|
||||||
|
#enable=
|
||||||
|
|
||||||
|
# Disable the message, report, category or checker with the given id(s). You
|
||||||
|
# can either give multiple identifiers separated by comma (,) or put this
|
||||||
|
# option multiple times (only on the command line, not in the configuration
|
||||||
|
# file where it should appear only once).You can also use "--disable=all" to
|
||||||
|
# disable everything first and then reenable specific checks. For example, if
|
||||||
|
# you want to run only the similarities checker, you can use "--disable=all
|
||||||
|
# --enable=similarities". If you want to run only the classes checker, but have
|
||||||
|
# no Warning level messages displayed, use"--disable=all --enable=classes
|
||||||
|
# --disable=W"
|
||||||
|
disable=execfile-builtin,zip-builtin-not-iterating,range-builtin-not-iterating,hex-method,old-division,file-builtin,long-builtin,input-builtin,no-absolute-import,invalid-name,delslice-method,suppressed-message,coerce-builtin,buffer-builtin,import-star-module-level,round-builtin,old-ne-operator,apply-builtin,missing-final-newline,basestring-builtin,xrange-builtin,getslice-method,filter-builtin-not-iterating,map-builtin-not-iterating,raw_input-builtin,indexing-exception,dict-iter-method,metaclass-assignment,setslice-method,next-method-called,intern-builtin,using-cmp-argument,missing-docstring,oct-method,backtick,print-statement,reload-builtin,long-suffix,old-raise-syntax,unicode-builtin,nonzero-method,old-octal-literal,cmp-method,useless-suppression,dict-view-method,parameter-unpacking,unpacking-in-except,coerce-method,unichr-builtin,raising-string,cmp-builtin,reduce-builtin,standarderror-builtin,no-else-return,too-many-locals,too-many-statements,too-few-public-methods,too-many-public-methods,too-many-instance-attributes
|
||||||
|
|
||||||
|
|
||||||
|
[REPORTS]
|
||||||
|
|
||||||
|
# Set the output format. Available formats are text, parseable, colorized, msvs
|
||||||
|
# (visual studio) and html. You can also give a reporter class, eg
|
||||||
|
# mypackage.mymodule.MyReporterClass.
|
||||||
|
output-format=text
|
||||||
|
|
||||||
|
# Put messages in a separate file for each module / package specified on the
|
||||||
|
# command line instead of printing them on stdout. Reports (if any) will be
|
||||||
|
# written in a file name "pylint_global.[txt|html]".
|
||||||
|
files-output=no
|
||||||
|
|
||||||
|
# Tells whether to display a full report or only the messages
|
||||||
|
reports=yes
|
||||||
|
|
||||||
|
# Python expression which should return a note less than 10 (10 is the highest
|
||||||
|
# note). You have access to the variables errors warning, statement which
|
||||||
|
# respectively contain the number of errors / warnings messages and the total
|
||||||
|
# number of statements analyzed. This is used by the global evaluation report
|
||||||
|
# (RP0004).
|
||||||
|
evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)
|
||||||
|
|
||||||
|
# Template used to display messages. This is a python new-style format string
|
||||||
|
# used to format the message information. See doc for all details
|
||||||
|
#msg-template=
|
||||||
|
|
||||||
|
|
||||||
|
[SIMILARITIES]
|
||||||
|
|
||||||
|
# Minimum lines number of a similarity.
|
||||||
|
min-similarity-lines=5
|
||||||
|
|
||||||
|
# Ignore comments when computing similarities.
|
||||||
|
ignore-comments=yes
|
||||||
|
|
||||||
|
# Ignore docstrings when computing similarities.
|
||||||
|
ignore-docstrings=yes
|
||||||
|
|
||||||
|
# Ignore imports when computing similarities.
|
||||||
|
ignore-imports=no
|
||||||
|
|
||||||
|
|
||||||
|
[MISCELLANEOUS]
|
||||||
|
|
||||||
|
# List of note tags to take in consideration, separated by a comma.
|
||||||
|
notes=FIXME,XXX,TODO
|
||||||
|
|
||||||
|
|
||||||
|
[VARIABLES]
|
||||||
|
|
||||||
|
# Tells whether we should check for unused import in __init__ files.
|
||||||
|
init-import=no
|
||||||
|
|
||||||
|
# A regular expression matching the name of dummy variables (i.e. expectedly
|
||||||
|
# not used).
|
||||||
|
dummy-variables-rgx=_$|dummy
|
||||||
|
|
||||||
|
# List of additional names supposed to be defined in builtins. Remember that
|
||||||
|
# you should avoid to define new builtins when possible.
|
||||||
|
additional-builtins=
|
||||||
|
|
||||||
|
# List of strings which can identify a callback function by name. A callback
|
||||||
|
# name must start or end with one of those strings.
|
||||||
|
callbacks=cb_,_cb
|
||||||
|
|
||||||
|
|
||||||
|
[LOGGING]
|
||||||
|
|
||||||
|
# Logging modules to check that the string format arguments are in logging
|
||||||
|
# function parameter format
|
||||||
|
logging-modules=logging
|
||||||
|
|
||||||
|
|
||||||
|
[SPELLING]
|
||||||
|
|
||||||
|
# Spelling dictionary name. Available dictionaries: none. To make it working
|
||||||
|
# install python-enchant package.
|
||||||
|
spelling-dict=
|
||||||
|
|
||||||
|
# List of comma separated words that should not be checked.
|
||||||
|
spelling-ignore-words=
|
||||||
|
|
||||||
|
# A path to a file that contains private dictionary; one word per line.
|
||||||
|
spelling-private-dict-file=
|
||||||
|
|
||||||
|
# Tells whether to store unknown words to indicated private dictionary in
|
||||||
|
# --spelling-private-dict-file option instead of raising a message.
|
||||||
|
spelling-store-unknown-words=no
|
||||||
|
|
||||||
|
|
||||||
|
[TYPECHECK]
|
||||||
|
|
||||||
|
# Tells whether missing members accessed in mixin class should be ignored. A
|
||||||
|
# mixin class is detected if its name ends with "mixin" (case insensitive).
|
||||||
|
ignore-mixin-members=yes
|
||||||
|
|
||||||
|
# List of module names for which member attributes should not be checked
|
||||||
|
# (useful for modules/projects where namespaces are manipulated during runtime
|
||||||
|
# and thus existing member attributes cannot be deduced by static analysis. It
|
||||||
|
# supports qualified module names, as well as Unix pattern matching.
|
||||||
|
ignored-modules=
|
||||||
|
|
||||||
|
# List of classes names for which member attributes should not be checked
|
||||||
|
# (useful for classes with attributes dynamically set). This supports can work
|
||||||
|
# with qualified names.
|
||||||
|
ignored-classes=SQLObject
|
||||||
|
|
||||||
|
# List of members which are set dynamically and missed by pylint inference
|
||||||
|
# system, and so shouldn't trigger E1101 when accessed. Python regular
|
||||||
|
# expressions are accepted.
|
||||||
|
generated-members=REQUEST,acl_users,aq_parent
|
||||||
|
|
||||||
|
|
||||||
|
[FORMAT]
|
||||||
|
|
||||||
|
# Maximum number of characters on a single line.
|
||||||
|
max-line-length=100
|
||||||
|
|
||||||
|
# Regexp for a line that is allowed to be longer than the limit.
|
||||||
|
ignore-long-lines=^\s*(# )?<?https?://\S+>?$
|
||||||
|
|
||||||
|
# Allow the body of an if to be on the same line as the test if there is no
|
||||||
|
# else.
|
||||||
|
single-line-if-stmt=no
|
||||||
|
|
||||||
|
# List of optional constructs for which whitespace checking is disabled. `dict-
|
||||||
|
# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}.
|
||||||
|
# `trailing-comma` allows a space between comma and closing bracket: (a, ).
|
||||||
|
# `empty-line` allows space-only lines.
|
||||||
|
no-space-check=trailing-comma,dict-separator
|
||||||
|
|
||||||
|
# Maximum number of lines in a module
|
||||||
|
max-module-lines=1000
|
||||||
|
|
||||||
|
# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1
|
||||||
|
# tab).
|
||||||
|
indent-string=' '
|
||||||
|
|
||||||
|
# Number of spaces of indent required inside a hanging or continued line.
|
||||||
|
indent-after-paren=4
|
||||||
|
|
||||||
|
# Expected format of line ending, e.g. empty (any line ending), LF or CRLF.
|
||||||
|
expected-line-ending-format=
|
||||||
|
|
||||||
|
|
||||||
|
[BASIC]
|
||||||
|
|
||||||
|
# List of builtins function names that should not be used, separated by a comma
|
||||||
|
bad-functions=map,filter
|
||||||
|
|
||||||
|
# Good variable names which should always be accepted, separated by a comma
|
||||||
|
good-names=i,j,k,ex,Run,_
|
||||||
|
|
||||||
|
# Bad variable names which should always be refused, separated by a comma
|
||||||
|
bad-names=foo,bar,baz,toto,tutu,tata
|
||||||
|
|
||||||
|
# Colon-delimited sets of names that determine each other's naming style when
|
||||||
|
# the name regexes allow several styles.
|
||||||
|
name-group=
|
||||||
|
|
||||||
|
# Include a hint for the correct naming format with invalid-name
|
||||||
|
include-naming-hint=no
|
||||||
|
|
||||||
|
# Regular expression matching correct module names
|
||||||
|
module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$
|
||||||
|
|
||||||
|
# Naming hint for module names
|
||||||
|
module-name-hint=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$
|
||||||
|
|
||||||
|
# Regular expression matching correct variable names
|
||||||
|
variable-rgx=[a-z_][a-z0-9_]{2,30}$
|
||||||
|
|
||||||
|
# Naming hint for variable names
|
||||||
|
variable-name-hint=[a-z_][a-z0-9_]{2,30}$
|
||||||
|
|
||||||
|
# Regular expression matching correct class names
|
||||||
|
class-rgx=[A-Z_][a-zA-Z0-9]+$
|
||||||
|
|
||||||
|
# Naming hint for class names
|
||||||
|
class-name-hint=[A-Z_][a-zA-Z0-9]+$
|
||||||
|
|
||||||
|
# Regular expression matching correct attribute names
|
||||||
|
attr-rgx=[a-z_][a-z0-9_]{2,30}$
|
||||||
|
|
||||||
|
# Naming hint for attribute names
|
||||||
|
attr-name-hint=[a-z_][a-z0-9_]{2,30}$
|
||||||
|
|
||||||
|
# Regular expression matching correct constant names
|
||||||
|
const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$
|
||||||
|
|
||||||
|
# Naming hint for constant names
|
||||||
|
const-name-hint=(([A-Z_][A-Z0-9_]*)|(__.*__))$
|
||||||
|
|
||||||
|
# Regular expression matching correct class attribute names
|
||||||
|
class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$
|
||||||
|
|
||||||
|
# Naming hint for class attribute names
|
||||||
|
class-attribute-name-hint=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$
|
||||||
|
|
||||||
|
# Regular expression matching correct argument names
|
||||||
|
argument-rgx=[a-z_][a-z0-9_]{2,30}$
|
||||||
|
|
||||||
|
# Naming hint for argument names
|
||||||
|
argument-name-hint=[a-z_][a-z0-9_]{2,30}$
|
||||||
|
|
||||||
|
# Regular expression matching correct function names
|
||||||
|
function-rgx=[a-z_][a-z0-9_]{2,30}$
|
||||||
|
|
||||||
|
# Naming hint for function names
|
||||||
|
function-name-hint=[a-z_][a-z0-9_]{2,30}$
|
||||||
|
|
||||||
|
# Regular expression matching correct method names
|
||||||
|
method-rgx=[a-z_][a-z0-9_]{2,30}$
|
||||||
|
|
||||||
|
# Naming hint for method names
|
||||||
|
method-name-hint=[a-z_][a-z0-9_]{2,30}$
|
||||||
|
|
||||||
|
# Regular expression matching correct inline iteration names
|
||||||
|
inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$
|
||||||
|
|
||||||
|
# Naming hint for inline iteration names
|
||||||
|
inlinevar-name-hint=[A-Za-z_][A-Za-z0-9_]*$
|
||||||
|
|
||||||
|
# Regular expression which should only match function or class names that do
|
||||||
|
# not require a docstring.
|
||||||
|
no-docstring-rgx=__.*__
|
||||||
|
|
||||||
|
# Minimum line length for functions/classes that require docstrings, shorter
|
||||||
|
# ones are exempt.
|
||||||
|
docstring-min-length=-1
|
||||||
|
|
||||||
|
|
||||||
|
[ELIF]
|
||||||
|
|
||||||
|
# Maximum number of nested blocks for function / method body
|
||||||
|
max-nested-blocks=5
|
||||||
|
|
||||||
|
|
||||||
|
[IMPORTS]
|
||||||
|
|
||||||
|
# Deprecated modules which should not be used, separated by a comma
|
||||||
|
deprecated-modules=stringprep,optparse
|
||||||
|
|
||||||
|
# Create a graph of every (i.e. internal and external) dependencies in the
|
||||||
|
# given file (report RP0402 must not be disabled)
|
||||||
|
import-graph=
|
||||||
|
|
||||||
|
# Create a graph of external dependencies in the given file (report RP0402 must
|
||||||
|
# not be disabled)
|
||||||
|
ext-import-graph=
|
||||||
|
|
||||||
|
# Create a graph of internal dependencies in the given file (report RP0402 must
|
||||||
|
# not be disabled)
|
||||||
|
int-import-graph=
|
||||||
|
|
||||||
|
|
||||||
|
[CLASSES]
|
||||||
|
|
||||||
|
# List of method names used to declare (i.e. assign) instance attributes.
|
||||||
|
defining-attr-methods=__init__,__new__,setUp
|
||||||
|
|
||||||
|
# List of valid names for the first argument in a class method.
|
||||||
|
valid-classmethod-first-arg=cls
|
||||||
|
|
||||||
|
# List of valid names for the first argument in a metaclass class method.
|
||||||
|
valid-metaclass-classmethod-first-arg=mcs
|
||||||
|
|
||||||
|
# List of member names, which should be excluded from the protected access
|
||||||
|
# warning.
|
||||||
|
exclude-protected=_asdict,_fields,_replace,_source,_make
|
||||||
|
|
||||||
|
|
||||||
|
[DESIGN]
|
||||||
|
|
||||||
|
# Maximum number of arguments for function / method
|
||||||
|
max-args=7
|
||||||
|
|
||||||
|
# Argument names that match this expression will be ignored. Default to name
|
||||||
|
# with leading underscore
|
||||||
|
ignored-argument-names=_.*
|
||||||
|
|
||||||
|
# Maximum number of locals for function / method body
|
||||||
|
max-locals=15
|
||||||
|
|
||||||
|
# Maximum number of return / yield for function / method body
|
||||||
|
max-returns=6
|
||||||
|
|
||||||
|
# Maximum number of branch for function / method body
|
||||||
|
max-branches=12
|
||||||
|
|
||||||
|
# Maximum number of statements in function / method body
|
||||||
|
max-statements=50
|
||||||
|
|
||||||
|
# Maximum number of parents for a class (see R0901).
|
||||||
|
max-parents=7
|
||||||
|
|
||||||
|
# Maximum number of attributes for a class (see R0902).
|
||||||
|
max-attributes=7
|
||||||
|
|
||||||
|
# Minimum number of public methods for a class (see R0903).
|
||||||
|
min-public-methods=2
|
||||||
|
|
||||||
|
# Maximum number of public methods for a class (see R0904).
|
||||||
|
max-public-methods=20
|
||||||
|
|
||||||
|
# Maximum number of boolean expressions in a if statement
|
||||||
|
max-bool-expr=5
|
||||||
|
|
||||||
|
|
||||||
|
[EXCEPTIONS]
|
||||||
|
|
||||||
|
# Exceptions that will emit a warning when being caught. Defaults to
|
||||||
|
# "Exception"
|
||||||
|
overgeneral-exceptions=Exception
|
|
@ -0,0 +1,68 @@
|
||||||
|
================
|
||||||
|
TTRV Contributors
|
||||||
|
================
|
||||||
|
* `deepend <https://github.com/deepend-tildeclub>`_
|
||||||
|
|
||||||
|
|
||||||
|
================
|
||||||
|
RTV Contributors
|
||||||
|
================
|
||||||
|
|
||||||
|
Thanks to the following people for their contributions to this project.
|
||||||
|
|
||||||
|
* `Michael Lazar <https://github.com/michael-lazar>`_
|
||||||
|
* `Tobin Brown <https://github.com/Brobin>`_
|
||||||
|
* `woorst <https://github.com/woorst>`_
|
||||||
|
* `Théo Piboubès <https://github.com/ThePib>`_
|
||||||
|
* `Yusuke Sakamoto <https://github.com/yskmt>`_
|
||||||
|
* `Johnathan Jenkins <https://github.com/shaggytwodope>`_
|
||||||
|
* `tyjak <https://github.com/tyjak>`_
|
||||||
|
* `Edridge D'Souza <https://github.com/edridgedsouza>`_
|
||||||
|
* `Josue Ortega <https://github.com/noahfx>`_
|
||||||
|
* `mekhami <https://github.com/mekhami>`_
|
||||||
|
* `Nemanja Nedeljković <https://github.com/nemanjan00>`_
|
||||||
|
* `obosob <https://github.com/obosob>`_
|
||||||
|
* `codesoap <https://github.com/codesoap>`_
|
||||||
|
* `Toby Hughes <https://github.com/tobywhughes>`_
|
||||||
|
* `Noah Morrison <https://github.com/noahmorrison>`_
|
||||||
|
* `Mardigon Toler <https://github.com/mardigontoler>`_
|
||||||
|
* `5225225 <https://github.com/5225225>`_
|
||||||
|
* `Shawn Hind <https://github.com/shawnhind>`_
|
||||||
|
* `Antoine Nguyen <https://github.com/anhtuann>`_
|
||||||
|
* `JuanPablo <https://github.com/juanpabloaj>`_
|
||||||
|
* `Pablo Arias <https://github.com/pabloariasal>`_
|
||||||
|
* `Robert Greener <https://github.com/ragreener1>`_
|
||||||
|
* `mac1202 <https://github.com/mac1202>`_
|
||||||
|
* `Iqbal Singh <https://github.com/nagracks>`_
|
||||||
|
* `Lorenz Leitner <https://github.com/LoLei>`_
|
||||||
|
* `Markus Pettersson <https://github.com/MarkusPettersson98>`_
|
||||||
|
* `Reshef Elisha <https://github.com/ReshefElisha>`_
|
||||||
|
* `Ryan Reno <https://github.com/rreno>`_
|
||||||
|
* `Sam Tebbs <https://github.com/SamTebbs33>`_
|
||||||
|
* `Justin Partain <https://github.com/jupart>`_
|
||||||
|
* `afloofloo <https://github.com/afloofloo>`_
|
||||||
|
* `0xflotus <https://github.com/0xflotus>`_
|
||||||
|
* `Caleb Perkins <https://github.com/calebperkins>`_
|
||||||
|
* `Charles Saracco <https://github.com/crsaracco>`_
|
||||||
|
* `Corey McCandless <https://github.com/cmccandless>`_
|
||||||
|
* `Crestwave <https://github.com/Crestwave>`_
|
||||||
|
* `Danilo G. Baio <https://github.com/dbaio>`_
|
||||||
|
* `Donovan Glover <https://github.com/GloverDonovan>`_
|
||||||
|
* `Fabio Alessandro Locati <https://github.com/Fale>`_
|
||||||
|
* `Gabriel Le Breton <https://github.com/GabLeRoux>`_
|
||||||
|
* `Hans Roman <https://github.com/snahor>`_
|
||||||
|
* `micronn <https://github.com/micronn>`_
|
||||||
|
* `Ivan Klishch <https://github.com/klivan>`_
|
||||||
|
* `Joe MacDonald <https://github.com/joeythesaint>`_
|
||||||
|
* `Marc Abramowitz <https://github.com/msabramo>`_
|
||||||
|
* `Matt <https://github.com/mehandes>`_
|
||||||
|
* `Matthew Smith <https://github.com/msmith491>`_
|
||||||
|
* `Michael Kwon <https://github.com/mskwon>`_
|
||||||
|
* `Michael Wei <https://github.com/no2chem>`_
|
||||||
|
* `Ram-Z <https://github.com/Ram-Z>`_
|
||||||
|
* `Vivek Anand <https://github.com/vivekanand1101>`_
|
||||||
|
* `Wieland Hoffmann <https://github.com/mineo>`_
|
||||||
|
* `Adam Talsma <https://github.com/a-tal>`_
|
||||||
|
* `geheimnisse <https://github.com/geheimnisse>`_
|
||||||
|
* `Alexander Terry <https://github.com/mralext20>`_
|
||||||
|
* `peterpans01 <https://github.com/peterpans01>`_
|
|
@ -0,0 +1,783 @@
|
||||||
|
=============
|
||||||
|
TTVR Changelog
|
||||||
|
=============
|
||||||
|
|
||||||
|
.. _1.27.0: https://github.com/tildeclub/ttrv/releases/tag/v1.27.0
|
||||||
|
.. _1.26.0: https://github.com/tildeclub/ttrv/releases/tag/v1.26.0
|
||||||
|
.. _1.25.1: https://github.com/tildeclub/ttrv/releases/tag/v1.25.1
|
||||||
|
.. _1.25.0: https://github.com/tildeclub/ttrv/releases/tag/v1.25.0
|
||||||
|
.. _1.24.0: https://github.com/tildeclub/ttrv/releases/tag/v1.24.0
|
||||||
|
.. _1.23.0: https://github.com/tildeclub/ttrv/releases/tag/v1.23.0
|
||||||
|
.. _1.22.1: https://github.com/tildeclub/ttrv/releases/tag/v1.22.1
|
||||||
|
.. _1.22.0: https://github.com/tildeclub/ttrv/releases/tag/v1.22.0
|
||||||
|
.. _1.21.0: https://github.com/tildeclub/ttrv/releases/tag/v1.21.0
|
||||||
|
.. _1.20.0: https://github.com/tildeclub/ttrv/releases/tag/v1.20.0
|
||||||
|
.. _1.19.0: https://github.com/tildeclub/ttrv/releases/tag/v1.19.0
|
||||||
|
.. _1.18.0: https://github.com/tildeclub/ttrv/releases/tag/v1.18.0
|
||||||
|
.. _1.17.1: https://github.com/tildeclub/ttrv/releases/tag/v1.17.1
|
||||||
|
.. _1.17.0: https://github.com/tildeclub/ttrv/releases/tag/v1.17.0
|
||||||
|
.. _1.16.0: https://github.com/tildeclub/ttrv/releases/tag/v1.16.0
|
||||||
|
.. _1.15.1: https://github.com/tildeclub/ttrv/releases/tag/v1.15.1
|
||||||
|
.. _1.15.0: https://github.com/tildeclub/ttrv/releases/tag/v1.15.0
|
||||||
|
.. _1.14.1: https://github.com/tildeclub/ttrv/releases/tag/v1.14.1
|
||||||
|
.. _1.13.0: https://github.com/tildeclub/ttrv/releases/tag/v1.13.0
|
||||||
|
.. _1.12.1: https://github.com/tildeclub/ttrv/releases/tag/v1.12.1
|
||||||
|
.. _1.12.0: https://github.com/tildeclub/ttrv/releases/tag/v1.12.0
|
||||||
|
.. _1.11.0: https://github.com/tildeclub/ttrv/releases/tag/v1.11.0
|
||||||
|
.. _1.10.0: https://github.com/tildeclub/ttrv/releases/tag/v1.10.0
|
||||||
|
.. _1.9.1: https://github.com/tildeclub/ttrv/releases/tag/v1.9.1
|
||||||
|
.. _1.9.0: https://github.com/tildeclub/ttrv/releases/tag/v1.9.0
|
||||||
|
.. _1.8.1: https://github.com/tildeclub/ttrv/releases/tag/v1.8.1
|
||||||
|
.. _1.8.0: https://github.com/tildeclub/ttrv/releases/tag/v1.8.0
|
||||||
|
.. _1.7.0: https://github.com/tildeclub/ttrv/releases/tag/v1.7.0
|
||||||
|
.. _1.6.1: https://github.com/tildeclub/ttrv/releases/tag/v1.6.1
|
||||||
|
.. _1.6: https://github.com/tildeclub/ttrv/releases/tag/v1.6
|
||||||
|
.. _1.5: https://github.com/tildeclub/ttrv/releases/tag/v1.5
|
||||||
|
.. _1.4.2: https://github.com/tildeclub/ttrv/releases/tag/v1.4.2
|
||||||
|
.. _1.4.1: https://github.com/tildeclub/ttrv/releases/tag/v1.4.1
|
||||||
|
.. _1.4: https://github.com/tildeclub/ttrv/releases/tag/v1.4
|
||||||
|
.. _1.3: https://github.com/tildeclub/ttrv/releases/tag/v1.3
|
||||||
|
.. _1.2.2: https://github.com/tildeclub/ttrv/releases/tag/v1.2.2
|
||||||
|
.. _1.2.1: https://github.com/tildeclub/ttrv/releases/tag/v1.2.1
|
||||||
|
.. _1.2: https://github.com/tildeclub/ttrv/releases/tag/v1.2
|
||||||
|
|
||||||
|
--------------------
|
||||||
|
1.27.0_ (2019-06-02)
|
||||||
|
--------------------
|
||||||
|
|
||||||
|
This is the final release of RTV. See here for more information:
|
||||||
|
|
||||||
|
https://github.com/michael-lazar/rtv/issues/696
|
||||||
|
|
||||||
|
Features
|
||||||
|
|
||||||
|
* Added a configuration option to toggle whether to open web browser links in a
|
||||||
|
new tab or a new window.
|
||||||
|
|
||||||
|
Documentation
|
||||||
|
|
||||||
|
* Improved the mailcap example for the ``feh`` command.
|
||||||
|
* Fixed the the descriptions for the ``j`` & ``k`` keys (they were swapped).
|
||||||
|
|
||||||
|
--------------------
|
||||||
|
1.26.0_ (2019-03-03)
|
||||||
|
--------------------
|
||||||
|
|
||||||
|
Features
|
||||||
|
|
||||||
|
* Added a brand new inbox page for viewing private messages and comment replies.
|
||||||
|
The inbox is accessible with the ``i`` key. Supported actions include viewing
|
||||||
|
message chains and replying to messages, marking messages as read/unread, and
|
||||||
|
opening the context of a comment.
|
||||||
|
* Added the ability to compose new private messages with the ``C`` key.
|
||||||
|
* Updated the inline help ``?`` document to contain a more comprehensive list
|
||||||
|
of commands.
|
||||||
|
* Opening a link from the command line is now faster at startup because the
|
||||||
|
default subreddit will not be loaded beforehand.
|
||||||
|
* Added a new ``--debug-info`` command to display useful system information.
|
||||||
|
|
||||||
|
Bugfixes
|
||||||
|
|
||||||
|
* Fixed opening comments with the prompt ``/`` from the subscription window.
|
||||||
|
* The subscription and multireddit ``s``/``S`` keys now work from all pages.
|
||||||
|
* Relative time strings are now correctly pluralized.
|
||||||
|
* Fixed an unclosed file handler when opening the web browser.
|
||||||
|
* Fixed the application not starting if the user has an empty front page.
|
||||||
|
|
||||||
|
Configuration Changes
|
||||||
|
|
||||||
|
* Renamed the following keybindings to better represent their usage:
|
||||||
|
|
||||||
|
* ``SORT_HOT`` -> ``SORT_1``
|
||||||
|
* ``SORT_TOP`` -> ``SORT_2``
|
||||||
|
* ``SORT_RISING`` -> ``SORT_3``
|
||||||
|
* ``SORT_NEW`` -> ``SORT_4``
|
||||||
|
* ``SORT_CONTROVERSIAL`` -> ``SORT_5``
|
||||||
|
* ``SORT_GILDED`` -> ``SORT_6``
|
||||||
|
* ``SUBREDDIT_OPEN_SUBSCRIPTIONS`` -> ``SUBSCRIPTIONS``
|
||||||
|
* ``SUBREDDIT_OPEN_MULTIREDDITS`` -> ``MULTIREDDITS``
|
||||||
|
|
||||||
|
|
||||||
|
* Added new keybindings to support the inbox page:
|
||||||
|
|
||||||
|
* ``SORT_7``
|
||||||
|
* ``PRIVATE_MESSAGE``
|
||||||
|
* ``INBOX_VIEW_CONTEXT``
|
||||||
|
* ``INBOX_OPEN_SUBMISSION``
|
||||||
|
* ``INBOX_REPLY``
|
||||||
|
* ``INBOX_MARK_READ``
|
||||||
|
* ``INBOX_EXIT``
|
||||||
|
|
||||||
|
* Added new theme elements to support the inbox page:
|
||||||
|
|
||||||
|
* <New>
|
||||||
|
* <Distinguished>
|
||||||
|
* <MessageSubject>
|
||||||
|
* <MessageLink>
|
||||||
|
* <MessageAuthor>
|
||||||
|
* <MessageSubreddit>
|
||||||
|
* <MessageText>
|
||||||
|
|
||||||
|
--------------------
|
||||||
|
1.25.1_ (2019-02-13)
|
||||||
|
--------------------
|
||||||
|
|
||||||
|
Bugfixes
|
||||||
|
|
||||||
|
* Fixed a bug that was causing newlines to be stripped when posting comments
|
||||||
|
and submissions.
|
||||||
|
|
||||||
|
|
||||||
|
--------------------
|
||||||
|
1.25.0_ (2019-02-03)
|
||||||
|
--------------------
|
||||||
|
|
||||||
|
Features
|
||||||
|
|
||||||
|
* You can now open HTML links that are embedded inside of comments and
|
||||||
|
submissions by pressing the ``ENTER`` key and selecting a link from the list.
|
||||||
|
This also works when copying links to the clipboard using ``Y``.
|
||||||
|
* Added the ``--no-autologin`` command line argument to disable automatically
|
||||||
|
logging in at startup.
|
||||||
|
* Added the ``max_pager_cols`` configuration option to limit the text width
|
||||||
|
when sending text to the system ``PAGER``.
|
||||||
|
* Additional filtering options have been added when viewing user pages.
|
||||||
|
* The gilded flair now displays the number of times a submission has been
|
||||||
|
gilded.
|
||||||
|
* Submissions/comments now display the time that they were most recently edited.
|
||||||
|
|
||||||
|
Bugfixes
|
||||||
|
|
||||||
|
* Fixed the MIME parser for gfycat, and gfycat videos are now downloaded as mp4.
|
||||||
|
* Fixed formatting when composing posts with leading whitespace.
|
||||||
|
* Fixed crash when attempting to display a long terminal title.
|
||||||
|
|
||||||
|
Documentation
|
||||||
|
|
||||||
|
* RTV has been moved to the Arch Community Repository and installation
|
||||||
|
instructions for Arch have been updated accordingly.
|
||||||
|
|
||||||
|
|
||||||
|
--------------------
|
||||||
|
1.24.0_ (2018-08-12)
|
||||||
|
--------------------
|
||||||
|
|
||||||
|
Features
|
||||||
|
|
||||||
|
* Python 3.7 is now officially supported.
|
||||||
|
* Lines that start with the hash symbol (#) are no longer ignored when
|
||||||
|
composing posts in your editor. This allows # to be used with Reddit's
|
||||||
|
markdown parser to denote headers.
|
||||||
|
* Added a new *dark colorblind* theme.
|
||||||
|
* Added support for the ``$RTV_PAGER`` environment variable, which can be
|
||||||
|
used to set a unique PAGER for rtv.
|
||||||
|
* Added the ability to sort submissions by **guilded**.
|
||||||
|
|
||||||
|
Bugfixes
|
||||||
|
|
||||||
|
* Fixed a crash when setting the ``$BROWSER`` with python 3.7.
|
||||||
|
* Improved the error message when attempting to vote on an archived post.
|
||||||
|
* Cleaned up several outdated MIME parsers. Removed the vidme, twitch,
|
||||||
|
oddshot, and imgtc parsers. Fixed the liveleak and reddit video parsers.
|
||||||
|
|
||||||
|
|
||||||
|
--------------------
|
||||||
|
1.23.0_ (2018-06-24)
|
||||||
|
--------------------
|
||||||
|
|
||||||
|
Features
|
||||||
|
|
||||||
|
* Submissions can now be marked as *[hidden]* using the ``space`` key. Hidden
|
||||||
|
submissions will be removed from the feed when the page is reloaded.
|
||||||
|
* New MIME parsers have been added for vimeo.com and streamja.com.
|
||||||
|
* Added support for opening links with **qutebrowser**.
|
||||||
|
|
||||||
|
Bugfixes
|
||||||
|
|
||||||
|
* Fixed unhandled OAuth server log messages being dumped to stdout.
|
||||||
|
* Fixed the application crashing when performing rate-limited requests.
|
||||||
|
* Fixed crash when displaying posts that contain null byte characters.
|
||||||
|
|
||||||
|
Documentation
|
||||||
|
|
||||||
|
* Added README badge for *saythanks.io*.
|
||||||
|
* Updated the mailcap template to support *v.redd.it* links.
|
||||||
|
|
||||||
|
|
||||||
|
--------------------
|
||||||
|
1.22.1_ (2018-03-11)
|
||||||
|
--------------------
|
||||||
|
|
||||||
|
I forgot to check in a commit before publishing the 1.22.0 release (whoops!)
|
||||||
|
|
||||||
|
Bugfixes
|
||||||
|
|
||||||
|
* Updated the ``__version__.py`` file to report the current version.
|
||||||
|
* Added the missing v1.22.0 entry to the CHANGELOG.
|
||||||
|
|
||||||
|
--------------------
|
||||||
|
1.22.0_ (2018-03-07)
|
||||||
|
--------------------
|
||||||
|
|
||||||
|
Features
|
||||||
|
|
||||||
|
* Added the ``--no-flash`` option to disable terminal flashing.
|
||||||
|
|
||||||
|
Bugfixes
|
||||||
|
|
||||||
|
* Fixed automatically exiting on launch when trying to open an invalid
|
||||||
|
subreddit with the ``-s`` flag.
|
||||||
|
* Fixed error handling for HTTP request timeouts when checking for new
|
||||||
|
messages in the inbox.
|
||||||
|
* Fixed a typo in the sample theme config.
|
||||||
|
|
||||||
|
Documentation
|
||||||
|
|
||||||
|
* Added the FreeBSD package to the README.
|
||||||
|
|
||||||
|
--------------------
|
||||||
|
1.21.0_ (2017-12-30)
|
||||||
|
--------------------
|
||||||
|
|
||||||
|
Features
|
||||||
|
|
||||||
|
* Full support for customizable themes has been added. For more information,
|
||||||
|
see the new section on themes in the README, and the ``THEMES.md`` file.
|
||||||
|
|
||||||
|
Bugfixes
|
||||||
|
|
||||||
|
* Fixed incorrect URL strings being sent to the **opera** web browser.
|
||||||
|
* Fixed timeout messages for the **surf** and **vimb** web browsers.
|
||||||
|
* Switched to using ``XDG_DATA_HOME`` to store the rtv browser history and
|
||||||
|
credentials file.
|
||||||
|
|
||||||
|
--------------------
|
||||||
|
1.20.0_ (2017-12-05)
|
||||||
|
--------------------
|
||||||
|
|
||||||
|
Features
|
||||||
|
|
||||||
|
* Text piped to the ``$PAGER`` will now wrap on word / sentence breaks.
|
||||||
|
* New MIME parsers have been added for liveleak.com and worldstarhiphop.com.
|
||||||
|
|
||||||
|
Bugfixes
|
||||||
|
|
||||||
|
* Fixed a regression where text from the web browser's stdout/stderr was
|
||||||
|
being sent to the terminal window.
|
||||||
|
* Fixed crashing on startup when the terminal doesn't support colors.
|
||||||
|
* Fixed broken text formatting when running inside of emacs ``term``.
|
||||||
|
|
||||||
|
Codebase
|
||||||
|
|
||||||
|
* Dropped support for python 3.3 because it's no longer supported upstream
|
||||||
|
by **pytest**. The application will still install through pip but will no
|
||||||
|
longer be tested.
|
||||||
|
* Added a text logo to the README.
|
||||||
|
|
||||||
|
--------------------
|
||||||
|
1.19.0_ (2017-10-24)
|
||||||
|
--------------------
|
||||||
|
|
||||||
|
Features
|
||||||
|
|
||||||
|
* Greatly improved loading times by using smarter rate limiting and page caching.
|
||||||
|
* The logout prompt is now visible as a popup notification.
|
||||||
|
* New MIME parsers have been added for gifs.com, giphy.com, imgtc.com,
|
||||||
|
imgflip.com, livememe.com, makeameme.org and flickr.com
|
||||||
|
* Improved mailcap examples for parsing video links with mpv.
|
||||||
|
|
||||||
|
Bugfixes
|
||||||
|
|
||||||
|
* Patched a backwards-incompatible Reddit API change with the comment
|
||||||
|
permalink now being returned in the response JSON.
|
||||||
|
* Fixed crashing when a comment contained exotic unicode characters like emojis.
|
||||||
|
* Removed the option to select custom sorting ranges for controversial and
|
||||||
|
top comments.
|
||||||
|
* Fixed MIME parsing for single image Imgur galleries.
|
||||||
|
|
||||||
|
Codebase
|
||||||
|
|
||||||
|
* Preliminary refactoring for the upcoming theme support.
|
||||||
|
* Created some utility scripts for project maintenance.
|
||||||
|
* Created a release checklist document.
|
||||||
|
* Updated the README gif images and document layout.
|
||||||
|
|
||||||
|
--------------------
|
||||||
|
1.18.0_ (2017-09-06)
|
||||||
|
--------------------
|
||||||
|
|
||||||
|
Features
|
||||||
|
|
||||||
|
* The ``rtv -l`` flag has been deprecated and replaced with a positional
|
||||||
|
argument, in order to match the syntax of other command line web browsers.
|
||||||
|
* NSFW content is now filtered according to the user's reddit profile
|
||||||
|
settings.
|
||||||
|
* ``$RTV_BROWSER`` has been added as a way to set the preferred web browser.
|
||||||
|
* Sorting options for **relevance** and **comments** are now displayed on
|
||||||
|
the search results page.
|
||||||
|
* An **[S]** badge is now displayed next to the submission author.
|
||||||
|
* The gfycat MIME parser has been expanded to support more URLs.
|
||||||
|
* New MIME parsers have been added for oddshot.tv, clips.twitch.tv,
|
||||||
|
clippituser.tv, and Reddit's beta hosted videos.
|
||||||
|
|
||||||
|
Bugfixes
|
||||||
|
|
||||||
|
* Users can now use the prompt to navigate to "/comments/..." pages from
|
||||||
|
inside of a submission.
|
||||||
|
* Users can now navigate to multireddits using the "/u/me/" prefix.
|
||||||
|
* Fixed the ``$BROWSER`` behavior on macOS to support the **chrome**,
|
||||||
|
**firefox**, **safari**, and **default** keywords.
|
||||||
|
|
||||||
|
Codebase
|
||||||
|
|
||||||
|
* Travis CI tests have been moved to the trusty environment.
|
||||||
|
* Added more detailed logging of the environment and settings at startup.
|
||||||
|
|
||||||
|
--------------------
|
||||||
|
1.17.1_ (2017-08-06)
|
||||||
|
--------------------
|
||||||
|
|
||||||
|
Bugfixes
|
||||||
|
|
||||||
|
* ``J``/``K`` commands are now restricted to the submission page.
|
||||||
|
|
||||||
|
--------------------
|
||||||
|
1.17.0_ (2017-08-03)
|
||||||
|
--------------------
|
||||||
|
|
||||||
|
Features
|
||||||
|
|
||||||
|
* Added the ``J`` command to jump to the next sibling comment.
|
||||||
|
* Added the ``K`` command to jump to the parent comment.
|
||||||
|
* Search results can now be sorted, and the title bar has been updated
|
||||||
|
to display the current search query.
|
||||||
|
* Imgur URLs are now resolved via the Imgur API.
|
||||||
|
This enables the loading of large albums with over 10 images.
|
||||||
|
An ``imgur_client_id`` option has been added to the RTV configuration.
|
||||||
|
* A MIME parser has been added for www.liveleak.com.
|
||||||
|
* RTV now respects the ``$VISUAL`` environment variable.
|
||||||
|
|
||||||
|
Bugfixes
|
||||||
|
|
||||||
|
* Fixed a screen refresh bug on urxvt terminals.
|
||||||
|
* New key bindings will now attempt to fallback to their default key if not
|
||||||
|
defined in the user's configuration file.
|
||||||
|
|
||||||
|
Documentation
|
||||||
|
|
||||||
|
* Added additional mailcap examples for framebuffer videos and iTerm2.
|
||||||
|
* Python version information is now captured in the log at startup.
|
||||||
|
|
||||||
|
|
||||||
|
--------------------
|
||||||
|
1.16.0_ (2017-06-08)
|
||||||
|
--------------------
|
||||||
|
|
||||||
|
Features
|
||||||
|
|
||||||
|
* Added the ability to copy links to the OS clipboad with ``y`` and ``Y``.
|
||||||
|
* Both submissions and comments can now be viewed on **/user/** pages.
|
||||||
|
* A MIME parser has been added for www.streamable.com.
|
||||||
|
* A MIME parser has been added for www.vidme.com.
|
||||||
|
* Submission URLs can now be opened while viewing the comments page.
|
||||||
|
|
||||||
|
Bugfixes
|
||||||
|
|
||||||
|
* More graceful handling for the invalid LOCALE error on MacOS.
|
||||||
|
* A fatal error is now raised when trying to run on Windows without curses.
|
||||||
|
* Fixed an error when trying to view saved comments.
|
||||||
|
* Invalid refresh-tokens are now automatically deleted.
|
||||||
|
* Users who are signed up for Reddit's beta profiles can now launch RTV.
|
||||||
|
|
||||||
|
--------------------
|
||||||
|
1.15.1_ (2017-04-09)
|
||||||
|
--------------------
|
||||||
|
Codebase
|
||||||
|
|
||||||
|
* Removed the mailcap-fix dependency for python versions >= 3.6.0.
|
||||||
|
* Enabled installing test dependencies with ``pip install rtv[test]``.
|
||||||
|
|
||||||
|
--------------------
|
||||||
|
1.15.0_ (2017-03-30)
|
||||||
|
--------------------
|
||||||
|
Features
|
||||||
|
|
||||||
|
* Added the ability to open comment threads using the submission's
|
||||||
|
permalink. E.g. **/comments/30rwj2**
|
||||||
|
|
||||||
|
Bugfixes
|
||||||
|
|
||||||
|
* Updated ``requests`` requirement to fix a bug in version 2.3.0.
|
||||||
|
* Fixed an edge case where comment trees were unfolding out of order.
|
||||||
|
|
||||||
|
Codebase
|
||||||
|
|
||||||
|
* Removed dependency on the PyPI ``praw`` package. A version of PRAW 3
|
||||||
|
is now bundled with rtv. This should make installation easier because
|
||||||
|
users are no longer required to maintain a legacy version of praw in
|
||||||
|
their python dependencies.
|
||||||
|
* Removed ``update-checker`` dependency.
|
||||||
|
|
||||||
|
--------------------
|
||||||
|
1.14.1_ (2017-01-12)
|
||||||
|
--------------------
|
||||||
|
Features
|
||||||
|
|
||||||
|
* The order-by option menu now triggers after a single '2' or '5' keystroke
|
||||||
|
instead of needing to double press.
|
||||||
|
|
||||||
|
Bugfixes
|
||||||
|
|
||||||
|
* Mailcap now handles multi-part shell commands correctly, e.g. "emacs -nw"
|
||||||
|
* OS X no longer relies on $DISPLAY to check if there is a display available.
|
||||||
|
* Added error handling for terminals that don't support hiding the cursor.
|
||||||
|
* Fixed a bug on tmux that prevented scrolling when $TERM was set to
|
||||||
|
"xterm-256color" instead of screen.
|
||||||
|
|
||||||
|
Documentation
|
||||||
|
|
||||||
|
* Added section to FAQ about garbled characters output by curses.
|
||||||
|
|
||||||
|
--------------------
|
||||||
|
1.13.0_ (2016-10-17)
|
||||||
|
--------------------
|
||||||
|
Features
|
||||||
|
|
||||||
|
* Pressing `2` or `5` twice now opens a menu to select the time frame.
|
||||||
|
* Added the `hide_username` config option.
|
||||||
|
* Added the `max_comment_cols` config option.
|
||||||
|
|
||||||
|
Bugfixes
|
||||||
|
|
||||||
|
* Fixed the terminal title from displaying b'' in py3.
|
||||||
|
* Flipped j and k in the documentation.
|
||||||
|
* Fixed bug when selecting post order for the front page.
|
||||||
|
* Added more descriptive error messages for invalid subreddits.
|
||||||
|
|
||||||
|
--------------------
|
||||||
|
1.12.1_ (2016-09-27)
|
||||||
|
--------------------
|
||||||
|
Bugfixes
|
||||||
|
|
||||||
|
* Fixed security vulnerability where malicious URLs could inject python code.
|
||||||
|
* No longer hangs when using mpv on long videos.
|
||||||
|
* Now falls back to ascii mode when the system locale is not utf-8.
|
||||||
|
|
||||||
|
--------------------
|
||||||
|
1.12.0_ (2016-08-25)
|
||||||
|
--------------------
|
||||||
|
Features
|
||||||
|
|
||||||
|
* Added a help banner with common key bindings.
|
||||||
|
* Added `gg` and `G` bindings to jump to the top and bottom the the page.
|
||||||
|
* Updated help screen now opens with the system PAGER.
|
||||||
|
* The `/` prompt now works from inside of submissions.
|
||||||
|
* Added an Instagram parser to extract images and videos from urls.
|
||||||
|
|
||||||
|
Bugixes
|
||||||
|
|
||||||
|
* Shortened reddit links (https://redd.it/) will now work with ``-s``.
|
||||||
|
|
||||||
|
Codebase
|
||||||
|
|
||||||
|
* Removed the Tornado dependency from the project.
|
||||||
|
* Added a requirements.txt file.
|
||||||
|
* Fixed a bunch of tests where cassettes were not being generated.
|
||||||
|
* Added compatability for pytest-xdist.
|
||||||
|
|
||||||
|
|
||||||
|
--------------------
|
||||||
|
1.11.0_ (2016-08-02)
|
||||||
|
--------------------
|
||||||
|
Features
|
||||||
|
|
||||||
|
* Added the ability to open image and video urls with the user's mailcap file.
|
||||||
|
* New ``--enable-media`` and ``copy-mailcap`` commands to support mailcap.
|
||||||
|
* New command `w` to save submissions and comments.
|
||||||
|
* New command `p` to toggle between the front page and the last visited subreddit.
|
||||||
|
* New command `S` to view subscribed multireddits.
|
||||||
|
* Extended ``/`` prompt to work with users, multireddits, and domains.
|
||||||
|
* New page ``/u/saved`` to view saved submissions.
|
||||||
|
* You can now specify the sort period by appending **-(period)**,
|
||||||
|
E.g. **/r/python/top-week**.
|
||||||
|
|
||||||
|
Bugfixes
|
||||||
|
|
||||||
|
* Terminal title is now only set when $DISPLAY is present.
|
||||||
|
* Urlview now works on the submission as well as comments.
|
||||||
|
* Fixed text encoding when using urlview.
|
||||||
|
* Removed `futures` dependency from the python 3 wheel.
|
||||||
|
* Unhandled resource warnings on exit are now ignored.
|
||||||
|
|
||||||
|
Documentation
|
||||||
|
|
||||||
|
* Various README updates.
|
||||||
|
* Updated asciinema demo video.
|
||||||
|
* Added script to update the AUTHORS.rst file.
|
||||||
|
|
||||||
|
--------------------
|
||||||
|
1.10.0_ (2016-07-11)
|
||||||
|
--------------------
|
||||||
|
Features
|
||||||
|
|
||||||
|
* New command, `b` extracts urls from comments using urlviewer.
|
||||||
|
* Comment files will no longer be destroyed if RTV encounters an error while posting.
|
||||||
|
* The terminal title now displays the subreddit name/url.
|
||||||
|
|
||||||
|
Bugfixes
|
||||||
|
|
||||||
|
* Fixed crash when entering empty or invalid subreddit name.
|
||||||
|
* Fixed crash when opening x-posts linked to subreddits.
|
||||||
|
* Fixed a bug where the terminal title wasn't getting set.
|
||||||
|
* **/r/me** is now displayed as *My Submissions* in the header.
|
||||||
|
|
||||||
|
-------------------
|
||||||
|
1.9.1_ (2016-06-13)
|
||||||
|
-------------------
|
||||||
|
Features
|
||||||
|
|
||||||
|
* Better support for */r/random*.
|
||||||
|
* Added a ``monochrome`` config setting to disable all color.
|
||||||
|
* Improved cursor positioning when expanding/hiding comments.
|
||||||
|
* Show ``(not enough space)`` when comments are too large.
|
||||||
|
|
||||||
|
Bugfixes
|
||||||
|
|
||||||
|
* Fixed permissions when copying the config file.
|
||||||
|
* Fixed bug where submission indicies were duplicated when paging.
|
||||||
|
* Specify praw v3.4.0 to avoid installing praw 4.
|
||||||
|
|
||||||
|
Documentation
|
||||||
|
|
||||||
|
* Added section to the readme on Arch Linux installation.
|
||||||
|
* Updated a few argument descriptions.
|
||||||
|
* Added a proper ascii logo.
|
||||||
|
|
||||||
|
-------------------
|
||||||
|
1.9.0_ (2016-04-05)
|
||||||
|
-------------------
|
||||||
|
Features
|
||||||
|
|
||||||
|
* You can now open long posts/comments with the $PAGER by pressing `l`.
|
||||||
|
* Changed a couple of visual separators.
|
||||||
|
|
||||||
|
Documentation
|
||||||
|
|
||||||
|
* Added testing instructions to the FAQ.
|
||||||
|
|
||||||
|
-------------------
|
||||||
|
1.8.1_ (2016-03-01)
|
||||||
|
-------------------
|
||||||
|
Features
|
||||||
|
|
||||||
|
* All keys are now rebindable through the config.
|
||||||
|
* New bindings - ctrl-d and ctrl-u for page up / page down.
|
||||||
|
* Added tag for stickied posts and comments.
|
||||||
|
* Added bullet between timestamp and comment count.
|
||||||
|
|
||||||
|
Bugfixes
|
||||||
|
|
||||||
|
* Links starting with np.reddit.com no longer return `Forbidden`.
|
||||||
|
|
||||||
|
Documentation
|
||||||
|
|
||||||
|
* Updated README.
|
||||||
|
|
||||||
|
-------------------
|
||||||
|
1.8.0_ (2015-12-20)
|
||||||
|
-------------------
|
||||||
|
Features
|
||||||
|
|
||||||
|
* A banner on the top of the page now displays the selected page sort order.
|
||||||
|
* Hidden scores now show up as "- pts".
|
||||||
|
* Oauth settings are now accesible through the config file.
|
||||||
|
* New argument `--config` specifies the config file to use.
|
||||||
|
* New argument `--copy-config` generates a default config file.
|
||||||
|
|
||||||
|
Documentation
|
||||||
|
|
||||||
|
* Added a keyboard reference from keyboardlayouteditor.com
|
||||||
|
* Added a link to an asciinema demo video
|
||||||
|
|
||||||
|
-------------------
|
||||||
|
1.7.0_ (2015-12-08)
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
**Note**
|
||||||
|
This version comes with a large change in the internal structure of the project,
|
||||||
|
but does not break backwards compatibility. This includes adding a new test
|
||||||
|
suite that will hopefully improve the stability of future releases.
|
||||||
|
|
||||||
|
Continuous Integration additions
|
||||||
|
|
||||||
|
* Travis-CI https://travis-ci.org/michael-lazar/rtv
|
||||||
|
* Coveralls https://coveralls.io/github/michael-lazar/rtv
|
||||||
|
* Gitter (chat) https://gitter.im/michael-lazar/rtv
|
||||||
|
* Added a tox config for local testing
|
||||||
|
* Added a pylint config for static code and style analysis
|
||||||
|
* The project now uses VCR.py to record HTTP interactions for testing.
|
||||||
|
|
||||||
|
Features
|
||||||
|
|
||||||
|
* Added a wider utilization of the loading screen for functions that make
|
||||||
|
reddit API calls.
|
||||||
|
* In-progress loading screens can now be cancelled by pressing the `Esc` key.
|
||||||
|
|
||||||
|
Bugfixes
|
||||||
|
|
||||||
|
* OSX users should now be able to login using OAuth.
|
||||||
|
* Comments now return the correct nested level when loading "More Comments".
|
||||||
|
* Several unicode fixes, the project is now much more consistent in the way
|
||||||
|
that unicode is handled.
|
||||||
|
* Several undocumented bug fixes as a result of the code restructure.
|
||||||
|
|
||||||
|
|
||||||
|
-------------------
|
||||||
|
1.6.1_ (2015-10-19)
|
||||||
|
-------------------
|
||||||
|
Bugfixes
|
||||||
|
|
||||||
|
* Fixed authentication checking for */r/me*.
|
||||||
|
* Added force quit option with the `Q` key.
|
||||||
|
* Removed option to sort subscriptions.
|
||||||
|
* Fixed crash with pressing `i` when not logged in.
|
||||||
|
* Removed futures requirement from the python 3 distribution.
|
||||||
|
|
||||||
|
Documentation
|
||||||
|
|
||||||
|
* Updated screenshot in README.
|
||||||
|
* Added section to the FAQ on installation.
|
||||||
|
|
||||||
|
-----------------
|
||||||
|
1.6_ (2015-10-14)
|
||||||
|
-----------------
|
||||||
|
Features
|
||||||
|
|
||||||
|
* Switched all authentication to OAuth.
|
||||||
|
* Can now list the version with `rtv --version`.
|
||||||
|
* Added a man page.
|
||||||
|
* Added confirmation prompt when quitting.
|
||||||
|
* Submissions now display the index in front of their title.
|
||||||
|
|
||||||
|
Bugfixes
|
||||||
|
|
||||||
|
* Streamlined error logging.
|
||||||
|
|
||||||
|
Documentation
|
||||||
|
|
||||||
|
* Added missing docs for the `i` key.
|
||||||
|
* New documentation for OAuth.
|
||||||
|
* New FAQ section.
|
||||||
|
|
||||||
|
-----------------
|
||||||
|
1.5_ (2015-08-26)
|
||||||
|
-----------------
|
||||||
|
Features
|
||||||
|
|
||||||
|
* New page to view and open subscribed subreddits with `s`.
|
||||||
|
* Sorting method can now be toggled with the `1` - `5` keys.
|
||||||
|
* Links to x-posts are now opened inside of RTV.
|
||||||
|
|
||||||
|
Bugfixes
|
||||||
|
|
||||||
|
* Added */r/* to subreddit names in the subreddit view.
|
||||||
|
|
||||||
|
-------------------
|
||||||
|
1.4.2_ (2015-08-01)
|
||||||
|
-------------------
|
||||||
|
Features
|
||||||
|
|
||||||
|
* Pressing the `o` key now opens selfposts directly inside of rtv.
|
||||||
|
|
||||||
|
Bugfixes
|
||||||
|
|
||||||
|
* Fixed invalid subreddits from throwing unexpected errors.
|
||||||
|
|
||||||
|
-------------------
|
||||||
|
1.4.1_ (2015-07-11)
|
||||||
|
-------------------
|
||||||
|
Features
|
||||||
|
|
||||||
|
* Added the ability to check for unread messages with the `i` key.
|
||||||
|
* Upped required PRAW version to 3.
|
||||||
|
|
||||||
|
Bugfixes
|
||||||
|
|
||||||
|
* Fixed crash caused by downvoting.
|
||||||
|
* Missing flairs now display properly.
|
||||||
|
* Fixed ResourceWarning on Python 3.2+.
|
||||||
|
|
||||||
|
-----------------
|
||||||
|
1.4_ (2015-05-16)
|
||||||
|
-----------------
|
||||||
|
Features
|
||||||
|
|
||||||
|
* Unicode support has been vastly improved and is now turned on by default.
|
||||||
|
Ascii only mode can be toggled with the `--ascii` command line flag.
|
||||||
|
* Added pageup and pagedown with the `m` and `n` keys.
|
||||||
|
* Support for terminal based webbrowsers such as links and w3m.
|
||||||
|
* Browsing history is now persistent and stored in `$XDG_CACHE_HOME`.
|
||||||
|
|
||||||
|
Bugfixes
|
||||||
|
|
||||||
|
* Several improvements for handling unicode.
|
||||||
|
* Fixed crash caused by resizing the window and exiting a submission.
|
||||||
|
|
||||||
|
-----------------
|
||||||
|
1.3_ (2015-04-22)
|
||||||
|
-----------------
|
||||||
|
Features
|
||||||
|
|
||||||
|
* Added edit `e` and delete `d` for comments and submissions.
|
||||||
|
* Added *nsfw* tags.
|
||||||
|
|
||||||
|
Bugfixes
|
||||||
|
|
||||||
|
* Upvote/downvote icon now displays in the submission selfpost.
|
||||||
|
* Loading large *MoreComment* blocks no longer hangs the program.
|
||||||
|
* Improved logging and error handling with praw interactions.
|
||||||
|
|
||||||
|
-------------------
|
||||||
|
1.2.2_ (2015-04-07)
|
||||||
|
-------------------
|
||||||
|
Bugfixes
|
||||||
|
|
||||||
|
* Fixed default subreddit not being set.
|
||||||
|
|
||||||
|
Documentation
|
||||||
|
|
||||||
|
* Added changelog and contributor links to the README.
|
||||||
|
|
||||||
|
-------------------
|
||||||
|
1.2.1_ (2015-04-06)
|
||||||
|
-------------------
|
||||||
|
Bugfixes
|
||||||
|
|
||||||
|
* Fixed crashing on invalid subreddit names
|
||||||
|
|
||||||
|
-----------------
|
||||||
|
1.2_ (2015-04-06)
|
||||||
|
-----------------
|
||||||
|
Features
|
||||||
|
|
||||||
|
* Added user login / logout with the `u` key.
|
||||||
|
* Added subreddit searching with the `f` key.
|
||||||
|
* Added submission posting with the `p` key.
|
||||||
|
* Added viewing of user submissions with `/r/me`.
|
||||||
|
* Program title now displays in the terminal window.
|
||||||
|
* Gold symbols now display on guilded comments and posts.
|
||||||
|
* Moved default config location to XDG_CONFIG_HOME.
|
||||||
|
|
||||||
|
Bugfixes
|
||||||
|
|
||||||
|
* Improved error handling for submission / comment posts.
|
||||||
|
* Fixed handling of unicode flairs.
|
||||||
|
* Improved displaying of the help message and selfposts on small terminal windows.
|
||||||
|
* The author's name now correctly highlights in submissions
|
||||||
|
* Corrected user agent formatting.
|
||||||
|
* Various minor bugfixes.
|
||||||
|
|
||||||
|
------------------
|
||||||
|
1.1.1 (2015-03-30)
|
||||||
|
------------------
|
||||||
|
* Post comments using your text editor.
|
|
@ -0,0 +1,108 @@
|
||||||
|
----------------------
|
||||||
|
Contributor Guidelines
|
||||||
|
----------------------
|
||||||
|
|
||||||
|
Before you start
|
||||||
|
================
|
||||||
|
|
||||||
|
- Post an issue on the `tracker <https://github.com/tildeclub/ttrv/issues>`_ describing the bug or feature you would like to add
|
||||||
|
- If an issue already exists, leave a comment to let others know that you intend to work on it
|
||||||
|
|
||||||
|
Considerations
|
||||||
|
==============
|
||||||
|
|
||||||
|
- One of the project's goals is to maintain compatibility with as many terminal emulators as possible.
|
||||||
|
Please be mindful of this when designing a new feature
|
||||||
|
|
||||||
|
- Is it compatible with both Linux and OS X?
|
||||||
|
- Is it compatible with both Python 2 and Python 3
|
||||||
|
- Will it work over ssh (without X11)?
|
||||||
|
- What about terminals that don't support color? Or in those with limited (8/256) colors?
|
||||||
|
- Will it work in tmux/screen?
|
||||||
|
- Will is fail gracefully if unicode is not supported?
|
||||||
|
|
||||||
|
- If you're adding a new feature, try to include a few test cases.
|
||||||
|
See the section below on setting up your test environment
|
||||||
|
- If you tried, but you can't get the tests running in your environment, it's ok
|
||||||
|
- If you are unsure about anything, ask!
|
||||||
|
|
||||||
|
Submitting a pull request
|
||||||
|
=========================
|
||||||
|
|
||||||
|
- Reference the issue # that the pull request is related to
|
||||||
|
- Make sure you have merged in the latest changes from the ``master`` branch
|
||||||
|
- After you submit, make sure that the Travis-CI build passes
|
||||||
|
- Be prepared to have your code reviewed.
|
||||||
|
For non-trivial additions, it's normal for this process to take a few iterations
|
||||||
|
|
||||||
|
Style guide
|
||||||
|
===========
|
||||||
|
|
||||||
|
- All code should follow `PEP 8 <https://www.python.org/dev/peps/pep-0008/>`_
|
||||||
|
- Try to keep lines under 80 characters, but don't sacrifice readability to do it!
|
||||||
|
|
||||||
|
**Ugly**
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
text = ''.join(
|
||||||
|
line for line in fp2 if not line.startswith('#'))
|
||||||
|
|
||||||
|
**Better**
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
text = ''.join(line for line in fp2 if not line.startswith('#'))
|
||||||
|
|
||||||
|
- Use the existing codebase as a reference when writing docstrings (adopted from the `Google Style Guide <https://google.github.io/styleguide/pyguide.html#Comments>`_)
|
||||||
|
- Add an encoding header ``# -*- coding: utf-8 -*-`` to all new files
|
||||||
|
- **Please don't submit pull requests for style-only code changes**
|
||||||
|
|
||||||
|
Running the tests
|
||||||
|
=================
|
||||||
|
|
||||||
|
This project uses `pytest <http://pytest.org/>`_ and `VCR.py <https://vcrpy.readthedocs.org/>`_
|
||||||
|
|
||||||
|
VCR is a tool that records HTTP requests made during the test run and stores them in *tests/cassettes* for subsequent runs.
|
||||||
|
This both speeds up the tests and helps to maintain consistency across runs.
|
||||||
|
|
||||||
|
1. Install the test dependencies
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
$ pip install ttrv[test]
|
||||||
|
|
||||||
|
2. Set your ``$PYTHONPATH`` to point to the directory of your ttrv repository.
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
$ export PYTHONPATH=~/code/ttrv/
|
||||||
|
|
||||||
|
3. Run the tests using the existing cassettes
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
$ python -m pytest ~/code/ttrv/tests/
|
||||||
|
================================ test session starts ================================
|
||||||
|
platform linux -- Python 3.4.0, pytest-2.9.2, py-1.4.31, pluggy-0.3.1
|
||||||
|
rootdir: ~/code/ttrv/, inifile:
|
||||||
|
plugins: xdist-1.14, cov-2.2.0
|
||||||
|
collected 113 items
|
||||||
|
|
||||||
|
4. By default, the cassettes will act as read-only.
|
||||||
|
If you have written a new test and would like to record a cassette, you must provide your own refresh token.
|
||||||
|
The easiest thing to do is to use the token generated by ttrv when you log in.
|
||||||
|
This is usually stored as *~/.local/share/ttrv/refresh-token*.
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
$ python -m pytest ~/code/ttrv/tests/ --record-mode once --refresh-token ~/.local/share/ttrv/refresh-token
|
||||||
|
================================ test session starts ================================
|
||||||
|
platform linux -- Python 3.4.0, pytest-2.9.2, py-1.4.31, pluggy-0.3.1
|
||||||
|
rootdir: ~/code/ttrv/, inifile:
|
||||||
|
plugins: xdist-1.14, cov-2.2.0
|
||||||
|
collected 113 items
|
||||||
|
|
||||||
|
Note that all sensitive information will automatically be stripped from the cassette when it's saved.
|
||||||
|
|
||||||
|
5. Once you have generated a new cassette, go ahead and commit it to your branch along with your test case
|
|
@ -0,0 +1,103 @@
|
||||||
|
# Controls
|
||||||
|
|
||||||
|
## Basic Commands
|
||||||
|
|
||||||
|
- <kbd>j</kbd> or <kbd>▼</kbd> - Move the cursor down
|
||||||
|
- <kbd>k</kbd> or <kbd>▲</kbd> - Move the cursor up
|
||||||
|
- <kbd>l</kbd> or <kbd>►</kbd> - View the currently selected item
|
||||||
|
- <kbd>h</kbd> or <kbd>◄</kbd> - Return to the previous view
|
||||||
|
- <kbd>m</kbd> or <kbd>PgUp</kbd> - Move the cursor up one page
|
||||||
|
- <kbd>n</kbd> or <kbd>PgDn</kbd> - Move the cursor down one page
|
||||||
|
- <kbd>gg</kbd> - Jump to the top of the page
|
||||||
|
- <kbd>G</kbd> - Jump to the bottom of the page
|
||||||
|
- <kbd>1</kbd> to <kbd>7</kbd> - Sort submissions by category
|
||||||
|
- <kbd>r</kbd> or <kbd>F5</kbd> - Refresh the content on the current page
|
||||||
|
- <kbd>u</kbd> - Login to your reddit account
|
||||||
|
- <kbd>q</kbd> - Quit
|
||||||
|
- <kbd>Q</kbd> - Force quit
|
||||||
|
- <kbd>y</kbd> - Copy submission permalink to clipboard
|
||||||
|
- <kbd>Y</kbd> - Copy submission link to clipboard
|
||||||
|
- <kbd>F2</kbd> - Cycle to the previous color theme
|
||||||
|
- <kbd>F3</kbd> - Cycle to the next color theme
|
||||||
|
- <kbd>?</kbd> - Show the help screen
|
||||||
|
- <kbd>/</kbd> - Open a prompt to select a subreddit
|
||||||
|
|
||||||
|
The <kbd>/</kbd> key opens a text prompt at the bottom of the screen. You can use
|
||||||
|
this to type in the name of the subreddit that you want to open. The following text
|
||||||
|
formats are recognized:
|
||||||
|
|
||||||
|
- ``/python`` - Open a subreddit, shorthand
|
||||||
|
- ``/r/python`` - Open a subreddit
|
||||||
|
- ``/r/python/new`` - Open a subreddit, sorted by category
|
||||||
|
- ``/r/python/controversial-year`` - Open a subreddit, sorted by category and time
|
||||||
|
- ``/r/python+linux+commandline`` - Open multiple subreddits merged together
|
||||||
|
- ``/comments/30rwj2`` - Open a submission, shorthand
|
||||||
|
- ``/r/python/comments/30rwj2`` - Open a submission
|
||||||
|
- ``/r/front`` - Open your front page
|
||||||
|
- ``/u/me`` - View your submissions
|
||||||
|
- ``/u/me/saved`` - View your saved content
|
||||||
|
- ``/u/me/hidden`` - View your hidden content
|
||||||
|
- ``/u/me/upvoted`` - View your upvoted content
|
||||||
|
- ``/u/me/downvoted`` - View your downvoted content
|
||||||
|
- ``/u/spez`` - View a user's submissions and comments
|
||||||
|
- ``/u/spez/submitted`` - View a user's submissions
|
||||||
|
- ``/u/spez/comments`` - View a user's comments
|
||||||
|
- ``/u/multi-mod/m/android`` - Open a user's curated multireddit
|
||||||
|
- ``/domain/python.org`` - Search for links for the given domain
|
||||||
|
|
||||||
|
## Authenticated Commands
|
||||||
|
|
||||||
|
Some actions require that you be logged in to your reddit account. You can login
|
||||||
|
by pressing the <kbd>u</kbd> key. Once you are logged in, your username will
|
||||||
|
appear in the top-right corner of the screen.
|
||||||
|
|
||||||
|
- <kbd>a</kbd> - Upvote
|
||||||
|
- <kbd>z</kbd> - Downvote
|
||||||
|
- <kbd>c</kbd> - Compose a new submission or comment
|
||||||
|
- <kbd>C</kbd> - Compose a new private message
|
||||||
|
- <kbd>e</kbd> - Edit the selected submission or comment
|
||||||
|
- <kbd>d</kbd> - Delete the selected submission or comment
|
||||||
|
- <kbd>i</kbd> - View your inbox (see [inbox mode](#inbox-mode))
|
||||||
|
- <kbd>s</kbd> - View your subscribed subreddits (see [subscription mode](#subscription-mode))
|
||||||
|
- <kbd>S</kbd> - View your subscribed multireddits (see [subscription mode](#subscription-mode))
|
||||||
|
- <kbd>u</kbd> - Logout of your reddit account
|
||||||
|
- <kbd>w</kbd> - Save the selected submission or comment
|
||||||
|
|
||||||
|
## Subreddit Mode
|
||||||
|
|
||||||
|
The following actions can be performed when viewing a subreddit:
|
||||||
|
|
||||||
|
- <kbd>l</kbd> or <kbd>►</kbd> - View the comments for the selected submission (see [submission mode](#submission-mode))
|
||||||
|
- <kbd>o</kbd> or <kbd>ENTER</kbd> - Open the selected submission link using your web browser or ``.mailcap`` config
|
||||||
|
- <kbd>SPACE</kbd> - Mark the selected submission as *hidden*
|
||||||
|
- <kbd>p</kbd> - Toggle between the currently viewed subreddit and ``/r/front``
|
||||||
|
- <kbd>f</kbd> - Open a prompt to search the current subreddit for a text string
|
||||||
|
|
||||||
|
## Submission Mode
|
||||||
|
|
||||||
|
The following actions can be performed when viewing a submission:
|
||||||
|
|
||||||
|
- <kbd>h</kbd> or <kbd>◄</kbd> - Close the submission and return to the previous page
|
||||||
|
- <kbd>l</kbd> or <kbd>►</kbd> - View the selected comment using the system's pager
|
||||||
|
- <kbd>o</kbd> or <kbd>ENTER</kbd> - Open a link in the comment using your web browser or ``.mailcap`` config
|
||||||
|
- <kbd>SPACE</kbd> - Fold or expand the selected comment and its children
|
||||||
|
- <kbd>b</kbd> - Send the comment text to the system's urlviewer application
|
||||||
|
- <kbd>J</kbd> - Move the cursor down the the next comment at the same indentation
|
||||||
|
- <kbd>K</kbd> - Move the cursor up to the parent comment
|
||||||
|
|
||||||
|
## Subscription Mode
|
||||||
|
|
||||||
|
The following actions can be performed when viewing your subscriptions or multireddits:
|
||||||
|
|
||||||
|
- <kbd>h</kbd> or <kbd>◄</kbd> - Close your subscriptions and return to the previous page
|
||||||
|
- <kbd>l</kbd> or <kbd>►</kbd> - Open the selected subreddit or multireddit
|
||||||
|
|
||||||
|
## Inbox Mode
|
||||||
|
|
||||||
|
The following actions can be performed when viewing your inbox:
|
||||||
|
|
||||||
|
- <kbd>h</kbd> or <kbd>◄</kbd> - Close your inbox and return to the previous page
|
||||||
|
- <kbd>l</kbd> or <kbd>►</kbd> - View the context of the selected comment
|
||||||
|
- <kbd>o</kbd> or <kbd>Enter</kbd> - Open the submission of the selected comment
|
||||||
|
- <kbd>c</kbd> - Reply to the selected comment or message
|
||||||
|
- <kbd>w</kbd> - Mark the selected comment or message as seen
|
|
@ -0,0 +1,22 @@
|
||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2015 michael-lazar
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
include version.py
|
||||||
|
include CHANGELOG.rst
|
||||||
|
include AUTHORS.rst
|
||||||
|
include README.md
|
||||||
|
include LICENSE
|
||||||
|
include ttrv.1
|
||||||
|
include ttrv/templates/*
|
||||||
|
include ttrv/themes/*
|
|
@ -0,0 +1,228 @@
|
||||||
|
<h1 align="center">Tilde Terminal Reddit Viewer (TTRV)</h1>
|
||||||
|
<p>Forked from Original source/development at: <a href="https://github.com/michael-lazar/rtv">RTV</a></p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
A text-based interface (TUI) to view and interact with Reddit from your terminal.<br>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img alt="title image" src="https://github.com/tildeclub/ttrv/raw/master/resources/title_image.png"/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
* [Demo](#demo)
|
||||||
|
* [Installation](#installation)
|
||||||
|
* [Usage](#usage)
|
||||||
|
* [Settings](#settings)
|
||||||
|
* [Themes](#themes)
|
||||||
|
* [FAQ](#faq)
|
||||||
|
* [Contributing](#contributing)
|
||||||
|
* [License](#license)
|
||||||
|
|
||||||
|
## Demo
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img alt="title image" src="https://github.com/tildeclub/ttrv/raw/master/resources/demo.gif"/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### PyPI package
|
||||||
|
|
||||||
|
TTRV is available on [PyPI](https://pypi.python.org/pypi/ttrv/) and can be installed with pip:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ pip install ttrv
|
||||||
|
```
|
||||||
|
|
||||||
|
### From source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ git clone https://github.com/tildeclub/ttrv.git
|
||||||
|
$ cd ttrv/
|
||||||
|
$ python setup.py install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
TTRV is not supported on Windows but you can enable Windows subsystem for Linux, download your preferred Linux distribution from Microsoft Store and access it from there.
|
||||||
|
|
||||||
|
To open links on Edge, paste the line below to ``{HOME}/.bashrc``
|
||||||
|
```
|
||||||
|
export BROWSER='/mnt/c/Program Files (x86)/Microsoft/Edge/Application/msedge.exe'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
To run the program, type:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ ttrv --help
|
||||||
|
```
|
||||||
|
|
||||||
|
### Controls
|
||||||
|
|
||||||
|
Move the cursor using either the arrow keys or *Vim* style movement:
|
||||||
|
|
||||||
|
- Press <kbd>▲</kbd> and <kbd>▼</kbd> to scroll through submissions
|
||||||
|
- Press <kbd>▶</kbd> to view the selected submission and <kbd>◀</kbd> to return
|
||||||
|
- Press <kbd>space-bar</kbd> to expand/collapse comments
|
||||||
|
- Press <kbd>u</kbd> to login (this requires a web browser for [OAuth](https://github.com/reddit-archive/reddit/wiki/oauth2))
|
||||||
|
- Press <kbd>?</kbd> to open the help screen
|
||||||
|
|
||||||
|
Press <kbd>/</kbd> to open the navigation prompt, where you can type things like:
|
||||||
|
|
||||||
|
- ``/front``
|
||||||
|
- ``/r/commandprompt+linuxmasterrace``
|
||||||
|
- ``/r/programming/controversial``
|
||||||
|
- ``/u/me``
|
||||||
|
- ``/u/multi-mod/m/art``
|
||||||
|
- ``/domain/github.com``
|
||||||
|
|
||||||
|
See [CONTROLS](CONTROLS.md) for the full list of commands.
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
### Configuration File
|
||||||
|
|
||||||
|
Configuration files are stored in the ``{HOME}/.config/ttrv/`` directory.
|
||||||
|
|
||||||
|
Check out [ttrv.cfg](ttrv/templates/ttrv.cfg) for the full list of configurable options. You can clone this file into your home directory by running:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ ttrv --copy-config
|
||||||
|
```
|
||||||
|
|
||||||
|
### Viewing Media Links
|
||||||
|
|
||||||
|
You can use [mailcap](https://en.wikipedia.org/wiki/Media_type#Mailcap) to configure how TTRV will open different types of links.
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img alt="title image" src="https://github.com/tildeclub/ttrv/raw/master/resources/mailcap.gif"/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
A mailcap file allows you to associate different MIME media types, like ``image/jpeg`` or ``video/mp4``, with shell commands. This feature is disabled by default because it takes a few extra steps to configure. To get started, copy the default mailcap template to your home directory.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ ttrv --copy-mailcap
|
||||||
|
```
|
||||||
|
|
||||||
|
This template contains examples for common MIME types that work with popular reddit websites like *imgur*, *youtube*, and *gfycat*. Open the mailcap template and follow the [instructions](ttrv/templates/mailcap) listed inside.
|
||||||
|
|
||||||
|
Once you've setup your mailcap file, enable it by launching ttrv with the ``ttrv --enable-media`` flag (or set it in your **ttrv.cfg**)
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
The default programs that TTRV interacts with can be configured through environment variables:
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td><strong>$TTRV_EDITOR</strong></td>
|
||||||
|
<td>A program used to compose text submissions and comments, e.g. <strong>vim</strong>, <strong>emacs</strong>, <strong>gedit</strong>
|
||||||
|
<br/> <em>If not specified, will fallback to $VISUAL and $EDITOR in that order.</em></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>$TTRV_BROWSER</strong></td>
|
||||||
|
<td>A program used to open links to external websites, e.g. <strong>firefox</strong>, <strong>google-chrome</strong>, <strong>w3m</strong>, <strong>lynx</strong>
|
||||||
|
<br/> <em>If not specified, will fallback to $BROWSER, or your system's default browser.</em></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>$TTRV_URLVIEWER</strong></td>
|
||||||
|
<td>A tool used to extract hyperlinks from blocks of text, e.g. <a href=https://github.com/sigpipe/urlview>urlview</a>, <a href=https://github.com/firecat53/urlscan>urlscan</a>
|
||||||
|
<br/> <em>If not specified, will fallback to urlview if it is installed.</em></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
### Clipboard
|
||||||
|
|
||||||
|
TTRV supports copying submission links to the OS clipboard. On macOS this is supported out of the box.
|
||||||
|
On Linux systems you will need to install either [xsel](http://www.vergenet.net/~conrad/software/xsel/) or [xclip](https://sourceforge.net/projects/xclip/).
|
||||||
|
|
||||||
|
## Themes
|
||||||
|
|
||||||
|
Themes can be used to customize the look and feel of TTRV
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<p><strong>Solarized Dark</strong></p>
|
||||||
|
<img src="https://github.com/tildeclub/ttrv/raw/master/resources/theme_solarized_dark.png"></img>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<p><strong>Solarized Light</strong></p>
|
||||||
|
<img src="https://github.com/tildeclub/ttrv/raw/master/resources/theme_solarized_light.png"></img>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<p><strong>Papercolor</strong></p>
|
||||||
|
<img src="https://github.com/tildeclub/ttrv/raw/master/resources/theme_papercolor.png"></img>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<p><strong>Molokai</strong></p>
|
||||||
|
<img src="https://github.com/tildeclub/ttrv/raw/master/resources/theme_molokai.png"></img>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
You can list all installed themes with the ``--list-themes`` command, and select one with ``--theme``. You can save your choice permanently in your [ttrv.cfg](ttrv/templates/ttrv.cfg) file. You can also use the <kbd>F2</kbd> & <kbd>F3</kbd> keys inside of TTRV to cycle through all available themes.
|
||||||
|
|
||||||
|
For instructions on writing and installing your own themes, see [THEMES.md](THEMES.md).
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Why am I getting an error during installation/when launching ttrv?</summary>
|
||||||
|
|
||||||
|
> If your distro ships with an older version of python 2.7 or python-requests,
|
||||||
|
> you may experience SSL errors or other package incompatibilities. The
|
||||||
|
> easiest way to fix this is to install ttrv using python 3. If you
|
||||||
|
> don't already have pip3, see http://stackoverflow.com/a/6587528 for setup
|
||||||
|
> instructions. Then do
|
||||||
|
>
|
||||||
|
> ```bash
|
||||||
|
> $ sudo pip uninstall ttrv
|
||||||
|
> $ sudo pip3 install -U ttrv
|
||||||
|
> ```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
<details>
|
||||||
|
<summary>Why do I see garbled text like <em>M-b~@M-"</em> or <em>^@</em>?</summary>
|
||||||
|
|
||||||
|
> This type of text usually shows up when python is unable to render
|
||||||
|
> unicode properly.
|
||||||
|
>
|
||||||
|
> 1. Try starting TTRV in ascii-only mode with ``ttrv --ascii``
|
||||||
|
> 2. Make sure that the terminal/font that you're using supports unicode
|
||||||
|
> 3. Try [setting the LOCALE to utf-8](https://perlgeek.de/en/article/set-up-a-clean-utf8-environment)
|
||||||
|
> 4. Your python may have been built against the wrong curses library,
|
||||||
|
> see [here](stackoverflow.com/questions/19373027) and
|
||||||
|
> [here](https://bugs.python.org/issue4787) for more information
|
||||||
|
|
||||||
|
</details>
|
||||||
|
<details>
|
||||||
|
<summary>How do I run the code directly from the repository?</summary>
|
||||||
|
|
||||||
|
> This project is structured to be run as a python *module*. This means that
|
||||||
|
> you need to launch it using python's ``-m`` flag. See the example below, which
|
||||||
|
> assumes that you have cloned the repository into the directory **~/ttrv_project**.
|
||||||
|
>
|
||||||
|
> ```bash
|
||||||
|
> $ cd ~/ttrv_project
|
||||||
|
> $ python3 -m ttrv
|
||||||
|
> ```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
All feedback and suggestions are welcome, just post an issue!
|
||||||
|
|
||||||
|
Before writing any code, please read the [Contributor Guidelines](CONTRIBUTING.rst).
|
||||||
|
|
||||||
|
## License
|
||||||
|
This project is distributed under the [MIT](LICENSE) license.
|
||||||
|
|
|
@ -0,0 +1,215 @@
|
||||||
|
# Themes
|
||||||
|
|
||||||
|
## Installing Themes
|
||||||
|
|
||||||
|
You can install custom themes by copying them into your **~/.config/ttrv/themes/**
|
||||||
|
directory. The name of the theme will match the name of the file.
|
||||||
|
|
||||||
|
```
|
||||||
|
$ cp my-custom-theme.cfg ~/.config/ttrv/themes/
|
||||||
|
$ ttrv --theme my-custom-theme
|
||||||
|
```
|
||||||
|
|
||||||
|
If you've created a cool theme and would like to share it with the community,
|
||||||
|
please submit a pull request!
|
||||||
|
|
||||||
|
## A quick primer on ANSI colors
|
||||||
|
|
||||||
|
Color support on modern terminals can be split into 4 categories:
|
||||||
|
|
||||||
|
1. No support for colors
|
||||||
|
2. 8 system colors - Black, Red, Green, Yellow, Blue, Magenta,
|
||||||
|
Cyan, and White
|
||||||
|
3. 16 system colors - Everything above + bright variations
|
||||||
|
4. 256 extended colors - Everything above + 6x6x6 color palette + 24 greyscale colors
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img alt="terminal colors" src="resources/terminal_colors.png"/>
|
||||||
|
<br><i>The 256 terminal color codes, image from <a href=https://github.com/eikenb/terminal-colors>https://github.com/eikenb/terminal-colors</a></i>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
The 16 system colors, along with the default foreground and background,
|
||||||
|
can usually be customized through your terminal's profile settings. The
|
||||||
|
6x6x6 color palette and grayscale colors are constant RGB values across
|
||||||
|
all terminals. TTRV's default theme only uses the 8 primary system colors,
|
||||||
|
which is why it matches the "look and feel" of the terminal that you're
|
||||||
|
running it in.
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img alt="iTerm preferences" src="resources/iterm_preferences.png"/>
|
||||||
|
<br><i>Setting the 16 system colors in iTerm preferences</i>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
The curses library determines your terminal's color support by reading your
|
||||||
|
environment's ``$TERM`` variable, and looking up your terminal's
|
||||||
|
capabilities in the [terminfo](https://linux.die.net/man/5/terminfo)
|
||||||
|
database. You can emulate this behavior by using the ``tput`` command:
|
||||||
|
|
||||||
|
```
|
||||||
|
bash$ export TERM=xterm
|
||||||
|
bash$ tput colors
|
||||||
|
8
|
||||||
|
bash$ export TERM=xterm-256color
|
||||||
|
bash$ tput colors
|
||||||
|
256
|
||||||
|
bash$ export TERM=vt220
|
||||||
|
bash$ tput colors
|
||||||
|
-1
|
||||||
|
```
|
||||||
|
|
||||||
|
In general you should not be setting your ``$TERM`` variable manually,
|
||||||
|
it will be set automatically by you terminal. Often, problems with
|
||||||
|
terminal colors can be traced back to somebody hardcoding
|
||||||
|
``TERM=xterm-256color`` in their .bashrc file.
|
||||||
|
|
||||||
|
## Understanding TTRV Themes
|
||||||
|
|
||||||
|
Here's an example of what an TTRV theme file looks like:
|
||||||
|
|
||||||
|
```
|
||||||
|
[theme]
|
||||||
|
;<element> = <foreground> <background> <attributes>
|
||||||
|
Normal = default default normal
|
||||||
|
Selected = default default normal
|
||||||
|
SelectedCursor = default default reverse
|
||||||
|
|
||||||
|
TitleBar = cyan - bold+reverse
|
||||||
|
OrderBar = yellow - bold
|
||||||
|
OrderBarHighlight = yellow - bold+reverse
|
||||||
|
HelpBar = cyan - bold+reverse
|
||||||
|
Prompt = cyan - bold+reverse
|
||||||
|
NoticeInfo = - - bold
|
||||||
|
NoticeLoading = - - bold
|
||||||
|
NoticeError = - - bold
|
||||||
|
NoticeSuccess = - - bold
|
||||||
|
|
||||||
|
CursorBlock = - - -
|
||||||
|
CursorBar1 = magenta - -
|
||||||
|
CursorBar2 = cyan - -
|
||||||
|
CursorBar3 = green - -
|
||||||
|
CursorBar4 = yellow - -
|
||||||
|
|
||||||
|
CommentAuthor = blue - bold
|
||||||
|
CommentAuthorSelf = green - bold
|
||||||
|
CommentCount = - - -
|
||||||
|
CommentText = - - -
|
||||||
|
Created = - - -
|
||||||
|
Downvote = red - bold
|
||||||
|
Gold = yellow - bold
|
||||||
|
HiddenCommentExpand = - - bold
|
||||||
|
HiddenCommentText = - - -
|
||||||
|
MultiredditName = yellow - bold
|
||||||
|
MultiredditText = - - -
|
||||||
|
NeutralVote = - - bold
|
||||||
|
NSFW = red - bold+reverse
|
||||||
|
Saved = green - -
|
||||||
|
Score = - - -
|
||||||
|
Separator = - - bold
|
||||||
|
Stickied = green - -
|
||||||
|
SubscriptionName = yellow - bold
|
||||||
|
SubscriptionText = - - -
|
||||||
|
SubmissionAuthor = green - bold
|
||||||
|
SubmissionFlair = red - -
|
||||||
|
SubmissionSubreddit = yellow - -
|
||||||
|
SubmissionText = - - -
|
||||||
|
SubmissionTitle = - - bold
|
||||||
|
Upvote = green - bold
|
||||||
|
Link = blue - underline
|
||||||
|
LinkSeen = magenta - underline
|
||||||
|
UserFlair = yellow - bold
|
||||||
|
```
|
||||||
|
|
||||||
|
Every piece of text drawn on the screen is assigned to an ``<element>``,
|
||||||
|
which has three properties:
|
||||||
|
|
||||||
|
- ``<foreground>``: The text color
|
||||||
|
- ``<background>``: The background color
|
||||||
|
- ``<attributes>``: Additional text attributes, like bold or underlined
|
||||||
|
|
||||||
|
### Colors
|
||||||
|
|
||||||
|
The ``<foreground>`` and ``<background>`` properties can be set to any the following values:
|
||||||
|
|
||||||
|
- ``default``, which means use the terminal's default foreground or background color.
|
||||||
|
- The 16 system colors:
|
||||||
|
<p>
|
||||||
|
<table>
|
||||||
|
<tr><td>black</td><td>dark_gray</td></tr>
|
||||||
|
<tr><td>red</td></td><td>bright_red</td></tr>
|
||||||
|
<tr><td>green</td></td><td>bright_green</td></tr>
|
||||||
|
<tr><td>yellow</td></td><td>bright_yellow</td></tr>
|
||||||
|
<tr><td>blue</td></td><td>bright_blue</td></tr>
|
||||||
|
<tr><td>magenta</td></td><td>bright_magenta</td></tr>
|
||||||
|
<tr><td>cyan</td></td><td>bright_cyan</td></tr>
|
||||||
|
<tr><td>light_gray</td></td><td>white</td></tr>
|
||||||
|
</table>
|
||||||
|
</p>
|
||||||
|
- ``ansi_{n}``, where n is between 0 and 255. These will map to their
|
||||||
|
corresponding ANSI colors (see the figure above).
|
||||||
|
- Hex RGB codes, like ``#0F0F0F``, which will be converted to their nearest
|
||||||
|
ANSI color. This is generally not recommended because the conversion process
|
||||||
|
downscales the color resolution and the resulting colors will look "off".
|
||||||
|
|
||||||
|
### Attributes
|
||||||
|
|
||||||
|
The ``<attributes>`` property can be set to any of the following values:
|
||||||
|
|
||||||
|
- ``normal``, ``bold``, ``underline``, or ``standout``.
|
||||||
|
- ``reverse`` will swap the foreground and background colors.
|
||||||
|
|
||||||
|
Attributes can be mixed together using the + symbol. For example,
|
||||||
|
``bold+underline`` will make the text bold and underlined.
|
||||||
|
|
||||||
|
### Modifiers
|
||||||
|
|
||||||
|
TTRV themes use special "modifer" elements to define the default
|
||||||
|
application style. This allows you to do things like set the default
|
||||||
|
background color without needing to set ``<background>`` on every
|
||||||
|
single element. The three modifier elements are:
|
||||||
|
|
||||||
|
- ``Normal`` - The default modifier that applies to all text elements.
|
||||||
|
- ``Selected`` - Applies to text elements that are highlighted on the page.
|
||||||
|
- ``SelectedCursor`` - Like ``Selected``, but only applies to ``CursorBlock``
|
||||||
|
and ``CursorBar{n}`` elements.
|
||||||
|
|
||||||
|
When an element is marked with a ``-`` token, it means inherit the
|
||||||
|
attribute value from the relevant modifier. This is best explained
|
||||||
|
through an example:
|
||||||
|
|
||||||
|
```
|
||||||
|
[theme]
|
||||||
|
;<element> = <foreground> <background> <attributes>
|
||||||
|
Normal = ansi_241 ansi_230 normal
|
||||||
|
Selected = ansi_241 ansi_254 normal
|
||||||
|
|
||||||
|
Link = ansi_33 - underline
|
||||||
|
```
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="resources/theme_modifiers.png"/>
|
||||||
|
<br><i>The default solarized-light theme</i>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
In the snippet above, the ``Link`` element has its background color set
|
||||||
|
to the ``-`` token. This means that it will inherit it's background
|
||||||
|
from either the ``Normal`` (light yellow, ansi_230) or the ``Selected`` (light grey, ansi_254)
|
||||||
|
element, depending on if it's selected or not.
|
||||||
|
|
||||||
|
Compare this with what happens when the ``Link`` background is hard-coded to ``ansi_230``:
|
||||||
|
|
||||||
|
```
|
||||||
|
[theme]
|
||||||
|
;<element> = <foreground> <background> <attributes>
|
||||||
|
Normal = ansi_241 ansi_230 normal
|
||||||
|
Selected = ansi_241 ansi_254 normal
|
||||||
|
|
||||||
|
Link = ansi_33 ansi_230 underline
|
||||||
|
```
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="resources/theme_modifiers_2.png"/>
|
||||||
|
<br><i>The Link element hard-coded to ansi_230</i>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
In this case, the ``Link`` background stays yellow (ansi_230) even when the link is
|
||||||
|
selected by the cursor.
|
|
@ -0,0 +1,10 @@
|
||||||
|
beautifulsoup4==4.5.1
|
||||||
|
decorator==4.0.10
|
||||||
|
kitchen==1.2.4
|
||||||
|
mailcap-fix==0.1.3
|
||||||
|
requests==2.20.0
|
||||||
|
six==1.10.0
|
||||||
|
pytest==3.2.3
|
||||||
|
vcrpy==1.10.5
|
||||||
|
pylint==1.6.5
|
||||||
|
pytest-xdist==1.22.5
|
After Width: | Height: | Size: 609 KiB |
After Width: | Height: | Size: 111 KiB |
After Width: | Height: | Size: 53 KiB |
After Width: | Height: | Size: 7.4 KiB |
After Width: | Height: | Size: 12 KiB |
After Width: | Height: | Size: 2.4 MiB |
After Width: | Height: | Size: 947 KiB |
After Width: | Height: | Size: 33 KiB |
After Width: | Height: | Size: 82 KiB |
After Width: | Height: | Size: 55 KiB |
After Width: | Height: | Size: 55 KiB |
After Width: | Height: | Size: 86 KiB |
After Width: | Height: | Size: 78 KiB |
After Width: | Height: | Size: 87 KiB |
After Width: | Height: | Size: 80 KiB |
After Width: | Height: | Size: 85 KiB |
After Width: | Height: | Size: 168 KiB |
|
@ -0,0 +1,59 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
"""
|
||||||
|
Scrape the project contributors list from Github and update AUTHORS.rst
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
_filepath = os.path.dirname(os.path.relpath(__file__))
|
||||||
|
|
||||||
|
FILENAME = os.path.abspath(os.path.join(_filepath, '..', 'AUTHORS.rst'))
|
||||||
|
URL = "https://api.github.com/repos/tildeclub/ttrv/contributors?per_page=1000"
|
||||||
|
HEADER = """\
|
||||||
|
================
|
||||||
|
TTRV Contributors
|
||||||
|
================
|
||||||
|
|
||||||
|
Thanks to the following people for their contributions to this project.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
|
||||||
|
logging.captureWarnings(True)
|
||||||
|
|
||||||
|
# Request the list of contributors
|
||||||
|
print('GET {}'.format(URL))
|
||||||
|
resp = requests.get(URL)
|
||||||
|
contributors = resp.json()
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
for contributor in contributors:
|
||||||
|
time.sleep(1.0)
|
||||||
|
|
||||||
|
# Request each contributor individually to get the full name
|
||||||
|
print('GET {}'.format(contributor['url']))
|
||||||
|
resp = requests.get(contributor['url'])
|
||||||
|
user = resp.json()
|
||||||
|
|
||||||
|
name = user.get('name') or contributor['login']
|
||||||
|
url = user['html_url']
|
||||||
|
lines.append('* `{} <{}>`_'.format(name, url))
|
||||||
|
|
||||||
|
print('Writing to {}'.format(FILENAME))
|
||||||
|
text = HEADER + '\n'.join(lines)
|
||||||
|
text = text.encode('utf-8')
|
||||||
|
with open(FILENAME, 'wb') as fp:
|
||||||
|
fp.write(text)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
|
@ -0,0 +1,84 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
|
||||||
|
"""
|
||||||
|
Internal tool used to automatically generate an up-to-date version of the tvr
|
||||||
|
man page. Currently this script should be manually ran after each version bump.
|
||||||
|
In the future, it would be nice to have this functionality built into setup.py.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
$ python scripts/build_manpage.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
_filepath = os.path.dirname(os.path.relpath(__file__))
|
||||||
|
ROOT = os.path.abspath(os.path.join(_filepath, '..'))
|
||||||
|
sys.path.insert(0, ROOT)
|
||||||
|
|
||||||
|
import tvr
|
||||||
|
from tvr import config
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
|
||||||
|
parser = config.build_parser()
|
||||||
|
help_text = parser.format_help()
|
||||||
|
help_sections = help_text.split('\n\n')
|
||||||
|
del help_sections[1]
|
||||||
|
|
||||||
|
data = {}
|
||||||
|
print('Fetching version')
|
||||||
|
data['version'] = tvr.__version__
|
||||||
|
print('Fetching release date')
|
||||||
|
data['release_date'] = datetime.utcnow().strftime('%B %d, %Y')
|
||||||
|
print('Fetching synopsis')
|
||||||
|
synopsis = help_sections[0].replace('usage: ', '')
|
||||||
|
synopsis = ' '.join(line.strip() for line in synopsis.split('\n'))
|
||||||
|
data['synopsis'] = synopsis
|
||||||
|
print('Fetching description')
|
||||||
|
data['description'] = help_sections[1]
|
||||||
|
# Build the options section for each argument from the help section
|
||||||
|
# Example Before:
|
||||||
|
# -h, --help show this help message and exit
|
||||||
|
# Example After
|
||||||
|
# .TP
|
||||||
|
# \fB-h\fR, \fB--help\fR
|
||||||
|
# show this help message and exit
|
||||||
|
options = ''
|
||||||
|
lines = help_sections[2].split('\n')[1:] # positional arguments
|
||||||
|
lines.extend(help_sections[3].split('\n')[1:]) # optional arguments
|
||||||
|
lines = [line.strip() for line in lines]
|
||||||
|
arguments = []
|
||||||
|
for line in lines:
|
||||||
|
if line.startswith('-'):
|
||||||
|
arguments.append(line)
|
||||||
|
elif line.startswith('URL'):
|
||||||
|
# Special case for URL which is a positional argument
|
||||||
|
arguments.append(line)
|
||||||
|
else:
|
||||||
|
arguments[-1] = arguments[-1] + ' ' + line
|
||||||
|
for argument in arguments:
|
||||||
|
flag, description = (col.strip() for col in argument.split(' ', 1))
|
||||||
|
flag = ', '.join(r'\fB'+f+r'\fR' for f in flag.split(', '))
|
||||||
|
options += '\n'.join(('.TP', flag, description, '\n'))
|
||||||
|
data['options'] = options
|
||||||
|
print('Fetching license')
|
||||||
|
data['license'] = tvr.__license__
|
||||||
|
print('Fetching copyright')
|
||||||
|
data['copyright'] = tvr.__copyright__
|
||||||
|
# Escape dashes is all of the sections
|
||||||
|
data = {k: v.replace('-', r'\-') for k, v in data.items()}
|
||||||
|
print('Reading from %s/scripts/tvr.1.template' % ROOT)
|
||||||
|
with open(os.path.join(ROOT, 'scripts/tvr.1.template')) as fp:
|
||||||
|
template = fp.read()
|
||||||
|
print('Populating template')
|
||||||
|
out = template.format(**data)
|
||||||
|
print('Writing to %s/tvr.1' % ROOT)
|
||||||
|
with open(os.path.join(ROOT, 'tvr.1'), 'w') as fp:
|
||||||
|
fp.write(out)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
|
@ -0,0 +1,15 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
ROOT="$(dirname "$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )")"
|
||||||
|
|
||||||
|
cd ${ROOT}
|
||||||
|
echo -e "\nTests: "
|
||||||
|
echo "$(wc -l tests/*.py)"
|
||||||
|
echo -e "\nScripts: "
|
||||||
|
echo "$(wc -l scripts/*)"
|
||||||
|
echo -e "\nTemplates: "
|
||||||
|
echo "$(wc -l ttrv/templates/*)"
|
||||||
|
echo -e "\nCode: "
|
||||||
|
echo "$(wc -l ttrv/*.py)"
|
||||||
|
echo -e "\nCombined: "
|
||||||
|
echo "$(cat tests/*.py scripts/* ttrv/templates/* ttrv/*.py | wc -l) total lines"
|
|
@ -0,0 +1,283 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
from __future__ import print_function
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import curses
|
||||||
|
import locale
|
||||||
|
import threading
|
||||||
|
from types import MethodType
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
from vcr import VCR
|
||||||
|
from six.moves.urllib.parse import urlparse, parse_qs
|
||||||
|
|
||||||
|
from ttrv.theme import Theme, ThemeList
|
||||||
|
from ttrv.config import Config
|
||||||
|
from ttrv.packages import praw
|
||||||
|
from ttrv.oauth import OAuthHelper
|
||||||
|
from ttrv.terminal import Terminal
|
||||||
|
from ttrv.objects import curses_session
|
||||||
|
from ttrv.subreddit_page import SubredditPage
|
||||||
|
from ttrv.submission_page import SubmissionPage
|
||||||
|
from ttrv.subscription_page import SubscriptionPage
|
||||||
|
|
||||||
|
try:
|
||||||
|
from unittest import mock
|
||||||
|
except ImportError:
|
||||||
|
import mock
|
||||||
|
|
||||||
|
|
||||||
|
def initialize_vcr():
|
||||||
|
|
||||||
|
def auth_matcher(r1, r2):
|
||||||
|
return (r1.headers.get('authorization') ==
|
||||||
|
r2.headers.get('authorization'))
|
||||||
|
|
||||||
|
def uri_with_query_matcher(r1, r2):
|
||||||
|
p1, p2 = urlparse(r1.uri), urlparse(r2.uri)
|
||||||
|
return (p1[:3] == p2[:3] and
|
||||||
|
parse_qs(p1.query, True) == parse_qs(p2.query, True))
|
||||||
|
|
||||||
|
cassette_dir = os.path.join(os.path.dirname(__file__), 'cassettes')
|
||||||
|
if not os.path.exists(cassette_dir):
|
||||||
|
os.makedirs(cassette_dir)
|
||||||
|
|
||||||
|
filename = os.path.join(cassette_dir, 'demo_theme.yaml')
|
||||||
|
if os.path.exists(filename):
|
||||||
|
record_mode = 'none'
|
||||||
|
else:
|
||||||
|
record_mode = 'once'
|
||||||
|
vcr = VCR(
|
||||||
|
record_mode=record_mode,
|
||||||
|
filter_headers=[('Authorization', '**********')],
|
||||||
|
filter_post_data_parameters=[('refresh_token', '**********')],
|
||||||
|
match_on=['method', 'uri_with_query', 'auth', 'body'],
|
||||||
|
cassette_library_dir=cassette_dir)
|
||||||
|
vcr.register_matcher('auth', auth_matcher)
|
||||||
|
vcr.register_matcher('uri_with_query', uri_with_query_matcher)
|
||||||
|
|
||||||
|
return vcr
|
||||||
|
|
||||||
|
|
||||||
|
# Patch the getch method so we can display multiple notifications or
|
||||||
|
# other elements that require a keyboard input on the screen at the
|
||||||
|
# same time without blocking the main thread.
|
||||||
|
def notification_getch(self):
|
||||||
|
if self.pause_getch:
|
||||||
|
return -1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def prompt_getch(self):
|
||||||
|
while self.pause_getch:
|
||||||
|
time.sleep(1)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def draw_screen(stdscr, reddit, config, theme, oauth):
|
||||||
|
|
||||||
|
threads = []
|
||||||
|
max_y, max_x = stdscr.getmaxyx()
|
||||||
|
mid_x = int(max_x / 2)
|
||||||
|
tall_y, short_y = int(max_y / 3 * 2), int(max_y / 3)
|
||||||
|
|
||||||
|
stdscr.clear()
|
||||||
|
stdscr.refresh()
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# Submission Page
|
||||||
|
# ===================================================================
|
||||||
|
win1 = stdscr.derwin(tall_y - 1, mid_x - 1, 0, 0)
|
||||||
|
term = Terminal(win1, config)
|
||||||
|
term.set_theme(theme)
|
||||||
|
oauth.term = term
|
||||||
|
|
||||||
|
url = 'https://www.reddit.com/r/Python/comments/4dy7xr'
|
||||||
|
with term.loader('Loading'):
|
||||||
|
page = SubmissionPage(reddit, term, config, oauth, url=url)
|
||||||
|
|
||||||
|
# Tweak the data in order to demonstrate the full range of settings
|
||||||
|
data = page.content.get(-1)
|
||||||
|
data['object'].link_flair_text = 'flair'
|
||||||
|
data['object'].gilded = 1
|
||||||
|
data['object'].over_18 = True
|
||||||
|
data['object'].saved = True
|
||||||
|
data.update(page.content.strip_praw_submission(data['object']))
|
||||||
|
data = page.content.get(0)
|
||||||
|
data['object'].author.name = 'kafoozalum'
|
||||||
|
data['object'].stickied = True
|
||||||
|
data['object'].author_flair_text = 'flair'
|
||||||
|
data['object'].likes = True
|
||||||
|
data.update(page.content.strip_praw_comment(data['object']))
|
||||||
|
data = page.content.get(1)
|
||||||
|
data['object'].saved = True
|
||||||
|
data['object'].likes = False
|
||||||
|
data['object'].score_hidden = True
|
||||||
|
data['object'].gilded = 1
|
||||||
|
data.update(page.content.strip_praw_comment(data['object']))
|
||||||
|
data = page.content.get(2)
|
||||||
|
data['object'].author.name = 'kafoozalum'
|
||||||
|
data['object'].body = data['object'].body[:100]
|
||||||
|
data.update(page.content.strip_praw_comment(data['object']))
|
||||||
|
page.content.toggle(9)
|
||||||
|
page.content.toggle(5)
|
||||||
|
page.draw()
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# Subreddit Page
|
||||||
|
# ===================================================================
|
||||||
|
win2 = stdscr.derwin(tall_y - 1, mid_x - 1, 0, mid_x + 1)
|
||||||
|
term = Terminal(win2, config)
|
||||||
|
term.set_theme(theme)
|
||||||
|
oauth.term = term
|
||||||
|
|
||||||
|
with term.loader('Loading'):
|
||||||
|
page = SubredditPage(reddit, term, config, oauth, '/u/saved')
|
||||||
|
|
||||||
|
# Tweak the data in order to demonstrate the full range of settings
|
||||||
|
data = page.content.get(3)
|
||||||
|
data['object'].hide_score = True
|
||||||
|
data['object'].author = None
|
||||||
|
data['object'].saved = False
|
||||||
|
data.update(page.content.strip_praw_submission(data['object']))
|
||||||
|
page.content.order = 'rising'
|
||||||
|
page.nav.cursor_index = 1
|
||||||
|
page.draw()
|
||||||
|
|
||||||
|
term.pause_getch = True
|
||||||
|
term.getch = MethodType(notification_getch, term)
|
||||||
|
thread = threading.Thread(target=term.show_notification,
|
||||||
|
args=('Success',),
|
||||||
|
kwargs={'style': 'Success'})
|
||||||
|
thread.start()
|
||||||
|
threads.append((thread, term))
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# Subscription Page
|
||||||
|
# ===================================================================
|
||||||
|
win3 = stdscr.derwin(short_y, mid_x - 1, tall_y, 0)
|
||||||
|
term = Terminal(win3, config)
|
||||||
|
term.set_theme(theme)
|
||||||
|
oauth.term = term
|
||||||
|
|
||||||
|
with term.loader('Loading'):
|
||||||
|
page = SubscriptionPage(reddit, term, config, oauth, 'popular')
|
||||||
|
page.nav.cursor_index = 1
|
||||||
|
page.draw()
|
||||||
|
|
||||||
|
term.pause_getch = True
|
||||||
|
term.getch = MethodType(notification_getch, term)
|
||||||
|
thread = threading.Thread(target=term.show_notification,
|
||||||
|
args=('Error',),
|
||||||
|
kwargs={'style': 'Error'})
|
||||||
|
thread.start()
|
||||||
|
threads.append((thread, term))
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# Multireddit Page
|
||||||
|
# ===================================================================
|
||||||
|
win4 = stdscr.derwin(short_y, mid_x - 1, tall_y, mid_x + 1)
|
||||||
|
term = Terminal(win4, config)
|
||||||
|
term.set_theme(theme)
|
||||||
|
oauth.term = term
|
||||||
|
|
||||||
|
with term.loader('Loading'):
|
||||||
|
page = SubscriptionPage(reddit, term, config, oauth, 'multireddit')
|
||||||
|
page.nav.cursor_index = 1
|
||||||
|
page.draw()
|
||||||
|
|
||||||
|
term.pause_getch = True
|
||||||
|
term.getch = MethodType(notification_getch, term)
|
||||||
|
thread = threading.Thread(target=term.show_notification,
|
||||||
|
args=('Info',),
|
||||||
|
kwargs={'style': 'Info'})
|
||||||
|
thread.start()
|
||||||
|
threads.append((thread, term))
|
||||||
|
|
||||||
|
term = Terminal(win4, config)
|
||||||
|
term.set_theme(theme)
|
||||||
|
term.pause_getch = True
|
||||||
|
term.getch = MethodType(prompt_getch, term)
|
||||||
|
thread = threading.Thread(target=term.prompt_y_or_n, args=('Prompt: ',))
|
||||||
|
thread.start()
|
||||||
|
threads.append((thread, term))
|
||||||
|
|
||||||
|
time.sleep(0.5)
|
||||||
|
curses.curs_set(0)
|
||||||
|
return threads
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
|
||||||
|
locale.setlocale(locale.LC_ALL, '')
|
||||||
|
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
theme = Theme.from_name(sys.argv[1])
|
||||||
|
else:
|
||||||
|
theme = Theme()
|
||||||
|
|
||||||
|
vcr = initialize_vcr()
|
||||||
|
with vcr.use_cassette('demo_theme.yaml') as cassette, \
|
||||||
|
curses_session() as stdscr:
|
||||||
|
|
||||||
|
config = Config()
|
||||||
|
if vcr.record_mode == 'once':
|
||||||
|
config.load_refresh_token()
|
||||||
|
else:
|
||||||
|
config.refresh_token = 'mock_refresh_token'
|
||||||
|
|
||||||
|
reddit = praw.Reddit(user_agent='TTRV Theme Demo',
|
||||||
|
decode_html_entities=False,
|
||||||
|
disable_update_check=True)
|
||||||
|
reddit.config.api_request_delay = 0
|
||||||
|
|
||||||
|
config.history.add('https://api.reddit.com/comments/6llvsl/_/djutc3s')
|
||||||
|
config.history.add('http://i.imgur.com/Z9iGKWv.gifv')
|
||||||
|
config.history.add('https://www.reddit.com/r/Python/comments/6302cj/rpython_official_job_board/')
|
||||||
|
|
||||||
|
term = Terminal(stdscr, config)
|
||||||
|
term.set_theme()
|
||||||
|
oauth = OAuthHelper(reddit, term, config)
|
||||||
|
oauth.authorize()
|
||||||
|
|
||||||
|
theme_list = ThemeList()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
term = Terminal(stdscr, config)
|
||||||
|
term.set_theme(theme)
|
||||||
|
threads = draw_screen(stdscr, reddit, config, theme, oauth)
|
||||||
|
|
||||||
|
try:
|
||||||
|
ch = term.show_notification(theme.display_string)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
ch = Terminal.ESCAPE
|
||||||
|
|
||||||
|
for thread, term in threads:
|
||||||
|
term.pause_getch = False
|
||||||
|
thread.join()
|
||||||
|
|
||||||
|
if vcr.record_mode == 'once':
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
cassette.play_counts = Counter()
|
||||||
|
|
||||||
|
theme_list.reload()
|
||||||
|
|
||||||
|
if ch == curses.KEY_RIGHT:
|
||||||
|
theme = theme_list.next(theme)
|
||||||
|
elif ch == curses.KEY_LEFT:
|
||||||
|
theme = theme_list.previous(theme)
|
||||||
|
elif ch == Terminal.ESCAPE:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
# Force the theme to reload
|
||||||
|
theme = theme_list.next(theme)
|
||||||
|
theme = theme_list.previous(theme)
|
||||||
|
|
||||||
|
|
||||||
|
sys.exit(main())
|
||||||
|
|
|
@ -0,0 +1,30 @@
|
||||||
|
"""
|
||||||
|
Initialize an authenticated instance of PRAW to interact with.
|
||||||
|
|
||||||
|
$ python -i initialize_session.py
|
||||||
|
"""
|
||||||
|
from ttrv.docs import AGENT
|
||||||
|
from ttrv.packages import praw
|
||||||
|
from ttrv.content import RequestHeaderRateLimiter
|
||||||
|
from ttrv.config import Config
|
||||||
|
|
||||||
|
config = Config()
|
||||||
|
config.load_refresh_token()
|
||||||
|
|
||||||
|
reddit = praw.Reddit(
|
||||||
|
user_agent=AGENT.format(version='test_session'),
|
||||||
|
decode_html_entities=False,
|
||||||
|
disable_update_check=True,
|
||||||
|
timeout=10, # 10 second request timeout
|
||||||
|
handler=RequestHeaderRateLimiter())
|
||||||
|
|
||||||
|
|
||||||
|
reddit.set_oauth_app_info(
|
||||||
|
config['oauth_client_id'],
|
||||||
|
config['oauth_client_secret'],
|
||||||
|
config['oauth_redirect_uri'])
|
||||||
|
reddit.refresh_access_information(config.refresh_token)
|
||||||
|
|
||||||
|
inbox = reddit.get_inbox()
|
||||||
|
items = [next(inbox) for _ in range(20)]
|
||||||
|
pass
|
|
@ -0,0 +1,31 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
|
||||||
|
"""
|
||||||
|
Utility script used to examine the python webbrowser module with different OSs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ['BROWSER'] = 'firefox'
|
||||||
|
|
||||||
|
# If we want to override the $BROWSER variable that the python webbrowser
|
||||||
|
# references, it needs to be done before the webbrowser module is imported
|
||||||
|
# for the first time.
|
||||||
|
TTRV_BROWSER, BROWSER = os.environ.get('TTRV_BROWSER'), os.environ.get('BROWSER')
|
||||||
|
if TTRV_BROWSER:
|
||||||
|
os.environ['BROWSER'] = TTRV_BROWSER
|
||||||
|
|
||||||
|
print('TTRV_BROWSER=%s' % TTRV_BROWSER)
|
||||||
|
print('BROWSER=%s' % BROWSER)
|
||||||
|
|
||||||
|
import webbrowser
|
||||||
|
|
||||||
|
print('webbrowser._browsers:')
|
||||||
|
for key, val in webbrowser._browsers.items():
|
||||||
|
print(' %s: %s' % (key, val))
|
||||||
|
|
||||||
|
print('webbrowser._tryorder:')
|
||||||
|
for name in webbrowser._tryorder:
|
||||||
|
print(' %s' % name)
|
||||||
|
|
||||||
|
webbrowser.open_new_tab('https://www.python.org')
|
|
@ -0,0 +1,9 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Removes any lingering build/release files from the project directory
|
||||||
|
|
||||||
|
find . -type f -name '*.pyc' -delete
|
||||||
|
find . -type f -name '*.pyo' -delete
|
||||||
|
find . -type d -name '__pycache__' -exec rm -rv {} +
|
||||||
|
find . -type d -name 'build' -exec rm -rv {} +
|
||||||
|
find . -type d -name 'dist' -exec rm -rv {} +
|
||||||
|
find . -type d -name '*.egg-info' -exec rm -rv {} +
|
|
@ -0,0 +1,47 @@
|
||||||
|
.TH "TTRV" "1" "{release_date}" "Version {version}" "Usage and Commands"
|
||||||
|
.SH NAME
|
||||||
|
TTRV - Reddit Terminal Viewer
|
||||||
|
.SH SYNOPSIS
|
||||||
|
{synopsis}
|
||||||
|
.SH DESCRIPTION
|
||||||
|
{description}
|
||||||
|
.SH OPTIONS
|
||||||
|
{options}
|
||||||
|
.SH CONTROLS
|
||||||
|
Move the cursor using the arrow keys or vim style movement.
|
||||||
|
.br
|
||||||
|
Press \fBup\fR and \fBdown\fR to scroll through submissions.
|
||||||
|
.br
|
||||||
|
Press \fBright\fR to view the selected submission and \fBleft\fR to return.
|
||||||
|
.br
|
||||||
|
Press \fB?\fR to open the help screen.
|
||||||
|
.SH FILES
|
||||||
|
.TP
|
||||||
|
.BR $XDG_CONFIG_HOME/ttrv/ttrv.cfg
|
||||||
|
The configuration file can be used to customize default program settings.
|
||||||
|
.TP
|
||||||
|
.BR $XDG_DATA_HOME/ttrv/refresh-token
|
||||||
|
After you login to reddit, your most recent OAuth refresh token will be stored
|
||||||
|
for future sessions.
|
||||||
|
.TP
|
||||||
|
.BR $XDG_DATA_HOME/ttrv/history.log
|
||||||
|
This file stores URLs that have been recently opened in order to
|
||||||
|
visually highlight them as "seen".
|
||||||
|
.SH ENVIRONMENT
|
||||||
|
.TP
|
||||||
|
.BR TTRV_EDITOR
|
||||||
|
Text editor to use when editing comments and submissions. Will fallback to
|
||||||
|
\fI$EDITOR\fR.
|
||||||
|
.TP
|
||||||
|
.BR TTRV_URLVIEWER
|
||||||
|
Url viewer to use to extract links from comments. Requires a compatible
|
||||||
|
program to be installed.
|
||||||
|
.TP
|
||||||
|
.BR TTRV_BROWSER
|
||||||
|
Web browser to use when opening links. Will fallback to \fI$BROWSER\fR.
|
||||||
|
.SH AUTHOR
|
||||||
|
Michael Lazar <lazar.michael22@gmail.com> (2017).
|
||||||
|
.SH BUGS
|
||||||
|
Report bugs to \fIhttps://github.com/tildeclub/ttrv/issues\fR
|
||||||
|
.SH LICENSE
|
||||||
|
{license}
|
|
@ -0,0 +1,49 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
"""
|
||||||
|
Update the project's bundled dependencies by downloading the git repository and
|
||||||
|
copying over the most recent commit.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
_filepath = os.path.dirname(os.path.relpath(__file__))
|
||||||
|
ROOT = os.path.abspath(os.path.join(_filepath, '..'))
|
||||||
|
|
||||||
|
PRAW_REPO = 'https://github.com/michael-lazar/praw3.git'
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
|
||||||
|
tmpdir = tempfile.mkdtemp()
|
||||||
|
subprocess.check_call(['git', 'clone', PRAW_REPO, tmpdir])
|
||||||
|
|
||||||
|
# Update the commit hash reference
|
||||||
|
os.chdir(tmpdir)
|
||||||
|
p = subprocess.Popen(['git', 'rev-parse', 'HEAD'], stdout=subprocess.PIPE)
|
||||||
|
p.wait()
|
||||||
|
commit = p.stdout.read().strip()
|
||||||
|
print('Found commit %s' % commit)
|
||||||
|
regex = 's/^__praw_hash__ =.*$/__praw_hash__ = \'%s\'/g' % commit
|
||||||
|
packages_root = os.path.join(ROOT, 'ttrv', 'packages', '__init__.py')
|
||||||
|
print('Updating commit hash in %s' % packages_root)
|
||||||
|
subprocess.check_call(['sed', '-i', '', regex, packages_root])
|
||||||
|
|
||||||
|
# Overwrite the project files
|
||||||
|
src = os.path.join(tmpdir, 'praw')
|
||||||
|
dest = os.path.join(ROOT, 'ttrv', 'packages', 'praw')
|
||||||
|
print('Copying package files to %s' % dest)
|
||||||
|
shutil.rmtree(dest, ignore_errors=True)
|
||||||
|
shutil.copytree(src, dest)
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
print('Removing directory %s' % tmpdir)
|
||||||
|
shutil.rmtree(tmpdir)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
|
@ -0,0 +1,86 @@
|
||||||
|
import sys
|
||||||
|
import codecs
|
||||||
|
import setuptools
|
||||||
|
|
||||||
|
from version import __version__ as version
|
||||||
|
|
||||||
|
|
||||||
|
install_requires = [
|
||||||
|
'beautifulsoup4',
|
||||||
|
'decorator',
|
||||||
|
'kitchen',
|
||||||
|
'requests >=2.4.0', # https://github.com/tildeclub/ttrv/issues/325
|
||||||
|
'six',
|
||||||
|
]
|
||||||
|
|
||||||
|
tests_require = [
|
||||||
|
'coveralls',
|
||||||
|
'pytest>=3.1.0', # Pinned for the ``pytest.param`` method
|
||||||
|
'coverage',
|
||||||
|
'mock',
|
||||||
|
'pylint',
|
||||||
|
'vcrpy',
|
||||||
|
]
|
||||||
|
|
||||||
|
extras_require = {
|
||||||
|
'test': tests_require
|
||||||
|
}
|
||||||
|
|
||||||
|
# https://hynek.me/articles/conditional-python-dependencies/
|
||||||
|
if int(setuptools.__version__.split(".", 1)[0]) < 18:
|
||||||
|
assert "bdist_wheel" not in sys.argv
|
||||||
|
if sys.version_info[0:2] < (3, 6):
|
||||||
|
install_requires.append("mailcap-fix")
|
||||||
|
else:
|
||||||
|
# Building the bdist_wheel with conditional environment dependencies
|
||||||
|
# requires setuptools version > 18. For older setuptools versions this
|
||||||
|
# will raise an error.
|
||||||
|
extras_require.update({":python_version<'3.6'": ["mailcap-fix"]})
|
||||||
|
|
||||||
|
|
||||||
|
def long_description():
|
||||||
|
with codecs.open('README.md', encoding='utf8') as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
|
||||||
|
setuptools.setup(
|
||||||
|
name='ttrv',
|
||||||
|
version=version,
|
||||||
|
description='Tilde Terminal Reddit Viewer',
|
||||||
|
long_description=long_description(),
|
||||||
|
long_description_content_type='text/markdown',
|
||||||
|
url='https://github.com/tildeclub/ttrv',
|
||||||
|
author='deepend (forked from RTV)',
|
||||||
|
author_email='deepend@tilde.club',
|
||||||
|
license='MIT',
|
||||||
|
keywords='reddit terminal praw curses',
|
||||||
|
packages=[
|
||||||
|
'ttrv',
|
||||||
|
'ttrv.packages',
|
||||||
|
'ttrv.packages.praw'
|
||||||
|
],
|
||||||
|
package_data={
|
||||||
|
'ttrv': ['templates/*', 'themes/*'],
|
||||||
|
'ttrv.packages.praw': ['praw.ini']
|
||||||
|
},
|
||||||
|
data_files=[("share/man/man1", ["ttrv.1"])],
|
||||||
|
install_requires=install_requires,
|
||||||
|
tests_require=tests_require,
|
||||||
|
extras_require=extras_require,
|
||||||
|
entry_points={'console_scripts': ['ttrv=ttrv.__main__:main']},
|
||||||
|
classifiers=[
|
||||||
|
'Intended Audience :: End Users/Desktop',
|
||||||
|
'Environment :: Console :: Curses',
|
||||||
|
'Operating System :: MacOS :: MacOS X',
|
||||||
|
'Operating System :: POSIX',
|
||||||
|
'Natural Language :: English',
|
||||||
|
'Programming Language :: Python :: 2.7',
|
||||||
|
'Programming Language :: Python :: 3.4',
|
||||||
|
'Programming Language :: Python :: 3.5',
|
||||||
|
'Programming Language :: Python :: 3.6',
|
||||||
|
'Programming Language :: Python :: 3.7',
|
||||||
|
'Topic :: Terminals',
|
||||||
|
'Topic :: Internet :: WWW/HTTP :: Dynamic Content :: Message Boards',
|
||||||
|
'Topic :: Internet :: WWW/HTTP :: Dynamic Content :: News/Diary',
|
||||||
|
],
|
||||||
|
)
|
|
@ -0,0 +1,119 @@
|
||||||
|
.TH "TTRV" "1" "June 03, 2019" "Version 1.27.0" "Usage and Commands"
|
||||||
|
.SH NAME
|
||||||
|
TTRV - Tilde Terminal Reddit Viewer
|
||||||
|
.SH SYNOPSIS
|
||||||
|
ttrv [URL] [\-s SUBREDDIT]
|
||||||
|
.SH DESCRIPTION
|
||||||
|
TTRV (Tilde Terminal Reddit Viewer) is a terminal interface to view and interact with reddit.
|
||||||
|
.SH OPTIONS
|
||||||
|
.TP
|
||||||
|
\fBURL\fR
|
||||||
|
[optional] Full URL of a submission to open
|
||||||
|
|
||||||
|
.TP
|
||||||
|
\fB\-h\fR, \fB\-\-help\fR
|
||||||
|
show this help message and exit
|
||||||
|
|
||||||
|
.TP
|
||||||
|
\fB\-s SUBREDDIT\fR
|
||||||
|
Name of the subreddit that will be loaded on start
|
||||||
|
|
||||||
|
.TP
|
||||||
|
\fB\-\-log FILE\fR
|
||||||
|
Log HTTP requests to the given file
|
||||||
|
|
||||||
|
.TP
|
||||||
|
\fB\-\-config FILE\fR
|
||||||
|
Load configuration settings from the given file
|
||||||
|
|
||||||
|
.TP
|
||||||
|
\fB\-\-ascii\fR
|
||||||
|
Enable ascii\-only mode
|
||||||
|
|
||||||
|
.TP
|
||||||
|
\fB\-\-monochrome\fR
|
||||||
|
Disable color
|
||||||
|
|
||||||
|
.TP
|
||||||
|
\fB\-\-theme FILE\fR
|
||||||
|
Color theme to use, see \-\-list\-themes for valid options
|
||||||
|
|
||||||
|
.TP
|
||||||
|
\fB\-\-list\-themes\fR
|
||||||
|
List all of the available color themes
|
||||||
|
|
||||||
|
.TP
|
||||||
|
\fB\-\-non\-persistent\fR
|
||||||
|
Forget the authenticated user when the program exits
|
||||||
|
|
||||||
|
.TP
|
||||||
|
\fB\-\-no\-autologin\fR
|
||||||
|
Do not authenticate automatically on startup
|
||||||
|
|
||||||
|
.TP
|
||||||
|
\fB\-\-clear\-auth\fR
|
||||||
|
Remove any saved user data before launching
|
||||||
|
|
||||||
|
.TP
|
||||||
|
\fB\-\-copy\-config\fR
|
||||||
|
Copy the default configuration to {HOME}/.config/ttrv/ttrv.cfg
|
||||||
|
|
||||||
|
.TP
|
||||||
|
\fB\-\-copy\-mailcap\fR
|
||||||
|
Copy an example mailcap configuration to {HOME}/.mailcap
|
||||||
|
|
||||||
|
.TP
|
||||||
|
\fB\-\-enable\-media\fR
|
||||||
|
Open external links using programs defined in the mailcap config
|
||||||
|
|
||||||
|
.TP
|
||||||
|
\fB\-V\fR, \fB\-\-version\fR
|
||||||
|
show program's version number and exit
|
||||||
|
|
||||||
|
.TP
|
||||||
|
\fB\-\-no\-flash\fR
|
||||||
|
Disable screen flashing
|
||||||
|
|
||||||
|
.TP
|
||||||
|
\fB\-\-debug\-info\fR
|
||||||
|
Show system and environment information and exit
|
||||||
|
|
||||||
|
|
||||||
|
.SH CONTROLS
|
||||||
|
Move the cursor using the arrow keys or vim style movement.
|
||||||
|
.br
|
||||||
|
Press \fBup\fR and \fBdown\fR to scroll through submissions.
|
||||||
|
.br
|
||||||
|
Press \fBright\fR to view the selected submission and \fBleft\fR to return.
|
||||||
|
.br
|
||||||
|
Press \fB?\fR to open the help screen.
|
||||||
|
.SH FILES
|
||||||
|
.TP
|
||||||
|
.BR $XDG_CONFIG_HOME/ttrv/ttrv.cfg
|
||||||
|
The configuration file can be used to customize default program settings.
|
||||||
|
.TP
|
||||||
|
.BR $XDG_DATA_HOME/ttrv/refresh-token
|
||||||
|
After you login to reddit, your most recent OAuth refresh token will be stored
|
||||||
|
for future sessions.
|
||||||
|
.TP
|
||||||
|
.BR $XDG_DATA_HOME/ttrv/history.log
|
||||||
|
This file stores URLs that have been recently opened in order to
|
||||||
|
visually highlight them as "seen".
|
||||||
|
.SH ENVIRONMENT
|
||||||
|
.TP
|
||||||
|
.BR TTRV_EDITOR
|
||||||
|
Text editor to use when editing comments and submissions. Will fallback to
|
||||||
|
\fI$EDITOR\fR.
|
||||||
|
.TP
|
||||||
|
.BR TTRV_URLVIEWER
|
||||||
|
Url viewer to use to extract links from comments. Requires a compatible
|
||||||
|
program to be installed.
|
||||||
|
.TP
|
||||||
|
.BR TTRV_BROWSER
|
||||||
|
Web browser to use when opening links. Will fallback to \fI$BROWSER\fR.
|
||||||
|
.SH AUTHOR
|
||||||
|
deepend <root@tilde.club> (2017).
|
||||||
|
.SH BUGS
|
||||||
|
Report bugs to \fIhttps://github.com/tildeclub/ttrv/issues\fR
|
||||||
|
.SH LICENSE
|
||||||
|
The MIT License (MIT)
|
|
@ -0,0 +1,258 @@
|
||||||
|
Metadata-Version: 2.1
|
||||||
|
Name: ttrv
|
||||||
|
Version: 1.27.3
|
||||||
|
Summary: Tilde Terminal Reddit Viewer
|
||||||
|
Home-page: https://github.com/tildeclub/ttrv
|
||||||
|
Author: deepend (forked from RTV)
|
||||||
|
Author-email: deepend@tilde.club
|
||||||
|
License: MIT
|
||||||
|
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.5
|
||||||
|
Classifier: Programming Language :: Python :: 3.6
|
||||||
|
Classifier: Programming Language :: Python :: 3.7
|
||||||
|
Classifier: Topic :: Terminals
|
||||||
|
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content :: Message Boards
|
||||||
|
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content :: News/Diary
|
||||||
|
Description-Content-Type: text/markdown
|
||||||
|
Provides-Extra: test
|
||||||
|
License-File: LICENSE
|
||||||
|
License-File: AUTHORS.rst
|
||||||
|
|
||||||
|
<h1 align="center">Tilde Terminal Reddit Viewer (TTRV)</h1>
|
||||||
|
<p>Forked from Original source/development at: <a href="https://github.com/michael-lazar/rtv">RTV</a></p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
A text-based interface (TUI) to view and interact with Reddit from your terminal.<br>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img alt="title image" src="https://github.com/tildeclub/ttrv/raw/master/resources/title_image.png"/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
* [Demo](#demo)
|
||||||
|
* [Installation](#installation)
|
||||||
|
* [Usage](#usage)
|
||||||
|
* [Settings](#settings)
|
||||||
|
* [Themes](#themes)
|
||||||
|
* [FAQ](#faq)
|
||||||
|
* [Contributing](#contributing)
|
||||||
|
* [License](#license)
|
||||||
|
|
||||||
|
## Demo
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img alt="title image" src="https://github.com/tildeclub/ttrv/raw/master/resources/demo.gif"/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### PyPI package
|
||||||
|
|
||||||
|
TTRV is available on [PyPI](https://pypi.python.org/pypi/ttrv/) and can be installed with pip:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ pip install ttrv
|
||||||
|
```
|
||||||
|
|
||||||
|
### From source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ git clone https://github.com/tildeclub/ttrv.git
|
||||||
|
$ cd ttrv/
|
||||||
|
$ python setup.py install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
TTRV is not supported on Windows but you can enable Windows subsystem for Linux, download your preferred Linux distribution from Microsoft Store and access it from there.
|
||||||
|
|
||||||
|
To open links on Edge, paste the line below to ``{HOME}/.bashrc``
|
||||||
|
```
|
||||||
|
export BROWSER='/mnt/c/Program Files (x86)/Microsoft/Edge/Application/msedge.exe'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
To run the program, type:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ ttrv --help
|
||||||
|
```
|
||||||
|
|
||||||
|
### Controls
|
||||||
|
|
||||||
|
Move the cursor using either the arrow keys or *Vim* style movement:
|
||||||
|
|
||||||
|
- Press <kbd>▲</kbd> and <kbd>▼</kbd> to scroll through submissions
|
||||||
|
- Press <kbd>▶</kbd> to view the selected submission and <kbd>◀</kbd> to return
|
||||||
|
- Press <kbd>space-bar</kbd> to expand/collapse comments
|
||||||
|
- Press <kbd>u</kbd> to login (this requires a web browser for [OAuth](https://github.com/reddit-archive/reddit/wiki/oauth2))
|
||||||
|
- Press <kbd>?</kbd> to open the help screen
|
||||||
|
|
||||||
|
Press <kbd>/</kbd> to open the navigation prompt, where you can type things like:
|
||||||
|
|
||||||
|
- ``/front``
|
||||||
|
- ``/r/commandprompt+linuxmasterrace``
|
||||||
|
- ``/r/programming/controversial``
|
||||||
|
- ``/u/me``
|
||||||
|
- ``/u/multi-mod/m/art``
|
||||||
|
- ``/domain/github.com``
|
||||||
|
|
||||||
|
See [CONTROLS](CONTROLS.md) for the full list of commands.
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
### Configuration File
|
||||||
|
|
||||||
|
Configuration files are stored in the ``{HOME}/.config/ttrv/`` directory.
|
||||||
|
|
||||||
|
Check out [ttrv.cfg](ttrv/templates/ttrv.cfg) for the full list of configurable options. You can clone this file into your home directory by running:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ ttrv --copy-config
|
||||||
|
```
|
||||||
|
|
||||||
|
### Viewing Media Links
|
||||||
|
|
||||||
|
You can use [mailcap](https://en.wikipedia.org/wiki/Media_type#Mailcap) to configure how TTRV will open different types of links.
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img alt="title image" src="https://github.com/tildeclub/ttrv/raw/master/resources/mailcap.gif"/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
A mailcap file allows you to associate different MIME media types, like ``image/jpeg`` or ``video/mp4``, with shell commands. This feature is disabled by default because it takes a few extra steps to configure. To get started, copy the default mailcap template to your home directory.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ ttrv --copy-mailcap
|
||||||
|
```
|
||||||
|
|
||||||
|
This template contains examples for common MIME types that work with popular reddit websites like *imgur*, *youtube*, and *gfycat*. Open the mailcap template and follow the [instructions](ttrv/templates/mailcap) listed inside.
|
||||||
|
|
||||||
|
Once you've setup your mailcap file, enable it by launching ttrv with the ``ttrv --enable-media`` flag (or set it in your **ttrv.cfg**)
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
The default programs that TTRV interacts with can be configured through environment variables:
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td><strong>$TTRV_EDITOR</strong></td>
|
||||||
|
<td>A program used to compose text submissions and comments, e.g. <strong>vim</strong>, <strong>emacs</strong>, <strong>gedit</strong>
|
||||||
|
<br/> <em>If not specified, will fallback to $VISUAL and $EDITOR in that order.</em></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>$TTRV_BROWSER</strong></td>
|
||||||
|
<td>A program used to open links to external websites, e.g. <strong>firefox</strong>, <strong>google-chrome</strong>, <strong>w3m</strong>, <strong>lynx</strong>
|
||||||
|
<br/> <em>If not specified, will fallback to $BROWSER, or your system's default browser.</em></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>$TTRV_URLVIEWER</strong></td>
|
||||||
|
<td>A tool used to extract hyperlinks from blocks of text, e.g. <a href=https://github.com/sigpipe/urlview>urlview</a>, <a href=https://github.com/firecat53/urlscan>urlscan</a>
|
||||||
|
<br/> <em>If not specified, will fallback to urlview if it is installed.</em></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
### Clipboard
|
||||||
|
|
||||||
|
TTRV supports copying submission links to the OS clipboard. On macOS this is supported out of the box.
|
||||||
|
On Linux systems you will need to install either [xsel](http://www.vergenet.net/~conrad/software/xsel/) or [xclip](https://sourceforge.net/projects/xclip/).
|
||||||
|
|
||||||
|
## Themes
|
||||||
|
|
||||||
|
Themes can be used to customize the look and feel of TTRV
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<p><strong>Solarized Dark</strong></p>
|
||||||
|
<img src="https://github.com/tildeclub/ttrv/raw/master/resources/theme_solarized_dark.png"></img>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<p><strong>Solarized Light</strong></p>
|
||||||
|
<img src="https://github.com/tildeclub/ttrv/raw/master/resources/theme_solarized_light.png"></img>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<p><strong>Papercolor</strong></p>
|
||||||
|
<img src="https://github.com/tildeclub/ttrv/raw/master/resources/theme_papercolor.png"></img>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<p><strong>Molokai</strong></p>
|
||||||
|
<img src="https://github.com/tildeclub/ttrv/raw/master/resources/theme_molokai.png"></img>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
You can list all installed themes with the ``--list-themes`` command, and select one with ``--theme``. You can save your choice permanently in your [ttrv.cfg](ttrv/templates/ttrv.cfg) file. You can also use the <kbd>F2</kbd> & <kbd>F3</kbd> keys inside of TTRV to cycle through all available themes.
|
||||||
|
|
||||||
|
For instructions on writing and installing your own themes, see [THEMES.md](THEMES.md).
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Why am I getting an error during installation/when launching ttrv?</summary>
|
||||||
|
|
||||||
|
> If your distro ships with an older version of python 2.7 or python-requests,
|
||||||
|
> you may experience SSL errors or other package incompatibilities. The
|
||||||
|
> easiest way to fix this is to install ttrv using python 3. If you
|
||||||
|
> don't already have pip3, see http://stackoverflow.com/a/6587528 for setup
|
||||||
|
> instructions. Then do
|
||||||
|
>
|
||||||
|
> ```bash
|
||||||
|
> $ sudo pip uninstall ttrv
|
||||||
|
> $ sudo pip3 install -U ttrv
|
||||||
|
> ```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
<details>
|
||||||
|
<summary>Why do I see garbled text like <em>M-b~@M-"</em> or <em>^@</em>?</summary>
|
||||||
|
|
||||||
|
> This type of text usually shows up when python is unable to render
|
||||||
|
> unicode properly.
|
||||||
|
>
|
||||||
|
> 1. Try starting TTRV in ascii-only mode with ``ttrv --ascii``
|
||||||
|
> 2. Make sure that the terminal/font that you're using supports unicode
|
||||||
|
> 3. Try [setting the LOCALE to utf-8](https://perlgeek.de/en/article/set-up-a-clean-utf8-environment)
|
||||||
|
> 4. Your python may have been built against the wrong curses library,
|
||||||
|
> see [here](stackoverflow.com/questions/19373027) and
|
||||||
|
> [here](https://bugs.python.org/issue4787) for more information
|
||||||
|
|
||||||
|
</details>
|
||||||
|
<details>
|
||||||
|
<summary>How do I run the code directly from the repository?</summary>
|
||||||
|
|
||||||
|
> This project is structured to be run as a python *module*. This means that
|
||||||
|
> you need to launch it using python's ``-m`` flag. See the example below, which
|
||||||
|
> assumes that you have cloned the repository into the directory **~/ttrv_project**.
|
||||||
|
>
|
||||||
|
> ```bash
|
||||||
|
> $ cd ~/ttrv_project
|
||||||
|
> $ python3 -m ttrv
|
||||||
|
> ```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
All feedback and suggestions are welcome, just post an issue!
|
||||||
|
|
||||||
|
Before writing any code, please read the [Contributor Guidelines](CONTRIBUTING.rst).
|
||||||
|
|
||||||
|
## License
|
||||||
|
This project is distributed under the [MIT](LICENSE) license.
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,54 @@
|
||||||
|
AUTHORS.rst
|
||||||
|
CHANGELOG.rst
|
||||||
|
LICENSE
|
||||||
|
MANIFEST.in
|
||||||
|
README.md
|
||||||
|
setup.cfg
|
||||||
|
setup.py
|
||||||
|
ttrv.1
|
||||||
|
version.py
|
||||||
|
ttrv/__init__.py
|
||||||
|
ttrv/__main__.py
|
||||||
|
ttrv/__version__.py
|
||||||
|
ttrv/clipboard.py
|
||||||
|
ttrv/config.py
|
||||||
|
ttrv/content.py
|
||||||
|
ttrv/docs.py
|
||||||
|
ttrv/exceptions.py
|
||||||
|
ttrv/inbox_page.py
|
||||||
|
ttrv/mime_parsers.py
|
||||||
|
ttrv/oauth.py
|
||||||
|
ttrv/objects.py
|
||||||
|
ttrv/page.py
|
||||||
|
ttrv/submission_page.py
|
||||||
|
ttrv/subreddit_page.py
|
||||||
|
ttrv/subscription_page.py
|
||||||
|
ttrv/terminal.py
|
||||||
|
ttrv/theme.py
|
||||||
|
ttrv.egg-info/PKG-INFO
|
||||||
|
ttrv.egg-info/SOURCES.txt
|
||||||
|
ttrv.egg-info/dependency_links.txt
|
||||||
|
ttrv.egg-info/entry_points.txt
|
||||||
|
ttrv.egg-info/requires.txt
|
||||||
|
ttrv.egg-info/top_level.txt
|
||||||
|
ttrv/packages/__init__.py
|
||||||
|
ttrv/packages/praw/__init__.py
|
||||||
|
ttrv/packages/praw/decorator_helpers.py
|
||||||
|
ttrv/packages/praw/decorators.py
|
||||||
|
ttrv/packages/praw/errors.py
|
||||||
|
ttrv/packages/praw/handlers.py
|
||||||
|
ttrv/packages/praw/helpers.py
|
||||||
|
ttrv/packages/praw/internal.py
|
||||||
|
ttrv/packages/praw/multiprocess.py
|
||||||
|
ttrv/packages/praw/objects.py
|
||||||
|
ttrv/packages/praw/praw.ini
|
||||||
|
ttrv/packages/praw/settings.py
|
||||||
|
ttrv/templates/index.html
|
||||||
|
ttrv/templates/mailcap
|
||||||
|
ttrv/templates/ttrv.cfg
|
||||||
|
ttrv/themes/colorblind-dark.cfg
|
||||||
|
ttrv/themes/default.cfg.example
|
||||||
|
ttrv/themes/molokai.cfg
|
||||||
|
ttrv/themes/papercolor.cfg
|
||||||
|
ttrv/themes/solarized-dark.cfg
|
||||||
|
ttrv/themes/solarized-light.cfg
|
|
@ -0,0 +1 @@
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
[console_scripts]
|
||||||
|
ttrv = ttrv.__main__:main
|
||||||
|
|
|
@ -0,0 +1,16 @@
|
||||||
|
beautifulsoup4
|
||||||
|
decorator
|
||||||
|
kitchen
|
||||||
|
requests>=2.4.0
|
||||||
|
six
|
||||||
|
|
||||||
|
[:python_version<'3.6']
|
||||||
|
mailcap-fix
|
||||||
|
|
||||||
|
[test]
|
||||||
|
coveralls
|
||||||
|
pytest>=3.1.0
|
||||||
|
coverage
|
||||||
|
mock
|
||||||
|
pylint
|
||||||
|
vcrpy
|
|
@ -0,0 +1 @@
|
||||||
|
ttrv
|
|
@ -0,0 +1,33 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r"""
|
||||||
|
________ _____ ______
|
||||||
|
___ __/__________________ ______(_)____________ ___ /
|
||||||
|
__ / _ _ \_ ___/_ __ `__ \_ /__ __ \ __ `/_ /
|
||||||
|
_ / / __/ / _ / / / / / / _ / / / /_/ /_ /
|
||||||
|
/_/ \___//_/ /_/ /_/ /_//_/ /_/ /_/\__,_/ /_/
|
||||||
|
|
||||||
|
|
||||||
|
________ __________________________
|
||||||
|
___ __ \__________ /_____ /__(_)_ /_
|
||||||
|
__ /_/ / _ \ __ /_ __ /__ /_ __/
|
||||||
|
_ _, _// __/ /_/ / / /_/ / _ / / /_
|
||||||
|
/_/ |_| \___/\__,_/ \__,_/ /_/ \__/
|
||||||
|
|
||||||
|
|
||||||
|
___ ______
|
||||||
|
__ | / /__(_)_______ ______________
|
||||||
|
__ | / /__ /_ _ \_ | /| / / _ \_ ___/
|
||||||
|
__ |/ / _ / / __/_ |/ |/ // __/ /
|
||||||
|
_____/ /_/ \___/____/|__/ \___//_/
|
||||||
|
|
||||||
|
(TTVR)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from .__version__ import __version__
|
||||||
|
|
||||||
|
__title__ = 'Tilde Terminal Reddit Viewer'
|
||||||
|
__author__ = 'tildeclub'
|
||||||
|
__license__ = 'The MIT License (MIT)'
|
||||||
|
__copyright__ = '(c) 2019 tilde.club'
|
|
@ -0,0 +1,280 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# pylint: disable=wrong-import-position
|
||||||
|
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
from __future__ import print_function
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import locale
|
||||||
|
import logging
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
import six
|
||||||
|
import requests
|
||||||
|
|
||||||
|
# Need to check for curses compatibility before performing the trv imports
|
||||||
|
try:
|
||||||
|
import curses
|
||||||
|
except ImportError:
|
||||||
|
if sys.platform == 'win32':
|
||||||
|
sys.exit('Fatal Error: This program is not compatible with Windows '
|
||||||
|
'Operating Systems.')
|
||||||
|
else:
|
||||||
|
sys.exit('Fatal Error: Your python distribution appears to be missing '
|
||||||
|
'_curses.so.\nWas it compiled without support for curses?')
|
||||||
|
|
||||||
|
# If we want to override the $BROWSER variable that the python webbrowser
|
||||||
|
# references, it needs to be done before the webbrowser module is imported
|
||||||
|
# for the first time.
|
||||||
|
webbrowser_import_warning = ('webbrowser' in sys.modules)
|
||||||
|
TTRV_BROWSER, BROWSER = os.environ.get('TTRV_BROWSER'), os.environ.get('BROWSER')
|
||||||
|
if TTRV_BROWSER:
|
||||||
|
os.environ['BROWSER'] = TTRV_BROWSER
|
||||||
|
|
||||||
|
from . import docs
|
||||||
|
from . import packages
|
||||||
|
from .packages import praw
|
||||||
|
from .config import Config, copy_default_config, copy_default_mailcap
|
||||||
|
from .theme import Theme
|
||||||
|
from .oauth import OAuthHelper
|
||||||
|
from .terminal import Terminal
|
||||||
|
from .content import RequestHeaderRateLimiter
|
||||||
|
from .objects import curses_session, patch_webbrowser
|
||||||
|
from .subreddit_page import SubredditPage
|
||||||
|
from .submission_page import SubmissionPage
|
||||||
|
from .exceptions import ConfigError, SubredditError, SubmissionError
|
||||||
|
from .__version__ import __version__
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# Pycharm debugging note:
|
||||||
|
# You can use pycharm to debug a curses application by launching ttrv in a
|
||||||
|
# console window (python -m ttrv) and using pycharm to attach to the remote
|
||||||
|
# process. On Ubuntu, you may need to allow ptrace permissions by setting
|
||||||
|
# ptrace_scope to 0 in /etc/sysctl.d/10-ptrace.conf.
|
||||||
|
# http://blog.mellenthin.de/archives/2010/10/18/gdb-attach-fails
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point"""
|
||||||
|
|
||||||
|
# Squelch SSL warnings
|
||||||
|
logging.captureWarnings(True)
|
||||||
|
if six.PY3:
|
||||||
|
# These ones get triggered even when capturing warnings is turned on
|
||||||
|
warnings.simplefilter('ignore', ResourceWarning) # pylint:disable=E0602
|
||||||
|
|
||||||
|
# Set the terminal title
|
||||||
|
if os.getenv('DISPLAY'):
|
||||||
|
title = 'ttrv {0}'.format(__version__)
|
||||||
|
sys.stdout.write('\x1b]2;{0}\x07'.format(title))
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
args = Config.get_args()
|
||||||
|
fargs, bindings = Config.get_file(args.get('config'))
|
||||||
|
|
||||||
|
# Apply the file config first, then overwrite with any command line args
|
||||||
|
config = Config()
|
||||||
|
config.update(**fargs)
|
||||||
|
config.update(**args)
|
||||||
|
|
||||||
|
# If key bindings are supplied in the config file, overwrite the defaults
|
||||||
|
if bindings:
|
||||||
|
config.keymap.set_bindings(bindings)
|
||||||
|
|
||||||
|
if config['copy_config']:
|
||||||
|
return copy_default_config()
|
||||||
|
if config['copy_mailcap']:
|
||||||
|
return copy_default_mailcap()
|
||||||
|
if config['list_themes']:
|
||||||
|
return Theme.print_themes()
|
||||||
|
|
||||||
|
# Load the browsing history from previous sessions
|
||||||
|
config.load_history()
|
||||||
|
|
||||||
|
# Load any previously saved auth session token
|
||||||
|
config.load_refresh_token()
|
||||||
|
if config['clear_auth']:
|
||||||
|
config.delete_refresh_token()
|
||||||
|
|
||||||
|
if config['log']:
|
||||||
|
# Log request headers to the file (print hack only works on python 3.x)
|
||||||
|
# from http import client
|
||||||
|
# _http_logger = logging.getLogger('http.client')
|
||||||
|
# client.HTTPConnection.debuglevel = 2
|
||||||
|
# def print_to_file(*args, **_):
|
||||||
|
# if args[0] != "header:":
|
||||||
|
# _http_logger.info(' '.join(args))
|
||||||
|
# client.print = print_to_file
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.DEBUG,
|
||||||
|
filename=config['log'],
|
||||||
|
format='%(asctime)s:%(levelname)s:%(filename)s:%(lineno)d:%(message)s')
|
||||||
|
else:
|
||||||
|
# Add an empty handler so the logger doesn't complain
|
||||||
|
logging.root.addHandler(logging.NullHandler())
|
||||||
|
|
||||||
|
# Make sure the locale is UTF-8 for unicode support
|
||||||
|
default_locale = locale.setlocale(locale.LC_ALL, '')
|
||||||
|
try:
|
||||||
|
encoding = locale.getlocale()[1] or locale.getdefaultlocale()[1]
|
||||||
|
except ValueError:
|
||||||
|
# http://stackoverflow.com/a/19961403
|
||||||
|
# OS X on some terminals will set the LC_CTYPE to "UTF-8"
|
||||||
|
# (as opposed to something like "en_US.UTF-8") and python
|
||||||
|
# doesn't know how to handle it.
|
||||||
|
_logger.warning('Error parsing system locale: `%s`,'
|
||||||
|
' falling back to utf-8', default_locale)
|
||||||
|
encoding = 'UTF-8'
|
||||||
|
|
||||||
|
if not encoding or encoding.lower() != 'utf-8':
|
||||||
|
text = ('System encoding was detected as (%s) instead of UTF-8'
|
||||||
|
', falling back to ascii only mode' % encoding)
|
||||||
|
warnings.warn(text)
|
||||||
|
config['ascii'] = True
|
||||||
|
|
||||||
|
if packages.__praw_bundled__:
|
||||||
|
praw_info = 'packaged, commit {}'.format(packages.__praw_hash__[:12])
|
||||||
|
else:
|
||||||
|
praw_info = 'system installed v{}'.format(praw.__version__)
|
||||||
|
|
||||||
|
# Update the webbrowser module's default behavior
|
||||||
|
patch_webbrowser()
|
||||||
|
if webbrowser_import_warning:
|
||||||
|
_logger.warning('webbrowser module was unexpectedly imported before'
|
||||||
|
'$BROWSER could be overwritten')
|
||||||
|
|
||||||
|
# Construct the reddit user agent
|
||||||
|
user_agent = docs.AGENT.format(version=__version__)
|
||||||
|
|
||||||
|
debug_info = [
|
||||||
|
'ttrv version: ttrv {}'.format(__version__),
|
||||||
|
'ttrv module path: {}'.format(os.path.abspath(__file__)),
|
||||||
|
'python version: {}'.format(sys.version.replace('\n', ' ')),
|
||||||
|
'python executable: {}'.format(sys.executable),
|
||||||
|
'praw version: {}'.format(praw_info),
|
||||||
|
'locale, encoding: {}, {}'.format(default_locale, encoding),
|
||||||
|
'Environment Variables']
|
||||||
|
for name, value in [
|
||||||
|
('BROWSER', BROWSER),
|
||||||
|
('DISPLAY', os.getenv('DISPLAY')),
|
||||||
|
('EDITOR', os.getenv('EDITOR')),
|
||||||
|
('LANG', os.getenv('LANG')),
|
||||||
|
('PAGER', os.getenv('PAGER')),
|
||||||
|
('TTRV_BROWSER', TTRV_BROWSER),
|
||||||
|
('TTRV_EDITOR', os.getenv('TTRV_EDITOR')),
|
||||||
|
('TTRV_PAGER', os.getenv('TTRV_PAGER')),
|
||||||
|
('TTRV_URLVIEWER', os.getenv('TTRV_URLVIEWER')),
|
||||||
|
('TERM', os.getenv('TERM')),
|
||||||
|
('VISUAL', os.getenv('VISUAL')),
|
||||||
|
('XDG_CONFIG_HOME', os.getenv('XDG_CONFIG_HOME')),
|
||||||
|
('XDG_DATA_HOME', os.getenv('XDG_DATA_HOME')),
|
||||||
|
]:
|
||||||
|
debug_info.append(' {:<16}: {}'.format(name, value or ''))
|
||||||
|
debug_info.append('')
|
||||||
|
debug_text = '\n'.join(debug_info)
|
||||||
|
|
||||||
|
_logger.info(debug_text)
|
||||||
|
if config['debug_info']:
|
||||||
|
print(debug_text)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
with curses_session() as stdscr:
|
||||||
|
|
||||||
|
term = Terminal(stdscr, config)
|
||||||
|
|
||||||
|
if config['monochrome'] or config['theme'] == 'monochrome':
|
||||||
|
_logger.info('Using monochrome theme')
|
||||||
|
theme = Theme(use_color=False)
|
||||||
|
elif config['theme'] and config['theme'] != 'default':
|
||||||
|
_logger.info('Loading theme: %s', config['theme'])
|
||||||
|
theme = Theme.from_name(config['theme'])
|
||||||
|
else:
|
||||||
|
# Set to None to let the terminal figure out which theme
|
||||||
|
# to use depending on if colors are supported or not
|
||||||
|
theme = None
|
||||||
|
term.set_theme(theme)
|
||||||
|
|
||||||
|
with term.loader('Initializing', catch_exception=False):
|
||||||
|
reddit = praw.Reddit(user_agent=user_agent,
|
||||||
|
decode_html_entities=False,
|
||||||
|
disable_update_check=True,
|
||||||
|
timeout=10, # 10 second request timeout
|
||||||
|
handler=RequestHeaderRateLimiter())
|
||||||
|
|
||||||
|
# Dial the request cache up from 30 seconds to 5 minutes
|
||||||
|
# I'm trying this out to make navigation back and forth
|
||||||
|
# between pages quicker, it may still need to be fine tuned.
|
||||||
|
reddit.config.api_request_delay = 300
|
||||||
|
|
||||||
|
# Authorize on launch if the refresh token is present
|
||||||
|
oauth = OAuthHelper(reddit, term, config)
|
||||||
|
if config['autologin'] and config.refresh_token:
|
||||||
|
oauth.authorize(autologin=True)
|
||||||
|
|
||||||
|
# Open the supplied submission link before opening the subreddit
|
||||||
|
if config['link']:
|
||||||
|
# Expand shortened urls like https://redd.it/
|
||||||
|
# Praw won't accept the shortened versions, add the reddit
|
||||||
|
# headers to avoid a 429 response from reddit.com
|
||||||
|
url = requests.head(
|
||||||
|
config['link'],
|
||||||
|
headers=reddit.http.headers,
|
||||||
|
allow_redirects=True
|
||||||
|
).url
|
||||||
|
|
||||||
|
page = None
|
||||||
|
with term.loader('Loading submission'):
|
||||||
|
try:
|
||||||
|
page = SubmissionPage(reddit, term, config, oauth, url)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception(e)
|
||||||
|
raise SubmissionError('Unable to load {0}'.format(url))
|
||||||
|
while page:
|
||||||
|
page = page.loop()
|
||||||
|
|
||||||
|
page = None
|
||||||
|
name = config['subreddit']
|
||||||
|
with term.loader('Loading subreddit'):
|
||||||
|
try:
|
||||||
|
page = SubredditPage(reddit, term, config, oauth, name)
|
||||||
|
except Exception as e:
|
||||||
|
# If we can't load the subreddit that was requested, try
|
||||||
|
# to load the "popular" page instead so at least the
|
||||||
|
# application still launches. This used to use the user's
|
||||||
|
# front page, but some users have an empty front page.
|
||||||
|
_logger.exception(e)
|
||||||
|
page = SubredditPage(reddit, term, config, oauth, 'popular')
|
||||||
|
raise SubredditError('Unable to load {0}'.format(name))
|
||||||
|
|
||||||
|
# Launch the subreddit page
|
||||||
|
while page:
|
||||||
|
page = page.loop()
|
||||||
|
|
||||||
|
except ConfigError as e:
|
||||||
|
_logger.exception(e)
|
||||||
|
print(e)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception(e)
|
||||||
|
import traceback
|
||||||
|
exit_message = '\n'.join([
|
||||||
|
debug_text,
|
||||||
|
traceback.format_exc(),
|
||||||
|
'ttrv has crashed. Please report this traceback at:',
|
||||||
|
'https://github.com/tildeclub/ttrv/issues\n'])
|
||||||
|
sys.stderr.write(exit_message)
|
||||||
|
return 1 # General error exception code
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
# Try to save the browsing history
|
||||||
|
config.save_history()
|
||||||
|
# Ensure sockets are closed to prevent a ResourceWarning
|
||||||
|
if 'reddit' in locals():
|
||||||
|
reddit.handler.http.close()
|
||||||
|
|
||||||
|
|
||||||
|
sys.exit(main())
|
|
@ -0,0 +1,4 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
__version__ = '1.27.3'
|
|
@ -0,0 +1,51 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from .exceptions import ProgramError
|
||||||
|
|
||||||
|
|
||||||
|
def _subprocess_copy(text, args_list):
|
||||||
|
p = subprocess.Popen(args_list, stdin=subprocess.PIPE, close_fds=True)
|
||||||
|
p.communicate(input=text.encode('utf-8'))
|
||||||
|
|
||||||
|
|
||||||
|
def copy(text):
|
||||||
|
"""
|
||||||
|
Copy text to OS clipboard.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if sys.platform == 'darwin':
|
||||||
|
copy_osx(text)
|
||||||
|
else:
|
||||||
|
# For Linux, BSD, cygwin, etc.
|
||||||
|
copy_linux(text)
|
||||||
|
|
||||||
|
|
||||||
|
def copy_osx(text):
|
||||||
|
_subprocess_copy(text, ['pbcopy', 'w'])
|
||||||
|
|
||||||
|
|
||||||
|
def copy_linux(text):
|
||||||
|
|
||||||
|
def get_command_name():
|
||||||
|
# Checks for the installation of xsel or xclip
|
||||||
|
for cmd in ['xsel', 'xclip']:
|
||||||
|
cmd_exists = subprocess.call(
|
||||||
|
['which', cmd],
|
||||||
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE) == 0
|
||||||
|
if cmd_exists:
|
||||||
|
return cmd
|
||||||
|
return None
|
||||||
|
|
||||||
|
cmd_args = {
|
||||||
|
'xsel': ['xsel', '-b', '-i'],
|
||||||
|
'xclip': ['xclip', '-selection', 'c']}
|
||||||
|
cmd_name = get_command_name()
|
||||||
|
|
||||||
|
if cmd_name is None:
|
||||||
|
raise ProgramError("External copy application not found")
|
||||||
|
|
||||||
|
_subprocess_copy(text, cmd_args.get(cmd_name))
|
|
@ -0,0 +1,303 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
import os
|
||||||
|
import codecs
|
||||||
|
import shutil
|
||||||
|
import argparse
|
||||||
|
from functools import partial
|
||||||
|
|
||||||
|
import six
|
||||||
|
from six.moves import configparser
|
||||||
|
|
||||||
|
from . import docs, __version__
|
||||||
|
from .objects import KeyMap
|
||||||
|
|
||||||
|
PACKAGE = os.path.dirname(__file__)
|
||||||
|
HOME = os.path.expanduser('~')
|
||||||
|
TEMPLATES = os.path.join(PACKAGE, 'templates')
|
||||||
|
DEFAULT_CONFIG = os.path.join(TEMPLATES, 'ttrv.cfg')
|
||||||
|
DEFAULT_MAILCAP = os.path.join(TEMPLATES, 'mailcap')
|
||||||
|
DEFAULT_THEMES = os.path.join(PACKAGE, 'themes')
|
||||||
|
XDG_CONFIG_HOME = os.getenv('XDG_CONFIG_HOME', os.path.join(HOME, '.config'))
|
||||||
|
XDG_DATA_HOME = os.getenv('XDG_DATA_HOME', os.path.join(HOME, '.local', 'share'))
|
||||||
|
CONFIG = os.path.join(XDG_CONFIG_HOME, 'ttrv', 'ttrv.cfg')
|
||||||
|
MAILCAP = os.path.join(HOME, '.mailcap')
|
||||||
|
TOKEN = os.path.join(XDG_DATA_HOME, 'ttrv', 'refresh-token')
|
||||||
|
HISTORY = os.path.join(XDG_DATA_HOME, 'ttrv', 'history.log')
|
||||||
|
THEMES = os.path.join(XDG_CONFIG_HOME, 'ttrv', 'themes')
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog='ttrv', description=docs.SUMMARY,
|
||||||
|
epilog=docs.CONTROLS,
|
||||||
|
usage=docs.USAGE,
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
parser.add_argument(
|
||||||
|
'link', metavar='URL', nargs='?',
|
||||||
|
help='[optional] Full URL of a submission to open')
|
||||||
|
parser.add_argument(
|
||||||
|
'-s', dest='subreddit',
|
||||||
|
help='Name of the subreddit that will be loaded on start')
|
||||||
|
parser.add_argument(
|
||||||
|
'-l', dest='link_deprecated',
|
||||||
|
help=argparse.SUPPRESS) # Deprecated, use the positional arg instead
|
||||||
|
parser.add_argument(
|
||||||
|
'--log', metavar='FILE', action='store',
|
||||||
|
help='Log HTTP requests to the given file')
|
||||||
|
parser.add_argument(
|
||||||
|
'--config', metavar='FILE', action='store',
|
||||||
|
help='Load configuration settings from the given file')
|
||||||
|
parser.add_argument(
|
||||||
|
'--ascii', action='store_const', const=True,
|
||||||
|
help='Enable ascii-only mode')
|
||||||
|
parser.add_argument(
|
||||||
|
'--monochrome', action='store_const', const=True,
|
||||||
|
help='Disable color')
|
||||||
|
parser.add_argument(
|
||||||
|
'--theme', metavar='FILE', action='store',
|
||||||
|
help='Color theme to use, see --list-themes for valid options')
|
||||||
|
parser.add_argument(
|
||||||
|
'--list-themes', metavar='FILE', action='store_const', const=True,
|
||||||
|
help='List all of the available color themes')
|
||||||
|
parser.add_argument(
|
||||||
|
'--non-persistent', dest='persistent', action='store_const', const=False,
|
||||||
|
help='Forget the authenticated user when the program exits')
|
||||||
|
parser.add_argument(
|
||||||
|
'--no-autologin', dest='autologin', action='store_const', const=False,
|
||||||
|
help='Do not authenticate automatically on startup')
|
||||||
|
parser.add_argument(
|
||||||
|
'--clear-auth', dest='clear_auth', action='store_const', const=True,
|
||||||
|
help='Remove any saved user data before launching')
|
||||||
|
parser.add_argument(
|
||||||
|
'--copy-config', dest='copy_config', action='store_const', const=True,
|
||||||
|
help='Copy the default configuration to {HOME}/.config/ttrv/ttrv.cfg')
|
||||||
|
parser.add_argument(
|
||||||
|
'--copy-mailcap', dest='copy_mailcap', action='store_const', const=True,
|
||||||
|
help='Copy an example mailcap configuration to {HOME}/.mailcap')
|
||||||
|
parser.add_argument(
|
||||||
|
'--enable-media', dest='enable_media', action='store_const', const=True,
|
||||||
|
help='Open external links using programs defined in the mailcap config')
|
||||||
|
parser.add_argument(
|
||||||
|
'-V', '--version', action='version', version='ttrv ' + __version__)
|
||||||
|
parser.add_argument(
|
||||||
|
'--no-flash', dest='flash', action='store_const', const=False,
|
||||||
|
help='Disable screen flashing')
|
||||||
|
parser.add_argument(
|
||||||
|
'--debug-info', dest='debug_info', action='store_const', const=True,
|
||||||
|
help='Show system and environment information and exit')
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def copy_default_mailcap(filename=MAILCAP):
|
||||||
|
"""
|
||||||
|
Copy the example mailcap configuration to the specified file.
|
||||||
|
"""
|
||||||
|
return _copy_settings_file(DEFAULT_MAILCAP, filename, 'mailcap')
|
||||||
|
|
||||||
|
|
||||||
|
def copy_default_config(filename=CONFIG):
|
||||||
|
"""
|
||||||
|
Copy the default ttrv user configuration to the specified file.
|
||||||
|
"""
|
||||||
|
return _copy_settings_file(DEFAULT_CONFIG, filename, 'config')
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_settings_file(source, destination, name):
|
||||||
|
"""
|
||||||
|
Copy a file from the repo to the user's home directory.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if os.path.exists(destination):
|
||||||
|
try:
|
||||||
|
ch = six.moves.input(
|
||||||
|
'File %s already exists, overwrite? y/[n]):' % destination)
|
||||||
|
if ch not in ('Y', 'y'):
|
||||||
|
return
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
return
|
||||||
|
|
||||||
|
filepath = os.path.dirname(destination)
|
||||||
|
if not os.path.exists(filepath):
|
||||||
|
os.makedirs(filepath)
|
||||||
|
|
||||||
|
print('Copying default %s to %s' % (name, destination))
|
||||||
|
shutil.copy(source, destination)
|
||||||
|
os.chmod(destination, 0o664)
|
||||||
|
|
||||||
|
|
||||||
|
class OrderedSet(object):
|
||||||
|
"""
|
||||||
|
A simple implementation of an ordered set. A set is used to check
|
||||||
|
for membership, and a list is used to maintain ordering.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, elements=None):
|
||||||
|
elements = elements or []
|
||||||
|
self._set = set(elements)
|
||||||
|
self._list = elements
|
||||||
|
|
||||||
|
def __contains__(self, item):
|
||||||
|
return item in self._set
|
||||||
|
|
||||||
|
def __len__(self):
|
||||||
|
return len(self._list)
|
||||||
|
|
||||||
|
def __getitem__(self, item):
|
||||||
|
return self._list[item]
|
||||||
|
|
||||||
|
def add(self, item):
|
||||||
|
self._set.add(item)
|
||||||
|
self._list.append(item)
|
||||||
|
|
||||||
|
|
||||||
|
class Config(object):
|
||||||
|
"""
|
||||||
|
This class manages the loading and saving of configs and other files.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, history_file=HISTORY, token_file=TOKEN, **kwargs):
|
||||||
|
|
||||||
|
self.history_file = history_file
|
||||||
|
self.token_file = token_file
|
||||||
|
self.config = kwargs
|
||||||
|
|
||||||
|
default, bindings = self.get_file(DEFAULT_CONFIG)
|
||||||
|
self.default = default
|
||||||
|
self.keymap = KeyMap(bindings)
|
||||||
|
|
||||||
|
# `refresh_token` and `history` are saved/loaded at separate locations,
|
||||||
|
# so they are treated differently from the rest of the config options.
|
||||||
|
self.refresh_token = None
|
||||||
|
self.history = OrderedSet()
|
||||||
|
|
||||||
|
def __getitem__(self, item):
|
||||||
|
if item in self.config:
|
||||||
|
return self.config[item]
|
||||||
|
else:
|
||||||
|
return self.default.get(item, None)
|
||||||
|
|
||||||
|
def __setitem__(self, key, value):
|
||||||
|
self.config[key] = value
|
||||||
|
|
||||||
|
def __delitem__(self, key):
|
||||||
|
self.config.pop(key, None)
|
||||||
|
|
||||||
|
def update(self, **kwargs):
|
||||||
|
self.config.update(kwargs)
|
||||||
|
|
||||||
|
def load_refresh_token(self):
|
||||||
|
if os.path.exists(self.token_file):
|
||||||
|
with open(self.token_file) as fp:
|
||||||
|
self.refresh_token = fp.read().strip()
|
||||||
|
else:
|
||||||
|
self.refresh_token = None
|
||||||
|
|
||||||
|
def save_refresh_token(self):
|
||||||
|
self._ensure_filepath(self.token_file)
|
||||||
|
with open(self.token_file, 'w+') as fp:
|
||||||
|
fp.write(self.refresh_token)
|
||||||
|
|
||||||
|
def delete_refresh_token(self):
|
||||||
|
if os.path.exists(self.token_file):
|
||||||
|
os.remove(self.token_file)
|
||||||
|
self.refresh_token = None
|
||||||
|
|
||||||
|
def load_history(self):
|
||||||
|
if os.path.exists(self.history_file):
|
||||||
|
with codecs.open(self.history_file, encoding='utf-8') as fp:
|
||||||
|
self.history = OrderedSet([line.strip() for line in fp])
|
||||||
|
else:
|
||||||
|
self.history = OrderedSet()
|
||||||
|
|
||||||
|
def save_history(self):
|
||||||
|
self._ensure_filepath(self.history_file)
|
||||||
|
with codecs.open(self.history_file, 'w+', encoding='utf-8') as fp:
|
||||||
|
fp.writelines('\n'.join(self.history[-self['history_size']:]))
|
||||||
|
|
||||||
|
def delete_history(self):
|
||||||
|
if os.path.exists(self.history_file):
|
||||||
|
os.remove(self.history_file)
|
||||||
|
self.history = OrderedSet()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_args():
|
||||||
|
"""
|
||||||
|
Load settings from the command line.
|
||||||
|
"""
|
||||||
|
|
||||||
|
parser = build_parser()
|
||||||
|
args = vars(parser.parse_args())
|
||||||
|
|
||||||
|
# Overwrite the deprecated "-l" option into the link variable
|
||||||
|
if args['link_deprecated'] and args['link'] is None:
|
||||||
|
args['link'] = args['link_deprecated']
|
||||||
|
args.pop('link_deprecated', None)
|
||||||
|
|
||||||
|
# Filter out argument values that weren't supplied
|
||||||
|
return {key: val for key, val in args.items() if val is not None}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_file(cls, filename=None):
|
||||||
|
"""
|
||||||
|
Load settings from an ttrv configuration file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if filename is None:
|
||||||
|
filename = CONFIG
|
||||||
|
|
||||||
|
config = configparser.ConfigParser()
|
||||||
|
if os.path.exists(filename):
|
||||||
|
with codecs.open(filename, encoding='utf-8') as fp:
|
||||||
|
config.readfp(fp)
|
||||||
|
|
||||||
|
return cls._parse_ttrv_file(config)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_ttrv_file(config):
|
||||||
|
|
||||||
|
ttrv = {}
|
||||||
|
if config.has_section('ttrv'):
|
||||||
|
ttrv = dict(config.items('ttrv'))
|
||||||
|
|
||||||
|
# convert non-string params to their typed representation
|
||||||
|
params = {
|
||||||
|
'ascii': partial(config.getboolean, 'ttrv'),
|
||||||
|
'monochrome': partial(config.getboolean, 'ttrv'),
|
||||||
|
'persistent': partial(config.getboolean, 'ttrv'),
|
||||||
|
'autologin': partial(config.getboolean, 'ttrv'),
|
||||||
|
'clear_auth': partial(config.getboolean, 'ttrv'),
|
||||||
|
'enable_media': partial(config.getboolean, 'ttrv'),
|
||||||
|
'history_size': partial(config.getint, 'ttrv'),
|
||||||
|
'oauth_redirect_port': partial(config.getint, 'ttrv'),
|
||||||
|
'oauth_scope': lambda x: ttrv[x].split(','),
|
||||||
|
'max_comment_cols': partial(config.getint, 'ttrv'),
|
||||||
|
'max_pager_cols': partial(config.getint, 'ttrv'),
|
||||||
|
'hide_username': partial(config.getboolean, 'ttrv'),
|
||||||
|
'flash': partial(config.getboolean, 'ttrv'),
|
||||||
|
'force_new_browser_window': partial(config.getboolean, 'ttrv')
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, func in params.items():
|
||||||
|
if key in ttrv:
|
||||||
|
ttrv[key] = func(key)
|
||||||
|
|
||||||
|
bindings = {}
|
||||||
|
if config.has_section('bindings'):
|
||||||
|
bindings = dict(config.items('bindings'))
|
||||||
|
|
||||||
|
for name, keys in bindings.items():
|
||||||
|
bindings[name] = [key.strip() for key in keys.split(',')]
|
||||||
|
|
||||||
|
return ttrv, bindings
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _ensure_filepath(filename):
|
||||||
|
"""
|
||||||
|
Ensure that the directory exists before trying to write to the file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
filepath = os.path.dirname(filename)
|
||||||
|
if not os.path.exists(filepath):
|
||||||
|
os.makedirs(filepath)
|
|
@ -0,0 +1,229 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
AGENT = """\
|
||||||
|
desktop:https://github.com/tildeclub/ttrv:{version}\
|
||||||
|
(by /u/civilization_phaze_3)\
|
||||||
|
"""
|
||||||
|
|
||||||
|
SUMMARY = """
|
||||||
|
TVR (Terminal Viewer Reddit) is a terminal interface to view and interact with reddit.
|
||||||
|
"""
|
||||||
|
|
||||||
|
USAGE = """\
|
||||||
|
ttrv [URL] [-s SUBREDDIT]
|
||||||
|
|
||||||
|
$ ttrv https://www.reddit.com/r/programming/comments/7h9l31
|
||||||
|
$ ttrv -s linux
|
||||||
|
"""
|
||||||
|
|
||||||
|
CONTROLS = """
|
||||||
|
Move the cursor using the arrow keys or vim style movement.
|
||||||
|
Press `?` to open the help screen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
HELP = """\
|
||||||
|
====================================
|
||||||
|
Reddit Terminal Viewer
|
||||||
|
|
||||||
|
https://github.com/tildeclub/ttrv
|
||||||
|
====================================
|
||||||
|
|
||||||
|
[Basic Commands]
|
||||||
|
j : Move the cursor down
|
||||||
|
k : Move the cursor up
|
||||||
|
l : View the currently selected item
|
||||||
|
h : Return to the previous view
|
||||||
|
m : Move the cursor up one page
|
||||||
|
n : Move the cursor down one page
|
||||||
|
gg : Jump to the top of the page
|
||||||
|
G : Jump to the bottom of the page
|
||||||
|
1-7 : Sort submissions by category
|
||||||
|
r : Refresh the content on the current page
|
||||||
|
u : Login to your reddit account
|
||||||
|
q : Quit
|
||||||
|
Q : Force quit
|
||||||
|
y : Copy submission permalink to clipboard
|
||||||
|
Y : Copy submission link to clipboard
|
||||||
|
F2 : Cycle to the previous color theme
|
||||||
|
F3 : Cycle to the next color theme
|
||||||
|
? : Show the help screen
|
||||||
|
/ : Open a prompt to select a subreddit
|
||||||
|
|
||||||
|
[Authenticated Commands]
|
||||||
|
a : Upvote
|
||||||
|
z : Downvote
|
||||||
|
c : Compose a new submission or comment
|
||||||
|
C : Compose a new private message
|
||||||
|
e : Edit the selected submission or comment
|
||||||
|
d : Delete the selected submission or comment
|
||||||
|
i : View your inbox (see Inbox Mode)
|
||||||
|
s : View your subscribed subreddits (see Subscription Mode)
|
||||||
|
S : View your subscribed multireddits (see Subscription Mode)
|
||||||
|
u : Logout of your reddit account
|
||||||
|
w : Save the selected submission or comment
|
||||||
|
|
||||||
|
[Subreddit Mode]
|
||||||
|
l : View the comments for the selected submission (see Submission Mode)
|
||||||
|
o : Open the selected submission link using your web browser
|
||||||
|
SPACE : Mark the selected submission as hidden
|
||||||
|
p : Toggle between the currently viewed subreddit and /r/front
|
||||||
|
f : Open a prompt to search the current subreddit for a text string
|
||||||
|
|
||||||
|
[Submission Mode]
|
||||||
|
h : Close the submission and return to the previous page
|
||||||
|
l : View the selected comment using the system's pager
|
||||||
|
o : Open a link in the comment using your web browser
|
||||||
|
SPACE : Fold or expand the selected comment and its children
|
||||||
|
b : Send the comment text to the system's urlviewer application
|
||||||
|
J : Move the cursor down the the next comment at the same indentation
|
||||||
|
K : Move the cursor up to the parent comment
|
||||||
|
|
||||||
|
[Subscription Mode]
|
||||||
|
h : Close your subscriptions and return to the previous page
|
||||||
|
l : Open the selected subreddit or multireddit
|
||||||
|
|
||||||
|
[Inbox Mode]
|
||||||
|
h : Close your inbox and return to the previous page
|
||||||
|
l : View the context of the selected comment
|
||||||
|
o : Open the submission of the selected comment
|
||||||
|
c : Reply to the selected comment or message
|
||||||
|
w : Mark the selected comment or message as seen
|
||||||
|
|
||||||
|
[Prompt]
|
||||||
|
The / key opens a text prompt at the bottom of the screen. You can use this
|
||||||
|
to type in the name of the subreddit that you want to open. The following
|
||||||
|
text formats are recognized:
|
||||||
|
|
||||||
|
/python - Open a subreddit, shorthand
|
||||||
|
/r/python - Open a subreddit
|
||||||
|
/r/python/new - Open a subreddit, sorted by category
|
||||||
|
/r/python/controversial-year - Open a subreddit, sorted by category and time
|
||||||
|
/r/python+linux+commandline - Open multiple subreddits merged together
|
||||||
|
/comments/30rwj2 - Open a submission, shorthand
|
||||||
|
/r/python/comments/30rwj2 - Open a submission
|
||||||
|
/r/front - Open your front page
|
||||||
|
/u/me - View your submissions
|
||||||
|
/u/me/saved - View your saved content
|
||||||
|
/u/me/hidden - View your hidden content
|
||||||
|
/u/me/upvoted - View your upvoted content
|
||||||
|
/u/me/downvoted - View your downvoted content
|
||||||
|
/u/spez - View a user's submissions and comments
|
||||||
|
/u/spez/submitted - View a user's submissions
|
||||||
|
/u/spez/comments - View a user's comments
|
||||||
|
/u/multi-mod/m/android - Open a user's curated multireddit
|
||||||
|
/domain/python.org - Search for links for the given domain
|
||||||
|
"""
|
||||||
|
|
||||||
|
BANNER_SUBREDDIT = """
|
||||||
|
[1]hot [2]top [3]rising [4]new [5]controversial [6]gilded
|
||||||
|
"""
|
||||||
|
|
||||||
|
BANNER_SUBMISSION = """
|
||||||
|
[1]hot [2]top [3]rising [4]new [5]controversial
|
||||||
|
"""
|
||||||
|
|
||||||
|
BANNER_SEARCH = """
|
||||||
|
[1]relevance [2]top [3]comments [4]new
|
||||||
|
"""
|
||||||
|
|
||||||
|
BANNER_INBOX = """
|
||||||
|
[1]all [2]unread [3]messages [4]comments [5]posts [6]mentions [7]sent
|
||||||
|
"""
|
||||||
|
|
||||||
|
FOOTER_SUBREDDIT = """
|
||||||
|
[?]Help [q]Quit [l]Comments [/]Prompt [u]Login [o]Open [c]Post [a/z]Vote [r]Refresh
|
||||||
|
"""
|
||||||
|
|
||||||
|
FOOTER_SUBMISSION = """
|
||||||
|
[?]Help [q]Quit [h]Return [space]Fold/Expand [o]Open [c]Comment [a/z]Vote [r]Refresh
|
||||||
|
"""
|
||||||
|
|
||||||
|
FOOTER_SUBSCRIPTION = """
|
||||||
|
[?]Help [q]Quit [h]Return [l]Select Subreddit [r]Refresh
|
||||||
|
"""
|
||||||
|
|
||||||
|
FOOTER_INBOX = """
|
||||||
|
[?]Help [l]View Context [o]Open Submission [c]Reply [w]Mark Read [r]Refresh
|
||||||
|
"""
|
||||||
|
|
||||||
|
TOKEN = "INSTRUCTIONS"
|
||||||
|
|
||||||
|
REPLY_FILE = """<!--{token}
|
||||||
|
Replying to {{author}}'s {{type}}:
|
||||||
|
{{content}}
|
||||||
|
|
||||||
|
Enter your reply below this instruction block,
|
||||||
|
an empty message will abort the comment.
|
||||||
|
{token}-->
|
||||||
|
""".format(token=TOKEN)
|
||||||
|
|
||||||
|
COMMENT_EDIT_FILE = """<!--{token}
|
||||||
|
Editing comment #{{id}}.
|
||||||
|
The comment is shown below, update it and save the file.
|
||||||
|
{token}-->
|
||||||
|
|
||||||
|
{{content}}
|
||||||
|
""".format(token=TOKEN)
|
||||||
|
|
||||||
|
SUBMISSION_FILE = """<!--{token}
|
||||||
|
Submitting a selfpost to {{name}}.
|
||||||
|
|
||||||
|
Enter your submission below this instruction block:
|
||||||
|
- The first line will be interpreted as the title
|
||||||
|
- The following lines will be interpreted as the body
|
||||||
|
- An empty message will abort the submission
|
||||||
|
{token}-->
|
||||||
|
""".format(token=TOKEN)
|
||||||
|
|
||||||
|
SUBMISSION_EDIT_FILE = """<!--{token}
|
||||||
|
Editing submission #{{id}}.
|
||||||
|
The submission is shown below, update it and save the file.
|
||||||
|
{token}-->
|
||||||
|
|
||||||
|
{{content}}
|
||||||
|
""".format(token=TOKEN)
|
||||||
|
|
||||||
|
MESSAGE_FILE = """<!--{token}
|
||||||
|
Compose a new private message
|
||||||
|
|
||||||
|
Enter your message below this instruction block:
|
||||||
|
- The first line should contain the recipient's reddit name
|
||||||
|
- The second line should contain the message subject
|
||||||
|
- Subsequent lines will be interpreted as the message body
|
||||||
|
{token}-->
|
||||||
|
""".format(token=TOKEN)
|
||||||
|
|
||||||
|
OAUTH_ACCESS_DENIED = """\
|
||||||
|
<h1 style="color: red">Access Denied</h1><hr>
|
||||||
|
<p><span style="font-weight: bold">Reddit Terminal Viewer</span> was
|
||||||
|
denied access and will continue to operate in unauthenticated mode,
|
||||||
|
you can close this window.</p>
|
||||||
|
"""
|
||||||
|
|
||||||
|
OAUTH_ERROR = """\
|
||||||
|
<h1 style="color: red">Error</h1><hr>
|
||||||
|
<p>{error}</p>
|
||||||
|
"""
|
||||||
|
|
||||||
|
OAUTH_INVALID = """\
|
||||||
|
<h1>Wait...</h1><hr>
|
||||||
|
<p>This page is supposed to be a Reddit OAuth callback.
|
||||||
|
You can't just come here hands in your pocket!</p>
|
||||||
|
"""
|
||||||
|
|
||||||
|
OAUTH_SUCCESS = """\
|
||||||
|
<h1 style="color: green">Access Granted</h1><hr>
|
||||||
|
<p><span style="font-weight: bold">Reddit Terminal Viewer</span>
|
||||||
|
will now log in, you can close this window.</p>
|
||||||
|
"""
|
||||||
|
|
||||||
|
TIME_ORDER_MENU = """
|
||||||
|
Links from:
|
||||||
|
[1] Past hour
|
||||||
|
[2] Past 24 hours
|
||||||
|
[3] Past week
|
||||||
|
[4] Past month
|
||||||
|
[5] Past year
|
||||||
|
[6] All time
|
||||||
|
"""
|
|
@ -0,0 +1,63 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
|
||||||
|
class EscapeInterrupt(Exception):
|
||||||
|
"Signal that the ESC key has been pressed"
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigError(Exception):
|
||||||
|
"There was a problem with the configuration"
|
||||||
|
|
||||||
|
|
||||||
|
class TTRVError(Exception):
|
||||||
|
"Base TTRV error class"
|
||||||
|
|
||||||
|
|
||||||
|
class AccountError(TTRVError):
|
||||||
|
"Could not access user account"
|
||||||
|
|
||||||
|
|
||||||
|
class SubmissionError(TTRVError):
|
||||||
|
"Submission could not be loaded"
|
||||||
|
|
||||||
|
|
||||||
|
class SubredditError(TTRVError):
|
||||||
|
"Subreddit could not be loaded"
|
||||||
|
|
||||||
|
|
||||||
|
class NoSubmissionsError(TTRVError):
|
||||||
|
"No submissions for the given page"
|
||||||
|
|
||||||
|
def __init__(self, name):
|
||||||
|
self.name = name
|
||||||
|
message = '`{0}` has no submissions'.format(name)
|
||||||
|
super(NoSubmissionsError, self).__init__(message)
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionError(TTRVError):
|
||||||
|
"Content could not be fetched"
|
||||||
|
|
||||||
|
|
||||||
|
class InboxError(TTRVError):
|
||||||
|
"Content could not be fetched"
|
||||||
|
|
||||||
|
|
||||||
|
class ProgramError(TTRVError):
|
||||||
|
"Problem executing an external program"
|
||||||
|
|
||||||
|
|
||||||
|
class BrowserError(TTRVError):
|
||||||
|
"Could not open a web browser tab"
|
||||||
|
|
||||||
|
|
||||||
|
class TemporaryFileError(TTRVError):
|
||||||
|
"Indicates that an error has occurred and the file should not be deleted"
|
||||||
|
|
||||||
|
|
||||||
|
class MailcapEntryNotFound(TTRVError):
|
||||||
|
"A valid mailcap entry could not be coerced from the given url"
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidRefreshToken(TTRVError):
|
||||||
|
"The refresh token is corrupt and cannot be used to login"
|
|
@ -0,0 +1,204 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from . import docs
|
||||||
|
from .content import InboxContent
|
||||||
|
from .page import Page, PageController, logged_in
|
||||||
|
from .objects import Navigator, Command
|
||||||
|
|
||||||
|
|
||||||
|
class InboxController(PageController):
|
||||||
|
character_map = {}
|
||||||
|
|
||||||
|
|
||||||
|
class InboxPage(Page):
|
||||||
|
BANNER = docs.BANNER_INBOX
|
||||||
|
FOOTER = docs.FOOTER_INBOX
|
||||||
|
|
||||||
|
name = 'inbox'
|
||||||
|
|
||||||
|
def __init__(self, reddit, term, config, oauth, content_type='all'):
|
||||||
|
super(InboxPage, self).__init__(reddit, term, config, oauth)
|
||||||
|
|
||||||
|
self.controller = InboxController(self, keymap=config.keymap)
|
||||||
|
self.content = InboxContent.from_user(reddit, term.loader, content_type)
|
||||||
|
self.nav = Navigator(self.content.get)
|
||||||
|
self.content_type = content_type
|
||||||
|
|
||||||
|
def handle_selected_page(self):
|
||||||
|
"""
|
||||||
|
Open the subscription and submission pages subwindows, but close the
|
||||||
|
current page if any other type of page is selected.
|
||||||
|
"""
|
||||||
|
if not self.selected_page:
|
||||||
|
pass
|
||||||
|
if self.selected_page.name in ('subscription', 'submission'):
|
||||||
|
# Launch page in a subwindow
|
||||||
|
self.selected_page = self.selected_page.loop()
|
||||||
|
elif self.selected_page.name in ('subreddit', 'inbox'):
|
||||||
|
# Replace the current page
|
||||||
|
self.active = False
|
||||||
|
else:
|
||||||
|
raise RuntimeError(self.selected_page.name)
|
||||||
|
|
||||||
|
@logged_in
|
||||||
|
def refresh_content(self, order=None, name=None):
|
||||||
|
"""
|
||||||
|
Re-download all inbox content and reset the page index
|
||||||
|
"""
|
||||||
|
self.content_type = order or self.content_type
|
||||||
|
|
||||||
|
with self.term.loader():
|
||||||
|
self.content = InboxContent.from_user(
|
||||||
|
self.reddit, self.term.loader, self.content_type)
|
||||||
|
if not self.term.loader.exception:
|
||||||
|
self.nav = Navigator(self.content.get)
|
||||||
|
|
||||||
|
@InboxController.register(Command('SORT_1'))
|
||||||
|
def load_content_inbox(self):
|
||||||
|
self.refresh_content(order='all')
|
||||||
|
|
||||||
|
@InboxController.register(Command('SORT_2'))
|
||||||
|
def load_content_unread_messages(self):
|
||||||
|
self.refresh_content(order='unread')
|
||||||
|
|
||||||
|
@InboxController.register(Command('SORT_3'))
|
||||||
|
def load_content_messages(self):
|
||||||
|
self.refresh_content(order='messages')
|
||||||
|
|
||||||
|
@InboxController.register(Command('SORT_4'))
|
||||||
|
def load_content_comment_replies(self):
|
||||||
|
self.refresh_content(order='comments')
|
||||||
|
|
||||||
|
@InboxController.register(Command('SORT_5'))
|
||||||
|
def load_content_post_replies(self):
|
||||||
|
self.refresh_content(order='posts')
|
||||||
|
|
||||||
|
@InboxController.register(Command('SORT_6'))
|
||||||
|
def load_content_username_mentions(self):
|
||||||
|
self.refresh_content(order='mentions')
|
||||||
|
|
||||||
|
@InboxController.register(Command('SORT_7'))
|
||||||
|
def load_content_sent_messages(self):
|
||||||
|
self.refresh_content(order='sent')
|
||||||
|
|
||||||
|
@InboxController.register(Command('INBOX_MARK_READ'))
|
||||||
|
@logged_in
|
||||||
|
def mark_seen(self):
|
||||||
|
"""
|
||||||
|
Mark the selected message or comment as seen.
|
||||||
|
"""
|
||||||
|
data = self.get_selected_item()
|
||||||
|
if data['is_new']:
|
||||||
|
with self.term.loader('Marking as read'):
|
||||||
|
data['object'].mark_as_read()
|
||||||
|
if not self.term.loader.exception:
|
||||||
|
data['is_new'] = False
|
||||||
|
else:
|
||||||
|
with self.term.loader('Marking as unread'):
|
||||||
|
data['object'].mark_as_unread()
|
||||||
|
if not self.term.loader.exception:
|
||||||
|
data['is_new'] = True
|
||||||
|
|
||||||
|
@InboxController.register(Command('INBOX_REPLY'))
|
||||||
|
@logged_in
|
||||||
|
def inbox_reply(self):
|
||||||
|
"""
|
||||||
|
Reply to the selected private message or comment from the inbox.
|
||||||
|
"""
|
||||||
|
self.reply()
|
||||||
|
|
||||||
|
@InboxController.register(Command('INBOX_EXIT'))
|
||||||
|
def close_inbox(self):
|
||||||
|
"""
|
||||||
|
Close inbox and return to the previous page.
|
||||||
|
"""
|
||||||
|
self.active = False
|
||||||
|
|
||||||
|
@InboxController.register(Command('INBOX_VIEW_CONTEXT'))
|
||||||
|
@logged_in
|
||||||
|
def view_context(self):
|
||||||
|
"""
|
||||||
|
View the context surrounding the selected comment.
|
||||||
|
"""
|
||||||
|
url = self.get_selected_item().get('context')
|
||||||
|
if url:
|
||||||
|
self.selected_page = self.open_submission_page(url)
|
||||||
|
|
||||||
|
@InboxController.register(Command('INBOX_OPEN_SUBMISSION'))
|
||||||
|
@logged_in
|
||||||
|
def open_submission(self):
|
||||||
|
"""
|
||||||
|
Open the full submission and comment tree for the selected comment.
|
||||||
|
"""
|
||||||
|
url = self.get_selected_item().get('submission_permalink')
|
||||||
|
if url:
|
||||||
|
self.selected_page = self.open_submission_page(url)
|
||||||
|
|
||||||
|
def _draw_item(self, win, data, inverted):
|
||||||
|
|
||||||
|
n_rows, n_cols = win.getmaxyx()
|
||||||
|
n_cols -= 1 # Leave space for the cursor in the first column
|
||||||
|
|
||||||
|
# Handle the case where the window is not large enough to fit the data.
|
||||||
|
valid_rows = range(0, n_rows)
|
||||||
|
offset = 0 if not inverted else -(data['n_rows'] - n_rows)
|
||||||
|
|
||||||
|
row = offset
|
||||||
|
if row in valid_rows:
|
||||||
|
if data['is_new']:
|
||||||
|
attr = self.term.attr('New')
|
||||||
|
self.term.add_line(win, '[new]', row, 1, attr)
|
||||||
|
self.term.add_space(win)
|
||||||
|
|
||||||
|
attr = self.term.attr('MessageSubject')
|
||||||
|
self.term.add_line(win, '{subject}'.format(**data), attr=attr)
|
||||||
|
self.term.add_space(win)
|
||||||
|
else:
|
||||||
|
attr = self.term.attr('MessageSubject')
|
||||||
|
self.term.add_line(win, '{subject}'.format(**data), row, 1, attr)
|
||||||
|
self.term.add_space(win)
|
||||||
|
|
||||||
|
if data['link_title']:
|
||||||
|
attr = self.term.attr('MessageLink')
|
||||||
|
self.term.add_line(win, '{link_title}'.format(**data), attr=attr)
|
||||||
|
|
||||||
|
row = offset + 1
|
||||||
|
if row in valid_rows:
|
||||||
|
# reddit.user might be ``None`` if the user logs out while viewing
|
||||||
|
# this page
|
||||||
|
if data['author'] == getattr(self.reddit.user, 'name', None):
|
||||||
|
self.term.add_line(win, 'to ', row, 1)
|
||||||
|
text = '{recipient}'.format(**data)
|
||||||
|
else:
|
||||||
|
self.term.add_line(win, 'from ', row, 1)
|
||||||
|
text = '{author}'.format(**data)
|
||||||
|
attr = self.term.attr('MessageAuthor')
|
||||||
|
self.term.add_line(win, text, attr=attr)
|
||||||
|
self.term.add_space(win)
|
||||||
|
|
||||||
|
if data['distinguished']:
|
||||||
|
attr = self.term.attr('Distinguished')
|
||||||
|
text = '[{distinguished}]'.format(**data)
|
||||||
|
self.term.add_line(win, text, attr=attr)
|
||||||
|
self.term.add_space(win)
|
||||||
|
|
||||||
|
attr = self.term.attr('Created')
|
||||||
|
text = 'sent {created_long}'.format(**data)
|
||||||
|
self.term.add_line(win, text, attr=attr)
|
||||||
|
self.term.add_space(win)
|
||||||
|
|
||||||
|
if data['subreddit_name']:
|
||||||
|
attr = self.term.attr('MessageSubreddit')
|
||||||
|
text = 'via {subreddit_name}'.format(**data)
|
||||||
|
self.term.add_line(win, text, attr=attr)
|
||||||
|
self.term.add_space(win)
|
||||||
|
|
||||||
|
attr = self.term.attr('MessageText')
|
||||||
|
for row, text in enumerate(data['split_body'], start=offset + 2):
|
||||||
|
if row in valid_rows:
|
||||||
|
self.term.add_line(win, text, row, 1, attr=attr)
|
||||||
|
|
||||||
|
attr = self.term.attr('CursorBlock')
|
||||||
|
for y in range(n_rows):
|
||||||
|
self.term.addch(win, y, 0, str(' '), attr)
|
|
@ -0,0 +1,505 @@
|
||||||
|
import re
|
||||||
|
import logging
|
||||||
|
import mimetypes
|
||||||
|
import json
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class BaseMIMEParser(object):
|
||||||
|
"""
|
||||||
|
BaseMIMEParser can be sub-classed to define custom handlers for determining
|
||||||
|
the MIME type of external urls.
|
||||||
|
"""
|
||||||
|
pattern = re.compile(r'.*$')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_mimetype(url):
|
||||||
|
"""
|
||||||
|
Guess based on the file extension.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url (text): Web url that was linked to by a reddit submission.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
modified_url (text): The url (or filename) that will be used when
|
||||||
|
constructing the command to run.
|
||||||
|
content_type (text): The mime-type that will be used when
|
||||||
|
constructing the command to run. If the mime-type is unknown,
|
||||||
|
return None and the program will fallback to using the web
|
||||||
|
browser.
|
||||||
|
"""
|
||||||
|
filename = url.split('?')[0]
|
||||||
|
filename = filename.split('#')[0]
|
||||||
|
content_type, _ = mimetypes.guess_type(filename)
|
||||||
|
return url, content_type
|
||||||
|
|
||||||
|
|
||||||
|
class OpenGraphMIMEParser(BaseMIMEParser):
|
||||||
|
"""
|
||||||
|
Open graph protocol is used on many web pages.
|
||||||
|
|
||||||
|
<meta property="og:image" content="https://xxxx.jpg?ig_cache_key=xxxxx" />
|
||||||
|
<meta property="og:video:secure_url" content="https://xxxxx.mp4" />
|
||||||
|
|
||||||
|
If the page is a video page both of the above tags will be present and
|
||||||
|
priority is given to video content.
|
||||||
|
|
||||||
|
see http://ogp.me
|
||||||
|
"""
|
||||||
|
pattern = re.compile(r'.*$')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_mimetype(url):
|
||||||
|
page = requests.get(url)
|
||||||
|
soup = BeautifulSoup(page.content, 'html.parser')
|
||||||
|
for og_type in ['video', 'image']:
|
||||||
|
prop = 'og:' + og_type + ':secure_url'
|
||||||
|
tag = soup.find('meta', attrs={'property': prop})
|
||||||
|
if not tag:
|
||||||
|
prop = 'og:' + og_type
|
||||||
|
tag = soup.find('meta', attrs={'property': prop})
|
||||||
|
if tag:
|
||||||
|
return BaseMIMEParser.get_mimetype(tag.get('content'))
|
||||||
|
|
||||||
|
return url, None
|
||||||
|
|
||||||
|
|
||||||
|
class VideoTagMIMEParser(BaseMIMEParser):
|
||||||
|
"""
|
||||||
|
<video width="320" height="240" controls>
|
||||||
|
<source src="movie.mp4" res="HD" type="video/mp4">
|
||||||
|
<source src="movie.mp4" res="SD" type="video/mp4">
|
||||||
|
<source src="movie.ogg" type="video/ogg">
|
||||||
|
</video>
|
||||||
|
"""
|
||||||
|
pattern = re.compile(r'.*$')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_mimetype(url):
|
||||||
|
page = requests.get(url)
|
||||||
|
soup = BeautifulSoup(page.content, 'html.parser')
|
||||||
|
|
||||||
|
# TODO: Handle pages with multiple videos
|
||||||
|
video = soup.find('video')
|
||||||
|
source = None
|
||||||
|
if video:
|
||||||
|
source = video.find('source', attr={'res': 'HD'})
|
||||||
|
source = source or video.find('source', attr={'type': 'video/mp4'})
|
||||||
|
source = source or video.find('source')
|
||||||
|
if source:
|
||||||
|
return source.get('src'), source.get('type')
|
||||||
|
else:
|
||||||
|
return url, None
|
||||||
|
|
||||||
|
|
||||||
|
class GfycatMIMEParser(BaseMIMEParser):
|
||||||
|
"""
|
||||||
|
Gfycat provides a primitive json api to generate image links. URLs can be
|
||||||
|
downloaded as either gif, mp4, webm, or mjpg. Mp4 was selected because it's
|
||||||
|
fast and works with VLC.
|
||||||
|
|
||||||
|
https://gfycat.com/api
|
||||||
|
|
||||||
|
https://gfycat.com/UntidyAcidicIberianemeraldlizard -->
|
||||||
|
https://giant.gfycat.com/UntidyAcidicIberianemeraldlizard.webm
|
||||||
|
"""
|
||||||
|
pattern = re.compile(r'https?://(www\.)?gfycat\.com/[^.]+$')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_mimetype(url):
|
||||||
|
identifier = url.split('/')[-1]
|
||||||
|
api_url = 'https://api.gfycat.com/v1/gfycats/{}'.format(identifier)
|
||||||
|
resp = requests.get(api_url)
|
||||||
|
image_url = resp.json()['gfyItem']['mp4Url']
|
||||||
|
return image_url, 'video/mp4'
|
||||||
|
|
||||||
|
|
||||||
|
class YoutubeMIMEParser(BaseMIMEParser):
|
||||||
|
"""
|
||||||
|
Youtube videos can be streamed with vlc or downloaded with youtube-dl.
|
||||||
|
Assign a custom mime-type so they can be referenced in mailcap.
|
||||||
|
"""
|
||||||
|
pattern = re.compile(
|
||||||
|
r'(?:https?://)?(m\.)?(?:youtu\.be/|(?:www\.)?youtube\.com/watch'
|
||||||
|
r'(?:\.php)?\'?.*v=)([a-zA-Z0-9\-_]+)')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_mimetype(url):
|
||||||
|
return url, 'video/x-youtube'
|
||||||
|
|
||||||
|
|
||||||
|
class VimeoMIMEParser(BaseMIMEParser):
|
||||||
|
"""
|
||||||
|
Vimeo videos can be streamed with vlc or downloaded with youtube-dl.
|
||||||
|
Assign a custom mime-type so they can be referenced in mailcap.
|
||||||
|
"""
|
||||||
|
pattern = re.compile(r'https?://(www\.)?vimeo\.com/\d+$')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_mimetype(url):
|
||||||
|
return url, 'video/x-youtube'
|
||||||
|
|
||||||
|
|
||||||
|
class GifvMIMEParser(BaseMIMEParser):
|
||||||
|
"""
|
||||||
|
Special case for .gifv, which is a custom video format for imgur serves
|
||||||
|
as html with a special <video> frame. Note that attempting for download as
|
||||||
|
.webm also returns this html page. However, .mp4 appears to return the raw
|
||||||
|
video file.
|
||||||
|
"""
|
||||||
|
pattern = re.compile(r'.*[.]gifv$')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_mimetype(url):
|
||||||
|
modified_url = url[:-4] + 'mp4'
|
||||||
|
return modified_url, 'video/mp4'
|
||||||
|
|
||||||
|
|
||||||
|
class RedditUploadsMIMEParser(BaseMIMEParser):
|
||||||
|
"""
|
||||||
|
Reddit uploads do not have a file extension, but we can grab the mime-type
|
||||||
|
from the page header.
|
||||||
|
"""
|
||||||
|
pattern = re.compile(r'https://i\.reddituploads\.com/.+$')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_mimetype(url):
|
||||||
|
page = requests.head(url)
|
||||||
|
content_type = page.headers.get('Content-Type', '')
|
||||||
|
content_type = content_type.split(';')[0] # Strip out the encoding
|
||||||
|
return url, content_type
|
||||||
|
|
||||||
|
|
||||||
|
class RedditVideoMIMEParser(BaseMIMEParser):
|
||||||
|
"""
|
||||||
|
Reddit hosted videos/gifs.
|
||||||
|
Media uses MPEG-DASH format (.mpd)
|
||||||
|
"""
|
||||||
|
pattern = re.compile(r'https://v\.redd\.it/.+$')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_mimetype(url):
|
||||||
|
request_url = url + '/DASHPlaylist.mpd'
|
||||||
|
page = requests.get(request_url)
|
||||||
|
soup = BeautifulSoup(page.content, 'html.parser')
|
||||||
|
if not soup.find('representation', attrs={'mimetype': 'audio/mp4'}):
|
||||||
|
reps = soup.find_all('representation', attrs={'mimetype': 'video/mp4'})
|
||||||
|
reps = sorted(reps, reverse=True, key=lambda t: int(t.get('bandwidth')))
|
||||||
|
if reps:
|
||||||
|
url_suffix = reps[0].find('baseurl')
|
||||||
|
if url_suffix:
|
||||||
|
return url + '/' + url_suffix.text, 'video/mp4'
|
||||||
|
|
||||||
|
return request_url, 'video/x-youtube'
|
||||||
|
|
||||||
|
|
||||||
|
class ImgurApiMIMEParser(BaseMIMEParser):
|
||||||
|
"""
|
||||||
|
Imgur now provides a json API exposing its entire infrastructure. Each Imgur
|
||||||
|
page has an associated hash and can either contain an album, a gallery,
|
||||||
|
or single image.
|
||||||
|
|
||||||
|
The default client token for TTRV is shared among users and allows a maximum
|
||||||
|
global number of requests per day of 12,500. If we find that this limit is
|
||||||
|
not sufficient for all of ttrv's traffic, this method will be revisited.
|
||||||
|
|
||||||
|
Reference:
|
||||||
|
https://apidocs.imgur.com
|
||||||
|
"""
|
||||||
|
CLIENT_ID = None
|
||||||
|
pattern = re.compile(
|
||||||
|
r'https?://(w+\.)?(m\.)?imgur\.com/'
|
||||||
|
r'((?P<domain>a|album|gallery)/)?(?P<hash>[a-zA-Z0-9]+)$')
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_mimetype(cls, url):
|
||||||
|
|
||||||
|
endpoint = 'https://api.imgur.com/3/{domain}/{page_hash}'
|
||||||
|
headers = {'authorization': 'Client-ID {0}'.format(cls.CLIENT_ID)}
|
||||||
|
|
||||||
|
m = cls.pattern.match(url)
|
||||||
|
page_hash = m.group('hash')
|
||||||
|
|
||||||
|
if m.group('domain') in ('a', 'album'):
|
||||||
|
domain = 'album'
|
||||||
|
else:
|
||||||
|
# This could be a gallery or a single image, but there doesn't
|
||||||
|
# seem to be a way to reliably distinguish between the two.
|
||||||
|
# Assume a gallery, which appears to be more common, and fallback
|
||||||
|
# to an image request upon failure.
|
||||||
|
domain = 'gallery'
|
||||||
|
|
||||||
|
if not cls.CLIENT_ID:
|
||||||
|
return cls.fallback(url, domain)
|
||||||
|
|
||||||
|
api_url = endpoint.format(domain=domain, page_hash=page_hash)
|
||||||
|
r = requests.get(api_url, headers=headers)
|
||||||
|
|
||||||
|
if domain == 'gallery' and r.status_code != 200:
|
||||||
|
# Not a gallery, try to download using the image endpoint
|
||||||
|
api_url = endpoint.format(domain='image', page_hash=page_hash)
|
||||||
|
r = requests.get(api_url, headers=headers)
|
||||||
|
|
||||||
|
if r.status_code != 200:
|
||||||
|
_logger.warning('Imgur API failure, status %s', r.status_code)
|
||||||
|
return cls.fallback(url, domain)
|
||||||
|
|
||||||
|
data = r.json().get('data')
|
||||||
|
if not data:
|
||||||
|
_logger.warning('Imgur API failure, resp %s', r.json())
|
||||||
|
return cls.fallback(url, domain)
|
||||||
|
|
||||||
|
if 'images' in data and len(data['images']) > 1:
|
||||||
|
# TODO: handle imgur albums with mixed content, i.e. jpeg and gifv
|
||||||
|
link = ' '.join([d['link'] for d in data['images'] if not d['animated']])
|
||||||
|
mime = 'image/x-imgur-album'
|
||||||
|
else:
|
||||||
|
data = data['images'][0] if 'images' in data else data
|
||||||
|
# this handles single image galleries
|
||||||
|
|
||||||
|
link = data['mp4'] if data['animated'] else data['link']
|
||||||
|
mime = 'video/mp4' if data['animated'] else data['type']
|
||||||
|
|
||||||
|
link = link.replace('http://', 'https://')
|
||||||
|
return link, mime
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def fallback(cls, url, domain):
|
||||||
|
"""
|
||||||
|
Attempt to use one of the scrapers if the API doesn't work
|
||||||
|
"""
|
||||||
|
if domain == 'album':
|
||||||
|
# The old Imgur album scraper has stopped working and I haven't
|
||||||
|
# put in the effort to figure out why
|
||||||
|
return url, None
|
||||||
|
else:
|
||||||
|
return ImgurScrapeMIMEParser.get_mimetype(url)
|
||||||
|
|
||||||
|
|
||||||
|
class ImgurScrapeMIMEParser(BaseMIMEParser):
|
||||||
|
"""
|
||||||
|
The majority of imgur links don't point directly to the image, so we need
|
||||||
|
to open the provided url and scrape the page for the link.
|
||||||
|
|
||||||
|
Scrape the actual image url from an imgur landing page. Imgur intentionally
|
||||||
|
obscures this on most reddit links in order to draw more traffic for their
|
||||||
|
advertisements.
|
||||||
|
|
||||||
|
There are a couple of <meta> tags that supply the relevant info:
|
||||||
|
<meta name="twitter:image" content="https://i.imgur.com/xrqQ4LEh.jpg">
|
||||||
|
<meta property="og:image" content="http://i.imgur.com/xrqQ4LE.jpg?fb">
|
||||||
|
<link rel="image_src" href="http://i.imgur.com/xrqQ4LE.jpg">
|
||||||
|
"""
|
||||||
|
pattern = re.compile(r'https?://(w+\.)?(m\.)?imgur\.com/[^.]+$')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_mimetype(url):
|
||||||
|
page = requests.get(url)
|
||||||
|
soup = BeautifulSoup(page.content, 'html.parser')
|
||||||
|
tag = soup.find('meta', attrs={'name': 'twitter:image'})
|
||||||
|
if tag:
|
||||||
|
url = tag.get('content')
|
||||||
|
if GifvMIMEParser.pattern.match(url):
|
||||||
|
return GifvMIMEParser.get_mimetype(url)
|
||||||
|
return BaseMIMEParser.get_mimetype(url)
|
||||||
|
|
||||||
|
|
||||||
|
class InstagramMIMEParser(OpenGraphMIMEParser):
|
||||||
|
"""
|
||||||
|
Instagram uses the Open Graph protocol
|
||||||
|
"""
|
||||||
|
pattern = re.compile(r'https?://(www\.)?instagr((am\.com)|\.am)/p/[^.]+$')
|
||||||
|
|
||||||
|
|
||||||
|
class StreamableMIMEParser(OpenGraphMIMEParser):
|
||||||
|
"""
|
||||||
|
Streamable uses the Open Graph protocol
|
||||||
|
"""
|
||||||
|
pattern = re.compile(r'https?://(www\.)?streamable\.com/[^.]+$')
|
||||||
|
|
||||||
|
|
||||||
|
class LiveleakMIMEParser(BaseMIMEParser):
|
||||||
|
"""
|
||||||
|
https://www.liveleak.com/view?i=12c_3456789
|
||||||
|
<video>
|
||||||
|
<source src="https://cdn.liveleak.com/..mp4" res="HD" type="video/mp4">
|
||||||
|
<source src="https://cdn.liveleak.com/..mp4" res="SD" type="video/mp4">
|
||||||
|
</video>
|
||||||
|
Sometimes only one video source is available
|
||||||
|
"""
|
||||||
|
pattern = re.compile(r'https?://((www|m)\.)?liveleak\.com/view\?i=\w+$')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_mimetype(url):
|
||||||
|
page = requests.get(url)
|
||||||
|
soup = BeautifulSoup(page.content, 'html.parser')
|
||||||
|
|
||||||
|
urls = []
|
||||||
|
videos = soup.find_all('video')
|
||||||
|
for vid in videos:
|
||||||
|
source = vid.find('source', attr={'res': 'HD'})
|
||||||
|
source = source or vid.find('source')
|
||||||
|
if source:
|
||||||
|
urls.append((source.get('src'), source.get('type')))
|
||||||
|
|
||||||
|
# TODO: Handle pages with multiple videos
|
||||||
|
if urls:
|
||||||
|
return urls[0]
|
||||||
|
|
||||||
|
def filter_iframe(t):
|
||||||
|
return t.name == 'iframe' and 'youtube.com' in t['src']
|
||||||
|
|
||||||
|
iframe = soup.find_all(filter_iframe)
|
||||||
|
if iframe:
|
||||||
|
return YoutubeMIMEParser.get_mimetype(iframe[0]['src'].strip('/'))
|
||||||
|
|
||||||
|
return url, None
|
||||||
|
|
||||||
|
|
||||||
|
class ClippitUserMIMEParser(BaseMIMEParser):
|
||||||
|
"""
|
||||||
|
Clippit uses a video player container
|
||||||
|
"""
|
||||||
|
pattern = re.compile(r'https?://(www\.)?clippituser\.tv/c/.+$')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_mimetype(url):
|
||||||
|
page = requests.get(url)
|
||||||
|
soup = BeautifulSoup(page.content, 'html.parser')
|
||||||
|
tag = soup.find(id='player-container')
|
||||||
|
if tag:
|
||||||
|
quality = ['data-{}-file'.format(_) for _ in ['hd', 'sd']]
|
||||||
|
new_url = tag.get(quality[0])
|
||||||
|
if new_url:
|
||||||
|
return new_url, 'video/mp4'
|
||||||
|
|
||||||
|
return url, None
|
||||||
|
|
||||||
|
|
||||||
|
class GifsMIMEParser(OpenGraphMIMEParser):
|
||||||
|
"""
|
||||||
|
Gifs.com uses the Open Graph protocol
|
||||||
|
"""
|
||||||
|
pattern = re.compile(r'https?://(www\.)?gifs\.com/gif/.+$')
|
||||||
|
|
||||||
|
|
||||||
|
class GiphyMIMEParser(OpenGraphMIMEParser):
|
||||||
|
"""
|
||||||
|
Giphy.com uses the Open Graph protocol
|
||||||
|
"""
|
||||||
|
pattern = re.compile(r'https?://(www\.)?giphy\.com/gifs/.+$')
|
||||||
|
|
||||||
|
|
||||||
|
class ImgflipMIMEParser(OpenGraphMIMEParser):
|
||||||
|
"""
|
||||||
|
imgflip.com uses the Open Graph protocol
|
||||||
|
"""
|
||||||
|
pattern = re.compile(r'https?://(www\.)?imgflip\.com/i/.+$')
|
||||||
|
|
||||||
|
|
||||||
|
class LivememeMIMEParser(OpenGraphMIMEParser):
|
||||||
|
"""
|
||||||
|
livememe.com uses the Open Graph protocol
|
||||||
|
"""
|
||||||
|
pattern = re.compile(r'https?://(www\.)?livememe\.com/[^.]+$')
|
||||||
|
|
||||||
|
|
||||||
|
class MakeamemeMIMEParser(OpenGraphMIMEParser):
|
||||||
|
"""
|
||||||
|
makeameme.com uses the Open Graph protocol
|
||||||
|
"""
|
||||||
|
pattern = re.compile(r'https?://(www\.)?makeameme\.org/meme/.+$')
|
||||||
|
|
||||||
|
|
||||||
|
class FlickrMIMEParser(OpenGraphMIMEParser):
|
||||||
|
"""
|
||||||
|
Flickr uses the Open Graph protocol
|
||||||
|
"""
|
||||||
|
# TODO: handle albums/photosets (https://www.flickr.com/services/api)
|
||||||
|
pattern = re.compile(r'https?://(www\.)?flickr\.com/photos/[^/]+/[^/]+/?$')
|
||||||
|
|
||||||
|
|
||||||
|
class StreamjaMIMEParser(VideoTagMIMEParser):
|
||||||
|
"""
|
||||||
|
Embedded HTML5 video element
|
||||||
|
"""
|
||||||
|
pattern = re.compile(r'https?://(www\.)?streamja\.com/[^/]+/?$')
|
||||||
|
|
||||||
|
|
||||||
|
class WorldStarHipHopMIMEParser(BaseMIMEParser):
|
||||||
|
"""
|
||||||
|
<video>
|
||||||
|
<source src="https://hw-mobile.worldstarhiphop.com/..mp4" type="video/mp4">
|
||||||
|
<source src="" type="video/mp4">
|
||||||
|
</video>
|
||||||
|
Sometimes only one video source is available
|
||||||
|
"""
|
||||||
|
pattern = re.compile(r'https?://((www|m)\.)?worldstarhiphop\.com/videos/video.php\?v=\w+$')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_mimetype(url):
|
||||||
|
page = requests.get(url)
|
||||||
|
soup = BeautifulSoup(page.content, 'html.parser')
|
||||||
|
|
||||||
|
def filter_source(t):
|
||||||
|
return t.name == 'source' and t['src'] and t['type'] == 'video/mp4'
|
||||||
|
|
||||||
|
source = soup.find_all(filter_source)
|
||||||
|
if source:
|
||||||
|
return source[0]['src'], 'video/mp4'
|
||||||
|
|
||||||
|
def filter_iframe(t):
|
||||||
|
return t.name == 'iframe' and 'youtube.com' in t['src']
|
||||||
|
|
||||||
|
iframe = soup.find_all(filter_iframe)
|
||||||
|
if iframe:
|
||||||
|
return YoutubeMIMEParser.get_mimetype(iframe[0]['src'])
|
||||||
|
|
||||||
|
return url, None
|
||||||
|
|
||||||
|
|
||||||
|
class RedGifsParser(BaseMIMEParser):
|
||||||
|
"""
|
||||||
|
Extract from application/ld+json
|
||||||
|
"""
|
||||||
|
pattern = re.compile(r'https?://(www\.)?redgifs\.com/watch/.+$')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_mimetype(url):
|
||||||
|
page = requests.get(url)
|
||||||
|
soup = BeautifulSoup(page.content, 'html.parser')
|
||||||
|
tag = soup.find('script', attrs={'type': 'application/ld+json'})
|
||||||
|
if tag:
|
||||||
|
ld_json = json.loads(tag.text)
|
||||||
|
return ld_json['video']['contentUrl'], 'video/mp4'
|
||||||
|
|
||||||
|
return url, None
|
||||||
|
|
||||||
|
|
||||||
|
# Parsers should be listed in the order they will be checked
|
||||||
|
parsers = [
|
||||||
|
StreamjaMIMEParser,
|
||||||
|
ClippitUserMIMEParser,
|
||||||
|
StreamableMIMEParser,
|
||||||
|
InstagramMIMEParser,
|
||||||
|
GfycatMIMEParser,
|
||||||
|
ImgurApiMIMEParser,
|
||||||
|
RedditUploadsMIMEParser,
|
||||||
|
RedditVideoMIMEParser,
|
||||||
|
YoutubeMIMEParser,
|
||||||
|
VimeoMIMEParser,
|
||||||
|
LiveleakMIMEParser,
|
||||||
|
FlickrMIMEParser,
|
||||||
|
GifsMIMEParser,
|
||||||
|
GiphyMIMEParser,
|
||||||
|
ImgflipMIMEParser,
|
||||||
|
LivememeMIMEParser,
|
||||||
|
MakeamemeMIMEParser,
|
||||||
|
WorldStarHipHopMIMEParser,
|
||||||
|
GifvMIMEParser,
|
||||||
|
RedGifsParser,
|
||||||
|
BaseMIMEParser]
|
|
@ -0,0 +1,248 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
import string
|
||||||
|
import codecs
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
|
||||||
|
# pylint: disable=import-error
|
||||||
|
from six.moves.urllib.parse import urlparse, parse_qs
|
||||||
|
from six.moves.BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer
|
||||||
|
|
||||||
|
from . import docs
|
||||||
|
from .config import TEMPLATES
|
||||||
|
from .exceptions import InvalidRefreshToken
|
||||||
|
from .packages.praw.errors import HTTPException, OAuthException
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
INDEX = os.path.join(TEMPLATES, 'index.html')
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthHTTPServer(HTTPServer):
|
||||||
|
|
||||||
|
def handle_error(self, request, client_address):
|
||||||
|
"""
|
||||||
|
The default HTTPServer's error handler prints the request traceback
|
||||||
|
to stdout, which breaks the curses display.
|
||||||
|
|
||||||
|
Override it to log to a file instead.
|
||||||
|
"""
|
||||||
|
_logger.exception('Error processing request in OAuth HTTP Server')
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthHandler(BaseHTTPRequestHandler):
|
||||||
|
|
||||||
|
# params are stored as a global because we don't have control over what
|
||||||
|
# gets passed into the handler __init__. These will be accessed by the
|
||||||
|
# OAuthHelper class.
|
||||||
|
params = {'state': None, 'code': None, 'error': None}
|
||||||
|
shutdown_on_request = True
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
"""
|
||||||
|
Accepts GET requests to http://localhost:6500/, and stores the query
|
||||||
|
params in the global dict. If shutdown_on_request is true, stop the
|
||||||
|
server after the first successful request.
|
||||||
|
|
||||||
|
The http request may contain the following query params:
|
||||||
|
- state : unique identifier, should match what we passed to reddit
|
||||||
|
- code : code that can be exchanged for a refresh token
|
||||||
|
- error : if provided, the OAuth error that occurred
|
||||||
|
"""
|
||||||
|
|
||||||
|
parsed_path = urlparse(self.path)
|
||||||
|
if parsed_path.path != '/':
|
||||||
|
self.send_error(404)
|
||||||
|
|
||||||
|
qs = parse_qs(parsed_path.query)
|
||||||
|
self.params['state'] = qs['state'][0] if 'state' in qs else None
|
||||||
|
self.params['code'] = qs['code'][0] if 'code' in qs else None
|
||||||
|
self.params['error'] = qs['error'][0] if 'error' in qs else None
|
||||||
|
|
||||||
|
body = self.build_body()
|
||||||
|
|
||||||
|
# send_response also sets the Server and Date headers
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header('Content-Type', 'text/html; charset=UTF-8')
|
||||||
|
self.send_header('Content-Length', len(body))
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
if self.shutdown_on_request:
|
||||||
|
# Shutdown the server after serving the request
|
||||||
|
# http://stackoverflow.com/a/22533929
|
||||||
|
thread = threading.Thread(target=self.server.shutdown)
|
||||||
|
thread.daemon = True
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
def log_message(self, fmt, *args):
|
||||||
|
"""
|
||||||
|
Redirect logging to our own handler instead of stdout
|
||||||
|
"""
|
||||||
|
_logger.debug(fmt, *args)
|
||||||
|
|
||||||
|
def build_body(self, template_file=INDEX):
|
||||||
|
"""
|
||||||
|
Params:
|
||||||
|
template_file (text): Path to an index.html template
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
body (bytes): THe utf-8 encoded document body
|
||||||
|
"""
|
||||||
|
|
||||||
|
if self.params['error'] == 'access_denied':
|
||||||
|
message = docs.OAUTH_ACCESS_DENIED
|
||||||
|
elif self.params['error'] is not None:
|
||||||
|
message = docs.OAUTH_ERROR.format(error=self.params['error'])
|
||||||
|
elif self.params['state'] is None or self.params['code'] is None:
|
||||||
|
message = docs.OAUTH_INVALID
|
||||||
|
else:
|
||||||
|
message = docs.OAUTH_SUCCESS
|
||||||
|
|
||||||
|
with codecs.open(template_file, 'r', 'utf-8') as fp:
|
||||||
|
index_text = fp.read()
|
||||||
|
|
||||||
|
body = string.Template(index_text).substitute(message=message)
|
||||||
|
body = codecs.encode(body, 'utf-8')
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthHelper(object):
|
||||||
|
|
||||||
|
params = OAuthHandler.params
|
||||||
|
|
||||||
|
def __init__(self, reddit, term, config):
|
||||||
|
|
||||||
|
self.term = term
|
||||||
|
self.reddit = reddit
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
# Wait to initialize the server, we don't want to reserve the port
|
||||||
|
# unless we know that the server needs to be used.
|
||||||
|
self.server = None
|
||||||
|
|
||||||
|
self.reddit.set_oauth_app_info(
|
||||||
|
self.config['oauth_client_id'],
|
||||||
|
self.config['oauth_client_secret'],
|
||||||
|
self.config['oauth_redirect_uri'])
|
||||||
|
|
||||||
|
# Reddit's mobile website works better on terminal browsers
|
||||||
|
if not self.term.display:
|
||||||
|
if '.compact' not in self.reddit.config.API_PATHS['authorize']:
|
||||||
|
self.reddit.config.API_PATHS['authorize'] += '.compact'
|
||||||
|
|
||||||
|
def authorize(self, autologin=False):
|
||||||
|
|
||||||
|
self.params.update(state=None, code=None, error=None)
|
||||||
|
|
||||||
|
# If we already have a token, request new access credentials
|
||||||
|
if self.config.refresh_token:
|
||||||
|
with self.term.loader('Logging in'):
|
||||||
|
try:
|
||||||
|
self.reddit.refresh_access_information(
|
||||||
|
self.config.refresh_token)
|
||||||
|
except (HTTPException, OAuthException) as e:
|
||||||
|
# Reddit didn't accept the refresh-token
|
||||||
|
# This appears to throw a generic 400 error instead of the
|
||||||
|
# more specific invalid_token message that it used to send
|
||||||
|
if isinstance(e, HTTPException):
|
||||||
|
if e._raw.status_code != 400:
|
||||||
|
# No special handling if the error is something
|
||||||
|
# temporary like a 5XX.
|
||||||
|
raise e
|
||||||
|
|
||||||
|
# Otherwise we know the token is bad, so we can remove it.
|
||||||
|
_logger.exception(e)
|
||||||
|
self.clear_oauth_data()
|
||||||
|
raise InvalidRefreshToken(
|
||||||
|
' Invalid user credentials!\n'
|
||||||
|
'The cached refresh token has been removed')
|
||||||
|
|
||||||
|
else:
|
||||||
|
if not autologin:
|
||||||
|
# Only show the welcome message if explicitly logging
|
||||||
|
# in, not when TTRV first launches.
|
||||||
|
message = 'Welcome {}!'.format(self.reddit.user.name)
|
||||||
|
self.term.show_notification(message)
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
state = uuid.uuid4().hex
|
||||||
|
authorize_url = self.reddit.get_authorize_url(
|
||||||
|
state, scope=self.config['oauth_scope'], refreshable=True)
|
||||||
|
|
||||||
|
if self.server is None:
|
||||||
|
address = ('', self.config['oauth_redirect_port'])
|
||||||
|
self.server = OAuthHTTPServer(address, OAuthHandler)
|
||||||
|
|
||||||
|
if self.term.display:
|
||||||
|
# Open a background browser (e.g. firefox) which is non-blocking.
|
||||||
|
# The server will block until it responds to its first request,
|
||||||
|
# at which point we can check the callback params.
|
||||||
|
OAuthHandler.shutdown_on_request = True
|
||||||
|
with self.term.loader('Opening browser for authorization'):
|
||||||
|
self.term.open_browser(authorize_url)
|
||||||
|
self.server.serve_forever()
|
||||||
|
if self.term.loader.exception:
|
||||||
|
# Don't need to call server.shutdown() because serve_forever()
|
||||||
|
# is wrapped in a try-finally that doees it for us.
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
# Open the terminal webbrowser in a background thread and wait
|
||||||
|
# while for the user to close the process. Once the process is
|
||||||
|
# closed, the iloop is stopped and we can check if the user has
|
||||||
|
# hit the callback URL.
|
||||||
|
OAuthHandler.shutdown_on_request = False
|
||||||
|
with self.term.loader('Redirecting to reddit', delay=0):
|
||||||
|
# This load message exists to provide user feedback
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
thread = threading.Thread(target=self.server.serve_forever)
|
||||||
|
thread.daemon = True
|
||||||
|
thread.start()
|
||||||
|
try:
|
||||||
|
self.term.open_browser(authorize_url)
|
||||||
|
except Exception as e:
|
||||||
|
# If an exception is raised it will be seen by the thread
|
||||||
|
# so we don't need to explicitly shutdown() the server
|
||||||
|
_logger.exception(e)
|
||||||
|
self.term.show_notification('Browser Error', style='Error')
|
||||||
|
else:
|
||||||
|
self.server.shutdown()
|
||||||
|
finally:
|
||||||
|
thread.join()
|
||||||
|
|
||||||
|
if self.params['error'] == 'access_denied':
|
||||||
|
self.term.show_notification('Denied access', style='Error')
|
||||||
|
return
|
||||||
|
elif self.params['error']:
|
||||||
|
self.term.show_notification('Authentication error', style='Error')
|
||||||
|
return
|
||||||
|
elif self.params['state'] is None:
|
||||||
|
# Something went wrong but it's not clear what happened
|
||||||
|
return
|
||||||
|
elif self.params['state'] != state:
|
||||||
|
self.term.show_notification('UUID mismatch', style='Error')
|
||||||
|
return
|
||||||
|
|
||||||
|
with self.term.loader('Logging in'):
|
||||||
|
info = self.reddit.get_access_information(self.params['code'])
|
||||||
|
if self.term.loader.exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
message = 'Welcome {}!'.format(self.reddit.user.name)
|
||||||
|
self.term.show_notification(message)
|
||||||
|
|
||||||
|
self.config.refresh_token = info['refresh_token']
|
||||||
|
if self.config['persistent']:
|
||||||
|
self.config.save_refresh_token()
|
||||||
|
|
||||||
|
def clear_oauth_data(self):
|
||||||
|
self.reddit.clear_authentication()
|
||||||
|
self.config.delete_refresh_token()
|
|
@ -0,0 +1,709 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
import re
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import signal
|
||||||
|
import inspect
|
||||||
|
import weakref
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import webbrowser
|
||||||
|
import curses
|
||||||
|
import curses.ascii
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
import six
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from . import exceptions
|
||||||
|
from .packages import praw
|
||||||
|
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def patch_webbrowser():
|
||||||
|
"""
|
||||||
|
Some custom patches on top of the python webbrowser module to fix
|
||||||
|
user reported bugs and limitations of the module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# https://bugs.python.org/issue31014
|
||||||
|
# https://github.com/michael-lazar/ttrv/issues/588
|
||||||
|
def register_patch(name, klass, instance=None, update_tryorder=None, preferred=False):
|
||||||
|
"""
|
||||||
|
Wrapper around webbrowser.register() that detects if the function was
|
||||||
|
invoked with the legacy function signature. If so, the signature is
|
||||||
|
fixed before passing it along to the underlying function.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
register(name, klass, instance, -1)
|
||||||
|
register(name, klass, instance, update_tryorder=-1)
|
||||||
|
register(name, klass, instance, preferred=True)
|
||||||
|
"""
|
||||||
|
if update_tryorder is not None:
|
||||||
|
preferred = (update_tryorder == -1)
|
||||||
|
return webbrowser._register(name, klass, instance, preferred=preferred)
|
||||||
|
|
||||||
|
if sys.version_info[:2] >= (3, 7):
|
||||||
|
webbrowser._register = webbrowser.register
|
||||||
|
webbrowser.register = register_patch
|
||||||
|
|
||||||
|
# Add support for browsers that aren't defined in the python standard library
|
||||||
|
webbrowser.register('surf', None, webbrowser.BackgroundBrowser('surf'))
|
||||||
|
webbrowser.register('vimb', None, webbrowser.BackgroundBrowser('vimb'))
|
||||||
|
webbrowser.register('qutebrowser', None, webbrowser.BackgroundBrowser('qutebrowser'))
|
||||||
|
|
||||||
|
# Fix the opera browser, see https://github.com/michael-lazar/ttrv/issues/476.
|
||||||
|
# By default, opera will open a new tab in the current window, which is
|
||||||
|
# what we want to do anyway.
|
||||||
|
webbrowser.register('opera', None, webbrowser.BackgroundBrowser('opera'))
|
||||||
|
|
||||||
|
# https://bugs.python.org/issue31348
|
||||||
|
# Use MacOS actionscript when opening the program defined in by $BROWSER
|
||||||
|
if sys.platform == 'darwin' and 'BROWSER' in os.environ:
|
||||||
|
_userchoices = os.environ["BROWSER"].split(os.pathsep)
|
||||||
|
for cmdline in reversed(_userchoices):
|
||||||
|
if cmdline in ('safari', 'firefox', 'chrome', 'default'):
|
||||||
|
browser = webbrowser.MacOSXOSAScript(cmdline)
|
||||||
|
webbrowser.register(cmdline, None, browser, update_tryorder=-1)
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def curses_session():
|
||||||
|
"""
|
||||||
|
Setup terminal and initialize curses. Most of this copied from
|
||||||
|
curses.wrapper in order to convert the wrapper into a context manager.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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
|
||||||
|
# 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()
|
||||||
|
curses.use_default_colors()
|
||||||
|
except:
|
||||||
|
_logger.warning('Curses failed to initialize color support')
|
||||||
|
|
||||||
|
# Hide the blinking cursor
|
||||||
|
try:
|
||||||
|
curses.curs_set(0)
|
||||||
|
except:
|
||||||
|
_logger.warning('Curses failed to initialize the cursor mode')
|
||||||
|
|
||||||
|
yield stdscr
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if 'stdscr' in locals():
|
||||||
|
stdscr.keypad(0)
|
||||||
|
curses.echo()
|
||||||
|
curses.nocbreak()
|
||||||
|
curses.endwin()
|
||||||
|
|
||||||
|
|
||||||
|
class LoadScreen(object):
|
||||||
|
"""
|
||||||
|
Display a loading dialog while waiting for a blocking action to complete.
|
||||||
|
|
||||||
|
This class spins off a separate thread to animate the loading screen in the
|
||||||
|
background. The loading thread also takes control of stdscr.getch(). If
|
||||||
|
an exception occurs in the main thread while the loader is active, the
|
||||||
|
exception will be caught, attached to the loader object, and displayed as
|
||||||
|
a notification. The attached exception can be used to trigger context
|
||||||
|
sensitive actions. For example, if the connection hangs while opening a
|
||||||
|
submission, the user may press ctrl-c to raise a KeyboardInterrupt. In this
|
||||||
|
case we would *not* want to refresh the current page.
|
||||||
|
|
||||||
|
>>> with self.terminal.loader(...) as loader:
|
||||||
|
>>> # Perform a blocking request to load content
|
||||||
|
>>> blocking_request(...)
|
||||||
|
>>>
|
||||||
|
>>> if loader.exception is None:
|
||||||
|
>>> # Only run this if the load was successful
|
||||||
|
>>> self.refresh_content()
|
||||||
|
|
||||||
|
When a loader is nested inside of itself, the outermost loader takes
|
||||||
|
priority and all of the nested loaders become no-ops. Call arguments given
|
||||||
|
to nested loaders will be ignored, and errors will propagate to the parent.
|
||||||
|
|
||||||
|
>>> with self.terminal.loader(...) as loader:
|
||||||
|
>>>
|
||||||
|
>>> # Additional loaders will be ignored
|
||||||
|
>>> with self.terminal.loader(...):
|
||||||
|
>>> raise KeyboardInterrupt()
|
||||||
|
>>>
|
||||||
|
>>> # This code will not be executed because the inner loader doesn't
|
||||||
|
>>> # catch the exception
|
||||||
|
>>> assert False
|
||||||
|
>>>
|
||||||
|
>>> # The exception is finally caught by the outer loader
|
||||||
|
>>> assert isinstance(terminal.loader.exception, KeyboardInterrupt)
|
||||||
|
"""
|
||||||
|
|
||||||
|
EXCEPTION_MESSAGES = [
|
||||||
|
(exceptions.TTRVError, '{0}'),
|
||||||
|
(praw.errors.OAuthException, 'OAuth Error'),
|
||||||
|
(praw.errors.OAuthScopeRequired, 'Not logged in'),
|
||||||
|
(praw.errors.LoginRequired, 'Not logged in'),
|
||||||
|
(praw.errors.InvalidCaptcha, 'Error, captcha required'),
|
||||||
|
(praw.errors.InvalidSubreddit, '{0.args[0]}'),
|
||||||
|
(praw.errors.PRAWException, '{0.__class__.__name__}'),
|
||||||
|
(requests.exceptions.Timeout, 'HTTP request timed out'),
|
||||||
|
(requests.exceptions.RequestException, '{0.__class__.__name__}'),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, terminal):
|
||||||
|
|
||||||
|
self.exception = None
|
||||||
|
self.catch_exception = None
|
||||||
|
self.depth = 0
|
||||||
|
self._terminal = weakref.proxy(terminal)
|
||||||
|
self._args = None
|
||||||
|
self._animator = None
|
||||||
|
self._is_running = None
|
||||||
|
|
||||||
|
def __call__(
|
||||||
|
self,
|
||||||
|
message='Downloading',
|
||||||
|
trail='...',
|
||||||
|
delay=0.5,
|
||||||
|
interval=0.4,
|
||||||
|
catch_exception=True):
|
||||||
|
"""
|
||||||
|
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.
|
||||||
|
catch_exception (bool): If an exception occurs while the loader is
|
||||||
|
active, this flag determines whether it is caught or allowed to
|
||||||
|
bubble up.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if self.depth > 0:
|
||||||
|
return self
|
||||||
|
|
||||||
|
self.exception = None
|
||||||
|
self.catch_exception = catch_exception
|
||||||
|
self._args = (delay, interval, message, trail)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
|
||||||
|
self.depth += 1
|
||||||
|
if self.depth > 1:
|
||||||
|
return self
|
||||||
|
|
||||||
|
self._animator = threading.Thread(target=self.animate, args=self._args)
|
||||||
|
self._animator.daemon = True
|
||||||
|
self._is_running = True
|
||||||
|
self._animator.start()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, e, exc_tb):
|
||||||
|
|
||||||
|
self.depth -= 1
|
||||||
|
if self.depth > 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._is_running = False
|
||||||
|
self._animator.join()
|
||||||
|
|
||||||
|
if e is None or not self.catch_exception:
|
||||||
|
# Skip exception handling
|
||||||
|
return
|
||||||
|
|
||||||
|
self.exception = e
|
||||||
|
exc_name = type(e).__name__
|
||||||
|
_logger.info('Loader caught: %s - %s', exc_name, e)
|
||||||
|
|
||||||
|
if isinstance(e, KeyboardInterrupt):
|
||||||
|
# Don't need to print anything for this one, just swallow it
|
||||||
|
return True
|
||||||
|
|
||||||
|
for e_type, message in self.EXCEPTION_MESSAGES:
|
||||||
|
# Some exceptions we want to swallow and display a notification
|
||||||
|
if isinstance(e, e_type):
|
||||||
|
msg = message.format(e)
|
||||||
|
self._terminal.show_notification(msg, style='Error')
|
||||||
|
return True
|
||||||
|
|
||||||
|
def animate(self, delay, interval, message, trail):
|
||||||
|
|
||||||
|
# The animation starts with a configurable delay before drawing on the
|
||||||
|
# screen. This is to prevent very short loading sections from
|
||||||
|
# flickering on the screen before immediately disappearing.
|
||||||
|
with self._terminal.no_delay():
|
||||||
|
start = time.time()
|
||||||
|
while (time.time() - start) < delay:
|
||||||
|
# Pressing escape triggers a keyboard interrupt
|
||||||
|
if self._terminal.getch() == self._terminal.ESCAPE:
|
||||||
|
os.kill(os.getpid(), signal.SIGINT)
|
||||||
|
self._is_running = False
|
||||||
|
|
||||||
|
if not self._is_running:
|
||||||
|
return
|
||||||
|
time.sleep(0.01)
|
||||||
|
|
||||||
|
# Build the notification window. Note that we need to use
|
||||||
|
# curses.newwin() instead of stdscr.derwin() so the text below the
|
||||||
|
# notification window does not got erased when we cover it up.
|
||||||
|
message_len = len(message) + len(trail)
|
||||||
|
n_rows, n_cols = self._terminal.stdscr.getmaxyx()
|
||||||
|
v_offset, h_offset = self._terminal.stdscr.getbegyx()
|
||||||
|
s_row = (n_rows - 3) // 2 + v_offset
|
||||||
|
s_col = (n_cols - message_len - 1) // 2 + h_offset
|
||||||
|
window = curses.newwin(3, message_len + 2, s_row, s_col)
|
||||||
|
window.bkgd(str(' '), self._terminal.attr('NoticeLoading'))
|
||||||
|
|
||||||
|
# Animate the loading prompt until the stopping condition is triggered
|
||||||
|
# when the context manager exits.
|
||||||
|
with self._terminal.no_delay():
|
||||||
|
while True:
|
||||||
|
for i in range(len(trail) + 1):
|
||||||
|
if not self._is_running:
|
||||||
|
window.erase()
|
||||||
|
del window
|
||||||
|
self._terminal.stdscr.touchwin()
|
||||||
|
self._terminal.stdscr.refresh()
|
||||||
|
return
|
||||||
|
|
||||||
|
window.erase()
|
||||||
|
window.border()
|
||||||
|
self._terminal.add_line(window, message + trail[:i], 1, 1)
|
||||||
|
window.refresh()
|
||||||
|
|
||||||
|
# Break up the designated sleep interval into smaller
|
||||||
|
# chunks so we can more responsively check for interrupts.
|
||||||
|
for _ in range(int(interval / 0.01)):
|
||||||
|
# Pressing escape triggers a keyboard interrupt
|
||||||
|
if self._terminal.getch() == self._terminal.ESCAPE:
|
||||||
|
os.kill(os.getpid(), signal.SIGINT)
|
||||||
|
self._is_running = False
|
||||||
|
break
|
||||||
|
time.sleep(0.01)
|
||||||
|
|
||||||
|
|
||||||
|
class Navigator(object):
|
||||||
|
"""
|
||||||
|
Handles the math behind cursor movement and screen paging.
|
||||||
|
|
||||||
|
This class determines how cursor movements effect the currently displayed
|
||||||
|
page. For example, if scrolling down the page, items are drawn from the
|
||||||
|
bottom up. This ensures that the item at the very bottom of the screen
|
||||||
|
(the one selected by cursor) will be fully drawn and not cut off. Likewise,
|
||||||
|
when scrolling up the page, items are drawn from the top down. If the
|
||||||
|
cursor is moved around without hitting the top or bottom of the screen, the
|
||||||
|
current mode is preserved.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
valid_page_cb,
|
||||||
|
page_index=0,
|
||||||
|
cursor_index=0,
|
||||||
|
inverted=False,
|
||||||
|
top_item_height=None):
|
||||||
|
"""
|
||||||
|
Params:
|
||||||
|
valid_page_callback (func): This function, usually `Content.get`,
|
||||||
|
takes a page index and raises an IndexError if that index falls
|
||||||
|
out of bounds. This is used to determine the upper and lower
|
||||||
|
bounds of the page, i.e. when to stop scrolling.
|
||||||
|
page_index (int): Initial page index.
|
||||||
|
cursor_index (int): Initial cursor index, relative to the page.
|
||||||
|
inverted (bool): Whether the page scrolling is reversed of not.
|
||||||
|
normal - The page is drawn from the top of the screen,
|
||||||
|
starting with the page index, down to the bottom of
|
||||||
|
the screen.
|
||||||
|
inverted - The page is drawn from the bottom of the screen,
|
||||||
|
starting with the page index, up to the top of the
|
||||||
|
screen.
|
||||||
|
top_item_height (int): If this is set to a non-null value
|
||||||
|
The number of columns that the top-most item
|
||||||
|
should utilize if non-inverted. This is used for a special mode
|
||||||
|
where all items are drawn non-inverted except for the top one.
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.page_index = page_index
|
||||||
|
self.cursor_index = cursor_index
|
||||||
|
self.inverted = inverted
|
||||||
|
self.top_item_height = top_item_height
|
||||||
|
self._page_cb = valid_page_cb
|
||||||
|
|
||||||
|
@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 the index of the currently selected item.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return self.page_index + (self.step * self.cursor_index)
|
||||||
|
|
||||||
|
def move(self, direction, n_windows):
|
||||||
|
"""
|
||||||
|
Move the cursor up or down by the given increment.
|
||||||
|
|
||||||
|
Params:
|
||||||
|
direction (int): `1` will move the cursor down one item and `-1`
|
||||||
|
will move the cursor up one item.
|
||||||
|
n_windows (int): The number of items that are currently being drawn
|
||||||
|
on the screen.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
valid (bool): Indicates whether or not the attempted cursor move is
|
||||||
|
allowed. E.g. When the cursor is on the last comment,
|
||||||
|
attempting to scroll down any further would not be valid.
|
||||||
|
redraw (bool): Indicates whether or not the screen needs to be
|
||||||
|
redrawn.
|
||||||
|
"""
|
||||||
|
|
||||||
|
assert direction in (-1, 1)
|
||||||
|
|
||||||
|
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
|
||||||
|
self.top_item_height = None
|
||||||
|
redraw = True
|
||||||
|
else:
|
||||||
|
if self.cursor_index > 0:
|
||||||
|
self.cursor_index -= 1
|
||||||
|
if self.top_item_height and self.cursor_index == 0:
|
||||||
|
# Selecting the partially displayed item
|
||||||
|
self.top_item_height = None
|
||||||
|
redraw = True
|
||||||
|
else:
|
||||||
|
self.page_index -= self.step
|
||||||
|
if self._is_valid(self.absolute_index):
|
||||||
|
# We have reached the beginning of the page - move the
|
||||||
|
# index
|
||||||
|
self.top_item_height = None
|
||||||
|
redraw = True
|
||||||
|
else:
|
||||||
|
self.page_index += self.step
|
||||||
|
valid = False # Revert
|
||||||
|
|
||||||
|
return valid, redraw
|
||||||
|
|
||||||
|
def move_page(self, direction, n_windows):
|
||||||
|
"""
|
||||||
|
Move the page down (positive direction) or up (negative direction).
|
||||||
|
|
||||||
|
Paging down:
|
||||||
|
The post on the bottom of the page becomes the post at the top of
|
||||||
|
the page and the cursor is moved to the top.
|
||||||
|
Paging up:
|
||||||
|
The post at the top of the page becomes the post at the bottom of
|
||||||
|
the page and the cursor is moved to the bottom.
|
||||||
|
"""
|
||||||
|
|
||||||
|
assert direction in (-1, 1)
|
||||||
|
assert n_windows >= 0
|
||||||
|
|
||||||
|
# top of subreddit/submission page or only one
|
||||||
|
# submission/reply on the screen: act as normal move
|
||||||
|
if (self.absolute_index < 0) | (n_windows == 0):
|
||||||
|
valid, redraw = self.move(direction, n_windows)
|
||||||
|
else:
|
||||||
|
# first page
|
||||||
|
if self.absolute_index < n_windows and direction < 0:
|
||||||
|
self.page_index = -1
|
||||||
|
self.cursor_index = 0
|
||||||
|
self.inverted = False
|
||||||
|
|
||||||
|
# not submission mode: starting index is 0
|
||||||
|
if not self._is_valid(self.absolute_index):
|
||||||
|
self.page_index = 0
|
||||||
|
valid = True
|
||||||
|
else:
|
||||||
|
# flip to the direction of movement
|
||||||
|
if ((direction > 0) & (self.inverted is True)) \
|
||||||
|
| ((direction < 0) & (self.inverted is False)):
|
||||||
|
self.page_index += (self.step * (n_windows - 1))
|
||||||
|
self.inverted = not self.inverted
|
||||||
|
self.cursor_index \
|
||||||
|
= (n_windows - (direction < 0)) - self.cursor_index
|
||||||
|
|
||||||
|
valid = False
|
||||||
|
adj = 0
|
||||||
|
# check if reached the bottom
|
||||||
|
while not valid:
|
||||||
|
n_move = n_windows - adj
|
||||||
|
if n_move == 0:
|
||||||
|
break
|
||||||
|
|
||||||
|
self.page_index += n_move * direction
|
||||||
|
valid = self._is_valid(self.absolute_index)
|
||||||
|
if not valid:
|
||||||
|
self.page_index -= n_move * direction
|
||||||
|
adj += 1
|
||||||
|
|
||||||
|
redraw = True
|
||||||
|
|
||||||
|
return valid, redraw
|
||||||
|
|
||||||
|
def flip(self, n_windows):
|
||||||
|
"""
|
||||||
|
Flip the orientation of the page.
|
||||||
|
"""
|
||||||
|
|
||||||
|
assert n_windows >= 0
|
||||||
|
self.page_index += (self.step * n_windows)
|
||||||
|
self.cursor_index = n_windows
|
||||||
|
self.inverted = not self.inverted
|
||||||
|
self.top_item_height = None
|
||||||
|
|
||||||
|
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 Controller(object):
|
||||||
|
"""
|
||||||
|
Event handler for triggering functions with curses keypresses.
|
||||||
|
|
||||||
|
Register a keystroke to a class method using the @register decorator.
|
||||||
|
>>> @Controller.register('a', 'A')
|
||||||
|
>>> def func(self, *args)
|
||||||
|
>>> ...
|
||||||
|
|
||||||
|
Register a KeyBinding that can be defined later by the config file
|
||||||
|
>>> @Controller.register(Command("UPVOTE"))
|
||||||
|
>>> def upvote(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 = {}
|
||||||
|
|
||||||
|
def __init__(self, instance, keymap=None):
|
||||||
|
|
||||||
|
self.instance = instance
|
||||||
|
# Build a list of parent controllers that follow the object's MRO
|
||||||
|
# to check if any parent controllers have registered the keypress
|
||||||
|
self.parents = inspect.getmro(type(self))[:-1]
|
||||||
|
# Keep track of last key press for doubles like `gg`
|
||||||
|
self.last_char = None
|
||||||
|
|
||||||
|
if not keymap:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Go through the controller and all of it's parents and look for
|
||||||
|
# Command objects in the character map. Use the keymap the lookup the
|
||||||
|
# keys associated with those command objects and add them to the
|
||||||
|
# character map.
|
||||||
|
for controller in self.parents:
|
||||||
|
for command, func in controller.character_map.copy().items():
|
||||||
|
if isinstance(command, Command):
|
||||||
|
for key in keymap.get(command):
|
||||||
|
val = keymap.parse(key)
|
||||||
|
# If a double key press is defined, the first half
|
||||||
|
# must be unbound
|
||||||
|
if isinstance(val, tuple):
|
||||||
|
if controller.character_map.get(val[0]) is not None:
|
||||||
|
raise exceptions.ConfigError(
|
||||||
|
"Invalid configuration! `%s` is bound to "
|
||||||
|
"duplicate commands in the "
|
||||||
|
"%s" % (key, controller.__name__))
|
||||||
|
# Mark the first half of the double with None so
|
||||||
|
# that no other command can use it
|
||||||
|
controller.character_map[val[0]] = None
|
||||||
|
|
||||||
|
# Check if the key is already programmed to trigger a
|
||||||
|
# different function.
|
||||||
|
if controller.character_map.get(val, func) != func:
|
||||||
|
raise exceptions.ConfigError(
|
||||||
|
"Invalid configuration! `%s` is bound to "
|
||||||
|
"duplicate commands in the "
|
||||||
|
"%s" % (key, controller.__name__))
|
||||||
|
controller.character_map[val] = func
|
||||||
|
|
||||||
|
def trigger(self, char, *args, **kwargs):
|
||||||
|
|
||||||
|
if isinstance(char, six.string_types) and len(char) == 1:
|
||||||
|
char = ord(char)
|
||||||
|
|
||||||
|
func = None
|
||||||
|
# Check if the controller (or any of the controller's parents) have
|
||||||
|
# registered a function to the given key
|
||||||
|
for controller in self.parents:
|
||||||
|
func = controller.character_map.get((self.last_char, char))
|
||||||
|
if func:
|
||||||
|
break
|
||||||
|
func = controller.character_map.get(char)
|
||||||
|
if func:
|
||||||
|
break
|
||||||
|
|
||||||
|
if func:
|
||||||
|
self.last_char = None
|
||||||
|
return func(self.instance, *args, **kwargs)
|
||||||
|
else:
|
||||||
|
self.last_char = char
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def register(cls, *chars):
|
||||||
|
def inner(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 inner
|
||||||
|
|
||||||
|
|
||||||
|
class Command(object):
|
||||||
|
"""
|
||||||
|
Minimal class that should be used to wrap abstract commands that may be
|
||||||
|
implemented as one or more physical keystrokes.
|
||||||
|
|
||||||
|
E.g. Command("REFRESH") can be represented by the KeyMap to be triggered
|
||||||
|
by either `r` or `F5`
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, val):
|
||||||
|
self.val = val.upper()
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return 'Command(%s)' % self.val
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
return repr(self) == repr(other)
|
||||||
|
|
||||||
|
def __ne__(self, other):
|
||||||
|
return not self == other
|
||||||
|
|
||||||
|
def __hash__(self):
|
||||||
|
return hash(repr(self))
|
||||||
|
|
||||||
|
|
||||||
|
class KeyMap(object):
|
||||||
|
"""
|
||||||
|
Mapping between commands and the keys that they represent.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, bindings):
|
||||||
|
self._keymap = None
|
||||||
|
self.set_bindings(bindings)
|
||||||
|
|
||||||
|
def set_bindings(self, bindings):
|
||||||
|
new_keymap = {}
|
||||||
|
for command, keys in bindings.items():
|
||||||
|
if not isinstance(command, Command):
|
||||||
|
command = Command(command)
|
||||||
|
new_keymap[command] = keys
|
||||||
|
|
||||||
|
if not self._keymap:
|
||||||
|
self._keymap = new_keymap
|
||||||
|
else:
|
||||||
|
self._keymap.update(new_keymap)
|
||||||
|
|
||||||
|
def get(self, command):
|
||||||
|
if not isinstance(command, Command):
|
||||||
|
command = Command(command)
|
||||||
|
try:
|
||||||
|
return self._keymap[command]
|
||||||
|
except KeyError:
|
||||||
|
raise exceptions.ConfigError('Invalid configuration! `%s` key is '
|
||||||
|
'undefined' % command.val)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def parse(cls, key):
|
||||||
|
"""
|
||||||
|
Parse a key represented by a string and return its character code.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
if isinstance(key, int):
|
||||||
|
return key
|
||||||
|
elif re.match('[<]KEY_.*[>]', key):
|
||||||
|
# Curses control character
|
||||||
|
return getattr(curses, key[1:-1])
|
||||||
|
elif re.match('[<].*[>]', key):
|
||||||
|
# Ascii control character
|
||||||
|
return getattr(curses.ascii, key[1:-1])
|
||||||
|
elif key.startswith('0x'):
|
||||||
|
# Ascii hex code
|
||||||
|
return int(key, 16)
|
||||||
|
elif len(key) == 2:
|
||||||
|
# Double presses
|
||||||
|
return tuple(cls.parse(k) for k in key)
|
||||||
|
else:
|
||||||
|
# Ascii character
|
||||||
|
code = ord(key)
|
||||||
|
if 0 <= code <= 255:
|
||||||
|
return code
|
||||||
|
# Python 3.3 has a curses.get_wch() function that we can use
|
||||||
|
# for unicode keys, but Python 2.7 is limited to ascii.
|
||||||
|
raise exceptions.ConfigError('Invalid configuration! `%s` is '
|
||||||
|
'not in the ascii range' % key)
|
||||||
|
|
||||||
|
except (AttributeError, ValueError, TypeError):
|
||||||
|
raise exceptions.ConfigError('Invalid configuration! "%s" is not a '
|
||||||
|
'valid key' % key)
|
|
@ -0,0 +1,26 @@
|
||||||
|
"""
|
||||||
|
This stub allows the user to fallback to their system installation of
|
||||||
|
praw if the bundled package is missing. This technique was inspired by the
|
||||||
|
requests library and how it handles dependencies.
|
||||||
|
|
||||||
|
Reference:
|
||||||
|
https://github.com/kennethreitz/requests/blob/master/requests/packages/__init__.py
|
||||||
|
"""
|
||||||
|
from __future__ import absolute_import
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
__praw_hash__ = '1656ec224e574eed9cda4efcb497825d54b4d926'
|
||||||
|
__praw_bundled__ = True
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
from . import praw
|
||||||
|
except ImportError:
|
||||||
|
import praw
|
||||||
|
|
||||||
|
if not praw.__version__.startswith('3.'):
|
||||||
|
raise RuntimeError('Invalid PRAW version ({0}) detected, '
|
||||||
|
'ttrv requires PRAW version 3'.format(praw.__version__))
|
||||||
|
sys.modules['%s.praw' % __name__] = praw
|
||||||
|
__praw_bundled__ = False
|
|
@ -0,0 +1,38 @@
|
||||||
|
"""Internal helper functions used by praw.decorators."""
|
||||||
|
import inspect
|
||||||
|
from requests.compat import urljoin
|
||||||
|
import six
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def _get_captcha(reddit_session, captcha_id):
|
||||||
|
"""Prompt user for captcha solution and return a prepared result."""
|
||||||
|
url = urljoin(reddit_session.config['captcha'],
|
||||||
|
captcha_id + '.png')
|
||||||
|
sys.stdout.write('Captcha URL: {0}\nCaptcha: '.format(url))
|
||||||
|
sys.stdout.flush()
|
||||||
|
raw = sys.stdin.readline()
|
||||||
|
if not raw: # stdin has reached the end of file
|
||||||
|
# Trigger exception raising next time through. The request is
|
||||||
|
# cached so this will not require and extra request and delay.
|
||||||
|
sys.stdin.close()
|
||||||
|
return None
|
||||||
|
return {'iden': captcha_id, 'captcha': raw.strip()}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_mod_of_all(user, subreddit):
|
||||||
|
mod_subs = user.get_cached_moderated_reddits()
|
||||||
|
subs = six.text_type(subreddit).lower().split('+')
|
||||||
|
return all(sub in mod_subs for sub in subs)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_func_args(function):
|
||||||
|
if six.PY3 and not hasattr(sys, 'pypy_version_info'):
|
||||||
|
# CPython3 uses inspect.signature(), not inspect.getargspec()
|
||||||
|
# see #551 and #541 for more info
|
||||||
|
func_items = inspect.signature(function).parameters.items()
|
||||||
|
func_args = [name for name, param in func_items
|
||||||
|
if param.kind == param.POSITIONAL_OR_KEYWORD]
|
||||||
|
else:
|
||||||
|
func_args = inspect.getargspec(function).args
|
||||||
|
return func_args
|
|
@ -0,0 +1,294 @@
|
||||||
|
# This file is part of PRAW.
|
||||||
|
#
|
||||||
|
# PRAW is free software: you can redistribute it and/or modify it under the
|
||||||
|
# terms of the GNU General Public License as published by the Free Software
|
||||||
|
# Foundation, either version 3 of the License, or (at your option) any later
|
||||||
|
# version.
|
||||||
|
#
|
||||||
|
# PRAW is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||||
|
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||||
|
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License along with
|
||||||
|
# PRAW. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
"""
|
||||||
|
Decorators.
|
||||||
|
|
||||||
|
They mainly do two things: ensure API guidelines are followed and
|
||||||
|
prevent unnecessary failed API requests by testing that the call can be made
|
||||||
|
first. Also, they can limit the length of output strings and parse json
|
||||||
|
response for certain errors.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import print_function, unicode_literals
|
||||||
|
|
||||||
|
import decorator
|
||||||
|
import six
|
||||||
|
import sys
|
||||||
|
from functools import wraps
|
||||||
|
from .decorator_helpers import (
|
||||||
|
_get_captcha,
|
||||||
|
_is_mod_of_all,
|
||||||
|
_make_func_args
|
||||||
|
)
|
||||||
|
from . import errors
|
||||||
|
from warnings import filterwarnings, warn
|
||||||
|
|
||||||
|
|
||||||
|
# Enable deprecation warnings from this module
|
||||||
|
filterwarnings('default', category=DeprecationWarning,
|
||||||
|
module='^praw\.decorators$')
|
||||||
|
|
||||||
|
|
||||||
|
def alias_function(function, class_name):
|
||||||
|
"""Create a RedditContentObject function mapped to a BaseReddit function.
|
||||||
|
|
||||||
|
The BaseReddit classes define the majority of the API's functions. The
|
||||||
|
first argument for many of these functions is the RedditContentObject that
|
||||||
|
they operate on. This factory returns functions appropriate to be called on
|
||||||
|
a RedditContent object that maps to the corresponding BaseReddit function.
|
||||||
|
|
||||||
|
"""
|
||||||
|
@wraps(function)
|
||||||
|
def wrapped(self, *args, **kwargs):
|
||||||
|
func_args = _make_func_args(function)
|
||||||
|
if 'subreddit' in func_args and func_args.index('subreddit') != 1:
|
||||||
|
# Only happens for search
|
||||||
|
kwargs['subreddit'] = self
|
||||||
|
return function(self.reddit_session, *args, **kwargs)
|
||||||
|
else:
|
||||||
|
return function(self.reddit_session, self, *args, **kwargs)
|
||||||
|
# Only grab the short-line doc and add a link to the complete doc
|
||||||
|
if wrapped.__doc__ is not None:
|
||||||
|
wrapped.__doc__ = wrapped.__doc__.split('\n', 1)[0]
|
||||||
|
wrapped.__doc__ += ('\n\nSee :meth:`.{0}.{1}` for complete usage. '
|
||||||
|
'Note that you should exclude the subreddit '
|
||||||
|
'parameter when calling this convenience method.'
|
||||||
|
.format(class_name, function.__name__))
|
||||||
|
# Don't hide from sphinx as this is a parameter modifying decorator
|
||||||
|
return wrapped
|
||||||
|
|
||||||
|
|
||||||
|
def deprecated(msg=''):
|
||||||
|
"""Deprecate decorated method."""
|
||||||
|
@decorator.decorator
|
||||||
|
def wrap(function, *args, **kwargs):
|
||||||
|
if not kwargs.pop('disable_warning', False):
|
||||||
|
warn(msg, DeprecationWarning)
|
||||||
|
return function(*args, **kwargs)
|
||||||
|
return wrap
|
||||||
|
|
||||||
|
|
||||||
|
@decorator.decorator
|
||||||
|
def limit_chars(function, *args, **kwargs):
|
||||||
|
"""Truncate the string returned from a function and return the result."""
|
||||||
|
output_chars_limit = args[0].reddit_session.config.output_chars_limit
|
||||||
|
output_string = function(*args, **kwargs)
|
||||||
|
if -1 < output_chars_limit < len(output_string):
|
||||||
|
output_string = output_string[:output_chars_limit - 3] + '...'
|
||||||
|
return output_string
|
||||||
|
|
||||||
|
|
||||||
|
@decorator.decorator
|
||||||
|
def oauth_generator(function, *args, **kwargs):
|
||||||
|
"""Set the _use_oauth keyword argument to True when appropriate.
|
||||||
|
|
||||||
|
This is needed because generator functions may be called at anytime, and
|
||||||
|
PRAW relies on the Reddit._use_oauth value at original call time to know
|
||||||
|
when to make OAuth requests.
|
||||||
|
|
||||||
|
Returned data is not modified.
|
||||||
|
|
||||||
|
"""
|
||||||
|
if getattr(args[0], '_use_oauth', False):
|
||||||
|
kwargs['_use_oauth'] = True
|
||||||
|
return function(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
@decorator.decorator
|
||||||
|
def raise_api_exceptions(function, *args, **kwargs):
|
||||||
|
"""Raise client side exception(s) when present in the API response.
|
||||||
|
|
||||||
|
Returned data is not modified.
|
||||||
|
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return_value = function(*args, **kwargs)
|
||||||
|
except errors.HTTPException as exc:
|
||||||
|
if exc._raw.status_code != 400: # pylint: disable=W0212
|
||||||
|
raise # Unhandled HTTPErrors
|
||||||
|
try: # Attempt to convert v1 errors into older format (for now)
|
||||||
|
data = exc._raw.json() # pylint: disable=W0212
|
||||||
|
assert len(data) == 2
|
||||||
|
return_value = {'errors': [(data['reason'],
|
||||||
|
data['explanation'], '')]}
|
||||||
|
except Exception:
|
||||||
|
raise exc
|
||||||
|
if isinstance(return_value, dict):
|
||||||
|
if return_value.get('error') == 304: # Not modified exception
|
||||||
|
raise errors.NotModified(return_value)
|
||||||
|
elif return_value.get('errors'):
|
||||||
|
error_list = []
|
||||||
|
for error_type, msg, value in return_value['errors']:
|
||||||
|
if error_type in errors.ERROR_MAPPING:
|
||||||
|
if error_type == 'RATELIMIT':
|
||||||
|
args[0].evict(args[1])
|
||||||
|
error_class = errors.ERROR_MAPPING[error_type]
|
||||||
|
else:
|
||||||
|
error_class = errors.APIException
|
||||||
|
error_list.append(error_class(error_type, msg, value,
|
||||||
|
return_value))
|
||||||
|
if len(error_list) == 1:
|
||||||
|
raise error_list[0]
|
||||||
|
else:
|
||||||
|
raise errors.ExceptionList(error_list)
|
||||||
|
return return_value
|
||||||
|
|
||||||
|
|
||||||
|
@decorator.decorator
|
||||||
|
def require_captcha(function, *args, **kwargs):
|
||||||
|
"""Return a decorator for methods that require captchas."""
|
||||||
|
raise_captcha_exception = kwargs.pop('raise_captcha_exception', False)
|
||||||
|
captcha_id = None
|
||||||
|
|
||||||
|
# Get a handle to the reddit session
|
||||||
|
if hasattr(args[0], 'reddit_session'):
|
||||||
|
reddit_session = args[0].reddit_session
|
||||||
|
else:
|
||||||
|
reddit_session = args[0]
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
if captcha_id:
|
||||||
|
captcha_answer = _get_captcha(reddit_session, captcha_id)
|
||||||
|
|
||||||
|
# When the method is being decorated, all of its default
|
||||||
|
# parameters become part of this *args tuple. This means that
|
||||||
|
# *args currently contains a None where the captcha answer
|
||||||
|
# needs to go. If we put the captcha in the **kwargs,
|
||||||
|
# we get a TypeError for having two values of the same param.
|
||||||
|
func_args = _make_func_args(function)
|
||||||
|
if 'captcha' in func_args:
|
||||||
|
captcha_index = func_args.index('captcha')
|
||||||
|
args = list(args)
|
||||||
|
args[captcha_index] = captcha_answer
|
||||||
|
else:
|
||||||
|
kwargs['captcha'] = captcha_answer
|
||||||
|
return function(*args, **kwargs)
|
||||||
|
except errors.InvalidCaptcha as exception:
|
||||||
|
if raise_captcha_exception or \
|
||||||
|
not hasattr(sys.stdin, 'closed') or sys.stdin.closed:
|
||||||
|
raise
|
||||||
|
captcha_id = exception.response['captcha']
|
||||||
|
|
||||||
|
|
||||||
|
def restrict_access(scope, mod=None, login=None, oauth_only=False,
|
||||||
|
generator_called=False):
|
||||||
|
"""Restrict function access unless the user has the necessary permissions.
|
||||||
|
|
||||||
|
Raises one of the following exceptions when appropriate:
|
||||||
|
* LoginRequired
|
||||||
|
* LoginOrOAuthRequired
|
||||||
|
* the scope attribute will provide the necessary scope name
|
||||||
|
* ModeratorRequired
|
||||||
|
* ModeratorOrOAuthRequired
|
||||||
|
* the scope attribute will provide the necessary scope name
|
||||||
|
|
||||||
|
:param scope: Indicate the scope that is required for the API call. None or
|
||||||
|
False must be passed to indicate that no scope handles the API call.
|
||||||
|
All scopes save for `read` imply login=True. Scopes with 'mod' in their
|
||||||
|
name imply mod=True.
|
||||||
|
:param mod: Indicate that a moderator is required. Implies login=True.
|
||||||
|
:param login: Indicate that a login is required.
|
||||||
|
:param oauth_only: Indicate that only OAuth is supported for the function.
|
||||||
|
:param generator_called: Indicate that the function consists solely of
|
||||||
|
exhausting one or more oauth_generator wrapped generators. This is
|
||||||
|
because the oauth_generator itself will determine whether or not to
|
||||||
|
use the oauth domain.
|
||||||
|
|
||||||
|
Returned data is not modified.
|
||||||
|
|
||||||
|
This decorator assumes that all mod required functions fit one of these
|
||||||
|
categories:
|
||||||
|
|
||||||
|
* have the subreddit as the first argument (Reddit instance functions) or
|
||||||
|
have a subreddit keyword argument
|
||||||
|
* are called upon a subreddit object (Subreddit RedditContentObject)
|
||||||
|
* are called upon a RedditContent object with attribute subreddit
|
||||||
|
|
||||||
|
"""
|
||||||
|
if not scope and oauth_only:
|
||||||
|
raise TypeError('`scope` must be set when `oauth_only` is set')
|
||||||
|
|
||||||
|
mod = mod is not False and (mod or scope and 'mod' in scope)
|
||||||
|
login = login is not False and (login or mod or scope and scope != 'read')
|
||||||
|
|
||||||
|
@decorator.decorator
|
||||||
|
def wrap(function, *args, **kwargs):
|
||||||
|
if args[0] is None: # Occurs with (un)friend
|
||||||
|
assert login
|
||||||
|
raise errors.LoginRequired(function.__name__)
|
||||||
|
# This segment of code uses hasattr to determine what instance type
|
||||||
|
# the function was called on. We could use isinstance if we wanted
|
||||||
|
# to import the types at runtime (decorators is used by all the
|
||||||
|
# types).
|
||||||
|
if mod:
|
||||||
|
if hasattr(args[0], 'reddit_session'):
|
||||||
|
# Defer access until necessary for RedditContentObject.
|
||||||
|
# This is because scoped sessions may not require this
|
||||||
|
# attribute to exist, thus it might not be set.
|
||||||
|
from .objects import Subreddit
|
||||||
|
subreddit = args[0] if isinstance(args[0], Subreddit) \
|
||||||
|
else False
|
||||||
|
else:
|
||||||
|
subreddit = kwargs.get(
|
||||||
|
'subreddit', args[1] if len(args) > 1 else None)
|
||||||
|
if subreddit is None: # Try the default value
|
||||||
|
defaults = six.get_function_defaults(function)
|
||||||
|
subreddit = defaults[0] if defaults else None
|
||||||
|
else:
|
||||||
|
subreddit = None
|
||||||
|
|
||||||
|
obj = getattr(args[0], 'reddit_session', args[0])
|
||||||
|
# This function sets _use_oauth for one time use only.
|
||||||
|
# Verify that statement is actually true.
|
||||||
|
assert not obj._use_oauth # pylint: disable=W0212
|
||||||
|
|
||||||
|
if scope and obj.has_scope(scope):
|
||||||
|
obj._use_oauth = not generator_called # pylint: disable=W0212
|
||||||
|
elif oauth_only:
|
||||||
|
raise errors.OAuthScopeRequired(function.__name__, scope)
|
||||||
|
elif login and obj.is_logged_in():
|
||||||
|
if subreddit is False:
|
||||||
|
# Now fetch the subreddit attribute. There is no good
|
||||||
|
# reason for it to not be set during a logged in session.
|
||||||
|
subreddit = args[0].subreddit
|
||||||
|
if mod and not _is_mod_of_all(obj.user, subreddit):
|
||||||
|
if scope:
|
||||||
|
raise errors.ModeratorOrScopeRequired(
|
||||||
|
function.__name__, scope)
|
||||||
|
raise errors.ModeratorRequired(function.__name__)
|
||||||
|
elif login:
|
||||||
|
if scope:
|
||||||
|
raise errors.LoginOrScopeRequired(function.__name__, scope)
|
||||||
|
raise errors.LoginRequired(function.__name__)
|
||||||
|
try:
|
||||||
|
return function(*args, **kwargs)
|
||||||
|
finally:
|
||||||
|
obj._use_oauth = False # pylint: disable=W0212
|
||||||
|
return wrap
|
||||||
|
|
||||||
|
|
||||||
|
@decorator.decorator
|
||||||
|
def require_oauth(function, *args, **kwargs):
|
||||||
|
"""Verify that the OAuth functions can be used prior to use.
|
||||||
|
|
||||||
|
Returned data is not modified.
|
||||||
|
|
||||||
|
"""
|
||||||
|
if not args[0].has_oauth_app_info:
|
||||||
|
err_msg = ("The OAuth app config parameters client_id, client_secret "
|
||||||
|
"and redirect_url must be specified to use this function.")
|
||||||
|
raise errors.OAuthAppRequired(err_msg)
|
||||||
|
return function(*args, **kwargs)
|
|
@ -0,0 +1,475 @@
|
||||||
|
# This file is part of PRAW.
|
||||||
|
#
|
||||||
|
# PRAW is free software: you can redistribute it and/or modify it under the
|
||||||
|
# terms of the GNU General Public License as published by the Free Software
|
||||||
|
# Foundation, either version 3 of the License, or (at your option) any later
|
||||||
|
# version.
|
||||||
|
#
|
||||||
|
# PRAW is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||||
|
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||||
|
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License along with
|
||||||
|
# PRAW. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
"""
|
||||||
|
Error classes.
|
||||||
|
|
||||||
|
Includes two main exceptions: ClientException, when something goes
|
||||||
|
wrong on our end, and APIExeception for when something goes wrong on the
|
||||||
|
server side. A number of classes extend these two main exceptions for more
|
||||||
|
specific exceptions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import print_function, unicode_literals
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
import six
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
class PRAWException(Exception):
|
||||||
|
"""The base PRAW Exception class.
|
||||||
|
|
||||||
|
Ideally, this can be caught to handle any exception from PRAW.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class ClientException(PRAWException):
|
||||||
|
"""Base exception class for errors that don't involve the remote API."""
|
||||||
|
|
||||||
|
def __init__(self, message=None):
|
||||||
|
"""Construct a ClientException.
|
||||||
|
|
||||||
|
:param message: The error message to display.
|
||||||
|
|
||||||
|
"""
|
||||||
|
if not message:
|
||||||
|
message = 'Clientside error'
|
||||||
|
super(ClientException, self).__init__()
|
||||||
|
self.message = message
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
"""Return the message of the error."""
|
||||||
|
return self.message
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthScopeRequired(ClientException):
|
||||||
|
"""Indicates that an OAuth2 scope is required to make the function call.
|
||||||
|
|
||||||
|
The attribute `scope` will contain the name of the necessary scope.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, function, scope, message=None):
|
||||||
|
"""Contruct an OAuthScopeRequiredClientException.
|
||||||
|
|
||||||
|
:param function: The function that requires a scope.
|
||||||
|
:param scope: The scope required for the function.
|
||||||
|
:param message: A custom message to associate with the
|
||||||
|
exception. Default: `function` requires the OAuth2 scope `scope`
|
||||||
|
|
||||||
|
"""
|
||||||
|
if not message:
|
||||||
|
message = '`{0}` requires the OAuth2 scope `{1}`'.format(function,
|
||||||
|
scope)
|
||||||
|
super(OAuthScopeRequired, self).__init__(message)
|
||||||
|
self.scope = scope
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequired(ClientException):
|
||||||
|
"""Indicates that a logged in session is required.
|
||||||
|
|
||||||
|
This exception is raised on a preemptive basis, whereas NotLoggedIn occurs
|
||||||
|
in response to a lack of credentials on a privileged API call.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, function, message=None):
|
||||||
|
"""Construct a LoginRequired exception.
|
||||||
|
|
||||||
|
:param function: The function that requires login-based authentication.
|
||||||
|
:param message: A custom message to associate with the exception.
|
||||||
|
Default: `function` requires a logged in session
|
||||||
|
|
||||||
|
"""
|
||||||
|
if not message:
|
||||||
|
message = '`{0}` requires a logged in session'.format(function)
|
||||||
|
super(LoginRequired, self).__init__(message)
|
||||||
|
|
||||||
|
|
||||||
|
class LoginOrScopeRequired(OAuthScopeRequired, LoginRequired):
|
||||||
|
"""Indicates that either a logged in session or OAuth2 scope is required.
|
||||||
|
|
||||||
|
The attribute `scope` will contain the name of the necessary scope.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, function, scope, message=None):
|
||||||
|
"""Construct a LoginOrScopeRequired exception.
|
||||||
|
|
||||||
|
:param function: The function that requires authentication.
|
||||||
|
:param scope: The scope that is required if not logged in.
|
||||||
|
:param message: A custom message to associate with the exception.
|
||||||
|
Default: `function` requires a logged in session or the OAuth2
|
||||||
|
scope `scope`
|
||||||
|
|
||||||
|
"""
|
||||||
|
if not message:
|
||||||
|
message = ('`{0}` requires a logged in session or the '
|
||||||
|
'OAuth2 scope `{1}`').format(function, scope)
|
||||||
|
super(LoginOrScopeRequired, self).__init__(function, scope, message)
|
||||||
|
|
||||||
|
|
||||||
|
class ModeratorRequired(LoginRequired):
|
||||||
|
"""Indicates that a moderator of the subreddit is required."""
|
||||||
|
|
||||||
|
def __init__(self, function):
|
||||||
|
"""Construct a ModeratorRequired exception.
|
||||||
|
|
||||||
|
:param function: The function that requires moderator access.
|
||||||
|
|
||||||
|
"""
|
||||||
|
message = ('`{0}` requires a moderator '
|
||||||
|
'of the subreddit').format(function)
|
||||||
|
super(ModeratorRequired, self).__init__(message)
|
||||||
|
|
||||||
|
|
||||||
|
class ModeratorOrScopeRequired(LoginOrScopeRequired, ModeratorRequired):
|
||||||
|
"""Indicates that a moderator of the sub or OAuth2 scope is required.
|
||||||
|
|
||||||
|
The attribute `scope` will contain the name of the necessary scope.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, function, scope):
|
||||||
|
"""Construct a ModeratorOrScopeRequired exception.
|
||||||
|
|
||||||
|
:param function: The function that requires moderator authentication or
|
||||||
|
a moderator scope..
|
||||||
|
:param scope: The scope that is required if not logged in with
|
||||||
|
moderator access..
|
||||||
|
|
||||||
|
"""
|
||||||
|
message = ('`{0}` requires a moderator of the subreddit or the '
|
||||||
|
'OAuth2 scope `{1}`').format(function, scope)
|
||||||
|
super(ModeratorOrScopeRequired, self).__init__(function, scope,
|
||||||
|
message)
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthAppRequired(ClientException):
|
||||||
|
"""Raised when an OAuth client cannot be initialized.
|
||||||
|
|
||||||
|
This occurs when any one of the OAuth config values are not set.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class HTTPException(PRAWException):
|
||||||
|
"""Base class for HTTP related exceptions."""
|
||||||
|
|
||||||
|
def __init__(self, _raw, message=None):
|
||||||
|
"""Construct a HTTPException.
|
||||||
|
|
||||||
|
:params _raw: The internal request library response object. This object
|
||||||
|
is mapped to attribute `_raw` whose format may change at any time.
|
||||||
|
|
||||||
|
"""
|
||||||
|
if not message:
|
||||||
|
message = 'HTTP error'
|
||||||
|
super(HTTPException, self).__init__()
|
||||||
|
self._raw = _raw
|
||||||
|
self.message = message
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
"""Return the message of the error."""
|
||||||
|
return self.message
|
||||||
|
|
||||||
|
|
||||||
|
class Forbidden(HTTPException):
|
||||||
|
"""Raised when the user does not have permission to the entity."""
|
||||||
|
|
||||||
|
|
||||||
|
class NotFound(HTTPException):
|
||||||
|
"""Raised when the requested entity is not found."""
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidComment(PRAWException):
|
||||||
|
"""Indicate that the comment is no longer available on reddit."""
|
||||||
|
|
||||||
|
ERROR_TYPE = 'DELETED_COMMENT'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
"""Return the message of the error."""
|
||||||
|
return self.ERROR_TYPE
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidSubmission(PRAWException):
|
||||||
|
"""Indicates that the submission is no longer available on reddit."""
|
||||||
|
|
||||||
|
ERROR_TYPE = 'DELETED_LINK'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
"""Return the message of the error."""
|
||||||
|
return self.ERROR_TYPE
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidSubreddit(PRAWException):
|
||||||
|
"""Indicates that an invalid subreddit name was supplied."""
|
||||||
|
|
||||||
|
ERROR_TYPE = 'SUBREDDIT_NOEXIST'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
"""Return the message of the error."""
|
||||||
|
return self.ERROR_TYPE
|
||||||
|
|
||||||
|
|
||||||
|
class RedirectException(PRAWException):
|
||||||
|
"""Raised when a redirect response occurs that is not expected."""
|
||||||
|
|
||||||
|
def __init__(self, request_url, response_url, message=None):
|
||||||
|
"""Construct a RedirectException.
|
||||||
|
|
||||||
|
:param request_url: The url requested.
|
||||||
|
:param response_url: The url being redirected to.
|
||||||
|
:param message: A custom message to associate with the exception.
|
||||||
|
|
||||||
|
"""
|
||||||
|
if not message:
|
||||||
|
message = ('Unexpected redirect '
|
||||||
|
'from {0} to {1}').format(request_url, response_url)
|
||||||
|
super(RedirectException, self).__init__()
|
||||||
|
self.request_url = request_url
|
||||||
|
self.response_url = response_url
|
||||||
|
self.message = message
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
"""Return the message of the error."""
|
||||||
|
return self.message
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthException(PRAWException):
|
||||||
|
"""Base exception class for OAuth API calls.
|
||||||
|
|
||||||
|
Attribute `message` contains the error message.
|
||||||
|
Attribute `url` contains the url that resulted in the error.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, message, url):
|
||||||
|
"""Construct a OAuthException.
|
||||||
|
|
||||||
|
:param message: The message associated with the exception.
|
||||||
|
:param url: The url that resulted in error.
|
||||||
|
|
||||||
|
"""
|
||||||
|
super(OAuthException, self).__init__()
|
||||||
|
self.message = message
|
||||||
|
self.url = url
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
"""Return the message along with the url."""
|
||||||
|
return self.message + " on url {0}".format(self.url)
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthInsufficientScope(OAuthException):
|
||||||
|
"""Raised when the current OAuth scope is not sufficient for the action.
|
||||||
|
|
||||||
|
This indicates the access token is valid, but not for the desired action.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthInvalidGrant(OAuthException):
|
||||||
|
"""Raised when the code to retrieve access information is not valid."""
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthInvalidToken(OAuthException):
|
||||||
|
"""Raised when the current OAuth access token is not valid."""
|
||||||
|
|
||||||
|
|
||||||
|
class APIException(PRAWException):
|
||||||
|
"""Base exception class for the reddit API error message exceptions.
|
||||||
|
|
||||||
|
All exceptions of this type should have their own subclass.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, error_type, message, field='', response=None):
|
||||||
|
"""Construct an APIException.
|
||||||
|
|
||||||
|
:param error_type: The error type set on reddit's end.
|
||||||
|
:param message: The associated message for the error.
|
||||||
|
:param field: The input field associated with the error, or ''.
|
||||||
|
:param response: The HTTP response that resulted in the exception.
|
||||||
|
|
||||||
|
"""
|
||||||
|
super(APIException, self).__init__()
|
||||||
|
self.error_type = error_type
|
||||||
|
self.message = message
|
||||||
|
self.field = field
|
||||||
|
self.response = response
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
"""Return a string containing the error message and field."""
|
||||||
|
if hasattr(self, 'ERROR_TYPE'):
|
||||||
|
return '`{0}` on field `{1}`'.format(self.message, self.field)
|
||||||
|
else:
|
||||||
|
return '({0}) `{1}` on field `{2}`'.format(self.error_type,
|
||||||
|
self.message,
|
||||||
|
self.field)
|
||||||
|
|
||||||
|
|
||||||
|
class ExceptionList(APIException):
|
||||||
|
"""Raised when more than one exception occurred."""
|
||||||
|
|
||||||
|
def __init__(self, errors):
|
||||||
|
"""Construct an ExceptionList.
|
||||||
|
|
||||||
|
:param errors: The list of errors.
|
||||||
|
|
||||||
|
"""
|
||||||
|
super(ExceptionList, self).__init__(None, None)
|
||||||
|
self.errors = errors
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
"""Return a string representation for all the errors."""
|
||||||
|
ret = '\n'
|
||||||
|
for i, error in enumerate(self.errors):
|
||||||
|
ret += '\tError {0}) {1}\n'.format(i, six.text_type(error))
|
||||||
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
class AlreadySubmitted(APIException):
|
||||||
|
"""An exception to indicate that a URL was previously submitted."""
|
||||||
|
|
||||||
|
ERROR_TYPE = 'ALREADY_SUB'
|
||||||
|
|
||||||
|
|
||||||
|
class AlreadyModerator(APIException):
|
||||||
|
"""Used to indicate that a user is already a moderator of a subreddit."""
|
||||||
|
|
||||||
|
ERROR_TYPE = 'ALREADY_MODERATOR'
|
||||||
|
|
||||||
|
|
||||||
|
class BadCSS(APIException):
|
||||||
|
"""An exception to indicate bad CSS (such as invalid) was used."""
|
||||||
|
|
||||||
|
ERROR_TYPE = 'BAD_CSS'
|
||||||
|
|
||||||
|
|
||||||
|
class BadCSSName(APIException):
|
||||||
|
"""An exception to indicate a bad CSS name (such as invalid) was used."""
|
||||||
|
|
||||||
|
ERROR_TYPE = 'BAD_CSS_NAME'
|
||||||
|
|
||||||
|
|
||||||
|
class BadUsername(APIException):
|
||||||
|
"""An exception to indicate an invalid username was used."""
|
||||||
|
|
||||||
|
ERROR_TYPE = 'BAD_USERNAME'
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidCaptcha(APIException):
|
||||||
|
"""An exception for when an incorrect captcha error is returned."""
|
||||||
|
|
||||||
|
ERROR_TYPE = 'BAD_CAPTCHA'
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidEmails(APIException):
|
||||||
|
"""An exception for when invalid emails are provided."""
|
||||||
|
|
||||||
|
ERROR_TYPE = 'BAD_EMAILS'
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidFlairTarget(APIException):
|
||||||
|
"""An exception raised when an invalid user is passed as a flair target."""
|
||||||
|
|
||||||
|
ERROR_TYPE = 'BAD_FLAIR_TARGET'
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidInvite(APIException):
|
||||||
|
"""Raised when attempting to accept a nonexistent moderator invite."""
|
||||||
|
|
||||||
|
ERROR_TYPE = 'NO_INVITE_FOUND'
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidUser(APIException):
|
||||||
|
"""An exception for when a user doesn't exist."""
|
||||||
|
|
||||||
|
ERROR_TYPE = 'USER_DOESNT_EXIST'
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidUserPass(APIException):
|
||||||
|
"""An exception for failed logins."""
|
||||||
|
|
||||||
|
ERROR_TYPE = 'WRONG_PASSWORD'
|
||||||
|
|
||||||
|
|
||||||
|
class InsufficientCreddits(APIException):
|
||||||
|
"""Raised when there are not enough creddits to complete the action."""
|
||||||
|
|
||||||
|
ERROR_TYPE = 'INSUFFICIENT_CREDDITS'
|
||||||
|
|
||||||
|
|
||||||
|
class NotLoggedIn(APIException):
|
||||||
|
"""An exception for when a Reddit user isn't logged in."""
|
||||||
|
|
||||||
|
ERROR_TYPE = 'USER_REQUIRED'
|
||||||
|
|
||||||
|
|
||||||
|
class NotModified(APIException):
|
||||||
|
"""An exception raised when reddit returns {'error': 304}.
|
||||||
|
|
||||||
|
This error indicates that the requested content was not modified and is
|
||||||
|
being requested too frequently. Such an error usually occurs when multiple
|
||||||
|
instances of PRAW are running concurrently or in rapid succession.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, response):
|
||||||
|
"""Construct an instance of the NotModified exception.
|
||||||
|
|
||||||
|
This error does not have an error_type, message, nor field.
|
||||||
|
|
||||||
|
"""
|
||||||
|
super(NotModified, self).__init__(None, None, response=response)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
"""Return: That page has not been modified."""
|
||||||
|
return 'That page has not been modified.'
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimitExceeded(APIException):
|
||||||
|
"""An exception for when something has happened too frequently.
|
||||||
|
|
||||||
|
Contains a `sleep_time` attribute for the number of seconds that must
|
||||||
|
transpire prior to the next request.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
ERROR_TYPE = 'RATELIMIT'
|
||||||
|
|
||||||
|
|
||||||
|
class SubredditExists(APIException):
|
||||||
|
"""An exception to indicate that a subreddit name is not available."""
|
||||||
|
|
||||||
|
ERROR_TYPE = 'SUBREDDIT_EXISTS'
|
||||||
|
|
||||||
|
|
||||||
|
class UsernameExists(APIException):
|
||||||
|
"""An exception to indicate that a username is not available."""
|
||||||
|
|
||||||
|
ERROR_TYPE = 'USERNAME_TAKEN'
|
||||||
|
|
||||||
|
|
||||||
|
def _build_error_mapping():
|
||||||
|
def predicate(obj):
|
||||||
|
return inspect.isclass(obj) and hasattr(obj, 'ERROR_TYPE')
|
||||||
|
|
||||||
|
tmp = {}
|
||||||
|
for _, obj in inspect.getmembers(sys.modules[__name__], predicate):
|
||||||
|
tmp[obj.ERROR_TYPE] = obj
|
||||||
|
return tmp
|
||||||
|
ERROR_MAPPING = _build_error_mapping()
|
|
@ -0,0 +1,243 @@
|
||||||
|
"""Provides classes that handle request dispatching."""
|
||||||
|
|
||||||
|
from __future__ import print_function, unicode_literals
|
||||||
|
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from functools import wraps
|
||||||
|
from .errors import ClientException
|
||||||
|
from .helpers import normalize_url
|
||||||
|
from requests import Session
|
||||||
|
from six import text_type
|
||||||
|
from six.moves import cPickle # pylint: disable=F0401
|
||||||
|
from threading import Lock
|
||||||
|
from timeit import default_timer as timer
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimitHandler(object):
|
||||||
|
"""The base handler that provides thread-safe rate limiting enforcement.
|
||||||
|
|
||||||
|
While this handler is threadsafe, PRAW is not thread safe when the same
|
||||||
|
`Reddit` instance is being utilized from multiple threads.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
last_call = {} # Stores a two-item list: [lock, previous_call_time]
|
||||||
|
rl_lock = Lock() # lock used for adding items to last_call
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def rate_limit(function):
|
||||||
|
"""Return a decorator that enforces API request limit guidelines.
|
||||||
|
|
||||||
|
We are allowed to make a API request every api_request_delay seconds as
|
||||||
|
specified in praw.ini. This value may differ from reddit to reddit. For
|
||||||
|
reddit.com it is 2. Any function decorated with this will be forced to
|
||||||
|
delay _rate_delay seconds from the calling of the last function
|
||||||
|
decorated with this before executing.
|
||||||
|
|
||||||
|
This decorator must be applied to a RateLimitHandler class method or
|
||||||
|
instance method as it assumes `rl_lock` and `last_call` are available.
|
||||||
|
|
||||||
|
"""
|
||||||
|
@wraps(function)
|
||||||
|
def wrapped(cls, _rate_domain, _rate_delay, **kwargs):
|
||||||
|
cls.rl_lock.acquire()
|
||||||
|
lock_last = cls.last_call.setdefault(_rate_domain, [Lock(), 0])
|
||||||
|
with lock_last[0]: # Obtain the domain specific lock
|
||||||
|
cls.rl_lock.release()
|
||||||
|
# Sleep if necessary, then perform the request
|
||||||
|
now = timer()
|
||||||
|
delay = lock_last[1] + _rate_delay - now
|
||||||
|
if delay > 0:
|
||||||
|
now += delay
|
||||||
|
time.sleep(delay)
|
||||||
|
lock_last[1] = now
|
||||||
|
return function(cls, **kwargs)
|
||||||
|
return wrapped
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def evict(cls, urls): # pylint: disable=W0613
|
||||||
|
"""Method utilized to evict entries for the given urls.
|
||||||
|
|
||||||
|
:param urls: An iterable containing normalized urls.
|
||||||
|
:returns: The number of items removed from the cache.
|
||||||
|
|
||||||
|
By default this method returns False as a cache need not be present.
|
||||||
|
|
||||||
|
"""
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def __del__(self):
|
||||||
|
"""Cleanup the HTTP session."""
|
||||||
|
if self.http:
|
||||||
|
try:
|
||||||
|
self.http.close()
|
||||||
|
except: # Never fail pylint: disable=W0702
|
||||||
|
pass
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Establish the HTTP session."""
|
||||||
|
self.http = Session() # Each instance should have its own session
|
||||||
|
|
||||||
|
def request(self, request, proxies, timeout, verify, **_):
|
||||||
|
"""Responsible for dispatching the request and returning the result.
|
||||||
|
|
||||||
|
Network level exceptions should be raised and only
|
||||||
|
``requests.Response`` should be returned.
|
||||||
|
|
||||||
|
:param request: A ``requests.PreparedRequest`` object containing all
|
||||||
|
the data necessary to perform the request.
|
||||||
|
:param proxies: A dictionary of proxy settings to be utilized for the
|
||||||
|
request.
|
||||||
|
:param timeout: Specifies the maximum time that the actual HTTP request
|
||||||
|
can take.
|
||||||
|
:param verify: Specifies if SSL certificates should be validated.
|
||||||
|
|
||||||
|
``**_`` should be added to the method call to ignore the extra
|
||||||
|
arguments intended for the cache handler.
|
||||||
|
|
||||||
|
"""
|
||||||
|
settings = self.http.merge_environment_settings(
|
||||||
|
request.url, proxies, False, verify, None
|
||||||
|
)
|
||||||
|
return self.http.send(request, timeout=timeout, allow_redirects=False,
|
||||||
|
**settings)
|
||||||
|
|
||||||
|
RateLimitHandler.request = RateLimitHandler.rate_limit(
|
||||||
|
RateLimitHandler.request)
|
||||||
|
|
||||||
|
|
||||||
|
class DefaultHandler(RateLimitHandler):
|
||||||
|
"""Extends the RateLimitHandler to add thread-safe caching support."""
|
||||||
|
|
||||||
|
ca_lock = Lock()
|
||||||
|
cache = {}
|
||||||
|
cache_hit_callback = None
|
||||||
|
timeouts = {}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def with_cache(function):
|
||||||
|
"""Return a decorator that interacts with a handler's cache.
|
||||||
|
|
||||||
|
This decorator must be applied to a DefaultHandler class method or
|
||||||
|
instance method as it assumes `cache`, `ca_lock` and `timeouts` are
|
||||||
|
available.
|
||||||
|
|
||||||
|
"""
|
||||||
|
@wraps(function)
|
||||||
|
def wrapped(cls, _cache_key, _cache_ignore, _cache_timeout, **kwargs):
|
||||||
|
def clear_timeouts():
|
||||||
|
"""Clear the cache of timed out results."""
|
||||||
|
for key in list(cls.timeouts):
|
||||||
|
if timer() - cls.timeouts[key] > _cache_timeout:
|
||||||
|
del cls.timeouts[key]
|
||||||
|
del cls.cache[key]
|
||||||
|
|
||||||
|
if _cache_ignore:
|
||||||
|
return function(cls, **kwargs)
|
||||||
|
with cls.ca_lock:
|
||||||
|
clear_timeouts()
|
||||||
|
if _cache_key in cls.cache:
|
||||||
|
if cls.cache_hit_callback:
|
||||||
|
cls.cache_hit_callback(_cache_key)
|
||||||
|
return cls.cache[_cache_key]
|
||||||
|
# Releasing the lock before actually making the request allows for
|
||||||
|
# the possibility of more than one thread making the same request
|
||||||
|
# to get through. Without having domain-specific caching (under the
|
||||||
|
# assumption only one request to a domain can be made at a
|
||||||
|
# time), there isn't a better way to handle this.
|
||||||
|
result = function(cls, **kwargs)
|
||||||
|
# The handlers don't call `raise_for_status` so we need to ignore
|
||||||
|
# status codes that will result in an exception that should not be
|
||||||
|
# cached.
|
||||||
|
if result.status_code not in (200, 302):
|
||||||
|
return result
|
||||||
|
with cls.ca_lock:
|
||||||
|
cls.timeouts[_cache_key] = timer()
|
||||||
|
cls.cache[_cache_key] = result
|
||||||
|
return result
|
||||||
|
return wrapped
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def clear_cache(cls):
|
||||||
|
"""Remove all items from the cache."""
|
||||||
|
with cls.ca_lock:
|
||||||
|
cls.cache = {}
|
||||||
|
cls.timeouts = {}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def evict(cls, urls):
|
||||||
|
"""Remove items from cache matching URLs.
|
||||||
|
|
||||||
|
Return the number of items removed.
|
||||||
|
|
||||||
|
"""
|
||||||
|
if isinstance(urls, text_type):
|
||||||
|
urls = [urls]
|
||||||
|
urls = set(normalize_url(url) for url in urls)
|
||||||
|
retval = 0
|
||||||
|
with cls.ca_lock:
|
||||||
|
for key in list(cls.cache):
|
||||||
|
if key[0] in urls:
|
||||||
|
retval += 1
|
||||||
|
del cls.cache[key]
|
||||||
|
del cls.timeouts[key]
|
||||||
|
return retval
|
||||||
|
DefaultHandler.request = DefaultHandler.with_cache(RateLimitHandler.request)
|
||||||
|
|
||||||
|
|
||||||
|
class MultiprocessHandler(object):
|
||||||
|
"""A PRAW handler to interact with the PRAW multi-process server."""
|
||||||
|
|
||||||
|
def __init__(self, host='localhost', port=10101):
|
||||||
|
"""Construct an instance of the MultiprocessHandler."""
|
||||||
|
self.host = host
|
||||||
|
self.port = port
|
||||||
|
|
||||||
|
def _relay(self, **kwargs):
|
||||||
|
"""Send the request through the server and return the HTTP response."""
|
||||||
|
retval = None
|
||||||
|
delay_time = 2 # For connection retries
|
||||||
|
read_attempts = 0 # For reading from socket
|
||||||
|
while retval is None: # Evict can return False
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
sock_fp = sock.makefile('rwb') # Used for pickle
|
||||||
|
try:
|
||||||
|
sock.connect((self.host, self.port))
|
||||||
|
cPickle.dump(kwargs, sock_fp, cPickle.HIGHEST_PROTOCOL)
|
||||||
|
sock_fp.flush()
|
||||||
|
retval = cPickle.load(sock_fp)
|
||||||
|
except: # pylint: disable=W0702
|
||||||
|
exc_type, exc, _ = sys.exc_info()
|
||||||
|
socket_error = exc_type is socket.error
|
||||||
|
if socket_error and exc.errno == 111: # Connection refused
|
||||||
|
sys.stderr.write('Cannot connect to multiprocess server. I'
|
||||||
|
's it running? Retrying in {0} seconds.\n'
|
||||||
|
.format(delay_time))
|
||||||
|
time.sleep(delay_time)
|
||||||
|
delay_time = min(64, delay_time * 2)
|
||||||
|
elif exc_type is EOFError or socket_error and exc.errno == 104:
|
||||||
|
# Failure during socket READ
|
||||||
|
if read_attempts >= 3:
|
||||||
|
raise ClientException('Successive failures reading '
|
||||||
|
'from the multiprocess server.')
|
||||||
|
sys.stderr.write('Lost connection with multiprocess server'
|
||||||
|
' during read. Trying again.\n')
|
||||||
|
read_attempts += 1
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
sock_fp.close()
|
||||||
|
sock.close()
|
||||||
|
if isinstance(retval, Exception):
|
||||||
|
raise retval # pylint: disable=E0702
|
||||||
|
return retval
|
||||||
|
|
||||||
|
def evict(self, urls):
|
||||||
|
"""Forward the eviction to the server and return its response."""
|
||||||
|
return self._relay(method='evict', urls=urls)
|
||||||
|
|
||||||
|
def request(self, **kwargs):
|
||||||
|
"""Forward the request to the server and return its HTTP response."""
|
||||||
|
return self._relay(method='request', **kwargs)
|
|
@ -0,0 +1,481 @@
|
||||||
|
# This file is part of PRAW.
|
||||||
|
#
|
||||||
|
# PRAW is free software: you can redistribute it and/or modify it under the
|
||||||
|
# terms of the GNU General Public License as published by the Free Software
|
||||||
|
# Foundation, either version 3 of the License, or (at your option) any later
|
||||||
|
# version.
|
||||||
|
#
|
||||||
|
# PRAW is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||||
|
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||||
|
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License along with
|
||||||
|
# PRAW. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
"""
|
||||||
|
Helper functions.
|
||||||
|
|
||||||
|
The functions here provide functionality that is often needed by programs using
|
||||||
|
PRAW, but which isn't part of reddit's API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
import six
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
|
from functools import partial
|
||||||
|
from timeit import default_timer as timer
|
||||||
|
from .errors import HTTPException, PRAWException
|
||||||
|
from operator import attrgetter
|
||||||
|
|
||||||
|
BACKOFF_START = 4 # Minimum number of seconds to sleep during errors
|
||||||
|
KEEP_ITEMS = 128 # On each iteration only remember the first # items
|
||||||
|
|
||||||
|
# for conversion between broken reddit timestamps and unix timestamps
|
||||||
|
REDDIT_TIMESTAMP_OFFSET = 28800
|
||||||
|
|
||||||
|
|
||||||
|
def comment_stream(reddit_session, subreddit, limit=None, verbosity=1):
|
||||||
|
"""Indefinitely yield new comments from the provided subreddit.
|
||||||
|
|
||||||
|
Comments are yielded from oldest to newest.
|
||||||
|
|
||||||
|
:param reddit_session: The reddit_session to make requests from. In all the
|
||||||
|
examples this is assigned to the variable ``r``.
|
||||||
|
:param subreddit: Either a subreddit object, or the name of a
|
||||||
|
subreddit. Use `all` to get the comment stream for all comments made to
|
||||||
|
reddit.
|
||||||
|
:param limit: The maximum number of comments to fetch in a single
|
||||||
|
iteration. When None, fetch all available comments (reddit limits this
|
||||||
|
to 1000 (or multiple of 1000 for multi-subreddits). If this number is
|
||||||
|
too small, comments may be missed.
|
||||||
|
:param verbosity: A number that controls the amount of output produced to
|
||||||
|
stderr. <= 0: no output; >= 1: output the total number of comments
|
||||||
|
processed and provide the short-term number of comments processed per
|
||||||
|
second; >= 2: output when additional delays are added in order to avoid
|
||||||
|
subsequent unexpected http errors. >= 3: output debugging information
|
||||||
|
regarding the comment stream. (Default: 1)
|
||||||
|
|
||||||
|
"""
|
||||||
|
get_function = partial(reddit_session.get_comments,
|
||||||
|
six.text_type(subreddit))
|
||||||
|
return _stream_generator(get_function, limit, verbosity)
|
||||||
|
|
||||||
|
|
||||||
|
def submission_stream(reddit_session, subreddit, limit=None, verbosity=1):
|
||||||
|
"""Indefinitely yield new submissions from the provided subreddit.
|
||||||
|
|
||||||
|
Submissions are yielded from oldest to newest.
|
||||||
|
|
||||||
|
:param reddit_session: The reddit_session to make requests from. In all the
|
||||||
|
examples this is assigned to the variable ``r``.
|
||||||
|
:param subreddit: Either a subreddit object, or the name of a
|
||||||
|
subreddit. Use `all` to get the submissions stream for all submissions
|
||||||
|
made to reddit.
|
||||||
|
:param limit: The maximum number of submissions to fetch in a single
|
||||||
|
iteration. When None, fetch all available submissions (reddit limits
|
||||||
|
this to 1000 (or multiple of 1000 for multi-subreddits). If this number
|
||||||
|
is too small, submissions may be missed. Since there isn't a limit to
|
||||||
|
the number of submissions that can be retrieved from r/all, the limit
|
||||||
|
will be set to 1000 when limit is None.
|
||||||
|
:param verbosity: A number that controls the amount of output produced to
|
||||||
|
stderr. <= 0: no output; >= 1: output the total number of submissions
|
||||||
|
processed and provide the short-term number of submissions processed
|
||||||
|
per second; >= 2: output when additional delays are added in order to
|
||||||
|
avoid subsequent unexpected http errors. >= 3: output debugging
|
||||||
|
information regarding the submission stream. (Default: 1)
|
||||||
|
|
||||||
|
"""
|
||||||
|
if six.text_type(subreddit).lower() == "all":
|
||||||
|
if limit is None:
|
||||||
|
limit = 1000
|
||||||
|
if not hasattr(subreddit, 'reddit_session'):
|
||||||
|
subreddit = reddit_session.get_subreddit(subreddit)
|
||||||
|
return _stream_generator(subreddit.get_new, limit, verbosity)
|
||||||
|
|
||||||
|
|
||||||
|
def valid_redditors(redditors, sub):
|
||||||
|
"""Return a verified list of valid Redditor instances.
|
||||||
|
|
||||||
|
:param redditors: A list comprised of Redditor instances and/or strings
|
||||||
|
that are to be verified as actual redditor accounts.
|
||||||
|
:param sub: A Subreddit instance that the authenticated account has
|
||||||
|
flair changing permission on.
|
||||||
|
|
||||||
|
Note: Flair will be unset for all valid redditors in `redditors` on the
|
||||||
|
subreddit `sub`. A valid redditor is defined as a redditor that is
|
||||||
|
registered on reddit.
|
||||||
|
|
||||||
|
"""
|
||||||
|
simplified = list(set(six.text_type(x).lower() for x in redditors))
|
||||||
|
return [sub.reddit_session.get_redditor(simplified[i], fetch=False)
|
||||||
|
for (i, resp) in enumerate(sub.set_flair_csv(
|
||||||
|
({'user': x, 'flair_text': x} for x in simplified)))
|
||||||
|
if resp['ok']]
|
||||||
|
|
||||||
|
|
||||||
|
def submissions_between(reddit_session,
|
||||||
|
subreddit,
|
||||||
|
lowest_timestamp=None,
|
||||||
|
highest_timestamp=None,
|
||||||
|
newest_first=True,
|
||||||
|
extra_cloudsearch_fields=None,
|
||||||
|
verbosity=1):
|
||||||
|
"""Yield submissions between two timestamps.
|
||||||
|
|
||||||
|
If both ``highest_timestamp`` and ``lowest_timestamp`` are unspecified,
|
||||||
|
yields all submissions in the ``subreddit``.
|
||||||
|
|
||||||
|
Submissions are yielded from newest to oldest(like in the "new" queue).
|
||||||
|
|
||||||
|
:param reddit_session: The reddit_session to make requests from. In all the
|
||||||
|
examples this is assigned to the variable ``r``.
|
||||||
|
:param subreddit: Either a subreddit object, or the name of a
|
||||||
|
subreddit. Use `all` to get the submissions stream for all submissions
|
||||||
|
made to reddit.
|
||||||
|
:param lowest_timestamp: The lower bound for ``created_utc`` atributed of
|
||||||
|
submissions.
|
||||||
|
(Default: subreddit's created_utc or 0 when subreddit == "all").
|
||||||
|
:param highest_timestamp: The upper bound for ``created_utc`` attribute
|
||||||
|
of submissions. (Default: current unix time)
|
||||||
|
NOTE: both highest_timestamp and lowest_timestamp are proper
|
||||||
|
unix timestamps(just like ``created_utc`` attributes)
|
||||||
|
:param newest_first: If set to true, yields submissions
|
||||||
|
from newest to oldest. Otherwise yields submissions
|
||||||
|
from oldest to newest
|
||||||
|
:param extra_cloudsearch_fields: Allows extra filtering of results by
|
||||||
|
parameters like author, self. Full list is available here:
|
||||||
|
https://www.reddit.com/wiki/search
|
||||||
|
:param verbosity: A number that controls the amount of output produced to
|
||||||
|
stderr. <= 0: no output; >= 1: output the total number of submissions
|
||||||
|
processed; >= 2: output debugging information regarding
|
||||||
|
the search queries. (Default: 1)
|
||||||
|
"""
|
||||||
|
def debug(msg, level):
|
||||||
|
if verbosity >= level:
|
||||||
|
sys.stderr.write(msg + '\n')
|
||||||
|
|
||||||
|
def format_query_field(k, v):
|
||||||
|
if k in ["nsfw", "self"]:
|
||||||
|
# even though documentation lists "no" and "yes"
|
||||||
|
# as possible values, in reality they don't work
|
||||||
|
if v not in [0, 1, "0", "1"]:
|
||||||
|
raise PRAWException("Invalid value for the extra"
|
||||||
|
"field {}. Only '0' and '1' are"
|
||||||
|
"valid values.".format(k))
|
||||||
|
return "{}:{}".format(k, v)
|
||||||
|
return "{}:'{}'".format(k, v)
|
||||||
|
|
||||||
|
if extra_cloudsearch_fields is None:
|
||||||
|
extra_cloudsearch_fields = {}
|
||||||
|
|
||||||
|
extra_query_part = " ".join(
|
||||||
|
[format_query_field(k, v) for (k, v)
|
||||||
|
in sorted(extra_cloudsearch_fields.items())]
|
||||||
|
)
|
||||||
|
|
||||||
|
if highest_timestamp is None:
|
||||||
|
highest_timestamp = int(time.time()) + REDDIT_TIMESTAMP_OFFSET
|
||||||
|
else:
|
||||||
|
highest_timestamp = int(highest_timestamp) + REDDIT_TIMESTAMP_OFFSET
|
||||||
|
|
||||||
|
if lowest_timestamp is not None:
|
||||||
|
lowest_timestamp = int(lowest_timestamp) + REDDIT_TIMESTAMP_OFFSET
|
||||||
|
elif not isinstance(subreddit, six.string_types):
|
||||||
|
lowest_timestamp = int(subreddit.created)
|
||||||
|
elif subreddit not in ("all", "contrib", "mod", "friend"):
|
||||||
|
lowest_timestamp = int(reddit_session.get_subreddit(subreddit).created)
|
||||||
|
else:
|
||||||
|
lowest_timestamp = 0
|
||||||
|
|
||||||
|
original_highest_timestamp = highest_timestamp
|
||||||
|
original_lowest_timestamp = lowest_timestamp
|
||||||
|
|
||||||
|
# When making timestamp:X..Y queries, reddit misses submissions
|
||||||
|
# inside X..Y range, but they can be found inside Y..Z range
|
||||||
|
# It is not clear what is the value of Z should be, but it seems
|
||||||
|
# like the difference is usually about ~1 hour or less
|
||||||
|
# To be sure, let's set the workaround offset to 2 hours
|
||||||
|
out_of_order_submissions_workaround_offset = 7200
|
||||||
|
highest_timestamp += out_of_order_submissions_workaround_offset
|
||||||
|
lowest_timestamp -= out_of_order_submissions_workaround_offset
|
||||||
|
|
||||||
|
# Those parameters work ok, but there may be a better set of parameters
|
||||||
|
window_size = 60 * 60
|
||||||
|
search_limit = 100
|
||||||
|
min_search_results_in_window = 50
|
||||||
|
window_adjustment_ratio = 1.25
|
||||||
|
backoff = BACKOFF_START
|
||||||
|
|
||||||
|
processed_submissions = 0
|
||||||
|
prev_win_increased = False
|
||||||
|
prev_win_decreased = False
|
||||||
|
|
||||||
|
while highest_timestamp >= lowest_timestamp:
|
||||||
|
try:
|
||||||
|
if newest_first:
|
||||||
|
t1 = max(highest_timestamp - window_size, lowest_timestamp)
|
||||||
|
t2 = highest_timestamp
|
||||||
|
else:
|
||||||
|
t1 = lowest_timestamp
|
||||||
|
t2 = min(lowest_timestamp + window_size, highest_timestamp)
|
||||||
|
|
||||||
|
search_query = 'timestamp:{}..{}'.format(t1, t2)
|
||||||
|
if extra_query_part:
|
||||||
|
search_query = "(and {} {})".format(search_query,
|
||||||
|
extra_query_part)
|
||||||
|
|
||||||
|
debug(search_query, 3)
|
||||||
|
search_results = list(reddit_session.search(search_query,
|
||||||
|
subreddit=subreddit,
|
||||||
|
limit=search_limit,
|
||||||
|
syntax='cloudsearch',
|
||||||
|
sort='new'))
|
||||||
|
|
||||||
|
debug("Received {0} search results for query {1}"
|
||||||
|
.format(len(search_results), search_query),
|
||||||
|
2)
|
||||||
|
|
||||||
|
backoff = BACKOFF_START
|
||||||
|
except HTTPException as exc:
|
||||||
|
debug("{0}. Sleeping for {1} seconds".format(exc, backoff), 2)
|
||||||
|
time.sleep(backoff)
|
||||||
|
backoff *= 2
|
||||||
|
continue
|
||||||
|
|
||||||
|
if len(search_results) >= search_limit:
|
||||||
|
power = 2 if prev_win_decreased else 1
|
||||||
|
window_size = int(window_size / window_adjustment_ratio**power)
|
||||||
|
prev_win_decreased = True
|
||||||
|
debug("Decreasing window size to {0} seconds".format(window_size),
|
||||||
|
2)
|
||||||
|
# Since it is possible that there are more submissions
|
||||||
|
# in the current window, we have to re-do the request
|
||||||
|
# with reduced window
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
prev_win_decreased = False
|
||||||
|
|
||||||
|
search_results = [s for s in search_results
|
||||||
|
if original_lowest_timestamp <= s.created and
|
||||||
|
s.created <= original_highest_timestamp]
|
||||||
|
|
||||||
|
for submission in sorted(search_results,
|
||||||
|
key=attrgetter('created_utc', 'id'),
|
||||||
|
reverse=newest_first):
|
||||||
|
yield submission
|
||||||
|
|
||||||
|
processed_submissions += len(search_results)
|
||||||
|
debug('Total processed submissions: {}'
|
||||||
|
.format(processed_submissions), 1)
|
||||||
|
|
||||||
|
if newest_first:
|
||||||
|
highest_timestamp -= (window_size + 1)
|
||||||
|
else:
|
||||||
|
lowest_timestamp += (window_size + 1)
|
||||||
|
|
||||||
|
if len(search_results) < min_search_results_in_window:
|
||||||
|
power = 2 if prev_win_increased else 1
|
||||||
|
window_size = int(window_size * window_adjustment_ratio**power)
|
||||||
|
prev_win_increased = True
|
||||||
|
debug("Increasing window size to {0} seconds"
|
||||||
|
.format(window_size), 2)
|
||||||
|
else:
|
||||||
|
prev_win_increased = False
|
||||||
|
|
||||||
|
|
||||||
|
def _stream_generator(get_function, limit=None, verbosity=1):
|
||||||
|
def debug(msg, level):
|
||||||
|
if verbosity >= level:
|
||||||
|
sys.stderr.write(msg + '\n')
|
||||||
|
|
||||||
|
def b36_id(item):
|
||||||
|
return int(item.id, 36)
|
||||||
|
|
||||||
|
seen = BoundedSet(KEEP_ITEMS * 16)
|
||||||
|
before = None
|
||||||
|
count = 0 # Count is incremented to bypass the cache
|
||||||
|
processed = 0
|
||||||
|
backoff = BACKOFF_START
|
||||||
|
while True:
|
||||||
|
items = []
|
||||||
|
sleep = None
|
||||||
|
start = timer()
|
||||||
|
try:
|
||||||
|
i = None
|
||||||
|
params = {'uniq': count}
|
||||||
|
count = (count + 1) % 100
|
||||||
|
if before:
|
||||||
|
params['before'] = before
|
||||||
|
gen = enumerate(get_function(limit=limit, params=params))
|
||||||
|
for i, item in gen:
|
||||||
|
if b36_id(item) in seen:
|
||||||
|
if i == 0:
|
||||||
|
if before is not None:
|
||||||
|
# reddit sent us out of order data -- log it
|
||||||
|
debug('(INFO) {0} already seen with before of {1}'
|
||||||
|
.format(item.fullname, before), 3)
|
||||||
|
before = None
|
||||||
|
break
|
||||||
|
if i == 0: # Always the first item in the generator
|
||||||
|
before = item.fullname
|
||||||
|
if b36_id(item) not in seen:
|
||||||
|
items.append(item)
|
||||||
|
processed += 1
|
||||||
|
if verbosity >= 1 and processed % 100 == 0:
|
||||||
|
sys.stderr.write(' Items: {0} \r'
|
||||||
|
.format(processed))
|
||||||
|
sys.stderr.flush()
|
||||||
|
if i < KEEP_ITEMS:
|
||||||
|
seen.add(b36_id(item))
|
||||||
|
else: # Generator exhausted
|
||||||
|
if i is None: # Generator yielded no items
|
||||||
|
assert before is not None
|
||||||
|
# Try again without before as the before item may be too
|
||||||
|
# old or no longer exist.
|
||||||
|
before = None
|
||||||
|
backoff = BACKOFF_START
|
||||||
|
except HTTPException as exc:
|
||||||
|
sleep = (backoff, '{0}. Sleeping for {{0}} seconds.'.format(exc),
|
||||||
|
2)
|
||||||
|
backoff *= 2
|
||||||
|
# Provide rate limit
|
||||||
|
if verbosity >= 1:
|
||||||
|
rate = len(items) / (timer() - start)
|
||||||
|
sys.stderr.write(' Items: {0} ({1:.2f} ips) \r'
|
||||||
|
.format(processed, rate))
|
||||||
|
sys.stderr.flush()
|
||||||
|
# Yield items from oldest to newest
|
||||||
|
for item in items[::-1]:
|
||||||
|
yield item
|
||||||
|
# Sleep if necessary
|
||||||
|
if sleep:
|
||||||
|
sleep_time, msg, msg_level = sleep # pylint: disable=W0633
|
||||||
|
debug(msg.format(sleep_time), msg_level)
|
||||||
|
time.sleep(sleep_time)
|
||||||
|
|
||||||
|
|
||||||
|
def chunk_sequence(sequence, chunk_length, allow_incomplete=True):
|
||||||
|
"""Given a sequence, divide it into sequences of length `chunk_length`.
|
||||||
|
|
||||||
|
:param allow_incomplete: If True, allow final chunk to be shorter if the
|
||||||
|
given sequence is not an exact multiple of `chunk_length`.
|
||||||
|
If False, the incomplete chunk will be discarded.
|
||||||
|
"""
|
||||||
|
(complete, leftover) = divmod(len(sequence), chunk_length)
|
||||||
|
if not allow_incomplete:
|
||||||
|
leftover = 0
|
||||||
|
|
||||||
|
chunk_count = complete + min(leftover, 1)
|
||||||
|
|
||||||
|
chunks = []
|
||||||
|
for x in range(chunk_count):
|
||||||
|
left = chunk_length * x
|
||||||
|
right = left + chunk_length
|
||||||
|
chunks.append(sequence[left:right])
|
||||||
|
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
|
||||||
|
def convert_id36_to_numeric_id(id36):
|
||||||
|
"""Convert strings representing base36 numbers into an integer."""
|
||||||
|
if not isinstance(id36, six.string_types) or id36.count("_") > 0:
|
||||||
|
raise ValueError("must supply base36 string, not fullname (e.g. use "
|
||||||
|
"xxxxx, not t3_xxxxx)")
|
||||||
|
return int(id36, 36)
|
||||||
|
|
||||||
|
|
||||||
|
def convert_numeric_id_to_id36(numeric_id):
|
||||||
|
"""Convert an integer into its base36 string representation.
|
||||||
|
|
||||||
|
This method has been cleaned up slightly to improve readability. For more
|
||||||
|
info see:
|
||||||
|
|
||||||
|
https://github.com/reddit/reddit/blob/master/r2/r2/lib/utils/_utils.pyx
|
||||||
|
|
||||||
|
https://www.reddit.com/r/redditdev/comments/n624n/submission_ids_question/
|
||||||
|
|
||||||
|
https://en.wikipedia.org/wiki/Base36
|
||||||
|
"""
|
||||||
|
# base36 allows negative numbers, but reddit does not
|
||||||
|
if not isinstance(numeric_id, six.integer_types) or numeric_id < 0:
|
||||||
|
raise ValueError("must supply a positive int/long")
|
||||||
|
|
||||||
|
# Alphabet used for base 36 conversion
|
||||||
|
alphabet = '0123456789abcdefghijklmnopqrstuvwxyz'
|
||||||
|
alphabet_len = len(alphabet)
|
||||||
|
|
||||||
|
# Temp assign
|
||||||
|
current_number = numeric_id
|
||||||
|
base36 = []
|
||||||
|
|
||||||
|
# Current_number must be greater than alphabet length to while/divmod
|
||||||
|
if 0 <= current_number < alphabet_len:
|
||||||
|
return alphabet[current_number]
|
||||||
|
|
||||||
|
# Break up into chunks
|
||||||
|
while current_number != 0:
|
||||||
|
current_number, rem = divmod(current_number, alphabet_len)
|
||||||
|
base36.append(alphabet[rem])
|
||||||
|
|
||||||
|
# String is built in reverse order
|
||||||
|
return ''.join(reversed(base36))
|
||||||
|
|
||||||
|
|
||||||
|
def flatten_tree(tree, nested_attr='replies', depth_first=False):
|
||||||
|
"""Return a flattened version of the passed in tree.
|
||||||
|
|
||||||
|
:param nested_attr: The attribute name that contains the nested items.
|
||||||
|
Defaults to ``replies`` which is suitable for comments.
|
||||||
|
:param depth_first: When true, add to the list in a depth-first manner
|
||||||
|
rather than the default breadth-first manner.
|
||||||
|
|
||||||
|
"""
|
||||||
|
stack = deque(tree)
|
||||||
|
extend = stack.extend if depth_first else stack.extendleft
|
||||||
|
retval = []
|
||||||
|
while stack:
|
||||||
|
item = stack.popleft()
|
||||||
|
nested = getattr(item, nested_attr, None)
|
||||||
|
if nested:
|
||||||
|
extend(nested)
|
||||||
|
retval.append(item)
|
||||||
|
return retval
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_url(url):
|
||||||
|
"""Return url after stripping trailing .json and trailing slashes."""
|
||||||
|
if url.endswith('.json'):
|
||||||
|
url = url[:-5]
|
||||||
|
if url.endswith('/'):
|
||||||
|
url = url[:-1]
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
class BoundedSet(object):
|
||||||
|
"""A set with a maximum size that evicts the oldest items when necessary.
|
||||||
|
|
||||||
|
This class does not implement the complete set interface.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, max_items):
|
||||||
|
"""Construct an instance of the BoundedSet."""
|
||||||
|
self.max_items = max_items
|
||||||
|
self._fifo = []
|
||||||
|
self._set = set()
|
||||||
|
|
||||||
|
def __contains__(self, item):
|
||||||
|
"""Test if the BoundedSet contains item."""
|
||||||
|
return item in self._set
|
||||||
|
|
||||||
|
def add(self, item):
|
||||||
|
"""Add an item to the set discarding the oldest item if necessary."""
|
||||||
|
if item in self._set:
|
||||||
|
self._fifo.remove(item)
|
||||||
|
elif len(self._set) == self.max_items:
|
||||||
|
self._set.remove(self._fifo.pop(0))
|
||||||
|
self._fifo.append(item)
|
||||||
|
self._set.add(item)
|
|
@ -0,0 +1,271 @@
|
||||||
|
# This file is part of PRAW.
|
||||||
|
#
|
||||||
|
# PRAW is free software: you can redistribute it and/or modify it under the
|
||||||
|
# terms of the GNU General Public License as published by the Free Software
|
||||||
|
# Foundation, either version 3 of the License, or (at your option) any later
|
||||||
|
# version.
|
||||||
|
#
|
||||||
|
# PRAW is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||||
|
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||||
|
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License along with
|
||||||
|
# PRAW. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
"""Internal helper functions.
|
||||||
|
|
||||||
|
The functions in this module are not to be relied upon by third-parties.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import print_function, unicode_literals
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import six
|
||||||
|
import sys
|
||||||
|
from requests import Request, codes, exceptions
|
||||||
|
from requests.compat import urljoin
|
||||||
|
from .decorators import restrict_access
|
||||||
|
from .errors import (ClientException, HTTPException, Forbidden, NotFound,
|
||||||
|
InvalidSubreddit, OAuthException,
|
||||||
|
OAuthInsufficientScope, OAuthInvalidToken,
|
||||||
|
RedirectException)
|
||||||
|
from warnings import warn
|
||||||
|
try:
|
||||||
|
from OpenSSL import __version__ as _opensslversion
|
||||||
|
_opensslversionlist = [int(minor) if minor.isdigit() else minor
|
||||||
|
for minor in _opensslversion.split('.')]
|
||||||
|
except ImportError:
|
||||||
|
_opensslversionlist = [0, 15]
|
||||||
|
|
||||||
|
MIN_PNG_SIZE = 67
|
||||||
|
MIN_JPEG_SIZE = 128
|
||||||
|
MAX_IMAGE_SIZE = 512000
|
||||||
|
JPEG_HEADER = b'\xff\xd8\xff'
|
||||||
|
PNG_HEADER = b'\x89\x50\x4e\x47\x0d\x0a\x1a\x0a'
|
||||||
|
RE_REDIRECT = re.compile('(rand(om|nsfw))|about/sticky')
|
||||||
|
|
||||||
|
|
||||||
|
def _get_redditor_listing(subpath=''):
|
||||||
|
"""Return function to generate Redditor listings."""
|
||||||
|
def _listing(self, sort='new', time='all', *args, **kwargs):
|
||||||
|
"""Return a get_content generator for some RedditContentObject type.
|
||||||
|
|
||||||
|
:param sort: Specify the sort order of the results if applicable
|
||||||
|
(one of ``'hot'``, ``'new'``, ``'top'``, ``'controversial'``).
|
||||||
|
:param time: Specify the time-period to return submissions if
|
||||||
|
applicable (one of ``'hour'``, ``'day'``, ``'week'``,
|
||||||
|
``'month'``, ``'year'``, ``'all'``).
|
||||||
|
|
||||||
|
The additional parameters are passed directly into
|
||||||
|
:meth:`.get_content`. Note: the `url` parameter cannot be altered.
|
||||||
|
|
||||||
|
"""
|
||||||
|
kwargs.setdefault('params', {})
|
||||||
|
kwargs['params'].setdefault('sort', sort)
|
||||||
|
kwargs['params'].setdefault('t', time)
|
||||||
|
url = urljoin(self._url, subpath) # pylint: disable=W0212
|
||||||
|
return self.reddit_session.get_content(url, *args, **kwargs)
|
||||||
|
return _listing
|
||||||
|
|
||||||
|
|
||||||
|
def _get_sorter(subpath='', **defaults):
|
||||||
|
"""Return function to generate specific subreddit Submission listings."""
|
||||||
|
@restrict_access(scope='read')
|
||||||
|
def _sorted(self, *args, **kwargs):
|
||||||
|
"""Return a get_content generator for some RedditContentObject type.
|
||||||
|
|
||||||
|
The additional parameters are passed directly into
|
||||||
|
:meth:`.get_content`. Note: the `url` parameter cannot be altered.
|
||||||
|
|
||||||
|
"""
|
||||||
|
if not kwargs.get('params'):
|
||||||
|
kwargs['params'] = {}
|
||||||
|
for key, value in six.iteritems(defaults):
|
||||||
|
kwargs['params'].setdefault(key, value)
|
||||||
|
url = urljoin(self._url, subpath) # pylint: disable=W0212
|
||||||
|
return self.reddit_session.get_content(url, *args, **kwargs)
|
||||||
|
return _sorted
|
||||||
|
|
||||||
|
|
||||||
|
def _image_type(image):
|
||||||
|
size = os.path.getsize(image.name)
|
||||||
|
if size < MIN_PNG_SIZE:
|
||||||
|
raise ClientException('png image is too small.')
|
||||||
|
if size > MAX_IMAGE_SIZE:
|
||||||
|
raise ClientException('`image` is too big. Max: {0} bytes'
|
||||||
|
.format(MAX_IMAGE_SIZE))
|
||||||
|
first_bytes = image.read(MIN_PNG_SIZE)
|
||||||
|
image.seek(0)
|
||||||
|
if first_bytes.startswith(PNG_HEADER):
|
||||||
|
return 'png'
|
||||||
|
elif first_bytes.startswith(JPEG_HEADER):
|
||||||
|
if size < MIN_JPEG_SIZE:
|
||||||
|
raise ClientException('jpeg image is too small.')
|
||||||
|
return 'jpg'
|
||||||
|
raise ClientException('`image` must be either jpg or png.')
|
||||||
|
|
||||||
|
|
||||||
|
def _modify_relationship(relationship, unlink=False, is_sub=False):
|
||||||
|
"""Return a function for relationship modification.
|
||||||
|
|
||||||
|
Used to support friending (user-to-user), as well as moderating,
|
||||||
|
contributor creating, and banning (user-to-subreddit).
|
||||||
|
|
||||||
|
"""
|
||||||
|
# The API uses friend and unfriend to manage all of these relationships.
|
||||||
|
url_key = 'unfriend' if unlink else 'friend'
|
||||||
|
|
||||||
|
if relationship == 'friend':
|
||||||
|
access = {'scope': None, 'login': True}
|
||||||
|
elif relationship == 'moderator':
|
||||||
|
access = {'scope': 'modothers'}
|
||||||
|
elif relationship in ['banned', 'contributor', 'muted']:
|
||||||
|
access = {'scope': 'modcontributors'}
|
||||||
|
elif relationship in ['wikibanned', 'wikicontributor']:
|
||||||
|
access = {'scope': ['modcontributors', 'modwiki']}
|
||||||
|
else:
|
||||||
|
access = {'scope': None, 'mod': True}
|
||||||
|
|
||||||
|
@restrict_access(**access)
|
||||||
|
def do_relationship(thing, user, **kwargs):
|
||||||
|
data = {'name': six.text_type(user),
|
||||||
|
'type': relationship}
|
||||||
|
data.update(kwargs)
|
||||||
|
if is_sub:
|
||||||
|
data['r'] = six.text_type(thing)
|
||||||
|
else:
|
||||||
|
data['container'] = thing.fullname
|
||||||
|
|
||||||
|
session = thing.reddit_session
|
||||||
|
if relationship == 'moderator':
|
||||||
|
session.evict(session.config['moderators'].format(
|
||||||
|
subreddit=six.text_type(thing)))
|
||||||
|
url = session.config[url_key]
|
||||||
|
return session.request_json(url, data=data)
|
||||||
|
return do_relationship
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_request(reddit_session, url, params, data, auth, files,
|
||||||
|
method=None):
|
||||||
|
"""Return a requests Request object that can be "prepared"."""
|
||||||
|
# Requests using OAuth for authorization must switch to using the oauth
|
||||||
|
# domain.
|
||||||
|
if getattr(reddit_session, '_use_oauth', False):
|
||||||
|
bearer = 'bearer {0}'.format(reddit_session.access_token)
|
||||||
|
headers = {'Authorization': bearer}
|
||||||
|
config = reddit_session.config
|
||||||
|
for prefix in (config.api_url, config.permalink_url):
|
||||||
|
if url.startswith(prefix):
|
||||||
|
if config.log_requests >= 1:
|
||||||
|
msg = 'substituting {0} for {1} in url\n'.format(
|
||||||
|
config.oauth_url, prefix)
|
||||||
|
sys.stderr.write(msg)
|
||||||
|
url = config.oauth_url + url[len(prefix):]
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
headers = {}
|
||||||
|
headers.update(reddit_session.http.headers)
|
||||||
|
|
||||||
|
if method:
|
||||||
|
pass
|
||||||
|
elif data or files:
|
||||||
|
method = 'POST'
|
||||||
|
else:
|
||||||
|
method = 'GET'
|
||||||
|
|
||||||
|
# Log the request if logging is enabled
|
||||||
|
if reddit_session.config.log_requests >= 1:
|
||||||
|
sys.stderr.write('{0}: {1}\n'.format(method, url))
|
||||||
|
if reddit_session.config.log_requests >= 2:
|
||||||
|
if params:
|
||||||
|
sys.stderr.write('params: {0}\n'.format(params))
|
||||||
|
if data:
|
||||||
|
sys.stderr.write('data: {0}\n'.format(data))
|
||||||
|
if auth:
|
||||||
|
sys.stderr.write('auth: {0}\n'.format(auth))
|
||||||
|
# Prepare request
|
||||||
|
request = Request(method=method, url=url, headers=headers, params=params,
|
||||||
|
auth=auth, cookies=reddit_session.http.cookies)
|
||||||
|
if method == 'GET':
|
||||||
|
return request
|
||||||
|
# Most POST requests require adding `api_type` and `uh` to the data.
|
||||||
|
if data is True:
|
||||||
|
data = {}
|
||||||
|
|
||||||
|
if isinstance(data, dict):
|
||||||
|
if not auth:
|
||||||
|
data.setdefault('api_type', 'json')
|
||||||
|
if reddit_session.modhash:
|
||||||
|
data.setdefault('uh', reddit_session.modhash)
|
||||||
|
else:
|
||||||
|
request.headers.setdefault('Content-Type', 'application/json')
|
||||||
|
|
||||||
|
request.data = data
|
||||||
|
request.files = files
|
||||||
|
return request
|
||||||
|
|
||||||
|
|
||||||
|
def _raise_redirect_exceptions(response):
|
||||||
|
"""Return the new url or None if there are no redirects.
|
||||||
|
|
||||||
|
Raise exceptions if appropriate.
|
||||||
|
|
||||||
|
"""
|
||||||
|
if response.status_code not in [301, 302, 307]:
|
||||||
|
return None
|
||||||
|
new_url = urljoin(response.url, response.headers['location'])
|
||||||
|
if 'reddits/search' in new_url: # Handle non-existent subreddit
|
||||||
|
subreddit = new_url.rsplit('=', 1)[1]
|
||||||
|
raise InvalidSubreddit('`{0}` is not a valid subreddit'
|
||||||
|
.format(subreddit))
|
||||||
|
elif not RE_REDIRECT.search(response.url):
|
||||||
|
raise RedirectException(response.url, new_url)
|
||||||
|
return new_url
|
||||||
|
|
||||||
|
|
||||||
|
def _raise_response_exceptions(response):
|
||||||
|
"""Raise specific errors on some status codes."""
|
||||||
|
if not response.ok and 'www-authenticate' in response.headers:
|
||||||
|
msg = response.headers['www-authenticate']
|
||||||
|
if 'insufficient_scope' in msg:
|
||||||
|
raise OAuthInsufficientScope('insufficient_scope', response.url)
|
||||||
|
elif 'invalid_token' in msg:
|
||||||
|
raise OAuthInvalidToken('invalid_token', response.url)
|
||||||
|
else:
|
||||||
|
raise OAuthException(msg, response.url)
|
||||||
|
|
||||||
|
if response.status_code == codes.forbidden: # pylint: disable=E1101
|
||||||
|
raise Forbidden(_raw=response)
|
||||||
|
elif response.status_code == codes.not_found: # pylint: disable=E1101
|
||||||
|
raise NotFound(_raw=response)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
response.raise_for_status() # These should all be directly mapped
|
||||||
|
except exceptions.HTTPError as exc:
|
||||||
|
raise HTTPException(_raw=exc.response)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_reddit_list(arg):
|
||||||
|
"""Return an argument converted to a reddit-formatted list.
|
||||||
|
|
||||||
|
The returned format is a comma deliminated list. Each element is a string
|
||||||
|
representation of an object. Either given as a string or as an object that
|
||||||
|
is then converted to its string representation.
|
||||||
|
"""
|
||||||
|
if (isinstance(arg, six.string_types) or not (
|
||||||
|
hasattr(arg, "__getitem__") or hasattr(arg, "__iter__"))):
|
||||||
|
return six.text_type(arg)
|
||||||
|
else:
|
||||||
|
return ','.join(six.text_type(a) for a in arg)
|
||||||
|
|
||||||
|
|
||||||
|
def _warn_pyopenssl():
|
||||||
|
"""Warn the user against faulty versions of pyOpenSSL."""
|
||||||
|
if _opensslversionlist < [0, 15]: # versions >= 0.15 are fine
|
||||||
|
warn(RuntimeWarning(
|
||||||
|
"pyOpenSSL {0} may be incompatible with praw if validating"
|
||||||
|
"ssl certificates, which is on by default.\nSee https://"
|
||||||
|
"github.com/praw/pull/625 for more information".format(
|
||||||
|
_opensslversion)
|
||||||
|
))
|
|
@ -0,0 +1,102 @@
|
||||||
|
"""Provides a request server to be used with the multiprocess handler."""
|
||||||
|
|
||||||
|
from __future__ import print_function, unicode_literals
|
||||||
|
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
from optparse import OptionParser
|
||||||
|
from . import __version__
|
||||||
|
from .handlers import DefaultHandler
|
||||||
|
from requests import Session
|
||||||
|
from six.moves import cPickle, socketserver # pylint: disable=F0401
|
||||||
|
from threading import Lock
|
||||||
|
|
||||||
|
|
||||||
|
class ThreadingTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
|
||||||
|
# pylint: disable=R0903,W0232
|
||||||
|
"""A TCP server that creates new threads per connection."""
|
||||||
|
|
||||||
|
allow_reuse_address = True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def handle_error(_, client_addr):
|
||||||
|
"""Mute tracebacks of common errors."""
|
||||||
|
exc_type, exc_value, _ = sys.exc_info()
|
||||||
|
if exc_type is socket.error and exc_value[0] == 32:
|
||||||
|
pass
|
||||||
|
elif exc_type is cPickle.UnpicklingError:
|
||||||
|
sys.stderr.write('Invalid connection from {0}\n'
|
||||||
|
.format(client_addr[0]))
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
class RequestHandler(socketserver.StreamRequestHandler):
|
||||||
|
# pylint: disable=W0232
|
||||||
|
"""A class that handles incoming requests.
|
||||||
|
|
||||||
|
Requests to the same domain are cached and rate-limited.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
ca_lock = Lock() # lock around cache and timeouts
|
||||||
|
cache = {} # caches requests
|
||||||
|
http = Session() # used to make requests
|
||||||
|
last_call = {} # Stores a two-item list: [lock, previous_call_time]
|
||||||
|
rl_lock = Lock() # lock used for adding items to last_call
|
||||||
|
timeouts = {} # store the time items in cache were entered
|
||||||
|
|
||||||
|
do_evict = DefaultHandler.evict # Add in the evict method
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def cache_hit_callback(key):
|
||||||
|
"""Output when a cache hit occurs."""
|
||||||
|
print('HIT {0} {1}'.format('POST' if key[1][1] else 'GET', key[0]))
|
||||||
|
|
||||||
|
@DefaultHandler.with_cache
|
||||||
|
@DefaultHandler.rate_limit
|
||||||
|
def do_request(self, request, proxies, timeout, **_):
|
||||||
|
"""Dispatch the actual request and return the result."""
|
||||||
|
print('{0} {1}'.format(request.method, request.url))
|
||||||
|
response = self.http.send(request, proxies=proxies, timeout=timeout,
|
||||||
|
allow_redirects=False)
|
||||||
|
response.raw = None # Make pickleable
|
||||||
|
return response
|
||||||
|
|
||||||
|
def handle(self):
|
||||||
|
"""Parse the RPC, make the call, and pickle up the return value."""
|
||||||
|
data = cPickle.load(self.rfile) # pylint: disable=E1101
|
||||||
|
method = data.pop('method')
|
||||||
|
try:
|
||||||
|
retval = getattr(self, 'do_{0}'.format(method))(**data)
|
||||||
|
except Exception as e:
|
||||||
|
# All exceptions should be passed to the client
|
||||||
|
retval = e
|
||||||
|
cPickle.dump(retval, self.wfile, # pylint: disable=E1101
|
||||||
|
cPickle.HIGHEST_PROTOCOL)
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
"""The entry point from the praw-multiprocess utility."""
|
||||||
|
parser = OptionParser(version='%prog {0}'.format(__version__))
|
||||||
|
parser.add_option('-a', '--addr', default='localhost',
|
||||||
|
help=('The address or host to listen on. Specify -a '
|
||||||
|
'0.0.0.0 to listen on all addresses. '
|
||||||
|
'Default: localhost'))
|
||||||
|
parser.add_option('-p', '--port', type='int', default='10101',
|
||||||
|
help=('The port to listen for requests on. '
|
||||||
|
'Default: 10101'))
|
||||||
|
options, _ = parser.parse_args()
|
||||||
|
try:
|
||||||
|
server = ThreadingTCPServer((options.addr, options.port),
|
||||||
|
RequestHandler)
|
||||||
|
except (socket.error, socket.gaierror) as exc: # Handle bind errors
|
||||||
|
print(exc)
|
||||||
|
sys.exit(1)
|
||||||
|
print('Listening on {0} port {1}'.format(options.addr, options.port))
|
||||||
|
try:
|
||||||
|
server.serve_forever() # pylint: disable=E1101
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
server.socket.close() # pylint: disable=E1101
|
||||||
|
RequestHandler.http.close()
|
||||||
|
print('Goodbye!')
|
|
@ -0,0 +1,79 @@
|
||||||
|
[DEFAULT]
|
||||||
|
# The domain name PRAW will use to interact with the reddit site via its API.
|
||||||
|
api_domain: api.reddit.com
|
||||||
|
|
||||||
|
# Time, a float, in seconds, required between calls. See:
|
||||||
|
# http://code.reddit.com/wiki/API
|
||||||
|
api_request_delay: 2.0
|
||||||
|
|
||||||
|
# A boolean to indicate whether or not to check for package updates.
|
||||||
|
check_for_updates: True
|
||||||
|
|
||||||
|
# Time, a float, in seconds, to save the results of a get/post request.
|
||||||
|
cache_timeout: 30
|
||||||
|
|
||||||
|
# Log the API calls
|
||||||
|
# 0: no logging
|
||||||
|
# 1: log only the request URIs
|
||||||
|
# 2: log the request URIs as well as any POST data
|
||||||
|
log_requests: 0
|
||||||
|
|
||||||
|
# The domain name PRAW will use for oauth-related requests.
|
||||||
|
oauth_domain: oauth.reddit.com
|
||||||
|
|
||||||
|
# Whether or not to use HTTPS for oauth connections. This should only be
|
||||||
|
# changed for development environments.
|
||||||
|
oauth_https: True
|
||||||
|
|
||||||
|
# OAuth grant type: either `authorization_code` or `password`
|
||||||
|
oauth_grant_type: authorization_code
|
||||||
|
|
||||||
|
# The maximum length of unicode representations of Comment, Message and
|
||||||
|
# Submission objects. This is mainly used to fit them within a terminal window
|
||||||
|
# line. A negative value means no limit.
|
||||||
|
output_chars_limit: 80
|
||||||
|
|
||||||
|
# The domain name PRAW will use when permalinks are requested.
|
||||||
|
permalink_domain: www.reddit.com
|
||||||
|
|
||||||
|
# The domain name to use for short urls.
|
||||||
|
short_domain: redd.it
|
||||||
|
|
||||||
|
# A boolean to indicate if json_dict, which contains the original API response,
|
||||||
|
# should be stored on every object in the json_dict attribute. Default is
|
||||||
|
# False as memory usage will double if enabled.
|
||||||
|
store_json_result: False
|
||||||
|
|
||||||
|
# Maximum time, a float, in seconds, before a single HTTP request times
|
||||||
|
# out. urllib2.URLError is raised upon timeout.
|
||||||
|
timeout: 45
|
||||||
|
|
||||||
|
# A boolean to indicate if SSL certificats should be validated. The
|
||||||
|
# default is True.
|
||||||
|
validate_certs: True
|
||||||
|
|
||||||
|
# Object to kind mappings
|
||||||
|
comment_kind: t1
|
||||||
|
message_kind: t4
|
||||||
|
redditor_kind: t2
|
||||||
|
submission_kind: t3
|
||||||
|
subreddit_kind: t5
|
||||||
|
|
||||||
|
|
||||||
|
[reddit]
|
||||||
|
# Uses the default settings
|
||||||
|
|
||||||
|
[reddit_oauth_test]
|
||||||
|
oauth_client_id: stJlUSUbPQe5lQ
|
||||||
|
oauth_client_secret: iU-LsOzyJH7BDVoq-qOWNEq2zuI
|
||||||
|
oauth_redirect_uri: https://127.0.0.1:65010/authorize_callback
|
||||||
|
|
||||||
|
[local_example]
|
||||||
|
api_domain: reddit.local
|
||||||
|
api_request_delay: 0
|
||||||
|
log_requests: 0
|
||||||
|
message_kind: t7
|
||||||
|
permalink_domain: reddit.local
|
||||||
|
short_domain:
|
||||||
|
submission_kind: t6
|
||||||
|
subreddit_kind: t5
|
|
@ -0,0 +1,45 @@
|
||||||
|
# This file is part of PRAW.
|
||||||
|
#
|
||||||
|
# PRAW is free software: you can redistribute it and/or modify it under the
|
||||||
|
# terms of the GNU General Public License as published by the Free Software
|
||||||
|
# Foundation, either version 3 of the License, or (at your option) any later
|
||||||
|
# version.
|
||||||
|
#
|
||||||
|
# PRAW is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||||
|
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||||
|
# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License along with
|
||||||
|
# PRAW. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
"""Provides the code to load PRAW's configuration file `praw.ini`."""
|
||||||
|
|
||||||
|
from __future__ import print_function, unicode_literals
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from six.moves import configparser
|
||||||
|
|
||||||
|
|
||||||
|
def _load_configuration():
|
||||||
|
"""Attempt to load settings from various praw.ini files."""
|
||||||
|
config = configparser.RawConfigParser()
|
||||||
|
module_dir = os.path.dirname(sys.modules[__name__].__file__)
|
||||||
|
if 'APPDATA' in os.environ: # Windows
|
||||||
|
os_config_path = os.environ['APPDATA']
|
||||||
|
elif 'XDG_CONFIG_HOME' in os.environ: # Modern Linux
|
||||||
|
os_config_path = os.environ['XDG_CONFIG_HOME']
|
||||||
|
elif 'HOME' in os.environ: # Legacy Linux
|
||||||
|
os_config_path = os.path.join(os.environ['HOME'], '.config')
|
||||||
|
else:
|
||||||
|
os_config_path = None
|
||||||
|
locations = [os.path.join(module_dir, 'praw.ini'), 'praw.ini']
|
||||||
|
if os_config_path is not None:
|
||||||
|
locations.insert(1, os.path.join(os_config_path, 'praw.ini'))
|
||||||
|
if not config.read(locations):
|
||||||
|
raise Exception('Could not find config file in any of: {0}'
|
||||||
|
.format(locations))
|
||||||
|
return config
|
||||||
|
CONFIG = _load_configuration()
|
||||||
|
del _load_configuration
|
|
@ -0,0 +1,913 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
import re
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
import six
|
||||||
|
from kitchen.text.display import textual_width
|
||||||
|
|
||||||
|
from . import docs
|
||||||
|
from .clipboard import copy as clipboard_copy
|
||||||
|
from .objects import Controller, Command
|
||||||
|
from .exceptions import TemporaryFileError, ProgramError
|
||||||
|
from .__version__ import __version__
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def logged_in(f):
|
||||||
|
"""
|
||||||
|
Decorator for Page methods that require the user to be authenticated.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@wraps(f)
|
||||||
|
def wrapped_method(self, *args, **kwargs):
|
||||||
|
if not self.reddit.is_oauth_session():
|
||||||
|
self.term.show_notification('Not logged in')
|
||||||
|
return None
|
||||||
|
return f(self, *args, **kwargs)
|
||||||
|
return wrapped_method
|
||||||
|
|
||||||
|
|
||||||
|
class PageController(Controller):
|
||||||
|
character_map = {}
|
||||||
|
|
||||||
|
|
||||||
|
class Page(object):
|
||||||
|
|
||||||
|
BANNER = None
|
||||||
|
FOOTER = None
|
||||||
|
|
||||||
|
def __init__(self, reddit, term, config, oauth):
|
||||||
|
self.reddit = reddit
|
||||||
|
self.term = term
|
||||||
|
self.config = config
|
||||||
|
self.oauth = oauth
|
||||||
|
self.content = None
|
||||||
|
self.nav = None
|
||||||
|
self.controller = None
|
||||||
|
|
||||||
|
self.active = True
|
||||||
|
self.selected_page = None
|
||||||
|
self._row = 0
|
||||||
|
self._subwindows = None
|
||||||
|
|
||||||
|
def refresh_content(self, order=None, name=None):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def _draw_item(self, win, data, inverted):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def get_selected_item(self):
|
||||||
|
"""
|
||||||
|
Return the content dictionary that is currently selected by the cursor.
|
||||||
|
"""
|
||||||
|
return self.content.get(self.nav.absolute_index)
|
||||||
|
|
||||||
|
def loop(self):
|
||||||
|
"""
|
||||||
|
Main control loop runs the following steps:
|
||||||
|
1. Re-draw the screen
|
||||||
|
2. Wait for user to press a key (includes terminal resizing)
|
||||||
|
3. Trigger the method registered to the input key
|
||||||
|
4. Check if there are any nested pages that need to be looped over
|
||||||
|
|
||||||
|
The loop will run until self.active is set to False from within one of
|
||||||
|
the methods.
|
||||||
|
"""
|
||||||
|
self.active = True
|
||||||
|
|
||||||
|
# This needs to be called once before the main loop, in case a subpage
|
||||||
|
# was pre-selected before the loop started. This happens in __main__.py
|
||||||
|
# with ``page.open_submission(url=url)``
|
||||||
|
while self.selected_page and self.active:
|
||||||
|
self.handle_selected_page()
|
||||||
|
|
||||||
|
while self.active:
|
||||||
|
self.draw()
|
||||||
|
ch = self.term.stdscr.getch()
|
||||||
|
self.controller.trigger(ch)
|
||||||
|
|
||||||
|
while self.selected_page and self.active:
|
||||||
|
self.handle_selected_page()
|
||||||
|
|
||||||
|
return self.selected_page
|
||||||
|
|
||||||
|
def handle_selected_page(self):
|
||||||
|
"""
|
||||||
|
Some commands will result in an action that causes a new page to open.
|
||||||
|
Examples include selecting a submission, viewing subscribed subreddits,
|
||||||
|
or opening the user's inbox. With these commands, the newly selected
|
||||||
|
page will be pre-loaded and stored in ``self.selected_page`` variable.
|
||||||
|
It's up to each page type to determine what to do when another page is
|
||||||
|
selected.
|
||||||
|
|
||||||
|
- It can start a nested page.loop(). This would allow the user to
|
||||||
|
return to their previous screen after exiting the sub-page. For
|
||||||
|
example, this is what happens when opening an individual submission
|
||||||
|
from within a subreddit page. When the submission is closed, the
|
||||||
|
user resumes the subreddit that they were previously viewing.
|
||||||
|
|
||||||
|
- It can close the current self.loop() and bubble the selected page up
|
||||||
|
one level in the loop stack. For example, this is what happens when
|
||||||
|
the user opens their subscriptions and selects a subreddit. The
|
||||||
|
subscription page loop is closed and the selected subreddit is
|
||||||
|
bubbled up to the root level loop.
|
||||||
|
|
||||||
|
Care should be taken to ensure the user can never enter an infinite
|
||||||
|
nested loop, as this could lead to memory leaks and recursion errors.
|
||||||
|
|
||||||
|
# Example of an unsafe nested loop
|
||||||
|
subreddit_page.loop()
|
||||||
|
-> submission_page.loop()
|
||||||
|
-> subreddit_page.loop()
|
||||||
|
-> submission_page.loop()
|
||||||
|
...
|
||||||
|
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@PageController.register(Command('REFRESH'))
|
||||||
|
def reload_page(self):
|
||||||
|
"""
|
||||||
|
Clear the PRAW cache to force the page the re-fetch content from reddit.
|
||||||
|
"""
|
||||||
|
self.reddit.handler.clear_cache()
|
||||||
|
self.refresh_content()
|
||||||
|
|
||||||
|
@PageController.register(Command('EXIT'))
|
||||||
|
def exit(self):
|
||||||
|
"""
|
||||||
|
Prompt and exit the application.
|
||||||
|
"""
|
||||||
|
if self.term.prompt_y_or_n('Do you really want to quit? (y/n): '):
|
||||||
|
sys.exit()
|
||||||
|
|
||||||
|
@PageController.register(Command('FORCE_EXIT'))
|
||||||
|
def force_exit(self):
|
||||||
|
"""
|
||||||
|
Immediately exit the application.
|
||||||
|
"""
|
||||||
|
sys.exit()
|
||||||
|
|
||||||
|
@PageController.register(Command('PREVIOUS_THEME'))
|
||||||
|
def previous_theme(self):
|
||||||
|
"""
|
||||||
|
Cycle to preview the previous theme from the internal list of themes.
|
||||||
|
"""
|
||||||
|
theme = self.term.theme_list.previous(self.term.theme)
|
||||||
|
while not self.term.check_theme(theme):
|
||||||
|
theme = self.term.theme_list.previous(theme)
|
||||||
|
|
||||||
|
self.term.set_theme(theme)
|
||||||
|
self.draw()
|
||||||
|
message = self.term.theme.display_string
|
||||||
|
self.term.show_notification(message, timeout=1)
|
||||||
|
|
||||||
|
@PageController.register(Command('NEXT_THEME'))
|
||||||
|
def next_theme(self):
|
||||||
|
"""
|
||||||
|
Cycle to preview the next theme from the internal list of themes.
|
||||||
|
"""
|
||||||
|
theme = self.term.theme_list.next(self.term.theme)
|
||||||
|
while not self.term.check_theme(theme):
|
||||||
|
theme = self.term.theme_list.next(theme)
|
||||||
|
|
||||||
|
self.term.set_theme(theme)
|
||||||
|
self.draw()
|
||||||
|
message = self.term.theme.display_string
|
||||||
|
self.term.show_notification(message, timeout=1)
|
||||||
|
|
||||||
|
@PageController.register(Command('HELP'))
|
||||||
|
def show_help(self):
|
||||||
|
"""
|
||||||
|
Open the help documentation in the system pager.
|
||||||
|
"""
|
||||||
|
self.term.open_pager(docs.HELP.strip())
|
||||||
|
|
||||||
|
@PageController.register(Command('MOVE_UP'))
|
||||||
|
def move_cursor_up(self):
|
||||||
|
"""
|
||||||
|
Move the cursor up one selection.
|
||||||
|
"""
|
||||||
|
self._move_cursor(-1)
|
||||||
|
self.clear_input_queue()
|
||||||
|
|
||||||
|
@PageController.register(Command('MOVE_DOWN'))
|
||||||
|
def move_cursor_down(self):
|
||||||
|
"""
|
||||||
|
Move the cursor down one selection.
|
||||||
|
"""
|
||||||
|
self._move_cursor(1)
|
||||||
|
self.clear_input_queue()
|
||||||
|
|
||||||
|
@PageController.register(Command('MOVE_NEXT_UNREAD'))
|
||||||
|
def move_next_unread(self):
|
||||||
|
"""
|
||||||
|
Move the cursor to the next unread url.
|
||||||
|
"""
|
||||||
|
self._move_cursor_to_unread(1)
|
||||||
|
self.clear_input_queue()
|
||||||
|
|
||||||
|
@PageController.register(Command('MOVE_PREV_UNREAD'))
|
||||||
|
def move_prev_unread(self):
|
||||||
|
"""
|
||||||
|
Move the cursor to the previous unread url.
|
||||||
|
"""
|
||||||
|
self._move_cursor_to_unread(-1)
|
||||||
|
self.clear_input_queue()
|
||||||
|
|
||||||
|
@PageController.register(Command('PAGE_UP'))
|
||||||
|
def move_page_up(self):
|
||||||
|
"""
|
||||||
|
Move the cursor up approximately the number of entries on the page.
|
||||||
|
"""
|
||||||
|
self._move_page(-1)
|
||||||
|
self.clear_input_queue()
|
||||||
|
|
||||||
|
@PageController.register(Command('PAGE_DOWN'))
|
||||||
|
def move_page_down(self):
|
||||||
|
"""
|
||||||
|
Move the cursor down approximately the number of entries on the page.
|
||||||
|
"""
|
||||||
|
self._move_page(1)
|
||||||
|
self.clear_input_queue()
|
||||||
|
|
||||||
|
@PageController.register(Command('PAGE_TOP'))
|
||||||
|
def move_page_top(self):
|
||||||
|
"""
|
||||||
|
Move the cursor to the first item on the page.
|
||||||
|
"""
|
||||||
|
self.nav.page_index = self.content.range[0]
|
||||||
|
self.nav.cursor_index = 0
|
||||||
|
self.nav.inverted = False
|
||||||
|
|
||||||
|
@PageController.register(Command('PAGE_BOTTOM'))
|
||||||
|
def move_page_bottom(self):
|
||||||
|
"""
|
||||||
|
Move the cursor to the last item on the page.
|
||||||
|
"""
|
||||||
|
self.nav.page_index = self.content.range[1]
|
||||||
|
self.nav.cursor_index = 0
|
||||||
|
self.nav.inverted = True
|
||||||
|
|
||||||
|
@PageController.register(Command('UPVOTE'))
|
||||||
|
@logged_in
|
||||||
|
def upvote(self):
|
||||||
|
"""
|
||||||
|
Upvote the currently selected item.
|
||||||
|
"""
|
||||||
|
data = self.get_selected_item()
|
||||||
|
if 'likes' not in data:
|
||||||
|
self.term.flash()
|
||||||
|
elif getattr(data['object'], 'archived'):
|
||||||
|
self.term.show_notification("Voting disabled for archived post", style='Error')
|
||||||
|
elif data['likes']:
|
||||||
|
with self.term.loader('Clearing vote'):
|
||||||
|
data['object'].clear_vote()
|
||||||
|
if not self.term.loader.exception:
|
||||||
|
data['likes'] = None
|
||||||
|
else:
|
||||||
|
with self.term.loader('Voting'):
|
||||||
|
data['object'].upvote()
|
||||||
|
if not self.term.loader.exception:
|
||||||
|
data['likes'] = True
|
||||||
|
|
||||||
|
@PageController.register(Command('DOWNVOTE'))
|
||||||
|
@logged_in
|
||||||
|
def downvote(self):
|
||||||
|
"""
|
||||||
|
Downvote the currently selected item.
|
||||||
|
"""
|
||||||
|
data = self.get_selected_item()
|
||||||
|
if 'likes' not in data:
|
||||||
|
self.term.flash()
|
||||||
|
elif getattr(data['object'], 'archived'):
|
||||||
|
self.term.show_notification("Voting disabled for archived post", style='Error')
|
||||||
|
elif data['likes'] or data['likes'] is None:
|
||||||
|
with self.term.loader('Voting'):
|
||||||
|
data['object'].downvote()
|
||||||
|
if not self.term.loader.exception:
|
||||||
|
data['likes'] = False
|
||||||
|
else:
|
||||||
|
with self.term.loader('Clearing vote'):
|
||||||
|
data['object'].clear_vote()
|
||||||
|
if not self.term.loader.exception:
|
||||||
|
data['likes'] = None
|
||||||
|
|
||||||
|
@PageController.register(Command('SAVE'))
|
||||||
|
@logged_in
|
||||||
|
def save(self):
|
||||||
|
"""
|
||||||
|
Mark the currently selected item as saved through the reddit API.
|
||||||
|
"""
|
||||||
|
data = self.get_selected_item()
|
||||||
|
if 'saved' not in data:
|
||||||
|
self.term.flash()
|
||||||
|
elif not data['saved']:
|
||||||
|
with self.term.loader('Saving'):
|
||||||
|
data['object'].save()
|
||||||
|
if not self.term.loader.exception:
|
||||||
|
data['saved'] = True
|
||||||
|
else:
|
||||||
|
with self.term.loader('Unsaving'):
|
||||||
|
data['object'].unsave()
|
||||||
|
if not self.term.loader.exception:
|
||||||
|
data['saved'] = False
|
||||||
|
|
||||||
|
@PageController.register(Command('LOGIN'))
|
||||||
|
def login(self):
|
||||||
|
"""
|
||||||
|
Prompt to log into the user's account, or log out of the current
|
||||||
|
account.
|
||||||
|
"""
|
||||||
|
if self.reddit.is_oauth_session():
|
||||||
|
ch = self.term.show_notification('Log out? (y/n)')
|
||||||
|
if ch in (ord('y'), ord('Y')):
|
||||||
|
self.oauth.clear_oauth_data()
|
||||||
|
self.term.show_notification('Logged out')
|
||||||
|
else:
|
||||||
|
self.oauth.authorize()
|
||||||
|
|
||||||
|
def reply(self):
|
||||||
|
"""
|
||||||
|
Reply to the selected item. This is a utility method and should not
|
||||||
|
be bound to a key directly.
|
||||||
|
|
||||||
|
Item type:
|
||||||
|
Submission - add a top level comment
|
||||||
|
Comment - add a comment reply
|
||||||
|
Message - reply to a private message
|
||||||
|
"""
|
||||||
|
data = self.get_selected_item()
|
||||||
|
|
||||||
|
if data['type'] == 'Submission':
|
||||||
|
body = data['text']
|
||||||
|
description = 'submission'
|
||||||
|
reply = data['object'].add_comment
|
||||||
|
elif data['type'] in ('Comment', 'InboxComment'):
|
||||||
|
body = data['body']
|
||||||
|
description = 'comment'
|
||||||
|
reply = data['object'].reply
|
||||||
|
elif data['type'] == 'Message':
|
||||||
|
body = data['body']
|
||||||
|
description = 'private message'
|
||||||
|
reply = data['object'].reply
|
||||||
|
else:
|
||||||
|
self.term.flash()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Construct the text that will be displayed in the editor file.
|
||||||
|
# The post body will be commented out and added for reference
|
||||||
|
lines = [' |' + line for line in body.split('\n')]
|
||||||
|
content = '\n'.join(lines)
|
||||||
|
comment_info = docs.REPLY_FILE.format(
|
||||||
|
author=data['author'],
|
||||||
|
type=description,
|
||||||
|
content=content)
|
||||||
|
|
||||||
|
with self.term.open_editor(comment_info) as comment:
|
||||||
|
if not comment:
|
||||||
|
self.term.show_notification('Canceled')
|
||||||
|
return
|
||||||
|
|
||||||
|
with self.term.loader('Posting {}'.format(description), delay=0):
|
||||||
|
reply(comment)
|
||||||
|
# Give reddit time to process the submission
|
||||||
|
time.sleep(2.0)
|
||||||
|
|
||||||
|
if self.term.loader.exception is None:
|
||||||
|
self.reload_page()
|
||||||
|
else:
|
||||||
|
raise TemporaryFileError()
|
||||||
|
|
||||||
|
@PageController.register(Command('DELETE'))
|
||||||
|
@logged_in
|
||||||
|
def delete_item(self):
|
||||||
|
"""
|
||||||
|
Delete a submission or comment.
|
||||||
|
"""
|
||||||
|
data = self.get_selected_item()
|
||||||
|
if data.get('author') != self.reddit.user.name:
|
||||||
|
self.term.flash()
|
||||||
|
return
|
||||||
|
|
||||||
|
prompt = 'Are you sure you want to delete this? (y/n): '
|
||||||
|
if not self.term.prompt_y_or_n(prompt):
|
||||||
|
self.term.show_notification('Canceled')
|
||||||
|
return
|
||||||
|
|
||||||
|
with self.term.loader('Deleting', delay=0):
|
||||||
|
data['object'].delete()
|
||||||
|
# Give reddit time to process the request
|
||||||
|
time.sleep(2.0)
|
||||||
|
|
||||||
|
if self.term.loader.exception is None:
|
||||||
|
self.reload_page()
|
||||||
|
|
||||||
|
@PageController.register(Command('EDIT'))
|
||||||
|
@logged_in
|
||||||
|
def edit(self):
|
||||||
|
"""
|
||||||
|
Edit a submission or comment.
|
||||||
|
"""
|
||||||
|
data = self.get_selected_item()
|
||||||
|
if data.get('author') != self.reddit.user.name:
|
||||||
|
self.term.flash()
|
||||||
|
return
|
||||||
|
|
||||||
|
if data['type'] == 'Submission':
|
||||||
|
content = data['text']
|
||||||
|
info = docs.SUBMISSION_EDIT_FILE.format(
|
||||||
|
content=content, id=data['object'].id)
|
||||||
|
elif data['type'] == 'Comment':
|
||||||
|
content = data['body']
|
||||||
|
info = docs.COMMENT_EDIT_FILE.format(
|
||||||
|
content=content, id=data['object'].id)
|
||||||
|
else:
|
||||||
|
self.term.flash()
|
||||||
|
return
|
||||||
|
|
||||||
|
with self.term.open_editor(info) as text:
|
||||||
|
if not text or text == content:
|
||||||
|
self.term.show_notification('Canceled')
|
||||||
|
return
|
||||||
|
|
||||||
|
with self.term.loader('Editing', delay=0):
|
||||||
|
data['object'].edit(text)
|
||||||
|
time.sleep(2.0)
|
||||||
|
|
||||||
|
if self.term.loader.exception is None:
|
||||||
|
self.reload_page()
|
||||||
|
else:
|
||||||
|
raise TemporaryFileError()
|
||||||
|
|
||||||
|
@PageController.register(Command('PRIVATE_MESSAGE'))
|
||||||
|
@logged_in
|
||||||
|
def send_private_message(self):
|
||||||
|
"""
|
||||||
|
Send a new private message to another user.
|
||||||
|
"""
|
||||||
|
message_info = docs.MESSAGE_FILE
|
||||||
|
with self.term.open_editor(message_info) as text:
|
||||||
|
if not text:
|
||||||
|
self.term.show_notification('Canceled')
|
||||||
|
return
|
||||||
|
|
||||||
|
parts = text.split('\n', 2)
|
||||||
|
if len(parts) == 1:
|
||||||
|
self.term.show_notification('Missing message subject')
|
||||||
|
return
|
||||||
|
elif len(parts) == 2:
|
||||||
|
self.term.show_notification('Missing message body')
|
||||||
|
return
|
||||||
|
|
||||||
|
recipient, subject, message = parts
|
||||||
|
recipient = recipient.strip()
|
||||||
|
subject = subject.strip()
|
||||||
|
message = message.rstrip()
|
||||||
|
|
||||||
|
if not recipient:
|
||||||
|
self.term.show_notification('Missing recipient')
|
||||||
|
return
|
||||||
|
elif not subject:
|
||||||
|
self.term.show_notification('Missing message subject')
|
||||||
|
return
|
||||||
|
elif not message:
|
||||||
|
self.term.show_notification('Missing message body')
|
||||||
|
return
|
||||||
|
|
||||||
|
with self.term.loader('Sending message', delay=0):
|
||||||
|
self.reddit.send_message(
|
||||||
|
recipient, subject, message, raise_captcha_exception=True)
|
||||||
|
# Give reddit time to process the message
|
||||||
|
time.sleep(2.0)
|
||||||
|
|
||||||
|
if self.term.loader.exception:
|
||||||
|
raise TemporaryFileError()
|
||||||
|
else:
|
||||||
|
self.term.show_notification('Message sent!')
|
||||||
|
self.selected_page = self.open_inbox_page('sent')
|
||||||
|
|
||||||
|
def prompt_and_select_link(self):
|
||||||
|
"""
|
||||||
|
Prompt the user to select a link from a list to open.
|
||||||
|
|
||||||
|
Return the link that was selected, or ``None`` if no link was selected.
|
||||||
|
"""
|
||||||
|
data = self.get_selected_item()
|
||||||
|
url_full = data.get('url_full')
|
||||||
|
permalink = data.get('permalink')
|
||||||
|
|
||||||
|
if url_full and url_full != permalink:
|
||||||
|
# The item is a link-only submission that won't contain text
|
||||||
|
link = url_full
|
||||||
|
else:
|
||||||
|
html = data.get('html')
|
||||||
|
if html:
|
||||||
|
extracted_links = self.content.extract_links(html)
|
||||||
|
if not extracted_links:
|
||||||
|
# Only one selection to choose from, so just pick it
|
||||||
|
link = permalink
|
||||||
|
else:
|
||||||
|
# Let the user decide which link to open
|
||||||
|
links = []
|
||||||
|
if permalink:
|
||||||
|
links += [{'text': 'Permalink', 'href': permalink}]
|
||||||
|
links += extracted_links
|
||||||
|
link = self.term.prompt_user_to_select_link(links)
|
||||||
|
else:
|
||||||
|
# Some items like hidden comments don't have any HTML to parse
|
||||||
|
link = permalink
|
||||||
|
|
||||||
|
return link
|
||||||
|
|
||||||
|
@PageController.register(Command('COPY_PERMALINK'))
|
||||||
|
def copy_permalink(self):
|
||||||
|
"""
|
||||||
|
Copy the submission permalink to OS clipboard
|
||||||
|
"""
|
||||||
|
url = self.get_selected_item().get('permalink')
|
||||||
|
self.copy_to_clipboard(url)
|
||||||
|
|
||||||
|
@PageController.register(Command('COPY_URL'))
|
||||||
|
def copy_url(self):
|
||||||
|
"""
|
||||||
|
Copy a link to OS clipboard
|
||||||
|
"""
|
||||||
|
url = self.prompt_and_select_link()
|
||||||
|
self.copy_to_clipboard(url)
|
||||||
|
|
||||||
|
def copy_to_clipboard(self, url):
|
||||||
|
"""
|
||||||
|
Attempt to copy the selected URL to the user's clipboard
|
||||||
|
"""
|
||||||
|
if url is None:
|
||||||
|
self.term.flash()
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
clipboard_copy(url)
|
||||||
|
except (ProgramError, OSError) as e:
|
||||||
|
_logger.exception(e)
|
||||||
|
self.term.show_notification(
|
||||||
|
'Failed to copy url: {0}'.format(e))
|
||||||
|
else:
|
||||||
|
self.term.show_notification(
|
||||||
|
['Copied to clipboard:', url], timeout=1)
|
||||||
|
|
||||||
|
@PageController.register(Command('SUBSCRIPTIONS'))
|
||||||
|
@logged_in
|
||||||
|
def subscriptions(self):
|
||||||
|
"""
|
||||||
|
View a list of the user's subscribed subreddits
|
||||||
|
"""
|
||||||
|
self.selected_page = self.open_subscription_page('subreddit')
|
||||||
|
|
||||||
|
@PageController.register(Command('MULTIREDDITS'))
|
||||||
|
@logged_in
|
||||||
|
def multireddits(self):
|
||||||
|
"""
|
||||||
|
View a list of the user's subscribed multireddits
|
||||||
|
"""
|
||||||
|
self.selected_page = self.open_subscription_page('multireddit')
|
||||||
|
|
||||||
|
@PageController.register(Command('PROMPT'))
|
||||||
|
def prompt(self):
|
||||||
|
"""
|
||||||
|
Open a prompt to navigate to a different subreddit or comment"
|
||||||
|
"""
|
||||||
|
name = self.term.prompt_input('Enter page: /')
|
||||||
|
if name:
|
||||||
|
# Check if opening a submission url or a subreddit url
|
||||||
|
# Example patterns for submissions:
|
||||||
|
# comments/571dw3
|
||||||
|
# /comments/571dw3
|
||||||
|
# /r/pics/comments/571dw3/
|
||||||
|
# https://www.reddit.com/r/pics/comments/571dw3/at_disneyland
|
||||||
|
submission_pattern = re.compile(r'(^|/)comments/(?P<id>.+?)($|/)')
|
||||||
|
|
||||||
|
match = submission_pattern.search(name)
|
||||||
|
if match:
|
||||||
|
url = 'https://www.reddit.com/comments/{0}'.format(match.group('id'))
|
||||||
|
self.selected_page = self.open_submission_page(url)
|
||||||
|
else:
|
||||||
|
self.selected_page = self.open_subreddit_page(name)
|
||||||
|
|
||||||
|
@PageController.register(Command('INBOX'))
|
||||||
|
@logged_in
|
||||||
|
def inbox(self):
|
||||||
|
"""
|
||||||
|
View the user's inbox.
|
||||||
|
"""
|
||||||
|
self.selected_page = self.open_inbox_page('all')
|
||||||
|
|
||||||
|
def open_inbox_page(self, content_type):
|
||||||
|
"""
|
||||||
|
Open an instance of the inbox page for the logged in user.
|
||||||
|
"""
|
||||||
|
from .inbox_page import InboxPage
|
||||||
|
|
||||||
|
with self.term.loader('Loading inbox'):
|
||||||
|
page = InboxPage(self.reddit, self.term, self.config, self.oauth,
|
||||||
|
content_type=content_type)
|
||||||
|
if not self.term.loader.exception:
|
||||||
|
return page
|
||||||
|
|
||||||
|
def open_subscription_page(self, content_type):
|
||||||
|
"""
|
||||||
|
Open an instance of the subscriptions page with the selected content.
|
||||||
|
"""
|
||||||
|
from .subscription_page import SubscriptionPage
|
||||||
|
|
||||||
|
with self.term.loader('Loading {0}s'.format(content_type)):
|
||||||
|
page = SubscriptionPage(self.reddit, self.term, self.config,
|
||||||
|
self.oauth, content_type=content_type)
|
||||||
|
if not self.term.loader.exception:
|
||||||
|
return page
|
||||||
|
|
||||||
|
def open_submission_page(self, url=None, submission=None):
|
||||||
|
"""
|
||||||
|
Open an instance of the submission page for the given submission URL.
|
||||||
|
"""
|
||||||
|
from .submission_page import SubmissionPage
|
||||||
|
|
||||||
|
with self.term.loader('Loading submission'):
|
||||||
|
page = SubmissionPage(self.reddit, self.term, self.config,
|
||||||
|
self.oauth, url=url, submission=submission)
|
||||||
|
if not self.term.loader.exception:
|
||||||
|
return page
|
||||||
|
|
||||||
|
def open_subreddit_page(self, name):
|
||||||
|
"""
|
||||||
|
Open an instance of the subreddit page for the given subreddit name.
|
||||||
|
"""
|
||||||
|
from .subreddit_page import SubredditPage
|
||||||
|
|
||||||
|
with self.term.loader('Loading subreddit'):
|
||||||
|
page = SubredditPage(self.reddit, self.term, self.config,
|
||||||
|
self.oauth, name)
|
||||||
|
if not self.term.loader.exception:
|
||||||
|
return page
|
||||||
|
|
||||||
|
def clear_input_queue(self):
|
||||||
|
"""
|
||||||
|
Clear excessive input caused by the scroll wheel or holding down a key
|
||||||
|
"""
|
||||||
|
with self.term.no_delay():
|
||||||
|
while self.term.getch() != -1:
|
||||||
|
continue
|
||||||
|
|
||||||
|
def draw(self):
|
||||||
|
"""
|
||||||
|
Clear the terminal screen and redraw all of the sub-windows
|
||||||
|
"""
|
||||||
|
n_rows, n_cols = self.term.stdscr.getmaxyx()
|
||||||
|
if n_rows < self.term.MIN_HEIGHT or n_cols < self.term.MIN_WIDTH:
|
||||||
|
# TODO: Will crash when you try to navigate if the terminal is too
|
||||||
|
# small at startup because self._subwindows will never be populated
|
||||||
|
return
|
||||||
|
|
||||||
|
self._row = 0
|
||||||
|
self._draw_header()
|
||||||
|
self._draw_banner()
|
||||||
|
self._draw_content()
|
||||||
|
self._draw_footer()
|
||||||
|
self.term.clear_screen()
|
||||||
|
self.term.stdscr.refresh()
|
||||||
|
|
||||||
|
def _draw_header(self):
|
||||||
|
"""
|
||||||
|
Draw the title bar at the top of the screen
|
||||||
|
"""
|
||||||
|
n_rows, n_cols = self.term.stdscr.getmaxyx()
|
||||||
|
|
||||||
|
# Note: 2 argument form of derwin breaks PDcurses on Windows 7!
|
||||||
|
window = self.term.stdscr.derwin(1, n_cols, self._row, 0)
|
||||||
|
window.erase()
|
||||||
|
# curses.bkgd expects bytes in py2 and unicode in py3
|
||||||
|
window.bkgd(str(' '), self.term.attr('TitleBar'))
|
||||||
|
|
||||||
|
sub_name = self.content.name
|
||||||
|
sub_name = sub_name.replace('/r/front', 'Front Page')
|
||||||
|
|
||||||
|
parts = sub_name.split('/')
|
||||||
|
if len(parts) == 1:
|
||||||
|
pass
|
||||||
|
elif '/m/' in sub_name:
|
||||||
|
_, _, user, _, multi = parts
|
||||||
|
sub_name = '{} Curated by {}'.format(multi, user)
|
||||||
|
elif parts[1] == 'u':
|
||||||
|
noun = 'My' if parts[2] == 'me' else parts[2] + "'s"
|
||||||
|
user_room = parts[3] if len(parts) == 4 else 'overview'
|
||||||
|
title_lookup = {
|
||||||
|
'overview': 'Overview',
|
||||||
|
'submitted': 'Submissions',
|
||||||
|
'comments': 'Comments',
|
||||||
|
'saved': 'Saved Content',
|
||||||
|
'hidden': 'Hidden Content',
|
||||||
|
'upvoted': 'Upvoted Content',
|
||||||
|
'downvoted': 'Downvoted Content'
|
||||||
|
}
|
||||||
|
sub_name = "{} {}".format(noun, title_lookup[user_room])
|
||||||
|
|
||||||
|
query = self.content.query
|
||||||
|
if query:
|
||||||
|
sub_name = 'Searching {0}: {1}'.format(sub_name, query)
|
||||||
|
self.term.add_line(window, sub_name, 0, 0)
|
||||||
|
|
||||||
|
# Set the terminal title
|
||||||
|
if len(sub_name) > 50:
|
||||||
|
title = sub_name.strip('/')
|
||||||
|
title = title.replace('_', ' ')
|
||||||
|
try:
|
||||||
|
title = title.rsplit('/', 1)[1]
|
||||||
|
except IndexError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
title = sub_name
|
||||||
|
|
||||||
|
# Setting the terminal title will break emacs or systems without
|
||||||
|
# X window.
|
||||||
|
if os.getenv('DISPLAY') and not os.getenv('INSIDE_EMACS'):
|
||||||
|
title += ' - ttrv {0}'.format(__version__)
|
||||||
|
title = self.term.clean(title)
|
||||||
|
if six.PY3:
|
||||||
|
# In py3 you can't write bytes to stdout
|
||||||
|
title = title.decode('utf-8')
|
||||||
|
title = '\x1b]2;{0}\x07'.format(title)
|
||||||
|
else:
|
||||||
|
title = b'\x1b]2;{0}\x07'.format(title)
|
||||||
|
sys.stdout.write(title)
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
if self.reddit and self.reddit.user is not None:
|
||||||
|
# The starting position of the name depends on if we're converting
|
||||||
|
# to ascii or not
|
||||||
|
width = len if self.config['ascii'] else textual_width
|
||||||
|
|
||||||
|
if self.config['hide_username']:
|
||||||
|
username = "Logged in"
|
||||||
|
else:
|
||||||
|
username = self.reddit.user.name
|
||||||
|
s_col = (n_cols - width(username) - 1)
|
||||||
|
# Only print username if it fits in the empty space on the right
|
||||||
|
if (s_col - 1) >= width(sub_name):
|
||||||
|
self.term.add_line(window, username, 0, s_col)
|
||||||
|
|
||||||
|
self._row += 1
|
||||||
|
|
||||||
|
def _draw_banner(self):
|
||||||
|
"""
|
||||||
|
Draw the banner with sorting options at the top of the page
|
||||||
|
"""
|
||||||
|
n_rows, n_cols = self.term.stdscr.getmaxyx()
|
||||||
|
window = self.term.stdscr.derwin(1, n_cols, self._row, 0)
|
||||||
|
window.erase()
|
||||||
|
window.bkgd(str(' '), self.term.attr('OrderBar'))
|
||||||
|
|
||||||
|
banner = docs.BANNER_SEARCH if self.content.query else self.BANNER
|
||||||
|
items = banner.strip().split(' ')
|
||||||
|
|
||||||
|
distance = (n_cols - sum(len(t) for t in items) - 1) / (len(items) - 1)
|
||||||
|
spacing = max(1, int(distance)) * ' '
|
||||||
|
text = spacing.join(items)
|
||||||
|
self.term.add_line(window, text, 0, 0)
|
||||||
|
if self.content.order is not None:
|
||||||
|
order = self.content.order.split('-')[0]
|
||||||
|
col = text.find(order) - 3
|
||||||
|
attr = self.term.attr('OrderBarHighlight')
|
||||||
|
window.chgat(0, col, 3, attr)
|
||||||
|
|
||||||
|
self._row += 1
|
||||||
|
|
||||||
|
def _draw_content(self):
|
||||||
|
"""
|
||||||
|
Loop through submissions and fill up the content page.
|
||||||
|
"""
|
||||||
|
n_rows, n_cols = self.term.stdscr.getmaxyx()
|
||||||
|
window = self.term.stdscr.derwin(n_rows - self._row - 1, n_cols, self._row, 0)
|
||||||
|
window.erase()
|
||||||
|
win_n_rows, win_n_cols = window.getmaxyx()
|
||||||
|
|
||||||
|
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.
|
||||||
|
cancel_inverted = True
|
||||||
|
current_row = (win_n_rows - 1) if inverted else 0
|
||||||
|
available_rows = win_n_rows
|
||||||
|
top_item_height = None if inverted else self.nav.top_item_height
|
||||||
|
for data in self.content.iterate(page_index, step, win_n_cols - 2):
|
||||||
|
subwin_n_rows = min(available_rows, data['n_rows'])
|
||||||
|
subwin_inverted = inverted
|
||||||
|
if top_item_height is not None:
|
||||||
|
# Special case: draw the page as non-inverted, except for the
|
||||||
|
# top element. This element will be drawn as inverted with a
|
||||||
|
# restricted height
|
||||||
|
subwin_n_rows = min(subwin_n_rows, top_item_height)
|
||||||
|
subwin_inverted = True
|
||||||
|
top_item_height = None
|
||||||
|
subwin_n_cols = win_n_cols - data['h_offset']
|
||||||
|
start = current_row - subwin_n_rows + 1 if inverted else current_row
|
||||||
|
subwindow = window.derwin(subwin_n_rows, subwin_n_cols, start, data['h_offset'])
|
||||||
|
self._subwindows.append((subwindow, data, subwin_inverted))
|
||||||
|
available_rows -= (subwin_n_rows + 1) # Add one for the blank line
|
||||||
|
current_row += step * (subwin_n_rows + 1)
|
||||||
|
if available_rows <= 0:
|
||||||
|
# Indicate the page is full and we can keep the inverted screen.
|
||||||
|
cancel_inverted = False
|
||||||
|
break
|
||||||
|
|
||||||
|
if len(self._subwindows) == 1:
|
||||||
|
# Never draw inverted if only one subwindow. The top of the
|
||||||
|
# subwindow should always be aligned with the top of the screen.
|
||||||
|
cancel_inverted = True
|
||||||
|
|
||||||
|
if cancel_inverted and self.nav.inverted:
|
||||||
|
# In some cases we need to make sure that the screen 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.
|
||||||
|
self.nav.flip((len(self._subwindows) - 1))
|
||||||
|
self._draw_content()
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.nav.cursor_index >= len(self._subwindows):
|
||||||
|
# Don't allow the cursor to go over the number of subwindows
|
||||||
|
# This could happen if the window is resized and the cursor index is
|
||||||
|
# pushed out of bounds
|
||||||
|
self.nav.cursor_index = len(self._subwindows) - 1
|
||||||
|
|
||||||
|
# Now that the windows are setup, we can take a second pass through
|
||||||
|
# to draw the text onto each subwindow
|
||||||
|
for index, (win, data, inverted) in enumerate(self._subwindows):
|
||||||
|
if self.nav.absolute_index >= 0 and index == self.nav.cursor_index:
|
||||||
|
win.bkgd(str(' '), self.term.attr('Selected'))
|
||||||
|
with self.term.theme.turn_on_selected():
|
||||||
|
self._draw_item(win, data, inverted)
|
||||||
|
else:
|
||||||
|
win.bkgd(str(' '), self.term.attr('Normal'))
|
||||||
|
self._draw_item(win, data, inverted)
|
||||||
|
|
||||||
|
self._row += win_n_rows
|
||||||
|
|
||||||
|
def _draw_footer(self):
|
||||||
|
"""
|
||||||
|
Draw the key binds help bar at the bottom of the screen
|
||||||
|
"""
|
||||||
|
n_rows, n_cols = self.term.stdscr.getmaxyx()
|
||||||
|
window = self.term.stdscr.derwin(1, n_cols, self._row, 0)
|
||||||
|
window.erase()
|
||||||
|
window.bkgd(str(' '), self.term.attr('HelpBar'))
|
||||||
|
|
||||||
|
text = self.FOOTER.strip()
|
||||||
|
self.term.add_line(window, text, 0, 0)
|
||||||
|
self._row += 1
|
||||||
|
|
||||||
|
def _move_cursor(self, direction):
|
||||||
|
# Note: ACS_VLINE doesn't like changing the attribute, so disregard the
|
||||||
|
# redraw flag and opt to always redraw
|
||||||
|
valid, redraw = self.nav.move(direction, len(self._subwindows))
|
||||||
|
if not valid:
|
||||||
|
self.term.flash()
|
||||||
|
|
||||||
|
def _move_cursor_to_unread(self, direction):
|
||||||
|
url_in_history = True
|
||||||
|
valid = True
|
||||||
|
while valid and url_in_history:
|
||||||
|
valid, redraw = self.nav.move(direction, len(self._subwindows))
|
||||||
|
if valid:
|
||||||
|
data = self.get_selected_item()
|
||||||
|
url_in_history = data['url_full'] in self.config.history
|
||||||
|
if not valid:
|
||||||
|
self.term.flash()
|
||||||
|
|
||||||
|
def _move_page(self, direction):
|
||||||
|
valid, redraw = self.nav.move_page(direction, len(self._subwindows)-1)
|
||||||
|
if not valid:
|
||||||
|
self.term.flash()
|
||||||
|
|
||||||
|
def _prompt_period(self, order):
|
||||||
|
choices = {
|
||||||
|
'\n': order,
|
||||||
|
'1': '{0}-hour'.format(order),
|
||||||
|
'2': '{0}-day'.format(order),
|
||||||
|
'3': '{0}-week'.format(order),
|
||||||
|
'4': '{0}-month'.format(order),
|
||||||
|
'5': '{0}-year'.format(order),
|
||||||
|
'6': '{0}-all'.format(order)}
|
||||||
|
|
||||||
|
message = docs.TIME_ORDER_MENU.strip().splitlines()
|
||||||
|
ch = self.term.show_notification(message)
|
||||||
|
ch = six.unichr(ch)
|
||||||
|
return choices.get(ch)
|
|
@ -0,0 +1,415 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from . import docs
|
||||||
|
from .content import SubmissionContent
|
||||||
|
from .page import Page, PageController, logged_in
|
||||||
|
from .objects import Navigator, Command
|
||||||
|
|
||||||
|
|
||||||
|
class SubmissionController(PageController):
|
||||||
|
character_map = {}
|
||||||
|
|
||||||
|
|
||||||
|
class SubmissionPage(Page):
|
||||||
|
BANNER = docs.BANNER_SUBMISSION
|
||||||
|
FOOTER = docs.FOOTER_SUBMISSION
|
||||||
|
|
||||||
|
name = 'submission'
|
||||||
|
|
||||||
|
def __init__(self, reddit, term, config, oauth, url=None, submission=None):
|
||||||
|
super(SubmissionPage, self).__init__(reddit, term, config, oauth)
|
||||||
|
|
||||||
|
self.controller = SubmissionController(self, keymap=config.keymap)
|
||||||
|
|
||||||
|
if url:
|
||||||
|
self.content = SubmissionContent.from_url(
|
||||||
|
reddit, url, term.loader,
|
||||||
|
max_comment_cols=config['max_comment_cols'])
|
||||||
|
else:
|
||||||
|
self.content = SubmissionContent(
|
||||||
|
submission, term.loader,
|
||||||
|
max_comment_cols=config['max_comment_cols'])
|
||||||
|
|
||||||
|
# Start at the submission post, which is indexed as -1
|
||||||
|
self.nav = Navigator(self.content.get, page_index=-1)
|
||||||
|
|
||||||
|
def handle_selected_page(self):
|
||||||
|
"""
|
||||||
|
Open the subscription page in a subwindow, but close the current page
|
||||||
|
if any other type of page is selected.
|
||||||
|
"""
|
||||||
|
if not self.selected_page:
|
||||||
|
pass
|
||||||
|
elif self.selected_page.name == 'subscription':
|
||||||
|
# Launch page in a subwindow
|
||||||
|
self.selected_page = self.selected_page.loop()
|
||||||
|
elif self.selected_page.name in ('subreddit', 'submission', 'inbox'):
|
||||||
|
# Replace the current page
|
||||||
|
self.active = False
|
||||||
|
else:
|
||||||
|
raise RuntimeError(self.selected_page.name)
|
||||||
|
|
||||||
|
def refresh_content(self, order=None, name=None):
|
||||||
|
"""
|
||||||
|
Re-download comments and reset the page index
|
||||||
|
"""
|
||||||
|
order = order or self.content.order
|
||||||
|
url = name or self.content.name
|
||||||
|
|
||||||
|
# Hack to allow an order specified in the name by prompt_subreddit() to
|
||||||
|
# override the current default
|
||||||
|
if order == 'ignore':
|
||||||
|
order = None
|
||||||
|
|
||||||
|
with self.term.loader('Refreshing page'):
|
||||||
|
self.content = SubmissionContent.from_url(
|
||||||
|
self.reddit, url, self.term.loader, order=order,
|
||||||
|
max_comment_cols=self.config['max_comment_cols'])
|
||||||
|
if not self.term.loader.exception:
|
||||||
|
self.nav = Navigator(self.content.get, page_index=-1)
|
||||||
|
|
||||||
|
@SubmissionController.register(Command('SORT_1'))
|
||||||
|
def sort_content_hot(self):
|
||||||
|
self.refresh_content(order='hot')
|
||||||
|
|
||||||
|
@SubmissionController.register(Command('SORT_2'))
|
||||||
|
def sort_content_top(self):
|
||||||
|
self.refresh_content(order='top')
|
||||||
|
|
||||||
|
@SubmissionController.register(Command('SORT_3'))
|
||||||
|
def sort_content_rising(self):
|
||||||
|
self.refresh_content(order='rising')
|
||||||
|
|
||||||
|
@SubmissionController.register(Command('SORT_4'))
|
||||||
|
def sort_content_new(self):
|
||||||
|
self.refresh_content(order='new')
|
||||||
|
|
||||||
|
@SubmissionController.register(Command('SORT_5'))
|
||||||
|
def sort_content_controversial(self):
|
||||||
|
self.refresh_content(order='controversial')
|
||||||
|
|
||||||
|
@SubmissionController.register(Command('SUBMISSION_TOGGLE_COMMENT'))
|
||||||
|
def toggle_comment(self):
|
||||||
|
"""
|
||||||
|
Toggle the selected comment tree between visible and hidden
|
||||||
|
"""
|
||||||
|
current_index = self.nav.absolute_index
|
||||||
|
self.content.toggle(current_index)
|
||||||
|
|
||||||
|
# This logic handles a display edge case after a comment toggle. We
|
||||||
|
# want to make sure that when we re-draw the page, the cursor stays at
|
||||||
|
# its current absolute position on the screen. In order to do this,
|
||||||
|
# apply a fixed offset if, while inverted, we either try to hide the
|
||||||
|
# bottom comment or toggle any of the middle comments.
|
||||||
|
if self.nav.inverted:
|
||||||
|
data = self.content.get(current_index)
|
||||||
|
if data['hidden'] or self.nav.cursor_index != 0:
|
||||||
|
window = self._subwindows[-1][0]
|
||||||
|
n_rows, _ = window.getmaxyx()
|
||||||
|
self.nav.flip(len(self._subwindows) - 1)
|
||||||
|
self.nav.top_item_height = n_rows
|
||||||
|
|
||||||
|
@SubmissionController.register(Command('SUBMISSION_EXIT'))
|
||||||
|
def exit_submission(self):
|
||||||
|
"""
|
||||||
|
Close the submission and return to the subreddit page
|
||||||
|
"""
|
||||||
|
self.active = False
|
||||||
|
|
||||||
|
@SubmissionController.register(Command('SUBMISSION_OPEN_IN_BROWSER'))
|
||||||
|
def open_link(self):
|
||||||
|
"""
|
||||||
|
Open the link contained in the selected item.
|
||||||
|
|
||||||
|
If there is more than one link contained in the item, prompt the user
|
||||||
|
to choose which link to open.
|
||||||
|
"""
|
||||||
|
data = self.get_selected_item()
|
||||||
|
if data['type'] == 'Submission':
|
||||||
|
link = self.prompt_and_select_link()
|
||||||
|
if link:
|
||||||
|
self.config.history.add(link)
|
||||||
|
self.term.open_link(link)
|
||||||
|
elif data['type'] == 'Comment':
|
||||||
|
link = self.prompt_and_select_link()
|
||||||
|
if link:
|
||||||
|
self.term.open_link(link)
|
||||||
|
else:
|
||||||
|
self.term.flash()
|
||||||
|
|
||||||
|
@SubmissionController.register(Command('SUBMISSION_OPEN_IN_PAGER'))
|
||||||
|
def open_pager(self):
|
||||||
|
"""
|
||||||
|
Open the selected item with the system's pager
|
||||||
|
"""
|
||||||
|
n_rows, n_cols = self.term.stdscr.getmaxyx()
|
||||||
|
|
||||||
|
if self.config['max_pager_cols'] is not None:
|
||||||
|
n_cols = min(n_cols, self.config['max_pager_cols'])
|
||||||
|
|
||||||
|
data = self.get_selected_item()
|
||||||
|
if data['type'] == 'Submission':
|
||||||
|
text = '\n\n'.join((data['permalink'], data['text']))
|
||||||
|
self.term.open_pager(text, wrap=n_cols)
|
||||||
|
elif data['type'] == 'Comment':
|
||||||
|
text = '\n\n'.join((data['permalink'], data['body']))
|
||||||
|
self.term.open_pager(text, wrap=n_cols)
|
||||||
|
else:
|
||||||
|
self.term.flash()
|
||||||
|
|
||||||
|
@SubmissionController.register(Command('SUBMISSION_POST'))
|
||||||
|
@logged_in
|
||||||
|
def add_comment(self):
|
||||||
|
"""
|
||||||
|
Submit a reply to the selected item.
|
||||||
|
"""
|
||||||
|
self.reply()
|
||||||
|
|
||||||
|
@SubmissionController.register(Command('DELETE'))
|
||||||
|
@logged_in
|
||||||
|
def delete_comment(self):
|
||||||
|
"""
|
||||||
|
Delete the selected comment
|
||||||
|
"""
|
||||||
|
if self.get_selected_item()['type'] == 'Comment':
|
||||||
|
self.delete_item()
|
||||||
|
else:
|
||||||
|
self.term.flash()
|
||||||
|
|
||||||
|
@SubmissionController.register(Command('SUBMISSION_OPEN_IN_URLVIEWER'))
|
||||||
|
def comment_urlview(self):
|
||||||
|
"""
|
||||||
|
Open the selected comment with the URL viewer
|
||||||
|
"""
|
||||||
|
data = self.get_selected_item()
|
||||||
|
comment = data.get('body') or data.get('text') or data.get('url_full')
|
||||||
|
if comment:
|
||||||
|
self.term.open_urlview(comment)
|
||||||
|
else:
|
||||||
|
self.term.flash()
|
||||||
|
|
||||||
|
@SubmissionController.register(Command('SUBMISSION_GOTO_PARENT'))
|
||||||
|
def move_parent_up(self):
|
||||||
|
"""
|
||||||
|
Move the cursor up to the comment's parent. If the comment is
|
||||||
|
top-level, jump to the previous top-level comment.
|
||||||
|
"""
|
||||||
|
cursor = self.nav.absolute_index
|
||||||
|
if cursor > 0:
|
||||||
|
level = max(self.content.get(cursor)['level'], 1)
|
||||||
|
while self.content.get(cursor - 1)['level'] >= level:
|
||||||
|
self._move_cursor(-1)
|
||||||
|
cursor -= 1
|
||||||
|
self._move_cursor(-1)
|
||||||
|
else:
|
||||||
|
self.term.flash()
|
||||||
|
|
||||||
|
self.clear_input_queue()
|
||||||
|
|
||||||
|
@SubmissionController.register(Command('SUBMISSION_GOTO_SIBLING'))
|
||||||
|
def move_sibling_next(self):
|
||||||
|
"""
|
||||||
|
Jump to the next comment that's at the same level as the selected
|
||||||
|
comment and shares the same parent.
|
||||||
|
"""
|
||||||
|
cursor = self.nav.absolute_index
|
||||||
|
if cursor >= 0:
|
||||||
|
level = self.content.get(cursor)['level']
|
||||||
|
try:
|
||||||
|
move = 1
|
||||||
|
while self.content.get(cursor + move)['level'] > level:
|
||||||
|
move += 1
|
||||||
|
except IndexError:
|
||||||
|
self.term.flash()
|
||||||
|
else:
|
||||||
|
if self.content.get(cursor + move)['level'] == level:
|
||||||
|
for _ in range(move):
|
||||||
|
self._move_cursor(1)
|
||||||
|
else:
|
||||||
|
self.term.flash()
|
||||||
|
else:
|
||||||
|
self.term.flash()
|
||||||
|
|
||||||
|
self.clear_input_queue()
|
||||||
|
|
||||||
|
def _draw_item(self, win, data, inverted):
|
||||||
|
|
||||||
|
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)
|
||||||
|
else:
|
||||||
|
return self._draw_submission(win, data)
|
||||||
|
|
||||||
|
def _draw_comment(self, win, data, inverted):
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
# If there isn't enough space to fit the comment body on the screen,
|
||||||
|
# replace the last line with a notification.
|
||||||
|
split_body = data['split_body']
|
||||||
|
if data['n_rows'] > n_rows:
|
||||||
|
# Only when there is a single comment on the page and not inverted
|
||||||
|
if not inverted and len(self._subwindows) == 1:
|
||||||
|
cutoff = data['n_rows'] - n_rows + 1
|
||||||
|
split_body = split_body[:-cutoff]
|
||||||
|
split_body.append('(Not enough space to display)')
|
||||||
|
|
||||||
|
row = offset
|
||||||
|
if row in valid_rows:
|
||||||
|
if data['is_author']:
|
||||||
|
attr = self.term.attr('CommentAuthorSelf')
|
||||||
|
text = '{author} [S]'.format(**data)
|
||||||
|
else:
|
||||||
|
attr = self.term.attr('CommentAuthor')
|
||||||
|
text = '{author}'.format(**data)
|
||||||
|
self.term.add_line(win, text, row, 1, attr)
|
||||||
|
|
||||||
|
if data['flair']:
|
||||||
|
attr = self.term.attr('UserFlair')
|
||||||
|
self.term.add_space(win)
|
||||||
|
self.term.add_line(win, '{flair}'.format(**data), attr=attr)
|
||||||
|
|
||||||
|
arrow, attr = self.term.get_arrow(data['likes'])
|
||||||
|
self.term.add_space(win)
|
||||||
|
self.term.add_line(win, arrow, attr=attr)
|
||||||
|
|
||||||
|
attr = self.term.attr('Score')
|
||||||
|
self.term.add_space(win)
|
||||||
|
self.term.add_line(win, '{score}'.format(**data), attr=attr)
|
||||||
|
|
||||||
|
attr = self.term.attr('Created')
|
||||||
|
self.term.add_space(win)
|
||||||
|
self.term.add_line(win, '{created}{edited}'.format(**data),
|
||||||
|
attr=attr)
|
||||||
|
|
||||||
|
if data['gold']:
|
||||||
|
attr = self.term.attr('Gold')
|
||||||
|
self.term.add_space(win)
|
||||||
|
count = 'x{}'.format(data['gold']) if data['gold'] > 1 else ''
|
||||||
|
text = self.term.gilded + count
|
||||||
|
self.term.add_line(win, text, attr=attr)
|
||||||
|
|
||||||
|
if data['stickied']:
|
||||||
|
attr = self.term.attr('Stickied')
|
||||||
|
self.term.add_space(win)
|
||||||
|
self.term.add_line(win, '[stickied]', attr=attr)
|
||||||
|
|
||||||
|
if data['saved']:
|
||||||
|
attr = self.term.attr('Saved')
|
||||||
|
self.term.add_space(win)
|
||||||
|
self.term.add_line(win, '[saved]', attr=attr)
|
||||||
|
|
||||||
|
for row, text in enumerate(split_body, start=offset + 1):
|
||||||
|
attr = self.term.attr('CommentText')
|
||||||
|
if row in valid_rows:
|
||||||
|
self.term.add_line(win, text, row, 1, attr=attr)
|
||||||
|
|
||||||
|
# curses.vline() doesn't support custom colors so need to build the
|
||||||
|
# cursor bar on the left of the comment one character at a time
|
||||||
|
index = data['level'] % len(self.term.theme.CURSOR_BARS)
|
||||||
|
attr = self.term.attr(self.term.theme.CURSOR_BARS[index])
|
||||||
|
for y in range(n_rows):
|
||||||
|
self.term.addch(win, y, 0, self.term.vline, attr)
|
||||||
|
|
||||||
|
def _draw_more_comments(self, win, data):
|
||||||
|
|
||||||
|
n_rows, n_cols = win.getmaxyx()
|
||||||
|
n_cols -= 1
|
||||||
|
|
||||||
|
attr = self.term.attr('HiddenCommentText')
|
||||||
|
self.term.add_line(win, '{body}'.format(**data), 0, 1, attr=attr)
|
||||||
|
|
||||||
|
attr = self.term.attr('HiddenCommentExpand')
|
||||||
|
self.term.add_space(win)
|
||||||
|
self.term.add_line(win, '[{count}]'.format(**data), attr=attr)
|
||||||
|
|
||||||
|
index = data['level'] % len(self.term.theme.CURSOR_BARS)
|
||||||
|
attr = self.term.attr(self.term.theme.CURSOR_BARS[index])
|
||||||
|
self.term.addch(win, 0, 0, self.term.vline, attr)
|
||||||
|
|
||||||
|
def _draw_submission(self, win, data):
|
||||||
|
|
||||||
|
n_rows, n_cols = win.getmaxyx()
|
||||||
|
n_cols -= 3 # one for each side of the border + one for offset
|
||||||
|
|
||||||
|
attr = self.term.attr('SubmissionTitle')
|
||||||
|
for row, text in enumerate(data['split_title'], start=1):
|
||||||
|
self.term.add_line(win, text, row, 1, attr)
|
||||||
|
|
||||||
|
row = len(data['split_title']) + 1
|
||||||
|
attr = self.term.attr('SubmissionAuthor')
|
||||||
|
self.term.add_line(win, '{author}'.format(**data), row, 1, attr)
|
||||||
|
|
||||||
|
if data['flair']:
|
||||||
|
attr = self.term.attr('SubmissionFlair')
|
||||||
|
self.term.add_space(win)
|
||||||
|
self.term.add_line(win, '{flair}'.format(**data), attr=attr)
|
||||||
|
|
||||||
|
attr = self.term.attr('SubmissionSubreddit')
|
||||||
|
self.term.add_space(win)
|
||||||
|
self.term.add_line(win, '/r/{subreddit}'.format(**data), attr=attr)
|
||||||
|
|
||||||
|
attr = self.term.attr('Created')
|
||||||
|
self.term.add_space(win)
|
||||||
|
self.term.add_line(win, '{created_long}{edited_long}'.format(**data),
|
||||||
|
attr=attr)
|
||||||
|
|
||||||
|
row = len(data['split_title']) + 2
|
||||||
|
if data['url_full'] in self.config.history:
|
||||||
|
attr = self.term.attr('LinkSeen')
|
||||||
|
else:
|
||||||
|
attr = self.term.attr('Link')
|
||||||
|
self.term.add_line(win, '{url}'.format(**data), row, 1, 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)')
|
||||||
|
|
||||||
|
attr = self.term.attr('SubmissionText')
|
||||||
|
for row, text in enumerate(split_text, start=offset):
|
||||||
|
self.term.add_line(win, text, row, 1, attr=attr)
|
||||||
|
|
||||||
|
row = len(data['split_title']) + len(split_text) + 3
|
||||||
|
attr = self.term.attr('Score')
|
||||||
|
self.term.add_line(win, '{score}'.format(**data), row, 1, attr=attr)
|
||||||
|
|
||||||
|
arrow, attr = self.term.get_arrow(data['likes'])
|
||||||
|
self.term.add_space(win)
|
||||||
|
self.term.add_line(win, arrow, attr=attr)
|
||||||
|
|
||||||
|
attr = self.term.attr('CommentCount')
|
||||||
|
self.term.add_space(win)
|
||||||
|
self.term.add_line(win, '{comments}'.format(**data), attr=attr)
|
||||||
|
|
||||||
|
if data['gold']:
|
||||||
|
attr = self.term.attr('Gold')
|
||||||
|
self.term.add_space(win)
|
||||||
|
count = 'x{}'.format(data['gold']) if data['gold'] > 1 else ''
|
||||||
|
text = self.term.gilded + count
|
||||||
|
self.term.add_line(win, text, attr=attr)
|
||||||
|
|
||||||
|
if data['nsfw']:
|
||||||
|
attr = self.term.attr('NSFW')
|
||||||
|
self.term.add_space(win)
|
||||||
|
self.term.add_line(win, 'NSFW', attr=attr)
|
||||||
|
|
||||||
|
if data['saved']:
|
||||||
|
attr = self.term.attr('Saved')
|
||||||
|
self.term.add_space(win)
|
||||||
|
self.term.add_line(win, '[saved]', attr=attr)
|
||||||
|
|
||||||
|
win.border()
|
|
@ -0,0 +1,329 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
from . import docs
|
||||||
|
from .content import SubredditContent
|
||||||
|
from .page import Page, PageController, logged_in
|
||||||
|
from .objects import Navigator, Command
|
||||||
|
from .exceptions import TemporaryFileError
|
||||||
|
|
||||||
|
|
||||||
|
class SubredditController(PageController):
|
||||||
|
character_map = {}
|
||||||
|
|
||||||
|
|
||||||
|
class SubredditPage(Page):
|
||||||
|
BANNER = docs.BANNER_SUBREDDIT
|
||||||
|
FOOTER = docs.FOOTER_SUBREDDIT
|
||||||
|
|
||||||
|
name = 'subreddit'
|
||||||
|
|
||||||
|
def __init__(self, reddit, term, config, oauth, name):
|
||||||
|
"""
|
||||||
|
Params:
|
||||||
|
name (string): Name of subreddit to open
|
||||||
|
"""
|
||||||
|
super(SubredditPage, self).__init__(reddit, term, config, oauth)
|
||||||
|
|
||||||
|
self.controller = SubredditController(self, keymap=config.keymap)
|
||||||
|
self.content = SubredditContent.from_name(reddit, name, term.loader)
|
||||||
|
self.nav = Navigator(self.content.get)
|
||||||
|
self.toggled_subreddit = None
|
||||||
|
|
||||||
|
def handle_selected_page(self):
|
||||||
|
"""
|
||||||
|
Open all selected pages in subwindows except other subreddit pages.
|
||||||
|
"""
|
||||||
|
if not self.selected_page:
|
||||||
|
pass
|
||||||
|
elif self.selected_page.name in ('subscription', 'submission', 'inbox'):
|
||||||
|
# Launch page in a subwindow
|
||||||
|
self.selected_page = self.selected_page.loop()
|
||||||
|
elif self.selected_page.name == 'subreddit':
|
||||||
|
# Replace the current page
|
||||||
|
self.active = False
|
||||||
|
else:
|
||||||
|
raise RuntimeError(self.selected_page.name)
|
||||||
|
|
||||||
|
def refresh_content(self, order=None, name=None):
|
||||||
|
"""
|
||||||
|
Re-download all submissions and reset the page index
|
||||||
|
"""
|
||||||
|
order = order or self.content.order
|
||||||
|
|
||||||
|
# Preserve the query if staying on the current page
|
||||||
|
if name is None:
|
||||||
|
query = self.content.query
|
||||||
|
else:
|
||||||
|
query = None
|
||||||
|
|
||||||
|
name = name or self.content.name
|
||||||
|
|
||||||
|
# Hack to allow an order specified in the name by prompt_subreddit() to
|
||||||
|
# override the current default
|
||||||
|
if order == 'ignore':
|
||||||
|
order = None
|
||||||
|
|
||||||
|
with self.term.loader('Refreshing page'):
|
||||||
|
self.content = SubredditContent.from_name(
|
||||||
|
self.reddit, name, self.term.loader, order=order, query=query)
|
||||||
|
if not self.term.loader.exception:
|
||||||
|
self.nav = Navigator(self.content.get)
|
||||||
|
|
||||||
|
@SubredditController.register(Command('SORT_1'))
|
||||||
|
def sort_content_hot(self):
|
||||||
|
if self.content.query:
|
||||||
|
self.refresh_content(order='relevance')
|
||||||
|
else:
|
||||||
|
self.refresh_content(order='hot')
|
||||||
|
|
||||||
|
@SubredditController.register(Command('SORT_2'))
|
||||||
|
def sort_content_top(self):
|
||||||
|
order = self._prompt_period('top')
|
||||||
|
if order is None:
|
||||||
|
self.term.show_notification('Invalid option')
|
||||||
|
else:
|
||||||
|
self.refresh_content(order=order)
|
||||||
|
|
||||||
|
@SubredditController.register(Command('SORT_3'))
|
||||||
|
def sort_content_rising(self):
|
||||||
|
if self.content.query:
|
||||||
|
order = self._prompt_period('comments')
|
||||||
|
if order is None:
|
||||||
|
self.term.show_notification('Invalid option')
|
||||||
|
else:
|
||||||
|
self.refresh_content(order=order)
|
||||||
|
else:
|
||||||
|
self.refresh_content(order='rising')
|
||||||
|
|
||||||
|
@SubredditController.register(Command('SORT_4'))
|
||||||
|
def sort_content_new(self):
|
||||||
|
self.refresh_content(order='new')
|
||||||
|
|
||||||
|
@SubredditController.register(Command('SORT_5'))
|
||||||
|
def sort_content_controversial(self):
|
||||||
|
if self.content.query:
|
||||||
|
self.term.flash()
|
||||||
|
else:
|
||||||
|
order = self._prompt_period('controversial')
|
||||||
|
if order is None:
|
||||||
|
self.term.show_notification('Invalid option')
|
||||||
|
else:
|
||||||
|
self.refresh_content(order=order)
|
||||||
|
|
||||||
|
@SubredditController.register(Command('SORT_6'))
|
||||||
|
def sort_content_gilded(self):
|
||||||
|
if self.content.query:
|
||||||
|
self.term.flash()
|
||||||
|
else:
|
||||||
|
self.refresh_content(order='gilded')
|
||||||
|
|
||||||
|
@SubredditController.register(Command('SUBREDDIT_SEARCH'))
|
||||||
|
def search_subreddit(self, name=None):
|
||||||
|
"""
|
||||||
|
Open a prompt to search the given subreddit
|
||||||
|
"""
|
||||||
|
name = name or self.content.name
|
||||||
|
|
||||||
|
query = self.term.prompt_input('Search {0}: '.format(name))
|
||||||
|
if not query:
|
||||||
|
return
|
||||||
|
|
||||||
|
with self.term.loader('Searching'):
|
||||||
|
self.content = SubredditContent.from_name(
|
||||||
|
self.reddit, name, self.term.loader, query=query)
|
||||||
|
if not self.term.loader.exception:
|
||||||
|
self.nav = Navigator(self.content.get)
|
||||||
|
|
||||||
|
@SubredditController.register(Command('SUBREDDIT_FRONTPAGE'))
|
||||||
|
def show_frontpage(self):
|
||||||
|
"""
|
||||||
|
If on a subreddit, remember it and head back to the front page.
|
||||||
|
If this was pressed on the front page, go back to the last subreddit.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if self.content.name != '/r/front':
|
||||||
|
target = '/r/front'
|
||||||
|
self.toggled_subreddit = self.content.name
|
||||||
|
else:
|
||||||
|
target = self.toggled_subreddit
|
||||||
|
|
||||||
|
# target still may be empty string if this command hasn't yet been used
|
||||||
|
if target is not None:
|
||||||
|
self.refresh_content(order='ignore', name=target)
|
||||||
|
|
||||||
|
@SubredditController.register(Command('SUBREDDIT_OPEN'))
|
||||||
|
def open_submission(self, url=None):
|
||||||
|
"""
|
||||||
|
Select the current submission to view posts.
|
||||||
|
"""
|
||||||
|
if url is None:
|
||||||
|
data = self.get_selected_item()
|
||||||
|
url = data['permalink']
|
||||||
|
if data.get('url_type') == 'selfpost':
|
||||||
|
self.config.history.add(data['url_full'])
|
||||||
|
|
||||||
|
self.selected_page = self.open_submission_page(url)
|
||||||
|
|
||||||
|
@SubredditController.register(Command('SUBREDDIT_OPEN_IN_BROWSER'))
|
||||||
|
def open_link(self):
|
||||||
|
"""
|
||||||
|
Open a link with the webbrowser
|
||||||
|
"""
|
||||||
|
|
||||||
|
data = self.get_selected_item()
|
||||||
|
if data['url_type'] == 'selfpost':
|
||||||
|
self.open_submission()
|
||||||
|
elif data['url_type'] == 'x-post subreddit':
|
||||||
|
self.refresh_content(order='ignore', name=data['xpost_subreddit'])
|
||||||
|
elif data['url_type'] == 'x-post submission':
|
||||||
|
self.open_submission(url=data['url_full'])
|
||||||
|
self.config.history.add(data['url_full'])
|
||||||
|
else:
|
||||||
|
self.term.open_link(data['url_full'])
|
||||||
|
self.config.history.add(data['url_full'])
|
||||||
|
|
||||||
|
@SubredditController.register(Command('SUBREDDIT_POST'))
|
||||||
|
@logged_in
|
||||||
|
def post_submission(self):
|
||||||
|
"""
|
||||||
|
Post a new submission to the given subreddit.
|
||||||
|
"""
|
||||||
|
# Check that the subreddit can be submitted to
|
||||||
|
name = self.content.name
|
||||||
|
if '+' in name or name in ('/r/all', '/r/front', '/r/me', '/u/saved'):
|
||||||
|
self.term.show_notification("Can't post to {0}".format(name))
|
||||||
|
return
|
||||||
|
|
||||||
|
submission_info = docs.SUBMISSION_FILE.format(name=name)
|
||||||
|
with self.term.open_editor(submission_info) as text:
|
||||||
|
if not text:
|
||||||
|
self.term.show_notification('Canceled')
|
||||||
|
return
|
||||||
|
elif '\n' not in text:
|
||||||
|
self.term.show_notification('Missing body')
|
||||||
|
return
|
||||||
|
|
||||||
|
title, content = text.split('\n', 1)
|
||||||
|
with self.term.loader('Posting', delay=0):
|
||||||
|
submission = self.reddit.submit(name, title, text=content,
|
||||||
|
raise_captcha_exception=True)
|
||||||
|
# Give reddit time to process the submission
|
||||||
|
time.sleep(2.0)
|
||||||
|
if self.term.loader.exception:
|
||||||
|
raise TemporaryFileError()
|
||||||
|
|
||||||
|
if not self.term.loader.exception:
|
||||||
|
# Open the newly created submission
|
||||||
|
self.selected_page = self.open_submission_page(submission=submission)
|
||||||
|
|
||||||
|
@SubredditController.register(Command('SUBREDDIT_HIDE'))
|
||||||
|
@logged_in
|
||||||
|
def hide(self):
|
||||||
|
data = self.get_selected_item()
|
||||||
|
if not hasattr(data["object"], 'hide'):
|
||||||
|
self.term.flash()
|
||||||
|
elif data['hidden']:
|
||||||
|
with self.term.loader('Unhiding'):
|
||||||
|
data['object'].unhide()
|
||||||
|
data['hidden'] = False
|
||||||
|
else:
|
||||||
|
with self.term.loader('Hiding'):
|
||||||
|
data['object'].hide()
|
||||||
|
data['hidden'] = True
|
||||||
|
|
||||||
|
def _draw_item(self, win, data, inverted):
|
||||||
|
|
||||||
|
n_rows, n_cols = win.getmaxyx()
|
||||||
|
n_cols -= 1 # Leave space for the cursor in the first column
|
||||||
|
|
||||||
|
# Handle the case where the window is not large enough to fit the data.
|
||||||
|
valid_rows = range(0, n_rows)
|
||||||
|
offset = 0 if not inverted else -(data['n_rows'] - n_rows)
|
||||||
|
|
||||||
|
n_title = len(data['split_title'])
|
||||||
|
if data['url_full'] in self.config.history:
|
||||||
|
attr = self.term.attr('SubmissionTitleSeen')
|
||||||
|
else:
|
||||||
|
attr = self.term.attr('SubmissionTitle')
|
||||||
|
for row, text in enumerate(data['split_title'], start=offset):
|
||||||
|
if row in valid_rows:
|
||||||
|
self.term.add_line(win, text, row, 1, attr)
|
||||||
|
|
||||||
|
row = n_title + offset
|
||||||
|
if data['url_full'] in self.config.history:
|
||||||
|
attr = self.term.attr('LinkSeen')
|
||||||
|
else:
|
||||||
|
attr = self.term.attr('Link')
|
||||||
|
if row in valid_rows:
|
||||||
|
self.term.add_line(win, '{url}'.format(**data), row, 1, attr)
|
||||||
|
|
||||||
|
row = n_title + offset + 1
|
||||||
|
if row in valid_rows:
|
||||||
|
|
||||||
|
attr = self.term.attr('Score')
|
||||||
|
self.term.add_line(win, '{score}'.format(**data), row, 1, attr)
|
||||||
|
self.term.add_space(win)
|
||||||
|
|
||||||
|
arrow, attr = self.term.get_arrow(data['likes'])
|
||||||
|
self.term.add_line(win, arrow, attr=attr)
|
||||||
|
self.term.add_space(win)
|
||||||
|
|
||||||
|
attr = self.term.attr('Created')
|
||||||
|
self.term.add_line(win, '{created}{edited}'.format(**data), attr=attr)
|
||||||
|
|
||||||
|
if data['comments'] is not None:
|
||||||
|
attr = self.term.attr('Separator')
|
||||||
|
self.term.add_space(win)
|
||||||
|
self.term.add_line(win, '-', attr=attr)
|
||||||
|
|
||||||
|
attr = self.term.attr('CommentCount')
|
||||||
|
self.term.add_space(win)
|
||||||
|
self.term.add_line(win, '{comments}'.format(**data), attr=attr)
|
||||||
|
|
||||||
|
if data['saved']:
|
||||||
|
attr = self.term.attr('Saved')
|
||||||
|
self.term.add_space(win)
|
||||||
|
self.term.add_line(win, '[saved]', attr=attr)
|
||||||
|
|
||||||
|
if data['hidden']:
|
||||||
|
attr = self.term.attr('Hidden')
|
||||||
|
self.term.add_space(win)
|
||||||
|
self.term.add_line(win, '[hidden]', attr=attr)
|
||||||
|
|
||||||
|
if data['stickied']:
|
||||||
|
attr = self.term.attr('Stickied')
|
||||||
|
self.term.add_space(win)
|
||||||
|
self.term.add_line(win, '[stickied]', attr=attr)
|
||||||
|
|
||||||
|
if data['gold']:
|
||||||
|
attr = self.term.attr('Gold')
|
||||||
|
self.term.add_space(win)
|
||||||
|
count = 'x{}'.format(data['gold']) if data['gold'] > 1 else ''
|
||||||
|
text = self.term.gilded + count
|
||||||
|
self.term.add_line(win, text, attr=attr)
|
||||||
|
|
||||||
|
if data['nsfw']:
|
||||||
|
attr = self.term.attr('NSFW')
|
||||||
|
self.term.add_space(win)
|
||||||
|
self.term.add_line(win, 'NSFW', attr=attr)
|
||||||
|
|
||||||
|
row = n_title + offset + 2
|
||||||
|
if row in valid_rows:
|
||||||
|
attr = self.term.attr('SubmissionAuthor')
|
||||||
|
self.term.add_line(win, '{author}'.format(**data), row, 1, attr)
|
||||||
|
self.term.add_space(win)
|
||||||
|
|
||||||
|
attr = self.term.attr('SubmissionSubreddit')
|
||||||
|
self.term.add_line(win, '/r/{subreddit}'.format(**data), attr=attr)
|
||||||
|
|
||||||
|
if data['flair']:
|
||||||
|
attr = self.term.attr('SubmissionFlair')
|
||||||
|
self.term.add_space(win)
|
||||||
|
self.term.add_line(win, '{flair}'.format(**data), attr=attr)
|
||||||
|
|
||||||
|
attr = self.term.attr('CursorBlock')
|
||||||
|
for y in range(n_rows):
|
||||||
|
self.term.addch(win, y, 0, str(' '), attr)
|
|
@ -0,0 +1,97 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from . import docs
|
||||||
|
from .content import SubscriptionContent
|
||||||
|
from .page import Page, PageController, logged_in
|
||||||
|
from .objects import Navigator, Command
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionController(PageController):
|
||||||
|
character_map = {}
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionPage(Page):
|
||||||
|
BANNER = None
|
||||||
|
FOOTER = docs.FOOTER_SUBSCRIPTION
|
||||||
|
|
||||||
|
name = 'subscription'
|
||||||
|
|
||||||
|
def __init__(self, reddit, term, config, oauth, content_type='subreddit'):
|
||||||
|
super(SubscriptionPage, self).__init__(reddit, term, config, oauth)
|
||||||
|
|
||||||
|
self.controller = SubscriptionController(self, keymap=config.keymap)
|
||||||
|
self.content = SubscriptionContent.from_user(
|
||||||
|
reddit, term.loader, content_type)
|
||||||
|
self.nav = Navigator(self.content.get)
|
||||||
|
self.content_type = content_type
|
||||||
|
|
||||||
|
def handle_selected_page(self):
|
||||||
|
"""
|
||||||
|
Always close the current page when another page is selected.
|
||||||
|
"""
|
||||||
|
if self.selected_page:
|
||||||
|
self.active = False
|
||||||
|
|
||||||
|
def refresh_content(self, order=None, name=None):
|
||||||
|
"""
|
||||||
|
Re-download all subscriptions and reset the page index
|
||||||
|
"""
|
||||||
|
# reddit.get_my_subreddits() does not support sorting by order
|
||||||
|
if order:
|
||||||
|
self.term.flash()
|
||||||
|
return
|
||||||
|
|
||||||
|
with self.term.loader():
|
||||||
|
self.content = SubscriptionContent.from_user(
|
||||||
|
self.reddit, self.term.loader, self.content_type)
|
||||||
|
if not self.term.loader.exception:
|
||||||
|
self.nav = Navigator(self.content.get)
|
||||||
|
|
||||||
|
@SubscriptionController.register(Command('SUBSCRIPTION_SELECT'))
|
||||||
|
def select_subreddit(self):
|
||||||
|
"""
|
||||||
|
Store the selected subreddit and return to the subreddit page
|
||||||
|
"""
|
||||||
|
name = self.get_selected_item()['name']
|
||||||
|
self.selected_page = self.open_subreddit_page(name)
|
||||||
|
|
||||||
|
@SubscriptionController.register(Command('SUBSCRIPTION_EXIT'))
|
||||||
|
def close_subscriptions(self):
|
||||||
|
"""
|
||||||
|
Close subscriptions and return to the subreddit page
|
||||||
|
"""
|
||||||
|
self.active = False
|
||||||
|
|
||||||
|
def _draw_banner(self):
|
||||||
|
# Subscriptions can't be sorted, so disable showing the order menu
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _draw_item(self, win, data, inverted):
|
||||||
|
n_rows, n_cols = win.getmaxyx()
|
||||||
|
n_cols -= 1 # Leave space for the cursor in the first column
|
||||||
|
|
||||||
|
# Handle the case where the window is not large enough to fit the data.
|
||||||
|
valid_rows = range(0, n_rows)
|
||||||
|
offset = 0 if not inverted else -(data['n_rows'] - n_rows)
|
||||||
|
|
||||||
|
row = offset
|
||||||
|
if row in valid_rows:
|
||||||
|
if data['type'] == 'Multireddit':
|
||||||
|
attr = self.term.attr('MultiredditName')
|
||||||
|
else:
|
||||||
|
attr = self.term.attr('SubscriptionName')
|
||||||
|
self.term.add_line(win, '{name}'.format(**data), row, 1, attr)
|
||||||
|
|
||||||
|
row = offset + 1
|
||||||
|
for row, text in enumerate(data['split_title'], start=row):
|
||||||
|
if row in valid_rows:
|
||||||
|
if data['type'] == 'Multireddit':
|
||||||
|
attr = self.term.attr('MultiredditText')
|
||||||
|
else:
|
||||||
|
attr = self.term.attr('SubscriptionText')
|
||||||
|
self.term.add_line(win, text, row, 1, attr)
|
||||||
|
|
||||||
|
attr = self.term.attr('CursorBlock')
|
||||||
|
for y in range(n_rows):
|
||||||
|
self.term.addch(win, y, 0, str(' '), attr)
|
|
@ -0,0 +1,31 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>RTV OAuth2 Helper</title>
|
||||||
|
<!-- style borrowed from http://bettermotherfuckingwebsite.com/ -->
|
||||||
|
<style type="text/css">
|
||||||
|
body {
|
||||||
|
margin:40px auto;
|
||||||
|
max-width:650px;
|
||||||
|
line-height:1.6;
|
||||||
|
font-size:18px;
|
||||||
|
font-family:Arial, Helvetica, sans-serif;
|
||||||
|
color:#444;
|
||||||
|
padding:0 10px;
|
||||||
|
}
|
||||||
|
h1, h2, h3 {
|
||||||
|
line-height:1.2
|
||||||
|
}
|
||||||
|
#footer {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0px;
|
||||||
|
width: 100%;
|
||||||
|
font-size:14px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
${message}
|
||||||
|
<div id="footer">View the <a href="https://github.com/tildeclub/ttrv">Documentation</a></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -0,0 +1,70 @@
|
||||||
|
# Example mailcap file for Terminal Viewer for Reddit
|
||||||
|
# https://github.com/tildeclub/ttrv/
|
||||||
|
#
|
||||||
|
# Copy the contents of this file to {HOME}/.mailcap, or point to it using $MAILCAPS
|
||||||
|
# Then launch TTRV using the --enable-media flag. All shell commands defined in
|
||||||
|
# this file depend on external programs that must be installed on your system.
|
||||||
|
#
|
||||||
|
# HELP REQUESTED! If you come up with your own commands (especially for OS X)
|
||||||
|
# and would like to share, please post an issue on the GitHub tracker and we
|
||||||
|
# can get them added to this file as references.
|
||||||
|
#
|
||||||
|
#
|
||||||
|
# Mailcap 101
|
||||||
|
# - The first entry with a matching MIME type will be executed, * is a wildcard
|
||||||
|
# - %s will be replaced with the image or video url
|
||||||
|
# - Add ``test=test -n "$DISPLAY"`` if your command opens a new window
|
||||||
|
# - Add ``needsterminal`` for commands that use the terminal
|
||||||
|
# - Add ``copiousoutput`` for commands that dump text to stdout
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# Commands below this point will open media in a separate window without
|
||||||
|
# pausing execution of TTRV.
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
# Feh is a simple and effective image viewer
|
||||||
|
# Note that ttrv returns a list of urls for imgur albums, so we don't put quotes
|
||||||
|
# around the `%s`
|
||||||
|
image/x-imgur-album; feh -g 640x480 -. %s; test=test -n "$DISPLAY"
|
||||||
|
image/gif; mpv '%s' --autofit=640x480 --loop=inf; test=test -n "$DISPLAY"
|
||||||
|
image/*; feh -g 640x480 -. '%s'; test=test -n "$DISPLAY"
|
||||||
|
|
||||||
|
# Youtube videos are assigned a custom mime-type, which can be streamed with
|
||||||
|
# vlc or youtube-dl.
|
||||||
|
video/x-youtube; vlc '%s' --width 640 --height 480; test=test -n "$DISPLAY"
|
||||||
|
video/x-youtube; mpv --ytdl-format=bestvideo+bestaudio/best '%s' --autofit=640x480; test=test -n "$DISPLAY"
|
||||||
|
|
||||||
|
# Mpv is a simple and effective video streamer
|
||||||
|
video/*; mpv '%s' --autofit=640x480 --loop=inf; test=test -n "$DISPLAY"
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# Commands below this point will attempt to display media directly in the
|
||||||
|
# terminal when a desktop is not available (e.g. inside of an SSH session)
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
# View images directly in your terminal with iTerm2
|
||||||
|
# curl -L https://iterm2.com/misc/install_shell_integration_and_utilities.sh | bash
|
||||||
|
# image/*; bash -c '[[ "%s" == http* ]] && (curl -s %s | ~/.iterm2/imgcat) || ~/.iterm2/imgcat %s' && read -n 1; needsterminal
|
||||||
|
|
||||||
|
# View true images in the terminal, supported by rxvt-unicode, xterm and st
|
||||||
|
# Requires the w3m-img package
|
||||||
|
# image/*; w3m -o 'ext_image_viewer=off' '%s'; needsterminal
|
||||||
|
|
||||||
|
# Don't have a solution for albums yet
|
||||||
|
image/x-imgur-album; echo
|
||||||
|
|
||||||
|
# 256 color images using half-width unicode characters
|
||||||
|
# Much higher quality that img2txt, but must be built from source
|
||||||
|
# https://github.com/rossy/img2xterm
|
||||||
|
image/*; curl -s '%s' | convert -resize 80x80 - jpg:/tmp/ttrv.jpg && img2xterm /tmp/ttrv.jpg; needsterminal; copiousoutput
|
||||||
|
|
||||||
|
# Display images in classic ascii using img2txt and lib-caca
|
||||||
|
image/*; curl -s '%s' | convert - jpg:/tmp/ttrv.jpg && img2txt -f utf8 /tmp/ttrv.jpg; needsterminal; copiousoutput
|
||||||
|
|
||||||
|
# Full motion videos - requires a framebuffer to view
|
||||||
|
video/x-youtube; mpv -vo drm -quiet '%s'; needsterminal
|
||||||
|
video/*; mpv -vo drm -quiet '%s'; needsterminal
|
||||||
|
|
||||||
|
# Ascii videos
|
||||||
|
# video/x-youtube; youtube-dl -q -o - '%s' | mplayer -cache 8192 -vo caca -quiet -; needsterminal
|
||||||
|
# video/*; wget '%s' -O - | mplayer -cache 8192 -vo caca -quiet -; needsterminal
|
|
@ -0,0 +1,182 @@
|
||||||
|
; Tilde Terminal Reddit Viewer Configuration File
|
||||||
|
; https://github.com/tildeclub/ttrv
|
||||||
|
;
|
||||||
|
; This file should be placed in $XDG_CONFIG/ttrv/ttrv.cfg
|
||||||
|
; If $XDG_CONFIG is not set, use ~/.config/ttrv/ttrv.cfg
|
||||||
|
|
||||||
|
[ttrv]
|
||||||
|
##################
|
||||||
|
# General Settings
|
||||||
|
##################
|
||||||
|
|
||||||
|
; Turn on ascii-only mode to disable all unicode characters.
|
||||||
|
; This may be necessary for compatibility with some terminal browsers.
|
||||||
|
ascii = False
|
||||||
|
|
||||||
|
; Turn on monochrome mode to disable color.
|
||||||
|
monochrome = False
|
||||||
|
|
||||||
|
; Flash when an invalid action is executed.
|
||||||
|
flash = True
|
||||||
|
|
||||||
|
; Enable debugging by logging all HTTP requests and errors to the given file.
|
||||||
|
;log = /tmp/ttrv.log
|
||||||
|
|
||||||
|
; Default subreddit that will be opened when the program launches.
|
||||||
|
subreddit = front
|
||||||
|
;subreddit = python
|
||||||
|
;subreddit = python+linux+programming
|
||||||
|
;subreddit = all
|
||||||
|
|
||||||
|
; Allow ttrv to store reddit authentication credentials between sessions.
|
||||||
|
persistent = True
|
||||||
|
|
||||||
|
; Automatically log in on startup, if credentials are available.
|
||||||
|
autologin = True
|
||||||
|
|
||||||
|
; Clear any stored credentials when the program starts.
|
||||||
|
clear_auth = False
|
||||||
|
|
||||||
|
; Maximum number of opened links that will be saved in the history file.
|
||||||
|
history_size = 200
|
||||||
|
|
||||||
|
; Open external links using programs defined in the mailcap config.
|
||||||
|
enable_media = False
|
||||||
|
|
||||||
|
; Maximum number of columns for a comment
|
||||||
|
max_comment_cols = 120
|
||||||
|
|
||||||
|
; Maximum number of columns for pager
|
||||||
|
;max_pager_cols = 70
|
||||||
|
|
||||||
|
; Hide username if logged in, display "Logged in" instead
|
||||||
|
hide_username = False
|
||||||
|
|
||||||
|
; Color theme, use "ttrv --list-themes" to view a list of valid options.
|
||||||
|
; This can be an absolute filepath, or the name of a theme file that has
|
||||||
|
; been installed into either the custom of default theme paths.
|
||||||
|
;theme = molokai
|
||||||
|
|
||||||
|
; Open a new browser window instead of a new tab in existing instance
|
||||||
|
force_new_browser_window = False
|
||||||
|
|
||||||
|
################
|
||||||
|
# OAuth Settings
|
||||||
|
################
|
||||||
|
; This sections defines the paramaters that will be used during the OAuth
|
||||||
|
; authentication process. ttrv is registered as an "installed app",
|
||||||
|
; see https://github.com/reddit/reddit/wiki/OAuth2 for more information.
|
||||||
|
|
||||||
|
; These settings are defined at https://www.reddit.com/prefs/apps and should
|
||||||
|
; not be altered unless you are defining your own developer application.
|
||||||
|
oauth_client_id = E2oEtRQfdfAfNQ
|
||||||
|
oauth_client_secret = praw_gapfill
|
||||||
|
oauth_redirect_uri = http://127.0.0.1:65000/
|
||||||
|
|
||||||
|
; Port that the ttrv webserver will listen on. This should match the redirect
|
||||||
|
; uri defined above.
|
||||||
|
oauth_redirect_port = 65000
|
||||||
|
|
||||||
|
; Access permissions that will be requested.
|
||||||
|
oauth_scope = edit,history,identity,mysubreddits,privatemessages,read,report,save,submit,subscribe,vote
|
||||||
|
|
||||||
|
; This is a separate token for the imgur api. It's used to extract images
|
||||||
|
; from imgur links and albums so they can be opened with mailcap.
|
||||||
|
; See https://imgur.com/account/settings/apps to generate your own key.
|
||||||
|
imgur_client_id = 93396265f59dec9
|
||||||
|
|
||||||
|
[bindings]
|
||||||
|
##############
|
||||||
|
# Key Bindings
|
||||||
|
##############
|
||||||
|
; If you would like to define custom bindings, copy this section into your
|
||||||
|
; config file with the [bindings] heading. All commands must be bound to at
|
||||||
|
; least one key for the config to be valid.
|
||||||
|
;
|
||||||
|
; 1.) Plain keys can be represented by either uppercase/lowercase characters
|
||||||
|
; or the hexadecimal numbers referring their ascii codes. For reference, see
|
||||||
|
; https://en.wikipedia.org/wiki/ASCII#ASCII_printable_code_chart
|
||||||
|
; e.g. Q, q, 1, ?
|
||||||
|
; e.g. 0x20 (space), 0x3c (less-than sign)
|
||||||
|
;
|
||||||
|
; 2.) Special ascii control codes should be surrounded with <>. For reference,
|
||||||
|
; see https://en.wikipedia.org/wiki/ASCII#ASCII_control_code_chart
|
||||||
|
; e.g. <LF> (enter), <ESC> (escape)
|
||||||
|
;
|
||||||
|
; 3.) Other special keys are defined by curses, they should be surrounded by <>
|
||||||
|
; and prefixed with KEY_. For reference, see
|
||||||
|
; https://docs.python.org/2/library/curses.html#constants
|
||||||
|
; e.g. <KEY_LEFT> (left arrow), <KEY_F5>, <KEY_NPAGE> (page down)
|
||||||
|
;
|
||||||
|
; Notes:
|
||||||
|
; - Curses <KEY_ENTER> is unreliable and should always be used in conjunction
|
||||||
|
; with <LF>.
|
||||||
|
; - Use 0x20 for the space key.
|
||||||
|
; - A subset of Ctrl modifiers are available through the ascii control codes.
|
||||||
|
; For example, Ctrl-D will trigger an <EOT> signal. See the table above for
|
||||||
|
; a complete reference.
|
||||||
|
|
||||||
|
; Base page
|
||||||
|
EXIT = q
|
||||||
|
FORCE_EXIT = Q
|
||||||
|
HELP = ?
|
||||||
|
SORT_1 = 1
|
||||||
|
SORT_2 = 2
|
||||||
|
SORT_3 = 3
|
||||||
|
SORT_4 = 4
|
||||||
|
SORT_5 = 5
|
||||||
|
SORT_6 = 6
|
||||||
|
SORT_7 = 7
|
||||||
|
MOVE_UP = k, <KEY_UP>
|
||||||
|
MOVE_DOWN = j, <KEY_DOWN>
|
||||||
|
MOVE_NEXT_UNREAD = J
|
||||||
|
MOVE_PREV_UNREAD = K
|
||||||
|
PREVIOUS_THEME = <KEY_F2>
|
||||||
|
NEXT_THEME = <KEY_F3>
|
||||||
|
PAGE_UP = m, <KEY_PPAGE>, <NAK>
|
||||||
|
PAGE_DOWN = n, <KEY_NPAGE>, <EOT>
|
||||||
|
PAGE_TOP = gg
|
||||||
|
PAGE_BOTTOM = G
|
||||||
|
UPVOTE = a
|
||||||
|
DOWNVOTE = z
|
||||||
|
LOGIN = u
|
||||||
|
DELETE = d
|
||||||
|
EDIT = e
|
||||||
|
INBOX = i
|
||||||
|
REFRESH = r, <KEY_F5>
|
||||||
|
PROMPT = /
|
||||||
|
SAVE = w
|
||||||
|
COPY_PERMALINK = y
|
||||||
|
COPY_URL = Y
|
||||||
|
PRIVATE_MESSAGE = C
|
||||||
|
SUBSCRIPTIONS = s
|
||||||
|
MULTIREDDITS = S
|
||||||
|
|
||||||
|
; Submission page
|
||||||
|
SUBMISSION_TOGGLE_COMMENT = 0x20
|
||||||
|
SUBMISSION_OPEN_IN_BROWSER = o, <LF>, <KEY_ENTER>
|
||||||
|
SUBMISSION_POST = c
|
||||||
|
SUBMISSION_EXIT = h, <KEY_LEFT>
|
||||||
|
SUBMISSION_OPEN_IN_PAGER = l, <KEY_RIGHT>
|
||||||
|
SUBMISSION_OPEN_IN_URLVIEWER = b
|
||||||
|
SUBMISSION_GOTO_PARENT = K
|
||||||
|
SUBMISSION_GOTO_SIBLING = J
|
||||||
|
|
||||||
|
; Subreddit page
|
||||||
|
SUBREDDIT_SEARCH = f
|
||||||
|
SUBREDDIT_POST = c
|
||||||
|
SUBREDDIT_OPEN = l, <KEY_RIGHT>
|
||||||
|
SUBREDDIT_OPEN_IN_BROWSER = o, <LF>, <KEY_ENTER>
|
||||||
|
SUBREDDIT_FRONTPAGE = p
|
||||||
|
SUBREDDIT_HIDE = 0x20
|
||||||
|
|
||||||
|
; Subscription page
|
||||||
|
SUBSCRIPTION_SELECT = l, <LF>, <KEY_ENTER>, <KEY_RIGHT>
|
||||||
|
SUBSCRIPTION_EXIT = h, s, S, <ESC>, <KEY_LEFT>
|
||||||
|
|
||||||
|
; Inbox page
|
||||||
|
INBOX_VIEW_CONTEXT = l, <KEY_RIGHT>
|
||||||
|
INBOX_OPEN_SUBMISSION = o, <LF>, <KEY_ENTER>
|
||||||
|
INBOX_REPLY = c
|
||||||
|
INBOX_MARK_READ = w
|
||||||
|
INBOX_EXIT = h, <ESC>, <KEY_LEFT>
|
|
@ -0,0 +1,567 @@
|
||||||
|
# pylint: disable=bad-whitespace
|
||||||
|
|
||||||
|
import os
|
||||||
|
import codecs
|
||||||
|
import curses
|
||||||
|
import logging
|
||||||
|
from collections import OrderedDict
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
import six
|
||||||
|
from six.moves import configparser
|
||||||
|
|
||||||
|
from .config import THEMES, DEFAULT_THEMES
|
||||||
|
from .exceptions import ConfigError
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Theme(object):
|
||||||
|
|
||||||
|
ATTRIBUTE_CODES = {
|
||||||
|
'-': None,
|
||||||
|
'': None,
|
||||||
|
'normal': curses.A_NORMAL,
|
||||||
|
'bold': curses.A_BOLD,
|
||||||
|
'reverse': curses.A_REVERSE,
|
||||||
|
'underline': curses.A_UNDERLINE,
|
||||||
|
'standout': curses.A_STANDOUT
|
||||||
|
}
|
||||||
|
|
||||||
|
COLOR_CODES = {
|
||||||
|
'-': None,
|
||||||
|
'default': -1,
|
||||||
|
'black': curses.COLOR_BLACK,
|
||||||
|
'red': curses.COLOR_RED,
|
||||||
|
'green': curses.COLOR_GREEN,
|
||||||
|
'yellow': curses.COLOR_YELLOW,
|
||||||
|
'blue': curses.COLOR_BLUE,
|
||||||
|
'magenta': curses.COLOR_MAGENTA,
|
||||||
|
'cyan': curses.COLOR_CYAN,
|
||||||
|
'light_gray': curses.COLOR_WHITE,
|
||||||
|
'dark_gray': 8,
|
||||||
|
'bright_red': 9,
|
||||||
|
'bright_green': 10,
|
||||||
|
'bright_yellow': 11,
|
||||||
|
'bright_blue': 12,
|
||||||
|
'bright_magenta': 13,
|
||||||
|
'bright_cyan': 14,
|
||||||
|
'white': 15,
|
||||||
|
}
|
||||||
|
|
||||||
|
for i in range(256):
|
||||||
|
COLOR_CODES['ansi_{0}'.format(i)] = i
|
||||||
|
|
||||||
|
# For compatibility with as many terminals as possible, the default theme
|
||||||
|
# can only use the 8 basic colors with the default color as the background
|
||||||
|
DEFAULT_THEME = {
|
||||||
|
'modifiers': {
|
||||||
|
'Normal': (-1, -1, curses.A_NORMAL),
|
||||||
|
'Selected': (-1, -1, curses.A_NORMAL),
|
||||||
|
'SelectedCursor': (-1, -1, curses.A_REVERSE),
|
||||||
|
},
|
||||||
|
'page': {
|
||||||
|
'TitleBar': (curses.COLOR_CYAN, None, curses.A_BOLD | curses.A_REVERSE),
|
||||||
|
'OrderBar': (curses.COLOR_YELLOW, None, curses.A_BOLD),
|
||||||
|
'OrderBarHighlight': (curses.COLOR_YELLOW, None, curses.A_BOLD | curses.A_REVERSE),
|
||||||
|
'HelpBar': (curses.COLOR_CYAN, None, curses.A_BOLD | curses.A_REVERSE),
|
||||||
|
'Prompt': (curses.COLOR_CYAN, None, curses.A_BOLD | curses.A_REVERSE),
|
||||||
|
'NoticeInfo': (None, None, curses.A_BOLD),
|
||||||
|
'NoticeLoading': (None, None, curses.A_BOLD),
|
||||||
|
'NoticeError': (None, None, curses.A_BOLD),
|
||||||
|
'NoticeSuccess': (None, None, curses.A_BOLD),
|
||||||
|
},
|
||||||
|
# Fields that might be highlighted by the "SelectedCursor" element
|
||||||
|
'cursor': {
|
||||||
|
'CursorBlock': (None, None, None),
|
||||||
|
'CursorBar1': (curses.COLOR_MAGENTA, None, None),
|
||||||
|
'CursorBar2': (curses.COLOR_CYAN, None, None),
|
||||||
|
'CursorBar3': (curses.COLOR_GREEN, None, None),
|
||||||
|
'CursorBar4': (curses.COLOR_YELLOW, None, None),
|
||||||
|
},
|
||||||
|
# Fields that might be highlighted by the "Selected" element
|
||||||
|
'normal': {
|
||||||
|
'CommentAuthor': (curses.COLOR_BLUE, None, curses.A_BOLD),
|
||||||
|
'CommentAuthorSelf': (curses.COLOR_GREEN, None, curses.A_BOLD),
|
||||||
|
'CommentCount': (None, None, None),
|
||||||
|
'CommentText': (None, None, None),
|
||||||
|
'Created': (None, None, None),
|
||||||
|
'Downvote': (curses.COLOR_RED, None, curses.A_BOLD),
|
||||||
|
'Gold': (curses.COLOR_YELLOW, None, curses.A_BOLD),
|
||||||
|
'HiddenCommentExpand': (None, None, curses.A_BOLD),
|
||||||
|
'HiddenCommentText': (None, None, None),
|
||||||
|
'MultiredditName': (curses.COLOR_YELLOW, None, curses.A_BOLD),
|
||||||
|
'MultiredditText': (None, None, None),
|
||||||
|
'NeutralVote': (None, None, curses.A_BOLD),
|
||||||
|
'NSFW': (curses.COLOR_RED, None, curses.A_BOLD | curses.A_REVERSE),
|
||||||
|
'Saved': (curses.COLOR_GREEN, None, None),
|
||||||
|
'Hidden': (curses.COLOR_YELLOW, None, None),
|
||||||
|
'Score': (None, None, None),
|
||||||
|
'Separator': (None, None, curses.A_BOLD),
|
||||||
|
'Stickied': (curses.COLOR_GREEN, None, None),
|
||||||
|
'SubscriptionName': (curses.COLOR_YELLOW, None, curses.A_BOLD),
|
||||||
|
'SubscriptionText': (None, None, None),
|
||||||
|
'SubmissionAuthor': (curses.COLOR_GREEN, None, curses.A_BOLD),
|
||||||
|
'SubmissionFlair': (curses.COLOR_RED, None, None),
|
||||||
|
'SubmissionSubreddit': (curses.COLOR_YELLOW, None, None),
|
||||||
|
'SubmissionText': (None, None, None),
|
||||||
|
'SubmissionTitle': (None, None, curses.A_BOLD),
|
||||||
|
'SubmissionTitleSeen': (None, None, None),
|
||||||
|
'Upvote': (curses.COLOR_GREEN, None, curses.A_BOLD),
|
||||||
|
'Link': (curses.COLOR_BLUE, None, curses.A_UNDERLINE),
|
||||||
|
'LinkSeen': (curses.COLOR_MAGENTA, None, curses.A_UNDERLINE),
|
||||||
|
'UserFlair': (curses.COLOR_YELLOW, None, curses.A_BOLD),
|
||||||
|
'New': (curses.COLOR_RED, None, curses.A_BOLD),
|
||||||
|
'Distinguished': (curses.COLOR_RED, None, curses.A_BOLD),
|
||||||
|
'MessageSubject': (curses.COLOR_BLUE, None, curses.A_BOLD),
|
||||||
|
'MessageLink': (curses.COLOR_MAGENTA, None, curses.A_BOLD),
|
||||||
|
'MessageAuthor': (curses.COLOR_GREEN, None, curses.A_BOLD),
|
||||||
|
'MessageSubreddit': (curses.COLOR_YELLOW, None, None),
|
||||||
|
'MessageText': (None, None, None),
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_ELEMENTS = {k: v for group in DEFAULT_THEME.values()
|
||||||
|
for k, v in group.items()}
|
||||||
|
|
||||||
|
# The SubmissionPage uses this to determine which color bar to use
|
||||||
|
CURSOR_BARS = ['CursorBar1', 'CursorBar2', 'CursorBar3', 'CursorBar4']
|
||||||
|
|
||||||
|
def __init__(self, name=None, source=None, elements=None, use_color=True):
|
||||||
|
"""
|
||||||
|
Params:
|
||||||
|
name (str): A unique string that describes the theme
|
||||||
|
source (str): A string that describes the source of the theme:
|
||||||
|
built-in - Should only be used when Theme() is called directly
|
||||||
|
preset - Themes packaged with ttrv
|
||||||
|
installed - Themes in ~/.config/ttrv/themes/
|
||||||
|
custom - When a filepath is explicitly provided, e.g.
|
||||||
|
``ttrv --theme=/path/to/theme_file.cfg``
|
||||||
|
elements (dict): The theme's element map, should be in the same
|
||||||
|
format as Theme.DEFAULT_THEME.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if source not in (None, 'built-in', 'preset', 'installed', 'custom'):
|
||||||
|
raise ValueError('Invalid source')
|
||||||
|
|
||||||
|
if name is None and source is None:
|
||||||
|
name = 'default' if use_color else 'monochrome'
|
||||||
|
source = 'built-in'
|
||||||
|
elif name is None or source is None:
|
||||||
|
raise ValueError('Must specify both `name` and `source`, or neither one')
|
||||||
|
|
||||||
|
self.name = name
|
||||||
|
self.source = source
|
||||||
|
self.use_color = use_color
|
||||||
|
|
||||||
|
self._color_pair_map = None
|
||||||
|
self._attribute_map = None
|
||||||
|
self._selected = None
|
||||||
|
|
||||||
|
self.required_color_pairs = 0
|
||||||
|
self.required_colors = 0
|
||||||
|
|
||||||
|
if elements is None:
|
||||||
|
elements = self.DEFAULT_ELEMENTS.copy()
|
||||||
|
|
||||||
|
# Set any elements that weren't defined by the config to fallback to
|
||||||
|
# the default color and attributes
|
||||||
|
for key in self.DEFAULT_ELEMENTS.keys():
|
||||||
|
if key not in elements:
|
||||||
|
elements[key] = (None, None, None)
|
||||||
|
|
||||||
|
self._set_fallback(elements, 'Normal', (-1, -1, curses.A_NORMAL))
|
||||||
|
self._set_fallback(elements, 'Selected', 'Normal')
|
||||||
|
self._set_fallback(elements, 'SelectedCursor', 'Normal')
|
||||||
|
|
||||||
|
# Create the "Selected" versions of elements, which are prefixed with
|
||||||
|
# the @ symbol. For example, "@CommentText" represents how comment
|
||||||
|
# text is formatted when it is highlighted by the cursor.
|
||||||
|
for key in self.DEFAULT_THEME['normal']:
|
||||||
|
dest = '@{0}'.format(key)
|
||||||
|
self._set_fallback(elements, key, 'Selected', dest)
|
||||||
|
for key in self.DEFAULT_THEME['cursor']:
|
||||||
|
dest = '@{0}'.format(key)
|
||||||
|
self._set_fallback(elements, key, 'SelectedCursor', dest)
|
||||||
|
|
||||||
|
# Fill in the ``None`` values for all of the elements with normal text
|
||||||
|
for key in self.DEFAULT_THEME['normal']:
|
||||||
|
self._set_fallback(elements, key, 'Normal')
|
||||||
|
for key in self.DEFAULT_THEME['cursor']:
|
||||||
|
self._set_fallback(elements, key, 'Normal')
|
||||||
|
for key in self.DEFAULT_THEME['page']:
|
||||||
|
self._set_fallback(elements, key, 'Normal')
|
||||||
|
|
||||||
|
self.elements = elements
|
||||||
|
|
||||||
|
if self.use_color:
|
||||||
|
# Pre-calculate how many colors / color pairs the theme will need
|
||||||
|
colors, color_pairs = set(), set()
|
||||||
|
for fg, bg, _ in self.elements.values():
|
||||||
|
colors.add(fg)
|
||||||
|
colors.add(bg)
|
||||||
|
color_pairs.add((fg, bg))
|
||||||
|
|
||||||
|
# Don't count the default (-1, -1) as a color pair because it
|
||||||
|
# doesn't need to be initialized by curses.init_pair().
|
||||||
|
color_pairs.discard((-1, -1))
|
||||||
|
self.required_color_pairs = len(color_pairs)
|
||||||
|
|
||||||
|
# Determine how many colors the terminal needs to support in order
|
||||||
|
# to be able to use the theme. This uses the common breakpoints
|
||||||
|
# that 99% of terminals follow and doesn't take into account
|
||||||
|
# 88 color themes.
|
||||||
|
self.required_colors = None
|
||||||
|
for marker in [0, 8, 16, 256]:
|
||||||
|
if max(colors) < marker:
|
||||||
|
self.required_colors = marker
|
||||||
|
break
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_string(self):
|
||||||
|
return '{0} ({1})'.format(self.name, self.source)
|
||||||
|
|
||||||
|
def bind_curses(self):
|
||||||
|
"""
|
||||||
|
Bind the theme's colors to curses's internal color pair map.
|
||||||
|
|
||||||
|
This method must be called once (after curses has been initialized)
|
||||||
|
before any element attributes can be accessed. Color codes and other
|
||||||
|
special attributes will be mixed bitwise into a single value that
|
||||||
|
can be passed into curses draw functions.
|
||||||
|
"""
|
||||||
|
self._color_pair_map = {}
|
||||||
|
self._attribute_map = {}
|
||||||
|
|
||||||
|
for element, item in self.elements.items():
|
||||||
|
fg, bg, attrs = item
|
||||||
|
|
||||||
|
color_pair = (fg, bg)
|
||||||
|
if self.use_color and color_pair != (-1, -1):
|
||||||
|
# Curses limits the number of available color pairs, so we
|
||||||
|
# need to reuse them if there are multiple elements with the
|
||||||
|
# same foreground and background.
|
||||||
|
if color_pair not in self._color_pair_map:
|
||||||
|
# Index 0 is reserved by curses for the default color
|
||||||
|
index = len(self._color_pair_map) + 1
|
||||||
|
curses.init_pair(index, color_pair[0], color_pair[1])
|
||||||
|
self._color_pair_map[color_pair] = curses.color_pair(index)
|
||||||
|
attrs |= self._color_pair_map[color_pair]
|
||||||
|
|
||||||
|
self._attribute_map[element] = attrs
|
||||||
|
|
||||||
|
def get(self, element, selected=False):
|
||||||
|
"""
|
||||||
|
Returns the curses attribute code for the given element.
|
||||||
|
"""
|
||||||
|
if self._attribute_map is None:
|
||||||
|
raise RuntimeError('Attempted to access theme attribute before '
|
||||||
|
'calling initialize_curses_theme()')
|
||||||
|
|
||||||
|
if selected or self._selected:
|
||||||
|
element = '@{0}'.format(element)
|
||||||
|
|
||||||
|
return self._attribute_map[element]
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def turn_on_selected(self):
|
||||||
|
"""
|
||||||
|
Sets the selected modifier inside of context block.
|
||||||
|
|
||||||
|
For example:
|
||||||
|
>>> with theme.turn_on_selected():
|
||||||
|
>>> attr = theme.get('CursorBlock')
|
||||||
|
|
||||||
|
Is the same as:
|
||||||
|
>>> attr = theme.get('CursorBlock', selected=True)
|
||||||
|
|
||||||
|
Is also the same as:
|
||||||
|
>>> attr = theme.get('@CursorBlock')
|
||||||
|
|
||||||
|
"""
|
||||||
|
# This context manager should never be nested
|
||||||
|
assert self._selected is None
|
||||||
|
|
||||||
|
self._selected = True
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
self._selected = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def list_themes(cls, path=THEMES):
|
||||||
|
"""
|
||||||
|
Compile all of the themes configuration files in the search path.
|
||||||
|
"""
|
||||||
|
themes, errors = [], OrderedDict()
|
||||||
|
|
||||||
|
def load_themes(path, source):
|
||||||
|
"""
|
||||||
|
Load all themes in the given path.
|
||||||
|
"""
|
||||||
|
if os.path.isdir(path):
|
||||||
|
for filename in sorted(os.listdir(path)):
|
||||||
|
if not filename.endswith('.cfg'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
filepath = os.path.join(path, filename)
|
||||||
|
name = filename[:-4]
|
||||||
|
try:
|
||||||
|
# Make sure the theme is valid
|
||||||
|
theme = cls.from_file(filepath, source)
|
||||||
|
except Exception as e:
|
||||||
|
errors[(source, name)] = e
|
||||||
|
else:
|
||||||
|
themes.append(theme)
|
||||||
|
|
||||||
|
themes.extend([Theme(use_color=True), Theme(use_color=False)])
|
||||||
|
load_themes(DEFAULT_THEMES, 'preset')
|
||||||
|
load_themes(path, 'installed')
|
||||||
|
|
||||||
|
return themes, errors
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def print_themes(cls, path=THEMES):
|
||||||
|
"""
|
||||||
|
Prints a human-readable summary of the installed themes to stdout.
|
||||||
|
|
||||||
|
This is intended to be used as a command-line utility, outside of the
|
||||||
|
main curses display loop.
|
||||||
|
"""
|
||||||
|
themes, errors = cls.list_themes(path=path + '/')
|
||||||
|
|
||||||
|
print('\nInstalled ({0}):'.format(path))
|
||||||
|
installed = [t for t in themes if t.source == 'installed']
|
||||||
|
if installed:
|
||||||
|
for theme in installed:
|
||||||
|
line = ' {0:<20}[requires {1} colors]'
|
||||||
|
print(line.format(theme.name, theme.required_colors))
|
||||||
|
else:
|
||||||
|
print(' (empty)')
|
||||||
|
|
||||||
|
print('\nPresets:')
|
||||||
|
preset = [t for t in themes if t.source == 'preset']
|
||||||
|
for theme in preset:
|
||||||
|
line = ' {0:<20}[requires {1} colors]'
|
||||||
|
print(line.format(theme.name, theme.required_colors))
|
||||||
|
|
||||||
|
print('\nBuilt-in:')
|
||||||
|
built_in = [t for t in themes if t.source == 'built-in']
|
||||||
|
for theme in built_in:
|
||||||
|
line = ' {0:<20}[requires {1} colors]'
|
||||||
|
print(line.format(theme.name, theme.required_colors))
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
print('\nWARNING: Some files encountered errors:')
|
||||||
|
for (source, name), error in errors.items():
|
||||||
|
theme_info = '({0}) {1}'.format(source, name)
|
||||||
|
# Align multi-line error messages with the right column
|
||||||
|
err_message = six.text_type(error).replace('\n', '\n' + ' ' * 20)
|
||||||
|
print(' {0:<20}{1}'.format(theme_info, err_message))
|
||||||
|
|
||||||
|
print('')
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_name(cls, name, path=THEMES):
|
||||||
|
"""
|
||||||
|
Search for the given theme on the filesystem and attempt to load it.
|
||||||
|
|
||||||
|
Directories will be checked in a pre-determined order. If the name is
|
||||||
|
provided as an absolute file path, it will be loaded directly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if os.path.isfile(name):
|
||||||
|
return cls.from_file(name, 'custom')
|
||||||
|
|
||||||
|
filename = os.path.join(path, '{0}.cfg'.format(name))
|
||||||
|
if os.path.isfile(filename):
|
||||||
|
return cls.from_file(filename, 'installed')
|
||||||
|
|
||||||
|
filename = os.path.join(DEFAULT_THEMES, '{0}.cfg'.format(name))
|
||||||
|
if os.path.isfile(filename):
|
||||||
|
return cls.from_file(filename, 'preset')
|
||||||
|
|
||||||
|
raise ConfigError('Could not find theme named "{0}"'.format(name))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_file(cls, filename, source):
|
||||||
|
"""
|
||||||
|
Load a theme from the specified configuration file.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
filename: The name of the filename to load.
|
||||||
|
source: A description of where the theme was loaded from.
|
||||||
|
"""
|
||||||
|
_logger.info('Loading theme %s', filename)
|
||||||
|
|
||||||
|
try:
|
||||||
|
config = configparser.ConfigParser()
|
||||||
|
config.optionxform = six.text_type # Preserve case
|
||||||
|
with codecs.open(filename, encoding='utf-8') as fp:
|
||||||
|
config.readfp(fp)
|
||||||
|
except configparser.ParsingError as e:
|
||||||
|
raise ConfigError(e.message)
|
||||||
|
|
||||||
|
if not config.has_section('theme'):
|
||||||
|
raise ConfigError(
|
||||||
|
'Error loading {0}:\n'
|
||||||
|
' missing [theme] section'.format(filename))
|
||||||
|
|
||||||
|
theme_name = os.path.basename(filename)
|
||||||
|
theme_name, _ = os.path.splitext(theme_name)
|
||||||
|
|
||||||
|
elements = {}
|
||||||
|
for element, line in config.items('theme'):
|
||||||
|
if element not in cls.DEFAULT_ELEMENTS:
|
||||||
|
# Could happen if using a new config with an older version
|
||||||
|
# of the software
|
||||||
|
_logger.info('Skipping element %s', element)
|
||||||
|
continue
|
||||||
|
elements[element] = cls._parse_line(element, line, filename)
|
||||||
|
|
||||||
|
return cls(name=theme_name, source=source, elements=elements)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse_line(cls, element, line, filename=None):
|
||||||
|
"""
|
||||||
|
Parse a single line from a theme file.
|
||||||
|
|
||||||
|
Format:
|
||||||
|
<element>: <foreground> <background> <attributes>
|
||||||
|
"""
|
||||||
|
|
||||||
|
items = line.split()
|
||||||
|
if len(items) == 2:
|
||||||
|
fg, bg, attrs = items[0], items[1], ''
|
||||||
|
elif len(items) == 3:
|
||||||
|
fg, bg, attrs = items
|
||||||
|
else:
|
||||||
|
raise ConfigError(
|
||||||
|
'Error loading {0}, invalid line:\n'
|
||||||
|
' {1} = {2}'.format(filename, element, line))
|
||||||
|
|
||||||
|
if fg.startswith('#'):
|
||||||
|
fg = cls.rgb_to_ansi(fg)
|
||||||
|
if bg.startswith('#'):
|
||||||
|
bg = cls.rgb_to_ansi(bg)
|
||||||
|
|
||||||
|
if fg not in cls.COLOR_CODES:
|
||||||
|
raise ConfigError(
|
||||||
|
'Error loading {0}, invalid <foreground>:\n'
|
||||||
|
' {1} = {2}'.format(filename, element, line))
|
||||||
|
fg_code = cls.COLOR_CODES[fg]
|
||||||
|
|
||||||
|
if bg not in cls.COLOR_CODES:
|
||||||
|
raise ConfigError(
|
||||||
|
'Error loading {0}, invalid <background>:\n'
|
||||||
|
' {1} = {2}'.format(filename, element, line))
|
||||||
|
bg_code = cls.COLOR_CODES[bg]
|
||||||
|
|
||||||
|
attrs_code = curses.A_NORMAL
|
||||||
|
for attr in attrs.split('+'):
|
||||||
|
if attr not in cls.ATTRIBUTE_CODES:
|
||||||
|
raise ConfigError(
|
||||||
|
'Error loading {0}, invalid <attributes>:\n'
|
||||||
|
' {1} = {2}'.format(filename, element, line))
|
||||||
|
attr_code = cls.ATTRIBUTE_CODES[attr]
|
||||||
|
if attr_code is None:
|
||||||
|
attrs_code = None
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
attrs_code |= attr_code
|
||||||
|
|
||||||
|
return fg_code, bg_code, attrs_code
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _set_fallback(elements, src_field, fallback, dest_field=None):
|
||||||
|
"""
|
||||||
|
Helper function used to set the fallback attributes of an element when
|
||||||
|
they are defined by the configuration as "None" or "-".
|
||||||
|
"""
|
||||||
|
|
||||||
|
if dest_field is None:
|
||||||
|
dest_field = src_field
|
||||||
|
if isinstance(fallback, six.string_types):
|
||||||
|
fallback = elements[fallback]
|
||||||
|
|
||||||
|
attrs = elements[src_field]
|
||||||
|
elements[dest_field] = (
|
||||||
|
attrs[0] if attrs[0] is not None else fallback[0],
|
||||||
|
attrs[1] if attrs[1] is not None else fallback[1],
|
||||||
|
attrs[2] if attrs[2] is not None else fallback[2])
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def rgb_to_ansi(color):
|
||||||
|
"""
|
||||||
|
Converts hex RGB to the 6x6x6 xterm color space
|
||||||
|
|
||||||
|
Args:
|
||||||
|
color (str): RGB color string in the format "#RRGGBB"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: ansi color string in the format "ansi_n", where n
|
||||||
|
is between 16 and 230
|
||||||
|
|
||||||
|
Reference:
|
||||||
|
https://github.com/chadj2/bash-ui/blob/master/COLORS.md
|
||||||
|
"""
|
||||||
|
|
||||||
|
if color[0] != '#' or len(color) != 7:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
r = round(int(color[1:3], 16) / 51.0) # Normalize between 0-5
|
||||||
|
g = round(int(color[3:5], 16) / 51.0)
|
||||||
|
b = round(int(color[5:7], 16) / 51.0)
|
||||||
|
n = int(36 * r + 6 * g + b + 16)
|
||||||
|
return 'ansi_{0:d}'.format(n)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class ThemeList(object):
|
||||||
|
"""
|
||||||
|
This is a small container around Theme.list_themes() that can be used
|
||||||
|
to cycle through all of the available themes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.themes = None
|
||||||
|
self.errors = None
|
||||||
|
|
||||||
|
def reload(self):
|
||||||
|
"""
|
||||||
|
This acts as a lazy load, it won't read all of the theme files from
|
||||||
|
disk until the first time somebody tries to access the theme list.
|
||||||
|
"""
|
||||||
|
self.themes, self.errors = Theme.list_themes()
|
||||||
|
|
||||||
|
def _step(self, theme, direction):
|
||||||
|
"""
|
||||||
|
Traverse the list in the given direction and return the next theme
|
||||||
|
"""
|
||||||
|
if not self.themes:
|
||||||
|
self.reload()
|
||||||
|
|
||||||
|
# Try to find the starting index
|
||||||
|
key = (theme.source, theme.name)
|
||||||
|
for i, val in enumerate(self.themes):
|
||||||
|
if (val.source, val.name) == key:
|
||||||
|
index = i
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
# If the theme was set from a custom source it might
|
||||||
|
# not be a part of the list returned by list_themes().
|
||||||
|
self.themes.insert(0, theme)
|
||||||
|
index = 0
|
||||||
|
|
||||||
|
index = (index + direction) % len(self.themes)
|
||||||
|
new_theme = self.themes[index]
|
||||||
|
return new_theme
|
||||||
|
|
||||||
|
def next(self, theme):
|
||||||
|
return self._step(theme, 1)
|
||||||
|
|
||||||
|
def previous(self, theme):
|
||||||
|
return self._step(theme, -1)
|
|
@ -0,0 +1,70 @@
|
||||||
|
# Black ansi_235
|
||||||
|
# White ansi_253
|
||||||
|
|
||||||
|
# Sky Blue ansi_81
|
||||||
|
# Bluish Green ansi_36
|
||||||
|
# Yellow ansi_227
|
||||||
|
# Blue ansi_32
|
||||||
|
# Vermillion ansi_202
|
||||||
|
# Reddish Purple ansi_175
|
||||||
|
|
||||||
|
|
||||||
|
[theme]
|
||||||
|
;<element> = <foreground> <background> <attributes>
|
||||||
|
Normal = ansi_253 ansi_235 normal
|
||||||
|
Selected = - ansi_236 normal
|
||||||
|
SelectedCursor = - - reverse
|
||||||
|
|
||||||
|
TitleBar = ansi_81 - bold+reverse
|
||||||
|
OrderBar = ansi_227 - bold
|
||||||
|
OrderBarHighlight = ansi_227 - bold+reverse
|
||||||
|
HelpBar = ansi_81 - bold+reverse
|
||||||
|
Prompt = ansi_81 - bold+reverse
|
||||||
|
NoticeInfo = - - bold
|
||||||
|
NoticeLoading = - - bold
|
||||||
|
NoticeError = - - bold
|
||||||
|
NoticeSuccess = - - bold
|
||||||
|
|
||||||
|
CursorBlock = - - -
|
||||||
|
CursorBar1 = ansi_175 - -
|
||||||
|
CursorBar2 = ansi_81 - -
|
||||||
|
CursorBar3 = ansi_36 - -
|
||||||
|
CursorBar4 = ansi_227 - -
|
||||||
|
|
||||||
|
CommentAuthor = ansi_32 - bold
|
||||||
|
CommentAuthorSelf = ansi_36 - bold
|
||||||
|
CommentCount = - - -
|
||||||
|
CommentText = - - -
|
||||||
|
Created = - - -
|
||||||
|
Downvote = ansi_202 - bold
|
||||||
|
Gold = ansi_227 - bold
|
||||||
|
HiddenCommentExpand = - - bold
|
||||||
|
HiddenCommentText = - - -
|
||||||
|
MultiredditName = ansi_227 - bold
|
||||||
|
MultiredditText = - - -
|
||||||
|
NeutralVote = - - bold
|
||||||
|
NSFW = ansi_202 - bold+reverse
|
||||||
|
Saved = ansi_36 - -
|
||||||
|
Hidden = ansi_227 - -
|
||||||
|
Score = - - -
|
||||||
|
Separator = - - bold
|
||||||
|
Stickied = ansi_36 - -
|
||||||
|
SubscriptionName = ansi_227 - bold
|
||||||
|
SubscriptionText = - - -
|
||||||
|
SubmissionAuthor = ansi_36 - bold
|
||||||
|
SubmissionFlair = ansi_202 - -
|
||||||
|
SubmissionSubreddit = ansi_227 - -
|
||||||
|
SubmissionText = - - -
|
||||||
|
SubmissionTitle = - - bold
|
||||||
|
SubmissionTitleSeen = - - -
|
||||||
|
Upvote = ansi_36 - bold
|
||||||
|
Link = ansi_32 - underline
|
||||||
|
LinkSeen = ansi_175 - underline
|
||||||
|
UserFlair = ansi_227 - bold
|
||||||
|
New = ansi_227 - bold
|
||||||
|
Distinguished = ansi_202 - bold
|
||||||
|
MessageSubject = ansi_32 - bold
|
||||||
|
MessageLink = ansi_175 - bold
|
||||||
|
MessageAuthor = ansi_36 - bold
|
||||||
|
MessageSubreddit = ansi_227 - -
|
||||||
|
MessageText = - - -
|
|
@ -0,0 +1,59 @@
|
||||||
|
[theme]
|
||||||
|
;<element> = <foreground> <background> <attributes>
|
||||||
|
Normal = default default normal
|
||||||
|
Selected = default default normal
|
||||||
|
SelectedCursor = default default reverse
|
||||||
|
|
||||||
|
TitleBar = cyan - bold+reverse
|
||||||
|
OrderBar = yellow - bold
|
||||||
|
OrderBarHighlight = yellow - bold+reverse
|
||||||
|
HelpBar = cyan - bold+reverse
|
||||||
|
Prompt = cyan - bold+reverse
|
||||||
|
NoticeInfo = - - bold
|
||||||
|
NoticeLoading = - - bold
|
||||||
|
NoticeError = - - bold
|
||||||
|
NoticeSuccess = - - bold
|
||||||
|
|
||||||
|
CursorBlock = - - -
|
||||||
|
CursorBar1 = magenta - -
|
||||||
|
CursorBar2 = cyan - -
|
||||||
|
CursorBar3 = green - -
|
||||||
|
CursorBar4 = yellow - -
|
||||||
|
|
||||||
|
CommentAuthor = blue - bold
|
||||||
|
CommentAuthorSelf = green - bold
|
||||||
|
CommentCount = - - -
|
||||||
|
CommentText = - - -
|
||||||
|
Created = - - -
|
||||||
|
Downvote = red - bold
|
||||||
|
Gold = yellow - bold
|
||||||
|
HiddenCommentExpand = - - bold
|
||||||
|
HiddenCommentText = - - -
|
||||||
|
MultiredditName = yellow - bold
|
||||||
|
MultiredditText = - - -
|
||||||
|
NeutralVote = - - bold
|
||||||
|
NSFW = red - bold+reverse
|
||||||
|
Saved = green - -
|
||||||
|
Hidden = yellow - -
|
||||||
|
Score = - - -
|
||||||
|
Separator = - - bold
|
||||||
|
Stickied = green - -
|
||||||
|
SubscriptionName = yellow - bold
|
||||||
|
SubscriptionText = - - -
|
||||||
|
SubmissionAuthor = green - bold
|
||||||
|
SubmissionFlair = red - -
|
||||||
|
SubmissionSubreddit = yellow - -
|
||||||
|
SubmissionText = - - -
|
||||||
|
SubmissionTitle = - - bold
|
||||||
|
SubmissionTitleSeen = - - -
|
||||||
|
Upvote = green - bold
|
||||||
|
Link = blue - underline
|
||||||
|
LinkSeen = magenta - underline
|
||||||
|
UserFlair = yellow - bold
|
||||||
|
New = red - bold
|
||||||
|
Distinguished = red - bold
|
||||||
|
MessageSubject = blue - bold
|
||||||
|
MessageLink = magenta - bold
|
||||||
|
MessageAuthor = green - bold
|
||||||
|
MessageSubreddit = yellow - -
|
||||||
|
MessageText = - - -
|
|
@ -0,0 +1,82 @@
|
||||||
|
# https://github.com/tomasr/molokai
|
||||||
|
|
||||||
|
# normal ansi_252, ansi_234
|
||||||
|
# line number ansi_239, ansi_235
|
||||||
|
# cursor ansi_252, ansi_236
|
||||||
|
# pmenusel ansi_255, ansi_242
|
||||||
|
|
||||||
|
# text - normal ansi_252
|
||||||
|
# text - dim ansi_244
|
||||||
|
# text - ultra dim ansi_241
|
||||||
|
|
||||||
|
# purple ansi_141
|
||||||
|
# green ansi_154
|
||||||
|
# magenta ansi_199, ansi_16
|
||||||
|
# gold ansi_222, ansi_233
|
||||||
|
# red ansi_197
|
||||||
|
# red - dim ansi_203
|
||||||
|
# orange ansi_208
|
||||||
|
# blue ansi_81
|
||||||
|
# blue - dim ansi_67, ansi_16
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
[theme]
|
||||||
|
;<element> = <foreground> <background> <attributes>
|
||||||
|
Normal = ansi_252 ansi_234 normal
|
||||||
|
Selected = ansi_252 ansi_236 normal
|
||||||
|
SelectedCursor = ansi_252 ansi_234 bold+reverse
|
||||||
|
|
||||||
|
TitleBar = ansi_81 - bold+reverse
|
||||||
|
OrderBar = ansi_244 ansi_235 -
|
||||||
|
OrderBarHighlight = ansi_244 ansi_235 bold+reverse
|
||||||
|
HelpBar = ansi_81 - bold+reverse
|
||||||
|
Prompt = ansi_208 - bold+reverse
|
||||||
|
NoticeInfo = - - bold
|
||||||
|
NoticeLoading = - - bold
|
||||||
|
NoticeError = ansi_199 - bold
|
||||||
|
NoticeSuccess = ansi_154 - bold
|
||||||
|
|
||||||
|
CursorBlock = ansi_252 - -
|
||||||
|
CursorBar1 = ansi_141 - -
|
||||||
|
CursorBar2 = ansi_197 - -
|
||||||
|
CursorBar3 = ansi_154 - -
|
||||||
|
CursorBar4 = ansi_208 - -
|
||||||
|
|
||||||
|
CommentAuthor = ansi_81 - -
|
||||||
|
CommentAuthorSelf = ansi_154 - -
|
||||||
|
CommentCount = - - -
|
||||||
|
CommentText = - - -
|
||||||
|
Created = - - -
|
||||||
|
Downvote = ansi_197 - bold
|
||||||
|
Gold = ansi_222 - bold
|
||||||
|
HiddenCommentExpand = ansi_244 - bold
|
||||||
|
HiddenCommentText = ansi_244 - -
|
||||||
|
MultiredditName = - - bold
|
||||||
|
MultiredditText = ansi_244 - -
|
||||||
|
NeutralVote = - - bold
|
||||||
|
NSFW = ansi_197 - bold+reverse
|
||||||
|
Saved = ansi_199 - -
|
||||||
|
Hidden = ansi_208 - -
|
||||||
|
Score = - - bold
|
||||||
|
Separator = ansi_241 - bold
|
||||||
|
Stickied = ansi_208 - -
|
||||||
|
SubscriptionName = - - bold
|
||||||
|
SubscriptionText = ansi_244 - -
|
||||||
|
SubmissionAuthor = ansi_154 - -
|
||||||
|
SubmissionFlair = ansi_197 - -
|
||||||
|
SubmissionSubreddit = ansi_222 - -
|
||||||
|
SubmissionText = - - -
|
||||||
|
SubmissionTitle = - - bold
|
||||||
|
SubmissionTitleSeen = - - -
|
||||||
|
Upvote = ansi_154 - bold
|
||||||
|
Link = ansi_67 - underline
|
||||||
|
LinkSeen = ansi_141 - underline
|
||||||
|
UserFlair = ansi_222 - bold
|
||||||
|
New = ansi_208 - bold
|
||||||
|
Distinguished = ansi_197 - bold
|
||||||
|
MessageSubject = ansi_81 - bold
|
||||||
|
MessageLink = ansi_199 - bold
|
||||||
|
MessageAuthor = ansi_154 - bold
|
||||||
|
MessageSubreddit = ansi_222 - -
|
||||||
|
MessageText = - - -
|
|
@ -0,0 +1,80 @@
|
||||||
|
# https://github.com/NLKNguyen/papercolor-theme
|
||||||
|
|
||||||
|
# background ansi_255
|
||||||
|
# negative ansi_124
|
||||||
|
# positive ansi_28
|
||||||
|
# olive ansi_64
|
||||||
|
# neutral ansi_31
|
||||||
|
# comment ansi_102
|
||||||
|
# navy ansi_24
|
||||||
|
# foreground ansi_238
|
||||||
|
# nontext ansi_250
|
||||||
|
# red ansi_160
|
||||||
|
# pink ansi_162
|
||||||
|
# purple ansi_91
|
||||||
|
# accent ansi_166
|
||||||
|
# orange ansi_166
|
||||||
|
# blue ansi_25
|
||||||
|
# highlight ansi_24
|
||||||
|
# aqua ansi_31
|
||||||
|
# green ansi_28
|
||||||
|
|
||||||
|
[theme]
|
||||||
|
;<element> = <foreground> <background> <attributes>
|
||||||
|
Normal = ansi_238 ansi_255 normal
|
||||||
|
Selected = ansi_238 ansi_254 normal
|
||||||
|
SelectedCursor = ansi_238 ansi_255 bold+reverse
|
||||||
|
|
||||||
|
TitleBar = ansi_24 - bold+reverse
|
||||||
|
OrderBar = ansi_25 - bold
|
||||||
|
OrderBarHighlight = ansi_25 - bold+reverse
|
||||||
|
HelpBar = ansi_24 - bold+reverse
|
||||||
|
Prompt = ansi_31 - bold+reverse
|
||||||
|
NoticeInfo = ansi_238 ansi_252 bold
|
||||||
|
NoticeLoading = ansi_238 ansi_252 bold
|
||||||
|
NoticeError = ansi_124 ansi_225 bold
|
||||||
|
NoticeSuccess = ansi_28 ansi_157 bold
|
||||||
|
|
||||||
|
CursorBlock = ansi_102 - -
|
||||||
|
CursorBar1 = ansi_162 - -
|
||||||
|
CursorBar2 = ansi_166 - -
|
||||||
|
CursorBar3 = ansi_25 - -
|
||||||
|
CursorBar4 = ansi_91 - -
|
||||||
|
|
||||||
|
CommentAuthor = ansi_25 - bold
|
||||||
|
CommentAuthorSelf = ansi_64 - bold
|
||||||
|
CommentCount = - - -
|
||||||
|
CommentText = - - -
|
||||||
|
Created = - - -
|
||||||
|
Downvote = ansi_124 - bold
|
||||||
|
Gold = ansi_166 - bold
|
||||||
|
HiddenCommentExpand = ansi_102 - bold
|
||||||
|
HiddenCommentText = ansi_102 - -
|
||||||
|
MultiredditName = - - bold
|
||||||
|
MultiredditText = ansi_102 - -
|
||||||
|
NeutralVote = - - bold
|
||||||
|
NSFW = ansi_160 - bold+reverse
|
||||||
|
Saved = ansi_31 - bold
|
||||||
|
Hidden = ansi_166 - bold
|
||||||
|
Score = - - bold
|
||||||
|
Separator = - - bold
|
||||||
|
Stickied = ansi_166 - bold
|
||||||
|
SubscriptionName = - - bold
|
||||||
|
SubscriptionText = ansi_102 - -
|
||||||
|
SubmissionAuthor = ansi_64 - bold
|
||||||
|
SubmissionFlair = ansi_162 - bold
|
||||||
|
SubmissionSubreddit = ansi_166 - bold
|
||||||
|
SubmissionText = - - -
|
||||||
|
SubmissionTitle = - - bold
|
||||||
|
SubmissionTitleSeen = - - -
|
||||||
|
Upvote = ansi_28 - bold
|
||||||
|
Link = ansi_24 - underline
|
||||||
|
LinkSeen = ansi_91 - underline
|
||||||
|
UserFlair = ansi_162 - bold
|
||||||
|
New = ansi_162 - bold
|
||||||
|
Distinguished = ansi_160 - bold
|
||||||
|
MessageSubject = ansi_91 - bold
|
||||||
|
MessageLink = ansi_162 - bold
|
||||||
|
MessageAuthor = ansi_28 - bold
|
||||||
|
MessageSubreddit = ansi_166 - bold
|
||||||
|
MessageText = - - -
|
|
@ -0,0 +1,78 @@
|
||||||
|
# http://ethanschoonover.com/solarized
|
||||||
|
|
||||||
|
# base3 ansi_230
|
||||||
|
# base2 ansi_254
|
||||||
|
# base1 ansi_245 (optional emphasized content)
|
||||||
|
# base0 ansi_244 (body text / primary content)
|
||||||
|
# base00 ansi_241
|
||||||
|
# base01 ansi_240 (comments / secondary content)
|
||||||
|
# base02 ansi_235 (background highlights)
|
||||||
|
# base03 ansi_234 (background)
|
||||||
|
# yellow ansi_136
|
||||||
|
# orange ansi_166
|
||||||
|
# red ansi_160
|
||||||
|
# magenta ansi_125
|
||||||
|
# violet ansi_61
|
||||||
|
# blue ansi_33
|
||||||
|
# cyan ansi_37
|
||||||
|
# green ansi_64
|
||||||
|
|
||||||
|
[theme]
|
||||||
|
;<element> = <foreground> <background> <attributes>
|
||||||
|
Normal = ansi_244 ansi_234 normal
|
||||||
|
Selected = ansi_244 ansi_235 normal
|
||||||
|
SelectedCursor = ansi_244 ansi_235 bold+reverse
|
||||||
|
|
||||||
|
TitleBar = ansi_37 - bold+reverse
|
||||||
|
OrderBar = ansi_245 - bold
|
||||||
|
OrderBarHighlight = ansi_245 - bold+reverse
|
||||||
|
HelpBar = ansi_37 - bold+reverse
|
||||||
|
Prompt = ansi_33 - bold+reverse
|
||||||
|
NoticeInfo = - - bold
|
||||||
|
NoticeLoading = - - bold
|
||||||
|
NoticeError = ansi_160 - bold
|
||||||
|
NoticeSuccess = ansi_64 - bold
|
||||||
|
|
||||||
|
CursorBlock = ansi_240 - -
|
||||||
|
CursorBar1 = ansi_125 - -
|
||||||
|
CursorBar2 = ansi_160 - -
|
||||||
|
CursorBar3 = ansi_61 - -
|
||||||
|
CursorBar4 = ansi_37 - -
|
||||||
|
|
||||||
|
CommentAuthor = ansi_33 - bold
|
||||||
|
CommentAuthorSelf = ansi_64 - bold
|
||||||
|
CommentCount = - - -
|
||||||
|
CommentText = - - -
|
||||||
|
Created = - - -
|
||||||
|
Downvote = ansi_160 - bold
|
||||||
|
Gold = ansi_136 - bold
|
||||||
|
HiddenCommentExpand = ansi_240 - bold
|
||||||
|
HiddenCommentText = ansi_240 - -
|
||||||
|
MultiredditName = ansi_245 - bold
|
||||||
|
MultiredditText = ansi_240 - -
|
||||||
|
NeutralVote = - - bold
|
||||||
|
NSFW = ansi_160 - bold+reverse
|
||||||
|
Saved = ansi_125 - -
|
||||||
|
Hidden = ansi_136 - -
|
||||||
|
Score = - - -
|
||||||
|
Separator = - - bold
|
||||||
|
Stickied = ansi_136 - -
|
||||||
|
SubscriptionName = ansi_245 - bold
|
||||||
|
SubscriptionText = ansi_240 - -
|
||||||
|
SubmissionAuthor = ansi_64 - bold
|
||||||
|
SubmissionFlair = ansi_160 - -
|
||||||
|
SubmissionSubreddit = ansi_166 - -
|
||||||
|
SubmissionText = - - -
|
||||||
|
SubmissionTitle = ansi_245 - bold
|
||||||
|
SubmissionTitleSeen = - - -
|
||||||
|
Upvote = ansi_64 - bold
|
||||||
|
Link = ansi_33 - underline
|
||||||
|
LinkSeen = ansi_61 - underline
|
||||||
|
UserFlair = ansi_136 - bold
|
||||||
|
New = ansi_136 - bold
|
||||||
|
Distinguished = ansi_160 - bold
|
||||||
|
MessageSubject = ansi_37 - bold
|
||||||
|
MessageLink = ansi_125 - bold
|
||||||
|
MessageAuthor = ansi_64 - bold
|
||||||
|
MessageSubreddit = ansi_136 - -
|
||||||
|
MessageText = - - -
|
|
@ -0,0 +1,78 @@
|
||||||
|
# http://ethanschoonover.com/solarized
|
||||||
|
|
||||||
|
# base03 ansi_234
|
||||||
|
# base02 ansi_235
|
||||||
|
# base01 ansi_240 (optional emphasized content)
|
||||||
|
# base00 ansi_241 (body text / primary content)
|
||||||
|
# base0 ansi_244
|
||||||
|
# base1 ansi_245 (comments / secondary content)
|
||||||
|
# base2 ansi_254 (background highlights)
|
||||||
|
# base3 ansi_230 (background)
|
||||||
|
# yellow ansi_136
|
||||||
|
# orange ansi_166
|
||||||
|
# red ansi_160
|
||||||
|
# magenta ansi_125
|
||||||
|
# violet ansi_61
|
||||||
|
# blue ansi_33
|
||||||
|
# cyan ansi_37
|
||||||
|
# green ansi_64
|
||||||
|
|
||||||
|
[theme]
|
||||||
|
;<element> = <foreground> <background> <attributes>
|
||||||
|
Normal = ansi_241 ansi_230 normal
|
||||||
|
Selected = ansi_241 ansi_254 normal
|
||||||
|
SelectedCursor = ansi_241 ansi_254 bold+reverse
|
||||||
|
|
||||||
|
TitleBar = ansi_37 - bold+reverse
|
||||||
|
OrderBar = ansi_245 - bold
|
||||||
|
OrderBarHighlight = ansi_245 - bold+reverse
|
||||||
|
HelpBar = ansi_37 - bold+reverse
|
||||||
|
Prompt = ansi_33 - bold+reverse
|
||||||
|
NoticeInfo = - - bold
|
||||||
|
NoticeLoading = - - bold
|
||||||
|
NoticeError = ansi_160 - bold
|
||||||
|
NoticeSuccess = ansi_64 - bold
|
||||||
|
|
||||||
|
CursorBlock = ansi_245 - -
|
||||||
|
CursorBar1 = ansi_125 - -
|
||||||
|
CursorBar2 = ansi_160 - -
|
||||||
|
CursorBar3 = ansi_61 - -
|
||||||
|
CursorBar4 = ansi_37 - -
|
||||||
|
|
||||||
|
CommentAuthor = ansi_33 - bold
|
||||||
|
CommentAuthorSelf = ansi_64 - bold
|
||||||
|
CommentCount = - - -
|
||||||
|
CommentText = - - -
|
||||||
|
Created = - - -
|
||||||
|
Downvote = ansi_160 - bold
|
||||||
|
Gold = ansi_136 - bold
|
||||||
|
HiddenCommentExpand = ansi_245 - bold
|
||||||
|
HiddenCommentText = ansi_245 - -
|
||||||
|
MultiredditName = ansi_240 - bold
|
||||||
|
MultiredditText = ansi_245 - -
|
||||||
|
NeutralVote = - - bold
|
||||||
|
NSFW = ansi_160 - bold+reverse
|
||||||
|
Saved = ansi_125 - bold
|
||||||
|
Hidden = ansi_136 - bold
|
||||||
|
Score = - - -
|
||||||
|
Separator = - - bold
|
||||||
|
Stickied = ansi_136 - bold
|
||||||
|
SubscriptionName = ansi_240 - bold
|
||||||
|
SubscriptionText = ansi_245 - -
|
||||||
|
SubmissionAuthor = ansi_64 - bold
|
||||||
|
SubmissionFlair = ansi_160 - bold
|
||||||
|
SubmissionSubreddit = ansi_166 - bold
|
||||||
|
SubmissionText = - - -
|
||||||
|
SubmissionTitle = ansi_240 - bold
|
||||||
|
SubmissionTitleSeen = - - -
|
||||||
|
Upvote = ansi_64 - bold
|
||||||
|
Link = ansi_33 - underline
|
||||||
|
LinkSeen = ansi_61 - underline
|
||||||
|
UserFlair = ansi_136 - bold
|
||||||
|
New = ansi_227 - bold
|
||||||
|
Distinguished = ansi_202 - bold
|
||||||
|
MessageSubject = ansi_32 - bold
|
||||||
|
MessageLink = ansi_175 - bold
|
||||||
|
MessageAuthor = ansi_36 - bold
|
||||||
|
MessageSubreddit = ansi_166 - -
|
||||||
|
MessageText = - - -
|
|
@ -0,0 +1 @@
|
||||||
|
ttrv/__version__.py
|