|
1
|
|
|
#!/usr/bin/env python3 |
|
2
|
|
|
# -*- coding: utf-8 -*- |
|
3
|
|
|
|
|
4
|
|
|
"""Simple read-only API for Things 3.""" |
|
5
|
|
|
|
|
6
|
|
|
from __future__ import print_function |
|
7
|
|
|
|
|
8
|
|
|
__author__ = "Alexander Willner" |
|
9
|
|
|
__copyright__ = "2020 Alexander Willner" |
|
10
|
|
|
__credits__ = ["Alexander Willner"] |
|
11
|
|
|
__license__ = "Apache License 2.0" |
|
12
|
|
|
__version__ = "2.5.0.dev0" |
|
13
|
|
|
__maintainer__ = "Alexander Willner" |
|
14
|
|
|
__email__ = "[email protected]" |
|
15
|
|
|
__status__ = "Development" |
|
16
|
|
|
|
|
17
|
|
|
import sqlite3 |
|
18
|
|
|
import sys |
|
19
|
|
|
from random import shuffle |
|
20
|
|
|
from os import environ |
|
21
|
|
|
import getpass |
|
22
|
|
|
|
|
23
|
|
|
|
|
24
|
|
|
# pylint: disable=R0904 |
|
25
|
|
|
class Things3(): |
|
26
|
|
|
"""Simple read-only API for Things 3.""" |
|
27
|
|
|
# Variables |
|
28
|
|
|
debug = False |
|
29
|
|
|
database = None |
|
30
|
|
|
json = False |
|
31
|
|
|
filter = "" |
|
32
|
|
|
tag_waiting = "Waiting" if not environ.get('TAG_WAITING') \ |
|
33
|
|
|
else environ.get('TAG_WAITING') |
|
34
|
|
|
tag_mit = "MIT" if not environ.get('TAG_MIT') \ |
|
35
|
|
|
else environ.get('TAG_MIT') |
|
36
|
|
|
tag_cleanup = "Cleanup" if not environ.get('TAG_CLEANUP') \ |
|
37
|
|
|
else environ.get('TAG_CLEANUP') |
|
38
|
|
|
anonymize = bool(environ.get('ANONYMIZE')) |
|
39
|
|
|
|
|
40
|
|
|
# Database info |
|
41
|
|
|
FILE_DB = '/Library/Containers/'\ |
|
42
|
|
|
'com.culturedcode.ThingsMac/Data/Library/'\ |
|
43
|
|
|
'Application Support/Cultured Code/Things/Things.sqlite3' |
|
44
|
|
|
FILE_SQLITE = '/Users/' + getpass.getuser() + FILE_DB \ |
|
45
|
|
|
if not environ.get('THINGSDB') else environ.get('THINGSDB') |
|
46
|
|
|
|
|
47
|
|
|
TABLE_TASK = "TMTask" |
|
48
|
|
|
TABLE_AREA = "TMArea" |
|
49
|
|
|
TABLE_TAG = "TMTag" |
|
50
|
|
|
TABLE_TASKTAG = "TMTaskTag" |
|
51
|
|
|
DATE_CREATE = "creationDate" |
|
52
|
|
|
DATE_MOD = "userModificationDate" |
|
53
|
|
|
DATE_DUE = "dueDate" |
|
54
|
|
|
DATE_START = "startDate" |
|
55
|
|
|
DATE_STOP = "stopDate" |
|
56
|
|
|
IS_INBOX = "start = 0" |
|
57
|
|
|
IS_ANYTIME = "start = 1" |
|
58
|
|
|
IS_SOMEDAY = "start = 2" |
|
59
|
|
|
IS_SCHEDULED = f"{DATE_START} IS NOT NULL" |
|
60
|
|
|
IS_NOT_SCHEDULED = f"{DATE_START} IS NULL" |
|
61
|
|
|
IS_DUE = f"{DATE_DUE} IS NOT NULL" |
|
62
|
|
|
IS_NOT_DUE = f"{DATE_DUE} IS NULL" |
|
63
|
|
|
IS_RECURRING = "recurrenceRule IS NOT NULL" |
|
64
|
|
|
IS_NOT_RECURRING = "recurrenceRule IS NULL" |
|
65
|
|
|
IS_TASK = "type = 0" |
|
66
|
|
|
IS_PROJECT = "type = 1" |
|
67
|
|
|
IS_HEADING = "type = 2" |
|
68
|
|
|
IS_TRASHED = "trashed = 1" |
|
69
|
|
|
IS_NOT_TRASHED = "trashed = 0" |
|
70
|
|
|
IS_OPEN = "status = 0" |
|
71
|
|
|
IS_CANCELLED = "status = 2" |
|
72
|
|
|
IS_DONE = "status = 3" |
|
73
|
|
|
MODE_TASK = "type = 0" |
|
74
|
|
|
MODE_PROJECT = "type = 1" |
|
75
|
|
|
|
|
76
|
|
|
def __init__(self, |
|
77
|
|
|
database=FILE_SQLITE, |
|
78
|
|
|
tag_waiting='Waiting', |
|
79
|
|
|
tag_mit='MIT', |
|
80
|
|
|
json=False): |
|
81
|
|
|
self.database = database if database is not None else self.FILE_SQLITE |
|
82
|
|
|
self.tag_mit = tag_mit |
|
83
|
|
|
self.tag_waiting = tag_waiting |
|
84
|
|
|
self.json = json |
|
85
|
|
|
|
|
86
|
|
|
@staticmethod |
|
87
|
|
|
def anonymize_string(string): |
|
88
|
|
|
"""Scramble text.""" |
|
89
|
|
|
if string is None: |
|
90
|
|
|
return None |
|
91
|
|
|
string = list(string) |
|
92
|
|
|
shuffle(string) |
|
93
|
|
|
string = ''.join(string) |
|
94
|
|
|
return string |
|
95
|
|
|
|
|
96
|
|
|
@staticmethod |
|
97
|
|
|
def dict_factory(cursor, row): |
|
98
|
|
|
"""Convert SQL result into a dictionary""" |
|
99
|
|
|
dictionary = {} |
|
100
|
|
|
for idx, col in enumerate(cursor.description): |
|
101
|
|
|
dictionary[col[0]] = row[idx] |
|
102
|
|
|
return dictionary |
|
103
|
|
|
|
|
104
|
|
|
def anonymize_tasks(self, tasks): |
|
105
|
|
|
"""Scramble output for screenshots.""" |
|
106
|
|
|
if self.anonymize: |
|
107
|
|
|
for task in tasks: |
|
108
|
|
|
task['title'] = self.anonymize_string(task['title']) |
|
109
|
|
|
task['context'] = self.anonymize_string(task['context']) |
|
110
|
|
|
return tasks |
|
111
|
|
|
|
|
112
|
|
|
def get_inbox(self): |
|
113
|
|
|
"""Get all tasks from the inbox.""" |
|
114
|
|
|
query = f""" |
|
115
|
|
|
TASK.{self.IS_NOT_TRASHED} AND |
|
116
|
|
|
TASK.{self.IS_TASK} AND |
|
117
|
|
|
TASK.{self.IS_OPEN} AND |
|
118
|
|
|
TASK.{self.IS_INBOX} |
|
119
|
|
|
ORDER BY TASK.duedate DESC , TASK.todayIndex |
|
120
|
|
|
""" |
|
121
|
|
|
return self.get_rows(query) |
|
122
|
|
|
|
|
123
|
|
|
def get_today(self): |
|
124
|
|
|
"""Get all tasks from the today list.""" |
|
125
|
|
|
query = f""" |
|
126
|
|
|
TASK.{self.IS_NOT_TRASHED} AND |
|
127
|
|
|
TASK.{self.IS_TASK} AND |
|
128
|
|
|
TASK.{self.IS_OPEN} AND |
|
129
|
|
|
(TASK.{self.IS_ANYTIME} OR ( |
|
130
|
|
|
TASK.{self.IS_SOMEDAY} AND |
|
131
|
|
|
TASK.{self.DATE_START} <= strftime('%s', 'now') |
|
132
|
|
|
) |
|
133
|
|
|
) AND |
|
134
|
|
|
TASK.{self.IS_SCHEDULED} AND ( |
|
135
|
|
|
( |
|
136
|
|
|
PROJECT.title IS NULL OR ( |
|
137
|
|
|
PROJECT.{self.IS_NOT_TRASHED} |
|
138
|
|
|
) |
|
139
|
|
|
) AND ( |
|
140
|
|
|
HEADPROJ.title IS NULL OR ( |
|
141
|
|
|
HEADPROJ.{self.IS_NOT_TRASHED} |
|
142
|
|
|
) |
|
143
|
|
|
) |
|
144
|
|
|
) |
|
145
|
|
|
ORDER BY TASK.duedate DESC , TASK.todayIndex |
|
146
|
|
|
""" |
|
147
|
|
|
return self.get_rows(query) |
|
148
|
|
|
|
|
149
|
|
|
def get_someday(self): |
|
150
|
|
|
"""Get someday tasks.""" |
|
151
|
|
|
query = f""" |
|
152
|
|
|
TASK.{self.IS_NOT_TRASHED} AND |
|
153
|
|
|
TASK.{self.IS_TASK} AND |
|
154
|
|
|
TASK.{self.IS_OPEN} AND |
|
155
|
|
|
TASK.{self.IS_SOMEDAY} AND |
|
156
|
|
|
TASK.{self.IS_NOT_SCHEDULED} AND |
|
157
|
|
|
TASK.{self.IS_NOT_RECURRING} AND ( |
|
158
|
|
|
( |
|
159
|
|
|
PROJECT.title IS NULL OR ( |
|
160
|
|
|
PROJECT.{self.IS_NOT_TRASHED} |
|
161
|
|
|
) |
|
162
|
|
|
) AND ( |
|
163
|
|
|
HEADPROJ.title IS NULL OR ( |
|
164
|
|
|
HEADPROJ.{self.IS_NOT_TRASHED} |
|
165
|
|
|
) |
|
166
|
|
|
) |
|
167
|
|
|
) |
|
168
|
|
|
ORDER BY TASK.duedate DESC, TASK.creationdate DESC |
|
169
|
|
|
""" |
|
170
|
|
|
return self.get_rows(query) |
|
171
|
|
|
|
|
172
|
|
|
def get_upcoming(self): |
|
173
|
|
|
"""Get upcoming tasks.""" |
|
174
|
|
|
query = f""" |
|
175
|
|
|
TASK.{self.IS_NOT_TRASHED} AND |
|
176
|
|
|
TASK.{self.IS_TASK} AND |
|
177
|
|
|
TASK.{self.IS_OPEN} AND |
|
178
|
|
|
TASK.{self.IS_SOMEDAY} AND |
|
179
|
|
|
TASK.{self.IS_SCHEDULED} AND |
|
180
|
|
|
TASK.{self.IS_NOT_RECURRING} AND ( |
|
181
|
|
|
( |
|
182
|
|
|
PROJECT.title IS NULL OR ( |
|
183
|
|
|
PROJECT.{self.IS_NOT_TRASHED} |
|
184
|
|
|
) |
|
185
|
|
|
) AND ( |
|
186
|
|
|
HEADPROJ.title IS NULL OR ( |
|
187
|
|
|
HEADPROJ.{self.IS_NOT_TRASHED} |
|
188
|
|
|
) |
|
189
|
|
|
) |
|
190
|
|
|
) |
|
191
|
|
|
ORDER BY TASK.startdate, TASK.todayIndex |
|
192
|
|
|
""" |
|
193
|
|
|
return self.get_rows(query) |
|
194
|
|
|
|
|
195
|
|
|
def get_waiting(self): |
|
196
|
|
|
"""Get waiting tasks.""" |
|
197
|
|
|
return self.get_tag(self.tag_waiting) |
|
198
|
|
|
|
|
199
|
|
|
def get_mit(self): |
|
200
|
|
|
"""Get most important tasks.""" |
|
201
|
|
|
return self.get_tag(self.tag_mit) |
|
202
|
|
|
|
|
203
|
|
|
def get_tag(self, tag): |
|
204
|
|
|
"""Get task with specific tag""" |
|
205
|
|
|
query = f""" |
|
206
|
|
|
TASK.{self.IS_NOT_TRASHED} AND |
|
207
|
|
|
TASK.{self.IS_TASK} AND |
|
208
|
|
|
TASK.{self.IS_OPEN} AND |
|
209
|
|
|
TASK.{self.IS_NOT_RECURRING} AND |
|
210
|
|
|
TAGS.tags=(SELECT uuid FROM {self.TABLE_TAG} |
|
211
|
|
|
WHERE title='{tag}' |
|
212
|
|
|
) |
|
213
|
|
|
AND ( |
|
214
|
|
|
( |
|
215
|
|
|
PROJECT.title IS NULL OR ( |
|
216
|
|
|
PROJECT.{self.IS_NOT_TRASHED} |
|
217
|
|
|
) |
|
218
|
|
|
) AND ( |
|
219
|
|
|
HEADPROJ.title IS NULL OR ( |
|
220
|
|
|
HEADPROJ.{self.IS_NOT_TRASHED} |
|
221
|
|
|
) |
|
222
|
|
|
) |
|
223
|
|
|
) |
|
224
|
|
|
ORDER BY TASK.duedate DESC , TASK.todayIndex |
|
225
|
|
|
""" |
|
226
|
|
|
return self.get_rows(query) |
|
227
|
|
|
|
|
228
|
|
|
def get_anytime(self): |
|
229
|
|
|
"""Get anytime tasks.""" |
|
230
|
|
|
query = f""" |
|
231
|
|
|
TASK.{self.IS_NOT_TRASHED} AND |
|
232
|
|
|
TASK.{self.IS_TASK} AND |
|
233
|
|
|
TASK.{self.IS_OPEN} AND |
|
234
|
|
|
TASK.{self.IS_ANYTIME} AND |
|
235
|
|
|
TASK.{self.IS_NOT_SCHEDULED} AND ( |
|
236
|
|
|
( |
|
237
|
|
|
PROJECT.title IS NULL OR ( |
|
238
|
|
|
PROJECT.{self.IS_ANYTIME} AND |
|
239
|
|
|
PROJECT.{self.IS_NOT_SCHEDULED} AND |
|
240
|
|
|
PROJECT.{self.IS_NOT_TRASHED} |
|
241
|
|
|
) |
|
242
|
|
|
) AND ( |
|
243
|
|
|
HEADPROJ.title IS NULL OR ( |
|
244
|
|
|
HEADPROJ.{self.IS_ANYTIME} AND |
|
245
|
|
|
HEADPROJ.{self.IS_NOT_SCHEDULED} AND |
|
246
|
|
|
HEADPROJ.{self.IS_NOT_TRASHED} |
|
247
|
|
|
) |
|
248
|
|
|
) |
|
249
|
|
|
) |
|
250
|
|
|
ORDER BY TASK.duedate DESC , TASK.todayIndex |
|
251
|
|
|
""" |
|
252
|
|
|
if self.filter: |
|
253
|
|
|
# ugly hack for Kanban task view on project |
|
254
|
|
|
query = f""" |
|
255
|
|
|
TASK.{self.IS_NOT_TRASHED} AND |
|
256
|
|
|
TASK.{self.IS_TASK} AND |
|
257
|
|
|
TASK.{self.IS_OPEN} AND |
|
258
|
|
|
TASK.{self.IS_ANYTIME} AND |
|
259
|
|
|
TASK.{self.IS_NOT_SCHEDULED} AND ( |
|
260
|
|
|
( |
|
261
|
|
|
PROJECT.title IS NULL OR ( |
|
262
|
|
|
PROJECT.{self.IS_NOT_TRASHED} |
|
263
|
|
|
) |
|
264
|
|
|
) AND ( |
|
265
|
|
|
HEADPROJ.title IS NULL OR ( |
|
266
|
|
|
HEADPROJ.{self.IS_NOT_TRASHED} |
|
267
|
|
|
) |
|
268
|
|
|
) |
|
269
|
|
|
) |
|
270
|
|
|
ORDER BY TASK.duedate DESC , TASK.todayIndex |
|
271
|
|
|
""" |
|
272
|
|
|
return self.get_rows(query) |
|
273
|
|
|
|
|
274
|
|
|
def get_completed(self): |
|
275
|
|
|
"""Get completed tasks.""" |
|
276
|
|
|
query = f""" |
|
277
|
|
|
TASK.{self.IS_NOT_TRASHED} AND |
|
278
|
|
|
TASK.{self.IS_TASK} AND |
|
279
|
|
|
TASK.{self.IS_DONE} |
|
280
|
|
|
ORDER BY TASK.{self.DATE_STOP} |
|
281
|
|
|
""" |
|
282
|
|
|
return self.get_rows(query) |
|
283
|
|
|
|
|
284
|
|
|
def get_cancelled(self): |
|
285
|
|
|
"""Get cancelled tasks.""" |
|
286
|
|
|
query = f""" |
|
287
|
|
|
TASK.{self.IS_NOT_TRASHED} AND |
|
288
|
|
|
TASK.{self.IS_TASK} AND |
|
289
|
|
|
TASK.{self.IS_CANCELLED} |
|
290
|
|
|
ORDER BY TASK.{self.DATE_STOP} |
|
291
|
|
|
""" |
|
292
|
|
|
return self.get_rows(query) |
|
293
|
|
|
|
|
294
|
|
|
def get_trashed(self): |
|
295
|
|
|
"""Get trashed tasks.""" |
|
296
|
|
|
query = f""" |
|
297
|
|
|
TASK.{self.IS_TRASHED} AND |
|
298
|
|
|
TASK.{self.IS_TASK} |
|
299
|
|
|
ORDER BY TASK.{self.DATE_STOP} |
|
300
|
|
|
""" |
|
301
|
|
|
return self.get_rows(query) |
|
302
|
|
|
|
|
303
|
|
|
def get_projects(self): |
|
304
|
|
|
"""Get projects.""" |
|
305
|
|
|
query = f""" |
|
306
|
|
|
SELECT |
|
307
|
|
|
TASK.uuid, |
|
308
|
|
|
TASK.title, |
|
309
|
|
|
NULL as context |
|
310
|
|
|
FROM |
|
311
|
|
|
{self.TABLE_TASK} AS TASK |
|
312
|
|
|
WHERE |
|
313
|
|
|
TASK.{self.IS_NOT_TRASHED} AND |
|
314
|
|
|
TASK.{self.IS_PROJECT} AND |
|
315
|
|
|
TASK.{self.IS_OPEN} |
|
316
|
|
|
ORDER BY TASK.title |
|
317
|
|
|
""" |
|
318
|
|
|
return self.execute_query(query) |
|
319
|
|
|
|
|
320
|
|
|
def get_areas(self): |
|
321
|
|
|
"""Get areas.""" |
|
322
|
|
|
query = f""" |
|
323
|
|
|
SELECT |
|
324
|
|
|
AREA.uuid AS uuid, |
|
325
|
|
|
AREA.title AS title |
|
326
|
|
|
FROM |
|
327
|
|
|
{self.TABLE_AREA} AS AREA |
|
328
|
|
|
ORDER BY AREA.title |
|
329
|
|
|
""" |
|
330
|
|
|
return self.execute_query(query) |
|
331
|
|
|
|
|
332
|
|
|
def get_all(self): |
|
333
|
|
|
"""Get all tasks.""" |
|
334
|
|
|
query = f""" |
|
335
|
|
|
TASK.{self.IS_NOT_TRASHED} AND |
|
336
|
|
|
TASK.{self.IS_TASK} AND ( |
|
337
|
|
|
( |
|
338
|
|
|
PROJECT.title IS NULL OR ( |
|
339
|
|
|
PROJECT.{self.IS_NOT_TRASHED} |
|
340
|
|
|
) |
|
341
|
|
|
) AND ( |
|
342
|
|
|
HEADPROJ.title IS NULL OR ( |
|
343
|
|
|
HEADPROJ.{self.IS_NOT_TRASHED} |
|
344
|
|
|
) |
|
345
|
|
|
) |
|
346
|
|
|
) |
|
347
|
|
|
""" |
|
348
|
|
|
return self.get_rows(query) |
|
349
|
|
|
|
|
350
|
|
|
def get_due(self): |
|
351
|
|
|
"""Get due tasks.""" |
|
352
|
|
|
query = f""" |
|
353
|
|
|
TASK.{self.IS_NOT_TRASHED} AND |
|
354
|
|
|
TASK.{self.IS_TASK} AND |
|
355
|
|
|
TASK.{self.IS_OPEN} AND |
|
356
|
|
|
TASK.{self.IS_DUE} AND ( |
|
357
|
|
|
( |
|
358
|
|
|
PROJECT.title IS NULL OR ( |
|
359
|
|
|
PROJECT.{self.IS_NOT_TRASHED} |
|
360
|
|
|
) |
|
361
|
|
|
) AND ( |
|
362
|
|
|
HEADPROJ.title IS NULL OR ( |
|
363
|
|
|
HEADPROJ.{self.IS_NOT_TRASHED} |
|
364
|
|
|
) |
|
365
|
|
|
) |
|
366
|
|
|
) |
|
367
|
|
|
ORDER BY TASK.{self.DATE_DUE} |
|
368
|
|
|
""" |
|
369
|
|
|
return self.get_rows(query) |
|
370
|
|
|
|
|
371
|
|
|
def get_lint(self): |
|
372
|
|
|
"""Get tasks that float around""" |
|
373
|
|
|
query = f""" |
|
374
|
|
|
TASK.{self.IS_NOT_TRASHED} AND |
|
375
|
|
|
TASK.{self.IS_OPEN} AND |
|
376
|
|
|
TASK.{self.IS_TASK} AND |
|
377
|
|
|
(TASK.{self.IS_SOMEDAY} OR TASK.{self.IS_ANYTIME}) AND |
|
378
|
|
|
TASK.project IS NULL AND |
|
379
|
|
|
TASK.area IS NULL AND |
|
380
|
|
|
TASK.actionGroup IS NULL |
|
381
|
|
|
""" |
|
382
|
|
|
return self.get_rows(query) |
|
383
|
|
|
|
|
384
|
|
|
def get_empty_projects(self): |
|
385
|
|
|
"""Get projects that are empty""" |
|
386
|
|
|
query = f""" |
|
387
|
|
|
TASK.{self.IS_NOT_TRASHED} AND |
|
388
|
|
|
TASK.{self.IS_OPEN} AND |
|
389
|
|
|
TASK.{self.IS_PROJECT} AND |
|
390
|
|
|
(TASK.{self.IS_ANYTIME} OR TASK.{self.IS_SCHEDULED}) |
|
391
|
|
|
GROUP BY TASK.uuid |
|
392
|
|
|
HAVING |
|
393
|
|
|
(SELECT COUNT(uuid) |
|
394
|
|
|
FROM TMTask AS PROJECT_TASK |
|
395
|
|
|
WHERE |
|
396
|
|
|
PROJECT_TASK.project = TASK.uuid AND |
|
397
|
|
|
PROJECT_TASK.{self.IS_NOT_TRASHED} AND |
|
398
|
|
|
PROJECT_TASK.{self.IS_OPEN} AND |
|
399
|
|
|
PROJECT_TASK.{self.IS_ANYTIME} |
|
400
|
|
|
) = 0 |
|
401
|
|
|
""" |
|
402
|
|
|
return self.get_rows(query) |
|
403
|
|
|
|
|
404
|
|
|
def get_largest_projects(self): |
|
405
|
|
|
"""Get projects that are empty""" |
|
406
|
|
|
query = f""" |
|
407
|
|
|
SELECT |
|
408
|
|
|
TASK.uuid, |
|
409
|
|
|
TASK.title AS title, |
|
410
|
|
|
creationDate AS created, |
|
411
|
|
|
userModificationDate AS modified, |
|
412
|
|
|
(SELECT COUNT(uuid) |
|
413
|
|
|
FROM TMTask AS PROJECT_TASK |
|
414
|
|
|
WHERE |
|
415
|
|
|
PROJECT_TASK.project = TASK.uuid AND |
|
416
|
|
|
PROJECT_TASK.{self.IS_NOT_TRASHED} AND |
|
417
|
|
|
PROJECT_TASK.{self.IS_OPEN} |
|
418
|
|
|
) AS tasks |
|
419
|
|
|
FROM |
|
420
|
|
|
{self.TABLE_TASK} AS TASK |
|
421
|
|
|
WHERE |
|
422
|
|
|
TASK.{self.IS_NOT_TRASHED} AND |
|
423
|
|
|
TASK.{self.IS_OPEN} AND |
|
424
|
|
|
TASK.{self.IS_PROJECT} |
|
425
|
|
|
GROUP BY TASK.uuid |
|
426
|
|
|
ORDER BY tasks DESC |
|
427
|
|
|
""" |
|
428
|
|
|
return self.execute_query(query) |
|
429
|
|
|
|
|
430
|
|
|
def get_daystats(self): |
|
431
|
|
|
"""Get a history of task activities""" |
|
432
|
|
|
days = 365 |
|
433
|
|
|
query = f""" |
|
434
|
|
|
WITH RECURSIVE timeseries(x) AS ( |
|
435
|
|
|
SELECT 0 |
|
436
|
|
|
UNION ALL |
|
437
|
|
|
SELECT x+1 FROM timeseries |
|
438
|
|
|
LIMIT {days} |
|
439
|
|
|
) |
|
440
|
|
|
SELECT |
|
441
|
|
|
date(julianday("now", "-{days} days"), |
|
442
|
|
|
"+" || x || " days") as date, |
|
443
|
|
|
CREATED.TasksCreated as created, |
|
444
|
|
|
CLOSED.TasksClosed as completed, |
|
445
|
|
|
CANCELLED.TasksCancelled as cancelled, |
|
446
|
|
|
TRASHED.TasksTrashed as trashed |
|
447
|
|
|
FROM timeseries |
|
448
|
|
|
LEFT JOIN |
|
449
|
|
|
(SELECT COUNT(uuid) AS TasksCreated, |
|
450
|
|
|
date(creationDate,"unixepoch") AS DAY |
|
451
|
|
|
FROM {self.TABLE_TASK} AS TASK |
|
452
|
|
|
WHERE DAY NOT NULL |
|
453
|
|
|
AND TASK.{self.IS_TASK} |
|
454
|
|
|
GROUP BY DAY) |
|
455
|
|
|
AS CREATED ON CREATED.DAY = date |
|
456
|
|
|
LEFT JOIN |
|
457
|
|
|
(SELECT COUNT(uuid) AS TasksCancelled, |
|
458
|
|
|
date(stopDate,"unixepoch") AS DAY |
|
459
|
|
|
FROM {self.TABLE_TASK} AS TASK |
|
460
|
|
|
WHERE DAY NOT NULL |
|
461
|
|
|
AND TASK.{self.IS_CANCELLED} AND TASK.{self.IS_TASK} |
|
462
|
|
|
GROUP BY DAY) |
|
463
|
|
|
AS CANCELLED ON CANCELLED.DAY = date |
|
464
|
|
|
LEFT JOIN |
|
465
|
|
|
(SELECT COUNT(uuid) AS TasksTrashed, |
|
466
|
|
|
date(userModificationDate,"unixepoch") AS DAY |
|
467
|
|
|
FROM {self.TABLE_TASK} AS TASK |
|
468
|
|
|
WHERE DAY NOT NULL |
|
469
|
|
|
AND TASK.{self.IS_TRASHED} AND TASK.{self.IS_TASK} |
|
470
|
|
|
GROUP BY DAY) |
|
471
|
|
|
AS TRASHED ON TRASHED.DAY = date |
|
472
|
|
|
LEFT JOIN |
|
473
|
|
|
(SELECT COUNT(uuid) AS TasksClosed, |
|
474
|
|
|
date(stopDate,"unixepoch") AS DAY |
|
475
|
|
|
FROM {self.TABLE_TASK} AS TASK |
|
476
|
|
|
WHERE DAY NOT NULL |
|
477
|
|
|
AND TASK.{self.IS_DONE} AND TASK.{self.IS_TASK} |
|
478
|
|
|
GROUP BY DAY) |
|
479
|
|
|
AS CLOSED ON CLOSED.DAY = date |
|
480
|
|
|
""" |
|
481
|
|
|
return self.execute_query(query) |
|
482
|
|
|
|
|
483
|
|
|
def get_minutes_today(self): |
|
484
|
|
|
"""Count the planned minutes for today.""" |
|
485
|
|
|
query = f""" |
|
486
|
|
|
SELECT |
|
487
|
|
|
SUM(TAG.title) AS minutes |
|
488
|
|
|
FROM |
|
489
|
|
|
{self.TABLE_TASK} AS TASK |
|
490
|
|
|
LEFT OUTER JOIN |
|
491
|
|
|
TMTask PROJECT ON TASK.project = PROJECT.uuid |
|
492
|
|
|
LEFT OUTER JOIN |
|
493
|
|
|
TMArea AREA ON TASK.area = AREA.uuid |
|
494
|
|
|
LEFT OUTER JOIN |
|
495
|
|
|
TMTask HEADING ON TASK.actionGroup = HEADING.uuid |
|
496
|
|
|
LEFT OUTER JOIN |
|
497
|
|
|
TMTask HEADPROJ ON HEADING.project = HEADPROJ.uuid |
|
498
|
|
|
LEFT OUTER JOIN |
|
499
|
|
|
TMTaskTag TAGS ON TASK.uuid = TAGS.tasks |
|
500
|
|
|
LEFT OUTER JOIN |
|
501
|
|
|
TMTag TAG ON TAGS.tags = TAG.uuid |
|
502
|
|
|
WHERE |
|
503
|
|
|
printf("%d", TAG.title) = TAG.title AND |
|
504
|
|
|
TASK.{self.IS_NOT_TRASHED} AND |
|
505
|
|
|
TASK.{self.IS_TASK} AND |
|
506
|
|
|
TASK.{self.IS_OPEN} AND |
|
507
|
|
|
TASK.{self.IS_ANYTIME} AND |
|
508
|
|
|
TASK.{self.IS_SCHEDULED} AND ( |
|
509
|
|
|
( |
|
510
|
|
|
PROJECT.title IS NULL OR ( |
|
511
|
|
|
PROJECT.{self.IS_NOT_TRASHED} |
|
512
|
|
|
) |
|
513
|
|
|
) AND ( |
|
514
|
|
|
HEADPROJ.title IS NULL OR ( |
|
515
|
|
|
HEADPROJ.{self.IS_NOT_TRASHED} |
|
516
|
|
|
) |
|
517
|
|
|
) |
|
518
|
|
|
) |
|
519
|
|
|
""" |
|
520
|
|
|
return self.execute_query(query) |
|
521
|
|
|
|
|
522
|
|
|
def get_cleanup(self): |
|
523
|
|
|
"""Tasks and projects that need work.""" |
|
524
|
|
|
result = [] |
|
525
|
|
|
result.extend(self.get_lint()) |
|
526
|
|
|
result.extend(self.get_empty_projects()) |
|
527
|
|
|
result.extend(self.get_tag(self.tag_cleanup)) |
|
528
|
|
|
return result |
|
529
|
|
|
|
|
530
|
|
|
@staticmethod |
|
531
|
|
|
def get_not_implemented(): |
|
532
|
|
|
"""Not implemented warning.""" |
|
533
|
|
|
return [{"title": "not implemented"}] |
|
534
|
|
|
|
|
535
|
|
|
def get_rows(self, sql): |
|
536
|
|
|
"""Query Things database.""" |
|
537
|
|
|
|
|
538
|
|
|
sql = f""" |
|
539
|
|
|
SELECT DISTINCT |
|
540
|
|
|
TASK.uuid, |
|
541
|
|
|
TASK.title, |
|
542
|
|
|
CASE |
|
543
|
|
|
WHEN AREA.title IS NOT NULL THEN AREA.title |
|
544
|
|
|
WHEN PROJECT.title IS NOT NULL THEN PROJECT.title |
|
545
|
|
|
WHEN HEADING.title IS NOT NULL THEN HEADING.title |
|
546
|
|
|
END AS context, |
|
547
|
|
|
CASE |
|
548
|
|
|
WHEN AREA.uuid IS NOT NULL THEN AREA.uuid |
|
549
|
|
|
WHEN PROJECT.uuid IS NOT NULL THEN PROJECT.uuid |
|
550
|
|
|
END AS context_uuid, |
|
551
|
|
|
CASE |
|
552
|
|
|
WHEN TASK.recurrenceRule IS NULL |
|
553
|
|
|
THEN date(TASK.dueDate,"unixepoch") |
|
554
|
|
|
ELSE NULL |
|
555
|
|
|
END AS due, |
|
556
|
|
|
date(TASK.creationDate,"unixepoch") as created, |
|
557
|
|
|
date(TASK.userModificationDate,"unixepoch") as modified, |
|
558
|
|
|
date(TASK.startDate,"unixepoch") as started, |
|
559
|
|
|
date(TASK.stopDate,"unixepoch") as stopped, |
|
560
|
|
|
(SELECT COUNT(uuid) |
|
561
|
|
|
FROM TMTask AS PROJECT_TASK |
|
562
|
|
|
WHERE |
|
563
|
|
|
PROJECT_TASK.project = TASK.uuid AND |
|
564
|
|
|
PROJECT_TASK.{self.IS_NOT_TRASHED} AND |
|
565
|
|
|
PROJECT_TASK.{self.IS_OPEN} |
|
566
|
|
|
) AS size, |
|
567
|
|
|
CASE |
|
568
|
|
|
WHEN TASK.type = 0 THEN 'task' |
|
569
|
|
|
WHEN TASK.type = 1 THEN 'project' |
|
570
|
|
|
WHEN TASK.type = 2 THEN 'heading' |
|
571
|
|
|
END AS type |
|
572
|
|
|
FROM |
|
573
|
|
|
{self.TABLE_TASK} AS TASK |
|
574
|
|
|
LEFT OUTER JOIN |
|
575
|
|
|
{self.TABLE_TASK} PROJECT ON TASK.project = PROJECT.uuid |
|
576
|
|
|
LEFT OUTER JOIN |
|
577
|
|
|
{self.TABLE_AREA} AREA ON TASK.area = AREA.uuid |
|
578
|
|
|
LEFT OUTER JOIN |
|
579
|
|
|
{self.TABLE_TASK} HEADING ON TASK.actionGroup = HEADING.uuid |
|
580
|
|
|
LEFT OUTER JOIN |
|
581
|
|
|
{self.TABLE_TASK} HEADPROJ ON HEADING.project = HEADPROJ.uuid |
|
582
|
|
|
LEFT OUTER JOIN |
|
583
|
|
|
{self.TABLE_TASKTAG} TAGS ON TASK.uuid = TAGS.tasks |
|
584
|
|
|
LEFT OUTER JOIN |
|
585
|
|
|
{self.TABLE_TAG} TAG ON TAGS.tags = TAG.uuid |
|
586
|
|
|
WHERE |
|
587
|
|
|
{self.filter} |
|
588
|
|
|
{sql} |
|
589
|
|
|
""" |
|
590
|
|
|
|
|
591
|
|
|
return self.execute_query(sql) |
|
592
|
|
|
|
|
593
|
|
|
def execute_query(self, sql): |
|
594
|
|
|
"""Run the actual query""" |
|
595
|
|
|
if self.debug is True: |
|
596
|
|
|
print(sql) |
|
597
|
|
|
try: |
|
598
|
|
|
connection = sqlite3.connect(self.database) |
|
599
|
|
|
connection.row_factory = Things3.dict_factory |
|
600
|
|
|
cursor = connection.cursor() |
|
601
|
|
|
cursor.execute(sql) |
|
602
|
|
|
tasks = cursor.fetchall() |
|
603
|
|
|
tasks = self.anonymize_tasks(tasks) |
|
604
|
|
|
if self.debug: |
|
605
|
|
|
for task in tasks: |
|
606
|
|
|
print(task) |
|
607
|
|
|
return tasks |
|
608
|
|
|
except sqlite3.OperationalError as error: |
|
609
|
|
|
print(f"Could not query the database at: {self.database}.") |
|
610
|
|
|
print(f"Details: {error}.") |
|
611
|
|
|
sys.exit(2) |
|
612
|
|
|
|
|
613
|
|
|
# pylint: disable=C0103 |
|
614
|
|
|
def mode_project(self): |
|
615
|
|
|
"""Hack to switch to project view""" |
|
616
|
|
|
self.IS_TASK = self.MODE_PROJECT |
|
617
|
|
|
|
|
618
|
|
|
# pylint: disable=C0103 |
|
619
|
|
|
def mode_task(self): |
|
620
|
|
|
"""Hack to switch to project view""" |
|
621
|
|
|
self.IS_TASK = self.MODE_TASK |
|
622
|
|
|
|
|
623
|
|
|
functions = { |
|
624
|
|
|
"inbox": get_inbox, |
|
625
|
|
|
"today": get_today, |
|
626
|
|
|
"next": get_anytime, |
|
627
|
|
|
"backlog": get_someday, |
|
628
|
|
|
"upcoming": get_upcoming, |
|
629
|
|
|
"waiting": get_waiting, |
|
630
|
|
|
"mit": get_mit, |
|
631
|
|
|
"completed": get_completed, |
|
632
|
|
|
"cancelled": get_cancelled, |
|
633
|
|
|
"trashed": get_trashed, |
|
634
|
|
|
"projects": get_projects, |
|
635
|
|
|
"areas": get_areas, |
|
636
|
|
|
"all": get_all, |
|
637
|
|
|
"due": get_due, |
|
638
|
|
|
"lint": get_lint, |
|
639
|
|
|
"empty": get_empty_projects, |
|
640
|
|
|
"cleanup": get_cleanup, |
|
641
|
|
|
"top-proj": get_largest_projects, |
|
642
|
|
|
"stats-day": get_daystats, |
|
643
|
|
|
"stats-min-today": get_minutes_today |
|
644
|
|
|
} |
|
645
|
|
|
|