Previously, when parsing config options that were set to 'false', e.g. `TOPEN_NOTES_QUIET=false` in the env vars, we would still be setting it to true. This change fixes any falsy value to be correctly parsed as False.
382 lines
11 KiB
Python
Executable file
382 lines
11 KiB
Python
Executable file
#!/usr/bin/env python
|
|
"""
|
|
.. include:: ./README.md
|
|
|
|
# Usage as library
|
|
|
|
While normal operation is intended through the commandline to open or create
|
|
note files for taskwarrior tasks, the topen.py file can be used as a library to
|
|
open and edit taskwarrior notes programmatically.
|
|
|
|
You can make use of the open editor and utility functions to find and edit
|
|
notes, either filling in the required configuration manually or passing around
|
|
a TConf configuration object containing them all. If choosing the latter, you can
|
|
read the configuration in part from a `taskrc` file using the utility function
|
|
`parse_rc()`.
|
|
|
|
"""
|
|
|
|
import argparse
|
|
import configparser
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from dataclasses import asdict, dataclass
|
|
from pathlib import Path
|
|
from typing import Any, Callable, Self, cast
|
|
|
|
from tasklib import Task, TaskWarrior
|
|
|
|
NON_EXISTENT_PATH = Path("%%%%I_DONT_EXIST_%%%%")
|
|
|
|
|
|
def main(cfg: "TConf | None" = None, io: "_IO | None" = None) -> int:
|
|
"""Runs the cli interface.
|
|
|
|
First sets up the correct options, with overrides in the following order:
|
|
`defaults -> taskrc -> env vars -> cli opts`
|
|
with cli options having the highest priority.
|
|
|
|
Then uses those options to get the task corresponding to the task id passed
|
|
in as an argument, finds the matching notes file path and opens an editor
|
|
pointing to the file.
|
|
|
|
If the task does not yet have a note annotation it also adds it automatically.
|
|
|
|
Returns the status code as int, 0 for success, 1 for error.
|
|
"""
|
|
if not cfg:
|
|
cfg = build_config()
|
|
if not io:
|
|
io = _IO(quiet=cfg.notes_quiet)
|
|
|
|
if not cfg.task_id:
|
|
_ = io.err("Please provide task ID as argument.\n")
|
|
return 1
|
|
|
|
try:
|
|
task = get_task(id=cfg.task_id, data_location=cfg.task_data)
|
|
uuid = cast(str, task["uuid"])
|
|
except Task.DoesNotExist:
|
|
_ = io.err(f"Could not find task for ID: {cfg.task_id}.\n")
|
|
return 1
|
|
|
|
fpath = get_notes_file(uuid, notes_dir=cfg.notes_dir, notes_ext=cfg.notes_ext)
|
|
|
|
if not fpath.parent.exists():
|
|
fpath.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
io.out(f"Editing note: {fpath}")
|
|
open_editor(fpath, editor=cfg.notes_editor)
|
|
|
|
if fpath.exists():
|
|
if is_annotation_missing(task, annotation_content=cfg.notes_annot):
|
|
add_annotation(task, annotation_content=cfg.notes_annot)
|
|
io.out(f"Added annotation: {cfg.notes_annot}")
|
|
return 0
|
|
io.out("No note file, doing nothing.")
|
|
return 0
|
|
|
|
|
|
def get_task(id: str | int, data_location: Path) -> Task:
|
|
"""Finds a taskwarrior task from an id.
|
|
|
|
`id` can be either a taskwarrior id or uuid.
|
|
"""
|
|
tw = TaskWarrior(data_location)
|
|
try:
|
|
t = tw.tasks.get(id=id)
|
|
except Task.DoesNotExist:
|
|
t = tw.tasks.get(uuid=id)
|
|
|
|
return t
|
|
|
|
|
|
def get_notes_file(uuid: str, notes_dir: Path, notes_ext: str) -> Path:
|
|
"""Finds the notes file corresponding to a taskwarrior task."""
|
|
return Path(notes_dir).joinpath(f"{uuid}.{notes_ext}")
|
|
|
|
|
|
def open_editor(file: Path, editor: str) -> None:
|
|
"""Opens a file with the chosen editor."""
|
|
_ = subprocess.run(f"{editor} {file}", shell=True)
|
|
|
|
|
|
def is_annotation_missing(task: Task, annotation_content: str) -> bool:
|
|
"""Checks if the task is missing the annotation.
|
|
|
|
Only succeeds if the _complete_ annatotation is found,
|
|
and not just as a substring.
|
|
|
|
Returns True if annotation was added, otherwise False.
|
|
"""
|
|
for annot in task["annotations"] or []:
|
|
if annot["description"] == annotation_content:
|
|
return False
|
|
return True
|
|
|
|
|
|
def add_annotation(task: Task, annotation_content: str) -> None:
|
|
"""Adds an annotation to a task."""
|
|
task.add_annotation(annotation_content)
|
|
|
|
|
|
@dataclass()
|
|
class Opt:
|
|
"""Assembled metadata for a single configuration option."""
|
|
|
|
cli: tuple[str, ...] | None
|
|
env: str | None
|
|
rc: str | None
|
|
default: Any = None
|
|
metavar: str | None = None
|
|
cast: type | Callable = str
|
|
help_text: str = ""
|
|
flag: bool = False
|
|
|
|
|
|
def _real_path(p: Path | str) -> Path:
|
|
return Path(os.path.expandvars(p)).expanduser()
|
|
|
|
|
|
def _determine_default_task_rc() -> Path:
|
|
if _real_path("~/.taskrc").exists():
|
|
return _real_path("~/.taskrc")
|
|
if _real_path("$XDG_CONFIG_HOME/task/taskrc").exists():
|
|
return _real_path("$XDG_CONFIG_HOME/task/taskrc")
|
|
return _real_path("~/.config/task/taskrc")
|
|
|
|
|
|
def _strtobool(val: str) -> bool:
|
|
"""Convert a string representation of truth.
|
|
|
|
Coverts either to True or False, raising an error if it does not find a
|
|
valid value.
|
|
True values are 'y', 'yes', 't', 'true', 'on', and '1'; false values are 'n',
|
|
'no', 'f', 'false', 'off', and '0'.
|
|
Raises ValueError if 'val' is anything else.
|
|
"""
|
|
val = val.lower()
|
|
if val in ("y", "yes", "t", "true", "on", "1"):
|
|
return True
|
|
elif val in ("n", "no", "f", "false", "off", "0"):
|
|
return False
|
|
else:
|
|
raise ValueError(f"Invalid boolean value {val}")
|
|
|
|
|
|
OPTIONS: dict[str, Opt] = {
|
|
"task_id": Opt(None, None, None, default=None),
|
|
"task_rc": Opt(
|
|
("--task-rc",),
|
|
"TASKRC",
|
|
None, # taskrc has no key for this
|
|
default=_determine_default_task_rc(),
|
|
metavar="FILE",
|
|
cast=Path,
|
|
help_text="Location of taskwarrior config file",
|
|
),
|
|
"task_data": Opt(
|
|
("--task-data",),
|
|
"TASKDATA",
|
|
"data.location",
|
|
default=Path("~/.task"),
|
|
metavar="DIR",
|
|
cast=Path,
|
|
help_text="Location of taskwarrior data directory",
|
|
),
|
|
"notes_dir": Opt(
|
|
("-d", "--notes-dir"),
|
|
"TOPEN_NOTES_DIR",
|
|
"notes.dir",
|
|
default=None, # resolved later in TConf.__post_init__
|
|
metavar="DIR",
|
|
cast=Path,
|
|
help_text="Location of topen notes files",
|
|
),
|
|
"notes_ext": Opt(
|
|
("--extension",),
|
|
"TOPEN_NOTES_EXT",
|
|
"notes.ext",
|
|
default="md",
|
|
metavar="EXT",
|
|
help_text="Extension of note files",
|
|
),
|
|
"notes_annot": Opt(
|
|
("--annotation",),
|
|
"TOPEN_NOTES_ANNOT",
|
|
"notes.annot",
|
|
default="Note",
|
|
metavar="NOTE",
|
|
help_text="Annotation content to set within taskwarrior",
|
|
),
|
|
"notes_editor": Opt(
|
|
("--editor",),
|
|
"TOPEN_NOTES_EDITOR",
|
|
"notes.editor",
|
|
default="nano",
|
|
metavar="CMD",
|
|
help_text="Program to open note files with",
|
|
),
|
|
"notes_quiet": Opt(
|
|
("--quiet",),
|
|
"TOPEN_NOTES_QUIET",
|
|
"notes.quiet",
|
|
default=False,
|
|
cast=_strtobool,
|
|
help_text="Silence any verbose displayed information",
|
|
),
|
|
}
|
|
|
|
|
|
@dataclass()
|
|
class TConf:
|
|
"""Topen Configuration
|
|
|
|
Contains all the configuration options that can affect Topen note creation.
|
|
"""
|
|
|
|
task_id: int
|
|
"""The id (or uuid) of the task to edit a note for."""
|
|
task_rc: Path = OPTIONS["task_rc"].default
|
|
"""The path to the taskwarrior taskrc file. Can be absolute or relative to cwd."""
|
|
|
|
task_data: Path = OPTIONS["task_data"].default
|
|
"""The path to the taskwarrior data directory. Can be absolute or relative to cwd."""
|
|
|
|
notes_dir: Path = NON_EXISTENT_PATH
|
|
"""The path to the notes directory."""
|
|
|
|
notes_ext: str = OPTIONS["notes_ext"].default
|
|
"""The extension of note files."""
|
|
notes_annot: str = OPTIONS["notes_annot"].default
|
|
"""The annotation to add to taskwarrior tasks with notes."""
|
|
notes_editor: str = "" # added in post-init
|
|
"""The editor to open note files with."""
|
|
notes_quiet: bool = OPTIONS["notes_quiet"].default
|
|
"""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)
|
|
if self.notes_dir == NON_EXISTENT_PATH:
|
|
self.notes_dir = self._default_notes_dir()
|
|
self.notes_dir = _real_path(self.notes_dir)
|
|
if not self.notes_editor:
|
|
self.notes_editor = (
|
|
os.getenv("EDITOR")
|
|
or os.getenv("VISUAL")
|
|
or OPTIONS["notes_editor"].default
|
|
)
|
|
|
|
def __or__(self, other: Any, /) -> Self:
|
|
return self.__class__(**asdict(self) | asdict(other))
|
|
|
|
def _default_notes_dir(self) -> Path:
|
|
return self.task_data.joinpath("notes")
|
|
|
|
@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 build_config() -> TConf:
|
|
"""Return final configuration object."""
|
|
defaults = {k: opt.default for k, opt in OPTIONS.items()}
|
|
env = parse_env()
|
|
cli = parse_cli()
|
|
|
|
rc_path = Path(
|
|
cli.get("task_rc") or env.get("task_rc") or OPTIONS["task_rc"].default
|
|
).expanduser()
|
|
defaults["task_rc"] = rc_path # use XDG-included paths
|
|
rc = parse_rc(rc_path) if rc_path.exists() else {}
|
|
|
|
merged = defaults | rc | env | cli # later wins
|
|
return TConf.from_dict({k: v for k, v in merged.items() if v is not None})
|
|
|
|
|
|
def parse_cli() -> dict:
|
|
"""Parse cli options and arguments.
|
|
|
|
Returns them as a simple dict object.
|
|
"""
|
|
parser = argparse.ArgumentParser(
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
description="Taskwarrior note editing made easy.",
|
|
epilog="""Provide a taskwarrior task id or uuid and topen creates a
|
|
new note file for 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.
|
|
""",
|
|
)
|
|
_ = parser.add_argument(
|
|
"id", help="The id/uuid of the taskwarrior task for which we edit notes"
|
|
)
|
|
for key, opt in OPTIONS.items():
|
|
if opt.cli is None:
|
|
continue
|
|
parser.add_argument(
|
|
*opt.cli,
|
|
dest=key,
|
|
metavar=opt.metavar,
|
|
help=opt.help_text,
|
|
type=opt.cast or str,
|
|
default=None,
|
|
)
|
|
args = parser.parse_args()
|
|
cli_vals = {k: v for k, v in vars(args).items() if v is not None}
|
|
cli_vals["task_id"] = cli_vals.pop("id")
|
|
return cli_vals
|
|
|
|
|
|
def parse_env() -> dict[str, Any]:
|
|
"""Parse environment variable options.
|
|
|
|
Returns them as a simple dict object.
|
|
"""
|
|
out: dict[str, Any] = {}
|
|
for key, opt in OPTIONS.items():
|
|
if opt.env and (val := os.getenv(opt.env)) is not None:
|
|
out[key] = opt.cast(val)
|
|
return out
|
|
|
|
|
|
def parse_rc(rc_path: Path) -> dict:
|
|
"""Parse taskrc configuration file options.
|
|
|
|
Returns them as a simple dict object.
|
|
Uses dot.annotation for options just like taskwarrior settings.
|
|
"""
|
|
cfg = configparser.ConfigParser(allow_unnamed_section=True, allow_no_value=True)
|
|
with rc_path.expanduser().open() as fr:
|
|
cfg.read_string("[GENERAL]\n" + fr.read())
|
|
|
|
out: dict[str, Any] = {}
|
|
for key, opt in OPTIONS.items():
|
|
if opt.rc and cfg.has_option("GENERAL", opt.rc):
|
|
raw = cfg.get("GENERAL", opt.rc)
|
|
out[key] = opt.cast(raw)
|
|
return out
|
|
|
|
|
|
class _IO:
|
|
def __init__(self, quiet: bool = False) -> None:
|
|
self.quiet = quiet
|
|
|
|
def out(self, text: str) -> None:
|
|
if not self.quiet:
|
|
print(text)
|
|
|
|
def err(self, text: str) -> None:
|
|
sys.stderr.write(text)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
exit = main()
|
|
sys.exit(exit)
|