1import functools
2import glob
3import locale
4import sys
5from contextlib import contextmanager
6from datetime import timedelta
7from os.path import isdir
8
9import click
10import click_log
11
12from todoman import exceptions
13from todoman import formatters
14from todoman.configuration import ConfigurationException
15from todoman.configuration import load_config
16from todoman.interactive import TodoEditor
17from todoman.model import Database
18from todoman.model import Todo
19from todoman.model import cached_property
20
21click_log.basic_config()
22
23
24@contextmanager
25def handle_error():
26    try:
27        yield
28    except exceptions.TodomanException as e:
29        click.echo(e)
30        sys.exit(e.EXIT_CODE)
31
32
33def catch_errors(f):
34    @functools.wraps(f)
35    def wrapper(*a, **kw):
36        with handle_error():
37            return f(*a, **kw)
38
39    return wrapper
40
41
42TODO_ID_MIN = 1
43with_id_arg = click.argument("id", type=click.IntRange(min=TODO_ID_MIN))
44
45
46def _validate_lists_param(ctx, param=None, lists=()):
47    return [_validate_list_param(ctx, name=list_) for list_ in lists]
48
49
50def _validate_list_param(ctx, param=None, name=None):
51    ctx = ctx.find_object(AppContext)
52    if name is None:
53        if ctx.config["default_list"]:
54            name = ctx.config["default_list"]
55        else:
56            raise click.BadParameter("You must set `default_list` or use -l.")
57    lists = {list_.name: list_ for list_ in ctx.db.lists()}
58    fuzzy_matches = [
59        list_ for list_ in lists.values() if list_.name.lower() == name.lower()
60    ]
61
62    if len(fuzzy_matches) == 1:
63        return fuzzy_matches[0]
64
65    # case-insensitive matching collides or does not find a result,
66    # use exact matching
67    if name in lists:
68        return lists[name]
69    raise click.BadParameter(
70        "{}. Available lists are: {}".format(
71            name, ", ".join(list_.name for list_ in lists.values())
72        )
73    )
74
75
76def _validate_date_param(ctx, param, val):
77    ctx = ctx.find_object(AppContext)
78    try:
79        return ctx.formatter.parse_datetime(val)
80    except ValueError as e:
81        raise click.BadParameter(e)
82
83
84def _validate_priority_param(ctx, param, val):
85    ctx = ctx.find_object(AppContext)
86    try:
87        return ctx.formatter.parse_priority(val)
88    except ValueError as e:
89        raise click.BadParameter(e)
90
91
92def _validate_start_date_param(ctx, param, val):
93    ctx = ctx.find_object(AppContext)
94    if not val:
95        return val
96
97    if len(val) != 2 or val[0] not in ["before", "after"]:
98        raise click.BadParameter("Format should be '[before|after] [DATE]'")
99
100    is_before = val[0] == "before"
101
102    try:
103        dt = ctx.formatter.parse_datetime(val[1])
104        return is_before, dt
105    except ValueError as e:
106        raise click.BadParameter(e)
107
108
109def _validate_startable_param(ctx, param, val):
110    ctx = ctx.find_object(AppContext)
111    return val or ctx.config["startable"]
112
113
114def _validate_todos(ctx, param, val):
115    ctx = ctx.find_object(AppContext)
116    with handle_error():
117        return [ctx.db.todo(int(id)) for id in val]
118
119
120def _sort_callback(ctx, param, val):
121    fields = val.split(",") if val else []
122    for field in fields:
123        if field.startswith("-"):
124            field = field[1:]
125
126        if field not in Todo.ALL_SUPPORTED_FIELDS and field != "id":
127            raise click.BadParameter(f"Unknown field '{field}'")
128
129    return fields
130
131
132def validate_status(ctx=None, param=None, val=None) -> str:
133    statuses = val.upper().split(",")
134
135    if "ANY" in statuses:
136        return ",".join(Todo.VALID_STATUSES)
137
138    for status in statuses:
139        if status not in Todo.VALID_STATUSES:
140            raise click.BadParameter(
141                'Invalid status, "{}", statuses must be one of "{}", or "ANY"'.format(
142                    status, ", ".join(Todo.VALID_STATUSES)
143                )
144            )
145
146    return val
147
148
149def _todo_property_options(command):
150    click.option(
151        "--priority",
152        default="",
153        callback=_validate_priority_param,
154        help="Priority for this task",
155    )(command)
156    click.option("--location", help="The location where this todo takes place.")(
157        command
158    )
159    click.option(
160        "--due",
161        "-d",
162        default="",
163        callback=_validate_date_param,
164        help=("Due date of the task, in the format specified in the configuration."),
165    )(command)
166    click.option(
167        "--start",
168        "-s",
169        default="",
170        callback=_validate_date_param,
171        help="When the task starts.",
172    )(command)
173
174    @functools.wraps(command)
175    def command_wrap(*a, **kw):
176        kw["todo_properties"] = {
177            key: kw.pop(key) for key in ("due", "start", "location", "priority")
178        }
179        return command(*a, **kw)
180
181    return command_wrap
182
183
184class AppContext:
185    def __init__(self):
186        self.config = None
187        self.db = None
188        self.formatter_class = None
189
190    @cached_property
191    def ui_formatter(self):
192        return formatters.DefaultFormatter(
193            self.config["date_format"],
194            self.config["time_format"],
195            self.config["dt_separator"],
196        )
197
198    @cached_property
199    def formatter(self):
200        return self.formatter_class(
201            self.config["date_format"],
202            self.config["time_format"],
203            self.config["dt_separator"],
204        )
205
206
207pass_ctx = click.make_pass_decorator(AppContext)
208
209_interactive_option = click.option(
210    "--interactive",
211    "-i",
212    is_flag=True,
213    default=None,
214    help="Go into interactive mode before saving the task.",
215)
216
217
218@click.group(invoke_without_command=True)
219@click_log.simple_verbosity_option()
220@click.option(
221    "--colour",
222    "--color",
223    "colour",
224    default=None,
225    type=click.Choice(["always", "auto", "never"]),
226    help=(
227        "By default todoman will disable colored output if stdout "
228        "is not a TTY (value `auto`). Set to `never` to disable "
229        "colored output entirely, or `always` to enable it "
230        "regardless."
231    ),
232)
233@click.option(
234    "--porcelain",
235    is_flag=True,
236    help=(
237        "Use a JSON format that will "
238        "remain stable regardless of configuration or version."
239    ),
240)
241@click.option(
242    "--humanize",
243    "-h",
244    default=None,
245    is_flag=True,
246    help="Format all dates and times in a human friendly way",
247)
248@click.option(
249    "--config",
250    "-c",
251    default=None,
252    help="The config file to use.",
253    envvar="TODOMAN_CONFIG",
254    metavar="PATH",
255)
256@click.pass_context
257@click.version_option(prog_name="todoman")
258@catch_errors
259def cli(click_ctx, colour, porcelain, humanize, config):
260    ctx = click_ctx.ensure_object(AppContext)
261    try:
262        ctx.config = load_config(config)
263    except ConfigurationException as e:
264        raise click.ClickException(e.args[0])
265
266    if porcelain and humanize:
267        raise click.ClickException(
268            "--porcelain and --humanize cannot be used at the same time."
269        )
270
271    if humanize is None:  # False means explicitly disabled
272        humanize = ctx.config["humanize"]
273
274    if porcelain:
275        ctx.formatter_class = formatters.PorcelainFormatter
276    elif humanize:
277        ctx.formatter_class = formatters.HumanizedFormatter
278    else:
279        ctx.formatter_class = formatters.DefaultFormatter
280
281    colour = colour or ctx.config["color"]
282    if colour == "always":
283        click_ctx.color = True
284    elif colour == "never":
285        click_ctx.color = False
286
287    paths = [
288        path
289        for path in glob.iglob(ctx.config["path"])
290        if isdir(path) and not path.endswith("__pycache__")
291    ]
292    if len(paths) == 0:
293        raise exceptions.NoListsFound(ctx.config["path"])
294
295    ctx.db = Database(paths, ctx.config["cache_path"])
296
297    # Make python actually use LC_TIME, or the user's locale settings
298    locale.setlocale(locale.LC_TIME, "")
299
300    if not click_ctx.invoked_subcommand:
301        invoke_command(
302            click_ctx,
303            ctx.config["default_command"],
304        )
305
306
307def invoke_command(click_ctx, command):
308    name, *raw_args = command.split(" ")
309    if name not in cli.commands:
310        raise click.ClickException("Invalid setting for [default_command]")
311    parser = cli.commands[name].make_parser(click_ctx)
312    opts, args, param_order = parser.parse_args(raw_args)
313    for param in param_order:
314        opts[param.name] = param.handle_parse_result(click_ctx, opts, args)[0]
315    click_ctx.invoke(cli.commands[name], *args, **opts)
316
317
318try:  # pragma: no cover
319    import click_repl
320
321    click_repl.register_repl(cli)
322    click_repl.register_repl(cli, name="shell")
323except ImportError:
324    pass
325
326
327@cli.command()
328@click.argument("summary", nargs=-1)
329@click.option(
330    "--list",
331    "-l",
332    callback=_validate_list_param,
333    help="List in which the task will be saved.",
334)
335@click.option(
336    "--read-description",
337    "-r",
338    is_flag=True,
339    default=False,
340    help="Read task description from stdin.",
341)
342@_todo_property_options
343@_interactive_option
344@pass_ctx
345@catch_errors
346def new(ctx, summary, list, todo_properties, read_description, interactive):
347    """
348    Create a new task with SUMMARY.
349    """
350
351    todo = Todo(new=True, list=list)
352
353    default_due = ctx.config["default_due"]
354    if default_due:
355        todo.due = todo.created_at + timedelta(hours=default_due)
356
357    default_priority = ctx.config["default_priority"]
358    if default_priority is not None:
359        todo.priority = default_priority
360
361    for key, value in todo_properties.items():
362        if value:
363            setattr(todo, key, value)
364    todo.summary = " ".join(summary)
365
366    if read_description:
367        todo.description = "\n".join(sys.stdin)
368
369    if interactive or (not summary and interactive is None):
370        ui = TodoEditor(todo, ctx.db.lists(), ctx.ui_formatter)
371        ui.edit()
372        click.echo()  # work around lines going missing after urwid
373
374    if not todo.summary:
375        raise click.UsageError("No SUMMARY specified")
376
377    ctx.db.save(todo)
378    click.echo(ctx.formatter.detailed(todo))
379
380
381@cli.command()
382@pass_ctx
383@click.option(
384    "--raw",
385    is_flag=True,
386    help=(
387        "Open the raw file for editing in $EDITOR.\n"
388        "Only use this if you REALLY know what you're doing!"
389    ),
390)
391@_todo_property_options
392@_interactive_option
393@with_id_arg
394@catch_errors
395def edit(ctx, id, todo_properties, interactive, raw):
396    """
397    Edit the task with id ID.
398    """
399    todo = ctx.db.todo(id)
400    if raw:
401        click.edit(filename=todo.path)
402        return
403    old_list = todo.list
404
405    changes = False
406    for key, value in todo_properties.items():
407        if value is not None:
408            changes = True
409            setattr(todo, key, value)
410
411    if interactive or (not changes and interactive is None):
412        ui = TodoEditor(todo, ctx.db.lists(), ctx.ui_formatter)
413        ui.edit()
414
415    # This little dance avoids duplicates when changing the list:
416    new_list = todo.list
417    todo.list = old_list
418    ctx.db.save(todo)
419    if old_list != new_list:
420        ctx.db.move(todo, new_list=new_list, from_list=old_list)
421    click.echo(ctx.formatter.detailed(todo))
422
423
424@cli.command()
425@pass_ctx
426@with_id_arg
427@catch_errors
428def show(ctx, id):
429    """
430    Show details about a task.
431    """
432    todo = ctx.db.todo(id, read_only=True)
433    click.echo(ctx.formatter.detailed(todo))
434
435
436@cli.command()
437@pass_ctx
438@click.argument(
439    "todos",
440    nargs=-1,
441    required=True,
442    type=click.IntRange(0),
443    callback=_validate_todos,
444)
445@catch_errors
446def done(ctx, todos):
447    """Mark one or more tasks as done."""
448    for todo in todos:
449        todo.complete()
450        ctx.db.save(todo)
451        click.echo(ctx.formatter.detailed(todo))
452
453
454@cli.command()
455@pass_ctx
456@click.argument(
457    "todos",
458    nargs=-1,
459    required=True,
460    type=click.IntRange(0),
461    callback=_validate_todos,
462)
463@catch_errors
464def cancel(ctx, todos):
465    """Cancel one or more tasks."""
466    for todo in todos:
467        todo.cancel()
468        ctx.db.save(todo)
469        click.echo(ctx.formatter.detailed(todo))
470
471
472@cli.command()
473@pass_ctx
474@click.confirmation_option(prompt="Are you sure you want to delete all done tasks?")
475@catch_errors
476def flush(ctx):
477    """
478    Delete done tasks. This will also clear the cache to reset task IDs.
479    """
480    database = ctx.db
481    for todo in database.flush():
482        click.echo(ctx.formatter.simple_action("Flushing", todo))
483
484
485@cli.command()
486@pass_ctx
487@click.argument("ids", nargs=-1, required=True, type=click.IntRange(0))
488@click.option("--yes", is_flag=True, default=False)
489@catch_errors
490def delete(ctx, ids, yes):
491    """
492    Delete tasks.
493
494    Permanently deletes one or more task. It is recommended that you use the
495    `cancel` command if you wish to remove this from the pending list, but keep
496    the actual task around.
497    """
498
499    todos = []
500    for i in ids:
501        todo = ctx.db.todo(i)
502        click.echo(ctx.formatter.compact(todo))
503        todos.append(todo)
504
505    if not yes:
506        click.confirm("Do you want to delete those tasks?", abort=True)
507
508    for todo in todos:
509        click.echo(ctx.formatter.simple_action("Deleting", todo))
510        ctx.db.delete(todo)
511
512
513@cli.command()
514@pass_ctx
515@click.option(
516    "--list", "-l", callback=_validate_list_param, help="The list to copy the tasks to."
517)
518@click.argument("ids", nargs=-1, required=True, type=click.IntRange(0))
519@catch_errors
520def copy(ctx, list, ids):
521    """Copy tasks to another list."""
522
523    for id in ids:
524        original = ctx.db.todo(id)
525        todo = original.clone()
526        todo.list = list
527        click.echo(ctx.formatter.compact(todo))
528        ctx.db.save(todo)
529
530
531@cli.command()
532@pass_ctx
533@click.option(
534    "--list", "-l", callback=_validate_list_param, help="The list to move the tasks to."
535)
536@click.argument("ids", nargs=-1, required=True, type=click.IntRange(0))
537@catch_errors
538def move(ctx, list, ids):
539    """Move tasks to another list."""
540
541    for id in ids:
542        todo = ctx.db.todo(id)
543        click.echo(ctx.formatter.compact(todo))
544        ctx.db.move(todo, new_list=list, from_list=todo.list)
545
546
547@cli.command()
548@pass_ctx
549@click.argument("lists", nargs=-1, callback=_validate_lists_param)
550@click.option("--location", help="Only show tasks with location containg TEXT")
551@click.option("--category", help="Only show tasks with category containg TEXT")
552@click.option("--grep", help="Only show tasks with message containg TEXT")
553@click.option(
554    "--sort",
555    help=(
556        "Sort tasks using fields like : "
557        '"start", "due", "priority", "created_at", "percent_complete" etc.'
558        "\nFor all fields please refer to: "
559        "<https://todoman.readthedocs.io/en/stable/usage.html> "
560    ),
561    callback=_sort_callback,
562)
563@click.option(
564    "--reverse/--no-reverse",
565    default=True,
566    help="Sort tasks in reverse order (see --sort). Defaults to true.",
567)
568@click.option(
569    "--due", default=None, help="Only show tasks due in INTEGER hours", type=int
570)
571@click.option(
572    "--priority",
573    default=None,
574    help=(
575        "Only show tasks with priority at least as high as TEXT (low, medium or high)."
576    ),
577    type=str,
578    callback=_validate_priority_param,
579)
580@click.option(
581    "--start",
582    default=None,
583    callback=_validate_start_date_param,
584    nargs=2,
585    help="Only shows tasks before/after given DATE",
586)
587@click.option(
588    "--startable",
589    default=None,
590    is_flag=True,
591    callback=_validate_startable_param,
592    help=(
593        "Show only todos which "
594        "should can be started today (i.e.: start time is not in the "
595        "future)."
596    ),
597)
598@click.option(
599    "--status",
600    "-s",
601    default="NEEDS-ACTION,IN-PROCESS",
602    callback=validate_status,
603    help=(
604        "Show only todos with the "
605        "provided comma-separated statuses. Valid statuses are "
606        '"NEEDS-ACTION", "CANCELLED", "COMPLETED", "IN-PROCESS" or "ANY"'
607    ),
608)
609@catch_errors
610def list(ctx, *args, **kwargs):
611    """
612    List tasks (default). Filters any completed or cancelled tasks by default.
613
614    If no arguments are provided, all lists will be displayed, and only
615    incomplete tasks are show. Otherwise, only todos for the specified list
616    will be displayed.
617
618    eg:
619      \b
620      - `todo list' shows all unfinished tasks from all lists.
621      - `todo list work' shows all unfinished tasks from the list `work`.
622
623    This is the default action when running `todo'.
624
625    The following commands can further filter shown todos, or include those
626    omited by default:
627    """
628    hide_list = (len([_ for _ in ctx.db.lists()]) == 1) or (  # noqa: C416
629        len(kwargs["lists"]) == 1
630    )
631
632    todos = ctx.db.todos(**kwargs)
633    click.echo(ctx.formatter.compact_multiple(todos, hide_list))
634