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/api.py ADDED
@@ -0,0 +1,572 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ """
4
+ Module implementing Things API.
5
+ """
6
+
7
+ import os
8
+ from shlex import quote
9
+ import sys
10
+ from typing import List, Dict
11
+
12
+ from .database import Database
13
+
14
+
15
+ # --------------------------------------------------
16
+ # Core functions
17
+ # --------------------------------------------------
18
+
19
+
20
+ def tasks(uuid=None, include_items=False, **kwargs): # noqa: C901
21
+ """
22
+ Read tasks into dicts.
23
+
24
+ Note: "task" is a technical term used in the database to refer to a
25
+ to-do, project, or heading. For details, check the "type"-parameter.
26
+
27
+ Per default, only tasks marked as incomplete are included. If you
28
+ want to include completed or canceled tasks in the result, check the
29
+ "status"-parameter.
30
+
31
+ Parameters
32
+ ----------
33
+ uuid : str or None, optional
34
+ Any valid task uuid. If None, then return all tasks matched.
35
+
36
+ include_items : boolean, default False
37
+ Include items contained within a task. These might include
38
+ checklist items, headings, and to-dos.
39
+
40
+ type : {'to-do', 'heading', 'project', None}, optional
41
+ Only return a specific type of task:
42
+
43
+ 'to-do': may have a checklist; may be in an area and have tags.
44
+ 'project': may have to-dos and headings; may be in an area and
45
+ have tags.
46
+ 'heading': part of a project; groups tasks.
47
+ None: return all types of tasks.
48
+
49
+ status : {'incomplete', 'completed', 'canceled', None}, optional, \
50
+ default 'incomplete'
51
+
52
+ Only include tasks matching that status. If the argument is `None`,
53
+ then include tasks with any status value.
54
+
55
+ start : {'Inbox', 'Anytime', 'Someday', None}, optional
56
+ Only include tasks matching that start value. If the argument is
57
+ `None` (default), then include tasks with any start value.
58
+
59
+ area : str, bool, or None, optional
60
+ Any valid uuid of an area. Only include tasks matching that area.
61
+ If the argument is `False`, only include tasks _without_ an area.
62
+ If the argument is `True`, only include tasks _with_ an area.
63
+ If the argument is `None` (default), then include all tasks.
64
+
65
+ project : str or None, optional
66
+ Any valid uuid of a project. Only include tasks matching that project.
67
+ If the argument is `False`, only include tasks _without_ a project.
68
+ If the argument is `True`, only include tasks _with_ a project.
69
+ If the argument is `None` (default), then include all tasks.
70
+
71
+ heading : str or None, optional
72
+ Any valid uuid of a heading. Only include tasks matching that heading.
73
+ If the argument is `False`, only include tasks _without_ a heading.
74
+ If the argument is `True`, only include tasks _with_ a heading.
75
+ If the argument is `None` (default), then include all tasks.
76
+
77
+ tag : str or None, optional
78
+ Any valid title of a tag. Only include tasks matching that tag.
79
+ If the argument is `False`, only include tasks _without_ tags.
80
+ If the argument is `True`, only include tasks _with_ tags.
81
+ If the argument is `None` (default), then include all tasks.
82
+
83
+ start_date : bool or None, optional
84
+ If the argument is `False`, only include tasks _without_ a start date.
85
+ If the argument is `True`, only include tasks _with_ a start date.
86
+ If the argument is `None` (default), then include all tasks.
87
+
88
+ deadline : bool or None, optional
89
+ If the argument is `False`, only include tasks _without_ a deadline.
90
+ If the argument is `True`, only include tasks _with_ a deadline.
91
+ If the argument is `None` (default), then include all tasks.
92
+
93
+ search_query : str, optional
94
+ The string value is passed to the SQL LIKE operator. It can thus
95
+ include placeholders such as '%' and '_'. Per default, it is
96
+ wrapped in '% ... %'.
97
+
98
+ Currently titles and notes of to-dos, projects, headings, and areas
99
+ are taken into account.
100
+
101
+ If the query is `False`, then return items where titles or notes
102
+ are empty.
103
+
104
+ If the query `None`, then return all items.
105
+
106
+ index : {'index', 'todayIndex'}, default 'index'
107
+ Database field to order result by.
108
+
109
+ count_only : boolean, default False
110
+ Only output length of result. This is done by a SQL COUNT query.
111
+
112
+ filepath : str, optional
113
+ Any valid path of a SQLite database file generated by the Things app.
114
+ If no path is provided, then access the default database path.
115
+ If environment variable THINGSDB is set, this path will be used.
116
+
117
+ database : things.database.Database, optional
118
+ Any valid `things.database.Database` object previously instantiated.
119
+
120
+ Returns
121
+ -------
122
+ list of dict (default)
123
+ Representing multiple tasks.
124
+ dict (if `uuid` is given)
125
+ Representing a single task.
126
+ int (count_only == True)
127
+ Count of matching Tasks.
128
+
129
+ Examples
130
+ --------
131
+ >>> things.tasks()
132
+ ...
133
+ >>> things.tasks('2Ukg8I2nLukhyEM7wYiBeb')
134
+ ...
135
+ >>> things.tasks(area='hIo1FJlAYGKt1Yj38vzKc3', include_items=True)
136
+ ...
137
+ >>> things.tasks(status='completed', count_only=True)
138
+ 10
139
+
140
+ """
141
+ database = pop_database(kwargs)
142
+ result = database.get_tasks(
143
+ uuid=uuid, status=kwargs.pop("status", "incomplete"), **kwargs
144
+ )
145
+
146
+ if kwargs.get("count_only"):
147
+ return result
148
+
149
+ # overwrite `include_items` if fetching a single task.
150
+ if uuid:
151
+ include_items = True
152
+
153
+ for task in result:
154
+ # TK: How costly of an operation is it to do this for every task?
155
+ # IF costly, then can it be made significantly more efficient
156
+ # by optimizing SQL calls?
157
+
158
+ if task.get("tags"):
159
+ task["tags"] = database.get_tags(task=task["uuid"])
160
+
161
+ if not include_items:
162
+ continue
163
+
164
+ # include items
165
+ if task["type"] == "to-do":
166
+ if task.get("checklist"):
167
+ task["checklist"] = database.get_checklist_items(task_uuid=task["uuid"])
168
+ elif task["type"] == "project":
169
+ project = task
170
+ project["items"] = items = tasks(
171
+ project=project["uuid"],
172
+ include_items=True,
173
+ database=database,
174
+ )
175
+ # to-dos without headings appear before headings in app
176
+ items.sort(key=lambda item: item["type"], reverse=True)
177
+ elif task["type"] == "area":
178
+ area = task
179
+ area["items"] = items = tasks(
180
+ area=area["uuid"],
181
+ include_items=True,
182
+ database=database,
183
+ )
184
+ elif task["type"] == "heading":
185
+ heading = task
186
+ heading["items"] = tasks(
187
+ type="to-do",
188
+ heading=heading["uuid"],
189
+ include_items=True,
190
+ database=database,
191
+ )
192
+
193
+ if uuid:
194
+ result = result[0]
195
+
196
+ return result
197
+
198
+
199
+ def areas(uuid=None, include_items=False, **kwargs):
200
+ """
201
+ Read areas into dicts.
202
+
203
+ Parameters
204
+ ----------
205
+ uuid : str or None, optional
206
+ Any valid uuid of an area. If None, then return all areas.
207
+
208
+ include_items : boolean, default False
209
+ Include tasks and projects in each area.
210
+
211
+ tag : str or None, optional
212
+ Any valid title of a tag. Only include areas matching that tag.
213
+ If the argument is `False`, only include areas _without_ tags.
214
+ If the argument is `True`, only include areas _with_ tags.
215
+ If the argument is `None`, then ignore any tags present, that is,
216
+ include areas both with and without tags.
217
+
218
+ count_only : boolean, default False
219
+ Only output length of result. This is done by a SQL COUNT query.
220
+
221
+ filepath : str, optional
222
+ Any valid path of a SQLite database file generated by the Things app.
223
+ If no path is provided, then access the default database path.
224
+
225
+ database : things.database.Database, optional
226
+ Any valid `things.database.Database` object previously instantiated.
227
+
228
+ Returns
229
+ -------
230
+ list of dict (default)
231
+ Representing Things areas.
232
+ dict (if `uuid` is given)
233
+ Representing a single Things area.
234
+ int (count_only == True)
235
+ Count of matching areas.
236
+
237
+ Examples
238
+ --------
239
+ >>> things.areas()
240
+ ...
241
+ >>> things.areas(tag='Home')
242
+ ...
243
+ >>> things.areas(uuid='Gw9QefIdgR6nPEoY5hBNSh')
244
+ ...
245
+ >>> things.areas(include_items=True, status='completed')
246
+ ...
247
+ """
248
+ database = pop_database(kwargs)
249
+ result = database.get_areas(uuid=uuid, **kwargs)
250
+
251
+ if kwargs.get("count_only"):
252
+ return result
253
+
254
+ for area in result:
255
+ if area.get("tags"):
256
+ area["tags"] = database.get_tags(area=area["uuid"])
257
+ if include_items:
258
+ area["items"] = tasks(
259
+ area=area["uuid"], include_items=True, database=database
260
+ )
261
+
262
+ if uuid:
263
+ result = result[0]
264
+
265
+ return result
266
+
267
+
268
+ def tags(title=None, include_items=False, **kwargs):
269
+ """
270
+ Read tags into dicts.
271
+
272
+ Parameters
273
+ ----------
274
+ title : str, optional
275
+ Any valid title of a tag. Include all items of said tag.
276
+ If None, then return all tags.
277
+
278
+ include_items : boolean, default False
279
+ For each tag, include items tagged with that tag.
280
+ Items may include areas, tasks, and projects.
281
+
282
+ area : str, optional
283
+ Valid uuid of an area. Return tags of said area.
284
+
285
+ task : str, optional
286
+ Valid uuid of a task. Return tags of said task.
287
+
288
+ titles_only : bool, default False
289
+ If True, only return list of titles of tags.
290
+
291
+ filepath : str, optional
292
+ Any valid path of a SQLite database file generated by the Things app.
293
+ If no path is provided, then access the default database path.
294
+
295
+ database : things.database.Database, optional
296
+ Any valid `things.database.Database` object previously instantiated.
297
+
298
+ Returns
299
+ -------
300
+ list of dict (default)
301
+ Representing tags.
302
+ list of str (if `titles_only == True` or area / task is given)
303
+ Representing tag titles.
304
+ dict (if `title` is given)
305
+ Representing a single Things tag.
306
+
307
+ Examples
308
+ --------
309
+ >>> things.tags()
310
+ ...
311
+ >>> things.tags('Home')
312
+ ...
313
+ >>> things.tags(include_items=True)
314
+ ...
315
+ >>> things.tags(task='2Ukg8I2nLukhyEM7wYiBeb')
316
+ ...
317
+ """
318
+ database = pop_database(kwargs)
319
+ result = database.get_tags(title=title, **kwargs)
320
+
321
+ if include_items:
322
+ for tag in result:
323
+ tag_title = tag["title"]
324
+ tag["items"] = [
325
+ *areas(tag=tag_title, database=database),
326
+ *tasks(tag=tag_title, database=database),
327
+ ]
328
+
329
+ if title:
330
+ result = result[0]
331
+
332
+ return result
333
+
334
+
335
+ # --------------------------------------------------
336
+ # Utility API functions derived from above
337
+ # --------------------------------------------------
338
+
339
+
340
+ def search(query: str, **kwargs) -> List[Dict]:
341
+ """
342
+ Search tasks in the database.
343
+
344
+ Currently any part of a title and note of a to-do, project,
345
+ heading, or area is matched.
346
+
347
+ See the `search_query` parameter of `things.api.tasks` for details.
348
+
349
+ Examples
350
+ --------
351
+ >>> things.search('book')
352
+ [{'uuid': 'YrOmUnEXASmpq8ch6RsyPt',
353
+ 'type': 'to-do',
354
+ 'title': 'Book a hotel room',
355
+ ...},
356
+ {'uuid': 'KVHAxIIJ52a0h1RCbmg5D6',
357
+ 'type': 'to-do',
358
+ 'title': 'Book flights',
359
+ ...}]
360
+
361
+ >>> things.search('book%room')
362
+ [{'uuid': 'YrOmUnEXASmpq8ch6RsyPt',
363
+ 'type': 'to-do',
364
+ 'title': 'Book a hotel room',
365
+ ...}]
366
+ """
367
+ return tasks(search_query=query, **kwargs)
368
+
369
+
370
+ def get(uuid, default=None, **kwargs):
371
+ """
372
+ Find an object by uuid. If not found, return `default`.
373
+
374
+ Currently supports tasks, projects, headings, areas, and tags.
375
+ """
376
+ try:
377
+ return tasks(uuid=uuid, **kwargs)
378
+ except ValueError:
379
+ pass
380
+
381
+ try:
382
+ return areas(uuid=uuid, **kwargs)
383
+ except ValueError:
384
+ pass
385
+
386
+ for tag in tags(**kwargs):
387
+ if tag["uuid"] == uuid:
388
+ return tag
389
+
390
+ return default
391
+
392
+
393
+ # Filter by object type
394
+
395
+
396
+ def todos(uuid=None, **kwargs):
397
+ """
398
+ Read to-dos into dicts.
399
+
400
+ See `things.api.tasks` for details.
401
+ """
402
+ return tasks(uuid=uuid, type="to-do", **kwargs)
403
+
404
+
405
+ def projects(uuid=None, **kwargs):
406
+ """
407
+ Read projects into dicts.
408
+
409
+ See `things.api.tasks` for details.
410
+ """
411
+ return tasks(uuid=uuid, type="project", **kwargs)
412
+
413
+
414
+ def checklist_items(uuid, **kwargs):
415
+ """
416
+ Read checklist items into dicts.
417
+ See `things.api.tasks` for details.
418
+
419
+ Returns
420
+ -------
421
+ list of dict
422
+ Representing checklist items. Or [] if none found.
423
+ """
424
+ return tasks(uuid=uuid, type="to-do", **kwargs).get('checklist', [])
425
+
426
+ # Filter by collections in the Things app sidebar.
427
+
428
+
429
+ def inbox(**kwargs):
430
+ """
431
+ Read inbox into dicts.
432
+
433
+ See `things.api.tasks` for details.
434
+ """
435
+ return tasks(start="Inbox", **kwargs)
436
+
437
+
438
+ def today(**kwargs):
439
+ """
440
+ Note: This might not produce desired results if the Things app hasn't
441
+ been opened yet today and the yellow "OK" button clicked for new tasks.
442
+ In general, you can assume that whatever state the Things app was in
443
+ when you last opened it, that's the state reflected here by the API.
444
+ """
445
+ database = pop_database(kwargs)
446
+ if not database.was_modified_today():
447
+ print(
448
+ "[NOTE] The results reflect the state of the Things app "
449
+ "when it was last run. If the results seem out of date, "
450
+ "then run the Things app and click the yellow 'OK' button "
451
+ "to update Today's to-dos and projects.",
452
+ file=sys.stderr,
453
+ )
454
+ return tasks(
455
+ start_date=True,
456
+ start="Anytime",
457
+ index="todayIndex",
458
+ database=database,
459
+ **kwargs,
460
+ )
461
+
462
+
463
+ def upcoming(**kwargs):
464
+ """
465
+ Note: unscheduled tasks with a deadline are not included here.
466
+ See the `deadline` function instead.
467
+ """
468
+ return tasks(start_date=True, start="Someday", **kwargs)
469
+
470
+
471
+ def anytime(**kwargs):
472
+ """
473
+ Read anytime tasks into dicts.
474
+
475
+ See `things.api.tasks` for details.
476
+ """
477
+ return tasks(start="Anytime", **kwargs)
478
+
479
+
480
+ def someday(**kwargs):
481
+ """
482
+ Read someday tasks into dicts.
483
+
484
+ See `things.api.tasks` for details.
485
+ """
486
+ return tasks(start_date=False, start="Someday", **kwargs)
487
+
488
+
489
+ def logbook(**kwargs):
490
+ """
491
+ Read logbook tasks into dicts.
492
+
493
+ See `things.api.tasks` for details.
494
+ """
495
+ result = [*canceled(**kwargs), *completed(**kwargs)]
496
+ result.sort(key=lambda task: task["stop_date"], reverse=True)
497
+ return result
498
+
499
+
500
+ # Filter by various task properties
501
+
502
+
503
+ def canceled(**kwargs):
504
+ """
505
+ Read canceled tasks into dicts.
506
+
507
+ See `things.api.tasks` for details.
508
+ """
509
+ return tasks(status="canceled", **kwargs)
510
+
511
+
512
+ def completed(**kwargs):
513
+ """
514
+ Examples
515
+ --------
516
+ >>> things.completed(count_only=True)
517
+ 14
518
+ >>> things.completed(type='project', count_only=True)
519
+ 4
520
+ """
521
+ return tasks(status="completed", **kwargs)
522
+
523
+
524
+ def deadlines(**kwargs):
525
+ """
526
+ Read tasks with deadlines into dicts.
527
+
528
+ See `things.api.tasks` for details.
529
+ """
530
+ result = tasks(deadline=True, **kwargs)
531
+ result.sort(key=lambda task: task["deadline"])
532
+ return result
533
+
534
+
535
+ # Interact with Things app
536
+
537
+
538
+ def link(uuid):
539
+ """
540
+ Returns a things:///show?id=uuid link.
541
+ """
542
+
543
+ return f"things:///show?id={uuid}"
544
+
545
+
546
+ def show(uuid):
547
+ """
548
+ Show a certain uuid in the Things app.
549
+
550
+ Parameters
551
+ ----------
552
+ uuid : str
553
+ A valid uuid of any Things object.
554
+
555
+ Examples
556
+ --------
557
+ >>> tag = things.tags('Home')
558
+ >>> things.show(tag['uuid'])
559
+ """
560
+ uri = link(uuid)
561
+ os.system(f"open {quote(uri)}")
562
+
563
+
564
+ # Helper functions
565
+
566
+
567
+ def pop_database(kwargs):
568
+ """instantiate non-default database from `kwargs` if provided"""
569
+ filepath, database = kwargs.pop("filepath", None), kwargs.pop("database", None)
570
+ if not database:
571
+ database = Database(filepath=filepath)
572
+ return database