diff --git a/jrnl/datatypes/NestedDict.py b/jrnl/datatypes/NestedDict.py new file mode 100644 index 000000000..7ed8eefda --- /dev/null +++ b/jrnl/datatypes/NestedDict.py @@ -0,0 +1,5 @@ +"""https://stackoverflow.com/a/74873621/8740440""" +class NestedDict(dict): + def __missing__(self, x): + self[x] = NestedDict() + return self[x] diff --git a/jrnl/datatypes/__init__.py b/jrnl/datatypes/__init__.py new file mode 100644 index 000000000..e9859ff98 --- /dev/null +++ b/jrnl/datatypes/__init__.py @@ -0,0 +1 @@ +from .NestedDict import NestedDict diff --git a/jrnl/plugins/__init__.py b/jrnl/plugins/__init__.py index 993d8686a..e23a3aae6 100644 --- a/jrnl/plugins/__init__.py +++ b/jrnl/plugins/__init__.py @@ -3,6 +3,7 @@ from typing import Type +from jrnl.plugins.calendar_heatmap_exporter import CalendarHeatmapExporter from jrnl.plugins.dates_exporter import DatesExporter from jrnl.plugins.fancy_exporter import FancyExporter from jrnl.plugins.jrnl_importer import JRNLImporter @@ -14,14 +15,15 @@ from jrnl.plugins.yaml_exporter import YAMLExporter __exporters = [ + CalendarHeatmapExporter, + DatesExporter, + FancyExporter, JSONExporter, MarkdownExporter, TagExporter, - DatesExporter, TextExporter, XMLExporter, YAMLExporter, - FancyExporter, ] __importers = [JRNLImporter] diff --git a/jrnl/plugins/calendar_heatmap_exporter.py b/jrnl/plugins/calendar_heatmap_exporter.py new file mode 100644 index 000000000..a274d02b7 --- /dev/null +++ b/jrnl/plugins/calendar_heatmap_exporter.py @@ -0,0 +1,96 @@ +# Copyright © 2012-2023 jrnl contributors +# License: https://www.gnu.org/licenses/gpl-3.0.html + +from typing import TYPE_CHECKING +import calendar +from datetime import datetime + +from jrnl.datatypes import NestedDict +from jrnl.plugins.text_exporter import TextExporter +from jrnl.plugins.util import get_journal_frequency_as_dict + +from rich.align import Align +from rich import box +from rich.columns import Columns +from rich.console import Console +from rich.table import Table +from rich.text import Text + +if TYPE_CHECKING: + from jrnl.journals import Entry + from jrnl.journals import Journal + + +class CalendarHeatmapExporter(TextExporter): + """This Exporter displays a calendar heatmap of the journaling frequency.""" + + names = ["calendar", "heatmap"] + extension = "cal" + + @classmethod + def export_entry(cls, entry: "Entry"): + raise NotImplementedError + + @classmethod + def print_calendar_heatmap(cls, journal_frequency: NestedDict) -> str: + """Returns a string representation of the calendar heatmap.""" + console = Console() + cal = calendar.Calendar() + curr_year = datetime.now().year + curr_month = datetime.now().month + curr_day = datetime.now().day + with console.capture() as capture: + for year, month_journaling_freq in journal_frequency.items(): + year_calendar = [] + for month in range(1, 13): + if month > curr_month and year == curr_year: + break + table = Table( + title=f"{calendar.month_name[month]} {year}", + style="white", + box=box.SIMPLE_HEAVY, + padding=0, + ) + + for week_day in cal.iterweekdays(): + table.add_column( + "{:.3}".format(calendar.day_name[week_day]), justify="right" + ) + + month_days = cal.monthdayscalendar(year, month) + for weekdays in month_days: + days = [] + for _, day in enumerate(weekdays): + if day == 0: # Not a part of this month, just filler. + day_label = Text(str(day or ""), style="white") + elif day > curr_day and month == curr_month and year == curr_year: + break + else: + journal_frequency_for_day = month_journaling_freq[month][day] or 0 + # TODO: Make colors configurable? + if journal_frequency_for_day == 0: + day_label = Text(str(day or ""), style="red on black") + elif journal_frequency_for_day == 1: + day_label = Text(str(day or ""), style="black on yellow") + elif journal_frequency_for_day == 2: + day_label = Text(str(day or ""), style="black on green") + else: + day_label = Text(str(day or ""), style="black on white") + + days.append(day_label) + table.add_row(*days) + + year_calendar.append(Align.center(table)) + + # Print year header line + console.rule(str(year)) + console.print() + # Print calendar + console.print(Columns(year_calendar, padding=1, expand=True)) + return capture.get() + + @classmethod + def export_journal(cls, journal: "Journal"): + """Returns dates and their frequencies for an entire journal.""" + journal_entry_date_frequency = get_journal_frequency_as_dict(journal) + return cls.print_calendar_heatmap(journal_entry_date_frequency) diff --git a/jrnl/plugins/dates_exporter.py b/jrnl/plugins/dates_exporter.py index 38d101dd8..090d70a5e 100644 --- a/jrnl/plugins/dates_exporter.py +++ b/jrnl/plugins/dates_exporter.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING from jrnl.plugins.text_exporter import TextExporter +from jrnl.plugins.util import get_journal_frequency_as_str if TYPE_CHECKING: from jrnl.journals import Entry @@ -24,10 +25,6 @@ def export_entry(cls, entry: "Entry"): @classmethod def export_journal(cls, journal: "Journal") -> str: """Returns dates and their frequencies for an entire journal.""" - date_counts = Counter() - for entry in journal.entries: - # entry.date.date() gets date without time - date = str(entry.date.date()) - date_counts[date] += 1 + date_counts = get_journal_frequency_as_str(journal) result = "\n".join(f"{date}, {count}" for date, count in date_counts.items()) return result diff --git a/jrnl/plugins/util.py b/jrnl/plugins/util.py index ceaa0b041..3fe24057a 100644 --- a/jrnl/plugins/util.py +++ b/jrnl/plugins/util.py @@ -1,6 +1,8 @@ # Copyright © 2012-2023 jrnl contributors # License: https://www.gnu.org/licenses/gpl-3.0.html +from collections import Counter +from jrnl.datatypes import NestedDict from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -28,3 +30,24 @@ def oxford_list(lst: list) -> str: return lst[0] + " or " + lst[1] else: return ", ".join(lst[:-1]) + ", or " + lst[-1] + +def get_journal_frequency_as_dict(journal: "Journal") -> NestedDict: + """Returns a NestedDict of the form {year: {month: {day: count}}}""" + journal_frequency = NestedDict() + for entry in journal.entries: + date = entry.date.date() + if date.day in journal_frequency[date.year][date.month]: + journal_frequency[date.year][date.month][date.day] += 1 + else: + journal_frequency[date.year][date.month][date.day] = 1 + + return journal_frequency + +def get_journal_frequency_as_str(journal: "Journal") -> Counter: + """Returns a Counter of the form {date (YYYY-MM-DD): count}""" + date_counts = Counter() + for entry in journal.entries: + # entry.date.date() gets date without time + date = str(entry.date.date()) + date_counts[date] += 1 + return date_counts