Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
4aa059d9e6
|
|||
|
0ae283604a
|
|||
| 4d789d674d |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,8 +1,6 @@
|
|||||||
*.swp
|
*.swp
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|
||||||
__pycache__
|
|
||||||
|
|
||||||
tags
|
tags
|
||||||
|
|
||||||
venv
|
venv
|
||||||
|
|||||||
18
LICENSE
18
LICENSE
@@ -1,18 +0,0 @@
|
|||||||
Copyright 2018 Aaron Gutierrez
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
|
||||||
this software and associated documentation files (the "Software"), to deal in
|
|
||||||
the Software without restriction, including without limitation the rights to
|
|
||||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
|
||||||
the Software, and to permit persons to whom the Software is furnished to do so,
|
|
||||||
subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
|
||||||
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
||||||
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
|
||||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
|
||||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
||||||
34
README.md
34
README.md
@@ -1,38 +1,16 @@
|
|||||||
# cmdasana
|
# cmdasana
|
||||||
A curses CLI for Asana, using the Asana API.
|
A curses CLI for Asana, using the Asana API.
|
||||||
|
|
||||||
## Requirments
|
Requirments:
|
||||||
* python 3
|
|
||||||
* [python-asana](https://github.com/Asana/python-asana)
|
* [python-asana](https://github.com/Asana/python-asana)
|
||||||
* [urwid](http://urwid.org)
|
* [urwid (included)](http://urwid.org)
|
||||||
* [python-dateutil](https://github.com/dateutil/dateutil/)
|
* python 2
|
||||||
|
|
||||||
## Setup
|
Usage:
|
||||||
### Create an Asana OAuth app
|
|
||||||
See [instructions from Asana](https://asana.com/developers/documentation/getting-started/auth#register-an-app)
|
|
||||||
on how to create a new app. Use `urn:ietf:wg:oauth:2.0:oob` as the redirect
|
|
||||||
URL.
|
|
||||||
|
|
||||||
Once you create your app, save your client ID and secret in a file `secrets.py`:
|
|
||||||
```python
|
|
||||||
CLIENT_ID='...'
|
|
||||||
CLIENT_SECRET='...'
|
|
||||||
```
|
```
|
||||||
|
make
|
||||||
### Install dependencies
|
./cmdasana.py
|
||||||
Using `pip`:
|
|
||||||
```
|
|
||||||
pip3 install asana urwid python-dateutil
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
```
|
|
||||||
./main.py
|
|
||||||
```
|
```
|
||||||
|
|
||||||
When you first cmdasana, you will need to authorize the app in your browser.
|
When you first cmdasana, you will need to authorize the app in your browser.
|
||||||
Copy and paste your OAuth key into the terminal to get started.
|
Copy and paste your OAuth key into the terminal to get started.
|
||||||
|
|
||||||
## Navigation
|
|
||||||
Use arrow keys to navigate, `<enter>` to "click", and `<backspace>` to return to
|
|
||||||
the previous page.
|
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ class AsanaService(object):
|
|||||||
]
|
]
|
||||||
|
|
||||||
STORY_FIELDS = [
|
STORY_FIELDS = [
|
||||||
'created_at',
|
|
||||||
'created_by.name',
|
'created_by.name',
|
||||||
'html_text',
|
'html_text',
|
||||||
'text',
|
'text',
|
||||||
@@ -75,7 +74,7 @@ class AsanaService(object):
|
|||||||
Task,
|
Task,
|
||||||
self.client.tasks.find_by_project(project_id, params=params)
|
self.client.tasks.find_by_project(project_id, params=params)
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_stories(self, task_id):
|
def get_stories(self, task_id):
|
||||||
stories = self.client.stories.find_by_task(task_id, params = {
|
stories = self.client.stories.find_by_task(task_id, params = {
|
||||||
'opt_fields': self.STORY_FIELDS
|
'opt_fields': self.STORY_FIELDS
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
from datetime import timezone
|
import dateutil
|
||||||
import dateutil.parser
|
|
||||||
from html.parser import HTMLParser
|
from html.parser import HTMLParser
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
@@ -27,7 +26,7 @@ class Task(AsanaObject):
|
|||||||
return super(Task, self).name()
|
return super(Task, self).name()
|
||||||
|
|
||||||
def assignee(self):
|
def assignee(self):
|
||||||
if 'assignee' in self.object_dict and self.object_dict['assignee']:
|
if 'assignee' in self.object_dict:
|
||||||
return User(self.object_dict['assignee'])
|
return User(self.object_dict['assignee'])
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
@@ -40,26 +39,19 @@ class Task(AsanaObject):
|
|||||||
parser = HTMLTextParser()
|
parser = HTMLTextParser()
|
||||||
parser.feed(self.object_dict['html_notes'])
|
parser.feed(self.object_dict['html_notes'])
|
||||||
parser.close()
|
parser.close()
|
||||||
text = parser.get_formatted_text()
|
return parser.get_formatted_text()
|
||||||
if (len(text) > 0):
|
|
||||||
return text
|
|
||||||
else:
|
|
||||||
return ""
|
|
||||||
elif 'notes' in self.object_dict:
|
elif 'notes' in self.object_dict:
|
||||||
return self.object_dict['notes']
|
return self.object_dict['notes']
|
||||||
else:
|
else:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
def due_date(self):
|
def due_date(self):
|
||||||
if 'due_at' in self.object_dict and self.object_dict['due_at']:
|
if 'due_at' in self.object_dict:
|
||||||
datetime = dateutil.parser.parse(self.object_dict['due_at'])
|
return dateutil.parser.parse(self.object_dict['due_at'])
|
||||||
datetime = datetime.replace(tzinfo=timezone.utc).astimezone(tz=None)
|
elif 'due_one' in self.object_dict:
|
||||||
return datetime.strftime('%b %d, %Y %H:%M')
|
return dateutil.parser.parse(self.object_dict['due_on'])
|
||||||
elif 'due_on' in self.object_dict and self.object_dict['due_on']:
|
|
||||||
date = dateutil.parser.parse(self.object_dict['due_on'])
|
|
||||||
return date.strftime('%b %d, %Y')
|
|
||||||
else:
|
else:
|
||||||
return 'no due date'
|
return None
|
||||||
|
|
||||||
def parent(self):
|
def parent(self):
|
||||||
if 'parent' in self.object_dict and self.object_dict['parent']:
|
if 'parent' in self.object_dict and self.object_dict['parent']:
|
||||||
@@ -138,21 +130,6 @@ class Tag(object):
|
|||||||
def text_format(self):
|
def text_format(self):
|
||||||
return self.body.text_format()
|
return self.body.text_format()
|
||||||
|
|
||||||
class List(object):
|
|
||||||
def __init__(self, body):
|
|
||||||
self.body = body
|
|
||||||
|
|
||||||
def text_format(self):
|
|
||||||
return self.body.text_format()
|
|
||||||
|
|
||||||
class ListItem(object):
|
|
||||||
def __init__(self, body, indent):
|
|
||||||
self.body = body
|
|
||||||
self.indent = indent
|
|
||||||
|
|
||||||
def text_format(self):
|
|
||||||
return ('', [(' ' * self.indent), '• ', self.body.text_format(), '\n'])
|
|
||||||
|
|
||||||
class Text(object):
|
class Text(object):
|
||||||
def __init__(self, body):
|
def __init__(self, body):
|
||||||
self.body = body
|
self.body = body
|
||||||
@@ -164,7 +141,6 @@ class HTMLTextParser(HTMLParser):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.text = []
|
self.text = []
|
||||||
self.tag_stack = []
|
self.tag_stack = []
|
||||||
self.indent = 0
|
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
def handle_starttag(self, tag, attrs):
|
def handle_starttag(self, tag, attrs):
|
||||||
@@ -176,11 +152,6 @@ class HTMLTextParser(HTMLParser):
|
|||||||
self.tag_stack.append(Underline)
|
self.tag_stack.append(Underline)
|
||||||
elif tag == 'a':
|
elif tag == 'a':
|
||||||
self.tag_stack.append(Link)
|
self.tag_stack.append(Link)
|
||||||
elif tag == 'ul' or tag == 'ol':
|
|
||||||
self.indent += 2
|
|
||||||
self.tag_stack.append(List)
|
|
||||||
elif tag == 'li':
|
|
||||||
self.tag_stack.append(ListItem)
|
|
||||||
else:
|
else:
|
||||||
self.tag_stack.append(Tag)
|
self.tag_stack.append(Tag)
|
||||||
|
|
||||||
@@ -188,19 +159,14 @@ class HTMLTextParser(HTMLParser):
|
|||||||
self.text.append(Text(data))
|
self.text.append(Text(data))
|
||||||
|
|
||||||
def handle_endtag(self, tag):
|
def handle_endtag(self, tag):
|
||||||
data = self.text.pop() if len(self.text) > 0 else Text("")
|
data = self.text.pop()
|
||||||
Class = self.tag_stack.pop()
|
tag = self.tag_stack.pop()
|
||||||
|
|
||||||
if tag == 'ul' or tag =='ol':
|
self.text.append(tag(data))
|
||||||
self.indent -= 2
|
|
||||||
|
|
||||||
if tag == 'li':
|
|
||||||
self.text.append(Class(data, self.indent))
|
|
||||||
else:
|
|
||||||
self.text.append(Class(data))
|
|
||||||
|
|
||||||
def get_formatted_text(self):
|
def get_formatted_text(self):
|
||||||
formatted = [t.text_format() for t in self.text]
|
formatted = [t.text_format() for t in self.text]
|
||||||
|
print(formatted, file=sys.stderr)
|
||||||
return formatted
|
return formatted
|
||||||
|
|
||||||
|
|
||||||
@@ -211,13 +177,6 @@ class Story(AsanaObject):
|
|||||||
else:
|
else:
|
||||||
return ''
|
return ''
|
||||||
|
|
||||||
def created_at(self):
|
|
||||||
if 'created_at' in self.object_dict:
|
|
||||||
return dateutil.parser.parse(self.object_dict['created_at']) \
|
|
||||||
.replace(tzinfo=timezone.utc).astimezone(tz=None)
|
|
||||||
else:
|
|
||||||
return ''
|
|
||||||
|
|
||||||
def text(self):
|
def text(self):
|
||||||
if 'html_text' in self.object_dict:
|
if 'html_text' in self.object_dict:
|
||||||
parser = HTMLTextParser()
|
parser = HTMLTextParser()
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
asana==0.8.2
|
asana==0.6.5
|
||||||
python-dateutil==2.8.0
|
certifi==2017.11.5
|
||||||
urwid==2.0.1
|
chardet==3.0.4
|
||||||
|
idna==2.6
|
||||||
|
oauthlib==2.0.6
|
||||||
|
requests==2.14.2
|
||||||
|
requests-oauthlib==0.6.2
|
||||||
|
six==1.10.0
|
||||||
|
urllib3==1.22
|
||||||
|
urwid==1.3.1
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
palette = [
|
palette = [
|
||||||
('atm_section', 'white,bold', 'dark blue'),
|
('atm_section', 'white,bold', 'dark blue'),
|
||||||
('author', 'bold,dark blue', ''),
|
('author', 'bold,dark blue', ''),
|
||||||
('timestamp', 'underline', ''),
|
|
||||||
('custom_fields', 'dark red', ''),
|
('custom_fields', 'dark red', ''),
|
||||||
('header', 'bold,light green', ''),
|
('header', 'bold,light green', ''),
|
||||||
('project', 'yellow', ''),
|
('project', 'yellow', ''),
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import urwid
|
import urwid
|
||||||
from datetime import date, datetime
|
|
||||||
|
|
||||||
from ui.task_list import TaskRow
|
from ui.task_list import TaskRow
|
||||||
|
|
||||||
class TaskDetails(object):
|
class TaskDetails(object):
|
||||||
def __init__(self, task, stories, on_subtask_click, on_project_click,
|
def __init__(self, task, stories, on_subtask_click, on_project_click,
|
||||||
on_comment, on_assignee_click, on_due_date_click):
|
on_comment):
|
||||||
self.task = task
|
self.task = task
|
||||||
self.on_subtask_click = on_subtask_click,
|
self.on_subtask_click = on_subtask_click,
|
||||||
self.on_project_click = on_project_click,
|
self.on_project_click = on_project_click,
|
||||||
@@ -13,15 +12,13 @@ class TaskDetails(object):
|
|||||||
|
|
||||||
body = [
|
body = [
|
||||||
urwid.Text(('task', task.name())),
|
urwid.Text(('task', task.name())),
|
||||||
urwid.Divider('⎼'),
|
urwid.Divider('-'),
|
||||||
Memberships(task, on_subtask_click, on_project_click).component(),
|
Memberships(task, on_subtask_click, on_project_click).component(),
|
||||||
urwid.Divider('⎼'),
|
urwid.Divider('-'),
|
||||||
Assignee(task, on_assignee_click).component(),
|
|
||||||
DueDate(task, on_due_date_click).component(),
|
|
||||||
CustomFields(task).component(),
|
CustomFields(task).component(),
|
||||||
urwid.Divider('⎼'),
|
urwid.Divider('='),
|
||||||
urwid.Text(task.description()),
|
urwid.Text(task.description()),
|
||||||
urwid.Divider('⎼'),
|
urwid.Divider('-'),
|
||||||
]
|
]
|
||||||
|
|
||||||
if task.subtasks():
|
if task.subtasks():
|
||||||
@@ -41,45 +38,6 @@ class TaskDetails(object):
|
|||||||
def component(self):
|
def component(self):
|
||||||
return self.details
|
return self.details
|
||||||
|
|
||||||
class Assignee(object):
|
|
||||||
def __init__(self, task, on_click):
|
|
||||||
if task.assignee():
|
|
||||||
assignee = task.assignee().name()
|
|
||||||
else:
|
|
||||||
assignee = "unassigned"
|
|
||||||
|
|
||||||
|
|
||||||
self.assignee = urwid.SelectableIcon([('strong', 'Assignee: '), ('', assignee)])
|
|
||||||
|
|
||||||
self.on_click = on_click
|
|
||||||
#urwid.connect_signal(self.assignee, 'keypress', self.on_keypress)
|
|
||||||
|
|
||||||
def component(self):
|
|
||||||
return self.assignee
|
|
||||||
|
|
||||||
def on_keypress(self, size, key):
|
|
||||||
if key == "enter":
|
|
||||||
self.on_click()
|
|
||||||
else:
|
|
||||||
return key
|
|
||||||
|
|
||||||
class DueDate(object):
|
|
||||||
def __init__(self, task, on_click):
|
|
||||||
due_date = task.due_date()
|
|
||||||
self.due_date = urwid.SelectableIcon([('strong', 'Due: '), ('', str(task.due_date()))])
|
|
||||||
|
|
||||||
self.on_click = on_click
|
|
||||||
#urwid.connect_signal(self.due_date, 'keypress', self.on_keypress)
|
|
||||||
|
|
||||||
def component(self):
|
|
||||||
return self.due_date
|
|
||||||
|
|
||||||
def on_keypress(self, size, key):
|
|
||||||
if key == "enter":
|
|
||||||
self.on_click()
|
|
||||||
else:
|
|
||||||
return key
|
|
||||||
|
|
||||||
class Memberships(object):
|
class Memberships(object):
|
||||||
def __init__(self, task, on_subtask_click, on_project_click):
|
def __init__(self, task, on_subtask_click, on_project_click):
|
||||||
self.on_project_click = on_project_click
|
self.on_project_click = on_project_click
|
||||||
@@ -117,11 +75,7 @@ class CustomFields(object):
|
|||||||
class Stories(object):
|
class Stories(object):
|
||||||
def __init__(self, stories):
|
def __init__(self, stories):
|
||||||
components = [
|
components = [
|
||||||
urwid.Text([
|
urwid.Text([('author', s.creator())] + s.text())
|
||||||
('timestamp', s.created_at().strftime('%Y-%m-%d %H:%M')),
|
|
||||||
' ',
|
|
||||||
('author', s.creator()),
|
|
||||||
] + s.text())
|
|
||||||
for s in stories]
|
for s in stories]
|
||||||
|
|
||||||
self.stories = urwid.Pile(components)
|
self.stories = urwid.Pile(components)
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ class MyTasks(object):
|
|||||||
] + [TaskRow(t, self.callback) for t in self.today] + [
|
] + [TaskRow(t, self.callback) for t in self.today] + [
|
||||||
urwid.Text(('atm_section', 'Upcoming'))
|
urwid.Text(('atm_section', 'Upcoming'))
|
||||||
] + [TaskRow(t, self.callback) for t in self.upcoming] + [
|
] + [TaskRow(t, self.callback) for t in self.upcoming] + [
|
||||||
urwid.Text(('atm_section', 'Later'))
|
urwid.Text(('atm_section', 'Upcoming'))
|
||||||
] + [TaskRow(t, self.callback) for t in self.later]
|
] + [TaskRow(t, self.callback) for t in self.later]
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
|||||||
2
ui/ui.py
2
ui/ui.py
@@ -32,7 +32,7 @@ class Ui(object):
|
|||||||
stories,
|
stories,
|
||||||
self.task_details,
|
self.task_details,
|
||||||
self.task_list,
|
self.task_list,
|
||||||
None, None, None).component())
|
None).component())
|
||||||
thread = Thread(target=runInThread())
|
thread = Thread(target=runInThread())
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user