diff --git a/README.md b/README.md index 394a2de..2ab2998 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,4 @@ -## calcurse-load - -Personal hooks/scripts for calcurse. This integrates [`calcurse`](https://github.com/lfos/calcurse) with Google Calendar, and [`todo.txt`](http://todotxt.org/). +Hooks/scripts for loading data data for calcurse. This integrates [`calcurse`](https://github.com/lfos/calcurse) with Google Calendar, and [`todo.txt`](http://todotxt.org/). - pre-load: - Looks at the locally indexed Google Calendar JSON dump, adds events as `calcurse` appointments; adds summary/HTML links as appointment notes. @@ -35,7 +33,11 @@ alias calcurse='calcurse --datadir "$CALCURSE_DIR" --confdir ~/.config/calcurse In addition to that, this maintains a data directory in `$XDG_DATA_HOME/calcurse_load`, where it stores data for `gcal_index`. ---- +## About + +If you wanted to disable one of the `todotxt` or `gcal` extensions, you could remove or rename the corresponding scripts in the `hooks` directory. + +## gcal pre-load The `gcal` calcurse hook tries to read any `gcal_index`-created JSON files in the `$XDG_DATA_HOME/calcurse_load/gcal/` directory. If there's description/extra information for events from Google Calendar, this attaches corresponding notes to each calcurse event. Specifically, it: @@ -44,19 +46,7 @@ The `gcal` calcurse hook tries to read any `gcal_index`-created JSON files in th - Generates Google Calendar events from the JSON - Adds the newly created events and writes back to the appointments file. ---- - -The `post-save` `todotxt` hook converts the `calcurse` todos back to `todotxt` todos, and updates the `todotxt` file if any todos were added. A `todo.txt` is searched for in one of the common locations: - -- `$TODOTXT_FILE` -- `$TODO_DIR/todo.txt` -- `$XDG_CONFIG/todo/todo.txt` -- `~/.config/todo/todo.txt` -- `~/.todo/todo.txt` - -If you wanted to disable one of the `todotxt` or `gcal` extension, you could remove or rename the corresponding scripts in the `hooks` directory. - -### Google Calendar Update Process +### gcal update example `gcal_index` saves an index of Google Calendar events for a Google Account locally as a JSON file. @@ -88,6 +78,16 @@ Prints the JSON dump to STDOUT; example: For an example script one might put under cron, see [`example_update_google_cal`](./example_update_google_cal) +## todotxt + +The `pre-load`/`post-save` `todotxt` hook converts the `calcurse` todos back to `todotxt` todos, and updates the `todotxt` file if any todos were added. A `todo.txt` is searched for in one of the common locations: + +- `$TODOTXT_FILE` +- `$TODO_DIR/todo.txt` +- `$XDG_CONFIG/todo/todo.txt` +- `~/.config/todo/todo.txt` +- `~/.todo/todo.txt` + ### Todo.txt Priority Conversion | Todo.txt | Calcurse | @@ -116,4 +116,40 @@ required arguments: --post-save Execute the postsave action for the extension ``` -If you want to use this for other purposes; I defined a `Extension` base class in `calcurse_load.ext.abstract`, you'd just have to add a subclass in a file there, and then add it to the dictionary in `calcurse_load.ext` +If you want to use this for other purposes; there is a `Extension` base class in `calcurse_load.ext.abstract`. + +To load a custom extension, you can point this at the fully +qualified name of the extension class. For example, if you have a module +called `my_custom_calcurse` installed into your python environment, and +inside that module you have a class called `MyCustomExtension`, you can +load that extension by passing `my_custom_calcurse.MyCustomExtension` to +the `--pre-load` or `--post-save` options. + +For example to use it with the gcal extension, you could provide the fully qualified path: + +``` +python3 -m calcurse_load --pre-load calcurse_load.ext.gcal.gcal_ext +``` + +This should also work with relative paths, so for example you could put that extension in a `myextension.py` file in your calcurse configuration directory: + +``` +. +├── gcal.enabled +├── myextension.py +├── post-save +├── pre-load +└── todotxt.enabled + +1 directory, 5 files +``` + +Then, at the top of your `pre-load`/`post-save`, just be sure to change the directory to the current one, like: + +``` +#!/bin/sh + +cd "$(dirname "$0")" || exit 1 +``` + +And then you could define your `CustomExtension` in `myextension.py`, and use it like `python3 -m calcurse_load --pre-load myextension.CustomExtension` diff --git a/calcurse_load/__main__.py b/calcurse_load/__main__.py index b1a48cd..1cc81ad 100644 --- a/calcurse_load/__main__.py +++ b/calcurse_load/__main__.py @@ -1,64 +1,65 @@ -import sys -import argparse +from typing import Sequence +from functools import lru_cache -from .ext import EXTENSIONS +import click + +from .ext.all import EXTENSION_NAMES, get_extension +from .ext.abstract import Extension from .calcurse import get_configuration -from typing import Tuple, List, Callable +CHOICES = list(EXTENSION_NAMES) +CHOICES.append("custom.module.name.Extension") + +config = get_configuration() + + +@lru_cache(maxsize=None) +def _load_extension(name: str) -> Extension: + if name in CHOICES: + return get_extension(name)(config=config) + else: + import importlib -def parse_args() -> Tuple[argparse.Namespace, List[str]]: - parser = argparse.ArgumentParser( - description="Load extra data into calcurse", - # manually write out usage - usage="calcurse_load (--pre-load|--post-save) ({})...".format( - "|".join(EXTENSIONS.keys()) - ), - ) - required_args = parser.add_argument_group("required options") - hook_choice = required_args.add_mutually_exclusive_group(required=True) - hook_choice.add_argument( - "--pre-load", - help="Execute the preload action for the extension", - action="store_true", - default=False, - ) - hook_choice.add_argument( - "--post-save", - help="Execute the postsave action for the extension", - action="store_true", - default=False, - ) - extensions = [] - args, extra = parser.parse_known_args() - for arg in map(str.lower, extra): - if arg not in EXTENSIONS: - print( - f"Unexpected argument: {arg}, currently loaded extensions: '{','.join(EXTENSIONS)}'" - ) - else: - extensions.append(arg) - return args, extensions + module_name, class_name = name.rsplit(".", 1) + module = importlib.import_module(module_name) + extclass = getattr(module, class_name) + assert issubclass(extclass, Extension) + ext = extclass(config=config) + assert isinstance(ext, Extension) + return ext -def cli() -> None: - args, extensions = parse_args() - configuration = get_configuration() - if len(extensions) == 0: - print( - "No extensions passed!\nPossible extensions:\n{}".format( - "\n".join(EXTENSIONS) - ), - file=sys.stderr, - ) - sys.exit(1) - for ext in extensions: - ext_class: Callable = EXTENSIONS[ext] # type: ignore[type-arg] - load_hook = ext_class(config=configuration) - if args.pre_load: - load_hook.pre_load() - else: - load_hook.post_save() +@click.command() +@click.option( + "--pre-load", + help="Execute the preload action for the extension", + metavar="|".join(CHOICES), + multiple=True, + type=click.UNPROCESSED, + callback=lambda ctx, param, value: [_load_extension(v) for v in value], +) +@click.option( + "--post-save", + help="Execute the postsave action for the extension", + metavar="|".join(CHOICES), + multiple=True, + type=click.UNPROCESSED, + callback=lambda ctx, param, value: [_load_extension(v) for v in value], +) +def cli( + pre_load: Sequence[Extension], + post_save: Sequence[Extension], +) -> None: + """ + A CLI for loading data for calcurse + """ + for ext in pre_load: + click.echo(f"Loading {ext}") + ext.pre_load() + for ext in post_save: + click.echo(f"Saving {ext}") + ext.post_save() if __name__ == "__main__": diff --git a/calcurse_load/calcurse.py b/calcurse_load/calcurse.py index 127221a..10c18aa 100644 --- a/calcurse_load/calcurse.py +++ b/calcurse_load/calcurse.py @@ -31,18 +31,3 @@ def get_configuration() -> Configuration: calcurse_dir=calcurse_dir, calcurse_load_dir=calcurse_load_dir, ) - - -# so that eval "$(calcurse_load --shell)" can be called, -# to get the config values from the hooks -def eval_shell_configuration() -> str: - conf = get_configuration() - return "\n".join( - [ - f'{envvar}="{val}"' - for envvar, val in zip( - ("CALCURSE_DIR", "CALCURSE_LOAD_DIR"), - (conf.calcurse_dir, conf.calcurse_load_dir), - ) - ] - ) diff --git a/calcurse_load/ext/__init__.py b/calcurse_load/ext/__init__.py index f61d5d8..e69de29 100644 --- a/calcurse_load/ext/__init__.py +++ b/calcurse_load/ext/__init__.py @@ -1,4 +0,0 @@ -from .gcal import gcal_ext -from .todotxt import todotxt_ext - -EXTENSIONS = {"gcal": gcal_ext, "todotxt": todotxt_ext} diff --git a/calcurse_load/ext/abstract.py b/calcurse_load/ext/abstract.py index 0731eda..ff5178e 100644 --- a/calcurse_load/ext/abstract.py +++ b/calcurse_load/ext/abstract.py @@ -1,9 +1,18 @@ +import typing from abc import ABC, abstractmethod +if typing.TYPE_CHECKING: + from ..calcurse import Configuration + class Extension(ABC): - def __init__(self, config) -> None: # type: ignore[no-untyped-def] - self.config = config + def __init__(self, config: "Configuration") -> None: # type: ignore[no-untyped-def] + self.config: Configuration = config + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.config})" + + __str__ = __repr__ @abstractmethod def pre_load(self) -> None: diff --git a/calcurse_load/ext/all.py b/calcurse_load/ext/all.py new file mode 100644 index 0000000..ef641c9 --- /dev/null +++ b/calcurse_load/ext/all.py @@ -0,0 +1,17 @@ +from typing import Type +from .abstract import Extension + +EXTENSION_NAMES = {"gcal", "todotxt"} + + +def get_extension(name: str) -> Type[Extension]: + if name == "gcal": + from .gcal import gcal_ext + + return gcal_ext + elif name == "todotxt": + from .todotxt import todotxt_ext + + return todotxt_ext + else: + raise ValueError(f"Unknown extension: {name}") diff --git a/setup.cfg b/setup.cfg index 1ffd8a5..573c663 100644 --- a/setup.cfg +++ b/setup.cfg @@ -39,6 +39,7 @@ exclude = tests* include = calcurse_load + calcurse_load.* gcal_index [options.extras_require]