Allow editing of DayOne entries (#1001)

* add test to repro issue #955

* Allow editing of DayOne entries

* Add broken test for Dayone

Add test for editing Dayone entries (this test currently fails)

Co-authored-by: Jonathan Wren <jonathan@nowandwren.com>

* Fix editing logic for DayOneJournal

DayOneJournal previously reimplemented Journal._parse inside of
DayOneJournal.parse_editable_string, and in doing so caused issues
between itself and the class it was inheriting from. This commit fixes
the issue by moving the UUID to be in the body of the entry, rather than
above it. So, then Journal._parse still finds the correct boundaries
between entries, and DayOneJournal then parses the UUID afterward.

Co-authored-by: MinchinWeb <w_minchin@hotmail.com>
Co-authored-by: Micah Jerome Ellison <micah.jerome.ellison@gmail.com>
This commit is contained in:
Jonathan Wren 2020-07-18 12:54:52 -07:00 committed by GitHub
parent 4c2f2861db
commit a11aa24c7e
5 changed files with 85 additions and 82 deletions

View file

@ -1,6 +1,6 @@
default_hour: 9
default_minute: 0
editor: ''
editor: noop
template: false
encrypt: false
highlight: true

View file

@ -75,3 +75,21 @@ Feature: Dayone specific implementation details.
and the json output should contain entries.0.creator.generation_date
and the json output should contain entries.0.creator.device_agent
and "entries.0.creator.software_agent" in the json output should contain "jrnl"
Scenario: Editing Dayone with mock editor
Given we use the config "dayone.yaml"
When we run "jrnl --edit"
Then we should get no error
Scenario: Editing Dayone entries
Given we use the config "dayone.yaml"
When we open the editor and append
"""
Here is the first line.
Here is the second line.
"""
When we run "jrnl -n 1"
Then we should get no error
and the output should contain "This entry is starred!"
and the output should contain "Here is the first line"
and the output should contain "Here is the second line"

View file

@ -101,15 +101,24 @@ def move_up_dir(context, path):
os.chdir(path)
@when('we open the editor and enter "{text}"')
@when("we open the editor and enter nothing")
def open_editor_and_enter(context, text=""):
@when("we open the editor and {method}")
@when('we open the editor and {method} "{text}"')
@when("we open the editor and {method} nothing")
@when("we open the editor and {method} nothing")
def open_editor_and_enter(context, method, text=""):
text = text or context.text or ""
if method == "enter":
file_method = "w+"
elif method == "append":
file_method = "a"
else:
file_method = "r+"
def _mock_editor_function(command):
context.editor_command = command
tmpfile = command[-1]
with open(tmpfile, "w+") as f:
with open(tmpfile, file_method) as f:
f.write(text)
return tmpfile
@ -120,7 +129,7 @@ def open_editor_and_enter(context, text=""):
patch("subprocess.call", side_effect=_mock_editor_function), \
patch("sys.stdin.isatty", return_value=True) \
:
context.execute_steps('when we run "jrnl"')
cli.run(["--edit"])
# fmt: on
@ -209,8 +218,13 @@ def run(context, command, cache_dir=None):
args = ushlex(command)
def _mock_editor(command):
context.editor_command = command
try:
with patch("sys.argv", args):
with patch("sys.argv", args), patch(
"subprocess.call", side_effect=_mock_editor
):
cli.run(args[1:])
context.exit_status = 0
except SystemExit as e:
@ -282,7 +296,7 @@ def check_output_version_inline(context):
def check_output_inline(context, text=None, text2=None):
text = text or context.text
out = context.stdout_capture.getvalue()
assert text in out or text2 in out, text or text2
assert (text and text in out) or (text2 and text2 in out)
@then("the error output should contain")

View file

@ -16,7 +16,6 @@ import pytz
import tzlocal
from . import __title__, __version__, Entry, Journal
from . import time as jrnl_time
class DayOne(Journal.Journal):
@ -179,7 +178,26 @@ class DayOne(Journal.Journal):
def editable_str(self):
"""Turns the journal into a string of entries that can be edited
manually and later be parsed with eslf.parse_editable_str."""
return "\n".join([f"# {e.uuid}\n{str(e)}" for e in self.entries])
return "\n".join([f"{str(e)}\n# {e.uuid}\n" for e in self.entries])
def _update_old_entry(self, entry, new_entry):
for attr in ("title", "body", "date"):
old_attr = getattr(entry, attr)
new_attr = getattr(new_entry, attr)
if old_attr != new_attr:
entry.modified = True
setattr(entry, attr, new_attr)
def _get_and_remove_uuid_from_entry(self, entry):
uuid_regex = "^ *?# ([a-zA-Z0-9]+) *?$"
m = re.search(uuid_regex, entry.body, re.MULTILINE)
entry.uuid = m.group(1) if m else None
# remove the uuid from the body
entry.body = re.sub(uuid_regex, "", entry.body, flags=re.MULTILINE, count=1)
entry.body = entry.body.rstrip()
return entry
def parse_editable_str(self, edited):
"""Parses the output of self.editable_str and updates its entries."""
@ -187,79 +205,18 @@ class DayOne(Journal.Journal):
# UUIDs of the new entries against self.entries, updating the entries
# if the edited entries differ, and deleting entries from self.entries
# if they don't show up in the edited entries anymore.
entries_from_editor = self._parse(edited)
# Initialise our current entry
entries = []
current_entry = None
for entry in entries_from_editor:
entry = self._get_and_remove_uuid_from_entry(entry)
for line in edited.splitlines():
# try to parse line as UUID => new entry begins
line = line.rstrip()
m = re.match("# *([a-f0-9]+) *$", line.lower())
if m:
if current_entry:
entries.append(current_entry)
current_entry = Entry.Entry(self)
current_entry.modified = False
current_entry.uuid = m.group(1).lower()
else:
date_blob_re = re.compile("^\\[[^\\]]+\\] ")
date_blob = date_blob_re.findall(line)
if date_blob:
date_blob = date_blob[0]
new_date = jrnl_time.parse(date_blob.strip(" []"))
if line.endswith("*"):
current_entry.starred = True
line = line[:-1]
current_entry.title = line[len(date_blob) - 1 :].strip()
current_entry.date = new_date
elif current_entry:
current_entry.body += line + "\n"
# Append last entry
if current_entry:
entries.append(current_entry)
# Now, update our current entries if they changed
for entry in entries:
entry._parse_text()
matched_entries = [
e for e in self.entries if e.uuid.lower() == entry.uuid.lower()
]
# tags in entry body
if matched_entries:
# This entry is an existing entry
match = matched_entries[0]
# merge existing tags with tags pulled from the entry body
entry.tags = list(set(entry.tags + match.tags))
# extended Dayone metadata
if hasattr(match, "creator_device_agent"):
entry.creator_device_agent = match.creator_device_agent
if hasattr(match, "creator_generation_date"):
entry.creator_generation_date = match.creator_generation_date
if hasattr(match, "creator_host_name"):
entry.creator_host_name = match.creator_host_name
if hasattr(match, "creator_os_agent"):
entry.creator_os_agent = match.creator_os_agent
if hasattr(match, "creator_software_agent"):
entry.creator_software_agent = match.creator_software_agent
if hasattr(match, "location"):
entry.location = match.location
if hasattr(match, "weather"):
entry.weather = match.weather
if match != entry:
self.entries.remove(match)
entry.modified = True
self.entries.append(entry)
else:
# This entry seems to be new... save it.
entry.modified = True
self.entries.append(entry)
# Remove deleted entries
edited_uuids = [e.uuid for e in entries]
edited_uuids = [e.uuid for e in entries_from_editor]
self._deleted_entries = [e for e in self.entries if e.uuid not in edited_uuids]
self.entries[:] = [e for e in self.entries if e.uuid in edited_uuids]
return entries
for entry in entries_from_editor:
for old_entry in self.entries:
if entry.uuid == old_entry.uuid:
self._update_old_entry(old_entry, entry)
break

View file

@ -13,7 +13,9 @@ class Entry:
self.journal = journal # Reference to journal mainly to access its config
self.date = date or datetime.now()
self.text = text
self._title = self._body = self._tags = None
self._title = None
self._body = None
self._tags = None
self.starred = starred
self.modified = False
@ -37,18 +39,30 @@ class Entry:
self._parse_text()
return self._title
@title.setter
def title(self, x):
self._title = x
@property
def body(self):
if self._body is None:
self._parse_text()
return self._body
@body.setter
def body(self, x):
self._body = x
@property
def tags(self):
if self._tags is None:
self._parse_text()
return self._tags
@tags.setter
def tags(self, x):
self._tags = x
@staticmethod
def tag_regex(tagsymbols):
pattern = fr"(?<!\S)([{tagsymbols}][-+*#/\w]+)"