diff --git a/.gitignore b/.gitignore index db6df82b..afb0d874 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,7 @@ obj env/ env*/ venv*/ +.venv*/ # PyCharm Project files .idea/ diff --git a/CHANGELOG.md b/CHANGELOG.md index e33b6dd5..6c4761c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # 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/) [Full Changelog](https://github.com/jrnl-org/jrnl/compare/v2.4.2...HEAD) diff --git a/docs/overview.md b/docs/overview.md index 86211814..67feedcf 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -1,27 +1,41 @@ # Overview -## What is jrnl? +## Features -`jrnl` is a simple journal application for -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. +### Command-Line Interface -Optionally, your journal can be encrypted using the [256-bit -AES](http://en.wikipedia.org/wiki/Advanced_Encryption_Standard). +`jrnl` is a simple but powerful plain text journal application for the command +line. Everything happens on the command line. -## Why keep a journal? +### Text-Based -Journals aren't just for people who have too much -time on their summer vacation. A journal helps you to keep track of the -things you get done and how you did them. Your imagination may be -limitless, but your memory isn't. +`jrnl` stores your journals as human-readable, future-proof plain text files. +You can store them wherever you want, including in shared folders to keep them +synchronized between devices. And because journal files are stored as plain +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. -Just to reflect what made this day special, why you haven't wasted it. +### Support for Multiple Journals + +`jrnl` allows you to work with multiple journals, each of which is stored as a +single file using date and time tags to identify individual entries. `jrnl` +makes it easy to find the entries you want, and only the ones you want, so that +you can read them or edit them. -For professional use, consider a text-based journal to be the perfect -complement to your GTD todo list - a documentation of what and how -you've done it. Or use it as a quick way to keep a change log. Or use it -to keep a lab book. +### 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. diff --git a/features/core.feature b/features/core.feature index fbf15b54..f9b335e6 100644 --- a/features/core.feature +++ b/features/core.feature @@ -114,6 +114,12 @@ Feature: Basic reading and writing to a journal """ 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 Given we use the config "missingconfig" When we run "jrnl hello world" and enter diff --git a/features/dayone.feature b/features/dayone.feature index 8e50b42b..bf74dc8d 100644 --- a/features/dayone.feature +++ b/features/dayone.feature @@ -63,3 +63,15 @@ Feature: Dayone specific implementation details. Then we should get no error and the output should be parsable as json 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" diff --git a/features/encryption.feature b/features/encryption.feature index 787fa850..02f8b423 100644 --- a/features/encryption.feature +++ b/features/encryption.feature @@ -55,3 +55,27 @@ Then the config for journal "simple" should have "encrypt" set to "bool:True" When we run "jrnl simple -n 1" 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 diff --git a/features/steps/core.py b/features/steps/core.py index cc7b4c98..13b11bd2 100644 --- a/features/steps/core.py +++ b/features/steps/core.py @@ -41,6 +41,22 @@ class TestKeyring(keyring.backend.KeyringBackend): 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 keyring.set_keyring(TestKeyring()) @@ -213,6 +229,11 @@ def set_keychain(context, 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") def has_error(context): assert context.exit_status != 0, context.exit_status diff --git a/features/steps/export_steps.py b/features/steps/export_steps.py index 0aa180fd..6c8cd282 100644 --- a/features/steps/export_steps.py +++ b/features/steps/export_steps.py @@ -32,13 +32,21 @@ def check_output_field_not_key(context, field, key): @then('"{field}" in the json output should contain "{key}"') def check_output_field_key(context, field, key): out = context.stdout_capture.getvalue() - out_json = json.loads(out) - assert field in out_json - assert key in out_json[field] + struct = json.loads(out) + + 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}"') -def check_json_output_path(context, path, value): +def check_json_output_path(context, path, value=None): """ E.g. 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)] except ValueError: struct = struct[node] - assert struct == value, struct + + if value is not None: + assert struct == value, struct + else: + assert struct is not None @then("the output should be a valid XML string") diff --git a/jrnl/DayOneJournal.py b/jrnl/DayOneJournal.py index 1ee44b97..dd1cbc24 100644 --- a/jrnl/DayOneJournal.py +++ b/jrnl/DayOneJournal.py @@ -9,11 +9,13 @@ import re import time import uuid from xml.parsers.expat import ExpatError +import socket +import platform import pytz import tzlocal -from . import Entry, Journal +from . import __title__, __version__, Entry, Journal from . import time as jrnl_time @@ -71,6 +73,41 @@ class DayOne(Journal.Journal): 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.sort() return self @@ -85,6 +122,20 @@ class DayOne(Journal.Journal): if not hasattr(entry, "uuid"): 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 = ( Path(self.config["journal"]) @@ -102,10 +153,23 @@ class DayOne(Journal.Journal): tag.strip(self.config["tagsymbols"]).replace("_", " ") 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 with fn.open(mode="wb") as f: plistlib.dump(entry_plist, f, fmt=plistlib.FMT_XML, sort_keys=False) + for entry in self._deleted_entries: filename = os.path.join( self.config["journal"], "entries", entry.uuid + ".doentry" @@ -147,7 +211,7 @@ class DayOne(Journal.Journal): if line.endswith("*"): current_entry.starred = True 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 elif current_entry: current_entry.body += line + "\n" @@ -159,10 +223,33 @@ class DayOne(Journal.Journal): # 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] + 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 diff --git a/jrnl/EncryptedJournal.py b/jrnl/EncryptedJournal.py index 2a6df460..ec4ceee8 100644 --- a/jrnl/EncryptedJournal.py +++ b/jrnl/EncryptedJournal.py @@ -40,8 +40,11 @@ class EncryptedJournal(Journal): """Opens the journal file defined in the config and parses it into a list of Entries. Entries have the form (date, title, body).""" filename = filename or self.config["journal"] - + dirname = os.path.dirname(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.password = util.create_password(self.name) print( diff --git a/jrnl/Journal.py b/jrnl/Journal.py index 23fa80a0..72b3b9cd 100644 --- a/jrnl/Journal.py +++ b/jrnl/Journal.py @@ -74,8 +74,11 @@ class Journal: """Opens the journal file defined in the config and parses it into a list of Entries. Entries have the form (date, title, body).""" filename = filename or self.config["journal"] - + dirname = os.path.dirname(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) print(f"[Journal '{self.name}' created at {filename}]", file=sys.stderr) diff --git a/jrnl/__version__.py b/jrnl/__version__.py index 8b8ae09f..2f5c17c2 100644 --- a/jrnl/__version__.py +++ b/jrnl/__version__.py @@ -1 +1 @@ -__version__ = "v2.4.2" +__version__ = "v2.4.3-beta" diff --git a/jrnl/plugins/json_exporter.py b/jrnl/plugins/json_exporter.py index a5c70b97..8dfe057b 100644 --- a/jrnl/plugins/json_exporter.py +++ b/jrnl/plugins/json_exporter.py @@ -24,6 +24,27 @@ class JSONExporter(TextExporter): } if hasattr(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 @classmethod diff --git a/jrnl/util.py b/jrnl/util.py index db86a5ee..8ebaea41 100644 --- a/jrnl/util.py +++ b/jrnl/util.py @@ -59,9 +59,6 @@ def create_password( if yesno("Do you want to store the password in your keychain?", default=True): set_keychain(journal_name, pw) - else: - set_keychain(journal_name, None) - return pw @@ -107,7 +104,13 @@ def set_keychain(journal_name, password): except keyring.errors.PasswordDeleteError: pass else: - keyring.set_password("jrnl", journal_name, password) + try: + 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): diff --git a/pyproject.toml b/pyproject.toml index 3a2072a6..673f8bcc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "jrnl" -version = "v2.4.2" +version = "v2.4.3-beta" description = "Collect your thoughts and notes without leaving the command line." authors = [ "Manuel Ebert ",