mirror of
https://git.webmeisterei.com/webmeisterei/todoist-taskwarrior.git
synced 2023-12-21 10:23:00 +01:00
Add twoway sync
This commit is contained in:
parent
81ee6d16dc
commit
74da3bee81
2
.gitignore
vendored
2
.gitignore
vendored
@ -38,3 +38,5 @@ pip-delete-this-directory.txt
|
|||||||
# Environments
|
# Environments
|
||||||
venv/
|
venv/
|
||||||
|
|
||||||
|
# vscode
|
||||||
|
.vscode/
|
1
LICENSE
1
LICENSE
@ -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
|
||||||
|
87
README.md
87
README.md
@ -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)
|
6
TODO.md
6
TODO.md
@ -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
|
|
||||||
|
|
@ -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
37
setup.py
Normal 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'
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
@ -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()
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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
|
@ -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):
|
||||||
|
Loading…
Reference in New Issue
Block a user