Merge branch 'develop' into change-cwd-bug

This commit is contained in:
Jonathan Wren 2020-06-06 12:53:41 -07:00 committed by GitHub
commit 15ad02dc85
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 261 additions and 35 deletions

1
.gitignore vendored
View file

@ -43,6 +43,7 @@ obj
env/ env/
env*/ env*/
venv*/ venv*/
.venv*/
# PyCharm Project files # PyCharm Project files
.idea/ .idea/

View file

@ -1,5 +1,24 @@
# Changelog # Changelog
## [v2.4](https://pypi.org/project/jrnl/v2.4/) (2020-04-25)
[Full Changelog](https://github.com/jrnl-org/jrnl/compare/v2.4-beta...v2.4)
**Implemented enhancements:**
- Upgrade license to GPLv3 [\#918](https://github.com/jrnl-org/jrnl/pull/918) ([wren](https://github.com/wren))
**Build:**
- Update makefile to match pipeline better [\#919](https://github.com/jrnl-org/jrnl/pull/919) ([wren](https://github.com/wren))
**Updated documentation:**
- Clean up readme file [\#924](https://github.com/jrnl-org/jrnl/pull/924) ([wren](https://github.com/wren))
- Clarify that editing config isn't always destructive [\#923](https://github.com/jrnl-org/jrnl/pull/923) ([Epskampie](https://github.com/Epskampie))
# Changelog
## [Unreleased](https://github.com/jrnl-org/jrnl/) ## [Unreleased](https://github.com/jrnl-org/jrnl/)
[Full Changelog](https://github.com/jrnl-org/jrnl/compare/v2.4.2...HEAD) [Full Changelog](https://github.com/jrnl-org/jrnl/compare/v2.4.2...HEAD)

View file

@ -1,27 +1,41 @@
# Overview # Overview
## What is jrnl? ## Features
`jrnl` is a simple journal application for ### Command-Line Interface
your command line. Journals are stored as human readable plain text
files - you can put them into a Dropbox folder for instant syncing and
you can be assured that your journal will still be readable in 2050,
when all your fancy iPad journal applications will long be forgotten.
Optionally, your journal can be encrypted using the [256-bit `jrnl` is a simple but powerful plain text journal application for the command
AES](http://en.wikipedia.org/wiki/Advanced_Encryption_Standard). line. Everything happens on the command line.
## Why keep a journal? ### Text-Based
Journals aren't just for people who have too much `jrnl` stores your journals as human-readable, future-proof plain text files.
time on their summer vacation. A journal helps you to keep track of the You can store them wherever you want, including in shared folders to keep them
things you get done and how you did them. Your imagination may be synchronized between devices. And because journal files are stored as plain
limitless, but your memory isn't. text, you can rest assured that your journals will be readable for centuries.
For personal use, make it a good habit to write at least 20 words a day. ### Support for Multiple Journals
Just to reflect what made this day special, why you haven't wasted it.
For professional use, consider a text-based journal to be the perfect `jrnl` allows you to work with multiple journals, each of which is stored as a
complement to your GTD todo list - a documentation of what and how single file using date and time tags to identify individual entries. `jrnl`
you've done it. Or use it as a quick way to keep a change log. Or use it makes it easy to find the entries you want, and only the ones you want, so that
to keep a lab book. you can read them or edit them.
### Support for External Editors
`jrnl` allows you to search for specific entries and edit them in your favorite
text editor.
### Encryption
`jrnl` includes support for [256-bit AES
encryption](http://en.wikipedia.org/wiki/Advanced_Encryption_Standard) using
[cryptography.io](https://cryptography.io).
### Multi-Platform Support
`jrnl` is compatible with most operating systems. Pre-compiled binaries are available through several distribution channels, and you can build from source. See the installation page for more information.
### Open-Source
`jrnl` is written in [Python](https://www.python.org) and maintained by a [friendly community](https://github.com/jrnl-org/jrnl) of open-source software enthusiasts.

View file

@ -114,6 +114,12 @@ Feature: Basic reading and writing to a journal
""" """
And we should get no error And we should get no error
Scenario: Journal directory does not exist
Given we use the config "missing_directory.yaml"
When we run "jrnl Life is good"
and we run "jrnl -n 1"
Then the output should contain "Life is good"
Scenario: Installation with relative journal and referencing from another folder Scenario: Installation with relative journal and referencing from another folder
Given we use the config "missingconfig" Given we use the config "missingconfig"
When we run "jrnl hello world" and enter When we run "jrnl hello world" and enter

View file

@ -63,3 +63,15 @@ Feature: Dayone specific implementation details.
Then we should get no error Then we should get no error
and the output should be parsable as json and the output should be parsable as json
and the json output should contain entries.0.uuid = "4BB1F46946AD439996C9B59DE7C4DDC1" and the json output should contain entries.0.uuid = "4BB1F46946AD439996C9B59DE7C4DDC1"
Scenario: Writing into Dayone adds extended metadata
Given we use the config "dayone.yaml"
When we run "jrnl 01 may 1979: Being born hurts."
and we run "jrnl --export json"
Then "entries" in the json output should have 5 elements
and the json output should contain entries.0.creator.software_agent
and the json output should contain entries.0.creator.os_agent
and the json output should contain entries.0.creator.host_name
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"

View file

@ -55,3 +55,27 @@
Then the config for journal "simple" should have "encrypt" set to "bool:True" Then the config for journal "simple" should have "encrypt" set to "bool:True"
When we run "jrnl simple -n 1" When we run "jrnl simple -n 1"
Then the output should contain "2013-06-10 15:40 Life is good" Then the output should contain "2013-06-10 15:40 Life is good"
Scenario: Encrypt journal with no keyring backend and do not store in keyring
Given we use the config "basic.yaml"
When we disable the keychain
and we run "jrnl test entry"
and we run "jrnl --encrypt" and enter
"""
password
password
n
"""
Then we should get no error
Scenario: Encrypt journal with no keyring backend and do store in keyring
Given we use the config "basic.yaml"
When we disable the keychain
and we run "jrnl test entry"
and we run "jrnl --encrypt" and enter
"""
password
password
y
"""
Then we should get no error

View file

@ -41,6 +41,22 @@ class TestKeyring(keyring.backend.KeyringBackend):
self.keys[servicename][username] = None self.keys[servicename][username] = None
class NoKeyring(keyring.backend.KeyringBackend):
"""A keyring that simulated an environment with no keyring backend."""
priority = 2
keys = defaultdict(dict)
def set_password(self, servicename, username, password):
raise keyring.errors.NoKeyringError
def get_password(self, servicename, username):
raise keyring.errors.NoKeyringError
def delete_password(self, servicename, username):
raise keyring.errors.NoKeyringError
# set the keyring for keyring lib # set the keyring for keyring lib
keyring.set_keyring(TestKeyring()) keyring.set_keyring(TestKeyring())
@ -213,6 +229,11 @@ def set_keychain(context, journal, password):
keyring.set_password("jrnl", journal, password) keyring.set_password("jrnl", journal, password)
@when("we disable the keychain")
def disable_keychain(context):
keyring.core.set_keyring(NoKeyring())
@then("we should get an error") @then("we should get an error")
def has_error(context): def has_error(context):
assert context.exit_status != 0, context.exit_status assert context.exit_status != 0, context.exit_status

View file

@ -32,13 +32,21 @@ def check_output_field_not_key(context, field, key):
@then('"{field}" in the json output should contain "{key}"') @then('"{field}" in the json output should contain "{key}"')
def check_output_field_key(context, field, key): def check_output_field_key(context, field, key):
out = context.stdout_capture.getvalue() out = context.stdout_capture.getvalue()
out_json = json.loads(out) struct = json.loads(out)
assert field in out_json
assert key in out_json[field] for node in field.split("."):
try:
struct = struct[int(node)]
except ValueError:
assert node in struct
struct = struct[node]
assert key in struct
@then("the json output should contain {path}")
@then('the json output should contain {path} = "{value}"') @then('the json output should contain {path} = "{value}"')
def check_json_output_path(context, path, value): def check_json_output_path(context, path, value=None):
""" E.g. """ E.g.
the json output should contain entries.0.title = "hello" the json output should contain entries.0.title = "hello"
""" """
@ -50,7 +58,11 @@ def check_json_output_path(context, path, value):
struct = struct[int(node)] struct = struct[int(node)]
except ValueError: except ValueError:
struct = struct[node] struct = struct[node]
if value is not None:
assert struct == value, struct assert struct == value, struct
else:
assert struct is not None
@then("the output should be a valid XML string") @then("the output should be a valid XML string")

View file

@ -9,11 +9,13 @@ import re
import time import time
import uuid import uuid
from xml.parsers.expat import ExpatError from xml.parsers.expat import ExpatError
import socket
import platform
import pytz import pytz
import tzlocal import tzlocal
from . import Entry, Journal from . import __title__, __version__, Entry, Journal
from . import time as jrnl_time from . import time as jrnl_time
@ -71,6 +73,41 @@ class DayOne(Journal.Journal):
for tag in dict_entry.get("Tags", []) for tag in dict_entry.get("Tags", [])
] ]
"""Extended DayOne attributes"""
try:
entry.creator_device_agent = dict_entry["Creator"][
"Device Agent"
]
except:
pass
try:
entry.creator_generation_date = dict_entry["Creator"][
"Generation Date"
]
except:
entry.creator_generation_date = date
try:
entry.creator_host_name = dict_entry["Creator"]["Host Name"]
except:
pass
try:
entry.creator_os_agent = dict_entry["Creator"]["OS Agent"]
except:
pass
try:
entry.creator_software_agent = dict_entry["Creator"][
"Software Agent"
]
except:
pass
try:
entry.location = dict_entry["Location"]
except:
pass
try:
entry.weather = dict_entry["Weather"]
except:
pass
self.entries.append(entry) self.entries.append(entry)
self.sort() self.sort()
return self return self
@ -85,6 +122,20 @@ class DayOne(Journal.Journal):
if not hasattr(entry, "uuid"): if not hasattr(entry, "uuid"):
entry.uuid = uuid.uuid1().hex entry.uuid = uuid.uuid1().hex
if not hasattr(entry, "creator_device_agent"):
entry.creator_device_agent = "" # iPhone/iPhone5,3
if not hasattr(entry, "creator_generation_date"):
entry.creator_generation_date = utc_time
if not hasattr(entry, "creator_host_name"):
entry.creator_host_name = socket.gethostname()
if not hasattr(entry, "creator_os_agent"):
entry.creator_os_agent = "{}/{}".format(
platform.system(), platform.release()
)
if not hasattr(entry, "creator_software_agent"):
entry.creator_software_agent = "{}/{}".format(
__title__, __version__
)
fn = ( fn = (
Path(self.config["journal"]) Path(self.config["journal"])
@ -102,10 +153,23 @@ class DayOne(Journal.Journal):
tag.strip(self.config["tagsymbols"]).replace("_", " ") tag.strip(self.config["tagsymbols"]).replace("_", " ")
for tag in entry.tags for tag in entry.tags
], ],
"Creator": {
"Device Agent": entry.creator_device_agent,
"Generation Date": entry.creator_generation_date,
"Host Name": entry.creator_host_name,
"OS Agent": entry.creator_os_agent,
"Software Agent": entry.creator_software_agent,
},
} }
if hasattr(entry, "location"):
entry_plist["Location"] = entry.location
if hasattr(entry, "weather"):
entry_plist["Weather"] = entry.weather
# plistlib expects a binary object # plistlib expects a binary object
with fn.open(mode="wb") as f: with fn.open(mode="wb") as f:
plistlib.dump(entry_plist, f, fmt=plistlib.FMT_XML, sort_keys=False) plistlib.dump(entry_plist, f, fmt=plistlib.FMT_XML, sort_keys=False)
for entry in self._deleted_entries: for entry in self._deleted_entries:
filename = os.path.join( filename = os.path.join(
self.config["journal"], "entries", entry.uuid + ".doentry" self.config["journal"], "entries", entry.uuid + ".doentry"
@ -147,7 +211,7 @@ class DayOne(Journal.Journal):
if line.endswith("*"): if line.endswith("*"):
current_entry.starred = True current_entry.starred = True
line = line[:-1] line = line[:-1]
current_entry.title = line[len(date_blob) - 1 :] current_entry.title = line[len(date_blob) - 1 :].strip()
current_entry.date = new_date current_entry.date = new_date
elif current_entry: elif current_entry:
current_entry.body += line + "\n" current_entry.body += line + "\n"
@ -159,10 +223,33 @@ class DayOne(Journal.Journal):
# Now, update our current entries if they changed # Now, update our current entries if they changed
for entry in entries: for entry in entries:
entry._parse_text() entry._parse_text()
matched_entries = [e for e in self.entries if e.uuid.lower() == entry.uuid] matched_entries = [
e for e in self.entries if e.uuid.lower() == entry.uuid.lower()
]
# tags in entry body
if matched_entries: if matched_entries:
# This entry is an existing entry # This entry is an existing entry
match = matched_entries[0] 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: if match != entry:
self.entries.remove(match) self.entries.remove(match)
entry.modified = True entry.modified = True

View file

@ -40,8 +40,11 @@ class EncryptedJournal(Journal):
"""Opens the journal file defined in the config and parses it into a list of Entries. """Opens the journal file defined in the config and parses it into a list of Entries.
Entries have the form (date, title, body).""" Entries have the form (date, title, body)."""
filename = filename or self.config["journal"] filename = filename or self.config["journal"]
dirname = os.path.dirname(filename)
if not os.path.exists(filename): if not os.path.exists(filename):
if not os.path.isdir(dirname):
os.makedirs(dirname)
print(f"[Directory {dirname} created]", file=sys.stderr)
self.create_file(filename) self.create_file(filename)
self.password = util.create_password(self.name) self.password = util.create_password(self.name)
print( print(

View file

@ -74,8 +74,11 @@ class Journal:
"""Opens the journal file defined in the config and parses it into a list of Entries. """Opens the journal file defined in the config and parses it into a list of Entries.
Entries have the form (date, title, body).""" Entries have the form (date, title, body)."""
filename = filename or self.config["journal"] filename = filename or self.config["journal"]
dirname = os.path.dirname(filename)
if not os.path.exists(filename): if not os.path.exists(filename):
if not os.path.isdir(dirname):
os.makedirs(dirname)
print(f"[Directory {dirname} created]", file=sys.stderr)
self.create_file(filename) self.create_file(filename)
print(f"[Journal '{self.name}' created at {filename}]", file=sys.stderr) print(f"[Journal '{self.name}' created at {filename}]", file=sys.stderr)

View file

@ -1 +1 @@
__version__ = "v2.4.2" __version__ = "v2.4.3-beta"

View file

@ -24,6 +24,27 @@ class JSONExporter(TextExporter):
} }
if hasattr(entry, "uuid"): if hasattr(entry, "uuid"):
entry_dict["uuid"] = entry.uuid entry_dict["uuid"] = entry.uuid
if (
hasattr(entry, "creator_device_agent")
or hasattr(entry, "creator_generation_date")
or hasattr(entry, "creator_host_name")
or hasattr(entry, "creator_os_agent")
or hasattr(entry, "creator_software_agent")
):
entry_dict["creator"] = {}
if hasattr(entry, "creator_device_agent"):
entry_dict["creator"]["device_agent"] = entry.creator_device_agent
if hasattr(entry, "creator_generation_date"):
entry_dict["creator"]["generation_date"] = str(
entry.creator_generation_date
)
if hasattr(entry, "creator_host_name"):
entry_dict["creator"]["host_name"] = entry.creator_host_name
if hasattr(entry, "creator_os_agent"):
entry_dict["creator"]["os_agent"] = entry.creator_os_agent
if hasattr(entry, "creator_software_agent"):
entry_dict["creator"]["software_agent"] = entry.creator_software_agent
return entry_dict return entry_dict
@classmethod @classmethod

View file

@ -59,9 +59,6 @@ def create_password(
if yesno("Do you want to store the password in your keychain?", default=True): if yesno("Do you want to store the password in your keychain?", default=True):
set_keychain(journal_name, pw) set_keychain(journal_name, pw)
else:
set_keychain(journal_name, None)
return pw return pw
@ -107,7 +104,13 @@ def set_keychain(journal_name, password):
except keyring.errors.PasswordDeleteError: except keyring.errors.PasswordDeleteError:
pass pass
else: else:
try:
keyring.set_password("jrnl", journal_name, password) keyring.set_password("jrnl", journal_name, password)
except keyring.errors.NoKeyringError:
print(
"Keyring backend not found. Please install one of the supported backends by visiting: https://pypi.org/project/keyring/",
file=sys.stderr,
)
def yesno(prompt, default=True): def yesno(prompt, default=True):

View file

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "jrnl" name = "jrnl"
version = "v2.4.2" version = "v2.4.3-beta"
description = "Collect your thoughts and notes without leaving the command line." description = "Collect your thoughts and notes without leaving the command line."
authors = [ authors = [
"Manuel Ebert <manuel@1450.me>", "Manuel Ebert <manuel@1450.me>",