├── LICENSE ├── README.md └── nextaction.py /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Adam Kramer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # This code here no longer works - the torch is now being carried by @nikdoof and an up-to-date repository is located here: [https://github.com/nikdoof/NextAction](https://github.com/nikdoof/NextAction) 2 | 3 | NextAction 4 | ========== 5 | 6 | A more GTD-like workflow for Todoist. Uses the REST API to add and remove a `@next_action` label from tasks. 7 | 8 | This program looks at every list in your Todoist account. 9 | Any list that ends with `--` or `=` is treated specially, and processed by NextAction. 10 | 11 | Note that NextAction requires Todoist Premium to function properly, as labels are a premium feature. 12 | 13 | Activating NextAction 14 | ====== 15 | 16 | Sequential list processing 17 | ------ 18 | If a list ends with `--`, the top level of tasks will be treated as a priority queue and the most important will be labeled `@next_action`. 19 | Importance is determined by: 20 | 1. Priority 21 | 2. Due date 22 | 3. Order in the list 23 | 24 | `@next_action` waterfalls into indented regions. If the top level task that is selected to receive the `@next_action` label has subtasks, the same algorithm is used. The `@next_action` label is only applied to one task. 25 | 26 | Parallel list processing 27 | ------ 28 | If a list name ends with `=`, the top level of tasks will be treated as parallel `@next_action`s. 29 | The waterfall processing will be applied the same way as sequential lists - every parent task will be treated as sequential. This can be overridden by appending `=` to the name of the parent task. 30 | -------------------------------------------------------------------------------- /nextaction.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import argparse 4 | import copy 5 | import dateutil.parser 6 | import dateutil.tz 7 | import datetime 8 | import json 9 | import logging 10 | import time 11 | import urllib 12 | import urllib2 13 | 14 | NEXT_ACTION_LABEL = u'next_action' 15 | args = None 16 | 17 | class TraversalState(object): 18 | """Simple class to contain the state of the item tree traversal.""" 19 | def __init__(self, next_action_label_id): 20 | self.remove_labels = [] 21 | self.add_labels = [] 22 | self.found_next_action = False 23 | self.next_action_label_id = next_action_label_id 24 | 25 | def clone(self): 26 | """Perform a simple clone of this state object. 27 | 28 | For parallel traversals it's necessary to produce copies so that every 29 | traversal to a lower node has the same found_next_action status. 30 | """ 31 | t = TraversalState(self.next_action_label_id) 32 | t.found_next_action = self.found_next_action 33 | return t 34 | 35 | def merge(self, other): 36 | """Merge clones back together. 37 | 38 | After parallel traversals, merge the results back into the parent state. 39 | """ 40 | if other.found_next_action: 41 | self.found_next_action = True 42 | self.remove_labels += other.remove_labels 43 | self.add_labels += other.add_labels 44 | 45 | 46 | class Item(object): 47 | def __init__(self, initial_data): 48 | self.parent = None 49 | self.children = [] 50 | self.checked = initial_data['checked'] == 1 51 | self.content = initial_data['content'] 52 | self.indent = initial_data['indent'] 53 | self.item_id = initial_data['id'] 54 | self.labels = initial_data['labels'] 55 | self.priority = initial_data['priority'] 56 | if 'due_date_utc' in initial_data and initial_data['due_date_utc'] != None: 57 | p = dateutil.parser.parser() 58 | self.due_date_utc = p.parse(initial_data['due_date_utc']) 59 | else: 60 | # Arbitrary time in the future to always sort last 61 | self.due_date_utc = datetime.datetime(2100, 1, 1, tzinfo=dateutil.tz.tzutc()) 62 | 63 | def GetItemMods(self, state): 64 | if self.IsSequential(): 65 | self._SequentialItemMods(state) 66 | elif self.IsParallel(): 67 | self._ParallelItemMods(state) 68 | if not state.found_next_action and not self.checked: 69 | state.found_next_action = True 70 | if args.use_priority and self.priority != 4: 71 | state.add_labels.append(self) 72 | elif not args.use_priority and not state.next_action_label_id in self.labels: 73 | state.add_labels.append(self) 74 | else: 75 | if args.use_priority and self.priority == 4: 76 | state.remove_labels.append(self) 77 | elif not args.use_priority and state.next_action_label_id in self.labels: 78 | state.remove_labels.append(self) 79 | 80 | def SortChildren(self): 81 | # Sorting by priority and date seemed like a good idea at some point, but 82 | # that has proven wrong. Don't sort. 83 | pass 84 | 85 | def GetLabelRemovalMods(self, state): 86 | if args.use_priority: 87 | return 88 | if state.next_action_label_id in self.labels: 89 | state.remove_labels.append(self) 90 | for item in self.children: 91 | item.GetLabelRemovalMods(state) 92 | 93 | def _SequentialItemMods(self, state): 94 | """ 95 | Iterate over every child, walking down the tree. 96 | If none of our children are the next action, check if we are. 97 | """ 98 | for item in self.children: 99 | item.GetItemMods(state) 100 | 101 | def _ParallelItemMods(self, state): 102 | """ 103 | Iterate over every child, walking down the tree. 104 | If none of our children are the next action, check if we are. 105 | Clone the state each time we descend down to a child. 106 | """ 107 | frozen_state = state.clone() 108 | for item in self.children: 109 | temp_state = frozen_state.clone() 110 | item.GetItemMods(temp_state) 111 | state.merge(temp_state) 112 | 113 | def IsSequential(self): 114 | return not self.content.endswith('=') 115 | 116 | def IsParallel(self): 117 | return self.content.endswith('=') 118 | 119 | class Project(object): 120 | def __init__(self, initial_data): 121 | self.unsorted_items = dict() 122 | self.children = [] 123 | self.indent = 0 124 | self.is_archived = initial_data['is_archived'] == 1 125 | self.is_deleted = initial_data['is_deleted'] == 1 126 | self.name = initial_data['name'] 127 | # Project should act like an item, so it should have content. 128 | self.content = initial_data['name'] 129 | self.project_id = initial_data['id'] 130 | 131 | def UpdateChangedData(self, changed_data): 132 | self.name = changed_data['name'] 133 | 134 | def IsSequential(self): 135 | return self.name.endswith('--') 136 | 137 | def IsParallel(self): 138 | return self.name.endswith('=') 139 | 140 | SortChildren = Item.__dict__['SortChildren'] 141 | 142 | def GetItemMods(self, state): 143 | if self.IsSequential(): 144 | for item in self.children: 145 | item.GetItemMods(state) 146 | elif self.IsParallel(): 147 | frozen_state = state.clone() 148 | for item in self.children: 149 | temp_state = frozen_state.clone() 150 | item.GetItemMods(temp_state) 151 | state.merge(temp_state) 152 | else: # Remove all next_action labels in this project. 153 | for item in self.children: 154 | item.GetLabelRemovalMods(state) 155 | 156 | def AddItem(self, item): 157 | '''Collect unsorted child items 158 | 159 | All child items for all projects are bundled up into an 'Items' list in the 160 | v5 api. They must be normalized and then sorted to make use of them.''' 161 | self.unsorted_items[item['id']] = item 162 | 163 | def DelItem(self, item): 164 | '''Delete unsorted child items''' 165 | del self.unsorted_items[item['id']] 166 | 167 | 168 | def BuildItemTree(self): 169 | '''Build a tree of items build off the unsorted list 170 | 171 | Sort the unsorted children first so that indentation levels mean something. 172 | ''' 173 | self.children = [] 174 | sortfunc = lambda item: item['item_order'] 175 | sorted_items = sorted(self.unsorted_items.values(), key=sortfunc) 176 | parent_item = self 177 | previous_item = self 178 | for item_dict in sorted_items: 179 | item = Item(item_dict) 180 | if item.indent > previous_item.indent: 181 | logging.debug('pushing "%s" on the parent stack beneath "%s"', 182 | previous_item.content, parent_item.content) 183 | parent_item = previous_item 184 | # walk up the tree until we reach our parent 185 | while item.indent <= parent_item.indent: 186 | logging.debug('walking up the tree from "%s" to "%s"', 187 | parent_item.content, parent_item.parent.content) 188 | parent_item = parent_item.parent 189 | 190 | logging.debug('adding item "%s" with parent "%s"', item.content, 191 | parent_item.content) 192 | parent_item.children.append(item) 193 | item.parent = parent_item 194 | previous_item = item 195 | #self.SortChildren() 196 | 197 | 198 | class TodoistData(object): 199 | '''Construct an object based on a full Todoist /Get request's data''' 200 | def __init__(self, initial_data): 201 | 202 | self._next_action_id = None 203 | self._SetLabelData(initial_data) 204 | self._projects = dict() 205 | self._seq_no = initial_data['seq_no'] 206 | for project in initial_data['Projects']: 207 | if project['is_deleted'] == 0: 208 | self._projects[project['id']] = Project(project) 209 | for item in initial_data['Items']: 210 | self._projects[item['project_id']].AddItem(item) 211 | for project in self._projects.itervalues(): 212 | project.BuildItemTree() 213 | 214 | def _SetLabelData(self, label_data): 215 | if args.use_priority: 216 | return 217 | if 'Labels' not in label_data: 218 | logging.debug("Label data not found, wasn't updated.") 219 | return 220 | # Store label data - we need this to set the next_action label. 221 | for label in label_data['Labels']: 222 | if label['name'] == NEXT_ACTION_LABEL: 223 | self._next_action_id = label['id'] 224 | logging.info('Found next_action label, id: %s', label['id']) 225 | if self._next_action_id == None: 226 | logging.warning('Failed to find next_action label, need to create it.') 227 | 228 | def GetSyncState(self): 229 | return {'seq_no': self._seq_no} 230 | 231 | def UpdateChangedData(self, changed_data): 232 | if 'seq_no' in changed_data: 233 | self._seq_no = changed_data['seq_no'] 234 | if 'TempIdMapping' in changed_data: 235 | if self._next_action_id in changed_data['TempIdMapping']: 236 | logging.info('Discovered temp->real next_action mapping ID') 237 | self._next_action_id = changed_data['TempIdMapping'][self._next_action_id] 238 | if 'Projects' in changed_data: 239 | for project in changed_data['Projects']: 240 | # delete missing projects 241 | if project['is_deleted'] == 1: 242 | logging.info('forgetting deleted project %s' % project['name']) 243 | del self._projects[project['project_id']] 244 | if project['id'] in self._projects: 245 | self._projects[project['id']].UpdateChangedData(project) 246 | else : 247 | logging.info('found new project: %s' % project['name']) 248 | self._projects[project['id']] = Project(project) 249 | if 'Items' in changed_data: 250 | for item in changed_data['Items']: 251 | if item['is_deleted'] == 1: 252 | logging.info('removing deleted item %d from project %d' % (item['id'], item['project_id'])) 253 | self._projects[item['project_id']].DelItem(item) 254 | else: 255 | self._projects[item['project_id']].AddItem(item) 256 | for project in self._projects.itervalues(): 257 | project.BuildItemTree() 258 | 259 | def GetProjectMods(self): 260 | mods = [] 261 | # We need to create the next_action label 262 | if self._next_action_id == None and not args.use_priority: 263 | self._next_action_id = '$%d' % int(time.time()) 264 | mods.append({'type': 'label_register', 265 | 'timestamp': int(time.time()), 266 | 'temp_id': self._next_action_id, 267 | 'args': { 268 | 'name': NEXT_ACTION_LABEL 269 | }}) 270 | # Exit early so that we can receive the real ID for the label. 271 | # Otherwise we end up applying the label two different times, once with 272 | # the temporary ID and once with the real one. 273 | # This makes adding the label take an extra round through the sync 274 | # process, but that's fine since this only happens on the first ever run. 275 | logging.info("Adding next_action label") 276 | return mods 277 | for project in self._projects.itervalues(): 278 | state = TraversalState(self._next_action_id) 279 | project.GetItemMods(state) 280 | if len(state.add_labels) > 0 or len(state.remove_labels) > 0: 281 | logging.info("For project %s, the following mods:", project.name) 282 | for item in state.add_labels: 283 | # Intentionally add the next_action label to the item. 284 | # This prevents us from applying the label twice since the sync 285 | # interface does not return our changes back to us on GetAndSync. 286 | # Apply these changes to both the item in the tree and the unsorted 287 | # data. 288 | # I really don't like this aspect of the API - return me a full copy of 289 | # changed items please. 290 | # 291 | # Also... OMFG. "Priority 1" in the UI is actually priority 4 via the API. 292 | # Lowest priority = 1 here, but that is the word for highest priority 293 | # on the website. 294 | m = self.MakeNewMod(item) 295 | mods.append(m) 296 | if args.use_priority: 297 | item.priority = 4 298 | project.unsorted_items[item.item_id]['priority'] = 4 299 | m['args']['priority'] = item.priority 300 | else: 301 | item.labels.append(self._next_action_id) 302 | m['args']['labels'] = item.labels 303 | logging.info("add next_action to: %s", item.content) 304 | for item in state.remove_labels: 305 | m = self.MakeNewMod(item) 306 | mods.append(m) 307 | if args.use_priority: 308 | item.priority = 1 309 | project.unsorted_items[item.item_id]['priority'] = 1 310 | m['args']['priority'] = item.priority 311 | else: 312 | item.labels.remove(self._next_action_id) 313 | m['args']['labels'] = item.labels 314 | logging.info("remove next_action from: %s", item.content) 315 | return mods 316 | 317 | @staticmethod 318 | def MakeNewMod(item): 319 | return {'type': 'item_update', 320 | 'timestamp': int(time.time()), 321 | 'args': { 322 | 'id': item.item_id, 323 | } 324 | } 325 | 326 | 327 | 328 | 329 | def GetResponse(api_token): 330 | values = {'api_token': api_token, 'seq_no': '0'} 331 | data = urllib.urlencode(values) 332 | req = urllib2.Request('https://api.todoist.com/TodoistSync/v5.3/get', data) 333 | return urllib2.urlopen(req) 334 | 335 | def DoSyncAndGetUpdated(api_token, items_to_sync, sync_state): 336 | values = {'api_token': api_token, 337 | 'items_to_sync': json.dumps(items_to_sync)} 338 | for key, value in sync_state.iteritems(): 339 | values[key] = json.dumps(value) 340 | logging.debug("posting %s", values) 341 | data = urllib.urlencode(values) 342 | req = urllib2.Request('https://api.todoist.com/TodoistSync/v5.3/syncAndGetUpdated', data) 343 | return urllib2.urlopen(req) 344 | 345 | def main(): 346 | parser = argparse.ArgumentParser(description='Add NextAction labels to Todoist.') 347 | parser.add_argument('--api_token', required=True, help='Your API key') 348 | parser.add_argument('--use_priority', required=False, 349 | action="store_true", help='Use priority 1 rather than a label to indicate the next actions.') 350 | global args 351 | args = parser.parse_args() 352 | logging.basicConfig(level=logging.DEBUG) 353 | response = GetResponse(args.api_token) 354 | initial_data = response.read() 355 | logging.debug("Got initial data: %s", initial_data) 356 | initial_data = json.loads(initial_data) 357 | a = TodoistData(initial_data) 358 | while True: 359 | mods = a.GetProjectMods() 360 | if len(mods) == 0: 361 | time.sleep(5) 362 | else: 363 | logging.info("* Modifications necessary - skipping sleep cycle.") 364 | logging.info("** Beginning sync") 365 | sync_state = a.GetSyncState() 366 | changed_data = DoSyncAndGetUpdated(args.api_token,mods, sync_state).read() 367 | logging.debug("Got sync data %s", changed_data) 368 | changed_data = json.loads(changed_data) 369 | logging.info("* Updating model after receiving sync data") 370 | a.UpdateChangedData(changed_data) 371 | logging.info("* Finished updating model") 372 | logging.info("** Finished sync") 373 | 374 | if __name__ == '__main__': 375 | main() 376 | --------------------------------------------------------------------------------