Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/reference-config-file.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
20 changes: 15 additions & 5 deletions jrnl/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down
12 changes: 11 additions & 1 deletion jrnl/journals/Entry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand All @@ -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,
)
Expand Down
1 change: 1 addition & 0 deletions jrnl/journals/Journal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
27 changes: 27 additions & 0 deletions jrnl/time.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
41 changes: 41 additions & 0 deletions tests/bdd/features/datetime.feature
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
18 changes: 18 additions & 0 deletions tests/data/configs/timeformat_display.yaml
Original file line number Diff line number Diff line change
@@ -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
16 changes: 16 additions & 0 deletions tests/unit/test_parse_args.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions tests/unit/test_time.py
Original file line number Diff line number Diff line change
Expand Up @@ -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