things-py 0.0.4__py3.9.egg

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