diff --git a/docs/reference-config-file.md b/docs/reference-config-file.md index 6514a6aea..6c5760d6a 100644 --- a/docs/reference-config-file.md +++ b/docs/reference-config-file.md @@ -104,6 +104,15 @@ to data loss. !!! note `jrnl` doesn't support the `%z` or `%Z` time zone identifiers. +### timeformat_display +Controls how timestamps are displayed when viewing entries (for example, with +`jrnl -1` or `jrnl --short`). It does not affect how timestamps are stored. + +Leave this unset (or empty) to display timestamps using `timeformat`. Set it +to `relative` to display timestamps as a relative, human-readable string, such +as `5 minutes ago` or `in 3 days`. Otherwise, it accepts the same kind of time +format string as `timeformat`. + ### highlight If `true`, tags will be highlighted in cyan. diff --git a/jrnl/config.py b/jrnl/config.py index d72da9b76..132477b1a 100644 --- a/jrnl/config.py +++ b/jrnl/config.py @@ -2,6 +2,7 @@ # License: https://www.gnu.org/licenses/gpl-3.0.html import argparse +import json import logging import os from typing import TYPE_CHECKING @@ -11,6 +12,7 @@ from rich.pretty import pretty_repr from ruamel.yaml import YAML from ruamel.yaml import constructor +from ruamel.yaml.error import YAMLError from jrnl import __version__ from jrnl.color import is_valid_color @@ -49,13 +51,20 @@ def make_yaml_valid_dict(input: list) -> dict: """ assert len(input) == 2 + key, value = input - # yaml compatible strings are of the form Key:Value - yamlstr = YAML_SEPARATOR.join(input) - - runtime_modifications = YAML(typ="safe").load(yamlstr) + yaml = YAML(typ="safe") - return runtime_modifications + # yaml compatible strings are of the form Key:Value + try: + return yaml.load(YAML_SEPARATOR.join(input)) + except YAMLError: + # The value starts with (or otherwise contains) a character that YAML + # treats as reserved when unquoted, e.g. "%" in a strftime format + # string like "%d.%m.%Y". Retry with the value safely quoted so it's + # parsed as a plain string instead of erroring. + quoted_value = json.dumps(value) + return yaml.load(YAML_SEPARATOR.join([key, quoted_value])) def save_config(config: dict, alt_config_path: str | None = None) -> None: @@ -83,6 +92,7 @@ def get_default_config() -> dict[str, Any]: "default_hour": 9, "default_minute": 0, "timeformat": "%F %r", + "timeformat_display": "", "tagsymbols": "#@", "highlight": True, "linewrap": 79, diff --git a/jrnl/journals/Entry.py b/jrnl/journals/Entry.py index c1071a543..f19add457 100644 --- a/jrnl/journals/Entry.py +++ b/jrnl/journals/Entry.py @@ -7,6 +7,7 @@ import re from typing import TYPE_CHECKING +from jrnl import time from jrnl.color import colorize from jrnl.color import get_color from jrnl.color import highlight_tags_with_background_color @@ -100,6 +101,15 @@ def __str__(self): body=self.body.rstrip("\n "), ) + def _display_date_str(self) -> str: + """Returns the date of the entry formatted for display, honoring + the "timeformat_display" config key (which may be "relative" or a + strftime format string) and falling back to "timeformat".""" + display_format = self.journal.config.get("timeformat_display") + if display_format == "relative": + return time.relative_time(self.date) + return self.date.strftime(display_format or self.journal.config["timeformat"]) + def pprint(self, short: bool = False) -> str: """Returns a pretty-printed version of the entry. If short is true, only print the title.""" @@ -110,7 +120,7 @@ def pprint(self, short: bool = False) -> str: indent = "" date_str = colorize( - self.date.strftime(self.journal.config["timeformat"]), + self._display_date_str(), get_color(self.journal.config, "date"), bold=True, ) diff --git a/jrnl/journals/Journal.py b/jrnl/journals/Journal.py index b3964f00e..f5666f3a0 100644 --- a/jrnl/journals/Journal.py +++ b/jrnl/journals/Journal.py @@ -40,6 +40,7 @@ def __init__(self, name="default", **kwargs): "default_hour": 9, "default_minute": 0, "timeformat": "%Y-%m-%d %H:%M", + "timeformat_display": "", "tagsymbols": "@", "highlight": True, "linewrap": 80, diff --git a/jrnl/time.py b/jrnl/time.py index 0f3e8630c..18b686e06 100644 --- a/jrnl/time.py +++ b/jrnl/time.py @@ -101,3 +101,30 @@ def is_valid_date(year: int, month: int, day: int) -> bool: return True except ValueError: return False + + +_RELATIVE_PERIODS = ( + ("year", 60 * 60 * 24 * 365), + ("month", 60 * 60 * 24 * 30), + ("week", 60 * 60 * 24 * 7), + ("day", 60 * 60 * 24), + ("hour", 60 * 60), + ("minute", 60), +) + + +def relative_time(date: datetime.datetime, now: datetime.datetime | None = None) -> str: + """Returns a relative representation of a datetime, e.g. "5 minutes ago" + or "in 3 days", for use with the "relative" timeformat_display value.""" + now = now or datetime.datetime.now() + seconds = (now - date).total_seconds() + future = seconds < 0 + seconds = abs(seconds) + + for period_name, period_seconds in _RELATIVE_PERIODS: + value = int(seconds // period_seconds) + if value >= 1: + unit = period_name if value == 1 else f"{period_name}s" + return f"in {value} {unit}" if future else f"{value} {unit} ago" + + return "just now" diff --git a/tests/bdd/features/datetime.feature b/tests/bdd/features/datetime.feature index 0a3c3d6b3..a82945731 100644 --- a/tests/bdd/features/datetime.feature +++ b/tests/bdd/features/datetime.feature @@ -151,6 +151,47 @@ Feature: Reading and writing to journal with custom date formats """ + Scenario: timeformat_display can format dates differently from timeformat + # https://github.com/jrnl-org/jrnl/issues/1721 + Given we use the config "simple.yaml" + When we run "jrnl --config-override timeformat_display '%d.%m.%Y' -on '2013-06-09'" + Then the output should contain "09.06.2013 My first entry." + + + Scenario: timeformat_display also applies to jrnl --short / -s + # https://github.com/jrnl-org/jrnl/issues/1721 + Given we use the config "simple.yaml" + When we run "jrnl --config-override timeformat_display '%d.%m.%Y' -on '2013-06-09' --short" + Then the output should be "09.06.2013 My first entry." + When we run "jrnl --config-override timeformat_display '%d.%m.%Y' -on '2013-06-09' -s" + Then the output should be "09.06.2013 My first entry." + + + Scenario: timeformat_display set in the config file works the same way + # https://github.com/jrnl-org/jrnl/issues/1721 + Given we use the config "timeformat_display.yaml" + When we run "jrnl -on '2013-06-09' --short" + Then the output should be "09.06.2013 My first entry." + + + Scenario: timeformat_display of "relative" shows a relative timestamp + # https://github.com/jrnl-org/jrnl/issues/1721 + Given we use the config "simple.yaml" + And now is "2013-06-10 03:39:00 PM" + When we run "jrnl --config-override timeformat_display relative -on '2013-06-09'" + Then the output should contain "1 day ago My first entry." + + + Scenario: timeformat_display of "relative" works with jrnl --short / -s + # https://github.com/jrnl-org/jrnl/issues/1721 + Given we use the config "simple.yaml" + And now is "2013-06-10 03:39:00 PM" + When we run "jrnl --config-override timeformat_display relative -on '2013-06-09' --short" + Then the output should be "1 day ago My first entry." + When we run "jrnl --config-override timeformat_display relative -on '2013-06-09' -s" + Then the output should be "1 day ago My first entry." + + Scenario: Update near-valid dates after journal is edited Given we use the config "mostlyreadabledates.yaml" When we run "jrnl 2222-08-19: I have made it exactly one month into the future." diff --git a/tests/data/configs/timeformat_display.yaml b/tests/data/configs/timeformat_display.yaml new file mode 100644 index 000000000..1a11bc523 --- /dev/null +++ b/tests/data/configs/timeformat_display.yaml @@ -0,0 +1,18 @@ +default_hour: 9 +default_minute: 0 +editor: "" +encrypt: false +highlight: true +journals: + default: features/journals/simple.journal +linewrap: 80 +tagsymbols: "@" +template: false +timeformat: "%Y-%m-%d %H:%M" +timeformat_display: "%d.%m.%Y" +indent_character: "|" +colors: + date: none + title: none + body: none + tags: none diff --git a/tests/unit/test_parse_args.py b/tests/unit/test_parse_args.py index aef3bd9f0..88d6a621d 100644 --- a/tests/unit/test_parse_args.py +++ b/tests/unit/test_parse_args.py @@ -300,6 +300,22 @@ def test_deserialize_multiword_strings(self, input_str): assert input_str[0] in runtime_config assert runtime_config[input_str[0]] == input_str[1] + @pytest.mark.parametrize( + "input_str", + [ + ["timeformat_display", "%d.%m.%Y"], + ["timeformat_display", "%Y-%m-%d %H:%M"], + ["timeformat_display", "relative"], + ], + ) + def test_deserialize_strings_with_percent_signs(self, input_str): + # https://github.com/jrnl-org/jrnl/issues/1721 + # A bare "%" is a reserved YAML indicator character, so values like + # strftime format strings must still be usable via --config-override. + runtime_config = make_yaml_valid_dict(input_str) + assert runtime_config.__class__ is dict + assert runtime_config[input_str[0]] == input_str[1] + def test_deserialize_multiple_datatypes(self): cfg = make_yaml_valid_dict(["linewrap", "23"]) assert cfg["linewrap"] == 23 diff --git a/tests/unit/test_time.py b/tests/unit/test_time.py index 1901a4dc0..55a413992 100644 --- a/tests/unit/test_time.py +++ b/tests/unit/test_time.py @@ -42,3 +42,42 @@ def test_default_minute_is_added(): def test_is_valid_date(inputs): year, month, day, expected_result = inputs assert time.is_valid_date(year, month, day) == expected_result + + +@pytest.mark.parametrize( + "inputs", + [ + [datetime.timedelta(seconds=30), "just now"], + [datetime.timedelta(minutes=1), "1 minute ago"], + [datetime.timedelta(minutes=5), "5 minutes ago"], + [datetime.timedelta(hours=1), "1 hour ago"], + [datetime.timedelta(hours=3), "3 hours ago"], + [datetime.timedelta(days=1), "1 day ago"], + [datetime.timedelta(days=6), "6 days ago"], + [datetime.timedelta(weeks=1), "1 week ago"], + [datetime.timedelta(weeks=3), "3 weeks ago"], + [datetime.timedelta(days=30), "1 month ago"], + [datetime.timedelta(days=365), "1 year ago"], + [datetime.timedelta(days=730), "2 years ago"], + ], +) +def test_relative_time_past(inputs): + delta, expected_result = inputs + now = datetime.datetime(2023, 6, 15, 12, 0, 0) + assert time.relative_time(now - delta, now=now) == expected_result + + +@pytest.mark.parametrize( + "inputs", + [ + [datetime.timedelta(seconds=30), "just now"], + [datetime.timedelta(minutes=5), "in 5 minutes"], + [datetime.timedelta(hours=3), "in 3 hours"], + [datetime.timedelta(days=1), "in 1 day"], + [datetime.timedelta(days=365), "in 1 year"], + ], +) +def test_relative_time_future(inputs): + delta, expected_result = inputs + now = datetime.datetime(2023, 6, 15, 12, 0, 0) + assert time.relative_time(now + delta, now=now) == expected_result