package com.jdbernard.twitter import com.martiansoftware.nailgun.NGContext import org.slf4j.Logger import org.slf4j.LoggerFactory import twitter4j.Paging import twitter4j.Status import twitter4j.Twitter import twitter4j.TwitterFactory import twitter4j.conf.Configuration import twitter4j.conf.PropertyConfiguration public class TwitterCLI { private static String EOL = System.getProperty("line.separator") private static TwitterCLI nailgunInst private Twitter twitter private Scanner stdin private Map colors = [:] private int terminalWidth private boolean colored private boolean printStatusTimestamps private boolean strict private boolean warnings private static final VERSION = 1.0 private Logger log = LoggerFactory.getLogger(getClass()) /* ============================================= */ /* ======== MAIN METHODS - Entry points ======== */ /* ============================================= */ public static void main(String[] args) { TwitterCLI inst = new TwitterCLI(new File(System.getProperty("user.home"), ".gritterrc")) // trim the last argumnet, not all cli's are well-behaved args[-1] = args[-1].trim() inst.run((args as List) as LinkedList) } public static void nailMain(NGContext context) { if (nailgunInst == null) nailgunInst = new TwitterCLI(new File( System.getProperty("user.home"), ".gritterrc")) else nailgunInst.stdin = new Scanner(context.in) // trim the last argumnet, not all cli's are well-behaved context.args[-1] = context.args[-1].trim() nailgunInst.run((context.args as List) as LinkedList) } /* ===================================== */ /* ======== CONFIGURATION/SETUP ======== */ /* ===================================== */ public static void reconfigure(LinkedList args) { if (nailgunInst == null) main(args as String[]) else { nailgunInst = null nailgunInst = new TwitterCLI(new File( System.getProperty("user.home"), ".gritterrc")) nailgunInst.run(args) } } public TwitterCLI(File propFile) { // load the configuration Properties cfg = new Properties() propFile.withInputStream { is -> cfg.load(is) } // create a twitter instance twitter = (new TwitterFactory(new PropertyConfiguration(cfg))).getInstance() // configure the colors colors.author = new ConsoleColor(cfg.getProperty("colors.author", "CYAN:false")) colors.mentioned = new ConsoleColor(cfg.getProperty("colors.mentioned", "GREEN:false")) colors.error = new ConsoleColor(cfg.getProperty("colors.error", "RED:true")) colors.option = new ConsoleColor(cfg.getProperty("colors.option", "YELLOW:true")) colors.even = new ConsoleColor(cfg.getProperty("colors.even", "WHITE")) colors.odd = new ConsoleColor(cfg.getProperty("colors.odd", "YELLOW")) // configure the terminal width terminalWidth = (System.getenv().COLUMNS ?: cfg.terminalWidth ?: 79) as int colored = (cfg.colored ?: 'true') as boolean strict = (cfg.strict ?: 'false') as boolean warnings = (cfg.warnings ?: 'true') as boolean printStatusTimestamps = (cfg."timeline.printTimestamps" ?: 'true') as boolean stdin = new Scanner(System.in) } /* =================================== */ /* ======== PARSING FUNCTIONS ======== */ /* =================================== */ /** | Main entry to the command line parsing system. In general, the parsing | system (this method and the others dedicated to parsing arguments) | follow some common conventions: | | 1. An argument is either considered a *command* or a *parameter*. | 2. Arguments are parsed in order. | 3. A command can have aliases to make invokation read naturally. | 4. A command may expect further refining commands, one of which | will be the default. So if a command has possible refining | commands, but the next argument does not match any of them, | the default will be assumed and the current argument will not | be comsumed, but passed on to the refining command. | | For example: :: | | gritter set colored off show mine | | is parsed: | | * the first argument is expected to be a command | * the ``set`` command consumes two arguments, expecting both to | be parameters | * the set command is finished, but there are still arguments, so the | parsing process starts again, expecting the next argument to be a | command. | * the ``show`` command consumes one argument and expects a command | * ``mine`` does not match any of the possible refinements for ``show`` so | the default command, `timeline`, is assumed. | * the `show timeline` command consumes one argument, expecting a command | * The `show timeline mine` command executes | * No more arguments remain, so execution terminates. | | Recognized top-level commands are: | | +-----------------+-------------------+----------------------------+ | | *Command* | *Aliases* | *Description* | | +-----------------+-------------------+----------------------------+ | | ``delete`` | ``destroy``, | Delete a post, status, list| | | | ``remove`` | membership, etc. | | +-----------------+-------------------+----------------------------+ | | ``get`` | ``show`` | Display a list, timeline, | | | | | list membership, etc. | | +-----------------+-------------------+----------------------------+ | | ``help`` | | Display help for commands | | +-----------------+-------------------+----------------------------+ | | ``post`` | `add`, ``create`` | Post a new status, add a | | | | | new list subscription, etc.| | +-----------------+-------------------+----------------------------+ | | ``reconfigure`` | | Cause the tool to reload | | | | | its configuration file. | | +-----------------+-------------------+----------------------------+ | | ``set`` | | Set a configurable value | | | | | at runtime. | | +-----------------+-------------------+----------------------------+ | | @param args A {@link java.util.LinkedList} of arguments to parse. */ public void run(LinkedList args) { if (args.size() < 1) printUsage() log.debug("argument list: {}", args) while (args.peek()) { def command = args.poll() switch (command.toLowerCase()) { case ~/delete|destroy|remove/: delete(args); break case ~/get|show/: get(args); break // get|show case ~/help/: help(args); break // help case ~/post|add|create/: post(args); break // post|add|create case ~/reconfigure/: reconfigure(args); break // reconfigure case ~/set/: set(args); break // set default: // fallthrough if (strict) { log.error(color("Unrecognized command: '$command'", colors.error)) } else { if (warnings) { println "Command '$command' unrecognized: " + "assuming this is a parameter to 'show'" } args.addFirst(command) get(args) } } } } /* -------- DELETE Subparsing -------- */ /** | Parse a ``delete`` command. Valid options are: | | +-------------+--------------------------------------------------+ | | *Argument* | *Description* | | +-------------+--------------------------------------------------+ | | ``status`` | Destroy a status given a status id. *This is the | | | | default command.* | | +-------------+--------------------------------------------------+ | | ``list`` | Delete a list, remove list members, etc. | | +-------------+--------------------------------------------------+ | | @param args A {@link java.util.LinkedList} of arguments. */ public void delete(LinkedList args) { def option = args.poll() log.debug("Processing a 'delete' command, option = {}.", option) switch(option) { case "status": deleteStatus(args); break case "list": deleteList(args); break default: args.addFirst(option) deleteStatus(args) } } /** | Parse a ``delete list`` command. Valid options are: | | +------------------+---------------------------------------------+ | | *Argument* | *Description* | | +------------------+---------------------------------------------+ | | ``member`` | Remove a member from a list. | | +------------------+---------------------------------------------+ | | ``subscription`` | Unsubcribe from a given list. | | +------------------+---------------------------------------------+ | | *list-reference* | Delete the list specified by the reference. | | +------------------+---------------------------------------------+ | | @param args A {@link java.util.LinkedList} of arguments. */ public void deleteList(LinkedList args) { def option = args.poll() log.debug("Processing a 'delete list' command, option = {}.", option) switch(option) { case "member": deleteListMember(args); break case "subscription": deleteListSubscription(args); break default: args.addFirst(option) doDeleteList(args) } } /* -------- GET/SHOW Subparsing -------- */ /** | Parse a ``get`` command. Valid options are: | | +-------------------+-------------------------------------------------+ | | *Argument* | *Description* | | +-------------------+-------------------------------------------------+ | | ``list`` | Show a list timeline, members, subs, etc. | | +-------------------+-------------------------------------------------+ | | ``lists`` | Show lists all lists owned by a given user | | +-------------------+-------------------------------------------------+ | | ``subscriptions`` | Show all of the lists a given user is subcribed | | | | to. | | +-------------------+-------------------------------------------------+ | | ``timeline`` | Show a timeline of tweets. *This is the default | | | | command.* | | +-------------------+-------------------------------------------------+ | | ``user`` | Show information about a given users. | | +-------------------+-------------------------------------------------+ | | @param args A {@link java.util.LinkedList} of arguments. */ public void get(LinkedList args) { def option = args.poll() log.debug("Processing a 'get' command, option = {}.", option) switch(option) { case "list": showList(args); break case "lists": showLists(args); break case ~/subs.*/: showSubscriptions(args); break case "timeline": showTimeline(args); break case "user": showUser(args); break default: args.addFirst(option) showTimeline(args) } } /** | Parse a ``show list`` command. Valid options are: | | +--------------------+-----------------------------------------------+ | | *Argument* | *Description* | | +--------------------+-----------------------------------------------+ | | ``members`` | Show the members of a given list. This is the | | | | list of users who's tweets comprise the list. | | +--------------------+-----------------------------------------------+ | | ``subscribers`` | Show the subscribers of a given list. This is | | | | the list of users who are see the list. | | +--------------------+-----------------------------------------------+ | | ``subscriptions`` | Show all of the lists a given user is | | | | subscribed to. | | +--------------------+-----------------------------------------------+ | | *list-reference* | Show the timeline for a given list. | | +--------------------+-----------------------------------------------+ | | @param args a {@link java.util.LinkedList} of arguments. */ public void showList(LinkedList args) { def option = args.poll() log.debug("Processing a 'show list' command, option = '{}'", option) switch(option) { case "members": showListMembers(args); break case "subscribers": showListSubscribers(args); break case "subscriptions": showListSubscriptions(args); break default: args.addFirst(option) showListTimeline(args) } } /** | Parse a ``show lists`` command. ``show lists`` consumes at most one | argument, representing a user reference. It shows all of the lists | owned by the given user. If no user reference is given ``show lists`` | shows the lists owned by the currently logged in user. | | @param args a {@link java.util.LinkedList} of arguments. */ public void showLists(LinkedList args) { def user = args.poll() log.debug("Processing a 'show lists' command, user = '{}'", user) if (!user) user = twitter.screenName printLists(twitter.getUserLists(user, -1)) // TODO paging } /** | Parse a ``show list members`` command. ``show list members`` consumes | one argument, a reference to the list in question. | | @param args a {@util java.util.LinkedList} of arguments. */ public void showListMembers(LinkedList args) { def listRef = parseListReference(args) log.debug("Processing a 'show list members' command, list = '{}'", listRef) if (!listRef) { println color("show list members", colors.option) + color(" command requires a list reference in the form of " + "user/list: ", colors.error) + "gritter show list members " return } def userList = twitter.getUserListMembers(listRef.username, listRef.listId, -1) printUserList(userList) // TODO paging } /** | Parse a ``show list subscribers`` command. ``show list subscribers | consumes one argument, a reference to the list in question. | | @param args a {@util java.util.LinkedList} of arguments. */ public void showListSubscribers(LinkedList args) { def listRef = parseListReference(args) log.debug("Processing a 'show list subscribers' command, list = '{}'", listRef) if (!listRef) { println color("show list subscribers", colors.option) + color(" command requires a list reference in the form of " + "user/list: ", colors.error) + "gritter show list subscribers " return } printUserList(twitter.getUserListSubscribers( listRef.username, listRef.listId, -1)) // TODO: paging } /** | Parse a ``show list members`` command. ``show list members consumes one | argument, a reference to the list in question. | | @param args a {@util java.util.LinkedList} of arguments. */ public void showListSubscriptions(LinkedList args) { def user = args.poll() log.debug("Processing a 'show list subscriptions' command, list = '{}'", listRef) if (!user) user = twitter.screenName printLists(twitter.getUserListSubscriptions(user, -1)) // TODO: paging } public void showListTimeline(LinkedList args) { if (args.size() < 1) { println color("show list", colors.option) + color(" command requires a list reference in the form of " + "user/list: ", colors.error) + "gritter show list " return } def listRef = parseListReference(args) log.debug("Showing a list timeline, list = '{}'", listRef) if (listRef.listId == -1) { println color("show list", colors.option) + color(" command requires a list reference in the form of " + "user/list: ", colors.error) + "gritter show list " return } printTimeline(twitter.getUserListStatuses( listRef.username, listRef.listId, new Paging())) // TODO: paging } public void showTimeline(LinkedList args) { String timeline = args.poll() ?: "home" log.debug("Processing a 'show timeline' command, timeline = '{}'", timeline) switch (timeline) { // friends case "friends": printTimeline(twitter.friendsTimeline); break // home case "home": printTimeline(twitter.homeTimeline); break // mine case "mine": printTimeline(twitter.userTimeline); break // public case "public": printTimeline(twitter.publicTimeline); break // user case "user": String user = args.poll() if (user) { if (user.isNumber()) printTimeline(twitter.getUserTimeline(user as int)) else printTimeline(twitter.getUserTimeline(user)) } else println color("No user specified.", colors.error) break; default: println color("Unknown timeline: ", colors.error) + color(timeline, colors.option) break; } } public void showUser(LinkedList args) { def user = args.poll() log.debug("Processing a 'show user' command, user = '{}'", user) println color("show user", colors.option) + color(" is not yet implemented.", colors.error) } public void createList(LinkedList args) { def option = args.poll() switch(option) { case "member": addListMember(args); break case "subscription": addListSubscription(args); break default: args.addFirst(option) createNewList(args); break } } public void post(LinkedList args) { def option = args.poll() log.debug("Processing a 'post' command: option = '{}'", option) if (!option) { println color("post", colors.option) + color(" command requires at least two parameters: ", colors.error) + "gritter post " + "..." return } switch (option) { case "status": postStatus(args.poll()); break case "retweet": retweetStatus(args.poll()); break case "list": createList(args); break default: postStatus(option) } } public void set(LinkedList args) { def option = args.poll() def value = args.poll() log.debug("Processing a 'set' command: option = '{}', value = '{}'", option, value) if (!value) { // note: if option is null, value is null println color("set", colors.option) + color(" command requires two options: ", colors.error) + "gritter set " return } switch (option) { case "terminalWidth": terminalWidth = value as int; break case "colored": colored = value.toLowerCase() ==~ /true|t|on|yes|y/ break default: println color("No property named ", colors.error) + color(option, colors.option) + color(" exists.", colors.error) } } /* ======== WORKER FUNCTIONS ========*/ public void deleteListMember(LinkedList args) { def listRef = parseListReference(args) def user = args.poll() log.debug("Deleting a member from a list: list='{}', user='{}'", listRef, user) if (!user) { println color("delete list member", colors.option) + color(" requires two parameters: ", colors.error) + "gritter delete list member " return } // look up the user id if neccessary if (user.isLong()) user = user as long else user = twitter.showUser(user).id twitter.deleteUserListMember(listRef.listId, user) // TODO: error checking? print list name? println "Deleted " + color("@$user", colors.mentioned) + " from your list." } public void deleteListSubscribtion(LinkedList args) { def listRef = parseListReference(args) log.debug("Unsubscribing from a list: listRef='{}', user='{}'", listRef, user) if (!listRef) { println color("delete list subscription", colors.option) + color(" requires a list reference: ", colors.error) + "gritter delete list subscription " return } twitter.unsubscribeUserList(listRef.username, listRef.listId) // TODO: error checking? println "Unsubscribed from list: " + color(listRef.toString(), colors.option) } public void doDeleteList(LinkedList args) { def listRef = parseListReference(args) log.debug("Destroying a list: listRef='{}'", listRef) if (!listRef) { println color("destroy list", colors.option) + color(" requries a list reference: ", colors.error) + "gritter destroy list " return } println "Really destroy list '" + color(listRef.toString(), colors.option) + "' ?" if (stdin.nextLine() ==~ /yes|y|true|t/) { try { twitter.destroyUserList(listRef.listId) println "List destroyed." } catch (Exception e) { println "An error occurred trying to delete the list: '" + e.localizedMessage log.error("Error destroying list:", e) } } else println "Destroy list canceled." } public void deleteStatus(LinkedList args) { def statusId = args.poll() log.debug("Destroying a status: id='{}'", statusId) if (!statusId || !statusId.isLong()) { println color("destroy status", colors.option) + color(" requires a status id: ", colors.error) + "gritter delete status " return } statusId = statusId as long println "Really destroy status '" + color(statusId.toString(), colors.option) + "' ?" if (stdin.nextLine() ==~ /yes|y|true|t/) { try { twitter.destroyStatus(statusId) println "Status destroyed." } catch (Exception e) { println "An error occurred trying to destroy the status: '" + e.localizedMessage log.error("Error destroying status:", e) } } else println "Destroy status canceled." } public void printLists(def lists) { int colSize = 0 // fins largest indentation needed lists.each { list -> def curColSize = list.user.screenName.length() + list.slug.length() + list.id.toString().length() + 3 colSize = Math.max(colSize, curColSize) //println colSize //TODO, fix column alignment } lists.each { list -> //println colSize def col1 = color("@${list.user.screenName}", colors.author) + "/" + color("${list.slug} (${list.id})", colors.option) println col1.padLeft(colSize) + ": ${list.memberCount} members " + "and ${list.subscriberCount} subscribers" println wrapToWidth(list.description, terminalWidth, "".padLeft(8), "") //println col1.length() } } public void printUserList(def users) { int colSize = 0 colSize = users.inject(0) { curMax, user -> Math.max(curMax, user.id.toString().length()) } users.each { user -> println "${user.id.toString().padLeft(colSize)} - " + color(user.screenName, colors.author) + ": ${user.name}" } } public void printTimeline(def timeline) { log.debug("Printing a timeline: {}", timeline) Map formatOptions = [:] formatOptions.authorLength = 0 timeline.each { status -> if (status.user.screenName.length() > formatOptions.authorLength) formatOptions.authorLength = status.user.screenName.length() } formatOptions.indent = "".padLeft(formatOptions.authorLength + 2) timeline.eachWithIndex { status, rowNum -> formatOptions.rowNum = rowNum println formatStatus(status, formatOptions) } } public void postStatus(String status) { log.debug("Posting a status: '{}'", status) if (!status) { println color("post status ", colors.option) + color("command requires one option: ", colors.error) + "gritter post status " return } if (status.length() > 140) { println color("Status exceeds Twitter's 140 character limit.", colors.error) return } println "Update status: '$status'? " if (stdin.nextLine() ==~ /yes|y|true|t/) { try { twitter.updateStatus(status) println "Status posted." } catch (Exception e) { println "An error occurred trying to post the status: '" + e.localizedMessage log.error("Error posting status:", e) } } else println "Status post canceled." } public void retweetStatus(def statusId) { log.debug("Retweeting a status: '{}'", statusId) if (!statusId.isLong() || !statusId) { println color("retweet ", colors.option) + color("command requires a status id: ", colors.error) + "gritter post retweet " } statusId = statusId as long def status = twitter.showStatus(statusId) println "Retweet '" + color(status.text, colors.odd) + "'? " if (stdin.nextLine() ==~ /yes|y|true|t/) { twitter.retweetStatus(statusId) println "Status retweeted." } else { println "Retweet cancelled." } } public void addListMember(LinkedList args) { def listRef = parseListReference(args) def user = args.poll() log.debug("Adding a member to a list: list='{}', user='{}'", listRef, user) if (!user) { println color("add list member", colors.option) + color(" requires two parameters: ", colors.error) + "gritter add list member " return } if (!listRef) { println color("No list found that matches the given description: ", colors.error) + color(listRef, colors.option) return } // look up the user id if neccessary if (user.isLong()) user = user as long else user = twitter.showUser(user).id twitter.addUserListMember(listRef.listId, user) // TODO: error checking? println "Added list member." } public void addListSubscription(LinkedList args) { def listRef = parseListReference(args) log.debug("Subscribing to a list: list='{}'", listRef) if (!listRef) { println color("add list subscription ", colors.option) + color("expects a list name, user/list: ", colors.error) + "gritter add list subscription " return } twitter.subscribeUserList(listRef.username, listRef.listId) // TODO: error checking? println "Subscribed to list." } public void createNewList(LinkedList args) { def name = args.poll() def isPublic = args.poll() def desc = args.poll() log.debug("Creating a new list: name='{}', isPublic='{}', desc='{}'", name, isPublic, desc) if (desc == null) { println color("create list ", colors.option) + color("command requires three arguments: ", colors.error) + "gritter create list " return } println "Create list '${color(name, colors.option)}'?" if (stdin.nextLine() ==~ /yes|y|true|t/) { twitter.createUserList(name, isPublic ==~ /yes|y|true|t/, desc) println "List created." } else { println "List creation cancelled." } } /* ======== HELP DISPLAY FUNCTIONS ======== */ public void help(LinkedList args) { log.debug("Processing a 'help' command.") def command = args.poll() if (command != null) { switch (command.toLowerCase()) { case ~/delete|destroy|remove/: helpDelete(); break case ~/get|show/: helpGet(); break // get|show case ~/help/: helpHelp(); break // help case ~/post|add|create/: helpPost(); break // post|add|create case ~/reconfigure/: helpReconfigure(); break // reconfigure case ~/set/: helpSet(); break // set case~/list-reference/: helpListReference(); break // list-reference default: // fallthrough print color("Not a valid command: ", colors.error) println color(command, colors.option) break; } } else { println "Gritter v${VERSION} -- A command-line twitter client." println "Author: Jonathan Bernard " println "Website: https://www.github.com/jdbernard/gritter" println "" println "usage: " + color("gritter ...", colors.option) println "" println "Valid commands (with interchangable aliases):" println " delete (destroy, remove)" println " get (show)" println " help" println " post (add, create)" println " reconfigure" println " set" println "" println "use " + color("gritter help ", colors.option) + "for more information about a \nspecific command." println "" } } public void helpDelete() { println color("gritter delete ...", colors.option) println "" println "Valid uses:" println "" println " Destroy a status (tweet) by id:" println " gritter destroy " println " gritter destroy status " println "" println " Destroy a list:" println " gritter destroy list " println "" println " Remove a user from a list (stop following them in that list):" println " gritter remove list member " println "" println " Delete list subscription (stop following the list):" println " gritter delete list subscription " println "" println "For more information about list references, use " println color("gritter help list-reference", colors.option) } public void helpGet() { println color("gritter get ...", colors.option) println "" println "Valid uses:" println "" println " View a timeline:" println " gritter show [timeline] (friends|home|mine|public|user )" println "" println " View available lists for a user (defaults to current user):" println " gritter show lists" println " gritter show lists " println "" println " View a specific list (see the recent tweets):" println " gritter show list " println "" // TODO //println " View your current list subscriptions:" //println " gritter show list subscriptions" //println "" println " View user information:" println " gritter show user " println "" } public void helpHelp() { println color("gritter help ", colors.option) println "" println "Show usage information about a specific gritter command." println "" } public void helpPost() { println color("gritter post ...", colors.option) println "" println "Valid uses:" println "" println " Post a status update (tweet):" println " gritter post " println " gritter post status " println "" println " Retweet a status:" println " gritter post retweet " println "" println " Create a new list:" println " gritter create list " println "" println " Add a user to a list:" println " gritter add list member " println "" println " Subscribe to a list:" println " gritter add list subscription " println "" } public void helpReconfigure() { println color("gritter reconfigure", colors.option) println "" println "Causes gritter to release resources, forget variables set" println "manually and reload it's configuration file." println "" } public void helpSet() { println color("gritter set ", colors.option) println "" println "Sets a configuration value manually. Configurable values are:" println "" println " terminalWidth Gritter wraps its output to fit " println " within this width." println "" println " colored Should gritter's output be in color?" println " yes,no,true,false are all valid." println "" } public void helpListReference() { println " a ${color('', colors.option)} is defined as:" println " ${color('[/]', colors.option)}" + "where username is optional (defaults" println " to the current user) and list-id may be the list name or " println " internal id number." println "" } /* ======== UTILITY FUNCTIONS ======== */ public def parseListReference(LinkedList args) { def username = args.poll() def listId log.debug("Looking up a list: ref = '{}'", username) log.debug("remaining args='{}'", args) if (!username) return false username = username.split("/") if (username.length != 2) { listId = username[0] username = twitter.screenName } else { listId = username[1] username = username[0] } if (listId.isInteger()) listId = listId as int else listId = findListByName(username, listId) // TODO: err if list does not exist return [username: username, listId: listId] } public int findListByName(String userName, String listName) { def userLists long cursor = -1 int listId = -1 while(listId == -1) { userLists = twitter.getUserLists(userName, cursor) userLists.each { list -> if (listName == list.name || listName == list.slug) listId = list.id } if (!userLists.hasNext()) break cursor = userLists.nextCursor } return listId } public String formatStatus(Status status, Map formatOptions) { def indent = formatOptions.indent ?: "" def authorLength = formatOptions.authorLength ?: 0 def rowNum = formatOptions.rowNum ?: 0 String result def textColor = (rowNum % 2 == 0 ? colors.even : colors.odd) // add author's username result = color(status.user.screenName.padLeft( authorLength) + ": ", colors.author, textColor) // format the status text String text = status.text // if this status takes up more room than we have left on the line if (text.length() > terminalWidth - indent.length()) { // wrap text to terminal width text = wrapToWidth(text, terminalWidth, indent, ""). substring(indent.length()) // if we are } // color @mentions in the tweet text = text.replaceAll(/(@\w+)/, color("\$1", colors.mentioned, textColor)) result += text return result } public static void printUsage() { // TODO } public String resetColor() { colored ? "\u001b[m" : "" } public String color(def message, ConsoleColor color, ConsoleColor existing = null) { if (!colored) return message return color.toString() + message + (existing ?: resetColor()) } static String wrapToWidth(String text, int width, String prefix, String suffix) { int lastSpaceIdx = 0; int curLineLength = 0; int lineStartIdx = 0; int i = 0; int actualWidth = width - prefix.length() - suffix.length() String wrapped = "" text = text.replaceAll("[\n\r]", " ") for (i = 0; i < text.length(); i++) { curLineLength++ if (curLineLength > actualWidth) { if (lastSpaceIdx == -1) // we haven't seen a space on this line lastSpaceIdx = lineStartIdx + actualWidth - 1 wrapped += prefix + text[lineStartIdx.. 1) wrapped += prefix + text[lineStartIdx..