Passed
Push — master ( b554d9...0f1c51 )
by Alexander
01:17
created

things3.things3.Things3.get_mit()   A

Complexity

Conditions 1

Size

Total Lines 3
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 2
nop 1
dl 0
loc 3
rs 10
c 0
b 0
f 0
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.3.0"
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
    tag_waiting = "Waiting" if not environ.get('TAG_WAITING') \
32
        else environ.get('TAG_WAITING')
33
    tag_mit = "MIT" if not environ.get('TAG_MIT') \
34
        else environ.get('TAG_MIT')
35
    tag_cleanup = "Cleanup" if not environ.get('TAG_CLEANUP') \
36
        else environ.get('TAG_CLEANUP')
37
    anonymize = bool(environ.get('ANONYMIZE'))
38
39
    # Database info
40
    FILE_DB = '/Library/Containers/'\
41
              'com.culturedcode.ThingsMac/Data/Library/'\
42
              'Application Support/Cultured Code/Things/Things.sqlite3'
43
    FILE_SQLITE = '/Users/' + getpass.getuser() + FILE_DB \
44
        if not environ.get('THINGSDB') else environ.get('THINGSDB')
45
46
    TABLE_TASK = "TMTask"
47
    TABLE_AREA = "TMArea"
48
    TABLE_TAG = "TMTag"
49
    TABLE_TASKTAG = "TMTaskTag"
50
    DATE_CREATE = "creationDate"
51
    DATE_MOD = "userModificationDate"
52
    DATE_DUE = "dueDate"
53
    DATE_START = "startDate"
54
    DATE_STOP = "stopDate"
55
    IS_INBOX = "start = 0"
56
    IS_ANYTIME = "start = 1"
57
    IS_SOMEDAY = "start = 2"
58
    IS_SCHEDULED = f"{DATE_START} IS NOT NULL"
59
    IS_NOT_SCHEDULED = f"{DATE_START} IS NULL"
60
    IS_DUE = f"{DATE_DUE} IS NOT NULL"
61
    IS_NOT_DUE = f"{DATE_DUE} IS NULL"
62
    IS_RECURRING = "recurrenceRule IS NOT NULL"
63
    IS_NOT_RECURRING = "recurrenceRule IS NULL"
64
    IS_TASK = "type = 0"
65
    IS_PROJECT = "type = 1"
66
    IS_HEADING = "type = 2"
67
    IS_TRASHED = "trashed = 1"
68
    IS_NOT_TRASHED = "trashed = 0"
69
    IS_OPEN = "status = 0"
70
    IS_CANCELLED = "status = 2"
71
    IS_DONE = "status = 3"
72
73
    # Query Index
74
    I_UUID = 0
75
    I_TITLE = 1
76
    I_CONTEXT = 2
77
    I_CONTEXT_UUID = 3
78
    I_DUE = 4
79
    I_CREATE = 5
80
    I_MOD = 6
81
    I_START = 7
82
    I_STOP = 8
83
84
    def __init__(self,
85
                 database=FILE_SQLITE,
86
                 tag_waiting='Waiting',
87
                 tag_mit='MIT',
88
                 json=False):
89
        self.database = database if database is not None else self.FILE_SQLITE
90
        self.tag_mit = tag_mit
91
        self.tag_waiting = tag_waiting
92
        self.json = json
93
94
    @staticmethod
95
    def anonymize_string(string):
96
        """Scramble text."""
97
        string = list(string)
98
        shuffle(string)
99
        string = ''.join(string)
100
        return string
101
102
    def anonymize_tasks(self, tasks):
103
        """Scramble output for screenshots."""
104
        result = tasks
105
        if self.anonymize:
106
            result = []
107
            for task in tasks:
108
                task = list(task)
109
                task[self.I_TITLE] = \
110
                    self.anonymize_string(str(task[self.I_TITLE]))
111
                task[self.I_CONTEXT] = \
112
                    self.anonymize_string(str(task[self.I_CONTEXT]))
113
                result.append(task)
114
        return result
115
116
    def get_inbox(self):
117
        """Get all tasks from the inbox."""
118
        query = f"""
119
                TASK.{self.IS_NOT_TRASHED} AND
120
                TASK.{self.IS_TASK} AND
121
                TASK.{self.IS_OPEN} AND
122
                TASK.{self.IS_INBOX}
123
                ORDER BY TASK.duedate DESC , TASK.todayIndex
124
                """
125
        return self.get_rows(query)
126
127
    def get_today(self):
128
        """Get all tasks from the today list."""
129
        query = f"""
130
                TASK.{self.IS_NOT_TRASHED} AND
131
                TASK.{self.IS_TASK} AND
132
                TASK.{self.IS_OPEN} AND
133
                TASK.{self.IS_ANYTIME} 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_ANYTIME} AND
161
                            PROJECT.{self.IS_NOT_SCHEDULED} AND
162
                            PROJECT.{self.IS_NOT_TRASHED}
163
                        )
164
                    ) AND (
165
                        HEADPROJ.title IS NULL OR (
166
                            HEADPROJ.{self.IS_ANYTIME} AND
167
                            HEADPROJ.{self.IS_NOT_SCHEDULED} AND
168
                            HEADPROJ.{self.IS_NOT_TRASHED}
169
                        )
170
                    )
171
                )
172
                ORDER BY TASK.duedate DESC, TASK.creationdate DESC
173
                """
174
        return self.get_rows(query)
175
176
    def get_upcoming(self):
177
        """Get upcoming tasks."""
178
        query = f"""
179
                TASK.{self.IS_NOT_TRASHED} AND
180
                TASK.{self.IS_TASK} AND
181
                TASK.{self.IS_OPEN} AND
182
                TASK.{self.IS_SOMEDAY} AND
183
                TASK.{self.IS_SCHEDULED} AND
184
                TASK.{self.IS_NOT_RECURRING} AND (
185
                    (
186
                        PROJECT.title IS NULL OR (
187
                            PROJECT.{self.IS_NOT_TRASHED}
188
                        )
189
                    ) AND (
190
                        HEADPROJ.title IS NULL OR (
191
                            HEADPROJ.{self.IS_NOT_TRASHED}
192
                        )
193
                    )
194
                )
195
                ORDER BY TASK.startdate, TASK.todayIndex
196
                """
197
        return self.get_rows(query)
198
199
    def get_waiting(self):
200
        """Get waiting tasks."""
201
        return self.get_tag(self.tag_waiting)
202
203
    def get_mit(self):
204
        """Get most important tasks."""
205
        return self.get_tag(self.tag_mit)
206
207
    def get_tag(self, tag):
208
        """Get task with specific tag"""
209
        query = f"""
210
                TASK.{self.IS_NOT_TRASHED} AND
211
                TASK.{self.IS_TASK} AND
212
                TASK.{self.IS_OPEN} AND
213
                TASK.{self.IS_NOT_RECURRING} AND
214
                TAGS.tags=(SELECT uuid FROM {self.TABLE_TAG}
215
                             WHERE title='{tag}'
216
                          )
217
                AND (
218
                    (
219
                        PROJECT.title IS NULL OR (
220
                            PROJECT.{self.IS_NOT_TRASHED}
221
                        )
222
                    ) AND (
223
                        HEADPROJ.title IS NULL OR (
224
                            HEADPROJ.{self.IS_NOT_TRASHED}
225
                        )
226
                    )
227
                )
228
                ORDER BY TASK.duedate DESC , TASK.todayIndex
229
                """
230
        return self.get_rows(query)
231
232
    def get_anytime(self):
233
        """Get anytime tasks."""
234
        query = f"""
235
                TASK.{self.IS_NOT_TRASHED} AND
236
                TASK.{self.IS_TASK} AND
237
                TASK.{self.IS_OPEN} AND
238
                TASK.{self.IS_ANYTIME} AND
239
                TASK.{self.IS_NOT_SCHEDULED} AND (
240
                    (
241
                        PROJECT.title IS NULL OR (
242
                            PROJECT.{self.IS_ANYTIME} AND
243
                            PROJECT.{self.IS_NOT_SCHEDULED} AND
244
                            PROJECT.{self.IS_NOT_TRASHED}
245
                        )
246
                    ) AND (
247
                        HEADPROJ.title IS NULL OR (
248
                            HEADPROJ.{self.IS_ANYTIME} AND
249
                            HEADPROJ.{self.IS_NOT_SCHEDULED} AND
250
                            HEADPROJ.{self.IS_NOT_TRASHED}
251
                        )
252
                    )
253
                )
254
                ORDER BY TASK.duedate DESC , TASK.todayIndex
255
                """
256
        return self.get_rows(query)
257
258
    def get_completed(self):
259
        """Get completed tasks."""
260
        query = f"""
261
                TASK.{self.IS_NOT_TRASHED} AND
262
                TASK.{self.IS_TASK} AND
263
                TASK.{self.IS_DONE}
264
                ORDER BY TASK.{self.DATE_STOP}
265
                """
266
        return self.get_rows(query)
267
268
    def get_cancelled(self):
269
        """Get cancelled tasks."""
270
        query = f"""
271
                TASK.{self.IS_NOT_TRASHED} AND
272
                TASK.{self.IS_TASK} AND
273
                TASK.{self.IS_CANCELLED}
274
                ORDER BY TASK.{self.DATE_STOP}
275
                """
276
        return self.get_rows(query)
277
278
    def get_trashed(self):
279
        """Get trashed tasks."""
280
        query = f"""
281
                TASK.{self.IS_TRASHED} AND
282
                TASK.{self.IS_TASK}
283
                ORDER BY TASK.{self.DATE_STOP}
284
                """
285
        return self.get_rows(query)
286
287
    def get_all(self):
288
        """Get all tasks."""
289
        query = f"""
290
                TASK.{self.IS_NOT_TRASHED} AND
291
                TASK.{self.IS_TASK} AND (
292
                    (
293
                        PROJECT.title IS NULL OR (
294
                            PROJECT.{self.IS_NOT_TRASHED}
295
                        )
296
                    ) AND (
297
                        HEADPROJ.title IS NULL OR (
298
                            HEADPROJ.{self.IS_NOT_TRASHED}
299
                        )
300
                    )
301
                )
302
                """
303
        return self.get_rows(query)
304
305
    def get_due(self):
306
        """Get due tasks."""
307
        query = f"""
308
                TASK.{self.IS_NOT_TRASHED} AND
309
                TASK.{self.IS_TASK} AND
310
                TASK.{self.IS_OPEN} AND
311
                TASK.{self.IS_DUE} AND (
312
                    (
313
                        PROJECT.title IS NULL OR (
314
                            PROJECT.{self.IS_NOT_TRASHED}
315
                        )
316
                    ) AND (
317
                        HEADPROJ.title IS NULL OR (
318
                            HEADPROJ.{self.IS_NOT_TRASHED}
319
                        )
320
                    )
321
                )
322
                ORDER BY TASK.{self.DATE_DUE}
323
                """
324
        return self.get_rows(query)
325
326
    def get_lint(self):
327
        """Get tasks that float around"""
328
        query = f"""
329
            TASK.{self.IS_NOT_TRASHED} AND
330
            TASK.{self.IS_OPEN} AND
331
            TASK.{self.IS_TASK} AND
332
            (TASK.{self.IS_SOMEDAY} OR TASK.{self.IS_ANYTIME}) AND
333
            TASK.project IS NULL AND
334
            TASK.area IS NULL AND
335
            TASK.actionGroup IS NULL
336
            """
337
        return self.get_rows(query)
338
339
    def get_empty_projects(self):
340
        """Get projects that are empty"""
341
        query = f"""
342
            TASK.{self.IS_NOT_TRASHED} AND
343
            TASK.{self.IS_OPEN} AND
344
            TASK.{self.IS_PROJECT}
345
            GROUP BY TASK.uuid
346
            HAVING
347
                (SELECT COUNT(uuid)
348
                 FROM TMTask
349
                 WHERE
350
                   project = TASK.uuid AND
351
                   {self.IS_NOT_TRASHED} AND
352
                   {self.IS_OPEN}
353
                ) = 0
354
            """
355
        return self.get_rows(query)
356
357
    def get_cleanup(self):
358
        """Tasks and projects that need work."""
359
        result = []
360
        result.extend(self.get_lint())
361
        result.extend(self.get_empty_projects())
362
        result.extend(self.get_tag(self.tag_cleanup))
363
        return result
364
365
    @staticmethod
366
    def get_not_implemented():
367
        """Not implemented warning."""
368
        return [["0", "not implemented", "no context", "0", "0", "0", "0",
369
                 "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0"]]
370
371
    def get_rows(self, sql):
372
        """Query Things database."""
373
374
        sql = """
375
            SELECT DISTINCT
376
                TASK.uuid,
377
                TASK.title,
378
                CASE
379
                    WHEN AREA.title IS NOT NULL THEN AREA.title
380
                    WHEN PROJECT.title IS NOT NULL THEN PROJECT.title
381
                    WHEN HEADING.title IS NOT NULL THEN HEADING.title
382
                END,
383
                CASE
384
                    WHEN AREA.uuid IS NOT NULL THEN AREA.uuid
385
                    WHEN PROJECT.uuid IS NOT NULL THEN PROJECT.uuid
386
                END,
387
                CASE
388
                    WHEN TASK.recurrenceRule IS NULL
389
                    THEN date(TASK.dueDate,"unixepoch")
390
                ELSE NULL
391
                END,
392
                date(TASK.creationDate,"unixepoch"),
393
                date(TASK.userModificationDate,"unixepoch"),
394
                date(TASK.startDate,"unixepoch"),
395
                date(TASK.stopDate,"unixepoch")
396
            FROM
397
                TMTask AS TASK
398
            LEFT OUTER JOIN
399
                TMTask PROJECT ON TASK.project = PROJECT.uuid
400
            LEFT OUTER JOIN
401
                TMArea AREA ON TASK.area = AREA.uuid
402
            LEFT OUTER JOIN
403
                TMTask HEADING ON TASK.actionGroup = HEADING.uuid
404
            LEFT OUTER JOIN
405
                TMTask HEADPROJ ON HEADING.project = HEADPROJ.uuid
406
            LEFT OUTER JOIN
407
                TMTaskTag TAGS ON TASK.uuid = TAGS.tasks
408
            LEFT OUTER JOIN
409
                TMTag TAG ON TAGS.tags = TAG.uuid
410
            WHERE
411
                """ + sql
412
413
        if self.debug is True:
414
            print(sql)
415
416
        try:
417
            cursor = sqlite3.connect(self.database).cursor()
418
            cursor.execute(sql)
419
            tasks = cursor.fetchall()
420
            tasks = self.anonymize_tasks(tasks)
421
            if self.debug:
422
                for task in tasks:
423
                    print(task)
424
            return tasks
425
        except sqlite3.OperationalError as error:
426
            print(f"Could not query the database at: {self.database}.")
427
            print(f"Details: {error}.")
428
            sys.exit(2)
429
430
    def convert_task_to_model(self, task):
431
        """Convert task to model."""
432
        model = {'uuid': task[self.I_UUID],
433
                 'title': task[self.I_TITLE],
434
                 'context': task[self.I_CONTEXT],
435
                 'context_uuid': task[self.I_CONTEXT_UUID],
436
                 'due': task[self.I_DUE],
437
                 'created': task[self.I_CREATE],
438
                 'modified': task[self.I_MOD],
439
                 'started': task[self.I_START],
440
                 'stopped': task[self.I_STOP]
441
                 }
442
        return model
443
444
    def convert_tasks_to_model(self, tasks):
445
        """Convert tasks to model."""
446
        model = []
447
        for task in tasks:
448
            model.append(self.convert_task_to_model(task))
449
        return model
450
451
    # pylint: disable=C0103
452
    def toggle_mode(self):
453
        """Hack to switch to project view"""
454
        if self.IS_TASK == "type = 1":
455
            self.IS_TASK = "type = 0"
456
        else:
457
            self.IS_TASK = "type = 1"
458
459
    functions = {
460
        "inbox": get_inbox,
461
        "today": get_today,
462
        "next": get_anytime,
463
        "backlog": get_someday,
464
        "upcoming": get_upcoming,
465
        "waiting": get_waiting,
466
        "mit": get_mit,
467
        "completed": get_completed,
468
        "cancelled": get_cancelled,
469
        "trashed": get_trashed,
470
        "all": get_all,
471
        "due": get_due,
472
        "lint": get_lint,
473
        "empty": get_empty_projects,
474
        "cleanup": get_cleanup
475
    }
476