Passed
Push — master ( 155686...1800eb )
by Alexander
01:22
created

things3.things3.Things3.get_lint()   A

Complexity

Conditions 1

Size

Total Lines 12
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 3
nop 1
dl 0
loc 12
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.2.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
                TAGS.tags=(SELECT uuid FROM {self.TABLE_TAG}
214
                             WHERE title='{tag}'
215
                          )
216
                AND (
217
                    (
218
                        PROJECT.title IS NULL OR (
219
                            PROJECT.{self.IS_NOT_TRASHED}
220
                        )
221
                    ) AND (
222
                        HEADPROJ.title IS NULL OR (
223
                            HEADPROJ.{self.IS_NOT_TRASHED}
224
                        )
225
                    )
226
                )
227
                ORDER BY TASK.duedate DESC , TASK.todayIndex
228
                """
229
        return self.get_rows(query)
230
231
    def get_anytime(self):
232
        """Get anytime tasks."""
233
        query = f"""
234
                TASK.{self.IS_NOT_TRASHED} AND
235
                TASK.{self.IS_TASK} AND
236
                TASK.{self.IS_OPEN} AND
237
                TASK.{self.IS_ANYTIME} AND
238
                TASK.{self.IS_NOT_SCHEDULED} AND (
239
                    (
240
                        PROJECT.title IS NULL OR (
241
                            PROJECT.{self.IS_ANYTIME} AND
242
                            PROJECT.{self.IS_NOT_SCHEDULED} AND
243
                            PROJECT.{self.IS_NOT_TRASHED}
244
                        )
245
                    ) AND (
246
                        HEADPROJ.title IS NULL OR (
247
                            HEADPROJ.{self.IS_ANYTIME} AND
248
                            HEADPROJ.{self.IS_NOT_SCHEDULED} AND
249
                            HEADPROJ.{self.IS_NOT_TRASHED}
250
                        )
251
                    )
252
                )
253
                ORDER BY TASK.duedate DESC , TASK.todayIndex
254
                """
255
        return self.get_rows(query)
256
257
    def get_completed(self):
258
        """Get completed tasks."""
259
        query = f"""
260
                TASK.{self.IS_NOT_TRASHED} AND
261
                TASK.{self.IS_TASK} AND
262
                TASK.{self.IS_DONE}
263
                ORDER BY TASK.{self.DATE_STOP}
264
                """
265
        return self.get_rows(query)
266
267
    def get_cancelled(self):
268
        """Get cancelled tasks."""
269
        query = f"""
270
                TASK.{self.IS_NOT_TRASHED} AND
271
                TASK.{self.IS_TASK} AND
272
                TASK.{self.IS_CANCELLED}
273
                ORDER BY TASK.{self.DATE_STOP}
274
                """
275
        return self.get_rows(query)
276
277
    def get_trashed(self):
278
        """Get trashed tasks."""
279
        query = f"""
280
                TASK.{self.IS_TRASHED} AND
281
                TASK.{self.IS_TASK}
282
                ORDER BY TASK.{self.DATE_STOP}
283
                """
284
        return self.get_rows(query)
285
286
    def get_all(self):
287
        """Get all tasks."""
288
        query = f"""
289
                TASK.{self.IS_NOT_TRASHED} AND
290
                TASK.{self.IS_TASK} AND (
291
                    (
292
                        PROJECT.title IS NULL OR (
293
                            PROJECT.{self.IS_NOT_TRASHED}
294
                        )
295
                    ) AND (
296
                        HEADPROJ.title IS NULL OR (
297
                            HEADPROJ.{self.IS_NOT_TRASHED}
298
                        )
299
                    )
300
                )
301
                """
302
        return self.get_rows(query)
303
304
    def get_due(self):
305
        """Get due tasks."""
306
        query = f"""
307
                TASK.{self.IS_NOT_TRASHED} AND
308
                TASK.{self.IS_TASK} AND
309
                TASK.{self.IS_OPEN} AND
310
                TASK.{self.IS_DUE} AND (
311
                    (
312
                        PROJECT.title IS NULL OR (
313
                            PROJECT.{self.IS_NOT_TRASHED}
314
                        )
315
                    ) AND (
316
                        HEADPROJ.title IS NULL OR (
317
                            HEADPROJ.{self.IS_NOT_TRASHED}
318
                        )
319
                    )
320
                )
321
                ORDER BY TASK.{self.DATE_DUE}
322
                """
323
        return self.get_rows(query)
324
325
    def get_lint(self):
326
        """Get tasks that float around"""
327
        query = f"""
328
            TASK.{self.IS_NOT_TRASHED} AND
329
            TASK.{self.IS_OPEN} AND
330
            TASK.{self.IS_TASK} AND
331
            (TASK.{self.IS_SOMEDAY} OR TASK.{self.IS_ANYTIME}) AND
332
            TASK.project IS NULL AND
333
            TASK.area IS NULL AND
334
            TASK.actionGroup IS NULL
335
            """
336
        return self.get_rows(query)
337
338
    def get_empty_projects(self):
339
        """Get projects that are empty"""
340
        query = f"""
341
            TASK.{self.IS_NOT_TRASHED} AND
342
            TASK.{self.IS_OPEN} AND
343
            TASK.{self.IS_PROJECT}
344
            GROUP BY TASK.uuid
345
            HAVING
346
                (SELECT COUNT(uuid)
347
                 FROM TMTask
348
                 WHERE
349
                   project = TASK.uuid AND
350
                   {self.IS_NOT_TRASHED} AND
351
                   {self.IS_OPEN}
352
                ) = 0
353
            """
354
        return self.get_rows(query)
355
356
    def get_cleanup(self):
357
        """Tasks and projects that need work."""
358
        result = []
359
        result.extend(self.get_lint())
360
        result.extend(self.get_empty_projects())
361
        result.extend(self.get_tag(self.tag_cleanup))
362
        return result
363
364
    @staticmethod
365
    def get_not_implemented():
366
        """Not implemented warning."""
367
        return [["0", "not implemented", "no context", "0", "0", "0", "0",
368
                 "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0"]]
369
370
    def get_rows(self, sql):
371
        """Query Things database."""
372
373
        sql = """
374
            SELECT DISTINCT
375
                TASK.uuid,
376
                TASK.title,
377
                CASE
378
                    WHEN AREA.title IS NOT NULL THEN AREA.title
379
                    WHEN PROJECT.title IS NOT NULL THEN PROJECT.title
380
                    WHEN HEADING.title IS NOT NULL THEN HEADING.title
381
                END,
382
                CASE
383
                    WHEN AREA.uuid IS NOT NULL THEN AREA.uuid
384
                    WHEN PROJECT.uuid IS NOT NULL THEN PROJECT.uuid
385
                END,
386
                CASE
387
                    WHEN TASK.recurrenceRule IS NULL
388
                    THEN date(TASK.dueDate,"unixepoch")
389
                ELSE NULL
390
                END,
391
                date(TASK.creationDate,"unixepoch"),
392
                date(TASK.userModificationDate,"unixepoch"),
393
                date(TASK.startDate,"unixepoch"),
394
                date(TASK.stopDate,"unixepoch")
395
            FROM
396
                TMTask AS TASK
397
            LEFT OUTER JOIN
398
                TMTask PROJECT ON TASK.project = PROJECT.uuid
399
            LEFT OUTER JOIN
400
                TMArea AREA ON TASK.area = AREA.uuid
401
            LEFT OUTER JOIN
402
                TMTask HEADING ON TASK.actionGroup = HEADING.uuid
403
            LEFT OUTER JOIN
404
                TMTask HEADPROJ ON HEADING.project = HEADPROJ.uuid
405
            LEFT OUTER JOIN
406
                TMTaskTag TAGS ON TASK.uuid = TAGS.tasks
407
            LEFT OUTER JOIN
408
                TMTag TAG ON TAGS.tags = TAG.uuid
409
            WHERE
410
                """ + sql
411
412
        if self.debug is True:
413
            print(sql)
414
415
        try:
416
            cursor = sqlite3.connect(self.database).cursor()
417
            cursor.execute(sql)
418
            tasks = cursor.fetchall()
419
            tasks = self.anonymize_tasks(tasks)
420
            if self.debug:
421
                for task in tasks:
422
                    print(task)
423
            return tasks
424
        except sqlite3.OperationalError as error:
425
            print(f"Could not query the database at: {self.database}.")
426
            print(f"Details: {error}.")
427
            sys.exit(2)
428
429
    def convert_task_to_model(self, task):
430
        """Convert task to model."""
431
        model = {'uuid': task[self.I_UUID],
432
                 'title': task[self.I_TITLE],
433
                 'context': task[self.I_CONTEXT],
434
                 'context_uuid': task[self.I_CONTEXT_UUID],
435
                 'due': task[self.I_DUE],
436
                 'created': task[self.I_CREATE],
437
                 'modified': task[self.I_MOD],
438
                 'started': task[self.I_START],
439
                 'stopped': task[self.I_STOP]
440
                 }
441
        return model
442
443
    def convert_tasks_to_model(self, tasks):
444
        """Convert tasks to model."""
445
        model = []
446
        for task in tasks:
447
            model.append(self.convert_task_to_model(task))
448
        return model
449
450
    functions = {
451
        "inbox": get_inbox,
452
        "today": get_today,
453
        "next": get_anytime,
454
        "backlog": get_someday,
455
        "upcoming": get_upcoming,
456
        "waiting": get_waiting,
457
        "mit": get_mit,
458
        "completed": get_completed,
459
        "cancelled": get_cancelled,
460
        "trashed": get_trashed,
461
        "all": get_all,
462
        "due": get_due,
463
        "lint": get_lint,
464
        "empty": get_empty_projects,
465
        "cleanup": get_cleanup
466
    }
467