Passed
Push — master ( df79c1...e8a7be )
by Alexander
06:59 queued 12s
created

things3.things3.Things3.convert_new_things_lib()   A

Complexity

Conditions 3

Size

Total Lines 16
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

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