Passed
Push — master ( 6f7451...c395bc )
by Alexander
05:14
created

things3.things3.Things3.get_task()   A

Complexity

Conditions 3

Size

Total Lines 27
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 7
nop 3
dl 0
loc 27
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.6.3"
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, path
21
import getpass
22
import configparser
23
from pathlib import Path
24
25
26
# pylint: disable=R0904,R0902
27
class Things3():
28
    """Simple read-only API for Things 3."""
29
30
    # Database info
31
    FILE_CONFIG = str(Path.home()) + '/.kanbanviewrc'
32
    FILE_DB = '/Library/Group Containers/'\
33
              'JLMPQHK86H.com.culturedcode.ThingsMac/'\
34
              'Things Database.thingsdatabase/main.sqlite'
35
    TABLE_TASK = "TMTask"
36
    TABLE_AREA = "TMArea"
37
    TABLE_TAG = "TMTag"
38
    TABLE_TASKTAG = "TMTaskTag"
39
    DATE_CREATE = "creationDate"
40
    DATE_MOD = "userModificationDate"
41
    DATE_DUE = "dueDate"
42
    DATE_START = "startDate"
43
    DATE_STOP = "stopDate"
44
    IS_INBOX = "start = 0"
45
    IS_ANYTIME = "start = 1"
46
    IS_SOMEDAY = "start = 2"
47
    IS_SCHEDULED = f"{DATE_START} IS NOT NULL"
48
    IS_NOT_SCHEDULED = f"{DATE_START} IS NULL"
49
    IS_DUE = f"{DATE_DUE} IS NOT NULL"
50
    IS_RECURRING = "recurrenceRule IS NOT NULL"
51
    IS_NOT_RECURRING = "recurrenceRule IS NULL"
52
    IS_TASK = "type = 0"
53
    IS_PROJECT = "type = 1"
54
    IS_HEADING = "type = 2"
55
    IS_TRASHED = "trashed = 1"
56
    IS_NOT_TRASHED = "trashed = 0"
57
    IS_OPEN = "status = 0"
58
    IS_CANCELLED = "status = 2"
59
    IS_DONE = "status = 3"
60
    RECURRING_IS_NOT_PAUSED = "instanceCreationPaused = 0"
61
    RECURRING_HAS_NEXT_STARTDATE = "nextInstanceStartDate IS NOT NULL"
62
    MODE_TASK = "type = 0"
63
    MODE_PROJECT = "type = 1"
64
65
    # Variables
66
    debug = False
67
    user = getpass.getuser()
68
    database = f"/Users/{user}/{FILE_DB}"
69
    filter = ""
70
    tag_waiting = "Waiting"
71
    tag_mit = "MIT"
72
    tag_cleanup = "Cleanup"
73
    tag_a = "A"
74
    tag_b = "B"
75
    tag_c = "C"
76
    tag_d = "D"
77
    stat_days = 365
78
    anonymize = False
79
    config = configparser.ConfigParser()
80
    config.read(FILE_CONFIG)
81
82
    # pylint: disable=R0913
83
    def __init__(self,
84
                 database=None,
85
                 tag_waiting=None,
86
                 tag_mit=None,
87
                 tag_cleanup=None,
88
                 tag_a=None,
89
                 tag_b=None,
90
                 tag_c=None,
91
                 tag_d=None,
92
                 stat_days=None,
93
                 anonymize=None):
94
95
        cfg = self.get_from_config(tag_waiting, 'TAG_WAITING')
96
        self.tag_waiting = cfg if cfg else self.tag_waiting
97
        self.set_config('TAG_WAITING', self.tag_waiting)
98
99
        cfg = self.get_from_config(anonymize, 'ANONYMIZE')
100
        self.anonymize = (cfg == 'True') if (cfg == 'True') else self.anonymize
101
        self.set_config('ANONYMIZE', self.anonymize)
102
103
        cfg = self.get_from_config(tag_mit, 'TAG_MIT')
104
        self.tag_mit = cfg if cfg else self.tag_mit
105
        self.set_config('TAG_MIT', self.tag_mit)
106
107
        cfg = self.get_from_config(tag_cleanup, 'TAG_CLEANUP')
108
        self.tag_cleanup = cfg if cfg else self.tag_cleanup
109
        self.set_config('TAG_CLEANUP', self.tag_cleanup)
110
111
        cfg = self.get_from_config(tag_a, 'TAG_A')
112
        self.tag_a = cfg if cfg else self.tag_a
113
        self.set_config('TAG_A', self.tag_a)
114
115
        cfg = self.get_from_config(tag_b, 'TAG_B')
116
        self.tag_b = cfg if cfg else self.tag_b
117
        self.set_config('TAG_B', self.tag_b)
118
119
        cfg = self.get_from_config(tag_c, 'TAG_C')
120
        self.tag_c = cfg if cfg else self.tag_c
121
        self.set_config('TAG_C', self.tag_c)
122
123
        cfg = self.get_from_config(tag_d, 'TAG_D')
124
        self.tag_d = cfg if cfg else self.tag_d
125
        self.set_config('TAG_D', self.tag_d)
126
127
        cfg = self.get_from_config(stat_days, 'STAT_DAYS')
128
        self.stat_days = cfg if cfg else self.stat_days
129
        self.set_config('STAT_DAYS', self.stat_days)
130
131
        cfg = self.get_from_config(database, 'THINGSDB')
132
        self.database = cfg if cfg else self.database
133
        # Automated migration to new database location in Things 3.12.6/3.13.1
134
        # --------------------------------
135
        try:
136
            with open(self.database) as f_d:
137
                if "Your database file has been moved there" in f_d.readline():
138
                    self.database = f"/Users/{self.user}/{self.FILE_DB}"
139
        except (UnicodeDecodeError, FileNotFoundError, PermissionError):
140
            pass  # binary file (old database) or doesn't exist
141
        # --------------------------------
142
        self.set_config('THINGSDB', self.database)
143
144
    def set_config(self, key, value, domain='DATABASE'):
145
        """Write variable to config."""
146
        if domain not in self.config:
147
            self.config.add_section(domain)
148
        if value is not None and key is not None:
149
            self.config.set(domain, str(key), str(value))
150
            with open(self.FILE_CONFIG, "w+") as configfile:
151
                self.config.write(configfile)
152
153
    def get_config(self, key, domain='DATABASE'):
154
        """Get variable from config."""
155
        result = None
156
        if domain in self.config and key in self.config[domain]:
157
            result = path.expanduser(self.config[domain][key])
158
        return result
159
160
    def get_from_config(self, variable, key, domain='DATABASE'):
161
        """Set variable. Priority: input, environment, config"""
162
        result = None
163
        if variable is not None:
164
            result = variable
165
        elif environ.get(key):
166
            result = environ.get(key)
167
        elif domain in self.config and key in self.config[domain]:
168
            result = path.expanduser(self.config[domain][key])
169
        return result
170
171
    @staticmethod
172
    def anonymize_string(string):
173
        """Scramble text."""
174
        if string is None:
175
            return None
176
        string = list(string)
177
        shuffle(string)
178
        string = ''.join(string)
179
        return string
180
181
    @staticmethod
182
    def dict_factory(cursor, row):
183
        """Convert SQL result into a dictionary"""
184
        dictionary = {}
185
        for idx, col in enumerate(cursor.description):
186
            dictionary[col[0]] = row[idx]
187
        return dictionary
188
189
    def anonymize_tasks(self, tasks):
190
        """Scramble output for screenshots."""
191
        if self.anonymize:
192
            for task in tasks:
193
                task['title'] = self.anonymize_string(task['title'])
194
                task['context'] = self.anonymize_string(
195
                    task['context']) if 'context' in task else ''
196
        return tasks
197
198
    def get_inbox(self):
199
        """Get all tasks from the inbox."""
200
        query = f"""
201
                TASK.{self.IS_NOT_TRASHED} AND
202
                TASK.{self.IS_TASK} AND
203
                TASK.{self.IS_OPEN} AND
204
                TASK.{self.IS_INBOX}
205
                ORDER BY TASK.duedate DESC , TASK.todayIndex
206
                """
207
        return self.get_rows(query)
208
209
    def get_today(self):
210
        """Get all tasks from the todays list."""
211
        query = f"""
212
                TASK.{self.IS_NOT_TRASHED} AND
213
                TASK.{self.IS_TASK} AND
214
                TASK.{self.IS_OPEN} AND
215
                (TASK.{self.IS_ANYTIME} OR (
216
                     TASK.{self.IS_SOMEDAY} AND
217
                     TASK.{self.DATE_START} <= strftime('%s', 'now')
218
                     )
219
                ) AND
220
                TASK.{self.IS_SCHEDULED} AND (
221
                    (
222
                        PROJECT.title IS NULL OR (
223
                            PROJECT.{self.IS_NOT_TRASHED}
224
                        )
225
                    ) AND (
226
                        HEADPROJ.title IS NULL OR (
227
                            HEADPROJ.{self.IS_NOT_TRASHED}
228
                        )
229
                    )
230
                )
231
                ORDER BY TASK.duedate DESC , TASK.todayIndex
232
                """
233
        return self.get_rows(query)
234
235
    def get_task(self, area=None, project=None):
236
        """Get tasks."""
237
        afilter = f'AND TASK.area = "{area}"' \
238
            if area is not None else ''
239
        pfilter = f'AND TASK.project = "{project}"' \
240
            if project is not None else ''
241
        query = f"""
242
                TASK.{self.IS_NOT_TRASHED} AND
243
                TASK.{self.IS_TASK} AND
244
                TASK.{self.IS_OPEN} AND
245
                TASK.{self.IS_ANYTIME} AND
246
                TASK.{self.IS_NOT_RECURRING} AND (
247
                    (
248
                        PROJECT.title IS NULL OR (
249
                            PROJECT.{self.IS_NOT_TRASHED}
250
                        )
251
                    ) AND (
252
                        HEADPROJ.title IS NULL OR (
253
                            HEADPROJ.{self.IS_NOT_TRASHED}
254
                        )
255
                    )
256
                )
257
                {afilter}
258
                {pfilter}
259
                ORDER BY TASK.duedate DESC, TASK.{self.DATE_CREATE} DESC
260
                """
261
        return self.get_rows(query)
262
263
    def get_someday(self):
264
        """Get someday tasks."""
265
        query = f"""
266
                TASK.{self.IS_NOT_TRASHED} AND
267
                TASK.{self.IS_TASK} AND
268
                TASK.{self.IS_OPEN} AND
269
                TASK.{self.IS_SOMEDAY} AND
270
                TASK.{self.IS_NOT_SCHEDULED} AND
271
                TASK.{self.IS_NOT_RECURRING} AND (
272
                    (
273
                        PROJECT.title IS NULL OR (
274
                            PROJECT.{self.IS_NOT_TRASHED}
275
                        )
276
                    ) AND (
277
                        HEADPROJ.title IS NULL OR (
278
                            HEADPROJ.{self.IS_NOT_TRASHED}
279
                        )
280
                    )
281
                )
282
                ORDER BY TASK.duedate DESC, TASK.{self.DATE_CREATE} DESC
283
                """
284
        return self.get_rows(query)
285
286
    def get_upcoming(self):
287
        """Get upcoming tasks."""
288
        query = f"""
289
                TASK.{self.IS_NOT_TRASHED} AND
290
                TASK.{self.IS_TASK} AND
291
                TASK.{self.IS_OPEN} AND
292
                TASK.{self.IS_SOMEDAY} AND
293
                TASK.{self.IS_SCHEDULED} AND
294
                TASK.{self.IS_NOT_RECURRING} AND (
295
                    (
296
                        PROJECT.title IS NULL OR (
297
                            PROJECT.{self.IS_NOT_TRASHED}
298
                        )
299
                    ) AND (
300
                        HEADPROJ.title IS NULL OR (
301
                            HEADPROJ.{self.IS_NOT_TRASHED}
302
                        )
303
                    )
304
                )
305
                ORDER BY TASK.startdate, TASK.todayIndex
306
                """
307
        return self.get_rows(query)
308
309
    def get_waiting(self):
310
        """Get waiting tasks."""
311
        return self.get_tag(self.tag_waiting)
312
313
    def get_mit(self):
314
        """Get most important tasks."""
315
        return self.get_tag(self.tag_mit)
316
317
    def get_tag(self, tag):
318
        """Get task with specific tag"""
319
        query = f"""
320
                TASK.{self.IS_NOT_TRASHED} AND
321
                TASK.{self.IS_TASK} AND
322
                TASK.{self.IS_OPEN} AND
323
                TASK.{self.IS_NOT_RECURRING} AND
324
                TAGS.tags=(SELECT uuid FROM {self.TABLE_TAG}
325
                             WHERE title='{tag}'
326
                          )
327
                AND (
328
                    (
329
                        PROJECT.title IS NULL OR (
330
                            PROJECT.{self.IS_NOT_TRASHED}
331
                        )
332
                    ) AND (
333
                        HEADPROJ.title IS NULL OR (
334
                            HEADPROJ.{self.IS_NOT_TRASHED}
335
                        )
336
                    )
337
                )
338
                ORDER BY TASK.duedate DESC , TASK.todayIndex
339
                """
340
        return self.get_rows(query)
341
342
    def get_tag_today(self, tag):
343
        """Get today tasks with specific tag"""
344
        query = f"""
345
                TASK.{self.IS_NOT_TRASHED} AND
346
                TASK.{self.IS_TASK} AND
347
                TASK.{self.IS_OPEN} AND
348
                (TASK.{self.IS_ANYTIME} OR (
349
                     TASK.{self.IS_SOMEDAY} AND
350
                     TASK.{self.DATE_START} <= strftime('%s', 'now')
351
                     )
352
                ) AND
353
                TAGS.tags=(SELECT uuid FROM {self.TABLE_TAG}
354
                             WHERE title='{tag}') AND
355
                TASK.{self.IS_SCHEDULED} AND (
356
                    (
357
                        PROJECT.title IS NULL OR (
358
                            PROJECT.{self.IS_NOT_TRASHED}
359
                        )
360
                    ) AND (
361
                        HEADPROJ.title IS NULL OR (
362
                            HEADPROJ.{self.IS_NOT_TRASHED}
363
                        )
364
                    )
365
                )
366
                ORDER BY TASK.duedate DESC , TASK.todayIndex
367
            """
368
        return self.get_rows(query)
369
370
    def get_anytime(self):
371
        """Get anytime tasks."""
372
        query = f"""
373
                TASK.{self.IS_NOT_TRASHED} AND
374
                TASK.{self.IS_TASK} AND
375
                TASK.{self.IS_OPEN} AND
376
                TASK.{self.IS_ANYTIME} AND
377
                TASK.{self.IS_NOT_SCHEDULED} AND (
378
                    (
379
                        PROJECT.title IS NULL OR (
380
                            PROJECT.{self.IS_ANYTIME} AND
381
                            PROJECT.{self.IS_NOT_SCHEDULED} AND
382
                            PROJECT.{self.IS_NOT_TRASHED}
383
                        )
384
                    ) AND (
385
                        HEADPROJ.title IS NULL OR (
386
                            HEADPROJ.{self.IS_ANYTIME} AND
387
                            HEADPROJ.{self.IS_NOT_SCHEDULED} AND
388
                            HEADPROJ.{self.IS_NOT_TRASHED}
389
                        )
390
                    )
391
                )
392
                ORDER BY TASK.duedate DESC , TASK.todayIndex
393
                """
394
        if self.filter:
395
            # ugly hack for Kanban task view on project
396
            query = f"""
397
                TASK.{self.IS_NOT_TRASHED} AND
398
                TASK.{self.IS_TASK} AND
399
                TASK.{self.IS_OPEN} AND
400
                TASK.{self.IS_ANYTIME} AND
401
                TASK.{self.IS_NOT_SCHEDULED} AND (
402
                    (
403
                        PROJECT.title IS NULL OR (
404
                            PROJECT.{self.IS_NOT_TRASHED}
405
                        )
406
                    ) AND (
407
                        HEADPROJ.title IS NULL OR (
408
                            HEADPROJ.{self.IS_NOT_TRASHED}
409
                        )
410
                    )
411
                )
412
                ORDER BY TASK.duedate DESC , TASK.todayIndex
413
                """
414
        return self.get_rows(query)
415
416
    def get_completed(self):
417
        """Get completed tasks."""
418
        query = f"""
419
                TASK.{self.IS_NOT_TRASHED} AND
420
                TASK.{self.IS_TASK} AND
421
                TASK.{self.IS_DONE}
422
                ORDER BY TASK.{self.DATE_STOP}
423
                """
424
        return self.get_rows(query)
425
426
    def get_cancelled(self):
427
        """Get cancelled tasks."""
428
        query = f"""
429
                TASK.{self.IS_NOT_TRASHED} AND
430
                TASK.{self.IS_TASK} AND
431
                TASK.{self.IS_CANCELLED}
432
                ORDER BY TASK.{self.DATE_STOP}
433
                """
434
        return self.get_rows(query)
435
436
    def get_trashed(self):
437
        """Get trashed tasks."""
438
        query = f"""
439
                TASK.{self.IS_TRASHED} AND
440
                TASK.{self.IS_TASK}
441
                ORDER BY TASK.{self.DATE_STOP}
442
                """
443
        return self.get_rows(query)
444
445
    def get_projects(self, area=None):
446
        """Get projects."""
447
        afilter = f'AND TASK.area = "{area}"' if area is not None else ''
448
        query = f"""
449
                SELECT
450
                    TASK.uuid,
451
                    TASK.title,
452
                    NULL as context,
453
                    (SELECT COUNT(uuid)
454
                     FROM TMTask AS PROJECT_TASK
455
                     WHERE
456
                       PROJECT_TASK.project = TASK.uuid AND
457
                       PROJECT_TASK.{self.IS_NOT_TRASHED} AND
458
                       PROJECT_TASK.{self.IS_OPEN}
459
                    ) AS size
460
                FROM
461
                    {self.TABLE_TASK} AS TASK
462
                WHERE
463
                    TASK.{self.IS_NOT_TRASHED} AND
464
                    TASK.{self.IS_PROJECT} AND
465
                    TASK.{self.IS_OPEN}
466
                    {afilter}
467
                ORDER BY TASK.title COLLATE NOCASE
468
                """
469
        return self.execute_query(query)
470
471
    def get_areas(self):
472
        """Get areas."""
473
        query = f"""
474
                SELECT
475
                    AREA.uuid AS uuid,
476
                    AREA.title AS title,
477
                    (SELECT COUNT(uuid)
478
                        FROM TMTask AS PROJECT
479
                        WHERE
480
                        PROJECT.area = AREA.uuid AND
481
                        PROJECT.{self.IS_NOT_TRASHED} AND
482
                        PROJECT.{self.IS_OPEN}
483
                    ) AS size
484
                FROM
485
                    {self.TABLE_AREA} AS AREA
486
                ORDER BY AREA.title COLLATE NOCASE
487
                """
488
        return self.execute_query(query)
489
490
    def get_all(self):
491
        """Get all tasks."""
492
        query = f"""
493
                TASK.{self.IS_NOT_TRASHED} AND
494
                TASK.{self.IS_TASK} AND (
495
                    (
496
                        PROJECT.title IS NULL OR (
497
                            PROJECT.{self.IS_NOT_TRASHED}
498
                        )
499
                    ) AND (
500
                        HEADPROJ.title IS NULL OR (
501
                            HEADPROJ.{self.IS_NOT_TRASHED}
502
                        )
503
                    )
504
                )
505
                """
506
        return self.get_rows(query)
507
508
    def get_due(self):
509
        """Get due tasks."""
510
        query = f"""
511
                TASK.{self.IS_NOT_TRASHED} AND
512
                TASK.{self.IS_TASK} AND
513
                TASK.{self.IS_OPEN} AND
514
                TASK.{self.IS_DUE} AND (
515
                    (
516
                        PROJECT.title IS NULL OR (
517
                            PROJECT.{self.IS_NOT_TRASHED}
518
                        )
519
                    ) AND (
520
                        HEADPROJ.title IS NULL OR (
521
                            HEADPROJ.{self.IS_NOT_TRASHED}
522
                        )
523
                    )
524
                )
525
                ORDER BY TASK.{self.DATE_DUE}
526
                """
527
        return self.get_rows(query)
528
529
    def get_lint(self):
530
        """Get tasks that float around"""
531
        query = f"""
532
            TASK.{self.IS_NOT_TRASHED} AND
533
            TASK.{self.IS_OPEN} AND
534
            TASK.{self.IS_TASK} AND
535
            (TASK.{self.IS_SOMEDAY} OR TASK.{self.IS_ANYTIME}) AND
536
            TASK.project IS NULL AND
537
            TASK.area IS NULL AND
538
            TASK.actionGroup IS NULL
539
            """
540
        return self.get_rows(query)
541
542
    def get_empty_projects(self):
543
        """Get projects that are empty"""
544
        query = f"""
545
            TASK.{self.IS_NOT_TRASHED} AND
546
            TASK.{self.IS_OPEN} AND
547
            TASK.{self.IS_PROJECT} AND
548
            TASK.{self.IS_ANYTIME}
549
            GROUP BY TASK.uuid
550
            HAVING
551
                (SELECT COUNT(uuid)
552
                 FROM TMTask AS PROJECT_TASK
553
                 WHERE
554
                   PROJECT_TASK.project = TASK.uuid AND
555
                   PROJECT_TASK.{self.IS_NOT_TRASHED} AND
556
                   PROJECT_TASK.{self.IS_OPEN} AND
557
                   (PROJECT_TASK.{self.IS_ANYTIME} OR
558
                    PROJECT_TASK.{self.IS_SCHEDULED} OR
559
                      (PROJECT_TASK.{self.IS_RECURRING} AND
560
                       PROJECT_TASK.{self.RECURRING_IS_NOT_PAUSED} AND
561
                       PROJECT_TASK.{self.RECURRING_HAS_NEXT_STARTDATE}
562
                      )
563
                   )
564
                ) = 0
565
            """
566
        return self.get_rows(query)
567
568
    def get_largest_projects(self):
569
        """Get projects that are empty"""
570
        query = f"""
571
            SELECT
572
                TASK.uuid,
573
                TASK.title AS title,
574
                {self.DATE_CREATE} AS created,
575
                {self.DATE_MOD} AS modified,
576
                (SELECT COUNT(uuid)
577
                 FROM TMTask AS PROJECT_TASK
578
                 WHERE
579
                   PROJECT_TASK.project = TASK.uuid AND
580
                   PROJECT_TASK.{self.IS_NOT_TRASHED} AND
581
                   PROJECT_TASK.{self.IS_OPEN}
582
                ) AS tasks
583
            FROM
584
                {self.TABLE_TASK} AS TASK
585
            WHERE
586
               TASK.{self.IS_NOT_TRASHED} AND
587
               TASK.{self.IS_OPEN} AND
588
               TASK.{self.IS_PROJECT}
589
            GROUP BY TASK.uuid
590
            ORDER BY tasks COLLATE NOCASE DESC
591
            """
592
        return self.execute_query(query)
593
594
    def get_daystats(self):
595
        """Get a history of task activities"""
596
        query = f"""
597
                WITH RECURSIVE timeseries(x) AS (
598
                    SELECT 0
599
                    UNION ALL
600
                    SELECT x+1 FROM timeseries
601
                    LIMIT {self.stat_days}
602
                )
603
                SELECT
604
                    date(julianday("now", "-{self.stat_days} days"),
605
                         "+" || x || " days") as date,
606
                    CREATED.TasksCreated as created,
607
                    CLOSED.TasksClosed as completed,
608
                    CANCELLED.TasksCancelled as cancelled,
609
                    TRASHED.TasksTrashed as trashed
610
                FROM timeseries
611
                LEFT JOIN
612
                    (SELECT COUNT(uuid) AS TasksCreated,
613
                        date({self.DATE_CREATE},"unixepoch") AS DAY
614
                        FROM {self.TABLE_TASK} AS TASK
615
                        WHERE DAY NOT NULL
616
                          AND TASK.{self.IS_TASK}
617
                        GROUP BY DAY)
618
                    AS CREATED ON CREATED.DAY = date
619
                LEFT JOIN
620
                    (SELECT COUNT(uuid) AS TasksCancelled,
621
                        date(stopDate,"unixepoch") AS DAY
622
                        FROM {self.TABLE_TASK} AS TASK
623
                        WHERE DAY NOT NULL
624
                          AND TASK.{self.IS_CANCELLED} AND TASK.{self.IS_TASK}
625
                        GROUP BY DAY)
626
                        AS CANCELLED ON CANCELLED.DAY = date
627
                LEFT JOIN
628
                    (SELECT COUNT(uuid) AS TasksTrashed,
629
                        date({self.DATE_MOD},"unixepoch") AS DAY
630
                        FROM {self.TABLE_TASK} AS TASK
631
                        WHERE DAY NOT NULL
632
                          AND TASK.{self.IS_TRASHED} AND TASK.{self.IS_TASK}
633
                        GROUP BY DAY)
634
                        AS TRASHED ON TRASHED.DAY = date
635
                LEFT JOIN
636
                    (SELECT COUNT(uuid) AS TasksClosed,
637
                        date(stopDate,"unixepoch") AS DAY
638
                        FROM {self.TABLE_TASK} AS TASK
639
                        WHERE DAY NOT NULL
640
                          AND TASK.{self.IS_DONE} AND TASK.{self.IS_TASK}
641
                        GROUP BY DAY)
642
                        AS CLOSED ON CLOSED.DAY = date
643
                """
644
        return self.execute_query(query)
645
646
    def get_minutes_today(self):
647
        """Count the planned minutes for today."""
648
        query = f"""
649
                SELECT
650
                    SUM(TAG.title) AS minutes
651
                FROM
652
                    {self.TABLE_TASK} AS TASK
653
                LEFT OUTER JOIN
654
                TMTask PROJECT ON TASK.project = PROJECT.uuid
655
                LEFT OUTER JOIN
656
                    TMArea AREA ON TASK.area = AREA.uuid
657
                LEFT OUTER JOIN
658
                    TMTask HEADING ON TASK.actionGroup = HEADING.uuid
659
                LEFT OUTER JOIN
660
                    TMTask HEADPROJ ON HEADING.project = HEADPROJ.uuid
661
                LEFT OUTER JOIN
662
                    TMTaskTag TAGS ON TASK.uuid = TAGS.tasks
663
                LEFT OUTER JOIN
664
                    TMTag TAG ON TAGS.tags = TAG.uuid
665
                WHERE
666
                    printf("%d", TAG.title) = TAG.title AND
667
                    TASK.{self.IS_NOT_TRASHED} AND
668
                    TASK.{self.IS_TASK} AND
669
                    TASK.{self.IS_OPEN} AND
670
                    TASK.{self.IS_ANYTIME} AND
671
                    TASK.{self.IS_SCHEDULED} AND (
672
                        (
673
                            PROJECT.title IS NULL OR (
674
                                PROJECT.{self.IS_NOT_TRASHED}
675
                            )
676
                        ) AND (
677
                            HEADPROJ.title IS NULL OR (
678
                                HEADPROJ.{self.IS_NOT_TRASHED}
679
                            )
680
                        )
681
                    )
682
                """
683
        return self.execute_query(query)
684
685
    def get_cleanup(self):
686
        """Tasks and projects that need work."""
687
        result = []
688
        result.extend(self.get_lint())
689
        result.extend(self.get_empty_projects())
690
        result.extend(self.get_tag(self.tag_cleanup))
691
        result = [i for n, i in enumerate(result) if i not in result[n + 1:]]
692
        return result
693
694
    @staticmethod
695
    def get_not_implemented():
696
        """Not implemented warning."""
697
        return [{"title": "not implemented"}]
698
699
    def get_rows(self, sql):
700
        """Query Things database."""
701
702
        sql = f"""
703
            SELECT DISTINCT
704
                TASK.uuid,
705
                TASK.title,
706
                CASE
707
                    WHEN AREA.title IS NOT NULL THEN AREA.title
708
                    WHEN PROJECT.title IS NOT NULL THEN PROJECT.title
709
                    WHEN HEADING.title IS NOT NULL THEN HEADING.title
710
                END AS context,
711
                CASE
712
                    WHEN AREA.uuid IS NOT NULL THEN AREA.uuid
713
                    WHEN PROJECT.uuid IS NOT NULL THEN PROJECT.uuid
714
                END AS context_uuid,
715
                CASE
716
                    WHEN TASK.recurrenceRule IS NULL
717
                    THEN strftime('%d.%m.', TASK.dueDate,"unixepoch") ||
718
                         substr(strftime('%Y', TASK.dueDate,"unixepoch"),3, 2)
719
                ELSE NULL
720
                END AS due,
721
                date(TASK.{self.DATE_CREATE},"unixepoch") as created,
722
                date(TASK.{self.DATE_MOD},"unixepoch") as modified,
723
                strftime('%d.%m.', TASK.startDate,"unixepoch") ||
724
                  substr(strftime('%Y', TASK.startDate,"unixepoch"),3, 2)
725
                  as started,
726
                date(TASK.stopDate,"unixepoch") as stopped,
727
                (SELECT COUNT(uuid)
728
                 FROM TMTask AS PROJECT_TASK
729
                 WHERE
730
                   PROJECT_TASK.project = TASK.uuid AND
731
                   PROJECT_TASK.{self.IS_NOT_TRASHED} AND
732
                   PROJECT_TASK.{self.IS_OPEN}
733
                ) AS size,
734
                CASE
735
                    WHEN TASK.{self.IS_TASK} THEN 'task'
736
                    WHEN TASK.{self.IS_PROJECT} THEN 'project'
737
                    WHEN TASK.{self.IS_HEADING} THEN 'heading'
738
                END AS type,
739
                TASK.notes
740
            FROM
741
                {self.TABLE_TASK} AS TASK
742
            LEFT OUTER JOIN
743
                {self.TABLE_TASK} PROJECT ON TASK.project = PROJECT.uuid
744
            LEFT OUTER JOIN
745
                {self.TABLE_AREA} AREA ON TASK.area = AREA.uuid
746
            LEFT OUTER JOIN
747
                {self.TABLE_TASK} HEADING ON TASK.actionGroup = HEADING.uuid
748
            LEFT OUTER JOIN
749
                {self.TABLE_TASK} HEADPROJ ON HEADING.project = HEADPROJ.uuid
750
            LEFT OUTER JOIN
751
                {self.TABLE_TASKTAG} TAGS ON TASK.uuid = TAGS.tasks
752
            LEFT OUTER JOIN
753
                {self.TABLE_TAG} TAG ON TAGS.tags = TAG.uuid
754
            WHERE
755
                {self.filter}
756
                {sql}
757
                """
758
759
        return self.execute_query(sql)
760
761
    def execute_query(self, sql):
762
        """Run the actual query"""
763
        if self.debug is True:
764
            print(self.database)
765
            print(sql)
766
        try:
767
            connection = sqlite3.connect(
768
                'file:' + self.database + '?mode=ro', uri=True)
769
            connection.row_factory = Things3.dict_factory
770
            cursor = connection.cursor()
771
            cursor.execute(sql)
772
            tasks = cursor.fetchall()
773
            tasks = self.anonymize_tasks(tasks)
774
            if self.debug:
775
                for task in tasks:
776
                    print(task)
777
            return tasks
778
        except sqlite3.OperationalError as error:
779
            print(f"Could not query the database at: {self.database}.")
780
            print(f"Details: {error}.")
781
            sys.exit(2)
782
783
    # pylint: disable=C0103
784
    def mode_project(self):
785
        """Hack to switch to project view"""
786
        self.IS_TASK = self.MODE_PROJECT
787
788
    # pylint: disable=C0103
789
    def mode_task(self):
790
        """Hack to switch to project view"""
791
        self.IS_TASK = self.MODE_TASK
792
793
    functions = {
794
        "inbox": get_inbox,
795
        "today": get_today,
796
        "next": get_anytime,
797
        "backlog": get_someday,
798
        "upcoming": get_upcoming,
799
        "waiting": get_waiting,
800
        "mit": get_mit,
801
        "completed": get_completed,
802
        "cancelled": get_cancelled,
803
        "trashed": get_trashed,
804
        "projects": get_projects,
805
        "areas": get_areas,
806
        "all": get_all,
807
        "due": get_due,
808
        "lint": get_lint,
809
        "empty": get_empty_projects,
810
        "cleanup": get_cleanup,
811
        "top-proj": get_largest_projects,
812
        "stats-day": get_daystats,
813
        "stats-min-today": get_minutes_today
814
    }
815