2018-11-23 22:36:32 +01:00
|
|
|
import click
|
|
|
|
import os
|
|
|
|
import sys
|
|
|
|
|
|
|
|
from taskw import TaskWarrior
|
|
|
|
from todoist.api import TodoistAPI
|
2019-07-13 11:34:03 +02:00
|
|
|
from . import errors, io, utils, validation
|
2018-11-23 22:36:32 +01:00
|
|
|
|
2019-03-25 22:08:24 +01:00
|
|
|
|
|
|
|
# This is the location where the todoist
|
|
|
|
# data will be cached.
|
|
|
|
TODOIST_CACHE = '~/.todoist-sync/'
|
|
|
|
|
|
|
|
|
2018-11-23 22:36:32 +01:00
|
|
|
todoist = None
|
|
|
|
taskwarrior = None
|
|
|
|
|
2019-03-25 22:08:24 +01:00
|
|
|
|
2018-11-23 22:36:32 +01:00
|
|
|
""" CLI Commands """
|
|
|
|
|
|
|
|
@click.group()
|
|
|
|
def cli():
|
2019-03-23 22:01:51 +01:00
|
|
|
"""Manage the migration of data from Todoist into Taskwarrior. """
|
2018-11-23 22:36:32 +01:00
|
|
|
pass
|
|
|
|
|
|
|
|
|
2019-03-23 20:56:26 +01:00
|
|
|
@cli.command()
|
|
|
|
def synchronize():
|
2019-03-23 22:01:51 +01:00
|
|
|
"""Update the local Todoist task cache.
|
2019-03-30 18:34:11 +01:00
|
|
|
|
2019-03-23 22:01:51 +01:00
|
|
|
This command accesses Todoist via the API and updates a local
|
|
|
|
cache before exiting. This can be useful to pre-load the tasks,
|
|
|
|
and means `migrate` can be run without a network connection.
|
|
|
|
|
|
|
|
NOTE - the local Todoist data cache is usually located at:
|
|
|
|
|
|
|
|
~/.todoist-sync
|
|
|
|
"""
|
2019-03-24 16:25:20 +01:00
|
|
|
with io.with_feedback('Syncing tasks with todoist'):
|
|
|
|
todoist.sync()
|
2019-03-23 20:56:26 +01:00
|
|
|
|
|
|
|
|
2019-03-25 22:08:24 +01:00
|
|
|
@cli.command()
|
|
|
|
@click.confirmation_option(prompt=f'Are you sure you want to delete {TODOIST_CACHE}?')
|
|
|
|
def clean():
|
|
|
|
"""Remove the data stored in the Todoist task cache.
|
|
|
|
|
|
|
|
NOTE - the local Todoist data cache is usually located at:
|
|
|
|
|
|
|
|
~/.todoist-sync
|
|
|
|
"""
|
|
|
|
cache_dir = os.path.expanduser(TODOIST_CACHE)
|
|
|
|
|
|
|
|
# Delete all files in directory
|
|
|
|
for file_entry in os.scandir(cache_dir):
|
|
|
|
with io.with_feedback(f'Removing file {file_entry.path}'):
|
|
|
|
os.remove(file_entry)
|
|
|
|
|
|
|
|
# Delete directory
|
|
|
|
with io.with_feedback(f'Removing directory {cache_dir}'):
|
|
|
|
os.rmdir(cache_dir)
|
|
|
|
|
|
|
|
|
2018-11-23 22:36:32 +01:00
|
|
|
@cli.command()
|
2019-03-23 22:01:51 +01:00
|
|
|
@click.option('-i', '--interactive', is_flag=True, default=False,
|
|
|
|
help='Interactively choose which tasks to import and modify them '
|
|
|
|
'during the import.')
|
|
|
|
@click.option('--sync/--no-sync', default=True,
|
|
|
|
help='Enable/disable Todoist synchronization of the local task cache.')
|
2019-03-30 18:34:11 +01:00
|
|
|
@click.option('-p', '--map-project', metavar='SRC=DST', multiple=True,
|
2019-07-13 11:34:03 +02:00
|
|
|
callback=validation.validate_map,
|
2019-03-30 18:34:11 +01:00
|
|
|
help='Project names specified will be translated from SRC to DST. '
|
|
|
|
'If DST is omitted, the project will be unset when SRC matches.')
|
2019-06-09 20:30:47 +02:00
|
|
|
@click.option('-t', '--map-tag', metavar='SRC=DST', multiple=True,
|
2019-07-13 11:34:03 +02:00
|
|
|
callback=validation.validate_map,
|
2019-06-09 20:30:47 +02:00
|
|
|
help='Tags specified will be translated from SRC to DST. '
|
|
|
|
'If DST is omitted, the tag will be removed when SRC matches.')
|
2019-03-23 20:56:26 +01:00
|
|
|
@click.pass_context
|
2019-06-09 20:30:47 +02:00
|
|
|
def migrate(ctx, interactive, sync, map_project, map_tag):
|
2019-03-23 22:01:51 +01:00
|
|
|
"""Migrate tasks from Todoist to Taskwarrior.
|
|
|
|
|
|
|
|
By default this command will synchronize with the Todoist servers
|
|
|
|
and then migrate all tasks to Taskwarrior.
|
|
|
|
|
|
|
|
Pass --no-sync to skip synchronization.
|
|
|
|
|
|
|
|
Passing -i or --interactive allows more control over the import, putting
|
|
|
|
the user into an interactive command loop. Per task, the user can decide
|
|
|
|
whether to skip, rename, change the priority, or change the tags, before
|
|
|
|
moving on to the next task.
|
|
|
|
|
2019-06-21 01:25:14 +02:00
|
|
|
Use --map-project to change or remove the project. Project hierarchies will
|
|
|
|
be period-delimited during conversion. For example in the following,
|
|
|
|
'Work Errands' and 'House Errands' will be both be changed to 'errands',
|
|
|
|
'Programming.Open Source' will be changed to 'oss', and the project will be
|
|
|
|
removed when it is 'Taxes':
|
2019-03-30 18:34:11 +01:00
|
|
|
\r
|
2019-06-21 01:25:14 +02:00
|
|
|
--map-project 'Work Errands'=errands
|
|
|
|
--map-project 'House Errands'=errands
|
|
|
|
--map-project 'Programming.Open Source'=oss
|
|
|
|
--map-project Taxes=
|
2019-03-30 18:34:11 +01:00
|
|
|
|
2019-03-23 22:01:51 +01:00
|
|
|
This command can be run multiple times and will not duplicate tasks.
|
|
|
|
This is tracked in Taskwarrior by setting and detecting the
|
|
|
|
`todoist_id` property on the task.
|
|
|
|
"""
|
|
|
|
|
2019-03-23 20:56:26 +01:00
|
|
|
if sync:
|
|
|
|
ctx.invoke(synchronize)
|
2018-11-23 22:36:32 +01:00
|
|
|
|
|
|
|
tasks = todoist.items.all()
|
2019-03-25 22:18:03 +01:00
|
|
|
io.important(f'Starting migration of {len(tasks)} tasks...')
|
2018-11-24 18:48:48 +01:00
|
|
|
for idx, task in enumerate(tasks):
|
|
|
|
data = {}
|
2019-01-21 21:17:38 +01:00
|
|
|
tid = data['tid'] = task['id']
|
|
|
|
name = data['name'] = task['content']
|
2019-06-21 01:25:14 +02:00
|
|
|
|
|
|
|
# Project
|
|
|
|
p = todoist.projects.get_by_id(task['project_id'])
|
|
|
|
project_hierarchy = [p]
|
|
|
|
while p['parent_id']:
|
|
|
|
p = todoist.projects.get_by_id(p['parent_id'])
|
|
|
|
project_hierarchy.insert(0, p)
|
|
|
|
|
|
|
|
project_name = '.'.join(p['name'] for p in project_hierarchy)
|
|
|
|
project_name = utils.try_map(
|
2019-03-30 18:34:11 +01:00
|
|
|
map_project,
|
2019-06-21 01:25:14 +02:00
|
|
|
project_name
|
2019-03-30 18:34:11 +01:00
|
|
|
)
|
2019-06-21 01:25:14 +02:00
|
|
|
data['project'] = utils.maybe_quote_ws(project_name)
|
|
|
|
|
|
|
|
# Priority
|
2018-11-24 18:48:48 +01:00
|
|
|
data['priority'] = utils.parse_priority(task['priority'])
|
2019-06-21 01:25:14 +02:00
|
|
|
|
|
|
|
# Tags
|
2018-11-24 18:48:48 +01:00
|
|
|
data['tags'] = [
|
2019-06-09 20:30:47 +02:00
|
|
|
utils.try_map(map_tag, todoist.labels.get_by_id(l_id)['name'])
|
2018-11-23 22:36:32 +01:00
|
|
|
for l_id in task['labels']
|
|
|
|
]
|
2019-06-21 01:25:14 +02:00
|
|
|
|
|
|
|
# Dates
|
2018-11-24 18:48:48 +01:00
|
|
|
data['entry'] = utils.parse_date(task['date_added'])
|
|
|
|
data['due'] = utils.parse_date(task['due_date_utc'])
|
|
|
|
data['recur'] = utils.parse_recur(task['date_string'])
|
2018-11-23 22:36:32 +01:00
|
|
|
|
2019-03-24 12:47:06 +01:00
|
|
|
io.important(f'Task {idx + 1} of {len(tasks)}: {name}')
|
2019-01-21 21:17:38 +01:00
|
|
|
|
|
|
|
if check_task_exists(tid):
|
2019-03-24 12:47:06 +01:00
|
|
|
io.info(f'Already exists (todoist_id={tid})')
|
2019-01-21 21:17:38 +01:00
|
|
|
elif not interactive:
|
2018-11-24 18:48:48 +01:00
|
|
|
add_task(**data)
|
|
|
|
else:
|
2019-03-24 16:29:02 +01:00
|
|
|
add_task_interactive(**data)
|
2019-01-21 21:17:38 +01:00
|
|
|
|
|
|
|
|
|
|
|
def check_task_exists(tid):
|
|
|
|
""" Given a Taskwarrior ID, check if the task exists """
|
2019-07-13 11:43:25 +02:00
|
|
|
_, task = taskwarrior.get_task(todoist_id=tid)
|
|
|
|
return bool(task)
|
2018-11-23 22:36:32 +01:00
|
|
|
|
|
|
|
|
|
|
|
def add_task(tid, name, project, tags, priority, entry, due, recur):
|
|
|
|
"""Add a taskwarrior task from todoist task
|
|
|
|
|
|
|
|
Returns the taskwarrior task.
|
|
|
|
"""
|
2019-03-24 16:25:20 +01:00
|
|
|
with io.with_feedback(f"Importing '{name}' ({project})"):
|
|
|
|
return taskwarrior.task_add(
|
2019-01-21 21:17:38 +01:00
|
|
|
name,
|
|
|
|
project=project,
|
|
|
|
tags=tags,
|
|
|
|
priority=priority,
|
|
|
|
entry=entry,
|
|
|
|
due=due,
|
|
|
|
recur=recur,
|
|
|
|
todoist_id=tid,
|
|
|
|
)
|
2018-11-23 22:36:32 +01:00
|
|
|
|
|
|
|
|
2019-03-24 16:29:02 +01:00
|
|
|
def add_task_interactive(**task_data):
|
2018-11-24 18:48:48 +01:00
|
|
|
"""Interactively add tasks
|
|
|
|
|
|
|
|
y - add task
|
|
|
|
n - skip task
|
2019-07-13 11:34:03 +02:00
|
|
|
d - change description
|
2018-11-24 18:48:48 +01:00
|
|
|
p - change priority
|
|
|
|
t - change tags
|
2019-07-13 11:34:03 +02:00
|
|
|
r - change recur
|
2019-03-23 21:15:14 +01:00
|
|
|
q - quit immediately
|
2018-11-24 18:48:48 +01:00
|
|
|
? - print help
|
|
|
|
"""
|
|
|
|
callbacks = {
|
|
|
|
'y': lambda: task_data,
|
|
|
|
'n': lambda: task_data,
|
|
|
|
|
|
|
|
# Rename
|
2019-07-13 11:34:03 +02:00
|
|
|
'd': lambda: {
|
2018-11-24 18:48:48 +01:00
|
|
|
**task_data,
|
2019-03-24 12:47:06 +01:00
|
|
|
'name': io.prompt(
|
|
|
|
'Set name',
|
|
|
|
default=task_data['name'],
|
|
|
|
value_proc=lambda x: x.strip(),
|
|
|
|
),
|
2018-11-24 18:48:48 +01:00
|
|
|
},
|
|
|
|
|
|
|
|
# Edit tags
|
|
|
|
't': lambda: {
|
|
|
|
**task_data,
|
2019-03-24 12:47:06 +01:00
|
|
|
'tags': io.prompt(
|
|
|
|
'Set tags (space delimited)',
|
|
|
|
default=' '.join(task_data['tags']),
|
|
|
|
show_default=False,
|
|
|
|
value_proc=lambda x: x.split(' '),
|
|
|
|
),
|
2018-11-24 18:48:48 +01:00
|
|
|
},
|
|
|
|
|
|
|
|
# Edit priority
|
|
|
|
'p': lambda: {
|
|
|
|
**task_data,
|
2019-03-24 12:47:06 +01:00
|
|
|
'priority': io.prompt(
|
|
|
|
'Set priority',
|
|
|
|
default='',
|
|
|
|
show_default=False,
|
2019-03-24 12:55:10 +01:00
|
|
|
type=click.Choice(['L', 'M', 'H', '']),
|
2019-03-24 12:47:06 +01:00
|
|
|
),
|
2018-11-24 18:48:48 +01:00
|
|
|
},
|
|
|
|
|
2019-07-13 11:34:03 +02:00
|
|
|
# Edit recur
|
|
|
|
'r': lambda: {
|
|
|
|
**task_data,
|
|
|
|
'recur': io.prompt(
|
|
|
|
'Set recurrence (todoist style)',
|
|
|
|
default=task_data['recur'],
|
|
|
|
value_proc=validation.validate_recur,
|
|
|
|
),
|
|
|
|
},
|
|
|
|
|
|
|
|
|
2019-03-23 21:15:14 +01:00
|
|
|
# Quit
|
|
|
|
'q': lambda: exit(1),
|
|
|
|
|
2018-11-24 18:48:48 +01:00
|
|
|
# Help message
|
2019-03-24 12:47:06 +01:00
|
|
|
# Note: this echoes prompt help and then returns the
|
|
|
|
# task_data unchanged.
|
|
|
|
'?': lambda: io.warn('\n'.join([
|
|
|
|
x.strip() for x in
|
2019-03-24 16:29:02 +01:00
|
|
|
add_task_interactive.__doc__.split('\n')
|
2019-03-24 12:47:06 +01:00
|
|
|
])) or task_data,
|
2018-11-24 18:48:48 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
response = None
|
|
|
|
while response not in ('y', 'n'):
|
2019-03-24 12:47:06 +01:00
|
|
|
io.task(task_data)
|
|
|
|
response = io.prompt(
|
|
|
|
"Import this task?",
|
2018-11-24 18:48:48 +01:00
|
|
|
type=click.Choice(callbacks.keys()),
|
|
|
|
show_choices=True,
|
|
|
|
)
|
|
|
|
|
|
|
|
# Execute operation
|
|
|
|
task_data = callbacks[response]()
|
|
|
|
|
|
|
|
if response == 'n':
|
2019-03-24 12:47:06 +01:00
|
|
|
io.warn('Skipping task')
|
2018-11-24 18:48:48 +01:00
|
|
|
return
|
|
|
|
|
|
|
|
return add_task(**task_data)
|
|
|
|
|
|
|
|
|
2018-11-23 22:36:32 +01:00
|
|
|
""" Entrypoint """
|
|
|
|
|
|
|
|
if __name__ == '__main__':
|
|
|
|
is_help_cmd = '-h' in sys.argv or '--help' in sys.argv
|
|
|
|
todoist_api_key = os.getenv('TODOIST_API_KEY')
|
|
|
|
if todoist_api_key is None and not is_help_cmd:
|
2019-03-24 12:47:06 +01:00
|
|
|
io.error('TODOIST_API_KEY environment variable not specified. Exiting.')
|
|
|
|
exit(1)
|
2018-11-23 22:36:32 +01:00
|
|
|
|
2019-03-25 22:08:24 +01:00
|
|
|
todoist = TodoistAPI(todoist_api_key, cache=TODOIST_CACHE)
|
2019-01-21 21:17:38 +01:00
|
|
|
|
|
|
|
# Create the TaskWarrior client, overriding config to
|
|
|
|
# create a `todoist_id` field which we'll use to
|
|
|
|
# prevent duplicates
|
|
|
|
taskwarrior = TaskWarrior(config_overrides={
|
|
|
|
'uda.todoist_id.type': 'string',
|
|
|
|
})
|
2018-11-23 22:36:32 +01:00
|
|
|
cli()
|
|
|
|
|