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