From 6eb7d857f0cbc460f1c115e3f9afbc7aaa46f01d Mon Sep 17 00:00:00 2001 From: Jonathan Wren Date: Sat, 14 Jan 2023 16:30:12 -0800 Subject: [PATCH 01/35] WIP --- jrnl/controller.py | 241 ++++++++++++++++++--------------------- jrnl/journals/Journal.py | 8 +- 2 files changed, 117 insertions(+), 132 deletions(-) diff --git a/jrnl/controller.py b/jrnl/controller.py index 7d7c87ce2..03d128f9f 100644 --- a/jrnl/controller.py +++ b/jrnl/controller.py @@ -39,7 +39,8 @@ def run(args: "Namespace"): 3. Run standalone command if it does require config (encrypt, decrypt, etc), then exit 4. Load specified journal 5. Start write mode, or search mode - 6. Profit + 6. Perform actions with results from search mode (if needed) + 7. Profit """ # Run command if possible before config is available @@ -71,56 +72,56 @@ def run(args: "Namespace"): "args": args, "config": config, "journal": journal, + "old_entries": journal.entries, } - if _is_write_mode(**kwargs): - write_mode(**kwargs) - else: - search_mode(**kwargs) + if _is_append_mode(**kwargs): + append_mode(**kwargs) + return + + # If not append mode, then we're in search mode (only 2 modes exist) + search_mode(**kwargs) + + # perform actions (if needed) + if args.change_time: + _change_time_search_results(**kwargs) + + if args.delete: + _delete_search_results(**kwargs) + + # open results in editor (if `--edit` was used) + if args.edit: + _edit_search_results(**kwargs) + + _print_entries_found_count(len(journal), args) + + if not args.edit and not _has_action_args(args): + # display only occurs if no other action occurs + _display_search_results(**kwargs) -def _is_write_mode(args: "Namespace", config: dict, **kwargs) -> bool: +def _is_append_mode(args: "Namespace", config: dict, **kwargs) -> bool: """Determines if we are in write mode (as opposed to search mode)""" # Are any search filters present? If so, then search mode. - write_mode = not any( - ( - args.contains, - args.delete, - args.edit, - args.change_time, - args.excluded, - args.export, - args.end_date, - args.today_in_history, - args.month, - args.day, - args.year, - args.limit, - args.on_date, - args.short, - args.starred, - args.start_date, - args.strict, - args.tags, - ) + append_mode = ( + not _has_search_args(args) + and not _has_action_args(args) + and not _has_display_args(args) + and not args.edit ) # Might be writing and want to move to editor part of the way through if args.edit and args.text: - write_mode = True + append_mode = True # If the text is entirely tags, then we are also searching (not writing) - if ( - write_mode - and args.text - and all(word[0] in config["tagsymbols"] for word in " ".join(args.text).split()) - ): - write_mode = False + if append_mode and args.text and _has_only_tags(config["tagsymbols"], args.text): + append_mode = False - return write_mode + return append_mode -def write_mode(args: "Namespace", config: dict, journal: Journal, **kwargs) -> None: +def append_mode(args: "Namespace", config: dict, journal: Journal, **kwargs) -> None: """ Gets input from the user to write to the journal 1. Check for input from cli @@ -129,27 +130,27 @@ def write_mode(args: "Namespace", config: dict, journal: Journal, **kwargs) -> N 4. Use stdin.read as last resort 6. Write any found text to journal, or exit """ - logging.debug("Write mode: starting") + logging.debug("Append mode: starting") if args.text: - logging.debug("Write mode: cli text detected: %s", args.text) + logging.debug("Append mode: cli text detected: %s", args.text) raw = " ".join(args.text).strip() if args.edit: raw = _write_in_editor(config, raw) elif not sys.stdin.isatty(): - logging.debug("Write mode: receiving piped text") + logging.debug("Append mode: receiving piped text") raw = sys.stdin.read() else: raw = _write_in_editor(config) if not raw or raw.isspace(): - logging.error("Write mode: couldn't get raw text or entry was empty") + logging.error("Append mode: couldn't get raw text or entry was empty") raise JrnlException(Message(MsgText.NoTextReceived, MsgStyle.NORMAL)) logging.debug( - 'Write mode: appending raw text to journal "%s": %s', args.journal_name, raw + f"Append mode: appending raw text to journal '{args.journal_name}': {raw}" ) journal.new_entry(raw) if args.journal_name != DEFAULT_JOURNAL_KEY: @@ -161,66 +162,32 @@ def write_mode(args: "Namespace", config: dict, journal: Journal, **kwargs) -> N ) ) journal.write() - logging.debug("Write mode: completed journal.write()") + logging.debug("Append mode: completed journal.write()") def search_mode(args: "Namespace", journal: Journal, **kwargs) -> None: """ - Search for entries in a journal, then either: - 1. Send them to configured editor for user manipulation (and also - change their timestamps if requested) - 2. Change their timestamps - 2. Delete them (with confirmation for each entry) - 3. Display them (with formatting options) + Search for entries in a journal, and return the + results. If no search args, then return all results """ - kwargs = { - **kwargs, - "args": args, - "journal": journal, - "old_entries": journal.entries, - } - - if _has_search_args(args): - _filter_journal_entries(**kwargs) - _print_entries_found_count(len(journal), args) - - # Where do the search results go? - if args.edit: - # If we want to both edit and change time in one action - if args.change_time: - # Generate a new list instead of assigning so it won't be - # modified by _change_time_search_results - selected_entries = [e for e in journal.entries] - - no_change_time_prompt = len(journal.entries) == 1 - _change_time_search_results(no_prompt=no_change_time_prompt, **kwargs) - - # Re-filter the journal enties (_change_time_search_results - # puts the filtered entries back); use selected_entries - # instead of running _search_journal again, because times - # have changed since the original search - kwargs["old_entries"] = journal.entries - journal.entries = selected_entries - - _edit_search_results(**kwargs) + logging.debug("Search mode: starting") - elif not journal: - # Bail out if there are no entries and we're not editing + # If no search args, then return all results (don't filter anything) + if ( + not _has_search_args(args) + and not _has_display_args(args) + and not _has_only_tags(kwargs["config"]["tagsymbols"], args.text) + ): + logging.debug("Search mode: has not search args") return - elif args.change_time: - _change_time_search_results(**kwargs) - - elif args.delete: - _delete_search_results(**kwargs) - - else: - _display_search_results(**kwargs) + logging.debug("Search mode: has search args") + _filter_journal_entries(args, journal) def _write_in_editor(config: dict, template: str | None = None) -> str: if config["editor"]: - logging.debug("Write mode: opening editor") + logging.debug("Append mode: opening editor") if not template: template = _get_editor_template(config) raw = get_text_from_editor(config, template) @@ -232,10 +199,10 @@ def _write_in_editor(config: dict, template: str | None = None) -> str: def _get_editor_template(config: dict, **kwargs) -> str: - logging.debug("Write mode: loading template for entry") + logging.debug("Append mode: loading template for entry") if not config["template"]: - logging.debug("Write mode: no template configured") + logging.debug("Append mode: no template configured") return "" template_path = expand_path(config["template"]) @@ -243,9 +210,9 @@ def _get_editor_template(config: dict, **kwargs) -> str: try: with open(template_path) as f: template = f.read() - logging.debug("Write mode: template loaded: %s", template) + logging.debug("Append mode: template loaded: %s", template) except OSError: - logging.error("Write mode: template not loaded") + logging.error("Append mode: template not loaded") raise JrnlException( Message( MsgText.CantReadTemplate, @@ -257,26 +224,6 @@ def _get_editor_template(config: dict, **kwargs) -> str: return template -def _has_search_args(args: "Namespace") -> bool: - return any( - ( - args.on_date, - args.today_in_history, - args.text, - args.month, - args.day, - args.year, - args.start_date, - args.end_date, - args.strict, - args.starred, - args.excluded, - args.contains, - args.limit, - ) - ) - - def _filter_journal_entries(args: "Namespace", journal: Journal, **kwargs) -> None: """Filter journal entries in-place based upon search args""" if args.on_date: @@ -425,28 +372,21 @@ def _change_time_search_results( args: "Namespace", 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) - if no_prompt: - entries_to_change = journal.entries - else: - entries_to_change = journal.prompt_action_entries( - MsgText.ChangeTimeEntryQuestion - ) + import ipdb - if entries_to_change: - other_entries += [e for e in journal.entries if e not in entries_to_change] - journal.entries = entries_to_change + ipdb.sset_trace() + # separate entries we are not editing + # @todo if there's only 1, don't prompt + entries_to_change = journal.prompt_action_entries(MsgText.ChangeTimeEntryQuestion) + if entries_to_change: date = time.parse(args.change_time) - journal.change_date_entries(date) + journal.entries = old_entries + journal.change_date_entries(date, entries_to_change) - journal.entries += other_entries - journal.sort() journal.write() @@ -468,3 +408,46 @@ def _display_search_results(args: "Namespace", journal: Journal, **kwargs) -> No print(exporter.export(journal, args.filename)) else: print(journal.pprint()) + + +def _has_search_args(args: "Namespace") -> bool: + """Looking for arguments that filter a journal""" + return any( + ( + args.contains, + args.excluded, # -not + args.end_date, + args.today_in_history, + args.month, + args.day, + args.year, + args.limit, + args.on_date, + args.starred, + args.start_date, + args.strict, # -and + ) + ) + + +def _has_action_args(args: "Namespace") -> bool: + return any( + ( + args.change_time, + args.delete, + ) + ) + + +def _has_display_args(args: "Namespace") -> bool: + return any( + ( + args.tags, + args.short, + args.export, # --format + ) + ) + + +def _has_only_tags(tag_symbols: str, args_text: str) -> bool: + return all(word[0] in tag_symbols for word in " ".join(args_text).split()) diff --git a/jrnl/journals/Journal.py b/jrnl/journals/Journal.py index 5a8b016e1..b1c2586bf 100644 --- a/jrnl/journals/Journal.py +++ b/jrnl/journals/Journal.py @@ -299,11 +299,13 @@ def delete_entries(self, entries_to_delete: list[Entry]) -> None: for entry in entries_to_delete: self.entries.remove(entry) - def change_date_entries(self, date: datetime.datetime | None) -> None: + def change_date_entries( + self, date: datetime.datetime, entries_to_change: list[Entry] + ) -> None: """Changes entry dates to given date.""" date = time.parse(date) - for entry in self.entries: + for entry in entries_to_change: entry.date = date def prompt_action_entries(self, msg: MsgText) -> list[Entry]: @@ -436,7 +438,7 @@ def open_journal(journal_name: str, config: dict, legacy: bool = False) -> Journ If legacy is True, it will open Journals with legacy classes build for backwards compatibility with jrnl 1.x """ - logging.debug("open_journal start") + logging.debug(f"open_journal '{journal_name}'") validate_journal_name(journal_name, config) config = config.copy() config["journal"] = expand_path(config["journal"]) From dc6900e9fe6683e3e0fc477f205dc9612955ea9a Mon Sep 17 00:00:00 2001 From: Micah Jerome Ellison Date: Sat, 28 Jan 2023 12:48:49 -0800 Subject: [PATCH 02/35] Remove ipdb and remove search mode conditional that added explicit tag search behavior --- jrnl/controller.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/jrnl/controller.py b/jrnl/controller.py index 03d128f9f..86a8d566b 100644 --- a/jrnl/controller.py +++ b/jrnl/controller.py @@ -176,9 +176,9 @@ def search_mode(args: "Namespace", journal: Journal, **kwargs) -> None: if ( not _has_search_args(args) and not _has_display_args(args) - and not _has_only_tags(kwargs["config"]["tagsymbols"], args.text) + and not args.text ): - logging.debug("Search mode: has not search args") + logging.debug("Search mode: has no search args") return logging.debug("Search mode: has search args") @@ -374,10 +374,6 @@ def _change_time_search_results( old_entries: list["Entry"], **kwargs, ) -> None: - - import ipdb - - ipdb.sset_trace() # separate entries we are not editing # @todo if there's only 1, don't prompt entries_to_change = journal.prompt_action_entries(MsgText.ChangeTimeEntryQuestion) From 6e24769294d6b84fd2e301362423db18d0d7da26 Mon Sep 17 00:00:00 2001 From: Micah Jerome Ellison Date: Sat, 28 Jan 2023 13:05:48 -0800 Subject: [PATCH 03/35] Fix failing change-time test by using same method signature as base journal class --- jrnl/journals/FolderJournal.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/jrnl/journals/FolderJournal.py b/jrnl/journals/FolderJournal.py index 316a10f2c..a7d5cbfe1 100644 --- a/jrnl/journals/FolderJournal.py +++ b/jrnl/journals/FolderJournal.py @@ -92,14 +92,14 @@ def delete_entries(self, entries_to_delete: list["Entry"]) -> None: self.entries.remove(entry) self._diff_entry_dates.append(entry.date) - def change_date_entries(self, date: str) -> None: + def change_date_entries(self, date: str, entries_to_change: list["Entry"]) -> None: """Changes entry dates to given date.""" date = time.parse(date) self._diff_entry_dates.append(date) - for entry in self.entries: + for entry in entries_to_change: self._diff_entry_dates.append(entry.date) entry.date = date From bade98ef0d3e59678a0ae2155fcc7d07189d833b Mon Sep 17 00:00:00 2001 From: Micah Jerome Ellison Date: Sat, 28 Jan 2023 14:59:36 -0800 Subject: [PATCH 04/35] Fix user input mock - was not appropriately checking return value --- tests/lib/then_steps.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/lib/then_steps.py b/tests/lib/then_steps.py index 5502787d2..31d5631f5 100644 --- a/tests/lib/then_steps.py +++ b/tests/lib/then_steps.py @@ -190,12 +190,12 @@ def config_var_in_memory( @then("we should be prompted for a password") def password_was_called(cli_run): - assert cli_run["mocks"]["user_input"].called + assert cli_run["mocks"]["user_input"].return_value.input.called @then("we should not be prompted for a password") def password_was_not_called(cli_run): - assert not cli_run["mocks"]["user_input"].called + assert not cli_run["mocks"]["user_input"].return_value.input.called @then(parse("the cache directory should contain the files\n{file_list}")) From c182395fce3d10ffbb19b66676100c538d566162 Mon Sep 17 00:00:00 2001 From: Jonathan Wren Date: Sat, 11 Feb 2023 13:52:53 -0800 Subject: [PATCH 05/35] Clean up controller - streamline `run` function in `controller.py` - add debug logging - fix unnecessary import of Journal class (only needed for typing) - standardize summary display across different actions --- jrnl/controller.py | 77 +++++++++++++++++++---------------- tests/unit/test_controller.py | 8 ++-- 2 files changed, 46 insertions(+), 39 deletions(-) diff --git a/jrnl/controller.py b/jrnl/controller.py index 86a8d566b..5d2165201 100644 --- a/jrnl/controller.py +++ b/jrnl/controller.py @@ -15,7 +15,6 @@ from jrnl.editor import get_text_from_editor from jrnl.editor import get_text_from_stdin from jrnl.exception import JrnlException -from jrnl.journals import Journal from jrnl.journals import open_journal from jrnl.messages import Message from jrnl.messages import MsgStyle @@ -29,6 +28,7 @@ from argparse import Namespace from jrnl.journals import Entry + from jrnl.journals import Journal def run(args: "Namespace"): @@ -79,10 +79,28 @@ def run(args: "Namespace"): append_mode(**kwargs) return + # Get stats now for summary later + old_stats = _get_predit_stats(journal) + logging.debug(f"old_stats: {old_stats}") + # If not append mode, then we're in search mode (only 2 modes exist) search_mode(**kwargs) + _print_entries_found_count(len(journal), args) - # perform actions (if needed) + # Actions + _perform_actions_on_search_results(**kwargs) + + if _has_action_args(args): + _print_edited_summary(journal, old_stats) + else: + # display only occurs if no other action occurs + _display_search_results(**kwargs) + + +def _perform_actions_on_search_results(**kwargs): + args = kwargs["args"] + + # Perform actions (if needed) if args.change_time: _change_time_search_results(**kwargs) @@ -93,12 +111,6 @@ def run(args: "Namespace"): if args.edit: _edit_search_results(**kwargs) - _print_entries_found_count(len(journal), args) - - if not args.edit and not _has_action_args(args): - # display only occurs if no other action occurs - _display_search_results(**kwargs) - def _is_append_mode(args: "Namespace", config: dict, **kwargs) -> bool: """Determines if we are in write mode (as opposed to search mode)""" @@ -107,7 +119,6 @@ def _is_append_mode(args: "Namespace", config: dict, **kwargs) -> bool: not _has_search_args(args) and not _has_action_args(args) and not _has_display_args(args) - and not args.edit ) # Might be writing and want to move to editor part of the way through @@ -121,7 +132,7 @@ def _is_append_mode(args: "Namespace", config: dict, **kwargs) -> bool: return append_mode -def append_mode(args: "Namespace", config: dict, journal: Journal, **kwargs) -> None: +def append_mode(args: "Namespace", config: dict, journal: "Journal", **kwargs) -> None: """ Gets input from the user to write to the journal 1. Check for input from cli @@ -165,7 +176,7 @@ def append_mode(args: "Namespace", config: dict, journal: Journal, **kwargs) -> logging.debug("Append mode: completed journal.write()") -def search_mode(args: "Namespace", journal: Journal, **kwargs) -> None: +def search_mode(args: "Namespace", journal: "Journal", **kwargs) -> None: """ Search for entries in a journal, and return the results. If no search args, then return all results @@ -224,7 +235,7 @@ def _get_editor_template(config: dict, **kwargs) -> str: return template -def _filter_journal_entries(args: "Namespace", journal: Journal, **kwargs) -> None: +def _filter_journal_entries(args: "Namespace", journal: "Journal", **kwargs) -> None: """Filter journal entries in-place based upon search args""" if args.on_date: args.start_date = args.end_date = args.on_date @@ -250,6 +261,7 @@ def _filter_journal_entries(args: "Namespace", journal: Journal, **kwargs) -> No def _print_entries_found_count(count: int, args: "Namespace") -> None: + logging.debug(f"count: {count}") if count == 0: if args.edit or args.change_time: print_msg(Message(MsgText.NothingToModify, MsgStyle.WARNING)) @@ -257,26 +269,28 @@ def _print_entries_found_count(count: int, args: "Namespace") -> None: print_msg(Message(MsgText.NothingToDelete, MsgStyle.WARNING)) else: print_msg(Message(MsgText.NoEntriesFound, MsgStyle.NORMAL)) - elif args.limit: + return + elif args.limit and args.limit == count: # Don't show count if the user expects a limited number of results + logging.debug("args.limit is true-ish") return - elif args.edit or not (args.change_time or args.delete): - # Don't show count if we are ONLY changing the time or deleting entries - my_msg = ( - MsgText.EntryFoundCountSingular - if count == 1 - else MsgText.EntryFoundCountPlural - ) - print_msg(Message(my_msg, MsgStyle.NORMAL, {"num": count})) + + logging.debug("Printing general summary") + my_msg = ( + MsgText.EntryFoundCountSingular + if count == 1 + else MsgText.EntryFoundCountPlural + ) + print_msg(Message(my_msg, MsgStyle.NORMAL, {"num": count})) -def _other_entries(journal: Journal, entries: list["Entry"]) -> list["Entry"]: +def _other_entries(journal: "Journal", entries: list["Entry"]) -> list["Entry"]: """Find entries that are not in journal""" return [e for e in entries if e not in journal.entries] def _edit_search_results( - config: dict, journal: Journal, old_entries: list["Entry"], **kwargs + config: dict, journal: "Journal", old_entries: list["Entry"], **kwargs ) -> None: """ 1. Send the given journal entries to the user-configured editor @@ -295,16 +309,10 @@ def _edit_search_results( # separate entries we are not editing other_entries = _other_entries(journal, old_entries) - # Get stats now for summary later - old_stats = _get_predit_stats(journal) - # Send user to the editor edited = get_text_from_editor(config, journal.editable_str()) journal.parse_editable_str(edited) - # Print summary if available - _print_edited_summary(journal, old_stats) - # Put back entries we separated earlier, sort, and write the journal journal.entries += other_entries journal.sort() @@ -312,7 +320,7 @@ def _edit_search_results( def _print_edited_summary( - journal: Journal, old_stats: dict[str, int], **kwargs + journal: "Journal", old_stats: dict[str, int], **kwargs ) -> None: stats = { "added": len(journal) - old_stats["count"], @@ -352,12 +360,12 @@ def _print_edited_summary( print_msgs(msgs) -def _get_predit_stats(journal: Journal) -> dict[str, int]: +def _get_predit_stats(journal: "Journal") -> dict[str, int]: return {"count": len(journal)} def _delete_search_results( - journal: Journal, old_entries: list["Entry"], **kwargs + journal: "Journal", old_entries: list["Entry"], **kwargs ) -> None: entries_to_delete = journal.prompt_action_entries(MsgText.DeleteEntryQuestion) @@ -370,7 +378,7 @@ def _delete_search_results( def _change_time_search_results( args: "Namespace", - journal: Journal, + journal: "Journal", old_entries: list["Entry"], **kwargs, ) -> None: @@ -386,7 +394,7 @@ def _change_time_search_results( journal.write() -def _display_search_results(args: "Namespace", journal: Journal, **kwargs) -> None: +def _display_search_results(args: "Namespace", journal: "Journal", **kwargs) -> None: # Get export format from config file if not provided at the command line args.export = args.export or kwargs["config"].get("display_format") @@ -431,6 +439,7 @@ def _has_action_args(args: "Namespace") -> bool: ( args.change_time, args.delete, + args.edit, ) ) diff --git a/tests/unit/test_controller.py b/tests/unit/test_controller.py index d60cd2d68..e9eda346b 100644 --- a/tests/unit/test_controller.py +++ b/tests/unit/test_controller.py @@ -18,15 +18,13 @@ def random_string(): @pytest.mark.parametrize("export_format", ["pretty", "short"]) -@mock.patch("builtins.print") -@mock.patch("jrnl.controller.Journal.pprint") -def test_display_search_results_pretty_short(mock_pprint, mock_print, export_format): +def test_display_search_results_pretty_short(export_format): mock_args = parse_args(["--format", export_format]) - test_journal = mock.Mock(wraps=jrnl.journals.Journal) + test_journal = mock.Mock(wraps=jrnl.journals.Journal('default')) _display_search_results(mock_args, test_journal) - mock_print.assert_called_once_with(mock_pprint.return_value) + test_journal.pprint.assert_called_once() @pytest.mark.parametrize( From 752e55d3639775a76ac16b8bc4767352029bbabe Mon Sep 17 00:00:00 2001 From: Micah Jerome Ellison Date: Wed, 15 Feb 2023 11:11:07 -0800 Subject: [PATCH 06/35] Add currently-failing test conditions for count messages when changing time and deleting --- tests/bdd/features/change_time.feature | 3 +++ tests/bdd/features/delete.feature | 12 +++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/tests/bdd/features/change_time.feature b/tests/bdd/features/change_time.feature index 603dfd447..edef2a4cd 100644 --- a/tests/bdd/features/change_time.feature +++ b/tests/bdd/features/change_time.feature @@ -49,6 +49,8 @@ Feature: Change entry times in journal Given we use the config "" When we run "jrnl --change-time now asdfasdf" Then the output should contain "No entries to modify" + And the error output should not contain "entries modified" + And the error output should not contain "entries deleted" When we run "jrnl -99 --short" Then the output should be 2020-08-29 11:11 Entry the first. @@ -187,6 +189,7 @@ Feature: Change entry times in journal Given we use the config "" When we run "jrnl --change-time '2022-04-23 10:30' -contains dignissim" and enter Y + Then the error output should contain "1 entry modified" When we run "jrnl -99 --short" Then the output should be 2020-08-31 14:32 A second entry in what I hope to be a long series. diff --git a/tests/bdd/features/delete.feature b/tests/bdd/features/delete.feature index f147a8b16..28be63c33 100644 --- a/tests/bdd/features/delete.feature +++ b/tests/bdd/features/delete.feature @@ -11,6 +11,7 @@ Feature: Delete entries from journal N N Y + Then the error output should contain "1 entry deleted" When we run "jrnl -99 --short" Then the output should be 2020-08-29 11:11 Entry the first. @@ -28,6 +29,7 @@ Feature: Delete entries from journal Given we use the config "" When we run "jrnl --delete -n 1" and enter N + Then the error output should not contain "deleted" When we run "jrnl -99 --short" Then the output should be 2020-08-29 11:11 Entry the first. @@ -44,7 +46,7 @@ Feature: Delete entries from journal Scenario Outline: Delete flag with nonsense input deletes nothing (issue #932) Given we use the config "" When we run "jrnl --delete asdfasdf" - Then the output should contain "No entries to delete" + Then the error output should contain "No entries to delete" When we run "jrnl -99 --short" Then the output should be 2020-08-29 11:11 Entry the first. @@ -62,6 +64,7 @@ Feature: Delete entries from journal Given we use the config "" When we run "jrnl --delete @ipsum" and enter Y + Then the error output should contain "1 entry deleted" When we run "jrnl -99 --short" Then the output should be 2020-08-31 14:32 A second entry in what I hope to be a long series. @@ -79,6 +82,7 @@ Feature: Delete entries from journal When we run "jrnl --delete @ipsum @tagthree" and enter Y Y + Then the error output should contain "2 entries deleted" When we run "jrnl -99 --short" Then the output should be 2020-08-31 14:32 A second entry in what I hope to be a long series. @@ -94,6 +98,7 @@ Feature: Delete entries from journal Given we use the config "" When we run "jrnl --delete -and @tagone @tagtwo" and enter Y + Then the error output should contain "1 entry deleted" When we run "jrnl -99 --short" Then the output should be 2020-08-31 14:32 A second entry in what I hope to be a long series. @@ -110,6 +115,7 @@ Feature: Delete entries from journal Given we use the config "" When we run "jrnl --delete @tagone -not @ipsum" and enter Y + Then the error output should contain "1 entry deleted" When we run "jrnl -99 --short" Then the output should be 2020-08-29 11:11 Entry the first. @@ -126,6 +132,7 @@ Feature: Delete entries from journal Given we use the config "" When we run "jrnl --delete -from 2020-09-01" and enter Y + Then the error output should contain "1 entry deleted" When we run "jrnl -99 --short" Then the output should be 2020-08-29 11:11 Entry the first. @@ -143,6 +150,7 @@ Feature: Delete entries from journal When we run "jrnl --delete -to 2020-08-31" and enter Y Y + Then the error output should contain "2 entries deleted" When we run "jrnl -99 --short" Then the output should be 2020-09-24 09:14 The third entry finally after weeks without writing. @@ -158,6 +166,7 @@ Feature: Delete entries from journal Given we use the config "" When we run "jrnl --delete -starred" and enter Y + Then the error output should contain "1 entry deleted" When we run "jrnl -99 --short" Then the output should be 2020-08-29 11:11 Entry the first. @@ -174,6 +183,7 @@ Feature: Delete entries from journal Given we use the config "" When we run "jrnl --delete -contains dignissim" and enter Y + Then the error output should contain "1 entry deleted" When we run "jrnl -99 --short" Then the output should be 2020-08-31 14:32 A second entry in what I hope to be a long series. From d341f3240c31bb7467651e331df140bde8e5f6cc Mon Sep 17 00:00:00 2001 From: Micah Jerome Ellison Date: Sat, 18 Feb 2023 12:19:08 -0800 Subject: [PATCH 07/35] Don't show summary if no entries found and prevent extra line break when no entries found by short-circuiting display method --- jrnl/controller.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/jrnl/controller.py b/jrnl/controller.py index 5d2165201..c63b88acb 100644 --- a/jrnl/controller.py +++ b/jrnl/controller.py @@ -90,7 +90,8 @@ def run(args: "Namespace"): # Actions _perform_actions_on_search_results(**kwargs) - if _has_action_args(args): + + if len(journal.entries) != 0 and _has_action_args(args): _print_edited_summary(journal, old_stats) else: # display only occurs if no other action occurs @@ -395,6 +396,9 @@ def _change_time_search_results( def _display_search_results(args: "Namespace", journal: "Journal", **kwargs) -> None: + if len(journal.entries) == 0: + return + # Get export format from config file if not provided at the command line args.export = args.export or kwargs["config"].get("display_format") From 0c439200f4fdf57a5c48bc67bea1d193923f5f57 Mon Sep 17 00:00:00 2001 From: Micah Jerome Ellison Date: Sat, 18 Feb 2023 12:42:12 -0800 Subject: [PATCH 08/35] Track found entry count and remove incorrect modified stat logic --- jrnl/controller.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/jrnl/controller.py b/jrnl/controller.py index c63b88acb..7b3ee9104 100644 --- a/jrnl/controller.py +++ b/jrnl/controller.py @@ -85,13 +85,13 @@ def run(args: "Namespace"): # If not append mode, then we're in search mode (only 2 modes exist) search_mode(**kwargs) - _print_entries_found_count(len(journal), args) + entries_found_count = len(journal) + _print_entries_found_count(entries_found_count, args) # Actions _perform_actions_on_search_results(**kwargs) - - if len(journal.entries) != 0 and _has_action_args(args): + if entries_found_count != 0 and _has_action_args(args): _print_edited_summary(journal, old_stats) else: # display only occurs if no other action occurs @@ -328,7 +328,6 @@ def _print_edited_summary( "deleted": old_stats["count"] - len(journal), "modified": len([e for e in journal.entries if e.modified]), } - stats["modified"] -= stats["added"] msgs = [] if stats["added"] > 0: From 2e06e3d4eb3bec40070ffe15c7803a2a7e107d51 Mon Sep 17 00:00:00 2001 From: Micah Jerome Ellison Date: Sat, 18 Feb 2023 13:05:14 -0800 Subject: [PATCH 09/35] Track journal entry deletion consistently --- jrnl/controller.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/jrnl/controller.py b/jrnl/controller.py index 7b3ee9104..fee04199a 100644 --- a/jrnl/controller.py +++ b/jrnl/controller.py @@ -369,8 +369,9 @@ def _delete_search_results( ) -> None: entries_to_delete = journal.prompt_action_entries(MsgText.DeleteEntryQuestion) + journal.entries = old_entries + if entries_to_delete: - journal.entries = old_entries journal.delete_entries(entries_to_delete) journal.write() From 1873f69ded260afa1a5e6012a974597d695c5980 Mon Sep 17 00:00:00 2001 From: Micah Jerome Ellison Date: Sat, 18 Feb 2023 13:28:31 -0800 Subject: [PATCH 10/35] Remove unneeded exception when editor is empty and fix test that was testing incorrect message --- jrnl/editor.py | 3 --- tests/bdd/features/write.feature | 16 ++++++++-------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/jrnl/editor.py b/jrnl/editor.py index b6799b410..89fcb35d6 100644 --- a/jrnl/editor.py +++ b/jrnl/editor.py @@ -44,9 +44,6 @@ def get_text_from_editor(config: dict, template: str = "") -> str: raw = f.read() os.remove(tmpfile) - if not raw: - raise JrnlException(Message(MsgText.NoTextReceived, MsgStyle.NORMAL)) - return raw diff --git a/tests/bdd/features/write.feature b/tests/bdd/features/write.feature index ecc1965c9..f15f39e10 100644 --- a/tests/bdd/features/write.feature +++ b/tests/bdd/features/write.feature @@ -78,19 +78,19 @@ Feature: Writing new entries. Scenario Outline: Writing an empty entry from the editor should yield "No entry to save" message Given we use the config "" - And we write nothing to the editor if opened + And we append nothing to the editor if opened And we use the password "test" if prompted When we run "jrnl --edit" - Then the error output should contain "No entry to save, because no text was received" + Then the error output should contain "No edits to save, because nothing was changed" And the editor should have been called Examples: configs - | config_file | - | editor.yaml | - | editor_empty_folder.yaml | - | dayone.yaml | - | basic_encrypted.yaml | - | basic_onefile.yaml | + | config_file | + | editor.yaml | + | basic_onefile.yaml | + | basic_encrypted.yaml | + | basic_dayone.yaml | + | basic_folder.yaml | Scenario Outline: Writing an empty entry from the command line should yield "No entry to save" message Given we use the config "" From 80a4b0778f57d5e21402b9655469feb0ed1855dd Mon Sep 17 00:00:00 2001 From: Micah Jerome Ellison Date: Sat, 18 Feb 2023 13:32:39 -0800 Subject: [PATCH 11/35] Correct entry edit modified count test --- tests/bdd/features/write.feature | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bdd/features/write.feature b/tests/bdd/features/write.feature index f15f39e10..7796dde09 100644 --- a/tests/bdd/features/write.feature +++ b/tests/bdd/features/write.feature @@ -271,7 +271,7 @@ Feature: Writing new entries. [2021-11-13] I am replacing my whole journal with this entry When we run "jrnl --edit" Then the output should contain "2 entries deleted" - Then the output should contain "3 entries modified" + Then the output should contain "1 entry modified" Examples: configs | config_file | From 742a16b548804e60cc4c786c0bbb0031d5ef9d00 Mon Sep 17 00:00:00 2001 From: Micah Jerome Ellison Date: Sat, 18 Feb 2023 13:36:19 -0800 Subject: [PATCH 12/35] Track modification of entries with --change-time --- jrnl/journals/FolderJournal.py | 1 + jrnl/journals/Journal.py | 1 + 2 files changed, 2 insertions(+) diff --git a/jrnl/journals/FolderJournal.py b/jrnl/journals/FolderJournal.py index a7d5cbfe1..2eca0978d 100644 --- a/jrnl/journals/FolderJournal.py +++ b/jrnl/journals/FolderJournal.py @@ -102,6 +102,7 @@ def change_date_entries(self, date: str, entries_to_change: list["Entry"]) -> No for entry in entries_to_change: self._diff_entry_dates.append(entry.date) entry.date = date + entry.modified = True def parse_editable_str(self, edited: str) -> None: """Parses the output of self.editable_str and updates its entries.""" diff --git a/jrnl/journals/Journal.py b/jrnl/journals/Journal.py index b1c2586bf..fb5d9e333 100644 --- a/jrnl/journals/Journal.py +++ b/jrnl/journals/Journal.py @@ -307,6 +307,7 @@ def change_date_entries( for entry in entries_to_change: entry.date = date + entry.modified = True def prompt_action_entries(self, msg: MsgText) -> list[Entry]: """Prompts for action for each entry in a journal, using given message. From cac3b43e19a0217b80232d9678a0c36805b1928a Mon Sep 17 00:00:00 2001 From: Micah Jerome Ellison Date: Sat, 18 Feb 2023 14:34:17 -0800 Subject: [PATCH 13/35] Preserve existing behavior when editor is empty but make the message more clear --- jrnl/controller.py | 9 ++++++++- jrnl/editor.py | 3 +++ jrnl/exception.py | 4 ++++ jrnl/messages/MsgText.py | 8 ++++++++ 4 files changed, 23 insertions(+), 1 deletion(-) diff --git a/jrnl/controller.py b/jrnl/controller.py index fee04199a..e89e01762 100644 --- a/jrnl/controller.py +++ b/jrnl/controller.py @@ -311,7 +311,14 @@ def _edit_search_results( other_entries = _other_entries(journal, old_entries) # Send user to the editor - edited = get_text_from_editor(config, journal.editable_str()) + try: + edited = get_text_from_editor(config, journal.editable_str()) + except JrnlException as e: + if e.has_message_text(MsgText.NoTextReceived): + raise JrnlException(Message(MsgText.NoEditsReceivedJournalNotDeleted, MsgStyle.WARNING)) + else: + raise e + journal.parse_editable_str(edited) # Put back entries we separated earlier, sort, and write the journal diff --git a/jrnl/editor.py b/jrnl/editor.py index 89fcb35d6..b6799b410 100644 --- a/jrnl/editor.py +++ b/jrnl/editor.py @@ -44,6 +44,9 @@ def get_text_from_editor(config: dict, template: str = "") -> str: raw = f.read() os.remove(tmpfile) + if not raw: + raise JrnlException(Message(MsgText.NoTextReceived, MsgStyle.NORMAL)) + return raw diff --git a/jrnl/exception.py b/jrnl/exception.py index 2679c855b..2178ea18d 100644 --- a/jrnl/exception.py +++ b/jrnl/exception.py @@ -7,6 +7,7 @@ if TYPE_CHECKING: from jrnl.messages import Message + from jrnl.messages import MsgText class JrnlException(Exception): @@ -18,3 +19,6 @@ def __init__(self, *messages: "Message"): def print(self) -> None: for msg in self.messages: print_msg(msg) + + def has_message_text(self, message_text: "MsgText"): + return any([m.text == message_text for m in self.messages]) \ No newline at end of file diff --git a/jrnl/messages/MsgText.py b/jrnl/messages/MsgText.py index 4a8b6c61d..b7eca9101 100644 --- a/jrnl/messages/MsgText.py +++ b/jrnl/messages/MsgText.py @@ -151,6 +151,14 @@ def __str__(self) -> str: https://jrnl.sh/en/stable/external-editors/ """ + NoEditsReceivedJournalNotDeleted = """ + No text received from editor. Were you trying to delete all the entries? + + This seems a bit drastic, so the operation was cancelled. + + To delete all entries, use the --delete option. + """ + NoEditsReceived = "No edits to save, because nothing was changed" NoTextReceived = """ From 46b7a64549e485d8139ee9403ef1363a25472c14 Mon Sep 17 00:00:00 2001 From: Micah Jerome Ellison Date: Sat, 18 Feb 2023 14:41:09 -0800 Subject: [PATCH 14/35] Reconcile tests with new error message when clearing editor in edit mode --- tests/bdd/features/change_time.feature | 2 +- tests/bdd/features/write.feature | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/bdd/features/change_time.feature b/tests/bdd/features/change_time.feature index edef2a4cd..fa4584877 100644 --- a/tests/bdd/features/change_time.feature +++ b/tests/bdd/features/change_time.feature @@ -231,7 +231,7 @@ Feature: Change entry times in journal Y N Y - Then the error output should contain "No entry to save" + Then the error output should contain "No text received from editor. Were you trying to delete all the entries?" And the editor should have been called When we run "jrnl -99 --short" Then the output should be diff --git a/tests/bdd/features/write.feature b/tests/bdd/features/write.feature index 7796dde09..d1baa4ca0 100644 --- a/tests/bdd/features/write.feature +++ b/tests/bdd/features/write.feature @@ -76,12 +76,12 @@ Feature: Writing new entries. | basic_dayone.yaml | | basic_folder.yaml | - Scenario Outline: Writing an empty entry from the editor should yield "No entry to save" message + Scenario Outline: Clearing the editor's contents should yield "No text received" message Given we use the config "" - And we append nothing to the editor if opened + And we write nothing to the editor if opened And we use the password "test" if prompted When we run "jrnl --edit" - Then the error output should contain "No edits to save, because nothing was changed" + Then the error output should contain "No text received from editor. Were you trying to delete all the entries?" And the editor should have been called Examples: configs From 8ee1632760bd9f28ecbed4c4cbab47137acf541f Mon Sep 17 00:00:00 2001 From: Micah Jerome Ellison Date: Sat, 18 Feb 2023 15:06:23 -0800 Subject: [PATCH 15/35] Fix unit test that did not account for new short-circuit when displaying journal with no entries --- jrnl/controller.py | 2 +- tests/unit/test_controller.py | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/jrnl/controller.py b/jrnl/controller.py index e89e01762..d5f483d33 100644 --- a/jrnl/controller.py +++ b/jrnl/controller.py @@ -403,7 +403,7 @@ def _change_time_search_results( def _display_search_results(args: "Namespace", journal: "Journal", **kwargs) -> None: - if len(journal.entries) == 0: + if len(journal) == 0: return # Get export format from config file if not provided at the command line diff --git a/tests/unit/test_controller.py b/tests/unit/test_controller.py index e9eda346b..39b8ddf76 100644 --- a/tests/unit/test_controller.py +++ b/tests/unit/test_controller.py @@ -20,7 +20,11 @@ def random_string(): @pytest.mark.parametrize("export_format", ["pretty", "short"]) def test_display_search_results_pretty_short(export_format): mock_args = parse_args(["--format", export_format]) - test_journal = mock.Mock(wraps=jrnl.journals.Journal('default')) + + test_journal = jrnl.journals.Journal() + test_journal.new_entry("asdf") + + test_journal.pprint = mock.Mock() _display_search_results(mock_args, test_journal) From 2be80a5fa00f6ef42c46ae78bc36460382cca79d Mon Sep 17 00:00:00 2001 From: Micah Jerome Ellison Date: Sat, 18 Feb 2023 15:14:06 -0800 Subject: [PATCH 16/35] Fix other unit test that did not account for short-circuit --- tests/unit/test_controller.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_controller.py b/tests/unit/test_controller.py index 39b8ddf76..c6c1b4d84 100644 --- a/tests/unit/test_controller.py +++ b/tests/unit/test_controller.py @@ -42,7 +42,9 @@ def test_display_search_results_builtin_plugins( test_filename = random_string mock_args = parse_args(["--format", export_format, "--file", test_filename]) - test_journal = mock.Mock(wraps=jrnl.journals.Journal) + test_journal = jrnl.journals.Journal() + test_journal.new_entry("asdf") + mock_export = mock.Mock() mock_exporter.return_value.export = mock_export From 905a9ec37e44f5affab5a42a69a5fd715e423485 Mon Sep 17 00:00:00 2001 From: Giuseppe D'Andrea Date: Sat, 11 Feb 2023 21:10:18 +0100 Subject: [PATCH 17/35] Update documentation on temporary files naming (#1673) --- docs/privacy-and-security.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/privacy-and-security.md b/docs/privacy-and-security.md index a55e6fa37..32cf8ac3c 100644 --- a/docs/privacy-and-security.md +++ b/docs/privacy-and-security.md @@ -78,7 +78,7 @@ unencrypted temporary remains on your disk. If your computer were to shut off during this time, or the `jrnl` process were killed unexpectedly, then the unencrypted temporary file will remain on your disk. You can mitigate this issue by only saving with your editor right before closing it. You can also -manually delete these files (i.e. files named `jrnl_*.txt`) from your temporary +manually delete these files (i.e. files named `jrnl*.jrnl`) from your temporary folder. ## Plausible deniability From 99c29f20517727bdaf3368f2f95200ff65afaf33 Mon Sep 17 00:00:00 2001 From: Jrnl Bot Date: Sat, 11 Feb 2023 20:12:27 +0000 Subject: [PATCH 18/35] Update changelog [ci skip] --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dff0f843d..b7ae3e644 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ **Documentation:** - Documentation Change [\#1651](https://github.com/jrnl-org/jrnl/issues/1651) +- Update documentation on temporary files naming [\#1673](https://github.com/jrnl-org/jrnl/pull/1673) ([giuseppedandrea](https://github.com/giuseppedandrea)) - Update docs to include time and title in arguments with `--edit` [\#1657](https://github.com/jrnl-org/jrnl/pull/1657) ([pconrad-fb](https://github.com/pconrad-fb)) - Fix markup in "Advanced Usage" doc [\#1655](https://github.com/jrnl-org/jrnl/pull/1655) ([multani](https://github.com/multani)) - Remove Windows 7 known issue since Windows 7 is no longer supported [\#1636](https://github.com/jrnl-org/jrnl/pull/1636) ([micahellison](https://github.com/micahellison)) From 3ec0839748c914f2488540648f82a592d1bb00be Mon Sep 17 00:00:00 2001 From: David Isaksson Date: Sat, 11 Feb 2023 21:16:31 +0100 Subject: [PATCH 19/35] Add documentation about information leaks in Vim/Neovim (#1674) * Add documentation about using Vim/Neovim as editor * Add documentation about information leaks in editors * Spelling fix --------- Co-authored-by: Jonathan Wren --- docs/external-editors.md | 14 +++++++ docs/privacy-and-security.md | 78 ++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/docs/external-editors.md b/docs/external-editors.md index 977a9de21..b7219d8d9 100644 --- a/docs/external-editors.md +++ b/docs/external-editors.md @@ -37,6 +37,9 @@ jrnl yesterday: All my troubles seemed so far away. --edit All editors must be [blocking processes](https://en.wikipedia.org/wiki/Blocking_(computing)) to work with jrnl. Some editors, such as [micro](https://micro-editor.github.io/), are blocking by default, though others can be made to block with additional arguments, such as many of those documented below. If jrnl opens your editor but finishes running immediately, then your editor is not a blocking process, and you may be able to correct that with one of the suggestions below. +Please see [this section](./privacy-and-security.md#editor-history) about how +your editor might leak sensitive information and how to mitigate that risk. + ## Sublime Text To use [Sublime Text](https://www.sublimetext.com/), install the command line @@ -71,6 +74,17 @@ back to journal. In the case of MacVim, this is `-f`: editor: "mvim -f" ``` +## Vim/Neovim + +To use any of the Vim derivatives as editor in Linux, simply set the `editor` +to the executable: + +```yaml +editor: "vim" +# or +editor: "nvim" +``` + ## iA Writer On OS X, you can use the fabulous [iA diff --git a/docs/privacy-and-security.md b/docs/privacy-and-security.md index 32cf8ac3c..c767c5e04 100644 --- a/docs/privacy-and-security.md +++ b/docs/privacy-and-security.md @@ -67,6 +67,84 @@ Windows doesn't log history to disk, but it does keep it in your command prompt session. Close the command prompt or press `Alt`+`F7` to clear your history after journaling. +## Editor history + +Some editors keep usage history stored on disk for future use. This can be a +security risk in the sense that sensitive information can leak via recent +search patterns or editor commands. + +### Vim + +Vim stores progress data in a so called Viminfo file located at `~/.viminfo` +which contains all sorts of user data including command line history, search +string history, search/substitute patterns, contents of register etc. Also to +be able to recover opened files after an unexpected application close Vim uses +swap files. + +These options as well as other leaky features can be disabled by setting the +`editor` key in the Jrnl settings like this: + +``` yaml +editor: "vim -c 'set viminfo= noswapfile noundofile nobackup nowritebackup noshelltemp history=0 nomodeline secure'" +``` + +To disable all plugins and custom configurations and start Vim with the default +configuration `-u NONE` can be passed on the command line as well. This will +ensure that any rogue plugins or other difficult to catch information leaks are +eliminated. The downside to this is that the editor experience will decrease +quite a bit. + +To instead let Vim automatically detect when a Jrnl file is being edited an +autocommand can be used. Place this in your `~/.vimrc`: + +``` vim +autocmd BufNewFile,BufReadPre *.jrnl setlocal viminfo= noswapfile noundofile nobackup nowritebackup noshelltemp history=0 nomodeline secure +``` + +Please see `:h