Skip to content

Commit

Permalink
Whoops forgot to add that module
Browse files Browse the repository at this point in the history
  • Loading branch information
delucks committed Feb 22, 2020
1 parent 6022ebf commit 9e5f317
Showing 1 changed file with 328 additions and 0 deletions.
328 changes: 328 additions & 0 deletions todo/card.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,328 @@
'''Things which operate on or generate Trello cards'''
import re
import json
import shutil
import itertools
import webbrowser
from functools import partial

import arrow
import click
import trello
from prompt_toolkit import prompt
from prompt_toolkit.validation import Validator
from prompt_toolkit.completion import WordCompleter, FuzzyWordCompleter

from todo.input import prompt_for_confirmation, single_select
from todo.misc import get_title_of_webpage, DevNullRedirect, VALID_URL_REGEX, return_on_eof, build_name_lookup
from todo.exceptions import GTDException


def parse_user_date_input(user_input):
accepted_formats = ['MMM D YYYY', 'MM/DD/YYYY', 'DD/MM/YYYY']
for fmt in accepted_formats:
try:
input_datetime = arrow.get(user_input, fmt)
return input_datetime
except arrow.parser.ParserError:
continue
except ValueError:
continue
return None


class CardTool:
'''This static class holds functionality to do atomic modifications on certain cards.
These methods are used inside of the user interaction parts of the codebase as a way of doing the same operation across
different UI components.
'''

@staticmethod
def fetch_comments(card_json, connection):
'''Fetch the comments on this card and return them in JSON format.
'''
if 'comments' in card_json:
return card_json['comments']
query_params = {'filter': 'commentCard'}
comments = connection.trello.fetch_json('/cards/' + card_json['id'] + '/actions', query_params=query_params)
sorted_comments = sorted(comments, key=lambda comment: comment['date'])
card_json['comments'] = sorted_comments
return sorted_comments

@staticmethod
def fetch_attachments(card_json, connection):
'''Fetch the attachments on this card and return them in JSON format, after enriching the card JSON
with the full attachment structure.
'''
if 'attachments' in card_json:
return card_json['attachments']
attachments = connection.trello.fetch_json(
'/cards/' + card_json['id'] + '/attachments', query_params={'filter': 'false'}
)
card_json['attachments'] = attachments
return attachments

@staticmethod
@return_on_eof
def add_labels(card, label_choices):
'''Give the user a way to toggle labels on this card by their
name rather than by a numeric selection interface. Using
prompt_toolkit, we have automatic completion which makes
things substantially faster without having to do a visual
lookup against numeric IDs
:param trello.Card card: the card to modify
:param dict label_choices: str->trello.Label, the names and objects of labels on this board
'''
print('Enter a tag name to toggle it, <TAB> completes. Ctrl+D to exit')
label_completer = FuzzyWordCompleter(label_choices.keys())
while True:
userinput = prompt('gtd.py > tag > ', completer=label_completer).strip()
if userinput not in label_choices.keys():
if prompt_for_confirmation(f'Unrecognized tag name {userinput}, would you like to create it?', False):
label = card.board.add_label(userinput, 'black')
card.add_label(label)
click.echo(f'Successfully added tag {label.name} to board {card.board.name} and card {card.name}!')
label_choices = build_name_lookup(card.board.get_labels(limit=200))
label_completer = FuzzyWordCompleter(label_choices.keys())
else:
label_obj = label_choices[userinput]
try:
card.add_label(label_obj)
click.secho(f'Added tag {userinput}', fg='green')
except trello.exceptions.ResourceUnavailable:
# This label already exists on the card so remove it
card.remove_label(label_obj)
click.secho(f'Removed tag {userinput}', fg='red')

@staticmethod
def title_to_link(card):
# This assumes your link is in card.name somewhere
sp = card['name'].split()
links = [n for n in sp if VALID_URL_REGEX.search(n)]
existing_attachments = [a.name for a in card.get_attachments()]
user_parameters = {'oldname': card.name}
for idx, link_name in enumerate(links):
# Attach this link
if link_name not in existing_attachments:
card.attach(url=link_name)
# Get the URL & title of the link for the user to access in the renaming interface
user_parameters[f'link{idx}'] = link_name
possible_title = get_title_of_webpage(link_name)
if possible_title:
user_parameters[f'title{idx}'] = possible_title
# Give the user a default title without the link, but allow them to use the title of the page from a link as a var instead
reconstructed = ' '.join([n for n in sp if not VALID_URL_REGEX.search(n)])
CardTool.rename(card, variables=user_parameters, default=reconstructed)

@staticmethod
@return_on_eof
def manipulate_attachments(card):
'''Give the user a CRUD interface for attachments on this card'''
print('Enter a URL, "delete", "open", "print", or Enter to exit')
user_input = 'Nothing really'
attachment_completer = WordCompleter(['delete', 'print', 'open', 'http://', 'https://'], ignore_case=True)
while user_input != '':
user_input = prompt('gtd.py > attach > ', completer=attachment_completer).strip()
if re.search(VALID_URL_REGEX, user_input):
# attach this link
card.attach(url=user_input)
print(f'Attached {user_input}')
elif user_input in ['delete', 'open']:
attachment_opts = {a.name: a for a in card.get_attachments()}
if not attachment_opts:
print('This card is free of attachments')
continue
dest = single_select(attachment_opts.keys())
if dest is not None:
target = attachment_opts[dest]
if user_input == 'delete':
card.remove_attachment(target.id)
elif user_input == 'open':
with DevNullRedirect():
webbrowser.open(target.url)
elif user_input == 'print':
existing_attachments = card.get_attachments()
if existing_attachments:
print('Attachments:')
for a in existing_attachments:
print(' ' + a.name)

@staticmethod
@return_on_eof
def rename(card, default=None, variables={}):
if variables:
print('You can use the following variables in your new card title:')
for k, v in variables.items():
print(f' ${k}: {v}')
suggestion = variables.get('title0', None) or card.name
newname = prompt(f'Input new name for this card (blank for "{default or suggestion}"): ').strip()
if newname:
for k, v in variables.items():
expansion = f'${k}'
if expansion in newname:
newname = newname.replace(expansion, v)
card.set_name(newname)
else:
# If there wasn't a default set for the card name, leave the card name unchanged
card.set_name(default or suggestion)

@staticmethod
@return_on_eof
def set_due_date(card):
'''prompt for the date to set this card due as'''

def validate_date(text):
return re.match(r'\d{2}\/\d{2}\/\d{4}', text) or re.match(r'[A-Z][a-z]{2} \d{2} \d{4}', text)

validator = Validator.from_callable(
validate_date,
error_message='Enter a date in format "Jun 15 2018", "06/15/2018" or "15/06/2018". Ctrl+D to go back',
move_cursor_to_end=True,
)
while True:
user_input = prompt('gtd.py > duedate > ', validator=validator, validate_while_typing=True)
result = parse_user_date_input(user_input)
if result is None:
print('Invalid date format!')
else:
break
card.set_due(result)
card.fetch() # Needed to pick up the new due date
print('Due date set')
return result

@staticmethod
def move_to_list(card, list_choices):
'''Select labels to add to this card
:param trello.Card card: the card to modify
:param dict list_choices: str->trello.List, the names and objects of lists on this board
'''
dest = single_select(sorted(list_choices.keys()))
if dest is not None:
destination_list = list_choices[dest]
card.change_list(destination_list.id)
print(f'Moved to {destination_list.name}')
return destination_list

@staticmethod
def change_description(card):
old_desc = card.desc or ''
new_desc = click.edit(text=old_desc)
if new_desc is not None:
card.set_description(new_desc)
return new_desc


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 check_for_label_presence(card, tags):
'''Take in a comma-sep list of tag names, and ensure that
each is on this card'''
if card['idLabels']:
user_tags = set(tags.split(','))
card_tags = set(card['_labels'])
return user_tags.issubset(card_tags)
else:
return False


class CardView:
'''CardView presents an interface to a stateful set of cards selected by the user, allowing the user
to navigate back and forth between them, delete them from the list, etc.
CardView also translates filtering options from the CLI into parameters to request from Trello, or
filters to post-process the list of cards coming in.
Goals:
Be light on resources. Store a list of IDs and only create Card objects when they are viewed for the first time.
Minimize network calls.
Simplify the API for a command to iterate over a set of selected cards
'''

def __init__(self, context, cards):
self.context = context
self.cards = cards
self.position = 0

def __iter__(self):
return self

def __next__(self):
'''This bridges the class into an iterator that acts equivalently to the current "for card in cardsource" type of usage
It should be replaced with a more elegant way of moving through the cards
'''
if self.position < len(self.cards):
# card = trello.Card.from_json(self.context.board, self.cards[self.position])
card = self.cards[self.position]
self.position += 1
return card
else:
raise StopIteration

def json(self):
return json.dumps(self.cards, sort_keys=True, indent=2)

@staticmethod
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: open/closed/archived/all
if (status := kwargs.get('status', None)) is not None: # noqa
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
# TODO common field selection? Might be able to avoid ones that we don't use at all
# query_params['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)
else:
# If no lists are passed, call the board's card resource
cards_json = context.connection.trello.fetch_json(
f'/boards/{context.board.id}/cards', query_params=query_params
)
target_cards.extend(cards_json)

# Post-process the returned JSON, filtering down to the other passed parameters
filters = []
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)

# Create a CardView with those objects as the base
return CardView(context=context, cards=post_processed_cards)

0 comments on commit 9e5f317

Please sign in to comment.