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_hour: 9
default_minute: 0 default_minute: 0
editor: '' editor: noop
template: false template: false
encrypt: false encrypt: false
highlight: true 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.generation_date
and the json output should contain entries.0.creator.device_agent and the json output should contain entries.0.creator.device_agent
and "entries.0.creator.software_agent" in the json output should contain "jrnl" 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) os.chdir(path)
@when('we open the editor and enter "{text}"') @when("we open the editor and {method}")
@when("we open the editor and enter nothing") @when('we open the editor and {method} "{text}"')
def open_editor_and_enter(context, 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 "" 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): def _mock_editor_function(command):
context.editor_command = command context.editor_command = command
tmpfile = command[-1] tmpfile = command[-1]
with open(tmpfile, "w+") as f: with open(tmpfile, file_method) as f:
f.write(text) f.write(text)
return tmpfile return tmpfile
@ -120,7 +129,7 @@ def open_editor_and_enter(context, text=""):
patch("subprocess.call", side_effect=_mock_editor_function), \ patch("subprocess.call", side_effect=_mock_editor_function), \
patch("sys.stdin.isatty", return_value=True) \ patch("sys.stdin.isatty", return_value=True) \
: :
context.execute_steps('when we run "jrnl"') cli.run(["--edit"])
# fmt: on # fmt: on
@ -209,8 +218,13 @@ def run(context, command, cache_dir=None):
args = ushlex(command) args = ushlex(command)
def _mock_editor(command):
context.editor_command = command
try: try:
with patch("sys.argv", args): with patch("sys.argv", args), patch(
"subprocess.call", side_effect=_mock_editor
):
cli.run(args[1:]) cli.run(args[1:])
context.exit_status = 0 context.exit_status = 0
except SystemExit as e: except SystemExit as e:
@ -282,7 +296,7 @@ def check_output_version_inline(context):
def check_output_inline(context, text=None, text2=None): def check_output_inline(context, text=None, text2=None):
text = text or context.text text = text or context.text
out = context.stdout_capture.getvalue() 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") @then("the error output should contain")

View file

@ -16,7 +16,6 @@ import pytz
import tzlocal import tzlocal
from . import __title__, __version__, Entry, Journal from . import __title__, __version__, Entry, Journal
from . import time as jrnl_time
class DayOne(Journal.Journal): class DayOne(Journal.Journal):
@ -179,7 +178,26 @@ class DayOne(Journal.Journal):
def editable_str(self): def editable_str(self):
"""Turns the journal into a string of entries that can be edited """Turns the journal into a string of entries that can be edited
manually and later be parsed with eslf.parse_editable_str.""" 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): def parse_editable_str(self, edited):
"""Parses the output of self.editable_str and updates its entries.""" """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 # UUIDs of the new entries against self.entries, updating the entries
# if the edited entries differ, and deleting entries from self.entries # if the edited entries differ, and deleting entries from self.entries
# if they don't show up in the edited entries anymore. # if they don't show up in the edited entries anymore.
entries_from_editor = self._parse(edited)
# Initialise our current entry for entry in entries_from_editor:
entries = [] entry = self._get_and_remove_uuid_from_entry(entry)
current_entry = None
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 # 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._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] 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.journal = journal # Reference to journal mainly to access its config
self.date = date or datetime.now() self.date = date or datetime.now()
self.text = text self.text = text
self._title = self._body = self._tags = None self._title = None
self._body = None
self._tags = None
self.starred = starred self.starred = starred
self.modified = False self.modified = False
@ -37,18 +39,30 @@ class Entry:
self._parse_text() self._parse_text()
return self._title return self._title
@title.setter
def title(self, x):
self._title = x
@property @property
def body(self): def body(self):
if self._body is None: if self._body is None:
self._parse_text() self._parse_text()
return self._body return self._body
@body.setter
def body(self, x):
self._body = x
@property @property
def tags(self): def tags(self):
if self._tags is None: if self._tags is None:
self._parse_text() self._parse_text()
return self._tags return self._tags
@tags.setter
def tags(self, x):
self._tags = x
@staticmethod @staticmethod
def tag_regex(tagsymbols): def tag_regex(tagsymbols):
pattern = fr"(?<!\S)([{tagsymbols}][-+*#/\w]+)" pattern = fr"(?<!\S)([{tagsymbols}][-+*#/\w]+)"