diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..08090e9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,22 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Place notes into `notes` subdirectory of taskwarrior data directory by default +- Only annotate tasks for which a note has actually been created + +### Changed + +- Default to same paths as taskwarrior defaults (e.g. `~/.taskrc` and `~/.task/`) +- Look for taskrc file both in xdg location and in home directory + +### Fixed + +- Create any necessary parent directories for notes directory. diff --git a/README.md b/README.md index e8203f9..4956bff 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,7 @@ -# Topen - simple taskwarrior note editing +# Simple taskwarrior note management + +[Docs](https://marty-oehme.github.io/topen) +[Pypi](https://pypi.org/project/topen) A script without bells and whistles. Focuses on letting you quickly: @@ -8,19 +11,12 @@ Focuses on letting you quickly: It does both by simply being invoked with `topen `. -Provide a taskwarrior task id or uuid and topen creates a new note file or lets +Provide a taskwarrior task id or uuid and `topen` creates a new note file or lets you edit an existing one. Additionally it adds a small annotation to the task to let you see that there exists a note file next time you view the task. Should just work as-is without additional configuration in most modern taskwarrior setups.[^moderntw] -[^moderntw]: The script assumes your taskwarrior setup follows the XDG base directory suggestions. That means, -taskrc in `$XDG_CONFIG_HOME/task/taskrc`, usually `~/.config/task/taskrc`. Furthermore, at the moment it -assumes the taskwarrior _data_ residing in the `$XDG_DATA_HOME/task` directory. This will diverge from -many taskwarrior setups still and can be set through the cli option `--task-data`. The idea is for future -`topen` versions to recognize the task data directory from the taskrc file itself but this has not been -implemented. - Can be configured through environment variables or cli options, see below. Can be used as-is with the `topen` command or directly from taskwarrior by being aliased in your `taskrc`: @@ -39,42 +35,83 @@ That's all there is to it. You can install the script with your favorite python environment manager: ```bash -uv tool install git+https://git.martyoeh.me/Marty/topen.git +uv tool install topen ``` ```bash -pipx install git+https://git.martyoeh.me/Marty/topen.git +pipx install topen ``` ```bash -pip install git+https://git.martyoeh.me/Marty/topen.git +pip install topen ``` Or just manually copy the `topen` file to a directory in your PATH. +[tasklib](https://github.com/GothenburgBitFactory/tasklib) is the only dependency aside from the python standard library. If you just want to try the script out, feel free to do so by invoking it e.g. with `uvx git+https://git.martyoeh.me/Marty/topen.git`. -Only has [tasklib](https://github.com/GothenburgBitFactory/tasklib) as a dependency. +If you want to install the trunk version instead of a versioned release simply substitute for the git path: + +```bash +uv tool install git+https://git.martyoeh.me/Marty/topen.git +``` ## Configuration -```python -TASK_RC = os.getenv("TASKRC", "~/.config/task/taskrc") # not implemented yet -TASK_DATA_DIR = os.getenv("TASKDATA", "~/.local/share/task") -TOPEN_DIR = os.getenv("TOPEN_DIR", "~/.local/share/task/notes") -TOPEN_EXT = os.getenv("TOPEN_EXT", "md") -TOPEN_ANNOT = os.getenv("TOPEN_ANNOT", "Note") -TOPEN_EDITOR = os.getenv("EDITOR") or os.getenv("VISUAL", "nano") -TOPEN_QUIET = os.getenv("TOPEN_QUIET", False) +Most taskwarrior setups should not need much further configuration and just work out of the box. +However, if you want to diverge from the defaults explained here, +use the below settings to configure everything to your preferences. + +It looks for a taskrc file in the user's home directory (`~/.taskrc`) or the XDG base config directory (usually `~/.config/task/taskrc`). +The data directory also follows the taskwarrior defaults (`~/.task`) or is read from the taskrc `data.location` option. + +The notes directory defaults to be in the `notes` subdirectory of where-ever your taskwarrior data location is, +but can be set to anywhere else independently as well. + +This program can be configured in 3 different ways: options set in your regular taskwarrior `taskrc` file, +environment variables or options given on the command line. + +CLI options override environment variables, which in turn override configuration set in the `taskrc` file. + +### Taskrc configuration + +All options can be changed directly in your taskrc file. +This may be most useful for settings which do not change often for you, +such as the note extension or notes directory. + +The following settings are supported: + +```ini +data.location # used for the taskwarrior data directory +notes.dir # set the notes directory itself +notes.ext # set the note file extension +notes.annot # set the annotation added to tasks with notes +notes.editor # set the editor used to open notes +notes.quiet # set topen to hide all verbose information during use ``` -These are all environment variables offered, needs improved documentation. - +### Environment variables -Ultimately the goal would probably be to support reading from a taskwarrior 'taskrc' file, -which can then be optionally overwritten with env variables, -which can then be optionally overwritten with cli options. +Each option can be changed through setting the corresponding environment variable. -This is not fully implemented -- we support the above environment variables -and cli options, that's it. +These are the same as the `taskrc` file options with a prepended `TOPEN_` and dots turned to underscores. + +The following settings are supported: + +```bash +TASKRC= # taskwarrior config file location +TASKDATA= # taskwarrior data directory location +TOPEN_NOTES_DIR= # set the notes directory itself +TOPEN_NOTES_EXT= # set the note file extension +TOPEN_NOTES_ANNOT= # set the annotation added to tasks with notes +TOPEN_NOTES_EDITOR= notes.editor # set the editor used to open notes +TOPEN_NOTES_QUIET= # set topen to hide all verbose information during use +``` + +### CLI options + +Finally, each option can be set through the cli itself. + +To find out all the available options use `topen --help`. diff --git a/topen.py b/topen.py index 3e5fad1..274b84f 100755 --- a/topen.py +++ b/topen.py @@ -21,21 +21,13 @@ import configparser import os import subprocess import sys -from dataclasses import dataclass +from collections import namedtuple +from dataclasses import asdict, dataclass, field from pathlib import Path +from typing import Any, Self, cast from tasklib import Task, TaskWarrior -DEFAULTS_DICT = { - "task.rc": "~/.config/task/taskrc", - "task.data": "~/.local/share/task", - "notes.dir": "~/.local/share/task/notes", - "notes.ext": "md", - "notes.annot": "Note", - "notes.editor": os.getenv("EDITOR") or os.getenv("VISUAL") or "nano", - "notes.quiet": "False", -} - def main(): """Runs the cli interface. @@ -50,10 +42,10 @@ def main(): If the task does not yet have a note annotation it also adds it automatically. """ - opts_override = {"task.rc": DEFAULTS_DICT["task.rc"]} | parse_env() | parse_cli() - conf_file = _real_path(opts_override["task.rc"]) + opts_override = {"task_rc": TConf(0).task_rc} | parse_env() | parse_cli() + conf_file = _real_path(opts_override["task_rc"]) opts: dict = parse_conf(conf_file) | opts_override - cfg = conf_from_dict(opts) + cfg = TConf.from_dict(opts) if not cfg.task_id: _ = sys.stderr.write("Please provide task ID as argument.\n") @@ -66,11 +58,15 @@ def main(): if not uuid: _ = sys.stderr.write(f"Could not find task for ID: {cfg.task_id}.") sys.exit(1) - fname = get_notes_file(uuid, notes_dir=cfg.notes_dir, notes_ext=cfg.notes_ext) - open_editor(fname, editor=cfg.notes_editor) + fpath = get_notes_file(uuid, notes_dir=cfg.notes_dir, notes_ext=cfg.notes_ext) - add_annotation_if_missing(task, annotation_content=cfg.notes_annot) + if not fpath.parent.exists(): + fpath.parent.mkdir(parents=True, exist_ok=True) + open_editor(fpath, editor=cfg.notes_editor) + + if fpath.exists(): + add_annotation_if_missing(task, annotation_content=cfg.notes_annot) def get_task(id: str | int, data_location: Path) -> Task: @@ -120,41 +116,70 @@ class TConf: Contains all the configuration options that can affect Topen note creation. """ - task_rc: Path - """The path to the taskwarrior taskrc file.""" - task_data: Path - """The path to the taskwarrior data directory.""" task_id: int """The id (or uuid) of the task to edit a note for.""" + task_rc: Path + _task_rc: Path | None = field(init=False, repr=False, default=None) + """The path to the taskwarrior taskrc file. Can be absolute or relative to cwd.""" + + @property + def task_rc(self) -> Path: + if self._task_rc: + return self._task_rc + elif _real_path("~/.taskrc").exists(): + return _real_path("~/.taskrc") + elif _real_path("$XDG_CONFIG_HOME/task/taskrc").exists(): + return _real_path("$XDG_CONFIG_HOME/task/taskrc") + else: + return _real_path("~/.config/task/taskrc") + + @task_rc.setter + def task_rc(self, value: Path | property | None): + if type(value) is property: + value = TConf._notes_dir + self._task_rc = cast(Path, value) + + task_data: Path = Path("~/.task") + """The path to the taskwarrior data directory. Can be absolute or relative to cwd.""" notes_dir: Path """The path to the notes directory.""" - notes_ext: str + _notes_dir: Path | None = field(init=False, repr=False, default=None) + + @property + def notes_dir(self) -> Path: + return self._notes_dir if self._notes_dir else self.task_data.joinpath("notes") + + @notes_dir.setter + def notes_dir(self, value: Path | property | None): + if type(value) is property: + value = TConf._notes_dir + self._notes_dir = cast(Path, value) + + notes_ext: str = "md" """The extension of note files.""" - notes_annot: str + notes_annot: str = "Note" """The annotation to add to taskwarrior tasks with notes.""" - notes_editor: str + notes_editor: str = os.getenv("EDITOR") or os.getenv("VISUAL") or "nano" """The editor to open note files with.""" - notes_quiet: bool + notes_quiet: bool = False """If set topen will give no feedback on note editing.""" + def __post_init__(self): + self.task_rc = _real_path(self.task_rc) + self.task_data = _real_path(self.task_data) + self.notes_dir = _real_path(self.notes_dir) -def conf_from_dict(d: dict) -> TConf: - """Generate a TConf class from a dictionary. + def __or__(self, other: Any, /) -> Self: + return self.__class__(**asdict(self) | asdict(other)) - Turns a dictionary containing all the necessary entries into a TConf configuration file. - Will error if one any of the entries are missing. - """ - return TConf( - task_rc=_real_path(d["task.rc"]), - task_data=_real_path(d["task.data"]), - task_id=d["task.id"], - notes_dir=_real_path(d["notes.dir"]), - notes_ext=d["notes.ext"], - notes_annot=d["notes.annot"], - notes_editor=d["notes.editor"], - notes_quiet=d["notes.quiet"], - ) + @classmethod + def from_dict(cls, d: dict) -> Self: + """Generate a TConf class from a dictionary. + + Turns a dictionary containing all the necessary entries into a TConf configuration file. + """ + return cls(**d) def parse_cli() -> dict: @@ -199,14 +224,14 @@ you view the task. p = parser.parse_args() return _filtered_dict( { - "task.id": p.id, - "task.rc": p.task_rc, - "task.data": p.task_data, - "notes.dir": p.notes_dir, - "notes.ext": p.extension, - "notes.annot": p.annotation, - "notes.editor": p.editor, - "notes.quiet": p.quiet, + "task_id": p.id, + "task_rc": p.task_rc, + "task_data": p.task_data, + "notes_dir": p.notes_dir, + "notes_ext": p.extension, + "notes_annot": p.annotation, + "notes_editor": p.editor, + "notes_quiet": p.quiet, } ) @@ -218,13 +243,13 @@ def parse_env() -> dict: """ return _filtered_dict( { - "task.rc": os.getenv("TASKRC"), - "task.data": os.getenv("TASKDATA"), - "notes.dir": os.getenv("TOPEN_NOTES_DIR"), - "notes.ext": os.getenv("TOPEN_NOTES_EXT"), - "notes.annot": os.getenv("TOPEN_NOTES_ANNOT"), - "notes.editor": os.getenv("TOPEN_NOTES_EDITOR"), - "notes.quiet": os.getenv("TOPEN_NOTES_QUIET"), + "task_rc": os.getenv("TASKRC"), + "task_data": os.getenv("TASKDATA"), + "notes_dir": os.getenv("TOPEN_NOTES_DIR"), + "notes_ext": os.getenv("TOPEN_NOTES_EXT"), + "notes_annot": os.getenv("TOPEN_NOTES_ANNOT"), + "notes_editor": os.getenv("TOPEN_NOTES_EDITOR"), + "notes_quiet": os.getenv("TOPEN_NOTES_QUIET"), } ) @@ -235,20 +260,23 @@ def parse_conf(conf_file: Path) -> dict: Returns them as a simple dict object. Uses dot.annotation for options just like taskwarrior settings. """ - c = configparser.ConfigParser( - defaults=DEFAULTS_DICT, allow_unnamed_section=True, allow_no_value=True - ) + c = configparser.ConfigParser(allow_unnamed_section=True, allow_no_value=True) with open(conf_file.expanduser()) as f: - c.read_string("[DEFAULT]\n" + f.read()) + c.read_string("[GENERAL]\n" + f.read()) + ConfTrans = namedtuple("ParsedToTConf", ["name", "tconf_name"]) return _filtered_dict( { - "task.data": c.get("DEFAULT", "data.location"), - "notes.dir": c.get("DEFAULT", "notes.dir"), - "notes.ext": c.get("DEFAULT", "notes.ext"), - "notes.annot": c.get("DEFAULT", "notes.annot"), - "notes.editor": c.get("DEFAULT", "notes.editor"), - "notes.quiet": c.get("DEFAULT", "notes.quiet"), + opt.tconf_name: c.get("GENERAL", opt.name) + for opt in [ + ConfTrans("data.location", "task_data"), + ConfTrans("notes.dir", "notes_dir"), + ConfTrans("notes.ext", "notes_ext"), + ConfTrans("notes.annot", "notes_annot"), + ConfTrans("notes.editor", "notes_editor"), + ConfTrans("notes.quiet", "notes_quiet"), + ] + if c.has_option("GENERAL", opt.name) } )