Compare commits

...

11 commits

Author SHA1 Message Date
5e7d997e69
doc: Start CHANGELOG
Some checks are pending
website / build (push) Waiting to run
website / deploy (push) Blocked by required conditions
2025-04-08 21:00:28 +02:00
e3db83df46
doc: Update README 2025-04-08 21:00:28 +02:00
ee4f3781f8
feat: Use xdg location or home dir taskrc if it exists
The preference structure goes highest to lowest:

1. ~/.taskrc
2. $XDG_CONFIG_HOME/task/taskrc
3. ~/.config/task/taskrc

Uses first file found.
2025-04-08 21:00:27 +02:00
5976651a26
feat: Create note parent directories if necessary 2025-04-08 21:00:26 +02:00
4642b24c6b
ref: Parse conf file with dict comprehension and named tuple 2025-04-08 21:00:26 +02:00
9d5fa3e244
feat: Only annotate tasks if note file was created
In the case of opening the notes but then backing out again without
actually writing a notes file we should also not annotate the task with
anything, since it technically still does not have a note.
2025-04-08 21:00:25 +02:00
caec33120c
feat: Locate notes dir in task data dir by default 2025-04-08 21:00:25 +02:00
46d57042cd
ref: Turn TConf from dict into class factory method
Code from here:

https://stackoverflow.com/questions/56849331/what-is-the-proper-way-in-python-to-define-a-dataclass-that-has-both-an-auto-gen
2025-04-08 21:00:24 +02:00
4ad9f4c981
ref: Move default opt handling into TConf object 2025-04-08 21:00:24 +02:00
f3930ae4c7
doc: Add configuration description 2025-04-08 21:00:23 +02:00
4227465bfb
fix: Use tw default dirs 2025-04-08 21:00:23 +02:00
3 changed files with 180 additions and 93 deletions

22
CHANGELOG.md Normal file
View file

@ -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.

View file

@ -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 <task-id>`.
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.
<!-- TODO: IMPROVE DOC -->
### 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`.

158
topen.py
View file

@ -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)
}
)