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.
- EGG-INFO/PKG-INFO +137 -0
- EGG-INFO/SOURCES.txt +11 -0
- EGG-INFO/dependency_links.txt +1 -0
- EGG-INFO/top_level.txt +2 -0
- EGG-INFO/zip-safe +1 -0
- tests/__init__.py +0 -0
- tests/__pycache__/__init__.cpython-39.pyc +0 -0
- tests/__pycache__/test_things.cpython-39.pyc +0 -0
- tests/test_things.py +124 -0
- things/__init__.py +24 -0
- things/__pycache__/__init__.cpython-39.pyc +0 -0
- things/__pycache__/api.cpython-39.pyc +0 -0
- things/__pycache__/database.cpython-39.pyc +0 -0
- things/api.py +572 -0
- things/database.py +722 -0
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)
|