· 6 years ago · Nov 17, 2019, 02:42 PM
1#!/usr/bin/env python
2import logging
3import argparse
4
5# noinspection PyPackageRequirements
6from todoist.api import TodoistAPI
7
8import time
9import sys
10from datetime import datetime
11
12def chunk(iterable, chunk_size):
13 """Generate sequences of `chunk_size` elements from `iterable`."""
14 iterable = iter(iterable)
15 while True:
16 chunk = []
17 try:
18 for _ in range(chunk_size):
19 chunk.append(next(iterable))
20 yield chunk
21 except StopIteration:
22 if chunk:
23 yield chunk
24 break
25
26class TodoistConnection(object):
27 """docstring for TodoistConnection"""
28 def __init__(self, args, api, logging):
29 super(TodoistConnection, self).__init__()
30 self.args = args
31 self.api = api
32 self.logging = logging
33 self.label = None
34
35 def get_subitems(self, items, parent_item=None):
36 """Search a flat item list for child items"""
37 result_items = []
38 found = False
39 if parent_item:
40 required_indent = parent_item['indent'] + 1
41 else:
42 required_indent = 1
43
44 for item in items:
45 if parent_item:
46 if not found and item['id'] != parent_item['id']:
47 continue
48 else:
49 found = True
50 if item['indent'] == parent_item['indent'] and item['id'] != parent_item['id']:
51 return result_items
52 elif item['indent'] == required_indent and found:
53 result_items.append(item)
54 elif item['indent'] == required_indent:
55 result_items.append(item)
56 return result_items
57
58 def get_project_type(self, project_object):
59 """Identifies how a project should be handled"""
60 name = project_object['name'].strip()
61 if name == 'Inbox':
62 return self.args.inbox
63 elif name[-1] == self.args.parallel_suffix:
64 return 'parallel'
65 elif name[-1] == self.args.serial_suffix:
66 return 'serial'
67
68 def get_item_type(self, item):
69 """Identifies how a item with sub items should be handled"""
70 name = item['content'].strip()
71 if name[-1] == self.args.parallel_suffix:
72 return 'parallel'
73 elif name[-1] == self.args.serial_suffix:
74 return 'serial'
75
76 def add_label(self, item):
77 if self.label not in item['labels']:
78 labels = item['labels']
79 self.logging.debug('Updating %s with label', item['content'])
80 labels.append(self.label)
81 self.api.items.update(item['id'], labels=labels)
82
83 def remove_label(self, item):
84 if self.label in item['labels']:
85 labels = item['labels']
86 self.logging.debug('Updating %s without label', item['content'])
87 labels.remove(self.label)
88 self.api.items.update(item['id'], labels=labels)
89
90 def insert_serial_item(self, serial_items, item):
91 if len(serial_items):
92 if item['item_order'] < serial_items[-1]['item_order']:
93 serial_items.insert(0, item)
94 else:
95 serial_items.append(item)
96 else:
97 serial_items.append(item)
98
99 return serial_items
100
101 # Main loop
102
103def main():
104
105 parser = argparse.ArgumentParser()
106 parser.add_argument('-a', '--api_key', help='Todoist API Key')
107 parser.add_argument('-l', '--label', help='The next action label to use', default='next_action')
108 parser.add_argument('-d', '--delay', help='Specify the delay in seconds between syncs', default=5, type=int)
109 parser.add_argument('--debug', help='Enable debugging', action='store_true')
110 parser.add_argument('--inbox', help='The method the Inbox project should be processed',
111 default='parallel', choices=['parallel', 'serial'])
112 parser.add_argument('--parallel_suffix', default='.')
113 parser.add_argument('--serial_suffix', default='_')
114 parser.add_argument('--hide_future', help='Hide future dated next actions until the specified number of days',
115 default=7, type=int)
116 parser.add_argument('--onetime', help='Update Todoist once and exit', action='store_true')
117 args = parser.parse_args()
118
119 # Set debug
120 if args.debug:
121 log_level = logging.DEBUG
122 else:
123 log_level = logging.INFO
124 logging.basicConfig(level=log_level)
125
126 # Check we have a API key
127 if not args.api_key:
128 logging.error('No API key set, exiting...')
129 sys.exit(1)
130
131 # Run the initial sync
132 logging.debug('Connecting to the Todoist API')
133 api = TodoistAPI(token=args.api_key)
134 conn = TodoistConnection(args, api, logging)
135
136 conn.logging.debug('Syncing the current state from the API')
137 conn.api.sync()
138
139 # Check the next action label exists
140 labels = conn.api.labels.all(lambda x: x['name'] == args.label)
141 if len(labels) > 0:
142 label_id = labels[0]['id']
143 conn.logging.debug('Label %s found as label id %d', args.label, label_id)
144 else:
145 conn.logging.error("Label %s doesn't exist, please create it or change TODOIST_NEXT_ACTION_LABEL.", args.label)
146 sys.exit(1)
147
148 conn.label = label_id
149
150 while True:
151 try:
152 conn.api.sync()
153 except Exception as e:
154 conn.logging.exception('Error trying to sync with Todoist API: %s' % str(e))
155 else:
156 for project in conn.api.projects.all():
157 project_type = conn.get_project_type(project)
158 if project_type:
159 conn.logging.debug('Project %s being processed as %s', project['name'], project_type)
160
161 items = sorted(conn.api.items.all(lambda x: x['project_id'] == project['id']), key=lambda x: x['item_order'])
162
163 # for cases when a task is completed and the lowe task
164 #is not 1
165 serial_items = []
166
167 for item in items:
168
169 # If its too far in the future, remove the next_action tag and skip
170 if conn.args.hide_future > 0 and 'due_date_utc' in item.data and item['due_date_utc'] is not None:
171 due_date = datetime.strptime(item['due_date_utc'], '%a %d %b %Y %H:%M:%S +0000')
172 future_diff = (due_date - datetime.utcnow()).total_seconds()
173 if future_diff >= (conn.args.hide_future * 86400):
174 conn.remove_label(item)
175 continue
176
177 item_type = conn.get_item_type(item)
178 child_items = conn.get_subitems(items, item)
179
180 if item_type:
181 conn.logging.debug('Identified %s as %s type', item['content'], item_type)
182
183 if project_type == 'serial':
184 serial_items = conn.insert_serial_item(serial_items, item)
185
186 if len(child_items) > 0:
187 # Process parallel tagged items or untagged parents
188 for child_item in child_items:
189 conn.add_label(child_item)
190 # Remove the label from the parent
191 conn.remove_label(item)
192 # Process items as per project type on indent 1 if untagged
193 else:
194 if project_type == 'parallel':
195 conn.add_label(item)
196
197 if len(serial_items):
198 # Label to first item may not necessarily be in pos 1
199 s_item = serial_items.pop(0)
200 item_type = conn.get_item_type(s_item)
201 child_items = conn.get_subitems(items, s_item)
202
203 if len(child_items) > 0:
204 if item_type == 'serial':
205 for idx, child_item in enumerate(child_items):
206 if idx == 0:
207 conn.add_label(child_item)
208 else:
209 conn.remove_label(child_item)
210 conn.remove_label(s_item)
211
212 for child_item in child_items:
213 serial_items.remove(child_item)
214
215 else:
216 conn.add_label(s_item)
217
218 # Remove labels for items following
219 for s_item in serial_items:
220 conn.remove_label(s_item)
221 child_items = conn.get_subitems(items, s_item)
222 for child_item in child_items:
223 conn.remove_label(child_item)
224
225
226 conn.logging.debug('%d changes queued for sync... commiting if needed', len(conn.api.queue))
227 if len(conn.api.queue):
228 queue = conn.api.queue
229 for ch in chunk(queue, 100):
230 conn.api.queue = ch
231 conn.api.commit()
232
233 if conn.args.onetime:
234 break
235 conn.logging.debug('Sleeping for %d seconds', conn.args.delay)
236 time.sleep(conn.args.delay)
237
238
239if __name__ == '__main__':
240 main()