things-py 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
things/database.py ADDED
@@ -0,0 +1,702 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """Simple read-only API for Things."""
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__ = "alex@willner.ws"
15
+ __status__ = "Development"
16
+
17
+ import datetime
18
+ import os
19
+ import sqlite3
20
+ import sys
21
+
22
+
23
+ DEFAULT_DATABASE_FILEPATH = os.path.expanduser(
24
+ "~/Library/Group Containers/JLMPQHK86H.com.culturedcode.ThingsMac"
25
+ "/Things Database.thingsdatabase/main.sqlite"
26
+ )
27
+
28
+ START_TO_QUERY = {
29
+ "Inbox": "start = 0",
30
+ "Anytime": "start = 1",
31
+ "Someday": "start = 2",
32
+ }
33
+
34
+ STATUS_TO_QUERY = {
35
+ "incomplete": "status = 0",
36
+ "canceled": "status = 2",
37
+ "completed": "status = 3",
38
+ }
39
+
40
+ TYPE_TO_QUERY = {"to-do": "type = 0", "project": "type = 1", "heading": "type = 2"}
41
+
42
+ INDICES = ("index", "todayIndex")
43
+
44
+ COLUMNS_TO_OMIT_IF_NONE = ("area", "project", "heading", "trashed", "checklist", "tags")
45
+ COLUMNS_TO_TRANSFORM_TO_BOOL = ("trashed", "checklist", "tags")
46
+
47
+
48
+ # pylint: disable=R0904,R0902
49
+ class Database:
50
+ """
51
+ Access Things SQL database.
52
+
53
+ Parameters
54
+ ----------
55
+ filepath : str, optional
56
+ Any valid path of a SQLite database file generated by the Things app.
57
+ If no path is provided, then access the default database path.
58
+ """
59
+
60
+ # Database info
61
+ TABLE_TASK = "TMTask"
62
+ TABLE_AREA = "TMArea"
63
+ TABLE_TAG = "TMTag"
64
+ TABLE_TASKTAG = "TMTaskTag"
65
+ TABLE_AREATAG = "TMAreaTag"
66
+ TABLE_CHECKLIST_ITEM = "TMChecklistItem"
67
+ TABLE_META = "Meta"
68
+ DATE_CREATE = "creationDate"
69
+ DATE_MOD = "userModificationDate"
70
+ DATE_DUE = "dueDate"
71
+ DATE_START = "startDate"
72
+ DATE_STOP = "stopDate"
73
+ IS_INBOX = START_TO_QUERY["Inbox"]
74
+ IS_ANYTIME = START_TO_QUERY["Anytime"]
75
+ IS_SOMEDAY = START_TO_QUERY["Someday"]
76
+ IS_SCHEDULED = f"{DATE_START} IS NOT NULL"
77
+ IS_NOT_SCHEDULED = f"{DATE_START} IS NULL"
78
+ IS_DUE = f"{DATE_DUE} IS NOT NULL"
79
+ IS_RECURRING = "recurrenceRule IS NOT NULL"
80
+ IS_NOT_RECURRING = "recurrenceRule IS NULL"
81
+ IS_TODO = TYPE_TO_QUERY["to-do"]
82
+ IS_PROJECT = TYPE_TO_QUERY["project"]
83
+ IS_HEADING = TYPE_TO_QUERY["heading"]
84
+ IS_NOT_TRASHED = "trashed = 0"
85
+ IS_TRASHED = "trashed = 1"
86
+ IS_INCOMPLETE = STATUS_TO_QUERY["incomplete"]
87
+ IS_CANCELED = STATUS_TO_QUERY["canceled"]
88
+ IS_COMPLETED = STATUS_TO_QUERY["completed"]
89
+ RECURRING_IS_NOT_PAUSED = "instanceCreationPaused = 0"
90
+ RECURRING_HAS_NEXT_STARTDATE = "nextInstanceStartDate IS NOT NULL"
91
+
92
+ # Variables
93
+ debug = False
94
+ filter = ""
95
+
96
+ # pylint: disable=R0913
97
+ def __init__(self, filepath=None):
98
+ self.filepath = filepath or DEFAULT_DATABASE_FILEPATH
99
+
100
+ # Automated migration to new database location in Things 3.12.6/3.13.1
101
+ # --------------------------------
102
+ try:
103
+ with open(self.filepath) as file:
104
+ if "Your database file has been moved there" in file.readline():
105
+ self.filepath = DEFAULT_DATABASE_FILEPATH
106
+ except (UnicodeDecodeError, FileNotFoundError, PermissionError):
107
+ pass # binary file (old database) or doesn't exist
108
+ # --------------------------------
109
+
110
+ # Core methods
111
+
112
+ def get_tasks(
113
+ self,
114
+ uuid=None,
115
+ type=None,
116
+ status=None,
117
+ start=None,
118
+ area=None,
119
+ project=None,
120
+ heading=None,
121
+ tag=None,
122
+ start_date=None,
123
+ due_date=None,
124
+ index="index",
125
+ count_only=False,
126
+ querystr=None
127
+ ):
128
+ """Get tasks. See `api.tasks` for details on parameters."""
129
+
130
+ # Overwrites
131
+ start = start and start.title()
132
+
133
+ # Validation
134
+ validate("type", type, [None] + list(TYPE_TO_QUERY))
135
+ validate("status", status, [None] + list(STATUS_TO_QUERY))
136
+ validate("start", start, [None] + list(START_TO_QUERY))
137
+ validate("index", index, list(INDICES))
138
+
139
+ if tag is not None:
140
+ valid_tags = self.get_tags(titles_only=True)
141
+ validate("tag", tag, [None] + list(valid_tags))
142
+
143
+ if uuid:
144
+ if count_only is False and not self.get_tasks(uuid=uuid, count_only=True):
145
+ raise ValueError(f"No such task uuid found: {uuid!r}")
146
+
147
+ # Query
148
+ # TK: might consider executing SQL with parameters instead.
149
+ # See: https://docs.python.org/3/library/sqlite3.html#sqlite3.Cursor.execute
150
+
151
+ if uuid:
152
+ query = f"TRUE {make_filter('TASK.uuid', uuid)}"
153
+ else:
154
+ start_query = START_TO_QUERY.get(start)
155
+ status_query = STATUS_TO_QUERY.get(status)
156
+ type_query = TYPE_TO_QUERY.get(type)
157
+
158
+ query = f"""
159
+ TASK.{self.IS_NOT_TRASHED}
160
+ {f"AND TASK.{type_query}" if type_query else ""}
161
+ {f"AND TASK.{start_query}" if start_query else ""}
162
+ {f"AND TASK.{status_query}" if status_query else ""}
163
+ AND TASK.{self.IS_NOT_RECURRING}
164
+ AND (PROJECT.title IS NULL OR PROJECT.{self.IS_NOT_TRASHED})
165
+ AND (HEADPROJ.title IS NULL OR HEADPROJ.{self.IS_NOT_TRASHED})
166
+ {make_search(querystr)}
167
+ {make_filter('TASK.uuid', uuid)}
168
+ {make_filter("TASK.area", area)}
169
+ {make_filter("TASK.project", project)}
170
+ {make_filter("TASK.actionGroup", heading)}
171
+ {make_filter("TASK.startDate", start_date)}
172
+ {make_filter("TASK.dueDate", due_date)}
173
+ {make_filter("TAG.title", tag)}
174
+ ORDER BY TASK."{index}"
175
+ """
176
+
177
+ sql = self.make_tasks_sql(query)
178
+ if count_only:
179
+ return self.get_count(sql)
180
+ else:
181
+ return self.execute_query(sql)
182
+
183
+ def make_tasks_sql(self, query):
184
+ """Make SQL for tasks"""
185
+ return f"""
186
+ SELECT DISTINCT
187
+ TASK.uuid,
188
+ CASE
189
+ WHEN TASK.{self.IS_TODO} THEN 'to-do'
190
+ WHEN TASK.{self.IS_PROJECT} THEN 'project'
191
+ WHEN TASK.{self.IS_HEADING} THEN 'heading'
192
+ END AS type,
193
+ CASE
194
+ WHEN TASK.{self.IS_TRASHED} THEN 1
195
+ END AS trashed,
196
+ TASK.title,
197
+ CASE
198
+ WHEN TASK.{self.IS_INCOMPLETE} THEN 'incomplete'
199
+ WHEN TASK.{self.IS_COMPLETED} THEN 'completed'
200
+ WHEN TASK.{self.IS_CANCELED} THEN 'canceled'
201
+ END AS status,
202
+ CASE
203
+ WHEN AREA.uuid IS NOT NULL THEN AREA.uuid
204
+ END AS area,
205
+ CASE
206
+ WHEN PROJECT.uuid IS NOT NULL THEN PROJECT.uuid
207
+ END AS project,
208
+ CASE
209
+ WHEN HEADING.uuid IS NOT NULL THEN HEADING.uuid
210
+ END AS heading,
211
+ TASK.notes,
212
+ CASE
213
+ WHEN TAG.uuid IS NOT NULL THEN 1
214
+ END AS tags,
215
+ CASE
216
+ WHEN TASK.{self.IS_INBOX} THEN 'Inbox'
217
+ WHEN TASK.{self.IS_ANYTIME} THEN 'Anytime'
218
+ WHEN TASK.{self.IS_SOMEDAY} THEN 'Someday'
219
+ END AS start,
220
+ CASE
221
+ WHEN CHECKLIST_ITEM.uuid IS NOT NULL THEN 1
222
+ END AS checklist,
223
+ date(TASK.startDate, "unixepoch") AS start_date,
224
+ date(TASK.dueDate, "unixepoch") AS due_date,
225
+ date(TASK.stopDate, "unixepoch") AS stop_date,
226
+ datetime(TASK.{self.DATE_CREATE}, "unixepoch", "localtime") AS created,
227
+ datetime(TASK.{self.DATE_MOD}, "unixepoch", "localtime") AS modified
228
+ FROM
229
+ {self.TABLE_TASK} AS TASK
230
+ LEFT OUTER JOIN
231
+ {self.TABLE_TASK} PROJECT ON TASK.project = PROJECT.uuid
232
+ LEFT OUTER JOIN
233
+ {self.TABLE_AREA} AREA ON TASK.area = AREA.uuid
234
+ LEFT OUTER JOIN
235
+ {self.TABLE_TASK} HEADING ON TASK.actionGroup = HEADING.uuid
236
+ LEFT OUTER JOIN
237
+ {self.TABLE_TASK} HEADPROJ ON HEADING.project = HEADPROJ.uuid
238
+ LEFT OUTER JOIN
239
+ {self.TABLE_TASKTAG} TAGS ON TASK.uuid = TAGS.tasks
240
+ LEFT OUTER JOIN
241
+ {self.TABLE_TAG} TAG ON TAGS.tags = TAG.uuid
242
+ LEFT OUTER JOIN
243
+ {self.TABLE_CHECKLIST_ITEM} CHECKLIST_ITEM
244
+ ON CHECKLIST_ITEM.task = TASK.uuid
245
+ WHERE
246
+ {self.filter}
247
+ {query}
248
+ """
249
+
250
+ def get_rows(self, query):
251
+ return self.execute_query(self.make_tasks_sql(query))
252
+
253
+ def get_areas(self, uuid=None, tag=None, count_only=False):
254
+ """Get areas. See `api.areas` for details on parameters."""
255
+
256
+ # Validation
257
+ if tag is not None:
258
+ valid_tags = self.get_tags(titles_only=True)
259
+ validate("tag", tag, [None] + list(valid_tags))
260
+
261
+ if (
262
+ uuid
263
+ and count_only is False
264
+ and not self.get_areas(uuid=uuid, count_only=True)
265
+ ):
266
+ raise ValueError(f"No such area uuid found: {uuid!r}")
267
+
268
+ # Query
269
+ query = f"""
270
+ SELECT
271
+ AREA.uuid,
272
+ 'area' as type,
273
+ AREA.title,
274
+ CASE
275
+ WHEN AREA_TAG.areas IS NOT NULL THEN 1
276
+ END AS tags
277
+ FROM
278
+ {self.TABLE_AREA} AS AREA
279
+ LEFT OUTER JOIN
280
+ {self.TABLE_AREATAG} AREA_TAG ON AREA_TAG.areas = AREA.uuid
281
+ LEFT OUTER JOIN
282
+ {self.TABLE_TAG} TAG ON TAG.uuid = AREA_TAG.tags
283
+ WHERE
284
+ TRUE
285
+ {make_filter('TAG.title', tag)}
286
+ {make_filter('AREA.uuid', uuid)}
287
+ ORDER BY AREA."index"
288
+ """
289
+ if count_only:
290
+ return self.get_count(query)
291
+ else:
292
+ return self.execute_query(query)
293
+
294
+ def get_checklist_items(self, task_uuid=None):
295
+ """Get checklist items."""
296
+ query = f"""
297
+ SELECT
298
+ CHECKLIST_ITEM.title,
299
+ CASE
300
+ WHEN CHECKLIST_ITEM.{self.IS_INCOMPLETE} THEN 'incomplete'
301
+ WHEN CHECKLIST_ITEM.{self.IS_COMPLETED} THEN 'completed'
302
+ WHEN CHECKLIST_ITEM.{self.IS_CANCELED} THEN 'canceled'
303
+ END AS status,
304
+ date(CHECKLIST_ITEM.stopDate, "unixepoch") AS stop_date,
305
+ 'checklist item' as type,
306
+ CHECKLIST_ITEM.uuid,
307
+ datetime(
308
+ CHECKLIST_ITEM.{self.DATE_MOD}, "unixepoch", "localtime"
309
+ ) AS created,
310
+ datetime(
311
+ CHECKLIST_ITEM.{self.DATE_MOD}, "unixepoch", "localtime"
312
+ ) AS modified
313
+ FROM
314
+ {self.TABLE_CHECKLIST_ITEM} AS CHECKLIST_ITEM
315
+ WHERE
316
+ CHECKLIST_ITEM.task = ?
317
+ ORDER BY CHECKLIST_ITEM."index"
318
+ """
319
+ return self.execute_query(query, (task_uuid,))
320
+
321
+ def get_tags(self, title=None, area=None, task=None, titles_only=False):
322
+ """Get tags. See `api.tags` for details on parameters."""
323
+
324
+ # Validation
325
+ if title is not None:
326
+ valid_titles = self.get_tags(titles_only=True)
327
+ validate("title", title, [None] + list(valid_titles))
328
+
329
+ # Query
330
+ if task:
331
+ return self.get_tags_of_task(task)
332
+ if area:
333
+ return self.get_tags_of_area(area)
334
+
335
+ if titles_only:
336
+ query = f'SELECT title FROM {self.TABLE_TAG} ORDER BY "index"'
337
+ return self.execute_query(query, row_factory=list_factory)
338
+ else:
339
+ query = f"""
340
+ SELECT
341
+ uuid,
342
+ 'tag' AS type,
343
+ title,
344
+ shortcut
345
+ FROM
346
+ {self.TABLE_TAG}
347
+ WHERE
348
+ TRUE
349
+ {make_filter('title', title)}
350
+ ORDER BY "index"
351
+ """
352
+ return self.execute_query(query)
353
+
354
+ def get_tags_of_task(self, task_uuid):
355
+ """Get tag titles of task"""
356
+ query = f"""
357
+ SELECT
358
+ TAG.title
359
+ FROM
360
+ {self.TABLE_TASKTAG} AS TASK_TAG
361
+ LEFT OUTER JOIN
362
+ {self.TABLE_TAG} TAG ON TAG.uuid = TASK_TAG.tags
363
+ WHERE
364
+ TASK_TAG.tasks = ?
365
+ ORDER BY TAG."index"
366
+ """
367
+ return self.execute_query(
368
+ query, parameters=(task_uuid,), row_factory=list_factory
369
+ )
370
+
371
+ def get_tags_of_area(self, area_uuid):
372
+ """Get tag titles for area"""
373
+ query = f"""
374
+ SELECT
375
+ AREA.title
376
+ FROM
377
+ {self.TABLE_AREATAG} AS AREA_TAG
378
+ LEFT OUTER JOIN
379
+ {self.TABLE_TAG} AREA ON AREA.uuid = AREA_TAG.tags
380
+ WHERE
381
+ AREA_TAG.areas = ?
382
+ ORDER BY AREA."index"
383
+ """
384
+
385
+ return self.execute_query(
386
+ query, parameters=(area_uuid,), row_factory=list_factory
387
+ )
388
+
389
+ def get_version(self):
390
+ """Get Things Database version."""
391
+ import plistlib
392
+
393
+ query = f"SELECT value FROM {self.TABLE_META} WHERE key = 'databaseVersion'"
394
+ result = self.execute_query(query, row_factory=list_factory)
395
+ plist_bytes = result[0].encode()
396
+ return plistlib.loads(plist_bytes)
397
+
398
+ def get_count(self, sql):
399
+ sql = f"""SELECT COUNT(uuid) FROM ({sql})"""
400
+ return self.execute_query(sql, row_factory=list_factory)[0]
401
+
402
+ # noqa todo: add type hinting for resutl (List[Tuple[str, Any]]?)
403
+ def execute_query(self, sql, parameters=(), row_factory=None):
404
+ """Run the actual query"""
405
+ if self.debug is True:
406
+ print(self.filepath)
407
+ print(sql)
408
+ try:
409
+ uri = f"file:{self.filepath}?mode=ro" # "ro" means read-only
410
+ connection = sqlite3.connect(uri, uri=True)
411
+ connection.row_factory = row_factory or dict_factory
412
+ cursor = connection.cursor()
413
+ cursor.execute(sql, parameters)
414
+ tasks = cursor.fetchall()
415
+ if self.debug:
416
+ for task in tasks:
417
+ print(task)
418
+ return tasks
419
+ except sqlite3.OperationalError as error:
420
+ print(f"Could not query the database at: {self.filepath}.")
421
+ print(f"Details: {error}.")
422
+ sys.exit(2)
423
+
424
+ # -------- Utility methods --------
425
+
426
+ def last_modified(self):
427
+ mtime_seconds = os.path.getmtime(self.filepath)
428
+ return datetime.datetime.fromtimestamp(mtime_seconds)
429
+
430
+ def was_modified_today(self):
431
+ last_modified_date = self.last_modified().date()
432
+ todays_date = datetime.datetime.now().date()
433
+ return last_modified_date >= todays_date
434
+
435
+ # -------- Historical methods (TK: transform) --------
436
+
437
+ def get_trashed(self):
438
+ """Get trashed tasks."""
439
+ query = f"""
440
+ TASK.{self.IS_TRASHED} AND
441
+ TASK.{self.IS_TODO}
442
+ ORDER BY TASK.{self.DATE_STOP}
443
+ """
444
+ return self.get_rows(query)
445
+
446
+ def get_lint(self):
447
+ """Get tasks that float around"""
448
+ query = f"""
449
+ TASK.{self.IS_NOT_TRASHED} AND
450
+ TASK.{self.IS_INCOMPLETE} AND
451
+ TASK.{self.IS_TODO} AND
452
+ (TASK.{self.IS_SOMEDAY} OR TASK.{self.IS_ANYTIME}) AND
453
+ TASK.project IS NULL AND
454
+ TASK.area IS NULL AND
455
+ TASK.actionGroup IS NULL
456
+ """
457
+ return self.get_rows(query)
458
+
459
+ def get_empty_projects(self):
460
+ """Get projects that are empty"""
461
+ query = f"""
462
+ TASK.{self.IS_NOT_TRASHED} AND
463
+ TASK.{self.IS_INCOMPLETE} AND
464
+ TASK.{self.IS_PROJECT} AND
465
+ TASK.{self.IS_ANYTIME}
466
+ GROUP BY TASK.uuid
467
+ HAVING
468
+ (SELECT COUNT(uuid)
469
+ FROM TMTask AS PROJECT_TASK
470
+ WHERE
471
+ PROJECT_TASK.project = TASK.uuid AND
472
+ PROJECT_TASK.{self.IS_NOT_TRASHED} AND
473
+ PROJECT_TASK.{self.IS_INCOMPLETE} AND
474
+ (PROJECT_TASK.{self.IS_ANYTIME} OR
475
+ PROJECT_TASK.{self.IS_SCHEDULED} OR
476
+ (PROJECT_TASK.{self.IS_RECURRING} AND
477
+ PROJECT_TASK.{self.RECURRING_IS_NOT_PAUSED} AND
478
+ PROJECT_TASK.{self.RECURRING_HAS_NEXT_STARTDATE}
479
+ )
480
+ )
481
+ ) = 0
482
+ """
483
+ return self.get_rows(query)
484
+
485
+ def get_largest_projects(self):
486
+ """Get projects that are empty"""
487
+ query = f"""
488
+ SELECT
489
+ TASK.uuid,
490
+ TASK.title AS title,
491
+ {self.DATE_CREATE} AS created,
492
+ {self.DATE_MOD} AS modified,
493
+ (SELECT COUNT(uuid)
494
+ FROM TMTask AS PROJECT_TASK
495
+ WHERE
496
+ PROJECT_TASK.project = TASK.uuid AND
497
+ PROJECT_TASK.{self.IS_NOT_TRASHED} AND
498
+ PROJECT_TASK.{self.IS_INCOMPLETE}
499
+ ) AS tasks
500
+ FROM
501
+ {self.TABLE_TASK} AS TASK
502
+ WHERE
503
+ TASK.{self.IS_NOT_TRASHED} AND
504
+ TASK.{self.IS_INCOMPLETE} AND
505
+ TASK.{self.IS_PROJECT}
506
+ GROUP BY TASK.uuid
507
+ ORDER BY tasks COLLATE NOCASE DESC
508
+ """
509
+ return self.execute_query(query)
510
+
511
+ def get_daystats(self):
512
+ """Get a history of task activities
513
+
514
+ TK: understand what this does and whether to keep.
515
+ """
516
+ stat_days = 365
517
+
518
+ query = f"""
519
+ WITH RECURSIVE timeseries(x) AS (
520
+ SELECT 0
521
+ UNION ALL
522
+ SELECT x+1 FROM timeseries
523
+ LIMIT {stat_days}
524
+ )
525
+ SELECT
526
+ date(julianday("now", "-{stat_days} days"),
527
+ "+" || x || " days") as date,
528
+ CREATED.TasksCreated as created,
529
+ CLOSED.TasksClosed as completed,
530
+ CANCELLED.TasksCanceled as canceled,
531
+ TRASHED.TasksTrashed as trashed
532
+ FROM timeseries
533
+ LEFT JOIN
534
+ (SELECT COUNT(uuid) AS TasksCreated,
535
+ date({self.DATE_CREATE},"unixepoch") AS DAY
536
+ FROM {self.TABLE_TASK} AS TASK
537
+ WHERE DAY NOT NULL
538
+ AND TASK.{self.IS_TODO}
539
+ GROUP BY DAY)
540
+ AS CREATED ON CREATED.DAY = date
541
+ LEFT JOIN
542
+ (SELECT COUNT(uuid) AS TasksCanceled,
543
+ date(stopDate,"unixepoch") AS DAY
544
+ FROM {self.TABLE_TASK} AS TASK
545
+ WHERE DAY NOT NULL
546
+ AND TASK.{self.IS_CANCELED} AND TASK.{self.IS_TODO}
547
+ GROUP BY DAY)
548
+ AS CANCELLED ON CANCELLED.DAY = date
549
+ LEFT JOIN
550
+ (SELECT COUNT(uuid) AS TasksTrashed,
551
+ date({self.DATE_MOD},"unixepoch") AS DAY
552
+ FROM {self.TABLE_TASK} AS TASK
553
+ WHERE DAY NOT NULL
554
+ AND TASK.{self.IS_TRASHED} AND TASK.{self.IS_TODO}
555
+ GROUP BY DAY)
556
+ AS TRASHED ON TRASHED.DAY = date
557
+ LEFT JOIN
558
+ (SELECT COUNT(uuid) AS TasksClosed,
559
+ date(stopDate,"unixepoch") AS DAY
560
+ FROM {self.TABLE_TASK} AS TASK
561
+ WHERE DAY NOT NULL
562
+ AND TASK.{self.IS_COMPLETED} AND TASK.{self.IS_TODO}
563
+ GROUP BY DAY)
564
+ AS CLOSED ON CLOSED.DAY = date
565
+ """
566
+ return self.execute_query(query)
567
+
568
+ def get_minutes_today(self):
569
+ """Count the planned minutes for today."""
570
+ query = f"""
571
+ SELECT
572
+ SUM(TAG.title) AS minutes
573
+ FROM
574
+ {self.TABLE_TASK} AS TASK
575
+ LEFT OUTER JOIN
576
+ TMTask PROJECT ON TASK.project = PROJECT.uuid
577
+ LEFT OUTER JOIN
578
+ TMArea AREA ON TASK.area = AREA.uuid
579
+ LEFT OUTER JOIN
580
+ TMTask HEADING ON TASK.actionGroup = HEADING.uuid
581
+ LEFT OUTER JOIN
582
+ TMTask HEADPROJ ON HEADING.project = HEADPROJ.uuid
583
+ LEFT OUTER JOIN
584
+ TMTaskTag TAGS ON TASK.uuid = TAGS.tasks
585
+ LEFT OUTER JOIN
586
+ TMTag TAG ON TAGS.tags = TAG.uuid
587
+ WHERE
588
+ printf("%d", TAG.title) = TAG.title AND
589
+ TASK.{self.IS_NOT_TRASHED} AND
590
+ TASK.{self.IS_TODO} AND
591
+ TASK.{self.IS_INCOMPLETE} AND
592
+ TASK.{self.IS_ANYTIME} AND
593
+ TASK.{self.IS_SCHEDULED} AND (
594
+ (
595
+ PROJECT.title IS NULL OR (
596
+ PROJECT.{self.IS_NOT_TRASHED}
597
+ )
598
+ ) AND (
599
+ HEADPROJ.title IS NULL OR (
600
+ HEADPROJ.{self.IS_NOT_TRASHED}
601
+ )
602
+ )
603
+ )
604
+ """
605
+ return self.execute_query(query)
606
+
607
+ def get_cleanup(self):
608
+ """Tasks and projects that need work."""
609
+ result = []
610
+ result.extend(self.get_lint())
611
+ result.extend(self.get_empty_projects())
612
+ result = [i for n, i in enumerate(result) if i not in result[n + 1 :]]
613
+ return result
614
+
615
+ @staticmethod
616
+ def get_not_implemented():
617
+ """Not implemented warning."""
618
+ return [{"title": "not implemented"}]
619
+
620
+ functions = {
621
+ "trashed": get_trashed,
622
+ "areas": get_areas,
623
+ "lint": get_lint,
624
+ "empty": get_empty_projects,
625
+ "cleanup": get_cleanup,
626
+ "top-proj": get_largest_projects,
627
+ "stats-day": get_daystats,
628
+ "stats-min-today": get_minutes_today,
629
+ }
630
+
631
+
632
+ # Helper functions
633
+
634
+
635
+ def dict_factory(cursor, row):
636
+ """
637
+ Convert SQL result into a dictionary.
638
+
639
+ See also:
640
+ https://docs.python.org/3/library/sqlite3.html#sqlite3.Connection.row_factory
641
+ """
642
+ result = {}
643
+ for index, column in enumerate(cursor.description):
644
+ key, value = column[0], row[index]
645
+ if value is None and key in COLUMNS_TO_OMIT_IF_NONE:
646
+ continue
647
+ if value and key in COLUMNS_TO_TRANSFORM_TO_BOOL:
648
+ value = bool(value)
649
+ result[key] = value
650
+ return result
651
+
652
+
653
+ def list_factory(cursor, row):
654
+ """
655
+ Convert SQL selects of one column into a list.
656
+ """
657
+ return row[0]
658
+
659
+
660
+ def make_filter(column, value):
661
+ default = f'AND {column} = "{value}"'
662
+ # special options
663
+ return {
664
+ None: "",
665
+ False: f"AND {column} IS NULL",
666
+ True: f"AND {column} IS NOT NULL",
667
+ }.get(value, default)
668
+
669
+
670
+ def make_search(value: str) -> str:
671
+ query = ""
672
+ # noqa todo 'TMChecklistItem.title'
673
+ sources = ['TASK.title', 'TASK.notes', 'AREA.title']
674
+ for source in sources:
675
+ subsearch = make_sub_search(source, value)
676
+ if subsearch:
677
+ query = query + subsearch + ' OR '
678
+ if query.endswith(' OR '):
679
+ query = query[:-4]
680
+ if query != "":
681
+ query = 'AND (' + query + ')'
682
+ return query
683
+
684
+
685
+ def make_sub_search(column, value):
686
+ default = f'{column} LIKE "%{value}%"'
687
+ return {
688
+ None: "",
689
+ False: f"{column} IS NULL",
690
+ True: f"{column} IS NOT NULL",
691
+ }.get(value, default)
692
+
693
+
694
+ def validate(parameter, argument, valid_arguments):
695
+ """
696
+ For a given parameter, check if its argument type is valid.
697
+ """
698
+ if argument in valid_arguments:
699
+ return
700
+ message = f"Unrecognized {parameter} type: {argument!r}"
701
+ message += f"\nValid {parameter} types are {valid_arguments}"
702
+ raise ValueError(message)