From a2b217fdfc2ed70cae4c4cf0ed9b5dbb7ed25091 Mon Sep 17 00:00:00 2001 From: Aaron Lichtman Date: Sat, 25 Mar 2023 11:47:00 -0700 Subject: [PATCH] Add ability to use template with `--template` (#1667) * Add ability to pass template path with --template Update jrnl/args.py * Fix tests --- docs/tips-and-tricks.md | 38 +++++---- jrnl/args.py | 7 +- jrnl/config.py | 23 +++++- jrnl/controller.py | 122 +++++++++++++++++++--------- jrnl/journals/Journal.py | 3 +- jrnl/messages/MsgText.py | 14 +++- jrnl/override.py | 2 +- tests/bdd/features/template.feature | 45 +++++++++- tests/lib/fixtures.py | 12 +++ tests/lib/given_steps.py | 13 +++ tests/lib/then_steps.py | 5 ++ tests/unit/test_parse_args.py | 1 + 12 files changed, 217 insertions(+), 68 deletions(-) diff --git a/docs/tips-and-tricks.md b/docs/tips-and-tricks.md index a03bb79b..30e93b71 100644 --- a/docs/tips-and-tricks.md +++ b/docs/tips-and-tricks.md @@ -74,39 +74,45 @@ jrnlimport () { } ``` -## Using templates +## Using Templates !!! note Templates require an [external editor](./advanced.md) be configured. -A template is a code snippet that makes it easier to use repeated text -each time a new journal entry is started. There are two ways you can utilize -templates in your entries. +Templates are text files that are used for creating structured journals. +There are three ways you can use templates: -### 1. Command line arguments +### 1. Use the `--template` command line argument and the default $XDG_DATA_HOME/jrnl/templates directory -If you had a `template.txt` file with the following contents: +`$XDG_DATA_HOME/jrnl/templates` is created by default to store your templates! Create a template (like `default.md`) in this directory and pass `--template FILE_IN_DIR`. ```sh +jrnl --template default.md +``` + +### 2. Use the `--template` command line argument with a local / absolute path + +You can create a template file with any text. Here is an example: + +```sh +# /tmp/template.txt My Personal Journal Title: Body: ``` -The `template.txt` file could be used to create a new entry with these -command line arguments: +Then, pass the absolute or relative path to the template file as an argument, and your external +editor will open and have your template pre-populated. ```sh -jrnl < template.txt # Imports template.txt as the most recent entry -jrnl -1 --edit # Opens the most recent entry in the editor +jrnl --template /tmp/template.md ``` -### 2. Include the template file in `jrnl.yaml` +### 3. Set a default template file in `jrnl.yaml` -A more efficient way to work with a template file is to declare the file -in your [config file](./reference-config-file.md) by changing the `template` -setting from `false` to the template file's path in double quotes: +If you want a template by default, change the value of `template` in the [config file](./reference-config-file.md) +from `false` to the template file's path, wrapped in double quotes: ```sh ... @@ -114,9 +120,6 @@ template: "/path/to/template.txt" ... ``` -Changes can be saved as you continue writing the journal entry and will be -logged as a new entry in the journal you specified in the original argument. - !!! tip To read your journal entry or to verify the entry saved, you can use this command: `jrnl -n 1` (Check out [Formats](./formats.md) for more options). @@ -219,4 +222,3 @@ To cause vi to jump to the end of the last line of the entry you edit, in your c ```yaml editor: vi + -c "call cursor('.',strwidth(getline('.')))" ``` - diff --git a/jrnl/args.py b/jrnl/args.py index b1055da3..5c0283c9 100644 --- a/jrnl/args.py +++ b/jrnl/args.py @@ -211,6 +211,11 @@ def parse_args(args: list[str] = []) -> argparse.Namespace: "Writing", textwrap.dedent(compose_msg).strip() ) composing.add_argument("text", metavar="", nargs="*") + composing.add_argument( + "--template", + dest="template", + help="Path to template file. Can be a local path, absolute path, or a path relative to $XDG_DATA_HOME/jrnl/templates/", + ) read_msg = ( "To find entries from your journal, use any combination of the below filters." @@ -419,7 +424,7 @@ def parse_args(args: list[str] = []) -> argparse.Namespace: default="", help=""" Overrides default (created when first installed) config file for this command only. - + Examples: \n \t - Use a work config file for this jrnl entry, call: \n \t jrnl --config-file /home/user1/work_config.yaml diff --git a/jrnl/config.py b/jrnl/config.py index bc10b86b..6cb4bdc3 100644 --- a/jrnl/config.py +++ b/jrnl/config.py @@ -4,6 +4,7 @@ import argparse import logging import os +from pathlib import Path from typing import Any from typing import Callable @@ -34,7 +35,6 @@ YAML_FILE_ENCODING = "utf-8" def make_yaml_valid_dict(input: list) -> dict: - """ Convert a two-element list of configuration key-value pair into a flat dict. @@ -73,9 +73,9 @@ def save_config(config: dict, alt_config_path: str | None = None) -> None: yaml.dump(config, f) -def get_config_path() -> str: +def get_config_directory() -> str: try: - config_directory_path = xdg.BaseDirectory.save_config_path(XDG_RESOURCE) + return xdg.BaseDirectory.save_config_path(XDG_RESOURCE) except FileExistsError: raise JrnlException( Message( @@ -89,7 +89,13 @@ def get_config_path() -> str: ), ) - return os.path.join(config_directory_path or home_dir(), DEFAULT_CONFIG_NAME) + +def get_config_path() -> Path: + try: + config_directory_path = get_config_directory() + except JrnlException: + return Path(home_dir(), DEFAULT_CONFIG_NAME) + return Path(config_directory_path, DEFAULT_CONFIG_NAME) def get_default_config() -> dict[str, Any]: @@ -129,6 +135,15 @@ def get_default_journal_path() -> str: return os.path.join(journal_data_path, DEFAULT_JOURNAL_NAME) +def get_templates_path() -> Path: + # jrnl_xdg_resource_path is created by save_data_path if it does not exist + jrnl_xdg_resource_path = Path(xdg.BaseDirectory.save_data_path(XDG_RESOURCE)) + jrnl_templates_path = jrnl_xdg_resource_path / "templates" + # Create the directory if needed. + jrnl_templates_path.mkdir(exist_ok=True) + return jrnl_templates_path + + def scope_config(config: dict, journal_name: str) -> dict: if journal_name not in config["journals"]: return config diff --git a/jrnl/controller.py b/jrnl/controller.py index 77ba9d15..8d8057a9 100644 --- a/jrnl/controller.py +++ b/jrnl/controller.py @@ -11,6 +11,7 @@ from jrnl import time from jrnl.config import DEFAULT_JOURNAL_KEY from jrnl.config import get_config_path from jrnl.config import get_journal_name +from jrnl.config import get_templates_path from jrnl.config import scope_config from jrnl.editor import get_text_from_editor from jrnl.editor import get_text_from_stdin @@ -23,7 +24,7 @@ from jrnl.messages import MsgText from jrnl.output import print_msg from jrnl.output import print_msgs from jrnl.override import apply_overrides -from jrnl.path import expand_path +from jrnl.path import absolute_path if TYPE_CHECKING: from argparse import Namespace @@ -123,9 +124,78 @@ def _is_write_mode(args: "Namespace", config: dict, **kwargs) -> bool: return write_mode +def _read_template_file(template_arg: str, template_path_from_config: str) -> str: + """ + This function is called when either a template file is passed with --template, or config.template is set. + + The processing logic is: + If --template was not used: Load the global template file. + If --template was used: + * Check $XDG_DATA_HOME/jrnl/templates/template_arg. + * Check template_arg as an absolute / relative path. + + If a file is found, its contents are returned as a string. + If not, a JrnlException is raised. + """ + logging.debug( + "Write mode: Either a template arg was passed, or the global config is set." + ) + + # If filename is unset, we are in this flow due to a global template being configured + if not template_arg: + logging.debug("Write mode: Global template configuration detected.") + global_template_path = absolute_path(template_path_from_config) + try: + with open(global_template_path, encoding="utf-8") as f: + template_data = f.read() + return template_data + except FileNotFoundError: + raise JrnlException( + Message( + MsgText.CantReadTemplateGlobalConfig, + MsgStyle.ERROR, + { + "global_template_path": global_template_path, + }, + ) + ) + else: # A template CLI arg was passed. + logging.debug("Trying to load template from $XDG_DATA_HOME/jrnl/templates/") + jrnl_template_dir = get_templates_path() + logging.debug(f"Write mode: jrnl templates directory: {jrnl_template_dir}") + template_path = jrnl_template_dir / template_arg + try: + with open(template_path, encoding="utf-8") as f: + template_data = f.read() + return template_data + except FileNotFoundError: + logging.debug( + f"Couldn't open {template_path}. Treating --template argument like a local / abs path." + ) + pass + + normalized_template_arg_filepath = absolute_path(template_arg) + try: + with open(normalized_template_arg_filepath, encoding="utf-8") as f: + template_data = f.read() + return template_data + except FileNotFoundError: + raise JrnlException( + Message( + MsgText.CantReadTemplateCLIArg, + MsgStyle.ERROR, + { + "normalized_template_arg_filepath": normalized_template_arg_filepath, + "jrnl_template_dir": template_path, + }, + ) + ) + + def write_mode(args: "Namespace", config: dict, journal: Journal, **kwargs) -> None: """ Gets input from the user to write to the journal + 0. Check for a template passed as an argument, or in the global config 1. Check for input from cli 2. Check input being piped in 3. Open editor if configured (prepopulated with template if available) @@ -134,8 +204,16 @@ def write_mode(args: "Namespace", config: dict, journal: Journal, **kwargs) -> N """ logging.debug("Write mode: starting") - if args.text: - logging.debug("Write mode: cli text detected: %s", args.text) + if args.template or config["template"]: + logging.debug(f"Write mode: template CLI arg detected: {args.template}") + # Read template file and pass as raw text into the composer + template_data = _read_template_file(args.template, config["template"]) + raw = _write_in_editor(config, template_data) + if raw == template_data: + logging.error("Write mode: raw text was the same as the template") + raise JrnlException(Message(MsgText.NoChangesToTemplate, MsgStyle.NORMAL)) + elif args.text: + logging.debug(f"Write mode: cli text detected: {args.text}") raw = " ".join(args.text).strip() if args.edit: raw = _write_in_editor(config, raw) @@ -150,9 +228,6 @@ def write_mode(args: "Namespace", config: dict, journal: Journal, **kwargs) -> N if not raw or raw.isspace(): logging.error("Write mode: couldn't get raw text or entry was empty") raise JrnlException(Message(MsgText.NoTextReceived, MsgStyle.NORMAL)) - if config["template"] and raw == _get_editor_template(config): - logging.error("Write mode: raw text was the same as the template") - raise JrnlException(Message(MsgText.NoChangesToTemplate, MsgStyle.NORMAL)) logging.debug( 'Write mode: appending raw text to journal "%s": %s', args.journal_name, raw @@ -224,45 +299,16 @@ def search_mode(args: "Namespace", journal: Journal, **kwargs) -> None: _display_search_results(**kwargs) -def _write_in_editor(config: dict, template: str | None = None) -> str: +def _write_in_editor(config: dict, prepopulated_text: str | None = None) -> str: if config["editor"]: logging.debug("Write mode: opening editor") - if not template: - template = _get_editor_template(config) - raw = get_text_from_editor(config, template) - + raw = get_text_from_editor(config, prepopulated_text) else: raw = get_text_from_stdin() return raw -def _get_editor_template(config: dict, **kwargs) -> str: - logging.debug("Write mode: loading template for entry") - - if not config["template"]: - logging.debug("Write mode: no template configured") - return "" - - template_path = expand_path(config["template"]) - - try: - with open(template_path) as f: - template = f.read() - logging.debug("Write mode: template loaded: %s", template) - except OSError: - logging.error("Write mode: template not loaded") - raise JrnlException( - Message( - MsgText.CantReadTemplate, - MsgStyle.ERROR, - {"template": template_path}, - ) - ) - - return template - - def _has_search_args(args: "Namespace") -> bool: return any( ( @@ -438,7 +484,7 @@ def _change_time_search_results( journal: Journal, old_entries: list["Entry"], no_prompt: bool = False, - **kwargs + **kwargs, ) -> None: # separate entries we are not editing other_entries = _other_entries(journal, old_entries) diff --git a/jrnl/journals/Journal.py b/jrnl/journals/Journal.py index ab5ec4de..994c5326 100644 --- a/jrnl/journals/Journal.py +++ b/jrnl/journals/Journal.py @@ -335,7 +335,8 @@ class Journal: def new_entry(self, raw: str, date=None, sort: bool = True) -> Entry: """Constructs a new entry from some raw text input. - If a date is given, it will parse and use this, otherwise scan for a date in the input first.""" + If a date is given, it will parse and use this, otherwise scan for a date in the input first. + """ raw = raw.replace("\\n ", "\n").replace("\\n", "\n") # Split raw text into title and body diff --git a/jrnl/messages/MsgText.py b/jrnl/messages/MsgText.py index ee2a43a1..7b6c7c7a 100644 --- a/jrnl/messages/MsgText.py +++ b/jrnl/messages/MsgText.py @@ -105,10 +105,16 @@ class MsgText(Enum): KeyboardInterruptMsg = "Aborted by user" - CantReadTemplate = """ - Unreadable template - Could not read template file at: - {template} + CantReadTemplateGlobalConfig = """ + Could not read template file defined in config: + {global_template_path} + """ + + CantReadTemplateCLIArg = """ + Unable to find a template file based on the passed arg, and no global template was detected. + The following filepaths were checked: + jrnl XDG Template Directory : {jrnl_template_dir} + Local Filepath : {normalized_template_arg_filepath} """ NoNamedJournal = "No '{journal_name}' journal configured\n{journals}" diff --git a/jrnl/override.py b/jrnl/override.py index 695bcf4a..64a0fe86 100644 --- a/jrnl/override.py +++ b/jrnl/override.py @@ -9,6 +9,7 @@ from jrnl.config import update_config if TYPE_CHECKING: from argparse import Namespace + # import logging def apply_overrides(args: "Namespace", base_config: dict) -> dict: """Unpack CLI provided overrides into the configuration tree. @@ -26,7 +27,6 @@ def apply_overrides(args: "Namespace", base_config: dict) -> dict: cfg_with_overrides = base_config.copy() for pairs in overrides: - pairs = make_yaml_valid_dict(pairs) key_as_dots, override_value = _get_key_and_value_from_pair(pairs) keys = _convert_dots_to_list(key_as_dots) diff --git a/tests/bdd/features/template.feature b/tests/bdd/features/template.feature index 55130b2c..a6a69b9f 100644 --- a/tests/bdd/features/template.feature +++ b/tests/bdd/features/template.feature @@ -31,4 +31,47 @@ Feature: Using templates | basic_onefile.yaml | | basic_encrypted.yaml | | basic_folder.yaml | - | basic_dayone.yaml | \ No newline at end of file + | basic_dayone.yaml | + + Scenario Outline: --template nonexistent_file should throw an error + Given we use the config "" + And we use the password "test" if prompted + When we run "jrnl --template this_template_does_not_exist.template" + Then we should get an error + Then the error output should contain "Unable to find a template file based on the passed arg" + + Examples: configs + | config_file | + | basic_onefile.yaml | + | basic_encrypted.yaml | + | basic_folder.yaml | + | basic_dayone.yaml | + + Scenario Outline: --template local_filepath should be used in new entry + Given we use the config "" + And we use the password "test" if prompted + When we run "jrnl --template features/templates/basic.template" + Then the output should contain "No entry to save, because the template was not changed" + + Examples: configs + | config_file | + | basic_onefile.yaml | + | basic_encrypted.yaml | + | basic_folder.yaml | + | basic_dayone.yaml | + + Scenario Outline: --template file_in_XDG_templates_dir should be used in new entry + Given we use the config "" + And we use the password "test" if prompted + And we copy the template "basic.template" to the default templates folder + When we run "jrnl --template basic.template" + Then the output should contain "No entry to save, because the template was not changed" + + + Examples: configs + | config_file | + | basic_onefile.yaml | + | basic_encrypted.yaml | + | basic_folder.yaml | + | basic_dayone.yaml | + | basic_dayone.yaml | diff --git a/tests/lib/fixtures.py b/tests/lib/fixtures.py index 22c11d54..b9cf0ea4 100644 --- a/tests/lib/fixtures.py +++ b/tests/lib/fixtures.py @@ -89,6 +89,7 @@ def cli_run( mock_user_input, mock_overrides, mock_default_journal_path, + mock_default_templates_path, ): # Check if we need more mocks mock_factories.update(mock_args) @@ -98,6 +99,7 @@ def cli_run( mock_factories.update(mock_config_path) mock_factories.update(mock_user_input) mock_factories.update(mock_default_journal_path) + mock_factories.update(mock_default_templates_path) return { "status": 0, @@ -179,6 +181,16 @@ def mock_default_journal_path(temp_dir): } +@fixture +def mock_default_templates_path(temp_dir): + templates_path = Path(temp_dir.name, "templates") + return { + "get_templates_path": lambda: patch( + "jrnl.controller.get_templates_path", return_value=templates_path + ), + } + + @fixture def temp_dir(): return tempfile.TemporaryDirectory() diff --git a/tests/lib/given_steps.py b/tests/lib/given_steps.py index c5fd62a1..b76f1662 100644 --- a/tests/lib/given_steps.py +++ b/tests/lib/given_steps.py @@ -125,6 +125,19 @@ def we_use_the_config(request, temp_dir, working_dir, config_file): return config_dest +@given(parse('we copy the template "{template_file}" to the default templates folder'), target_fixture="default_templates_path") +def we_copy_the_template(request, temp_dir, working_dir, template_file): + # Move into temp dir as cwd + os.chdir(temp_dir.name) # @todo move this step to a more universal place + + # Copy template over + template_source = os.path.join(working_dir, "data", "templates", template_file) + template_dest = os.path.join(temp_dir.name, "templates", template_file) + os.makedirs(os.path.dirname(template_dest), exist_ok=True) + shutil.copy2(template_source, template_dest) + return template_dest + + @given(parse('the config "{config_file}" exists'), target_fixture="config_path") def config_exists(config_file, temp_dir, working_dir): config_source = os.path.join(working_dir, "data", "configs", config_file) diff --git a/tests/lib/then_steps.py b/tests/lib/then_steps.py index fd697dfb..2bfb3d7b 100644 --- a/tests/lib/then_steps.py +++ b/tests/lib/then_steps.py @@ -25,6 +25,11 @@ def should_get_no_error(cli_run): assert cli_run["status"] == 0, cli_run["status"] +@then("we should get an error") +def should_get_an_error(cli_run): + assert cli_run["status"] != 0, cli_run["status"] + + @then(parse("the output should match\n{regex}")) @then(parse('the output should match "{regex}"')) def output_should_match(regex, cli_run): diff --git a/tests/unit/test_parse_args.py b/tests/unit/test_parse_args.py index 0b266d23..517c8e46 100644 --- a/tests/unit/test_parse_args.py +++ b/tests/unit/test_parse_args.py @@ -42,6 +42,7 @@ def expected_args(**kwargs): "strict": False, "tagged": False, "tags": False, + "template": None, "text": [], "config_override": [], "config_file_path": "",