Passed
Push — master ( 332f19...a52237 )
by Alexander
02:00 queued 12s
created

things_cli.cli.ThingsCLI.csv_header()   A

Complexity

Conditions 2

Size

Total Lines 5
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

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