-
Notifications
You must be signed in to change notification settings - Fork 10
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
1 changed file
with
328 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |