Compare commits
11 commits
f4381da420
...
5e7d997e69
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e7d997e69 | |||
| e3db83df46 | |||
| ee4f3781f8 | |||
| 5976651a26 | |||
| 4642b24c6b | |||
| 9d5fa3e244 | |||
| caec33120c | |||
| 46d57042cd | |||
| 4ad9f4c981 | |||
| f3930ae4c7 | |||
| 4227465bfb |
3 changed files with 180 additions and 93 deletions
22
CHANGELOG.md
Normal file
22
CHANGELOG.md
Normal 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.
|
||||
93
README.md
93
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 <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
158
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)
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue