Skip to content

Commit

Permalink
Added a non menu based cli (#15)
Browse files Browse the repository at this point in the history
* Added standalone CLI 
* Upgraded to Graph v1.0 API
* Improved tod0 API
  • Loading branch information
devzeb authored Dec 13, 2020
1 parent c48797d commit aabd71a
Show file tree
Hide file tree
Showing 12 changed files with 856 additions and 145 deletions.
4 changes: 4 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
prompt_toolkit==3.0.3
requests_oauthlib==1.3.0
PyYAML==5.3

pytz~=2020.4
tzlocal~=2.1
setuptools~=49.2.1
10 changes: 6 additions & 4 deletions setup.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import setuptools
from setuptools import setup, find_packages

setuptools.setup(
setup(
name="tod0",
version="0.5.0",
version="0.6.0",
author="kiblee",
author_email="[email protected]",
packages=["todocli", "todocli.test"],
packages=find_packages(),
url="https://github.com/kiblee/tod0",
license="LICENSE",
description="A Terminal Client for Microsoft To-Do.",
Expand All @@ -14,11 +14,13 @@
"pyyaml",
"requests",
"requests_oauthlib",
"tzlocal",
],
include_package_data=True,
entry_points="""
[console_scripts]
tod0=todocli.interface:run
todocli=todocli.cli:main
""",
python_requires=">=3.6",
)
63 changes: 63 additions & 0 deletions todocli/api_urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
from odata_system_query import ODataSystemQuery

base_api_url = "https://graph.microsoft.com/v1.0/me/todo"


def _lists():
return "{}/lists".format(base_api_url)


def _list(todo_task_list_id):
return "{}/lists/{}".format(base_api_url, todo_task_list_id)


def _tasks(todo_task_list_id):
return "{}/lists/{}/tasks".format(base_api_url, todo_task_list_id)


def _task(todo_task_list_id, task_id):
return "{}/lists/{}/tasks/{}".format(base_api_url, todo_task_list_id, task_id)


def new_list():
return _lists()


def modify_list(todo_task_list_id):
return _list(todo_task_list_id)


def all_lists():
return _lists()


def query_list_id_by_name(list_name):
return (
_lists() + ODataSystemQuery().filter_startsWith("displayName", list_name).get()
)


def new_task(todo_task_list_id):
return _tasks(todo_task_list_id)


def modify_task(todo_task_list_id, task_id):
return _task(todo_task_list_id, task_id)


def query_completed_tasks(todo_task_list_id, num_tasks: int):
return (
_tasks(todo_task_list_id)
+ ODataSystemQuery().filter_ne("status", "completed").top(num_tasks).get()
)


def query_task_by_name(todo_task_list_id, task_name: str):
return (
_tasks(todo_task_list_id)
+ ODataSystemQuery().filter_eq("title", task_name).get()
)


def delete_task(todo_task_list_id, task_id):
return _task(todo_task_list_id, task_id)
155 changes: 14 additions & 141 deletions todocli/auth.py
Original file line number Diff line number Diff line change
@@ -1,100 +1,18 @@
import os
import time
import json
import os
import pickle
import yaml
from requests_oauthlib import OAuth2Session


# Oauth settings
settings = {}
settings["redirect"] = "https://localhost/login/authorized"
settings["scopes"] = "openid offline_access tasks.readwrite"
settings["authority"] = "https://login.microsoftonline.com/common"
settings["authorize_endpoint"] = "/oauth2/v2.0/authorize"
settings["token_endpoint"] = "/oauth2/v2.0/token"


def check_keys(keys):
client_id = keys["client_id"]
client_secret = keys["client_secret"]

if client_id == "" or client_secret == "":
print(
"Please enter your client id and secret in {}".format(
os.path.join(config_dir, "keys.yml")
)
)
print(
"Instructions to getting your API client id and secret can be found here:\n{}".format(
"https://github.com/kiblee/tod0/blob/master/GET_KEY.md"
)
)
exit()


def get_token():
try:
# Try to load token from local
with open(os.path.join(config_dir, "token.pkl"), "rb") as f:
token = pickle.load(f)

token = refresh_token(token)

except Exception:
# Authorize user to get token
outlook = OAuth2Session(client_id, scope=scope, redirect_uri=redirect)

# Redirect the user owner to the OAuth provider
authorization_url, state = outlook.authorization_url(authorize_url)
print("Please go here and authorize:\n", authorization_url)

# Get the authorization verifier code from the callback url
redirect_response = input("Paste the full redirect URL below:\n")

# Fetch the access token
token = outlook.fetch_token(
token_url,
client_secret=client_secret,
authorization_response=redirect_response,
)

store_token(token)
return token
from todocli.oauth import get_oauth_session, config_dir


def store_token(token):
with open(os.path.join(config_dir, "token.pkl"), "wb") as f:
pickle.dump(token, f)


def refresh_token(token):
# Check expiration
now = time.time()
# Subtract 5 minutes from expiration to account for clock skew
expire_time = token["expires_at"] - 300
if now >= expire_time:
# Refresh the token
aad_auth = OAuth2Session(
client_id, token=token, scope=scope, redirect_uri=redirect
)

refresh_params = {"client_id": client_id, "client_secret": client_secret}

new_token = aad_auth.refresh_token(token_url, **refresh_params)
return new_token

# Token still valid, just return it
return token
base_api_url = "https://graph.microsoft.com/beta/me/outlook/"


def parse_contents(response):
return json.loads(response.content.decode())["value"]


def list_tasks(all_=False, folder=""):
token = get_token()
outlook = OAuth2Session(client_id, scope=scope, token=token)
outlook = get_oauth_session()

if folder == "":
if all_:
Expand All @@ -119,8 +37,7 @@ def list_tasks(all_=False, folder=""):


def list_and_update_folders():
token = get_token()
outlook = OAuth2Session(client_id, scope=scope, token=token)
outlook = get_oauth_session()
o = outlook.get("{}/taskFolders?top=20".format(base_api_url))
contents = parse_contents(o)

Expand All @@ -143,12 +60,10 @@ def list_and_update_folders():

def create_folder(name):
"""Create folder with name `name`"""
token = get_token()
outlook = OAuth2Session(client_id, scope=scope, token=token)
outlook = get_oauth_session()

# Fill request body
request_body = {}
request_body["name"] = name
request_body = {"name": name}

o = outlook.post("{}/taskFolders".format(base_api_url), json=request_body)

Expand All @@ -157,20 +72,17 @@ def create_folder(name):

def delete_folder(folder_id):
"""Delete folder with id `folder_id`"""
token = get_token()
outlook = OAuth2Session(client_id, scope=scope, token=token)
outlook = get_oauth_session()
o = outlook.delete("{}/taskFolders/{}".format(base_api_url, folder_id))
return o.ok


def create_task(text, folder=None):
"""Create task with subject `text`"""
token = get_token()
outlook = OAuth2Session(client_id, scope=scope, token=token)
outlook = get_oauth_session()

# Fill request body
request_body = {}
request_body["subject"] = text
request_body = {"subject": text}

if folder is None:
o = outlook.post("{}/tasks".format(base_api_url), json=request_body)
Expand All @@ -183,53 +95,14 @@ def create_task(text, folder=None):


def delete_task(task_id):
token = get_token()
outlook = OAuth2Session(client_id, scope=scope, token=token)
outlook = get_oauth_session()

o = outlook.delete("{}/tasks/{}".format(base_api_url, task_id))
return o.ok


def complete_task(task_id):
token = get_token()
outlook = OAuth2Session(client_id, scope=scope, token=token)
outlook = get_oauth_session()

o = outlook.post("{}/tasks/{}/complete".format(base_api_url, task_id))
return o.ok


# Code taken from https://docs.microsoft.com/en-us/graph/tutorials/python?tutorial-step=3

# This is necessary because Azure does not guarantee
# to return scopes in the same case and order as requested
os.environ["OAUTHLIB_RELAX_TOKEN_SCOPE"] = "1"
os.environ["OAUTHLIB_IGNORE_SCOPE_CHANGE"] = "1"

redirect = settings["redirect"]
scope = settings["scopes"]

authorize_url = "{0}{1}".format(settings["authority"], settings["authorize_endpoint"])
token_url = "{0}{1}".format(settings["authority"], settings["token_endpoint"])

base_api_url = "https://graph.microsoft.com/beta/me/outlook/"

# User settings location
config_dir = "{}/.config/tod0".format(os.path.expanduser("~"))
if not os.path.isdir(config_dir):
os.makedirs(config_dir)

# Check for api keys
keys_path = os.path.join(config_dir, "keys.yml")
if not os.path.isfile(keys_path):
keys = {}
keys["client_id"] = ""
keys["client_secret"] = ""
with open(keys_path, "w") as f:
yaml.dump(keys, f)
check_keys(keys)
else:
# Load api keys
with open(keys_path) as f:
keys = yaml.load(f, yaml.SafeLoader)
check_keys(keys)

client_id = keys["client_id"]
client_secret = keys["client_secret"]
Loading

0 comments on commit aabd71a

Please sign in to comment.