def add_card(config, title, message, edit, listname): '''Add a new card. If no title is provided, $EDITOR will be opened so you can write one.''' connection, board = BoardTool.start(config) if listname is None: inbox = BoardTool.get_inbox_list(connection, config) else: pattern = re.compile(listname, flags=re.I) target_lists = filter(lambda x: pattern.search(x.name), board.get_lists('open')) try: inbox = next(target_lists) except StopIteration: click.secho('No list names matched by {}'.format(listname), fg='red') raise GTDException(1) if not title: title = click.edit(require_save=True, text='<Title here>') if title is None: # No changes were made in $EDITOR click.secho('No title entered for the new card!', fg='red') raise GTDException(1) else: title = title.strip() returned = inbox.add_card(name=title, desc=message) if edit: display = Display(config.color) list_lookup = BoardTool.list_lookup(board) label_lookup = BoardTool.label_lookup(board) CardTool.smart_menu(returned, display.show_card, list_lookup, label_lookup, color=config.color) else: click.secho('Successfully added card {0}!'.format(returned), fg='green')
def add_card(ctx, title, message, edit, listname): '''Add a new card. If no title is provided, $EDITOR will be opened so you can write one.''' connection = ctx.connection if listname is None: inbox = connection.inbox_list() else: pattern = re.compile(listname, flags=re.I) target_lists = filter(lambda x: pattern.search(x.name), ctx.board.get_lists('open')) try: inbox = next(target_lists) except StopIteration: click.secho(f'No list names matched by {listname}', fg='red') raise GTDException(1) if not title: title = click.edit(require_save=True, text='<Title here>') if title is None: # No changes were made in $EDITOR click.secho('No title entered for the new card!', fg='red') raise GTDException(1) else: title = title.strip() returned = inbox.add_card(name=title, desc=message) if edit: ctx.card_repl(returned) else: click.secho(f'Successfully added card "{returned.name}"!', fg='green')
def grep(config, pattern, insensitive, count, regexp): '''egrep through titles of cards on this board. This command attemps to replicate a couple of grep flags faithfully, so if you're a power-user of grep this command will feel familiar. ''' if not (pattern or regexp): click.secho('No pattern provided to grep: use either the argument or -e', fg='red') raise GTDException(1) # Merge together the different regex arguments final_pattern = '|'.join(regexp) if regexp else '' if pattern and final_pattern: final_pattern = final_pattern + '|' + pattern elif pattern: final_pattern = pattern flags = re.I if insensitive else 0 connection, board = BoardTool.start(config) cards = BoardTool.filter_cards( board, title_regex=final_pattern, regex_flags=flags ) if count: print(sum(1 for _ in cards)) raise GTDException(0) display = Display(config.color) if config.banner: display.banner() display.show_cards(cards)
def __connect(self, config): trello_client = self.initialize_trello(config) try: # This is the first connection to the API made by the client self.boards = trello_client.list_boards() return trello_client except requests.exceptions.ConnectionError: print('[FATAL] Could not connect to the Trello API!') raise GTDException(1) except trello.exceptions.Unauthorized: print('[FATAL] Trello API credentials are invalid') raise GTDException(1)
def __connect(self, config): trello_client = self.initialize_trello(config) try: # A simple API call (data reused in BoardTool.get_main_board) to initiate connection & test our credentials etc self.boards = trello_client.fetch_json('/members/me/boards/?filter=open') return trello_client except requests.exceptions.ConnectionError: print('[FATAL] Could not connect to the Trello API!') raise GTDException(1) except trello.exceptions.Unauthorized: print('[FATAL] Trello API credentials are invalid') raise GTDException(1)
def grep(ctx, pattern, insensitive, count, regexp, by, fields, use_json): '''egrep through titles of cards on this board. This command attemps to replicate a couple of grep flags faithfully, so if you're a power-user of grep this command will feel familiar. One deviation from grep is the --json flag, which outputs all matching cards in full JSON format. ''' if not (pattern or regexp): click.secho( 'No pattern provided to grep: use either the argument or -e', fg='red') raise GTDException(1) # Merge together the different regex arguments final_pattern = '|'.join(regexp) if regexp else '' if pattern and final_pattern: final_pattern = final_pattern + '|' + pattern elif pattern: final_pattern = pattern flags = re.I if insensitive else 0 cards = CardView.create(ctx, status='visible', title_regex=final_pattern, regex_flags=flags) if count: print(sum(1 for _ in cards)) return if use_json: print(cards.json()) else: ctx.display.banner() ctx.display.show_cards(cards, sort=by, table_fields=fields)
def create(context, **kwargs): '''Create a new CardView with the given filters on the cards to find. ''' # Establish all base filters for cards nested resource query parameters. query_params = {} regex_flags = kwargs.get('regex_flags', 0) # Card status, in nested card resource status = kwargs.get('status', 'visible') valid_filters = ['all', 'closed', 'open', 'visible'] if status not in valid_filters: click.secho( f'Card filter {status} is not valid! Use one of {",".join(valid_filters)}' ) raise GTDException(1) query_params['cards'] = status query_params['card_fields'] = 'all' target_cards = [] if (list_regex := kwargs.get('list_regex', None)) is not None: # noqa # Are lists passed? If so, query to find out the list IDs corresponding to the names we have target_list_ids = [] lists_json = context.connection.main_lists() pattern = re.compile(list_regex, flags=regex_flags) for list_object in lists_json: if pattern.search(list_object['name']): target_list_ids.append(list_object['id']) # Iteratively pull IDs from each list, passing the common parameters to them for list_id in target_list_ids: cards_json = context.connection.trello.fetch_json( f'/lists/{list_id}/cards', query_params=query_params) target_cards.extend(cards_json)
def cli(ctx, board, no_color, no_banner): '''gtd.py''' try: config = Configuration.from_file() except GTDException: click.echo('Could not find a valid config file for gtd.') if click.confirm('Would you like to create it interactively?'): ctx.invoke(onboard) click.secho('Re-run your command', fg='green') raise GTDException(0) else: click.secho( 'Put your config file in one of the following locations:', fg='red') for l in Configuration.all_config_locations(): print(' ' + l) raise if board is not None: config.board = board if no_color: config.color = False if no_banner: config.banner = False ctx.color = config.color ctx.obj = config
def from_file(filename=None): if filename is None: filename = Configuration.find_config_file() with open(filename, 'r') as config_yaml: file_config = yaml.safe_load(config_yaml) for prop in [ 'api_key', 'api_secret', 'oauth_token', 'oauth_token_secret' ]: if file_config.get(prop, None) is not None: # great! continue else: print( 'A required property {0} in your configuration was not found!' .format(prop)) print('Check the file {0}'.format(filename)) raise GTDException(1) # Hardcoded defaults are in the final 3 file_config.get() calls return Configuration(file_config['api_key'], file_config['api_secret'], file_config['oauth_token'], file_config['oauth_token_secret'], board=file_config.get('board', None), color=file_config.get('color', True), banner=file_config.get('banner', True))
def find_config_file(): # where to try finding the file in order for possible_loc in Configuration.all_config_locations(): if os.path.isfile(possible_loc): return possible_loc # If we've gotten this far and did not find the configuration file, it does not exist raise GTDException(1)
def grep(config, pattern, insensitive, count, regexp, by, fields, json): '''egrep through titles of cards on this board. This command attemps to replicate a couple of grep flags faithfully, so if you're a power-user of grep this command will feel familiar. One deviation from grep is the --json flag, which outputs all matching cards in full JSON format. ''' if not (pattern or regexp): click.secho( 'No pattern provided to grep: use either the argument or -e', fg='red') raise GTDException(1) # Merge together the different regex arguments final_pattern = '|'.join(regexp) if regexp else '' if pattern and final_pattern: final_pattern = final_pattern + '|' + pattern elif pattern: final_pattern = pattern flags = re.I if insensitive else 0 connection, board = BoardTool.start(config) cards = BoardTool.filter_cards(board, title_regex=final_pattern, regex_flags=flags) if count: print(sum(1 for _ in cards)) return display = Display(config.color) if config.banner and not json: display.banner() display.show_cards(cards, use_json=json, sort=by, table_fields=fields)
def show_cards(self, cards, tsv=False, sort='activity', table_fields=[]): '''Display an iterable of cards all at once. Uses a pretty-printed table by default, but can also print tab-separated values (TSV). Supports the following cli commands: show cards grep :param list(trello.Card)|iterable(trello.Card) cards: cards to show :param bool tsv: display these cards using a tab-separated value format :param str sort: the field name to sort by (must be a valid field name in this table) :param list table_fields: display only these fields ''' # TODO construct the table dynamically instead of filtering down an already-constructed table # TODO implement a custom sorting functions so the table can be sorted by multiple columns table = prettytable.PrettyTable() table.field_names = self.fields.keys() table.align = 'l' if tsv: table.set_style(prettytable.PLAIN_COLUMNS) else: table.hrules = prettytable.FRAME with click.progressbar(list(cards), label='Fetching cards', width=0) as pg: for card in pg: table.add_row([x(card) for x in self.fields.values()]) try: table[0] except IndexError: click.secho('No cards match!', fg='red') raise GTDException(1) if table_fields: print(table.get_string(fields=table_fields, sortby=sort)) else: print(self.resize_and_get_table(table, self.fields.keys(), sort))
def from_file(filename=None): if filename is None: filename = Configuration.find_config_file() with open(filename, 'r') as config_yaml: file_config = yaml.safe_load(config_yaml) for prop in ['api_key', 'api_secret', 'oauth_token', 'oauth_token_secret']: if file_config.get(prop, None) is not None: # great! continue else: print(f'A required property {prop} in your configuration was not found!') print(f'Check the file {filename}') raise GTDException(1) return Configuration( file_config['api_key'], file_config['api_secret'], file_config['oauth_token'], file_config['oauth_token_secret'], # No default board: first board chosen board=file_config.get('board', None), # Only used in tests test_board=file_config.get('test_board', None), # Terminal color by default color=file_config.get('color', True), # Don't print banner by default banner=file_config.get('banner', False), # No default inbox_list: first list chosen inbox_list=file_config.get('inbox_list', None), # By default, don't prompt user to open attachments of a card in review interface prompt_for_open_attachments=file_config.get('prompt_for_open_attachments', False), # By default, prompt user to add tags to untagged cards in review interface prompt_for_untagged_cards=file_config.get('prompt_for_untagged_cards', True), )
def search_for_regex(card, title_regex, regex_flags): try: return re.search(title_regex, card['name'], regex_flags) except re.error as e: click.secho( f'Invalid regular expression "{title_regex}" passed: {str(e)}', fg='red') raise GTDException(1)
def search_for_regex(card): try: return re.search(title_regex, card.name, regex_flags) except re.error as e: click.secho( 'Invalid regular expression "{1}" passed: {0}'.format( str(e), title_regex), fg='red') raise GTDException(1)
def __connect(self, config): trello_client = trello.TrelloClient( api_key=config.api_key, api_secret=config.api_secret, token=config.oauth_token, token_secret=config.oauth_token_secret, ) try: # A simple API call (data reused in self.main_board) to initiate connection & test our credentials etc self.boards = trello_client.fetch_json( '/members/me/boards/?filter=open') return trello_client except requests.exceptions.ConnectionError: print('[FATAL] Could not connect to the Trello API!') raise GTDException(1) except trello.exceptions.Unauthorized: print('[FATAL] Trello API credentials are invalid') raise GTDException(1)
def info(workflow, banner): '''Learn more about gtd.py''' if workflow: click.secho(WORKFLOW_TEXT, fg='yellow') raise GTDException(0) elif banner: print(get_banner()) else: print('gtd.py version {c}{0}{r}'.format(__version__, c=Colors.green, r=Colors.reset)) print('{c}https://github.com/delucks/gtd.py/{r}\nPRs welcome\n'.format(c=Colors.green, r=Colors.reset))
def show_cards(self, cards, use_json=False, tsv=False, table_fields=[], field_blacklist=[]): '''Display an iterable of cards all at once. Uses a pretty-printed table by default, but can also print JSON and tab-separated values (TSV). Supports the following cli commands: show cards grep :param list(trello.Card)|iterable(trello.Card) cards: cards to show :param bool use_json: display all metadata of these cards in JSON format :param bool tsv: display these cards using a tab-separated value format :param list table_fields: display only these fields (overrides field_blacklist) :param list field_blacklist: display all except these fields ''' if use_json: sanitized_cards = list(map( lambda d: d.pop('client') and d, [c.__dict__.copy() for c in cards] )) tostr = self._force_json(sanitized_cards) print(json.dumps(tostr, sort_keys=True, indent=2)) else: # TODO implement a custom sorting functions so the table can be sorted by multiple columns fields = OrderedDict() # This is done repetitively to establish column order fields['name'] = lambda c: c.name fields['list'] = lambda c: c.get_list().name fields['tags'] = lambda c: '\n'.join([l.name for l in c.list_labels]) if c.list_labels else '' fields['desc'] = lambda c: c.desc fields['due'] = lambda c: c.due or '' fields['last activity'] = lambda c: getattr(c, 'dateLastActivity') fields['board'] = lambda c: c.board.name fields['id'] = lambda c: getattr(c, 'id') fields['url'] = lambda c: getattr(c, 'shortUrl') table = prettytable.PrettyTable() table.field_names = fields.keys() table.align = 'l' if tsv: table.set_style(prettytable.PLAIN_COLUMNS) else: table.hrules = prettytable.FRAME with click.progressbar(list(cards), label='Fetching cards', width=0) as pg: for card in pg: table.add_row([x(card) for x in fields.values()]) try: table[0] except IndexError: click.secho('No cards match!', fg='red') raise GTDException(1) if table_fields: print(self.resize_and_get_table(table, table_fields)) elif field_blacklist: f = set(fields.keys()) - set(field_blacklist) print(self.resize_and_get_table(table, list(f))) else: print(self.resize_and_get_table(table, fields.keys()))
def info(ctx, workflow, banner): '''Learn more about gtd.py''' if workflow: click.secho(WORKFLOW_TEXT, fg='yellow') raise GTDException(0) elif banner: print(get_banner(use_color=ctx.config.color)) else: on = Colors.green if ctx.config.color else '' off = Colors.reset if ctx.config.color else '' print(f'gtd.py version {on}{__version__}{off}') print( f'Visit {on}https://github.com/delucks/gtd.py/{off} for more information' )
def suggest_config_location(): '''Do some platform detection and suggest a place for the users' config file to go''' system = platform.system() if system == 'Windows': print( 'gtd.py support for Windows is rudimentary to none. Try to put your config file in $HOME/.gtd.yaml and run the script again' ) raise GTDException(0) elif system == 'Darwin': preferred_location = os.path.expanduser('~/Library/Application Support/gtd/gtd.yaml') elif system == 'Linux': preferred_location = os.path.expanduser('~/.config/gtd/gtd.yaml') else: preferred_location = os.path.expanduser('~/.gtd.yaml') return preferred_location
def add_card(config, title, message, edit): '''Add a new card. If no title is provided, $EDITOR will be opened so you can write one.''' connection, board = BoardTool.start(config) inbox = BoardTool.get_inbox_list(connection, config) if not title: title = click.edit(require_save=True, text='<Title here>') if title is None: # No changes were made in $EDITOR click.secho('No title entered for the new card!', fg='red') raise GTDException(1) else: title = title.strip() returned = inbox.add_card(name=title, desc=message) if edit: display = Display(config.color) list_lookup = BoardTool.list_lookup(board) label_lookup = BoardTool.label_lookup(board) CardTool.smart_menu(returned, display.show_card, list_lookup, label_lookup, Colors.yellow) else: click.secho('Successfully added card {0}!'.format(returned), fg='green')
def show_boards(config, json, tsv, by, show_all): '''Show all boards your account can access''' connection, board = BoardTool.start(config) display = Display(config.color) if config.banner and not json: display.banner() if show_all: boards = connection.trello.fetch_json('/members/me/boards/?filter=all') else: boards = connection.boards if json: display.show_raw(boards, use_json=json) return # Set up a table to hold our boards board_columns = ['name', 'activity', 'members', 'permission', 'url'] if by not in board_columns: click.secho('Field {} is not a valid field: {}'.format( by, ','.join(board_columns)), fg='red') raise GTDException(1) table = prettytable.PrettyTable() table.field_names = board_columns table.align = 'l' if tsv: table.set_style(prettytable.PLAIN_COLUMNS) else: table.hrules = prettytable.FRAME for b in boards: table.add_row([ b['name'], b['dateLastActivity'] or '', len(b['memberships']), b['prefs']['permissionLevel'], b['shortUrl'], ]) try: table[0] except IndexError: click.secho('You have no boards!', fg='red') print(table.get_string(sortby=by))
def show_boards(ctx, use_json, tsv, by, show_all): '''Show all boards your account can access''' if show_all: boards = ctx.connection.trello.fetch_json( '/members/me/boards/?filter=all') else: boards = ctx.connection.boards if use_json: print(json.dumps(boards, sort_keys=True, indent=2)) return else: ctx.display.banner() # Set up a table to hold our boards board_columns = ['name', 'activity', 'members', 'permission', 'url'] if by not in board_columns: click.secho( f'Field {by} is not a valid field: {",".join(board_columns)}', fg='red') raise GTDException(1) table = prettytable.PrettyTable() table.field_names = board_columns table.align = 'l' if tsv: table.set_style(prettytable.PLAIN_COLUMNS) else: table.hrules = prettytable.FRAME for b in boards: table.add_row([ b['name'], b['dateLastActivity'] or '', len(b['memberships']), b['prefs']['permissionLevel'], b['shortUrl'], ]) try: table[0] except IndexError: click.secho('You have no boards!', fg='red') print(table.get_string(sortby=by))
def card_repl(self, card: dict) -> bool: '''card_repl displays a command-prompt based UI for modifying a card, with tab-completion and suggestions. It is the logic behind "gtd review" and the "-e" flag in "gtd add" It makes assumptions about what a user might want to do with a card: - Are there attachments? Maybe you want to open them. - Does there appear to be a URL in the title? You might want to attach it. - Are there no tags? Maybe you want to add some. Returns: boolean: move forwards or backwards in the deck of cards ''' on = Colors.yellow if self.config.color else '' off = Colors.reset if self.config.color else '' self.display.show_card(card) if self.config.prompt_for_open_attachments and card['badges'][ 'attachments']: if prompt_for_confirmation(f'{on}Open attachments?{off}', False): with DevNullRedirect(): for url in [ a['url'] for a in card.fetch_attachments() if a['url'] ]: webbrowser.open(url) if re.search(VALID_URL_REGEX, card['name']): if prompt_for_confirmation( f'{on}Link in title detected, want to attach it & rename?{off}', True): card.title_to_link() if self.config.prompt_for_untagged_cards and not card['labels']: print(f'{on}No tags on this card yet, want to add some?{off}') card.add_labels(self._label_choices) commands = { 'archive': 'mark this card as closed', 'attach': 'add, delete, or open attachments', 'change-list': 'move this to a different list on the same board', 'comment': 'add a comment to this card', 'delete': 'permanently delete this card', 'duedate': 'add a due date or change the due date', 'description': 'change the description of this card (desc)', 'help': 'display this help output (h)', 'move': 'move to a different board and list (m)', 'next': 'move to the next card (n)', 'open': 'open all links on this card (o)', 'prev': 'go back to the previous card (p)', 'print': 're-display this card', 'rename': 'change title of this card', 'tag': 'add or remove tags on this card (t)', 'unarchive': 'mark this card as open', 'quit': 'exit program', } command_completer = FuzzyWordCompleter(commands.keys()) while True: user_input = prompt('gtd.py > ', completer=command_completer) if user_input in ['q', 'quit']: raise GTDException(0) elif user_input in ['n', 'next']: return True elif user_input in ['p', 'prev']: return False elif user_input == 'print': card.fetch() self.display.show_card(card) elif user_input in ['o', 'open']: with DevNullRedirect(): for url in [ a['url'] for a in card.fetch_attachments() if a['url'] is not None ]: webbrowser.open(url) elif user_input in ['desc', 'description']: card.change_description() elif user_input == 'delete': card.delete() print('Card deleted') return True elif user_input == 'attach': card.manipulate_attachments() elif user_input == 'archive': card.set_closed(True) print('Card archived') return True elif user_input == 'unarchive': card.set_closed(False) print('Card returned to board') elif user_input in ['t', 'tag']: card.add_labels(self._label_choices) elif user_input == 'rename': # TODO optional form 'rename New name of card' card.rename() elif user_input == 'duedate': card.set_due_date() elif user_input in ['h', 'help']: for cname, cdesc in commands.items(): print('{0:<16}| {1}{2}{3}'.format(cname, on, cdesc, off)) elif user_input == 'change-list': if card.move_to_list(self._list_choices): return True elif user_input in ['m', 'move']: self.move_between_boards(card) elif user_input == 'comment': # TODO Optional form 'comment Contents of a comment' new_comment = click.edit(text='<Comment here>', require_save=True) if new_comment: card.comment(new_comment) else: click.secho('Change the text & save to post the comment', fg='red') else: print( f'{on}{user_input}{off} is not a command, type "{on}help{off}" to view available commands' )
def smart_menu(card, f_display, list_choices, label_choices, color=None): '''make assumptions about what you want to do with a card and ask the user if they want to''' on = color if color else '' off = Colors.reset if color else '' card.fetch() f_display(card) if card.get_attachments(): if prompt_for_confirmation( '{0}Open attachments?{1}'.format(on, off), False): with DevNullRedirect(): for url in [ a.url for a in card.get_attachments() if a.url is not None ]: webbrowser.open(url) if re.search(VALID_URL_REGEX, card.name): if prompt_for_confirmation( '{0}Link in title detected, want to attach it & rename?{1}' .format(on, off), True): CardTool.title_to_link(card) if not card.list_labels: print('{0}No tags on this card yet, want to add some?{1}'.format( on, off)) CardTool.add_labels(card, label_choices) commands = { 'archive': 'mark this card as closed', 'attach': 'add, delete, or open attachments', 'comment': 'add a comment to this card', 'delete': 'permanently delete this card', 'duedate': 'add a due date or change the due date', 'description': 'change the description of this card (desc)', 'help': 'display this help output (h)', 'move': 'move to a different list (m)', 'next': 'move on to the next card (n)', 'open': 'open all links on this card (o)', 'print': 'display this card (p)', 'rename': 'change title of this card', 'tag': 'add or remove tags on this card (t)', 'quit': 'exit program' } command_completer = WordCompleter(commands.keys()) while True: user_input = prompt('gtd.py > ', completer=command_completer) if user_input in ['q', 'quit']: raise GTDException(0) elif user_input in ['n', 'next']: break elif user_input in ['p', 'print']: card.fetch() f_display(card) elif user_input in ['o', 'open']: with DevNullRedirect(): for url in [ a.url for a in card.get_attachments() if a.url is not None ]: webbrowser.open(url) elif user_input in ['desc', 'description']: if CardTool.change_description(card): print('Description changed!') elif user_input == 'delete': card.delete() print('Card deleted') break elif user_input == 'attach': CardTool.manipulate_attachments(card) elif user_input == 'archive': card.set_closed(True) print('Card archived') break elif user_input in ['t', 'tag']: CardTool.add_labels(card, label_choices) elif user_input == 'rename': CardTool.rename(card) elif user_input == 'duedate': CardTool.set_due_date(card) elif user_input in ['h', 'help']: for cname, cdesc in commands.items(): print('{0:<16}| {1}{2}{3}'.format(cname, on, cdesc, off)) elif user_input in ['m', 'move']: if CardTool.move_to_list(card, list_choices): break elif user_input == 'comment': new_comment = click.edit(text='<Comment here>', require_save=True) if new_comment: card.comment(new_comment) else: click.secho('Change the text & save to post the comment', fg='red') else: print( '{0}{1}{2} is not a command, type "{0}help{2}" to view available commands' .format(on, user_input, off))
post_processed_cards = [] # Regular expression on trello.Card.name if (title_regex := kwargs.get('title_regex', None)) is not None: # noqa filters.append( partial(search_for_regex, title_regex=title_regex, regex_flags=regex_flags)) # boolean queries about whether the card has things if (has_attachments := kwargs.get('has_attachments', None)) is not None: # noqa filters.append(lambda c: c['badges']['attachments'] > 0) if (no_tags := kwargs.get('no_tags', None)) is not None: # noqa filters.append(lambda c: not c['idLabels']) if (has_due_date := kwargs.get('has_due_date', None)) is not None: # noqa filters.append(lambda c: c['due']) # comma-separated string of tags to filter on if (tags := kwargs.get('tags', None)) is not None: # noqa filters.append(partial(check_for_label_presence, tags=tags)) for card in target_cards: if all(filter_func(card) for filter_func in filters): post_processed_cards.append(card) if not post_processed_cards: click.secho('No cards matched the filters provided', fg='red') raise GTDException(0) # Create a CardView with those objects as the base return CardView(context=context, cards=post_processed_cards)