Passed
Push — master ( b91f77...d7abf9 )
by Alexander
01:47
created

things_cli.cli.ThingsCLI.gantt_add_task()   C

Complexity

Conditions 9

Size

Total Lines 29
Code Lines 23

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 9
eloc 23
nop 2
dl 0
loc 29
rs 6.6666
c 0
b 0
f 0
1
#!/usr/bin/env python3
2
3
"""A simple Python 3 CLI to read your Things app data."""
4
5
from __future__ import print_function
6
7
import argparse
8
import csv
9
from datetime import datetime
10
from io import StringIO
11
import json
12
import sys
13
from typing import Dict
14
import webbrowser
15
from xml.dom import minidom
16
import xml.etree.ElementTree as ETree
17
from xml.etree.ElementTree import Element, SubElement
18
19
import argcomplete  # type: ignore
0 ignored issues
show
introduced by
Unable to import 'argcomplete'
Loading history...
20
import things as api
0 ignored issues
show
introduced by
Unable to import 'things'
Loading history...
21
22
from things_cli import __version__
23
24
25
class ThingsCLI:  # pylint: disable=too-many-instance-attributes
26
    """A simple Python 3 CLI to read your Things app data."""
27
28
    print_json = False
29
    print_csv = False
30
    print_gantt = False
31
    print_opml = False
32
    # anonymize = False
33
    database = None
34
    recursive = False
35
    filter_project = None
36
    filter_area = None
37
    filter_tag = None
38
    only_projects = None
39
40
    def __init__(self, database=None):
41
        """Initialize class."""
42
        self.database = database
43
44
    def print_tasks(self, tasks):
45
        """Print a task."""
46
47
        if self.only_projects:
48
            for task in tasks:
49
                task["items"] = (
50
                    [
51
                        items
52
                        for items in task["items"]
53
                        if items["type"] in ["area", "project"]
54
                    ]
55
                    if task.get("items")
56
                    else []
57
                )
58
                for items in task["items"]:
59
                    items["items"] = (
60
                        [
61
                            sub_items
62
                            for sub_items in items["items"]
63
                            if sub_items["type"] in ["area", "project"]
64
                        ]
65
                        if items.get("items")
66
                        else []
67
                    )
68
69
        if self.print_json:
70
            print(json.dumps(tasks))
71
        elif self.print_opml:
72
            print(self.opml_dumps(tasks))
73
        elif self.print_csv:
74
            print(self.csv_dumps(tasks))
75
        elif self.print_gantt:
76
            print("gantt")
77
            print("  dateFormat  YYYY-MM-DD")
78
            print("  title       Things To-Dos")
79
            print("  excludes    weekends")
80
            print(self.gantt_dumps(tasks))
81
        else:
82
            print(self.txt_dumps(tasks), end="")
83
84
    def gantt_dumps(self, tasks, array=None):
85
        """Convert tasks into mermaid-js GANTT."""
86
87
        result = ""
88
89
        if array is None:
90
            array = {}
91
92
        for task in tasks:
93
            ThingsCLI.gantt_add_task(task, array)
94
            self.gantt_dumps(task.get("items", []), array)
95
96
        for group in array:
97
            result += f"  section {group}\n"
98
            for item in array[group]:
99
                result += item
100
101
        return result
102
103
    @staticmethod
104
    def gantt_add_task(task, array):
105
        """Add a task to a mermaid-js GANTT."""
106
107
        context = (
108
            task.get("project_title", None)
109
            or task.get("area_title", None)
110
            or task.get("heading_title", None)
111
            or task.get("start", None)
112
            or ""
113
        )
114
115
        title = task["title"].replace(":", " ")
116
        start = task.get("start_date")
117
        deadline = task.get("deadline") or "1h"
118
        if not start and deadline != "1h":
119
            start = deadline
120
        if start == deadline:
121
            deadline = "1h"
122
        if deadline == "1h":
123
            visual = ":milestone"
124
        else:
125
            visual = ":active"
126
            # lint-ignore todo: if in the past: done
127
        if start and not task.get("stop_date"):
128
            if context not in array:
129
                array[context] = []
130
            if not "".join(s for s in array[context] if title.lower() in s.lower()):
131
                array[context].append(f"    {title} {visual}, {start}, {deadline}\n")
132
133
    def csv_dumps(self, tasks):
134
        """Convert tasks into CSV."""
135
136
        fieldnames = []
137
        self.csv_header(tasks, fieldnames)
138
        if "items" in fieldnames:
139
            fieldnames.remove("items")
140
        if "checklist" in fieldnames:
141
            fieldnames.remove("checklist")
142
143
        output = StringIO()
144
        writer = csv.DictWriter(output, fieldnames=fieldnames, delimiter=";")
145
        writer.writeheader()
146
147
        self.csv_converter(tasks, writer)
148
149
        return output.getvalue()
150
151
    def csv_header(self, tasks, fieldnames):
152
        """Convert tasks into CSV header."""
153
        for task in tasks:
154
            fieldnames.extend(field for field in task if field not in fieldnames)
155
            self.csv_header(task.get("items", []), fieldnames)
156
157
    def csv_converter(self, tasks, writer):
158
        """Convert tasks into CSV."""
159
        if tasks is True:
160
            return
161
        for task in tasks:
162
            self.csv_converter(task.get("items", []), writer)
163
            task.pop("items", [])
164
            self.csv_converter(task.get("checklist", []), writer)
165
            task.pop("checklist", [])
166
            writer.writerow(task)
167
168
    def opml_dumps(self, tasks):
169
        """Convert tasks into OPML."""
170
171
        top = Element("opml")
172
        head = SubElement(top, "head")
173
        SubElement(head, "title").text = "Things 3 Database"
174
        body = SubElement(top, "body")
175
176
        self.opml_convert(tasks, body)
177
178
        return minidom.parseString(ETree.tostring(top)).toprettyxml(indent="   ")
179
180
    def opml_convert(self, tasks, top):
181
        """Print pretty OPML of selected tasks."""
182
183
        if tasks is True:
184
            return
185
        for task in tasks:
186
            area = SubElement(top, "outline")
187
            text = task["title"]
188
            if task.get("start_date"):
189
                text = f"{text} (Scheduled: {task['start_date']})"
190
            elif task.get("start"):
191
                text = f"{text} ({task['start']})"
192
            area.set("text", text)
193
            self.opml_convert(task.get("items", []), area)
194
            task.pop("items", [])
195
            self.opml_convert(task.get("checklist", []), area)
196
            task.pop("checklist", [])
197
198
    def txt_dumps(self, tasks, indentation="", result=""):
199
        """Print pretty text version of selected tasks."""
200
201
        if tasks is True:
202
            return result
203
        for task in tasks:
204
            title = task["title"]
205
            context = (
206
                task.get("project_title", None)
207
                or task.get("area_title", None)
208
                or task.get("heading_title", None)
209
                or task.get("start", None)
210
            )
211
            start = task.get("start_date", None)
212
            details = " | ".join(filter(None, [start, context]))
213
            result = result + f"{indentation}- {title} ({details})\n"
214
            result = self.txt_dumps(task.get("items", []), indentation + "  ", result)
215
            task.pop("items", [])
216
            result = self.txt_dumps(
217
                task.get("checklist", []), indentation + "  ", result
218
            )
219
220
        return result
221
222
    @classmethod
223
    def print_unimplemented(cls, command):
224
        """Show warning that method is not yet implemented."""
225
        print(f"command '{command}' not implemented yet", file=sys.stderr)
226
227
    @classmethod
228
    def get_parser(cls):
229
        """Create command line argument parser."""
230
        parser = argparse.ArgumentParser(description="Simple read-only Thing 3 CLI.")
231
232
        subparsers = parser.add_subparsers(
233
            help="", metavar="command", required=True, dest="command"
234
        )
235
236
        ################################
237
        # Core database methods
238
        ################################
239
        subparsers.add_parser("inbox", help="Shows inbox tasks")
240
        subparsers.add_parser("today", help="Shows todays tasks")
241
        subparsers.add_parser("upcoming", help="Shows upcoming tasks")
242
        subparsers.add_parser("anytime", help="Shows anytime tasks")
243
        subparsers.add_parser("completed", help="Shows completed tasks")
244
        subparsers.add_parser("someday", help="Shows someday tasks")
245
        subparsers.add_parser("canceled", help="Shows canceled tasks")
246
        subparsers.add_parser("trash", help="Shows trashed tasks")
247
        subparsers.add_parser("todos", help="Shows all todos")
248
        subparsers.add_parser("all", help="Shows all tasks")
249
        subparsers.add_parser("areas", help="Shows all areas")
250
        subparsers.add_parser("projects", help="Shows all projects")
251
        subparsers.add_parser("logbook", help="Shows completed tasks")
252
        subparsers.add_parser("logtoday", help="Shows tasks completed today")
253
        subparsers.add_parser("createdtoday", help="Shows tasks created today")
254
        subparsers.add_parser("tags", help="Shows all tags ordered by their usage")
255
        subparsers.add_parser("deadlines", help="Shows tasks with due dates")
256
257
        ################################
258
        # Additional functions
259
        ################################
260
        subparsers.add_parser("feedback", help="Give feedback")
261
        subparsers.add_parser(
262
            "search", help="Searches for a specific task"
263
        ).add_argument("string", help="String to search for")
264
265
        ################################
266
        # To be implemented in things.py
267
        ################################
268
        # subparsers.add_parser("repeating", help="Shows all repeating tasks")
269
        # subparsers.add_parser("subtasks", help="Shows all subtasks")
270
        # subparsers.add_parser("headings", help="Shows headings")
271
272
        ################################
273
        # To be converted from https://github.com/alexanderwillner/things.sh
274
        ################################
275
        # subparsers.add_parser("backlog", help="Shows backlog tasks")
276
        # subparsers.add_parser("empty", help="Shows projects that are empty")
277
        # subparsers.add_parser("hours", help="Shows hours planned today")
278
        # subparsers.add_parser("ical", help="Shows tasks ordered by due date as iCal")
279
        # subparsers.add_parser("lint", help="Shows tasks that float around")
280
        # subparsers.add_parser(
281
        #     "mostClosed", help="Shows days when most tasks were closed"
282
        # )
283
        # subparsers.add_parser(
284
        #     "mostCancelled", help="Shows days when most tasks were cancelled"
285
        # )
286
        # subparsers.add_parser(
287
        #     "mostTrashed", help="Shows days when most tasks were trashed"
288
        # )
289
        # subparsers.add_parser(
290
        #     "mostCreated", help="Shows days when most tasks were created"
291
        # )
292
        # subparsers.add_parser("mostTasks", help="Shows projects that have most tasks")
293
        # subparsers.add_parser(
294
        #     "mostCharacters", help="Shows tasks that have most characters"
295
        # )
296
        # subparsers.add_parser("nextish", help="Shows all nextish tasks")
297
        # subparsers.add_parser("old", help="Shows all old tasks")
298
        # subparsers.add_parser("schedule", help="Schedules an event using a template")
299
        # subparsers.add_parser("stat", help="Provides a number of statistics")
300
        # subparsers.add_parser("statcsv", help="Exports some statistics as CSV")
301
        # subparsers.add_parser("tag", help="Shows all tasks with the waiting for tag")
302
        # subparsers.add_parser(
303
        #     "waiting", help="Shows all tasks with the waiting for tag"
304
        # )
305
306
        ################################
307
        # To be converted from https://github.com/alexanderwillner/things.sh
308
        ################################
309
        # parser.add_argument("-a", "--anonymize",
310
        #                     action="store_true", default=False,
311
        #                     help="anonymize output", dest="anonymize")
312
313
        parser.add_argument(
314
            "-p", "--filter-project", dest="filter_project", help="filter by project"
315
        )
316
        parser.add_argument(
317
            "-a", "--filter-area", dest="filter_area", help="filter by area"
318
        )
319
        parser.add_argument(
320
            "-t", "--filtertag", dest="filter_tag", help="filter by tag"
321
        )
322
        parser.add_argument(
323
            "-e",
324
            "--only-projects",
325
            action="store_true",
326
            default=False,
327
            dest="only_projects",
328
            help="export only projects",
329
        )
330
        parser.add_argument(
331
            "-o",
332
            "--opml",
333
            action="store_true",
334
            default=False,
335
            help="output as OPML",
336
            dest="opml",
337
        )
338
339
        parser.add_argument(
340
            "-j",
341
            "--json",
342
            action="store_true",
343
            default=False,
344
            help="output as JSON",
345
            dest="json",
346
        )
347
348
        parser.add_argument(
349
            "-c",
350
            "--csv",
351
            action="store_true",
352
            default=False,
353
            help="output as CSV",
354
            dest="csv",
355
        )
356
357
        parser.add_argument(
358
            "-g",
359
            "--gantt",
360
            action="store_true",
361
            default=False,
362
            help="output as mermaid-js GANTT",
363
            dest="gantt",
364
        )
365
366
        parser.add_argument(
367
            "-r",
368
            "--recursive",
369
            help="in-depth output",
370
            dest="recursive",
371
            default=False,
372
            action="store_true",
373
        )
374
375
        parser.add_argument(
376
            "-d", "--database", help="set path to database", dest="database"
377
        )
378
379
        parser.add_argument(
380
            "--version",
381
            "-v",
382
            action="version",
383
            version=f"%(prog)s (version {__version__})",
384
        )
385
386
        argcomplete.autocomplete(parser)
387
388
        return parser
389
390
    def defaults(self):
391
        """Set default options for the new API."""
392
        return dict(
393
            project=self.filter_project,
394
            area=self.filter_area,
395
            tag=self.filter_tag,
396
            include_items=self.recursive,
397
            filepath=self.database,
398
        )
399
400
    def main(self, args=None):
401
        """Start the main app."""
402
403
        if args is None:
404
            self.main(ThingsCLI.get_parser().parse_args())
405
        else:
406
            command = args.command
407
            self.print_json = args.json
408
            self.print_csv = args.csv
409
            self.print_gantt = args.gantt
410
            self.print_opml = args.opml
411
            self.database = args.database or self.database
412
            self.filter_project = args.filter_project or None
413
            self.filter_area = args.filter_area or None
414
            self.filter_tag = args.filter_tag or None
415
            self.only_projects = args.only_projects or None
416
            self.recursive = args.recursive
417
            # self.anonymize = args.anonymize
418
            # self.things3.anonymize = self.anonymize ## not implemented
419
            defaults = self.defaults()
420
421
            self.parse_command(command, defaults, args)
422
423
    def parse_command(self, command: str, defaults: Dict, args):
424
        """Handle given command."""
425
426
        if command == "tags":
427
            defaults.pop("tag")
428
            defaults.pop("project")
429
        if command in ["all", "areas"]:
430
            defaults.pop("area")
431
            defaults.pop("project")
432
433
        if command == "all":
434
            inbox = api.inbox(**defaults)
435
            today = api.today(**defaults)
436
            upcoming = api.upcoming(**defaults)
437
            anytime = api.anytime(**defaults)
438
            someday = api.someday(**defaults)
439
            logbook = api.logbook(**defaults)
440
441
            no_area = api.projects(**defaults)
442
            areas = api.areas(**defaults)
443
            structure = [
444
                {"title": "Inbox", "items": inbox},
445
                {"title": "Today", "items": today},
446
                {"title": "Upcoming", "items": upcoming},
447
                {"title": "Anytime", "items": anytime},
448
                {"title": "Someday", "items": someday},
449
                {"title": "Logbook", "items": logbook},
450
                {"title": "No Area", "items": no_area},
451
                {"title": "Areas", "items": areas},
452
            ]
453
            self.print_tasks(structure)
454
        elif command == "logtoday":
455
            today = datetime.now().strftime("%Y-%m-%d")
456
            result = getattr(api, "logbook")(**defaults, stop_date=today)
457
            self.print_tasks(result)
458
        elif command == "createdtoday":
459
            result = getattr(api, "last")("1d")
460
            self.print_tasks(result)
461
        elif command == "upcoming":
462
            result = getattr(api, command)(**defaults)
463
            result.sort(key=lambda task: task["start_date"], reverse=False)
464
            self.print_tasks(result)
465
        elif command == "search":
466
            self.print_tasks(
467
                api.search(
468
                    args.string,
469
                    filepath=self.database,
470
                    include_items=self.recursive,
471
                )
472
            )
473
        elif command == "feedback":  # pragma: no cover
474
            webbrowser.open("https://github.com/thingsapi/things-cli/issues")
475
        elif command in dir(api):
476
            self.print_tasks(getattr(api, command)(**defaults))
477
        else:  # pragma: no cover
478
            ThingsCLI.print_unimplemented(command)
479
            sys.exit(3)
480
481
482
def main():
483
    """Start for CLI installation."""
484
    ThingsCLI().main()
485
486
487
if __name__ == "__main__":
488
    main()
489