Add twoway sync

This commit is contained in:
René Jochum 2019-08-05 04:16:17 +02:00
parent 81ee6d16dc
commit 74da3bee81
No known key found for this signature in database
GPG Key ID: 9E8B1C32F5F318A9
10 changed files with 468 additions and 252 deletions

2
.gitignore vendored
View File

@ -38,3 +38,5 @@ pip-delete-this-directory.txt
# Environments # Environments
venv/ venv/
# vscode
.vscode/

View File

@ -1,6 +1,7 @@
MIT License MIT License
Copyright (c) 2018 Matt Snider Copyright (c) 2018 Matt Snider
Copyright (c) 2019 Webmeisterei Informationstechnologie GmbH
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

View File

@ -1,53 +1,54 @@
# todoist-taskwarrior # todoist-taskwarrior
A tool for migrating Todoist tasks to Taskwarrior. A tool for syncing Todoist tasks with Taskwarrior.
## Installation
```bash
git clone https://git.webmeisterei.com/webmeisterei/todoist-taskwarrior.git
cd todoist-taskwarrior/
```
- To install in Virtualenv:
```bash
virtualenv -p /usr/bin/python3 venv
venv/bin/pip install -r requirements.txt
venv/bin/python setup.py install
```
- To install global:
```bash
sudo pip3 install -r requirements.txt
sudo python3 setup.py install
```
## Configure
First optain a Todoist API key from the [Todoist Integrations Settings](https://todoist.com/prefs/integrations).
Now you can configure `titwsync` with (replace `./venv/bin/titwsync` with `titwsync` if you use todoist_taskwarrior without a virtualenv):
```sh
./venv/bin/titwsync configure --map-project Inbox= --map-project Company=work --map-project Company.SubProject=work.subproject --map-tag books=reading <TODOIST_API_KEY>
```
`titwsync configure` writes the configuration to `~/.titwsyncrc.yaml`, with the key: `taskwarrior.project_sync.PROJECT_NAME` you can enable or disable the sync of a whole project!
## Usage ## Usage
Running the tool requires that your Todoist API key is available from the Running the tool requires that your Todoist API key is available from the
environment under the name `TODOIST_API_KEY`. The key can be found or created in environment under the name `TODOIST_API_KEY`. The key can be found or created in
the [Todoist Integrations Settings](https://todoist.com/prefs/integrations). the ).
The main task is `migrate` which will import all tasks. Since Todoist's internal The main task is `sync` which will sync all tasks. Since Todoist's internal
ID is saved with the task, subsequent runs will detect and skip duplicates: ID is saved with the task, subsequent runs will detect and skip duplicates:
```sh Replace `./venv/bin/titwsync` with `titwsync` if you use todoist_taskwarrior without a virtualenv.
$ python -m todoist_taskwarrior.cli migrate --help
Usage: cli.py migrate [OPTIONS]
Options:
-i, --interactive
--no-sync
--help Show this message and exit.
```
Using the `--interactive` flag will prompt the user for input for each task,
allowing the task to be modified before import:
```sh ```sh
$ python -m todoist_taskwarrior.cli migrate --interactive ./venv/bin/titwsync sync
Task 1 of 315: Work on an open source project
tid: 142424242
name: Work on an open source project
project: Open Source
priority:
tags:
entry: 2019-01-18T12:00:00+00:00
due: 2019-01-21T17:00:00+00:00
recur: 3 days
```
By default, `migrate` will refetch all tasks from Todoist on each run. To skip
this step and use the cached data without refetching, use the --no-sync flag.
The flags `--map-project` and `--map-tag` can be specified multiple times to translate or completely remove specific flags
```sh
$ python -m todoist_taskwarrior.cli migrate \
--map-project Errands=chores \
--map-project 'XYZ Corp'=work \
--map-tag books=reading
``` ```
## Development ## Development
@ -55,6 +56,14 @@ $ python -m todoist_taskwarrior.cli migrate \
### Testing ### Testing
```sh ```sh
$ python -m pytest tests python -m pytest tests
``` ```
## License
Licensed under the MIT license.
## Authors
- 2018-2019 [matt-snider](https://github.com/matt-snider/todoist-taskwarrior)
- 2019- [webmeisterei](https://git.webmeisterei.com/webmeisterei/todoist-taskwarrior)

View File

@ -1,6 +0,0 @@
# TODO:
* `revert` command that deletes all tasks with `todoist_id`
* Save skipped list so we don't prompt user on next run
* Allow input of scheduled, wait

View File

@ -1,5 +1,6 @@
Click==7.0 Click==7.0
todoist-python==8.0.0 todoist-python==8.0.0
pyyaml
# Temporarily use this until upstream PR #121 is merged # Temporarily use this until upstream PR #121 is merged
# https://github.com/ralphbean/taskw/pull/121 # https://github.com/ralphbean/taskw/pull/121

37
setup.py Normal file
View File

@ -0,0 +1,37 @@
# -*- coding: utf-8 -*-
from setuptools import find_packages
from setuptools import setup
setup(
name='todoist_taskwarrior',
version='0.1.0.dev0',
description="Todoist <-> Taskwarrior two-way sync",
classifiers=[
"Programming Language :: Python",
"Programming Language :: Python :: 3.5",
"Programming Language :: Python :: 3.6",
"Programming Language :: Python :: 3.7",
],
keywords='Todoist Taskwarrior',
author='René Jochum',
author_email='rene@webmeisterei.com',
url='https://git.webmeisterei.com/webmeisterei/todoist-taskwarrior',
license='MIT',
packages=find_packages('.', exclude=['ez_setup']),
package_dir={'': '.'},
include_package_data=True,
zip_safe=True,
install_requires=[
'setuptools',
'Click',
'todoist-python',
'taskw',
],
entry_points={
'console_scripts': [
'titwsync=todoist_taskwarrior.cli:cli'
],
}
)

View File

@ -1,27 +1,88 @@
import click import click
import os import os
import sys import sys
import datetime
import dateutil.parser
import io
import yaml
from taskw import TaskWarrior from taskw import TaskWarrior
from todoist.api import TodoistAPI from todoist.api import TodoistAPI
from . import errors, io, utils, validation from . import errors, log, utils, validation
# This is the location where the todoist # This is the location where the todoist
# data will be cached. # data will be cached.
TODOIST_CACHE = '~/.todoist-sync/' TODOIST_CACHE = '~/.todoist-sync/'
TITWSYNCRC = '~/.titwsyncrc.yaml'
config = None
todoist = None todoist = None
taskwarrior = None taskwarrior = None
""" CLI Commands """ """ CLI Commands """
@click.group() @click.group()
def cli(): def cli():
"""Manage the migration of data from Todoist into Taskwarrior. """ """Two-way sync of Todoist and Taskwarrior. """
pass global config, todoist, taskwarrior
is_help_cmd = '-h' in sys.argv or '--help' in sys.argv
rcfile = os.path.expanduser(TITWSYNCRC)
with open(rcfile, 'r') as stream:
config = yaml.safe_load(stream)
if 'todoist' not in config or 'api_key' not in config['todoist'] \
and not is_help_cmd:
log.error('Run configure first. Exiting.')
exit(1)
todoist = TodoistAPI(config['todoist']['api_key'], cache=TODOIST_CACHE)
# 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',
'uda.todoist_sync.type': 'date',
})
@cli.command()
@click.option('-p', '--map-project', metavar='SRC=DST', multiple=True,
callback=validation.validate_map,
help='Project names specified will be translated '
'from SRC to DST. '
'If DST is omitted, the project will be unset when SRC matches.')
@click.option('-t', '--map-tag', metavar='SRC=DST', multiple=True,
callback=validation.validate_map,
help='Tags specified will be translated from SRC to DST. '
'If DST is omitted, the tag will be removed when SRC matches.')
@click.argument('todoist_api_key')
def configure(map_project, map_tag, todoist_api_key):
"""Configure sync.
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':
\r
--map-project 'Work Errands'=errands
--map-project 'House Errands'=errands
--map-project 'Programming.Open Source'=oss
--map-project Taxes=
"""
data = {'todoist': {'api_key': todoist_api_key}, 'taskwarrior': {}}
data['todoist']['project_map'] = map_project
data['todoist']['tag_map'] = map_tag
data['taskwarrior']['project_sync'] = {
k: True for k in map_project.values()}
rcfile = os.path.expanduser(TITWSYNCRC)
with io.open(rcfile, 'w', encoding='utf8') as outfile:
yaml.dump(data, outfile, default_flow_style=False, allow_unicode=True)
@cli.command() @cli.command()
@ -36,12 +97,13 @@ def synchronize():
~/.todoist-sync ~/.todoist-sync
""" """
with io.with_feedback('Syncing tasks with todoist'): with log.with_feedback('Syncing tasks with todoist'):
todoist.sync() todoist.sync()
@cli.command() @cli.command()
@click.confirmation_option(prompt=f'Are you sure you want to delete {TODOIST_CACHE}?') @click.confirmation_option(
prompt=f'Are you sure you want to delete {TODOIST_CACHE}?')
def clean(): def clean():
"""Remove the data stored in the Todoist task cache. """Remove the data stored in the Todoist task cache.
@ -53,238 +115,340 @@ def clean():
# Delete all files in directory # Delete all files in directory
for file_entry in os.scandir(cache_dir): for file_entry in os.scandir(cache_dir):
with io.with_feedback(f'Removing file {file_entry.path}'): with log.with_feedback(f'Removing file {file_entry.path}'):
os.remove(file_entry) os.remove(file_entry)
# Delete directory # Delete directory
with io.with_feedback(f'Removing directory {cache_dir}'): with log.with_feedback(f'Removing directory {cache_dir}'):
os.rmdir(cache_dir) os.rmdir(cache_dir)
@cli.command() @cli.command()
@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.')
@click.option('-p', '--map-project', metavar='SRC=DST', multiple=True,
callback=validation.validate_map,
help='Project names specified will be translated from SRC to DST. '
'If DST is omitted, the project will be unset when SRC matches.')
@click.option('-t', '--map-tag', metavar='SRC=DST', multiple=True,
callback=validation.validate_map,
help='Tags specified will be translated from SRC to DST. '
'If DST is omitted, the tag will be removed when SRC matches.')
@click.pass_context @click.pass_context
def migrate(ctx, interactive, sync, map_project, map_tag): def sync(ctx):
"""Migrate tasks from Todoist to Taskwarrior. """Sync tasks between Todoist and 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.
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':
\r
--map-project 'Work Errands'=errands
--map-project 'House Errands'=errands
--map-project 'Programming.Open Source'=oss
--map-project Taxes=
This command can be run multiple times and will not duplicate tasks. This command can be run multiple times and will not duplicate tasks.
This is tracked in Taskwarrior by setting and detecting the This is tracked in Taskwarrior by setting and detecting the
`todoist_id` property on the task. `todoist_id` property on the task.
""" """
if sync: # Sync todoist to cache
ctx.invoke(synchronize) ctx.invoke(synchronize)
tasks = todoist.items.all() ti_project_list = _ti_project_list()
io.important(f'Starting migration of {len(tasks)} tasks...') default_project = utils.try_map(config['todoist']['project_map'], 'Inbox')
for idx, task in enumerate(tasks):
data = {}
tid = data['tid'] = task['id']
name = data['name'] = task['content']
# Log message and check if exists config_ps = config['taskwarrior']['project_sync']
io.important(f'Task {idx + 1} of {len(tasks)}: {name}')
if check_task_exists(tid): # Sync Taskwarrior->Todoist
io.info(f'Already exists (todoist_id={tid})') tw_tasks = taskwarrior.load_tasks()
log.important(f'Starting to sync tasks from Taskwarrior...')
for tw_task in tw_tasks['pending']:
if 'project' not in tw_task:
tw_task['project'] = default_project
desc = tw_task['description']
project = tw_task['project']
if (tw_task['project'] not in config_ps or
not config_ps[tw_task['project']]):
log.warn(f'Ignoring Task {desc} ({project})')
continue continue
# Project # Log message
p = todoist.projects.get_by_id(task['project_id']) log.important(f'Sync Task {desc} ({project})')
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) if 'todoist_id' in tw_task:
project_name = utils.try_map( ti_task = todoist.items.get_by_id(tw_task['todoist_id'])
map_project, if ti_task is None:
project_name # Ti task has been deleted
) taskwarrior.task_delete(uuid=tw_task['uuid'])
data['project'] = utils.maybe_quote_ws(project_name) continue
# Priority ti_task = ti_task['item']
data['priority'] = utils.parse_priority(task['priority']) c_ti_task = _convert_ti_task(ti_task, ti_project_list)
# Tags # Sync Todoist with Taskwarrior task
data['tags'] = [ _sync_task(tw_task, c_ti_task, ti_project_list)
utils.try_map(map_tag, todoist.labels.get_by_id(l_id)['name']) continue
for l_id in task['labels']
]
# Dates # Add Todoist task
data['entry'] = utils.parse_date(task['date_added']) _ti_add_task(tw_task, ti_project_list)
data['due'] = utils.parse_due(utils.try_get_model_prop(task, 'due'))
data['recur'] = parse_recur_or_prompt(utils.try_get_model_prop(task, 'due'))
if not interactive: with log.with_feedback('Syncing tasks with todoist'):
add_task(**data) todoist.commit()
todoist.sync()
# Sync Todoist->Taskwarrior
tasks = todoist.items.all()
log.important(f'Starting sync of {len(tasks)} tasks from Todoist...')
for idx, ti_task in enumerate(tasks):
c_ti_task = _convert_ti_task(ti_task, ti_project_list)
desc = c_ti_task['description']
project = c_ti_task['project']
if (c_ti_task['project'] not in config_ps or
not config_ps[c_ti_task['project']]):
log.warn(f'Ignoring Task {desc} ({project})')
continue
# Log message
log.important(f'Sync Task {desc} ({project})')
# Sync Todoist with Taskwarrior task
_, tw_task = taskwarrior.get_task(todoist_id=ti_task['id'])
if bool(tw_task):
if 'project' not in tw_task:
tw_task['project'] = default_project
_sync_task(tw_task, c_ti_task, ti_project_list)
continue
# Add Taskwarrior task
_tw_add_task(c_ti_task)
with log.with_feedback('Syncing tasks with todoist'):
todoist.commit()
todoist.sync()
def _convert_ti_task(ti_task, ti_project_list):
data = {}
data['tid'] = ti_task['id']
data['description'] = ti_task['content']
# Project
project_name = ''
for project_name, p in ti_project_list.items():
if p['id'] == ti_task['project_id']:
break
data['project'] = project_name
# Priority
data['priority'] = utils.ti_priority_to_tw(ti_task['priority'])
# Tags
data['tags'] = [
utils.try_map(config['todoist']['tag_map'],
todoist.labels.get_by_id(l_id)['name'])
for l_id in ti_task['labels']
]
# Dates
data['entry'] = utils.parse_date(ti_task['date_added'])
data['due'] = utils.parse_due(utils.try_get_model_prop(ti_task, 'due'))
data['recur'] = parse_recur_or_prompt(
utils.try_get_model_prop(ti_task, 'due'))
data['status'] = 'completed' if ti_task['checked'] == 1 else 'pending'
return data
def _sync_task(tw_task, ti_task, ti_project_list):
if 'todoist_sync' in tw_task:
ti_stamp = dateutil.parser.parse(tw_task['todoist_sync']).timestamp()
tw_stamp = dateutil.parser.parse(tw_task['modified']).timestamp()
if tw_stamp > ti_stamp:
_ti_update_task(tw_task, ti_project_list)
else: else:
add_task_interactive(**data) _tw_update_task(tw_task, ti_task)
else:
_tw_update_task(tw_task, ti_task)
def check_task_exists(tid): def _tw_add_task(ti_task):
""" Given a Taskwarrior ID, check if the task exists """
_, task = taskwarrior.get_task(todoist_id=tid)
return bool(task)
def add_task(tid, name, project, tags, priority, entry, due, recur):
"""Add a taskwarrior task from todoist task """Add a taskwarrior task from todoist task
Returns the taskwarrior task. Returns the taskwarrior task.
""" """
with io.with_feedback(f"Importing '{name}' ({project})"): description = ti_task['description']
project = ti_task['project']
with log.with_feedback(f"Taskwarrior add '{description}' ({project})"):
return taskwarrior.task_add( return taskwarrior.task_add(
name, ti_task['description'],
project=project, project=ti_task['project'],
tags=tags, tags=ti_task['tags'],
priority=priority, priority=ti_task['priority'],
entry=entry, entry=ti_task['entry'],
due=due, due=ti_task['due'],
recur=recur, recur=ti_task['recur'],
todoist_id=tid, status=ti_task['status'],
todoist_id=ti_task['tid'],
todoist_sync=datetime.datetime.now(),
) )
def add_task_interactive(**task_data): def _tw_update_task(tw_task, ti_task):
"""Interactively add tasks
y - add task def _compare_value(item):
n - skip task return ((ti_task[item] and item not in tw_task) or
d - change description (item in tw_task and tw_task[item] != ti_task[item]))
P - change project
p - change priority
t - change tags
r - change recur
q - quit immediately
? - print help
"""
callbacks = {
'y': lambda: task_data,
'n': lambda: task_data,
# Rename description = ti_task['description']
'd': lambda: { project = ti_task['project']
**task_data, with log.on_error(f"TW updating '{description}' ({project})"):
'name': io.prompt( changed = False
'Set name',
default=task_data['name'],
value_proc=lambda x: x.strip(),
),
},
# Edit tags if tw_task['description'] != ti_task['description']:
't': lambda: { tw_task['description'] = ti_task['description']
**task_data, changed = True
'tags': io.prompt(
'Set tags (space delimited)',
default=' '.join(task_data['tags']),
show_default=False,
value_proc=lambda x: x.split(' '),
),
},
# Edit project if tw_task['project'] != ti_task['project']:
'P': lambda: { tw_task['project'] = ti_task['project']
**task_data, changed = True
'project': io.prompt(
'Set project',
default=task_data['project'],
),
},
# Edit priority if _compare_value('tags'):
'p': lambda: { tw_task['tags'] = ti_task['tags']
**task_data, changed = True
'priority': io.prompt(
'Set priority',
default='',
show_default=False,
type=click.Choice(['L', 'M', 'H', '']),
),
},
# Edit recur if _compare_value('priority'):
'r': lambda: { tw_task['priority'] = ti_task['priority']
**task_data, changed = True
'recur': io.prompt(
'Set recurrence (todoist style)', if _compare_value('entry'):
default='', tw_task['entry'] = ti_task['entry']
value_proc=validation.validate_recur, changed = True
),
}, if _compare_value('due'):
tw_task['due'] = ti_task['due']
changed = True
if _compare_value('recur'):
tw_task['recur'] = ti_task['recur']
changed = True
if tw_task['status'] != ti_task['status']:
tw_task['status'] = ti_task['status']
changed = True
if changed:
tid = ti_task['tid']
log.info(f'TW updating (todoist_id={tid})...', nl=False)
log.success('OK')
tw_task['todoist_sync'] = datetime.datetime.now()
taskwarrior.task_update(tw_task)
# Quit def _ti_update_task(tw_task, ti_project_list):
'q': lambda: exit(1), description = tw_task['description']
project = tw_task['project']
with log.on_error(f"Todoist update '{description}' ({project})"):
changed = False
# Help message ti_task = todoist.items.get_by_id(tw_task['todoist_id'])
# Note: this echoes prompt help and then returns the
# task_data unchanged.
'?': lambda: io.warn('\n'.join([
x.strip() for x in
add_task_interactive.__doc__.split('\n')
])) or task_data,
}
response = None if tw_task['description'] != ti_task['item']['content']:
while response not in ('y', 'n'): ti_task['item']['content'] = tw_task['description']
io.task(task_data) changed = True
response = io.prompt(
"Import this task?", project = ti_project_list[tw_task['project']]
type=click.Choice(callbacks.keys()), if ti_task['item']['project_id'] != project['id']:
show_choices=True, changed = True
priority = 0
if 'priority' in tw_task:
priority = utils.tw_priority_to_ti(tw_task['priority'])
if ti_task['item']['priority'] != priority:
ti_task['item']['priority'] = priority
changed = True
if ((ti_task['item']['checked'] == 0 and
tw_task['status'] == 'completed') or
(ti_task['item']['checked'] == 1 and
tw_task['status'] == 'pending')):
changed = True
if changed:
tid = ti_task['item']['id']
log.info(f'Updating (todoist_id={tid})', nl=False)
log.success('OK')
todoist.items.update(tid, **ti_task)
# Move to another project
if ti_task['item']['project_id'] != project['id']:
todoist.items.move(tid, project_id=project['id'])
# Open/close ti task
if ti_task['item']['checked'] == 1 and \
tw_task['status'] == 'pending':
todoist.items.uncomplete(tid)
elif ti_task['item']['checked'] == 0 and \
tw_task['status'] == 'completed':
todoist.items.complete(tid)
tw_task['todoist_sync'] = datetime.datetime.now()
taskwarrior.task_update(tw_task)
else:
# Always set latest sync time so no more sync accures
tid = ti_task['item']['id']
log.info(f'TI updating (todoist_id={tid})...', nl=False)
log.success('OK')
tw_task['todoist_sync'] = datetime.datetime.now()
taskwarrior.task_update(tw_task)
def _ti_add_task(tw_task, ti_project_list):
description = tw_task['description']
project = tw_task['project']
with log.on_error(f"Todoist add '{description}' ({project})"):
# Add the item and commit the change
data = {}
if tw_task['project'] not in ti_project_list:
project = tw_task['project']
log.error(f'Project "{project}" not found on Todoist.')
return
data['project_id'] = ti_project_list[tw_task['project']]['id']
if 'priority' in tw_task:
data['priority'] = utils.tw_priority_to_ti(tw_task['priority'])
ti_task = todoist.items.add(tw_task['description'], **data)
todoist.commit()
tid = ti_task['id']
log.info(f'TI add (todoist_id={tid})')
tw_task['todoist_id'] = tid
tw_task['todoist_sync'] = datetime.datetime.now()
taskwarrior.task_update(tw_task)
def _ti_project_list():
result = {}
for p in todoist.projects.all():
project_hierarchy = [p]
pp = p
while pp['parent_id']:
pp = todoist.projects.get_by_id(p['parent_id'])
project_hierarchy.insert(0, pp)
project_name = '.'.join(p['name'] for p in project_hierarchy)
project_name = utils.try_map(
config['todoist']['project_map'],
project_name
) )
result[utils.maybe_quote_ws(project_name)] = p
# Execute operation return result
task_data = callbacks[response]()
if response == 'n':
io.warn('Skipping task')
return
return add_task(**task_data)
def parse_recur_or_prompt(due): def parse_recur_or_prompt(due):
try: try:
return utils.parse_recur(due) return utils.parse_recur(due)
except errors.UnsupportedRecurrence: except errors.UnsupportedRecurrence:
io.error("Unsupported recurrence: '%s'. Please enter a valid value" % due['string']) log.error(
return io.prompt( "Unsupported recurrence: '%s'. "
"Please enter a valid value" % due['string'])
return log.prompt(
'Set recurrence (todoist style)', 'Set recurrence (todoist style)',
default='', default='',
value_proc=validation.validate_recur, value_proc=validation.validate_recur,
@ -294,19 +458,4 @@ def parse_recur_or_prompt(due):
""" Entrypoint """ """ Entrypoint """
if __name__ == '__main__': 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:
io.error('TODOIST_API_KEY environment variable not specified. Exiting.')
exit(1)
todoist = TodoistAPI(todoist_api_key, cache=TODOIST_CACHE)
# 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',
})
cli() cli()

View File

@ -7,3 +7,10 @@ class UnsupportedRecurrence(Exception):
super().__init__('Unsupported recurrence: %s' % date_string) super().__init__('Unsupported recurrence: %s' % date_string)
self.date_string = date_string self.date_string = date_string
class TIItemNotFoundAfterCommit(Exception):
pass
class TIProjectNotFound(Exception):
pass

View File

@ -61,3 +61,12 @@ def with_feedback(description, success_status='OK', error_status='FAILED'):
else: else:
success(success_status) success(success_status)
@contextlib.contextmanager
def on_error(description, error_status='FAILED'):
try:
yield
except Exception as e:
info(f'{description}... ', nl=False)
error(f'{error_status} ({e})')
raise

View File

@ -24,16 +24,21 @@ def try_get_model_prop(m, key, default=None):
""" Priorities """ """ Priorities """
PRIORITY_MAP = {1: None, 2: 'L', 3: 'M', 4: 'H'} TI_PRIORITY_MAP = {1: None, 2: 'L', 3: 'M', 4: 'H'}
def parse_priority(priority): def ti_priority_to_tw(priority):
""" Converts a priority from Todoist to Taskwarrior. """ Converts a priority from Todoist to Taskwarrior.
Todoist saves priorities as 1, 2, 3, 4, whereas Taskwarrior uses L, M, H. Todoist saves priorities as 1, 2, 3, 4, whereas Taskwarrior uses L, M, H.
These values map very easily to eachother, as Todoist priority 1 indicates that These values map very easily to eachother, as Todoist priority 1 indicates that
no priority has been set. no priority has been set.
""" """
return PRIORITY_MAP[int(priority)] return TI_PRIORITY_MAP[int(priority)]
def tw_priority_to_ti(priority):
tw_priority_map = dict(map(reversed, TI_PRIORITY_MAP.items()))
return tw_priority_map[priority]
""" Strings """ """ Strings """
@ -71,8 +76,10 @@ def parse_date(date):
""" """
if not date: if not date:
return None return None
return dateutil.parser.parse(date).isoformat() naive = dateutil.parser.parse(date)
dt = naive.replace(tzinfo=None)
return dt.strftime('%Y%m%dT%H%M%SZ')
def parse_recur(due): def parse_recur(due):