# -*- coding: utf-8 -*- from __future__ import unicode_literals import re from datetime import datetime import six import praw from kitchen.text.display import wrap from . import exceptions class Content(object): def get(self, index, n_cols): raise NotImplementedError def iterate(self, index, step, n_cols=70): while True: if step < 0 and index < 0: # Hack to prevent displaying a submission's post if iterating # comments in the negative direction break try: yield self.get(index, n_cols=n_cols) except IndexError: break index += step @staticmethod def flatten_comments(comments, root_level=0): """ Flatten a PRAW comment tree while preserving the nested level of each comment via the `nested_level` attribute. """ stack = comments[:] for item in stack: item.nested_level = root_level retval = [] while stack: item = stack.pop(0) if isinstance(item, praw.objects.MoreComments): if item.count == 0: # MoreComments item count should never be zero, but if it # is then discard the MoreComment object. Need to look into # this further. continue else: if item._replies is None: # Attach children MoreComment replies to parents # https://github.com/praw-dev/praw/issues/391 item._replies = [stack.pop(0)] nested = getattr(item, 'replies', None) if nested: for n in nested: n.nested_level = item.nested_level + 1 stack[0:0] = nested retval.append(item) return retval @classmethod def strip_praw_comment(cls, comment): """ Parse through a submission comment and return a dict with data ready to be displayed through the terminal. """ data = {} data['object'] = comment data['level'] = comment.nested_level if isinstance(comment, praw.objects.MoreComments): data['type'] = 'MoreComments' data['count'] = comment.count data['body'] = 'More comments' else: author = getattr(comment, 'author', '[deleted]') name = getattr(author, 'name', '[deleted]') sub = getattr(comment, 'submission', '[deleted]') sub_author = getattr(sub, 'author', '[deleted]') sub_name = getattr(sub_author, 'name', '[deleted]') flair = getattr(comment, 'author_flair_text', '') permalink = getattr(comment, 'permalink', None) data['type'] = 'Comment' data['body'] = comment.body data['created'] = cls.humanize_timestamp(comment.created_utc) data['score'] = '{0} pts'.format(comment.score) data['author'] = name data['is_author'] = (name == sub_name) data['flair'] = flair data['likes'] = comment.likes data['gold'] = comment.gilded > 0 data['permalink'] = permalink return data @classmethod def strip_praw_submission(cls, sub): """ Parse through a submission and return a dict with data ready to be displayed through the terminal. Definitions: permalink - Full URL to the submission comments. url_full - Link that the submission points to. url - URL that is displayed on the subreddit page, may be "selfpost" or "x-post" or a link. """ reddit_link = re.compile( r'https?://(www\.)?(np\.)?redd(it\.com|\.it)/r/.*') author = getattr(sub, 'author', '[deleted]') name = getattr(author, 'name', '[deleted]') flair = getattr(sub, 'link_flair_text', '') data = {} data['object'] = sub data['type'] = 'Submission' data['title'] = sub.title data['text'] = sub.selftext data['created'] = cls.humanize_timestamp(sub.created_utc) data['comments'] = '{0} comments'.format(sub.num_comments) data['score'] = '{0} pts'.format(sub.score) data['author'] = name data['permalink'] = sub.permalink data['subreddit'] = six.text_type(sub.subreddit) data['flair'] = flair data['url_full'] = sub.url data['likes'] = sub.likes data['gold'] = sub.gilded > 0 data['nsfw'] = sub.over_18 data['index'] = None # This is filled in later by the method caller if data['flair'] and not data['flair'].startswith('['): data['flair'] = u'[{0}]'.format(data['flair'].strip()) url_full = data['url_full'] if data['permalink'].split('/r/')[-1] == url_full.split('/r/')[-1]: data['url_type'] = 'selfpost' data['url'] = 'self.{0}'.format(data['subreddit']) elif reddit_link.match(url_full): data['url_type'] = 'x-post' # Strip the subreddit name from the permalink to avoid having # submission.subreddit.url make a separate API call data['url'] = 'self.{0}'.format(url_full.split('/')[4]) else: data['url_type'] = 'external' data['url'] = url_full return data @staticmethod def strip_praw_subscription(subscription): """ Parse through a subscription and return a dict with data ready to be displayed through the terminal. """ data = {} data['object'] = subscription data['type'] = 'Subscription' data['name'] = "/r/" + subscription.display_name data['title'] = subscription.title return data @staticmethod def humanize_timestamp(utc_timestamp, verbose=False): """ Convert a utc timestamp into a human readable relative-time. """ timedelta = datetime.utcnow() - datetime.utcfromtimestamp(utc_timestamp) seconds = int(timedelta.total_seconds()) if seconds < 60: return 'moments ago' if verbose else '0min' minutes = seconds // 60 if minutes < 60: return '%d minutes ago' % minutes if verbose else '%dmin' % minutes hours = minutes // 60 if hours < 24: return '%d hours ago' % hours if verbose else '%dhr' % hours days = hours // 24 if days < 30: return '%d days ago' % days if verbose else '%dday' % days months = days // 30.4 if months < 12: return '%d months ago' % months if verbose else '%dmonth' % months years = months // 12 return '%d years ago' % years if verbose else '%dyr' % years @staticmethod def wrap_text(text, width): """ Wrap text paragraphs to the given character width while preserving newlines. """ out = [] for paragraph in text.splitlines(): # Wrap returns an empty list when paragraph is a newline. In order # to preserve newlines we substitute a list containing an empty # string. lines = wrap(paragraph, width=width) or [''] out.extend(lines) return out class SubmissionContent(Content): """ Grab a submission from PRAW and lazily store comments to an internal list for repeat access. """ def __init__(self, submission, loader, indent_size=2, max_indent_level=8, order=None): submission_data = self.strip_praw_submission(submission) comments = self.flatten_comments(submission.comments) self.indent_size = indent_size self.max_indent_level = max_indent_level self.name = submission_data['permalink'] self.order = order self._loader = loader self._submission = submission self._submission_data = submission_data self._comment_data = [self.strip_praw_comment(c) for c in comments] @classmethod def from_url(cls, reddit, url, loader, indent_size=2, max_indent_level=8, order=None): url = url.replace('http:', 'https:') submission = reddit.get_submission(url, comment_sort=order) return cls(submission, loader, indent_size, max_indent_level, order) def get(self, index, n_cols=70): """ Grab the `i`th submission, with the title field formatted to fit inside of a window of width `n` """ if index < -1: raise IndexError elif index == -1: data = self._submission_data data['split_title'] = self.wrap_text(data['title'], width=n_cols-2) data['split_text'] = self.wrap_text(data['text'], width=n_cols-2) data['n_rows'] = len(data['split_title'] + data['split_text']) + 5 data['offset'] = 0 else: data = self._comment_data[index] indent_level = min(data['level'], self.max_indent_level) data['offset'] = indent_level * self.indent_size if data['type'] == 'Comment': width = n_cols - data['offset'] data['split_body'] = self.wrap_text(data['body'], width=width) data['n_rows'] = len(data['split_body']) + 1 else: data['n_rows'] = 1 return data def toggle(self, index, n_cols=70): """ Toggle the state of the object at the given index. If it is a comment, pack it into a hidden comment. If it is a hidden comment, unpack it. If it is more comments, load the comments. """ data = self.get(index) if data['type'] == 'Submission': # Can't hide the submission! pass elif data['type'] == 'Comment': cache = [data] count = 1 for d in self.iterate(index + 1, 1, n_cols): if d['level'] <= data['level']: break count += d.get('count', 1) cache.append(d) comment = {} comment['type'] = 'HiddenComment' comment['cache'] = cache comment['count'] = count comment['level'] = data['level'] comment['body'] = 'Hidden' self._comment_data[index:index + len(cache)] = [comment] elif data['type'] == 'HiddenComment': self._comment_data[index:index + 1] = data['cache'] elif data['type'] == 'MoreComments': with self._loader(): # Undefined behavior if using a nested loader here assert self._loader.depth == 1 comments = data['object'].comments(update=True) if not self._loader.exception: comments = self.flatten_comments(comments, data['level']) comment_data = [self.strip_praw_comment(c) for c in comments] self._comment_data[index:index + 1] = comment_data else: raise ValueError('%s type not recognized' % data['type']) class SubredditContent(Content): """ Grab a subreddit from PRAW and lazily stores submissions to an internal list for repeat access. """ def __init__(self, name, submissions, loader, order=None): self.name = name self.order = order self._loader = loader self._submissions = submissions self._submission_data = [] # Verify that content exists for the given submission generator. # This is necessary because PRAW loads submissions lazily, and # there is is no other way to check things like multireddits that # don't have a real corresponding subreddit object. try: self.get(0) except IndexError: raise exceptions.SubredditError('No submissions') @classmethod def from_name(cls, reddit, name, loader, order=None, query=None): # Strip leading and trailing backslashes name = name.strip(' /') if name.startswith('r/'): name = name[2:] # If the order is not given explicitly, it will be searched for and # stripped out of the subreddit name e.g. python/new. if '/' in name: name, name_order = name.split('/') order = order or name_order display_name = '/r/{0}'.format(name) if order not in ['hot', 'top', 'rising', 'new', 'controversial', None]: raise exceptions.SubredditError('Unrecognized order "%s"' % order) if name == 'me': if not reddit.is_oauth_session(): raise exceptions.AccountError('Not logged in') elif order: submissions = reddit.user.get_submitted(sort=order) else: submissions = reddit.user.get_submitted() elif query: if name == 'front': submissions = reddit.search(query, subreddit=None, sort=order) else: submissions = reddit.search(query, subreddit=name, sort=order) else: if name == 'front': dispatch = { None: reddit.get_front_page, 'hot': reddit.get_front_page, 'top': reddit.get_top, 'rising': reddit.get_rising, 'new': reddit.get_new, 'controversial': reddit.get_controversial, } else: subreddit = reddit.get_subreddit(name) dispatch = { None: subreddit.get_hot, 'hot': subreddit.get_hot, 'top': subreddit.get_top, 'rising': subreddit.get_rising, 'new': subreddit.get_new, 'controversial': subreddit.get_controversial, } submissions = dispatch[order](limit=None) return cls(display_name, submissions, loader, order=order) def get(self, index, n_cols=70): """ Grab the `i`th submission, with the title field formatted to fit inside of a window of width `n_cols` """ if index < 0: raise IndexError while index >= len(self._submission_data): try: with self._loader(): submission = next(self._submissions) if self._loader.exception: raise IndexError except StopIteration: raise IndexError else: data = self.strip_praw_submission(submission) data['index'] = index # Add the post number to the beginning of the title data['title'] = '{0}. {1}'.format(index+1, data['title']) self._submission_data.append(data) # Modifies the original dict, faster than copying data = self._submission_data[index] data['split_title'] = self.wrap_text(data['title'], width=n_cols) data['n_rows'] = len(data['split_title']) + 3 data['offset'] = 0 return data class SubscriptionContent(Content): def __init__(self, subscriptions, loader): self.name = "Subscriptions" self.order = None self._loader = loader self._subscriptions = subscriptions self._subscription_data = [] try: self.get(0) except IndexError: raise exceptions.SubscriptionError('No subscriptions') @classmethod def from_user(cls, reddit, loader): subscriptions = reddit.get_my_subreddits(limit=None) return cls(subscriptions, loader) def get(self, index, n_cols=70): """ Grab the `i`th subscription, with the title field formatted to fit inside of a window of width `n_cols` """ if index < 0: raise IndexError while index >= len(self._subscription_data): try: with self._loader(): subscription = next(self._subscriptions) if self._loader.exception: raise IndexError except StopIteration: raise IndexError else: data = self.strip_praw_subscription(subscription) self._subscription_data.append(data) data = self._subscription_data[index] data['split_title'] = self.wrap_text(data['title'], width=n_cols) data['n_rows'] = len(data['split_title']) + 1 data['offset'] = 0 return data