mirror of
https://github.com/jrnl-org/jrnl.git
synced 2025-05-10 00:28:31 +02:00
Add ability to use template with --template
(#1667)
* Add ability to pass template path with --template Update jrnl/args.py * Fix tests
This commit is contained in:
parent
0725ea6b87
commit
a2b217fdfc
12 changed files with 217 additions and 68 deletions
|
@ -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('.')))"
|
||||
```
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}"
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -31,4 +31,47 @@ Feature: Using templates
|
|||
| basic_onefile.yaml |
|
||||
| basic_encrypted.yaml |
|
||||
| basic_folder.yaml |
|
||||
| basic_dayone.yaml |
|
||||
| basic_dayone.yaml |
|
||||
|
||||
Scenario Outline: --template nonexistent_file should throw an error
|
||||
Given we use the config "<config_file>"
|
||||
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 "<config_file>"
|
||||
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 "<config_file>"
|
||||
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 |
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -42,6 +42,7 @@ def expected_args(**kwargs):
|
|||
"strict": False,
|
||||
"tagged": False,
|
||||
"tags": False,
|
||||
"template": None,
|
||||
"text": [],
|
||||
"config_override": [],
|
||||
"config_file_path": "",
|
||||
|
|
Loading…
Add table
Reference in a new issue