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.
- tests/__init__.py +0 -0
- tests/test_things.py +115 -0
- things/__init__.py +23 -0
- things/api.py +485 -0
- things/database.py +702 -0
- things.py-0.0.1.dist-info/LICENSE +201 -0
- things.py-0.0.1.dist-info/METADATA +139 -0
- things.py-0.0.1.dist-info/RECORD +10 -0
- things.py-0.0.1.dist-info/WHEEL +5 -0
- things.py-0.0.1.dist-info/top_level.txt +2 -0
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)
|