├── .gitignore ├── .travis.yml ├── README.md ├── cliist-install ├── cliist.el ├── cliist.py ├── lib ├── __init__.py ├── api.py ├── cache.py ├── models.py ├── output.py ├── process.py ├── todoist.py └── utils.py └── settings.py.template /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | bin/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | eggs/ 16 | parts/ 17 | sdist/ 18 | var/ 19 | *.egg-info/ 20 | .installed.cfg 21 | *.egg 22 | 23 | # Installer logs 24 | pip-log.txt 25 | pip-delete-this-directory.txt 26 | 27 | # Unit test / coverage reports 28 | htmlcov/ 29 | .tox/ 30 | .coverage 31 | .cache 32 | nosetests.xml 33 | coverage.xml 34 | 35 | # Translations 36 | *.mo 37 | 38 | # Mr Developer 39 | .mr.developer.cfg 40 | .project 41 | .pydevproject 42 | 43 | # Rope 44 | .ropeproject 45 | 46 | # Django stuff: 47 | *.log 48 | *.pot 49 | 50 | # Sphinx documentation 51 | docs/_build/ 52 | 53 | settings.py 54 | *.elc 55 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: "python" 2 | python: 3 | - "3.2" 4 | - "3.3" 5 | - "3.4" 6 | - "pypy3" 7 | install: 8 | pip install flake8 9 | before_script: 10 | flake8 . 11 | script: 12 | cliist.py 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | cliist 2 | ====== 3 | 4 | Todoist commandline client is an asynchronous client for the Todoist todo-list web application. 5 | It currently supports: 6 | - adding/updating tasks 7 | - marking tasks complete 8 | - listing all tasks 9 | - listing project tasks 10 | - queryinig using Todoist query format 11 | - listing all projects and labels 12 | 13 | The client is currently tested only with Todoist Premium. 14 | 15 | Every listed resultset is cached to the OS. This is helpful when you want to mark tasks completed. 16 | 17 | ## After installation 18 | Run installation script and type in your API token: 19 | `cliist-install` 20 | 21 | ## Updating 22 | Please check if there are any new settings in `settings.py.template` and add them to your local settings. I will try to figure out a better way of updating the application in the future. 23 | 24 | ## Examples 25 | 26 | ### List all tasks for today and that are overdue 27 | Input: `./cliist.py` 28 | 29 | Output: 30 | 31 | ``` 32 | Overdue and today 33 | ================= 34 | - 12345677 05.07.2014 @ 21:59:59 task 1 that was overdue 35 | - 12345678 06.07.2014 @ 21:59:59 task 2 for today 36 | ``` 37 | 38 | ### List the tasks with a search string 39 | Input: `./cliist.py that` 40 | 41 | Output: 42 | 43 | ``` 44 | Overdue and today 45 | ================= 46 | - 12345677 05.07.2014 @ 21:59:59 task 1 that was overdue 47 | ``` 48 | 49 | ### List all projects 50 | Input: `./cliist.py -P` 51 | 52 | Output: 53 | 54 | ``` 55 | #Inbox 56 | #Proj1 57 | #Proj2 58 | #Proj3 59 | #Proj3.1 60 | #Proj3.2 61 | #Proj3.3 62 | #Proj4 63 | ``` 64 | 65 | ### Add a task for project Proj 1 with due date today and label @happy and of first priority 66 | Input: `./cliist.py -a a very important task @happy #Proj1 %%4` (cliist uses %%4 instead of !!4 because ! is a special character in bash). 67 | 68 | Alternetively, you could also input: `./cliist.py -a 'a very important task @happy' --project Proj1 --priority 4` 69 | 70 | ### Query 71 | Input: `./cliist.py -q @happy` 72 | 73 | Output: 74 | 75 | ``` 76 | @happy 77 | ================= 78 | - 12345677 !1 a very important task 79 | - 12345678 12.07.2014 @ 21:59:59 another happy task 80 | ``` 81 | 82 | ### Completing tasks 83 | 84 | First we add three types of tasks. Input: 85 | 86 | 87 | ``` 88 | cliist -a -d today normal task 89 | cliist -a -d 'ev day' recurring task 90 | cliist -a something for the future 91 | ``` 92 | 93 | List all tasks for today and tomorrow. Input: `cliist -q 'today, tomorrow'` 94 | 95 | Output: 96 | 97 | ``` 98 | today, tomorrow 99 | =============== 100 | - 12345671 28.10.2014 @ 23:59:59 some task from before 101 | - 12345672 28.10.2014 @ 23:59:59 another some task from before 102 | - 12345673 28.10.2014 @ 23:59:59 normal task 103 | - 12345673 28.10.2014 @ 23:59:59 recurring task 104 | ``` 105 | 106 | Complete two tasks via ID. Input: `cliist -c 12345672 12345671` 107 | You can also use a search string. Input: `cliist -c 'normal task'` 108 | The complete command also works with recurring tasks but ***only if the task appeared in the last listing command***. If it didn't, the task will be marked completed and will not appear on the next recurring date. 109 | 110 | Let's list the tasks again. Input: `` 111 | 112 | Output: 113 | 114 | ``` 115 | today, tomorrow 116 | =============== 117 | - 12345673 29.10.2014 @ 23:59:59 recurring task 118 | ``` 119 | 120 | ***Warning***: Search string can only be used if you used a listing command before and the task appeared in the list. In this example we used `cliist -q 'today, tomorrow'` before. 121 | 122 | 123 | 124 | ## All features 125 | cliist features can be easily listed with `cliist -h`: 126 | ``` 127 | ~ ○ cliist -h [17:48:41] 128 | Usage: cliist [options] task_content|search_string|task_id 129 | 130 | Simple Todoist console client. If no options and arguments specified, all 131 | uncompleted tasks for today and overdue are listed. Note: because ! is a 132 | special bash character, you can write %% instead of !! 133 | 134 | Options: 135 | -h, --help show this help message and exit 136 | -d DATE, --date=DATE Todoist due date formatted in Todoist date format. 137 | Available when no other options specified and when 138 | adding or editing tasks. 139 | -s ORDER, --sort=ORDER 140 | Sort todoist tasks by content (c), priority (p) or due 141 | date (d). Available every time a list of tasks is 142 | listed. 143 | -r, --reverse Reverse the list. Available every time tasks, projects 144 | or labels are listed. 145 | -e EDIT_ID, --edit=EDIT_ID 146 | Edit specified task content. Specify content with 147 | arguments. 148 | -q QUERY, --query=QUERY 149 | Query tasks using Todoist search queries as arguments. 150 | -c, --complete Mark tasks completed (arguments are task ids or search 151 | queries). 152 | -a, --add Todoist add task where content as arguments. 153 | -L, --labels List Todoist labels. 154 | -P, --projects List Todoist projects. 155 | -p PROJECT_NAME, --project-tasks=PROJECT_NAME 156 | List Todoist project tasks. 157 | -A, --all List all uncompleted todoist tasks. 158 | --gte=GTE_DATE List tasks with due date greater or equal than 159 | GTE_DATE 160 | --lte=LTE_DATE List tasks with due date less or equal to LTE_DATE 161 | --gt=GT_DATE List tasks with due date greater than GT_DATE 162 | --lt=LT_DATE List tasks with due date less than LT_DATE 163 | --eqaul=EQ_DATE List tasks with due date equal to EQ_DATE 164 | --not-equal=NEQ_DATE List tasks with due date not equal to NEQ_DATE 165 | --cached List cached resultset. 166 | -o TASK_PROJECT, --project=TASK_PROJECT 167 | New task project (available only when adding a task). 168 | -i TASK_PRIORITY, --priority=TASK_PRIORITY 169 | New task priority (available only when adding a task). 170 | --format=FORMAT Select output format (default plain). Formats: plain, 171 | org 172 | ``` 173 | 174 | -------------------------------------------------------------------------------- /cliist-install: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [[ -f settings.py ]]; then 4 | echo "Already installed." 5 | exit 0 6 | fi; 7 | 8 | echo -n "Specify your API token: " 9 | read token 10 | sed "s/API_TOKEN=''/API_TOKEN='$token'/g" settings.py.template > settings.py 11 | echo "Installed." 12 | exit 0 13 | -------------------------------------------------------------------------------- /cliist.el: -------------------------------------------------------------------------------- 1 | ;;; cliist.el --- Cliist calls in Emacs 2 | 3 | ;; Copyright (C) 2014 Žiga Stopinšek 4 | 5 | ;; Author: Žiga Stopinšek 6 | ;; Version: 0.1 7 | ;; Keywords: productiviry, todo 8 | ;; URL: http://github.com/ddksr/cliist 9 | 10 | ;;; Commentary: 11 | 12 | ;; This package provides some basic Todoist querying via commandline tool cliist. 13 | ;; You are required to install cliist ( http://github.com/ddksr/cliist ) 14 | 15 | ;;;###autoload 16 | 17 | (require 'dash) 18 | (require 'org) 19 | 20 | (setq cliist/list-exec "cliist %s --format org" 21 | cliist/exec "cliist %s") 22 | 23 | ;;; ---------- Functions: Helper functions 24 | 25 | (defun cliist/open-buffer-and-run-command (title cliist-command) 26 | (switch-to-buffer (get-buffer-create (format "*Cliist: %s*" title))) 27 | (erase-buffer) 28 | (org-mode) 29 | (insert (shell-command-to-string (format cliist/list-exec cliist-command))) 30 | (let ((current-prefix-arg 0)) 31 | (call-interactively 'goto-line)) ; insetead of using goto-line directly 32 | (move-to-column 0) 33 | (org-cycle) (org-cycle)) 34 | 35 | (defun cliist/project-list () 36 | (mapcar #'(lambda (x) 37 | (nth 1 (split-string x "#"))) 38 | (split-string 39 | (shell-command-to-string (format cliist/list-exec "-P")) "\n"))) 40 | 41 | (defun cliist/label-list () 42 | (split-string 43 | (shell-command-to-string (format cliist/list-exec "-L")) "\n")) 44 | 45 | (defun cliist/label-hash () 46 | (-let ((table (make-hash-table))) 47 | (-each (-map (lambda (str) (reverse (split-string str " "))) 48 | (split-string (shell-command-to-string 49 | (format cliist/list-exec "-L --info")) 50 | "\n")) 51 | (lambda (pair) (puthash (car pair) (nth 1 pair) table))) 52 | table)) 53 | 54 | 55 | (defun cliist/empty (val) 56 | (if (stringp val) 57 | (= (length val) 0) 58 | (not val))) 59 | 60 | (defun cliist/not-empty (val) 61 | (not (cliist/empty val))) 62 | 63 | (defun cliist/convert-date (&rest dates) 64 | (car (split-string (or (-first 'cliist/not-empty 65 | dates) " ") 66 | " "))) 67 | 68 | (defun cliist/org-get-info-at-point () 69 | (list (nth 4 (org-heading-components)) 70 | (cliist/convert-date (org-entry-get (point) "SCHEDULED") 71 | (org-entry-get (point) "DEADLINE")) 72 | (car (-filter 73 | 'cliist/not-empty 74 | (split-string (or (nth 5 (org-heading-components)) 75 | "") 76 | ":"))))) 77 | 78 | (defun cliist/get-info-at-point () 79 | (if (eq major-mode 'org-mode) 80 | (cliist/org-get-info-at-point) 81 | (list (buffer-substring (line-beginning-position) (line-end-position)) 82 | nil 83 | nil))) 84 | 85 | (defun cliist/add-task-cont (content &optional date tag) 86 | (let ((c-date (read-from-minibuffer "Date: " (or date ""))) 87 | (c-content (read-from-minibuffer "Content: " 88 | (if (cliist/not-empty tag) 89 | (format "#%s %s" tag content) 90 | (or content ""))))) 91 | (cliist/run (format "-a%s \"%s\"" 92 | (if (cliist/not-empty c-date) 93 | (format " -d \"%s\"" c-date) 94 | "") 95 | c-content)))) 96 | 97 | ;;; ---------- Functions: public api 98 | 99 | (defun cliist/today-and-overdue () 100 | "List today's and overdue tasks" 101 | (interactive) 102 | (cliist/open-buffer-and-run-command "today and overdue" "")) 103 | 104 | (defun cliist/query (query) 105 | "List tasks specified by query" 106 | (interactive "sQuery: \n") 107 | (cliist/open-buffer-and-run-command query (format "-q %s" query))) 108 | 109 | (defun cliist/project (name) 110 | "List project tasks" 111 | (interactive 112 | (list 113 | (completing-read "Project: " (cliist/project-list)))) 114 | (cliist/open-buffer-and-run-command (format "#%s" name) (format "-p %s" name))) 115 | 116 | (defun cliist/view-all () 117 | "List all uncompleted tasks, organize by project" 118 | (interactive) 119 | (cliist/open-buffer-and-run-command "All" "-A")) 120 | 121 | (defun cliist/completed (&optional number project) 122 | "List completed tasks. Specify NUMBER and PROJECT" 123 | (interactive 124 | (list 125 | (read-from-minibuffer "Number of items: " "30") 126 | (completing-read "Project (leave empty for all): " 127 | (cliist/project-list)))) 128 | (cliist/open-buffer-and-run-command "Completed" 129 | (format "--archive --limit %s %s" 130 | number 131 | (if (= (length project) 0) 132 | "" 133 | (concat "-p " project))))) 134 | 135 | (defun cliist/run (command) 136 | "Run cliist command" 137 | (interactive "sCommand: ") 138 | (shell-command (format cliist/exec command))) 139 | 140 | 141 | (defun cliist/add-task (&optional content date tag) 142 | "Add task to Todoist" 143 | (interactive) 144 | (apply 'cliist/add-task-cont 145 | (--zip-with (-first 'cliist/not-empty 146 | (list it other)) 147 | (list content date tag) 148 | (cliist/get-info-at-point)))) 149 | 150 | (define-minor-mode cliist-mode 151 | "Communicate with cliist via Emacs" 152 | :lighter " cliist" 153 | :global t 154 | :keymap (let ((map (make-sparse-keymap))) 155 | (define-key map (kbd "C-c t r") 'cliist/run) 156 | (define-key map (kbd "C-c t t") 'cliist/today-and-overdue) 157 | (define-key map (kbd "C-c t c") 'cliist/completed) 158 | (define-key map (kbd "C-c t p") 'cliist/project) 159 | (define-key map (kbd "C-c t q") 'cliist/query) 160 | (define-key map (kbd "C-c t v") 'cliist/view-all) 161 | (define-key map (kbd "C-c t a") 'cliist/add-task) 162 | map)) 163 | 164 | (provide 'cliist) 165 | -------------------------------------------------------------------------------- /cliist.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from optparse import OptionParser 4 | 5 | from lib import process, output, cache 6 | from lib.utils import CliistException 7 | 8 | USAGE = "usage: %prog [options] task_content|search_string|task_id" 9 | DESC = """Simple Todoist console client. 10 | If no options and arguments specified, all uncompleted tasks for today and overdue are listed. 11 | Note: because ! is a special bash character, you can write %% instead of !!""" 12 | 13 | def main(): 14 | parser = OptionParser(usage=USAGE, 15 | description=DESC) 16 | 17 | parser.add_option('-d', '--date', 18 | dest='date', 19 | default=None, 20 | help='Todoist due date formatted in Todoist date format. Available when no other options specified and when adding or editing tasks. If using with --archive, date can only be a full iso formatted date. Example: 2014-12-1T10:11') 21 | 22 | parser.add_option('-s', '--sort', 23 | dest='order', 24 | default=None, 25 | help='Sort todoist tasks by content (c), priority (p) or due date (d). Available every time a list of tasks is listed.') 26 | 27 | parser.add_option('-r', '--reverse', 28 | dest='reverse', 29 | action='store_true', 30 | default=False, 31 | help='Reverse the list. Available every time tasks, projects or labels are listed.') 32 | 33 | parser.add_option('-e', '--edit', 34 | dest='edit_id', 35 | default=None, 36 | help='Edit specified task content. Specify content with arguments.') 37 | 38 | parser.add_option('-q', '--query', 39 | dest='query', 40 | default=None, 41 | help='Query tasks using Todoist search queries as arguments.') 42 | 43 | parser.add_option('-c', '--complete', 44 | dest='complete', 45 | action='store_true', 46 | default=None, 47 | help='Mark tasks completed (arguments are task ids or search queries).') 48 | 49 | parser.add_option('-a', '--add', 50 | dest='add_task', 51 | action='store_true', 52 | default=False, 53 | help='Todoist add task where content as arguments.') 54 | 55 | parser.add_option('-L', '--labels', 56 | dest='labels', 57 | action='store_true', 58 | default=False, 59 | help='List Todoist labels.') 60 | 61 | parser.add_option('--info', 62 | dest='info', 63 | action='store_true', 64 | default=False, 65 | help='Add aditional info.') 66 | 67 | parser.add_option('-P', '--projects', 68 | dest='projects', 69 | action='store_true', 70 | default=False, 71 | help='List Todoist projects.') 72 | 73 | parser.add_option('-p', '--project-tasks', 74 | dest='project_name', 75 | default=False, 76 | help='List Todoist project tasks.') 77 | 78 | parser.add_option('-A', '--all', 79 | dest='all', 80 | action='store_true', 81 | default=False, 82 | help='List all uncompleted todoist tasks.') 83 | 84 | parser.add_option('--archive', 85 | dest='archive', 86 | action='store_true', 87 | help='If -p PROJECT is specified, show only completed tasks of that project. Date (-d) will be set as from date but it has to be in ISO format.') 88 | 89 | parser.add_option('--limit', 90 | dest='limit', 91 | default=30, 92 | help='Limit returned archive size.') 93 | 94 | parser.add_option('--gte', 95 | dest='gte_date', 96 | default=None, 97 | help='List tasks with due date greater or equal than GTE_DATE.') 98 | 99 | parser.add_option('--lte', 100 | dest='lte_date', 101 | default=None, 102 | help='List tasks with due date less or equal to LTE_DATE.') 103 | 104 | parser.add_option('--gt', 105 | dest='gt_date', 106 | default=None, 107 | help='List tasks with due date greater than GT_DATE.') 108 | 109 | parser.add_option('--lt', 110 | dest='lt_date', 111 | default=None, 112 | help='List tasks with due date less than LT_DATE.') 113 | 114 | parser.add_option('--eqaul', 115 | dest='eq_date', 116 | default=None, 117 | help='List tasks with due date equal to EQ_DATE.') 118 | 119 | parser.add_option('--not-equal', 120 | dest='neq_date', 121 | default=None, 122 | help='List tasks with due date not equal to NEQ_DATE.') 123 | 124 | parser.add_option('--cached', 125 | dest='cached', 126 | action='store_true', 127 | default=False, 128 | help='List cached resultset.') 129 | 130 | parser.add_option('-o', '--project', 131 | dest='task_project', 132 | default=None, 133 | help='New task project (available only when adding a task).') 134 | 135 | parser.add_option('-i', '--priority', 136 | dest='task_priority', 137 | default=False, 138 | help='New task priority (available only when adding a task).') 139 | 140 | parser.add_option('--format', 141 | dest='format', 142 | default='plain', 143 | help='Select output format (default plain). Formats: ' 144 | + ', '.join(output.formaters.keys()) + '.') 145 | 146 | 147 | 148 | options, args = parser.parse_args() 149 | try: 150 | cache.load() 151 | process.command(args, options) 152 | cache.save() 153 | except CliistException as msg: 154 | print(msg) 155 | 156 | if __name__ == '__main__': 157 | main() 158 | -------------------------------------------------------------------------------- /lib/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /lib/api.py: -------------------------------------------------------------------------------- 1 | import urllib.parse 2 | import urllib.request 3 | import json 4 | 5 | from .utils import CliistException 6 | 7 | from settings import API_TOKEN 8 | 9 | API_URL = 'https://api.todoist.com/API' 10 | 11 | def api_call(method, **options): 12 | options['token'] = API_TOKEN 13 | query_string = urllib.parse.urlencode(options, 14 | safe='', 15 | errors=None, 16 | encoding=None) 17 | url = "{apiurl}/{method}?{query}".format(apiurl=API_URL, 18 | method=method, 19 | query=query_string) 20 | try: 21 | req = urllib.request.urlopen(url) 22 | content = req.read().decode('utf-8') 23 | return json.loads(content) 24 | except Exception: 25 | raise CliistException('Error connecting to Todoist API') 26 | 27 | 28 | -------------------------------------------------------------------------------- /lib/cache.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | 4 | from .utils import CliistException 5 | 6 | try: 7 | from settings import CACHE_ENABLED, CACHE 8 | except: 9 | CACHE_ENABLED, CACHE = False, '' 10 | 11 | _cache = None 12 | def get(name): 13 | global _cache 14 | return _cache.get(name) 15 | 16 | def set(name, val): 17 | global _cache 18 | _cache[name] = val 19 | 20 | def load(): 21 | global _cache 22 | if not CACHE_ENABLED or not os.path.exists(CACHE): 23 | _cache = {} 24 | return 25 | try: 26 | with open(CACHE, 'r') as fd: 27 | _cache = json.loads(fd.read()) 28 | except: 29 | raise CliistException('Error loading _cache') 30 | 31 | def save(): 32 | if not CACHE_ENABLED: 33 | return 34 | try: 35 | with open(CACHE, 'w') as fd: 36 | fd.write(json.dumps(_cache)) 37 | except: 38 | raise CliistException('Error saving _cache') 39 | -------------------------------------------------------------------------------- /lib/models.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | from . import output, cache, api 4 | 5 | from settings import OUTPUT_DATE_FORMAT 6 | 7 | try: 8 | from settings import TIME_OFFSET 9 | except: 10 | TIME_OFFSET = 0 11 | 12 | class Task(dict): 13 | def __init__(self, task_raw): 14 | for key, val in task_raw.items(): 15 | self[key] = val 16 | 17 | self.due_date = None 18 | if task_raw.get('due_date'): 19 | due_date = task_raw['due_date'] 20 | if '+' in due_date: 21 | self.due_date = datetime.strptime(due_date, 22 | '%a %d %b %Y %H:%M:%S %z') 23 | else: 24 | self.due_date = datetime.strptime(due_date, 25 | '%a %d %b %Y %H:%M:%S') 26 | self.sort_date = (self.due_date or datetime(1500, 1, 1)).replace(tzinfo=None) 27 | 28 | self.project = task_raw.get('project_id') 29 | self.priority = int(task_raw.get('priority', '1')) 30 | self.labels = task_raw.get('labels', []) 31 | self['project_name'] = projects_dict.get(self.project, '') 32 | self['label_names'] = ' '.join(map( lambda x: labels_dict.get(x), self.labels )) 33 | self.content = task_raw.get('content', '') 34 | self.raw = task_raw 35 | self.date_string = task_raw.get('date_string', '') 36 | self.is_recurring = any([ 37 | 'every ' in (self.date_string or ''), 38 | 'ev ' in (self.date_string or ''), 39 | ]) 40 | self.in_history = bool(task_raw.get('in_history', 0)) 41 | self.checked = bool(task_raw.get('completed_date', None)) 42 | 43 | def get_date(self): 44 | if self.due_date: 45 | if TIME_OFFSET: 46 | return (self.due_date + timedelta(hours=TIME_OFFSET)).strftime(OUTPUT_DATE_FORMAT) 47 | return self.due_date.strftime(OUTPUT_DATE_FORMAT) 48 | return '' 49 | 50 | def get_key(self, order): 51 | key = getattr(self, order) 52 | if type(key) == str: 53 | return key.lower() 54 | return key 55 | 56 | def __hash__(self): 57 | return self.get('id') 58 | 59 | def pprint(self, output_engine=output.Plain): 60 | output_engine.task(self) 61 | 62 | 63 | class TaskSet(list): 64 | FILTERS = { 65 | 'gte': lambda val: (lambda item: item.sort_date.date() >= val), 66 | 'lte': lambda val: (lambda item: item.sort_date.date() <= val), 67 | 'gt': lambda val: (lambda item: item.sort_date.date() > val), 68 | 'lt': lambda val: (lambda item: item.sort_date.date() < val), 69 | 'eq': lambda val: (lambda item: item.sort_date.date() == val), 70 | 'neq': lambda val: (lambda item: item.sort_date.date() != val), 71 | 'search': lambda val: (lambda item: val.lower() in item['content'].lower()), 72 | } 73 | def __init__(self, result = {}, set_type='unknown'): 74 | if 'project_id' in result: 75 | self.set_type = 'project' 76 | else: 77 | self.set_type = set_type 78 | 79 | for task in result.get('uncompleted', []): 80 | self.append(Task(task)) 81 | self.raw = result 82 | 83 | def copy(self): 84 | copied = TaskSet(set_type=self.set_type) 85 | copied.set_type = self.set_type 86 | copied.extend(self) 87 | copied.raw = self.raw 88 | return copied 89 | 90 | def select(self, order=None, reverse=False, search=None, filters={}): 91 | if search: 92 | filters['search'] = search 93 | filtered = self.copy() 94 | for filtername, filterval in filters.items(): 95 | filtered = filter(TaskSet.FILTERS[filtername](filterval), filtered) 96 | if order: 97 | filtered = sorted(filtered, key=lambda task: task.get_key(order)) 98 | filtered=list(filtered) 99 | selected = TaskSet(set_type=self.set_type) 100 | selected.raw = self.raw 101 | for item in (reverse and filtered[::-1] or filtered): 102 | selected.append(item) 103 | return selected 104 | 105 | def pprint(self, output_engine=output.Plain): 106 | output_engine.task_set(self) 107 | 108 | def lookup(self, task_info): 109 | results = set() 110 | for task in self: 111 | if task_info.isdigit(): 112 | task_id = int(task_info) 113 | if task_id and task_id == int(task.get('id', 0)): 114 | results.add(task) 115 | elif task_info.lower() in task.get('content').lower(): 116 | results.add(task) 117 | return results 118 | 119 | 120 | class ResultSet: 121 | def __init__(self, result, name=None, no_save=False, **options): 122 | self.task_sets = [] 123 | self.tasks = TaskSet() 124 | self.name = name 125 | self.raw = result 126 | for resultset in result or []: 127 | if resultset.get('content'): 128 | self.tasks.append(Task(resultset)) 129 | continue 130 | for item in resultset['data']: 131 | if item.get('content'): 132 | self.tasks.append(Task(item)) 133 | else: 134 | self.task_sets.append(TaskSet(item).select(**options)) 135 | if options: 136 | self.tasks = self.tasks.select(**options) 137 | 138 | if not no_save: 139 | self.save() 140 | 141 | def pprint(self, output_engine=output.Plain): 142 | output_engine.result_set(self) 143 | 144 | def select(self, **options): 145 | return ResultSet(self.raw, name=self.name, **options) 146 | 147 | def dump(self): 148 | return { 'name': self.name, 'raw': self.raw, } 149 | 150 | def save(self): 151 | cache.set('resultset', self.dump()) 152 | 153 | def lookup(self, task_info): 154 | sets = [self.tasks] + self.task_sets 155 | tasks = set() 156 | for task_set in sets: 157 | for task_subset in map(lambda s: s.lookup(task_info), sets): 158 | for task in task_subset: 159 | tasks.add(task) 160 | return list(filter(lambda task: task is not None, tasks)) 161 | 162 | def lookup_one(self, task_info): 163 | tasks = self.lookup(task_info) 164 | if len(tasks) == 1: 165 | return tasks[0] 166 | return None 167 | 168 | @staticmethod 169 | def load(): 170 | dump = cache.get('resultset') 171 | if not dump: 172 | return None 173 | return ResultSet(dump['raw'], name=dump['name'], no_save=True) 174 | 175 | 176 | class LabelDict(dict): 177 | 178 | def __init__(self): 179 | for name, details in api.api_call('getLabels').items(): 180 | self[details['id']] = '@' + details['name'] 181 | 182 | class ProjectDict(dict): 183 | 184 | def __init__(self): 185 | for project in api.api_call('getProjects'): 186 | self[project['id']] = '#' + project['name'] 187 | 188 | projects_dict = ProjectDict() 189 | labels_dict = LabelDict() 190 | -------------------------------------------------------------------------------- /lib/output.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from settings import colors 4 | import xml.etree.ElementTree as ET 5 | 6 | class Plain: 7 | COLORS = { 8 | 'project': colors.PROJECT, 9 | 'unknown': colors.ENDC, 10 | 'set': colors.ENDC 11 | } 12 | FORMAT = { 13 | 'task': '{c0}{indent}{c5}{priority:>3.3} {c1}{content}{c0}\n {c3}{project_name:22.22}{c4} {label_names:22.22} {c2}Due: {due:12.12}{c0} [{taskid}]\n', 14 | 'project': '\n{color}#{project_name}\n', 15 | 'unknown': '', 16 | } 17 | 18 | @staticmethod 19 | def task(obj): 20 | indent = ' ' * (int(obj.get('indent', '1')) - 1) 21 | priority = ' ' 22 | if obj.priority and obj.priority != 1: 23 | priority = '!' * (obj.priority - 1) 24 | due = obj.get_date() 25 | if due: 26 | due += ' ' 27 | print(Plain.FORMAT['task'].format(c0=colors.ENDC, 28 | c1=colors.CONTENT, 29 | c2=colors.DATE, 30 | c3=colors.PROJECT, 31 | c4=colors.LABEL, 32 | c5=colors.PRIORITY, 33 | indent=indent, 34 | priority=priority, 35 | content=obj.get('content'), 36 | project_name=obj.get('project_name'), 37 | label_names=obj.get('label_names'), 38 | due=due, 39 | taskid=obj.get('id')), end='') 40 | 41 | @staticmethod 42 | def task_set(obj): 43 | color = Plain.COLORS[obj.set_type] 44 | print(Plain.FORMAT[obj.set_type].format(color=color, 45 | **obj.raw), end='') 46 | for task in obj: 47 | Plain.task(task) 48 | 49 | @staticmethod 50 | def result_set(obj): 51 | if obj.name: 52 | print('{}{}\n{}{}'.format(colors.FILTER, obj.name, 53 | ''.join('=' for _ in obj.name or ''), 54 | colors.ENDC)) 55 | for task_set in obj.task_sets: 56 | Plain.task_set(task_set) 57 | if obj.tasks: 58 | Plain.task_set(obj.tasks) 59 | 60 | class Org: 61 | PRIORITY = { 1: '', 2: 'C', 3: 'B', 4: 'A' } 62 | DATE = 'DEADLINE: <{} {}>' 63 | NAMES = { 64 | 'project': '{project_name}', 65 | 'unknown': '', 66 | } 67 | 68 | @staticmethod 69 | def task(obj, level=2): 70 | stars = ('*' * (level - 1)) + ('*' * (int(obj.get('indent', '1')))) 71 | indent = ' ' * (len(stars) + 1) 72 | priority = Org.PRIORITY[obj.priority or 1] 73 | due = obj.due_date and Org.DATE.format(obj.due_date.date().isoformat(), 74 | obj.due_date.strftime("%A")[:3]) 75 | props = { 76 | 'TaskID': obj.get('id'), 77 | 'Recurring': obj.is_recurring and 'yes' or 'no', 78 | } 79 | if obj.labels: 80 | props['Labels'] = ', '.join(map(str, obj.labels)) 81 | if obj.project: 82 | props['Project'] = obj.project 83 | if obj.date_string: 84 | props['DateString'] = obj.date_string 85 | 86 | print('{} {} {}{}'.format(stars, 87 | 'DONE' if obj.checked else 'TODO', 88 | '[#{}] '.format(priority) if priority else '', 89 | obj.content)) 90 | if due: 91 | print(indent + due) 92 | print(indent + ':PROPERTIES:') 93 | prop_len = max(len(val) for val in props.keys()) + 3 94 | for prop, value in props.items(): 95 | prop_value = ('{:<' + str(prop_len) + '}{}').format(':{}:'.format(prop), 96 | value) 97 | print(indent + prop_value) 98 | print(indent + ':END:') 99 | 100 | @staticmethod 101 | def task_set(obj, level=1): 102 | name = Org.NAMES[obj.set_type].format(**obj.raw) 103 | if name: 104 | print('{} {}'.format('*' * level, name)) 105 | for task in obj: 106 | Org.task(task, level=(level+1) if name else level) 107 | 108 | @staticmethod 109 | def result_set(obj): 110 | level = 1 111 | if obj.name: 112 | level = 2 113 | print('* ' + obj.name) 114 | for task_set in obj.task_sets: 115 | Org.task_set(task_set, level=level) 116 | for task in obj.tasks: 117 | Org.task(task, level=level) 118 | 119 | class Alfred: 120 | @staticmethod 121 | def task(items, obj): 122 | item = ET.SubElement(items, 'item') 123 | item.set('uid', str(obj.get('id'))) 124 | item.set('arg', str(obj.get('id'))) 125 | 126 | title = ET.SubElement(item, 'title') 127 | title.text = obj.content 128 | 129 | @staticmethod 130 | def task_set(items, obj): 131 | for task in obj: 132 | Alfred.task(items, task) 133 | 134 | @staticmethod 135 | def result_set(obj): 136 | items = ET.Element('items') 137 | for task_set in obj.task_sets: 138 | Alfred.task_set(items, task_set) 139 | for task in obj.tasks: 140 | Alfred.task(items, task) 141 | ET.dump(items) 142 | 143 | formaters = { 144 | 'plain': Plain, 145 | 'org': Org, 146 | 'alfred': Alfred 147 | } 148 | -------------------------------------------------------------------------------- /lib/process.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | import re 3 | from lib import todoist, output 4 | 5 | EU_DATE = re.compile('\d{1,2}\.\d{1,2}\.\d{2}(\d{2})?') 6 | ORDER_OPTIONS = { 7 | 'c': 'content', 8 | 'p': 'priority', 9 | 'd': 'sort_date', 10 | } 11 | 12 | def is_word_label(word): 13 | return word and word[0] == '@' 14 | 15 | def is_word_project(word): 16 | return word and word[0] == '#' 17 | 18 | def is_word_priority(word): 19 | return len(word) > 2 and word[:2] == '!!' and word[2:].isdigit() 20 | 21 | 22 | def str_date(date_str): 23 | y, m, d = 2000, 1, 1 24 | if '-' in date_str: 25 | y, m, d = date_str.split('-') 26 | if '.' in date_str: 27 | d, m, y = date_str.split('.') 28 | return date(int(y), int(m), int(d)) 29 | 30 | def todoist_date(date_str): 31 | if EU_DATE.match(date_str): 32 | return date_str.replace('.', '/') 33 | return date_str 34 | 35 | def content_info(content_raw, options): 36 | content = [] 37 | mapper = lambda w: w.replace('%%', '!!').split(' ') 38 | for words in map(mapper, content_raw): 39 | content.extend(words) 40 | 41 | raw_words = [] 42 | raw_labels = [] 43 | project = None 44 | priority = None 45 | for word in content: 46 | if not word: 47 | continue 48 | if is_word_label(word): 49 | raw_labels.append(word[1:]) 50 | elif is_word_project(word) and not project: 51 | project = word[1:] 52 | elif is_word_priority(word) and not priority: 53 | priority = word[2:] 54 | else: 55 | raw_words.append(word) 56 | return { 57 | 'content': ' '.join(raw_words), 58 | 'merged': ' '.join(content), 59 | 'raw': content_raw, 60 | 'labels': raw_labels, 61 | 'project': project or options.task_project, 62 | 'priority': priority or options.task_priority, 63 | } 64 | 65 | def get_filters(options): 66 | filters = {} 67 | if options.gte_date: 68 | filters['gte'] = str_date(options.gte_date) 69 | if options.lte_date: 70 | filters['lte'] = str_date(options.lte_date) 71 | if options.lt_date: 72 | filters['lt'] = str_date(options.lt_date) 73 | if options.gt_date: 74 | filters['gt'] = str_date(options.gt_date) 75 | if options.eq_date: 76 | filters['eq'] = str_date(options.eq_date) 77 | if options.neq_date: 78 | filters['neq'] = str_date(options.neq_date) 79 | return filters 80 | 81 | def command(args, options): 82 | cinfo = args and content_info(args, options) or {} 83 | formater = output.formaters[options.format] 84 | list_opts = { 85 | 'filters': get_filters(options), 86 | 'reverse': options.reverse, 87 | 'order': ORDER_OPTIONS.get(options.order), 88 | 'search': cinfo.get('merged'), 89 | } 90 | due_date = options.date and todoist_date(options.date) or None 91 | if options.query: 92 | todoist.query(cinfo, options.query, output_engine=formater, **list_opts) 93 | elif options.all: 94 | todoist.query(cinfo, 'view all', output_engine=formater, **list_opts) 95 | elif options.archive: 96 | todoist.archive(cinfo, options.limit, options.date, options.project_name, 97 | output_engine=formater, **list_opts) 98 | elif options.complete: 99 | todoist.complete_tasks(cinfo) 100 | elif options.add_task: 101 | todoist.add_task(cinfo, due_date) 102 | elif options.labels: 103 | todoist.list_labels(cinfo, info=options.info, reverse=options.reverse) 104 | elif options.projects: 105 | todoist.list_projects(cinfo, reverse=options.reverse) 106 | elif options.edit_id: 107 | todoist.edit_task(cinfo, options.edit_id, due_date) 108 | elif options.project_name: 109 | todoist.project_tasks(cinfo, options.project_name, 110 | output_engine=formater, **list_opts) 111 | elif options.cached: 112 | todoist.list_cache(output_engine=formater) 113 | else: 114 | todoist.list_tasks(cinfo, due_date, output_engine=formater, **list_opts) 115 | 116 | -------------------------------------------------------------------------------- /lib/todoist.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | 4 | from . import models, output 5 | from .utils import CliistException 6 | from .api import api_call 7 | 8 | QUERY_DELIMITER = re.compile(', *') 9 | TASK_FORMAT = '{c0}{indent} - {taskid:10} {priority}{c1}{content} {c2}{due}' 10 | 11 | def ulist(l): 12 | return json.dumps(l).replace(', ', ',') 13 | 14 | def prepare_task_info(cinfo, due_date=None): 15 | labels, project = [], None 16 | if cinfo.get('labels'): 17 | all_labels = list_labels(cinfo, stdout=False, 18 | do_search=False) 19 | for label in cinfo['labels']: 20 | if label not in all_labels: 21 | continue 22 | labels.append(all_labels[label]) 23 | if cinfo.get('project'): 24 | all_projects = list_projects(cinfo, stdout=False, 25 | do_search=False) 26 | for proj in all_projects: 27 | if cinfo.get('project') == proj['name']: 28 | project = proj 29 | break 30 | args = {} 31 | content = cinfo.get('content', '') 32 | if content.strip(): 33 | args['content'] = content 34 | if project: 35 | args['project_id'] = project['id'] 36 | if labels: 37 | args['labels'] = [label['id'] for label in labels] 38 | if cinfo.get('priority'): 39 | args['priority'] = int(cinfo['priority']) 40 | if due_date: 41 | args['date_string'] = due_date 42 | return labels, project, args 43 | 44 | def get_task(cinfo, task=None): 45 | cached = models.ResultSet.load() 46 | ids_recurring, ids_normal = [], [] 47 | for task_raw in [task] if task else cinfo['raw']: 48 | if task_raw.isdigit(): 49 | task_id = int(task_raw) 50 | if cached: 51 | task = cached.lookup_one(task_raw) 52 | if task is not None and task.is_recurring: 53 | ids_recurring.append(task_id) 54 | continue 55 | 56 | ids_normal.append(task_id) 57 | elif cached is not None: 58 | result = cached.lookup(task_raw) 59 | if len(result) > 1: 60 | raise CliistException('Too many cached results for {}.\nNo tasks were marked completed'.format(task_raw)) 61 | 62 | elif len(result) < 1: 63 | raise CliistException('No cached results for "{}".\nNo tasks were marked completed'.format(task_raw)) 64 | else: 65 | task = result[0] 66 | task_id = task.get('id') 67 | if task.is_recurring: 68 | ids_recurring.append(task_id) 69 | else: 70 | ids_normal.append(task_id) 71 | else: 72 | raise CliistException('No chached results. Please list your tasks with cliist to enable task lookup.\nNo tasks were marked completed') 73 | return ids_normal + ids_normal, ids_normal, ids_recurring 74 | 75 | 76 | def list_cache(output_engine=output.Plain): 77 | cached = models.ResultSet.load() 78 | if cached is None: 79 | raise CliistException('Cache is empty') 80 | cached.pprint(output_engine=output_engine) 81 | 82 | def project_tasks(cinfo, project_name, stdout=True, 83 | output_engine=output.Plain, **options): 84 | all_projects = list_projects(cinfo, stdout=False, 85 | do_search=False) 86 | project_id = None 87 | for proj in all_projects: 88 | if project_name == proj['name']: 89 | project_id = proj.get('id') 90 | break 91 | if not project_id: 92 | return 93 | result = api_call('getUncompletedItems', project_id=project_id) 94 | result_set = models.ResultSet(result, project_name or 'view all', **options) 95 | if stdout: 96 | result_set.pprint(output_engine=output_engine) 97 | return result_set 98 | 99 | def archive(cinfo, limit, date=None, project_name=None, stdout=True, 100 | output_engine=output.Plain, **options): 101 | result = None 102 | all_projects = list_projects(cinfo, stdout=False, do_search=False) 103 | kwargs = {'limit': limit} 104 | if date: 105 | kwargs['from_date'] = date 106 | for proj in all_projects: 107 | if project_name == proj['name']: 108 | kwargs['project_id'] = proj.get('id') 109 | break 110 | result = api_call('getAllCompletedItems', **kwargs)['items'] 111 | result_set = models.ResultSet(result, 112 | 'Completed: ' + (project_name or 'all'), 113 | **options) 114 | if stdout: 115 | result_set.pprint(output_engine=output_engine) 116 | return result_set 117 | 118 | def query(info, query, stdout=True, output_engine=output.Plain, **options): 119 | queries = QUERY_DELIMITER.split(query) 120 | result = api_call('query', queries=ulist(queries)) 121 | result_set = models.ResultSet(result, query or 'view all', **options) 122 | if stdout: 123 | result_set.pprint(output_engine=output_engine) 124 | return result_set 125 | 126 | def complete_tasks(cinfo): 127 | ids, ids_normal, ids_recurring = get_task(cinfo) 128 | if ids_normal: 129 | api_call('completeItems', ids=ids_normal) 130 | if ids_recurring: 131 | api_call('updateRecurringDate', ids=ids_recurring) 132 | 133 | def add_task(cinfo, due_date=None): 134 | if not cinfo: 135 | raise CliistException('Task has no content!') 136 | labels, project, api_args = prepare_task_info(cinfo, due_date) 137 | if 'content' not in api_args: 138 | raise CliistException('Task has no content!') 139 | if 'priority' not in api_args: 140 | api_args['priority'] = 1 141 | api_call('addItem', **api_args) 142 | 143 | def edit_task(cinfo, edit_id, due_date=None): 144 | if not cinfo: 145 | raise CliistException('No task content') 146 | # TODO: could use lookup 147 | labels, project, api_args = prepare_task_info(cinfo, due_date) 148 | api_args['id'] = edit_id 149 | api_call('updateItem', **api_args) 150 | 151 | 152 | def list_labels(cinfo, stdout=True, info=False, 153 | do_search=True, reverse=False): 154 | result = api_call('getLabels') 155 | search = do_search and cinfo.get('merged') 156 | for label_name in reverse and result.keys()[::-1] or result.keys(): 157 | label = result[label_name] 158 | if search and search.lower() not in label.lower(): 159 | continue 160 | if stdout: 161 | out_str = '@' + label_name 162 | if info: 163 | out_str += ' ' + str(label['id']) 164 | print(out_str) 165 | return result 166 | 167 | def list_projects(cinfo, stdout=True, do_search=True, reverse=False): 168 | result = api_call('getProjects') 169 | search = do_search and cinfo.get('merged') 170 | for project in reverse and result[::-1] or result: 171 | name = project['name'] 172 | if search and search.lower() not in name.lower(): 173 | continue 174 | indent = ' ' * (int(project.get('indent', '1')) - 1) 175 | if stdout: 176 | print(indent + '#' + name) 177 | return result 178 | 179 | def list_tasks(cinfo, due_date, stdout=True, output_engine=output.Plain, **options): 180 | result = api_call('query', queries=ulist(['overdue','today','tomorrow'])) 181 | if cinfo: 182 | options['search'] = cinfo.get('merged') 183 | result_set = models.ResultSet(result, name='Overdue, today and tomorrow', **options) 184 | if stdout: 185 | result_set.pprint(output_engine=output_engine) 186 | return result_set 187 | -------------------------------------------------------------------------------- /lib/utils.py: -------------------------------------------------------------------------------- 1 | class CliistException(Exception): 2 | pass 3 | 4 | -------------------------------------------------------------------------------- /settings.py.template: -------------------------------------------------------------------------------- 1 | # API token obtained from ToDoist web site 2 | API_TOKEN='' 3 | 4 | # Display due dates/times in specified format, see: 5 | # http://strftime.org/ 6 | OUTPUT_DATE_FORMAT='%d.%m.%Y @ %H:%M:%S' 7 | 8 | # ToDoist API returns Date/Times in UTC 9 | # Apply an offset in hours to these for display in local 10 | # time. E.g. -6 for US Central Time 11 | TIME_OFFSET=0 12 | 13 | # Caching of the task list - project/label caching not yet implemented 14 | CACHE_ENABLED = True 15 | CACHE = '/tmp/todoist.json' 16 | 17 | # Customize display colors using terminal escape codes, see: 18 | # http://www.tldp.org/HOWTO/Bash-Prompt-HOWTO/x329.html 19 | class colors: 20 | PROJECT = '\033[95m' 21 | FILTER = '\033[95m' 22 | LABEL = '\033[94m' 23 | CONTENT = '\033[92m' 24 | DATE = '\033[93m' 25 | PRIORITY = '\033[96m' 26 | FAIL = '\033[91m' 27 | ENDC = '\033[0m' 28 | --------------------------------------------------------------------------------