diff --git a/CHANGELOG.md b/CHANGELOG.md index c1e8f63..7580234 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,27 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). ### Release -### [0.3.2] - TBD +### [0.3.3] - 2018-02-17 + +#### Added +- List support for servers that support it. (See ``help list`` for more details.) +- Bumped to Mastodon.py 1.2.2 + +#### Added (awaiting proper config) +( The following items are active but require a re-working of the configuration file to make active. Currently they are flags inside the ``toot_parser.py`` file. Intrepid explorers may find them.) +- Added emoji shortcode (defaults to "off"). +- Added emoji "demoji" to show shortcodes for emoji (defaults to off). + +#### Fixed +- Fixed boosting private toots +- Fixed message for boosting toots +- Fixed leading / trailing whitespace from media filepath +- Added better exception handling around streaming API + +#### + +### Release +### [0.3.2] - 2017-12-23 #### Added - Reworked the Tootstream Parser to add styling, link-shortening, link retrieval, and emoji code shortening diff --git a/README.md b/README.md index fe61589..c58cb05 100644 --- a/README.md +++ b/README.md @@ -56,11 +56,23 @@ $ python3 setup.py install $ source /path/to/tootstream/bin/activate ``` -2: Run the script +2: Run the program ``` $ tootstream ``` -3: Close the environment with `$ deactivate` + +3: Use the ``help`` command to see the available commands +``` +[@myusername (default)]: help +``` + +4: Exit the program when finished +``` +[@myusername (default)]: quit + +``` + +5: Close the environment with `$ deactivate` ## Ubuntu and Unicode @@ -70,6 +82,26 @@ Tootstream relies heavily on Unicode fonts. The best experience can be had by in $ sudo apt-get install ttf-ancient-fonts ``` +## Configuration + +By default tootstream uses [configparser](https://docs.python.org/3/library/configparser.html) for configuration. The default configuration is stored in the default location for configparser (on the developer's machine this is under /home/myusername/.config/tootstream/tootstream.conf). + +At the moment tootstream only stores login information for each instance in the configuration file. Each instance is under its own section (the default configuration is under the ``[default]`` section). Multiple instances can be stored in the ``tootstream.conf`` file. (See "Using multiple instances") + +## Using multiple instances + +Tootstream supports using accounts on multiple Mastodon instances. + +Use the ``--instance`` parameter to pass the server location (in the case of Mastodon.social we'd use ``--instance mastodon.social``). + +Use the ``--profile`` parameter to use a different named profile. (in the case of Mastodon.social we could call it ``mastodon.social`` and name the section using ``--profile mastodon.social``). + +By default tootstream uses the ``[default]`` profile. If this already has an instance associated with it then tootstream will default to using that instance. + +If you have already set up a profile you may use the ``--profile`` command-line switch to start tootstream with it. The ``--instance`` parameter is optional (and redundant). + +You may select a different configuration using ``--config`` and pass it the full-path to that file. + ## Contributing Contributions welcome! Please read the [contributing guidelines](CONTRIBUTING.md) before getting started. diff --git a/requirements.txt b/requirements.txt index 5ab4b94..06287b4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ click>=6.7 -Mastodon.py==1.2.1 +Mastodon.py==1.2.2 colored>=1.3.5 humanize>=0.5.1 emoji>=0.4.5 diff --git a/setup.py b/setup.py index 8616f02..a6e3df1 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,7 @@ from setuptools import setup, find_packages setup( name="tootstream", - version="0.3.2", + version="0.3.3", python_requires=">=3", install_requires=[line.strip() for line in open('requirements.txt')], diff --git a/src/tootstream/toot.py b/src/tootstream/toot.py index 859ce0f..e184b48 100644 --- a/src/tootstream/toot.py +++ b/src/tootstream/toot.py @@ -15,11 +15,15 @@ import datetime import dateutil import shutil +import emoji # Get the version of Tootstream import pkg_resources # part of setuptools version = pkg_resources.require("tootstream")[0].version +# placeholder variable for converting enoji to shortcodes until we get it in config +convert_emoji_to_shortcode = False + #Looks best with black background. #TODO: Set color list in config file COLORS = list(range(19,231)) @@ -86,13 +90,27 @@ def on_update(self, status): # Get the current width of the terminal terminal_size = shutil.get_terminal_size((80, 20)) -toot_parser = TootParser(indent=' ', width=int(terminal_size.columns) - 2) +toot_parser = TootParser( + indent=' ', + width=int(terminal_size.columns) - 2, + convert_emoji_to_unicode=False, + convert_emoji_to_shortcode=convert_emoji_to_shortcode) toot_listener = TootListener() + ##################################### ######## UTILITY FUNCTIONS ######## ##################################### + +def list_support(mastodon, silent=False): + lists_available = mastodon.verify_minimum_version("2.1.0") + if lists_available is False and silent is False: + cprint("List support is not available with this version of Mastodon", + fg('red')) + return lists_available + + def get_content(toot): html = toot['content'] toot_parser.parse(html) @@ -136,6 +154,27 @@ def get_userid(mastodon, rest): else: return users[0]['id'] + +def get_list_id(mastodon, rest): + """Get the ID for a list""" + if not rest: + return -1 + + # maybe it's already an int + try: + return int(rest) + except ValueError: + pass + + rest = rest.strip() + + lists = mastodon.lists() + for item in lists: + if item['title'].lower() == rest.lower(): + return item['id'] + + + def flaghandler_note(mastodon, rest): """Parse input for flagsr. """ @@ -250,7 +289,7 @@ def flaghandler_tootreply(mastodon, rest): break # expand paths and check file access - fname = os.path.expanduser(fname) + fname = os.path.expanduser(fname).strip() if os.path.isfile(fname) and os.access(fname, os.R_OK): media.append(fname) count += 1 @@ -475,8 +514,8 @@ def cprint(text, style, end="\n"): def format_username(user): """Get a user's account name including lock indicator.""" - return ''.join(( "@", user['acct'], - (" {}".format(GLYPHS['locked']) if user['locked'] else "") )) + return ''.join(("@", user['acct'], + (" {}".format(GLYPHS['locked']) if user['locked'] else ""))) def format_user_counts(user): @@ -487,12 +526,20 @@ def format_user_counts(user): countfmt.format(GLYPHS['followed_by'], user['followers_count']) )) +def format_display_name(name): + if convert_emoji_to_shortcode: + name = emoji.demojize(name) + return name + return name + + def printUser(user): """Prints user data nicely with hardcoded colors.""" counts = stylize(format_user_counts(user), fg('blue')) print(format_username(user) + " " + counts) - cprint(user['display_name'], fg('cyan')) + display_name = format_display_name(user['display_name']) + cprint(display_name, fg('cyan')) print(user['url']) cprint(re.sub('<[^<]+?>', '', user['note']), fg('red')) @@ -501,7 +548,8 @@ def printUsersShort(users): for user in users: if not user: continue userid = "(id:"+str(user['id'])+")" - userdisp = "'"+str(user['display_name'])+"'" + display_name = format_display_name(user['display_name']) + userdisp = "'"+str(display_name)+"'" userurl = str(user['url']) cprint(" "+format_username(user), fg('green'), end=" ") cprint(" "+userid, fg('red'), end=" ") @@ -531,7 +579,8 @@ def format_toot_nameline(toot, dnamestyle): if not toot: return '' formatted_time = format_time(toot['created_at']) - out = [stylize(toot['account']['display_name'], dnamestyle), + display_name = format_display_name(toot['account']['display_name']) + out = [stylize(display_name, dnamestyle), stylize(format_username(toot['account']), fg('green')), stylize(formatted_time, attr('dim'))] return ' '.join(out) @@ -569,7 +618,8 @@ def printToot(toot): # then get other data from toot['reblog'] if toot['reblog']: header = stylize(" Boosted by ", fg('yellow')) - name = " ".join(( toot['account']['display_name'], + display_name = format_display_name(toot['account']['display_name']) + name = " ".join(( display_name, format_username(toot['account'])+":" )) out.append(header + stylize(name, fg('blue'))) toot = toot['reblog'] @@ -602,6 +652,12 @@ def edittoot(text): return '' +def printList(list_item): + """Prints list entry nicely with hardcoded colors.""" + cprint(list_item['title'], fg('cyan'), end=" ") + cprint("(id: %s)" % list_item['id'], fg('red')) + + ##################################### ######## DECORATORS ######## ##################################### @@ -869,10 +925,15 @@ def boost(mastodon, rest): rest = IDS.to_global(rest) if rest is None: return - mastodon.status_reblog(rest) - boosted = mastodon.status(rest) - msg = " You boosted: ", fg('white') + get_content(boosted) - cprint(msg, fg('green')) + try: + mastodon.status_reblog(rest) + boosted = mastodon.status(rest) + msg = " You boosted: " + fg('white') + get_content(boosted) + cprint(msg, fg('green')) + except Exception as e: + cprint("Received error: ", fg('red') + attr('bold'), end="") + cprint(e, fg('magenta') + attr('bold') + attr('underlined')) + boost.__argstr__ = '' boost.__section__ = 'Toots' @@ -1041,7 +1102,9 @@ def local(mastodon, rest): @command def stream(mastodon, rest): - """Streams a timeline. Specify home, fed, local, or a #hashtagname. + """Streams a timeline. Specify home, fed, local, list, or a #hashtagname. + +Timeline 'list' requires a list name (ex: stream list listname). Use ctrl+C to end streaming""" print("Use ctrl+C to end streaming") @@ -1052,13 +1115,22 @@ def stream(mastodon, rest): mastodon.stream_public(toot_listener) elif rest == "local": mastodon.stream_local(toot_listener) + elif rest.startswith('list'): + items = rest.split(' ') + if len(items) < 2: + print("list stream must have a list ID.") + return + item = get_list_id(mastodon, items[-1]) + mastodon.stream_list(item, toot_listener) elif rest.startswith('#'): tag = rest[1:] mastodon.stream_hashtag(tag, toot_listener) else: - print("Only 'home', 'fed', 'local', and '#hashtag' streams are supported.") + print("Only 'home', 'fed', 'local', 'list', and '#hashtag' streams are supported.") except KeyboardInterrupt: pass + except Exception as e: + cprint("Something went wrong: {}".format(e), fg('red')) stream.__argstr__ = '' stream.__section__ = 'Timeline' @@ -1090,7 +1162,7 @@ def note(mastodon, rest): return for note in reversed(mastodon.notifications()): - display_name = " " + note['account']['display_name'] + display_name = " " + format_display_name(note['account']['display_name']) username = format_username(note['account']) note_id = str(note['id']) @@ -1570,6 +1642,205 @@ def quit(mastodon, rest): quit.__section__ = 'Profile' +@command +def lists(mastodon, rest): + """Shows the lists that the user has created.""" + if not(list_support(mastodon)): + return + user_lists = mastodon.lists() + if len(user_lists) == 0: + cprint("No lists found", fg('red')) + return + for list_item in user_lists: + printList(list_item) +lists.__argstr__ = '' +lists.__section__ = 'List' + + +@command +def listcreate(mastodon, rest): + """Creates a list.""" + if not(list_support(mastodon)): + return + try: + mastodon.list_create(rest) + cprint("List {} created.".format(rest), fg('green')) + except Exception as e: + cprint("error while creating list: {}".format(type(e).__name__), fg('red')) + return +listcreate.__argstr__ = '' +listcreate.__section__ = 'List' + + +@command +def listrename(mastodon, rest): + """Rename a list. + ex: listrename oldlist newlist""" + if not(list_supportmastodon()): + return + rest = rest.strip() + if not rest: + cprint("Argument required.", fg('red')) + return + items = rest.split(' ') + if len(items) < 2: + cprint("Not enough arguments.", fg('red')) + return + + list_id = get_list_id(mastodon, items[0]) + updated_name = items[1] + + if not list_id: + cprint("List {} is not found".format(items[0]), fg('red')) + return + + try: + mastodon.list_update(list_id, updated_name) + cprint("Renamed {} to {}.".format(items[1], items[0]), fg('green')) + except Exception as e: + cprint("error while updating list: {}".format(type(e).__name__), fg('red')) +listrename.__argstr__ = ' ' +listrename.__section__ = 'List' + + +@command +def listdestroy(mastodon, rest): + """Destroys a list. + ex: listdestroy listname + listdestroy 23""" + if not(list_support(mastodon)): + return + item = get_list_id(mastodon, rest) + if not item or item == -1: + cprint("List {} is not found".format(rest), fg('red')) + return + try: + mastodon.list_delete(item) + cprint("List {} deleted.".format(rest), fg('green')) + + except Exception as e: + cprint("error while creating list: {}".format(type(e).__name__), fg('red')) + return +listdestroy.__argstr__ = '' +listdestroy.__section__ = 'List' + + +@command +def listhome(mastodon, rest): + """Show the toots from a list. + ex: listhome listname + listhome 23""" + if not(list_support(mastodon)): + return + if not rest: + cprint("Argument required.", fg('red')) + return + + try: + item = get_list_id(mastodon, rest) + if not item or item == -1: + cprint("List {} is not found".format(rest), fg('red')) + return + list_toots = mastodon.timeline_list(item) + for toot in reversed(list_toots): + printToot(toot) + completion_add(toot) + except Exception as e: + cprint("error while displaying list: {}".format(type(e).__name__), fg('red')) +listhome.__argstr__ = '' +listhome.__section__ = 'List' + + +@command +def listaccounts(mastodon, rest): + """Show the accounts for the list. + ex: listaccounts listname + listaccounts 23""" + if not(list_support(mastodon)): + return + item = get_list_id(mastodon, rest) + if not item: + cprint("List {} is not found".format(rest), fg('red')) + return + list_accounts = mastodon.list_accounts(item) + + cprint("List: %s" % rest, fg('green')) + for user in list_accounts: + printUser(user) + +listaccounts.__argstr__ = '' +listaccounts.__section__ = 'List' + + +@command +def listadd(mastodon, rest): + """Add user to list. + ex: listadd listname @user@instance.example.com + listadd 23 @user@instance.example.com""" + if not(list_support(mastodon)): + return + if not rest: + cprint("Argument required.", fg('red')) + return + items = rest.split(' ') + if len(items) < 2: + cprint("Not enough arguments.", fg('red')) + return + + list_id = get_list_id(mastodon, items[0]) + account_id = get_userid(mastodon, items[1]) + + if not list_id: + cprint("List {} is not found".format(items[0]), fg('red')) + return + + if not account_id: + cprint("Account {} is not found".format(items[1]), fg('red')) + return + + try: + mastodon.list_accounts_add(list_id, account_id) + cprint("Added {} to list {}.".format(items[1], items[0]), fg('green')) + except Exception as e: + cprint("error while adding to list: {}".format(type(e).__name__), fg('red')) +listadd.__argstr__ = ' ' +listadd.__section__ = 'List' + + +@command +def listremove(mastodon, rest): + """Remove user from list. + ex: listremove list user@instance.example.com + listremove 23 user@instance.example.com + listremove 23 42""" + if not(list_support(mastodon)): + return + if not rest: + cprint("Argument required.", fg('red')) + return + items = rest.split(' ') + if len(items) < 2: + cprint("Not enough arguments.", fg('red')) + return + + list_id = get_list_id(mastodon, items[0]) + account_id = get_userid(mastodon, items[1]) + + if not list_id: + cprint("List {} is not found".format(items[0]), fg('red')) + return + + if not account_id: + cprint("Account {} is not found".format(items[1]), fg('red')) + return + + try: + mastodon.list_accounts_delete(list_id, account_id) + cprint("Removed {} from list {}.".format(items[1], items[0]), fg('green')) + except Exception as e: + cprint("error while deleting from list: {}".format(type(e).__name__), fg('red')) +listremove.__argstr__ = ' ' +listremove.__section__ = 'List' ##################################### ######### END COMMAND BLOCK ######### ##################################### @@ -1624,6 +1895,7 @@ def main(instance, config, profile): access_token=token, api_base_url="https://" + instance) + # update config before writing if "token" not in config[profile]: config[profile] = { @@ -1647,6 +1919,10 @@ def main(instance, config, profile): prompt = "[@{} ({})]: ".format(str(user['username']), profile) # Completion setup stuff + if list_support(mastodon, silent=True): + for i in mastodon.lists(): + bisect.insort(completion_list, i['title'].lower()) + for i in mastodon.account_following(user['id'], limit=80): bisect.insort(completion_list, '@' + i['acct']) readline.set_completer(complete) diff --git a/src/tootstream/toot_parser.py b/src/tootstream/toot_parser.py index 92446f9..ce52675 100644 --- a/src/tootstream/toot_parser.py +++ b/src/tootstream/toot_parser.py @@ -4,14 +4,19 @@ from textwrap import TextWrapper -def convert_emoji_shortcodes(text): +def emoji_shortcode_to_unicode(text): """Convert standard emoji short codes to unicode emoji in the provided text. text - The text to parse. Returns the modified text. """ - return emoji.emojize(text, use_aliases = True) + return emoji.emojize(text, use_aliases=True) + + +def emoji_unicode_to_shortcodes(text): + """Convert unicode emoji to standard emoji short codes.""" + return emoji.demojize(text) def find_attr(name, attrs): @@ -76,21 +81,24 @@ class TootParser(HTMLParser): indent - A string to prepend to all lines in the output text. width - The maximum number of characters to allow in a line of text. shorten_links - Whether or not to shorten links. - convert_emoji - Whether or not to convert emoji short codes to unicode. + convert_emoji_to_unicode - Whether or not to convert emoji short codes to unicode. + convert_emoji_to_shortcode - Whether or not to convert emoji unicode to short codes unicode. link_style - The colored style to apply to generic links. mention_style - The colored style to apply to mentions. hashtag_style - The colored style to apply to hashtags. """ - def __init__(self, - indent = '', - width = 0, - convert_emoji = False, - shorten_links = False, - link_style = None, - mention_style = None, - hashtag_style = None): + def __init__( + self, + indent='', + width=0, + convert_emoji_to_unicode=False, + convert_emoji_to_shortcode=False, + shorten_links=False, + link_style=None, + mention_style=None, + hashtag_style=None): super().__init__() self.reset() @@ -98,7 +106,8 @@ def __init__(self, self.convert_charrefs = True self.indent = indent - self.convert_emoji = convert_emoji + self.convert_emoji_to_unicode = convert_emoji_to_unicode + self.convert_emoji_to_shortcode = convert_emoji_to_shortcode self.shorten_links = shorten_links self.link_style = link_style self.mention_style = mention_style @@ -139,8 +148,11 @@ def handle_data(self, data): if self.hide: return - if self.convert_emoji: - data = convert_emoji_shortcodes(data) + if self.convert_emoji_to_unicode: + data = emoji_shortcode_to_unicode(data) + + if self.convert_emoji_to_shortcode: + data = emoji_unicode_to_shortcodes(data) self.fed.append(data)