diff --git a/.gitignore b/.gitignore index 5fddb19..b39f770 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,8 @@ __version__.py +__pycache__/ +*.py[cod] +*.egg-info/ +src/letsdo.egg-info build/ dist/ -src/letsdo.egg-info +.eggs/ diff --git a/README.md b/README.md index c94cd3f..b92bf76 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,33 @@ color: true data_directory: /home/username/ ``` +An optional `hooks_directory` field can also be set: + +``` +color: true +data_directory: /home/username/ +hooks_directory: /home/username/.letsdo-hooks/ +``` + +## Hooks + +Letsdo supports hooks similarly to Taskwarrior. A hook is any executable script placed in the directory defined by `hooks_directory` in `.letsdo.yaml`. If `hooks_directory` is not set, the feature is silently ignored. + +Hook scripts are called with two arguments: +1. The event name: `start` when a task has just started, `stop` when a task has just stopped. +2. The full description of the task. + +Example hook script (`~/.letsdo-hooks/notify`): + +```sh +#!/bin/sh +event="$1" +task="$2" +notify-send "letsdo" "$event: $task" +``` + +All executable files in `hooks_directory` are called in alphabetical order on each event. + Let's see now the history: you can rapidly have a look at **today** and **yesterday** work done by typing: ``` diff --git a/src/configuration.py b/src/configuration.py index 8a1570b..8d1020e 100644 --- a/src/configuration.py +++ b/src/configuration.py @@ -27,6 +27,15 @@ def create_default_configuration(home="~"): return yaml.dump(default_config, f) +def get_hooks_directory(home="~"): + """Return the hooks directory path if configured, otherwise None""" + config = get_configuration(home) + hooks_dir = config.get("hooks_directory") + if hooks_dir: + return os.path.expanduser(hooks_dir) + return None + + def get_task_file_path(home="~"): """Return the running task data file path""" return os.path.join(get_configuration(home)["data_directory"], TASK_FILE_NAME) diff --git a/src/handlers.py b/src/handlers.py index 42652b8..bc4fc65 100644 --- a/src/handlers.py +++ b/src/handlers.py @@ -8,6 +8,7 @@ from datetime import datetime from app import Task, guess_task_id_from_string, work_on from configuration import autocomplete, create_default_configuration +from hooks import run_hooks from typing import Tuple @@ -36,6 +37,7 @@ def start_task_handler(description: str, start_str: str="") -> Tuple[bool, str]: task = Task.get_running() if task: + run_hooks("start", task.name) return True, f"{task.start_time}: {task.name} started" return False, "No task running" @@ -53,10 +55,13 @@ def edit_file_handler(filename) -> Tuple[bool, str]: def cancel_task_handler() -> Tuple[bool, str]: """handles a request to cancel the current task""" - msg = Task.cancel() - if not msg: + task = Task.get_running() + if not task: return False, "No task running, nothing to do" - return True, f"cancelled task: {msg}" + task_name = task.name + Task.cancel() + run_hooks("cancel", task_name) + return True, f"cancelled task: {task_name}" def stop_task_handler(stop_time: str) -> Tuple[bool, str]: @@ -65,11 +70,13 @@ def stop_task_handler(stop_time: str) -> Tuple[bool, str]: if not task: return False, "no task running, nothing to do" + task_name = task.name work_time = Task.stop(stop_time) if not work_time: return False, "error: could not get stop time" + run_hooks("stop", task_name) now = datetime.now().strftime("%H:%M") - msg = f"{now}: stopped task: {task.name}, after {work_time[0]}hours, {work_time[1]} minutes" + msg = f"{now}: stopped task: {task_name}, after {work_time[0]}hours, {work_time[1]} minutes" return True, msg diff --git a/src/hooks.py b/src/hooks.py new file mode 100644 index 0000000..763ef75 --- /dev/null +++ b/src/hooks.py @@ -0,0 +1,28 @@ +""" +Hook execution support. Hooks are scripts placed in the hooks_directory +configured in .letsdo.yaml. Each executable file in that directory is called +with two arguments: the event name ("start" or "stop") and the task description. +""" +import os +import subprocess +from log import LOGGER +from configuration import get_hooks_directory + + +def run_hooks(event: str, task_description: str) -> None: + """Run all executable hook scripts in the configured hooks directory.""" + hooks_dir = get_hooks_directory() + if not hooks_dir: + return + + if not os.path.isdir(hooks_dir): + LOGGER.warning(f"hooks_directory '{hooks_dir}' does not exist or is not a directory") + return + + for entry in sorted(os.listdir(hooks_dir)): + script_path = os.path.join(hooks_dir, entry) + if os.path.isfile(script_path) and os.access(script_path, os.X_OK): + try: + subprocess.run([script_path, event, task_description], check=False) + except Exception as exc: + LOGGER.warning(f"hook '{script_path}' failed: {exc}")