mirror of
https://github.com/jrnl-org/jrnl.git
synced 2025-05-10 16:48:31 +02:00
Merge remote-tracking branch 'maebert/2.0-rc1' into 2.0-rc1
Conflicts: setup.py
This commit is contained in:
commit
6bcd5be7d4
23 changed files with 320 additions and 164 deletions
|
@ -39,7 +39,7 @@ Feature: Basic reading and writing to a journal
|
|||
Given we use the config "basic.yaml"
|
||||
When we run "jrnl" and enter "25 jul 2013: I saw Elvis. He's alive."
|
||||
Then we should get no error
|
||||
and the journal should contain "2013-07-25 09:00 I saw Elvis."
|
||||
and the journal should contain "[2013-07-25 09:00] I saw Elvis."
|
||||
and the journal should contain "He's alive."
|
||||
|
||||
Scenario: Displaying the version number
|
||||
|
|
11
features/data/configs/upgrade_from_195.json
Normal file
11
features/data/configs/upgrade_from_195.json
Normal file
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"default_hour": 9,
|
||||
"timeformat": "%Y-%m-%d %H:%M",
|
||||
"linewrap": 80,
|
||||
"encrypt": false,
|
||||
"editor": "",
|
||||
"default_minute": 0,
|
||||
"highlight": true,
|
||||
"journals": {"default": "features/journals/simple_jrnl-1-9-5.journal"},
|
||||
"tagsymbols": "@"
|
||||
}
|
|
@ -1 +1 @@
|
|||
gAAAAABVH4F009PRK-vz0bGa2elPRuNWvQOFjDt_TQtTbgHDBCiWgEzsTF7c4Vy-iqm-MYOh2UUrh_kUX7vTzsj3R-OJsKEYRy060yUaOH3cfBB1QHmMBhefV2XSJ-A5u_PryN137rf7kbV5Xk0jSDi2GbRuIRT6yRER1y-MAn4RDs0jfpxfeskZ65ykaB9-5Rm-lA_1ygHM9Uwrcu3HyrMJei1C6kl23w==
|
||||
gAAAAABVIHB7tnwKExG7aC5ZbAbBL9SG2oY2GENeoOJ22i1PZigOvCYvrQN3kpsu0KGr7ay5K-_46R5YFlqJvtQ8anPH2FSITsaZy-l5Lz_5quw3rmzhLwAR1tc0icgtR4MEpXEdsuQ7cyb12Xq-JLDrnATs0id5Vow9Ri_tE7Xe4BXgXaySn3aRPwWKoninVxVPVvETY3MXHSUEXV9OZ-pH5kYBLGYbLA==
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
2013-06-09 15:39 My first entry.
|
||||
[2013-06-09 15:39] My first entry.
|
||||
Everything is alright
|
||||
|
||||
2013-06-10 15:40 Life is good.
|
||||
[2013-06-10 15:40] Life is good.
|
||||
But I'm better.
|
||||
|
|
3
features/data/journals/simple_jrnl-1-9-5.journal
Normal file
3
features/data/journals/simple_jrnl-1-9-5.journal
Normal file
|
@ -0,0 +1,3 @@
|
|||
2010-06-10 15:00 A life without chocolate is like a bad analogy.
|
||||
|
||||
2013-06-10 15:40 He said "[this] is the best time to be alive".
|
|
@ -1,2 +1,2 @@
|
|||
2013-06-10 15:40 I programmed for @OS/2.
|
||||
[2013-06-10 15:40] I programmed for @OS/2.
|
||||
Almost makes me want to go back to @C++, though. (Still better than @C#).
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
2014-07-22 11:11 This entry has an email.
|
||||
[2014-07-22 11:11] This entry has an email.
|
||||
@Newline tag should show as a tag.
|
||||
Kyla's @email is kyla@clevelandunderdog.org and Guinness's is guinness@fortheloveofpits.org.
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
2013-04-09 15:39 I have an @idea:
|
||||
[2013-04-09 15:39] I have an @idea:
|
||||
(1) write a command line @journal software
|
||||
(2) ???
|
||||
(3) PROFIT!
|
||||
|
||||
2013-06-10 15:40 I met with @dan.
|
||||
[2013-06-10 15:40] I met with @dan.
|
||||
As alway's he shared his latest @idea on how to rule the world with me.
|
||||
inst
|
||||
|
|
|
@ -36,6 +36,7 @@
|
|||
"""
|
||||
Y
|
||||
bad doggie no biscuit
|
||||
bad doggie no biscuit
|
||||
"""
|
||||
Then we should see the message "Password"
|
||||
and the output should contain "2013-06-10 15:40 Life is good"
|
||||
|
|
|
@ -20,3 +20,9 @@ Feature: Exporting a Journal
|
|||
and "tags" in the json output should contain "@journal"
|
||||
and "tags" in the json output should not contain "@dan"
|
||||
|
||||
Scenario: Exporting dayone to json
|
||||
Given we use the config "dayone.yaml"
|
||||
When we run "jrnl --export json"
|
||||
Then we should get no error
|
||||
and the output should be parsable as json
|
||||
and the json output should contain entries.0.uuid = "4BB1F46946AD439996C9B59DE7C4DDC1"
|
||||
|
|
|
@ -19,14 +19,14 @@ Feature: Zapped bugs should stay dead.
|
|||
Given we use the config "basic.yaml"
|
||||
When we run "jrnl 2013-11-30 15:42: Project Started."
|
||||
Then we should see the message "Entry added"
|
||||
and the journal should contain "2013-11-30 15:42 Project Started."
|
||||
and the journal should contain "[2013-11-30 15:42] Project Started."
|
||||
|
||||
Scenario: Date in the future should be parsed correctly
|
||||
# https://github.com/maebert/jrnl/issues/185
|
||||
Given we use the config "basic.yaml"
|
||||
When we run "jrnl 26/06/2019: Planet? Earth. Year? 2019."
|
||||
Then we should see the message "Entry added"
|
||||
and the journal should contain "2019-06-26 09:00 Planet?"
|
||||
and the journal should contain "[2019-06-26 09:00] Planet?"
|
||||
|
||||
Scenario: Loading entry with ambiguous time stamp
|
||||
#https://github.com/maebert/jrnl/issues/153
|
||||
|
@ -38,16 +38,26 @@ Feature: Zapped bugs should stay dead.
|
|||
2013-10-27 03:27 Some text.
|
||||
"""
|
||||
|
||||
Scenario: Title with an embedded period.
|
||||
Given we use the config "basic.yaml"
|
||||
When we run "jrnl 04-24-2014: Created a new website - empty.com. Hope to get a lot of traffic."
|
||||
Then we should see the message "Entry added"
|
||||
When we run "jrnl -1"
|
||||
Then the output should be
|
||||
"""
|
||||
2014-04-24 09:00 Created a new website - empty.com.
|
||||
| Hope to get a lot of traffic.
|
||||
"""
|
||||
Scenario: Title with an embedded period.
|
||||
Given we use the config "basic.yaml"
|
||||
When we run "jrnl 04-24-2014: Created a new website - empty.com. Hope to get a lot of traffic."
|
||||
Then we should see the message "Entry added"
|
||||
When we run "jrnl -1"
|
||||
Then the output should be
|
||||
"""
|
||||
2014-04-24 09:00 Created a new website - empty.com.
|
||||
| Hope to get a lot of traffic.
|
||||
"""
|
||||
|
||||
Scenario: Upgrade and parse journals with square brackets
|
||||
Given we use the config "upgrade_from_195.json"
|
||||
When we run "jrnl -2" and enter "Y"
|
||||
Then the output should contain
|
||||
"""
|
||||
2010-06-10 15:00 A life without chocolate is like a bad analogy.
|
||||
|
||||
2013-06-10 15:40 He said "[this] is the best time to be alive".
|
||||
"""
|
||||
|
||||
Scenario: Title with an embedded period on DayOne journal
|
||||
Given we use the config "dayone.yaml"
|
||||
|
|
|
@ -4,7 +4,7 @@ Feature: Starring entries
|
|||
Given we use the config "basic.yaml"
|
||||
When we run "jrnl 20 july 2013 *: Best day of my life!"
|
||||
Then we should see the message "Entry added"
|
||||
and the journal should contain "2013-07-20 09:00 Best day of my life! *"
|
||||
and the journal should contain "[2013-07-20 09:00] Best day of my life! *"
|
||||
|
||||
Scenario: Filtering by starred entries
|
||||
Given we use the config "basic.yaml"
|
||||
|
|
|
@ -126,6 +126,22 @@ def check_output_field_key(context, field, key):
|
|||
assert key in out_json[field]
|
||||
|
||||
|
||||
@then('the json output should contain {path} = "{value}"')
|
||||
def check_json_output_path(context, path, value):
|
||||
""" E.g.
|
||||
the json output should contain entries.0.title = "hello"
|
||||
"""
|
||||
out = context.stdout_capture.getvalue()
|
||||
struct = json.loads(out)
|
||||
|
||||
for node in path.split('.'):
|
||||
try:
|
||||
struct = struct[int(node)]
|
||||
except ValueError:
|
||||
struct = struct[node]
|
||||
assert struct == value, struct
|
||||
|
||||
|
||||
@then('the output should be')
|
||||
@then('the output should be "{text}"')
|
||||
def check_output(context, text=None):
|
||||
|
@ -146,12 +162,14 @@ def check_output_time_inline(context, text):
|
|||
assert local_date in out, local_date
|
||||
|
||||
|
||||
@then('the output should contain')
|
||||
@then('the output should contain "{text}"')
|
||||
def check_output_inline(context, text):
|
||||
def check_output_inline(context, text=None):
|
||||
text = text or context.text
|
||||
out = context.stdout_capture.getvalue()
|
||||
if isinstance(out, bytes):
|
||||
out = out.decode('utf-8')
|
||||
assert text in out
|
||||
assert text in out, text
|
||||
|
||||
|
||||
@then('the output should not contain "{text}"')
|
||||
|
|
|
@ -1,19 +1,20 @@
|
|||
from . import Journal, util
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives import hashes, padding
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
import hashlib
|
||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
import base64
|
||||
|
||||
|
||||
def make_key(password):
|
||||
if type(password) is unicode:
|
||||
password = password.encode('utf-8')
|
||||
password = util.bytes(password)
|
||||
kdf = PBKDF2HMAC(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=32,
|
||||
# Salt is hard-coded
|
||||
salt='\xf2\xd5q\x0e\xc1\x8d.\xde\xdc\x8e6t\x89\x04\xce\xf8',
|
||||
salt=b'\xf2\xd5q\x0e\xc1\x8d.\xde\xdc\x8e6t\x89\x04\xce\xf8',
|
||||
iterations=100000,
|
||||
backend=default_backend()
|
||||
)
|
||||
|
@ -32,13 +33,15 @@ class EncryptedJournal(Journal.Journal):
|
|||
and otherwise ask the user to enter a password up to three times.
|
||||
If the password is provided but wrong (or corrupt), this will simply
|
||||
return None."""
|
||||
with open(filename) as f:
|
||||
with open(filename, 'rb') as f:
|
||||
journal_encrypted = f.read()
|
||||
|
||||
def validate_password(password):
|
||||
key = make_key(password)
|
||||
try:
|
||||
return Fernet(key).decrypt(journal_encrypted).decode('utf-8')
|
||||
plain = Fernet(key).decrypt(journal_encrypted).decode('utf-8')
|
||||
self.config['password'] = password
|
||||
return plain
|
||||
except (InvalidToken, IndexError):
|
||||
return None
|
||||
if password:
|
||||
|
@ -48,7 +51,7 @@ class EncryptedJournal(Journal.Journal):
|
|||
def _store(self, filename, text):
|
||||
key = make_key(self.config['password'])
|
||||
journal = Fernet(key).encrypt(text.encode('utf-8'))
|
||||
with open(filename, 'w') as f:
|
||||
with open(filename, 'wb') as f:
|
||||
f.write(journal)
|
||||
|
||||
@classmethod
|
||||
|
@ -57,3 +60,35 @@ class EncryptedJournal(Journal.Journal):
|
|||
dummy = Fernet(key).encrypt("")
|
||||
with open(filename, 'w') as f:
|
||||
f.write(dummy)
|
||||
|
||||
|
||||
class LegacyEncryptedJournal(Journal.LegacyJournal):
|
||||
"""Legacy class to support opening journals encrypted with the jrnl 1.x
|
||||
standard. You'll not be able to save these journals anymore."""
|
||||
def __init__(self, name='default', **kwargs):
|
||||
super(LegacyEncryptedJournal, self).__init__(name, **kwargs)
|
||||
self.config['encrypt'] = True
|
||||
|
||||
def _load(self, filename, password=None):
|
||||
with open(filename, 'rb') as f:
|
||||
journal_encrypted = f.read()
|
||||
iv, cipher = journal_encrypted[:16], journal_encrypted[16:]
|
||||
|
||||
def validate_password(password):
|
||||
decryption_key = hashlib.sha256(password.encode('utf-8')).digest()
|
||||
decryptor = Cipher(algorithms.AES(decryption_key), modes.CBC(iv), default_backend()).decryptor()
|
||||
try:
|
||||
plain_padded = decryptor.update(cipher) + decryptor.finalize()
|
||||
self.config['password'] = password
|
||||
if plain_padded[-1] in (" ", 32):
|
||||
# Ancient versions of jrnl. Do not judge me.
|
||||
return plain_padded.decode('utf-8').rstrip(" ")
|
||||
else:
|
||||
unpadder = padding.PKCS7(algorithms.AES.block_size).unpadder()
|
||||
plain = unpadder.update(plain_padded) + unpadder.finalize()
|
||||
return plain.decode('utf-8')
|
||||
except ValueError:
|
||||
return None
|
||||
if password:
|
||||
return validate_password(password)
|
||||
return util.get_password(keychain=self.name, validator=validate_password)
|
||||
|
|
|
@ -20,19 +20,19 @@ class Entry:
|
|||
@staticmethod
|
||||
def tag_regex(tagsymbols):
|
||||
pattern = r'(?u)\s([{tags}][-+*#/\w]+)'.format(tags=tagsymbols)
|
||||
return re.compile( pattern, re.UNICODE )
|
||||
return re.compile(pattern, re.UNICODE)
|
||||
|
||||
def parse_tags(self):
|
||||
fulltext = " " + " ".join([self.title, self.body]).lower()
|
||||
fulltext = " " + " ".join([self.title, self.body]).lower()
|
||||
tagsymbols = self.journal.config['tagsymbols']
|
||||
tags = re.findall( Entry.tag_regex(tagsymbols), fulltext )
|
||||
tags = re.findall(Entry.tag_regex(tagsymbols), fulltext)
|
||||
self.tags = tags
|
||||
return set(tags)
|
||||
|
||||
def __unicode__(self):
|
||||
"""Returns a string representation of the entry to be written into a journal file."""
|
||||
date_str = self.date.strftime(self.journal.config['timeformat'])
|
||||
title = date_str + " " + self.title.rstrip("\n ")
|
||||
title = "[{}] {}".format(date_str, self.title.rstrip("\n "))
|
||||
if self.starred:
|
||||
title += " *"
|
||||
return "{title}{sep}{body}\n".format(
|
||||
|
@ -48,13 +48,14 @@ class Entry:
|
|||
if not short and self.journal.config['linewrap']:
|
||||
title = textwrap.fill(date_str + " " + self.title, self.journal.config['linewrap'])
|
||||
body = "\n".join([
|
||||
textwrap.fill((line + " ") if (len(line) == 0) else line,
|
||||
self.journal.config['linewrap'],
|
||||
initial_indent="| ",
|
||||
subsequent_indent="| ",
|
||||
drop_whitespace=False)
|
||||
for line in self.body.rstrip(" \n").splitlines()
|
||||
])
|
||||
textwrap.fill(
|
||||
(line + " ") if (len(line) == 0) else line,
|
||||
self.journal.config['linewrap'],
|
||||
initial_indent="| ",
|
||||
subsequent_indent="| ",
|
||||
drop_whitespace=False)
|
||||
for line in self.body.rstrip(" \n").splitlines()
|
||||
])
|
||||
else:
|
||||
title = date_str + " " + self.title.rstrip("\n ")
|
||||
body = self.body.rstrip("\n ")
|
||||
|
@ -83,7 +84,7 @@ class Entry:
|
|||
or self.body.rstrip() != other.body.rstrip() \
|
||||
or self.date != other.date \
|
||||
or self.starred != other.starred:
|
||||
return False
|
||||
return False
|
||||
return True
|
||||
|
||||
def __ne__(self, other):
|
||||
|
|
134
jrnl/Journal.py
134
jrnl/Journal.py
|
@ -10,6 +10,9 @@ import sys
|
|||
import codecs
|
||||
import re
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Journal(object):
|
||||
|
@ -33,6 +36,15 @@ class Journal(object):
|
|||
"""Returns the number of entries"""
|
||||
return len(self.entries)
|
||||
|
||||
@classmethod
|
||||
def from_journal(cls, other):
|
||||
"""Creates a new journal by copying configuration and entries from
|
||||
another journal object"""
|
||||
new_journal = cls(other.name, **other.config)
|
||||
new_journal.entries = other.entries
|
||||
log.debug("Imported %d entries from %s to %s", len(new_journal), other.__class__.__name__, cls.__name__)
|
||||
return new_journal
|
||||
|
||||
def import_(self, other_journal_txt):
|
||||
self.entries = list(frozenset(self.entries) | frozenset(self._parse(other_journal_txt)))
|
||||
self.sort()
|
||||
|
@ -41,9 +53,15 @@ class Journal(object):
|
|||
"""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']
|
||||
|
||||
if not os.path.exists(filename):
|
||||
util.prompt("[Journal '{0}' created at {1}]".format(self.name, filename))
|
||||
self._create(filename)
|
||||
|
||||
text = self._load(filename)
|
||||
self.entries = self._parse(text)
|
||||
self.sort()
|
||||
log.debug("opened %s with %d entries", self.__class__.__name__, len(self))
|
||||
return self
|
||||
|
||||
def write(self, filename=None):
|
||||
|
@ -64,37 +82,36 @@ class Journal(object):
|
|||
|
||||
def _parse(self, journal_txt):
|
||||
"""Parses a journal that's stored in a string and returns a list of entries"""
|
||||
|
||||
# Entries start with a line that looks like 'date title' - let's figure out how
|
||||
# long the date will be by constructing one
|
||||
date_length = len(datetime.today().strftime(self.config['timeformat']))
|
||||
|
||||
# Initialise our current entry
|
||||
entries = []
|
||||
current_entry = None
|
||||
|
||||
date_blob_re = re.compile("^\[[^\\]]+\] ")
|
||||
for line in journal_txt.splitlines():
|
||||
line = line.rstrip()
|
||||
try:
|
||||
# try to parse line as date => new entry begins
|
||||
new_date = datetime.strptime(line[:date_length], self.config['timeformat'])
|
||||
date_blob = date_blob_re.findall(line)
|
||||
if date_blob:
|
||||
date_blob = date_blob[0]
|
||||
new_date = time.parse(date_blob.strip(" []"))
|
||||
if new_date:
|
||||
# Found a date at the start of the line: This is a new entry.
|
||||
if current_entry:
|
||||
entries.append(current_entry)
|
||||
|
||||
# parsing successful => save old entry and create new one
|
||||
if new_date and current_entry:
|
||||
entries.append(current_entry)
|
||||
if line.endswith("*"):
|
||||
starred = True
|
||||
line = line[:-1]
|
||||
else:
|
||||
starred = False
|
||||
|
||||
if line.endswith("*"):
|
||||
starred = True
|
||||
line = line[:-1]
|
||||
else:
|
||||
starred = False
|
||||
|
||||
current_entry = Entry.Entry(self, date=new_date, title=line[date_length + 1:], starred=starred)
|
||||
except ValueError:
|
||||
# Happens when we can't parse the start of the line as an date.
|
||||
# In this case, just append line to our body.
|
||||
if current_entry:
|
||||
current_entry.body += line + "\n"
|
||||
current_entry = Entry.Entry(
|
||||
self,
|
||||
date=new_date,
|
||||
title=line[len(date_blob):],
|
||||
starred=starred
|
||||
)
|
||||
elif current_entry:
|
||||
# Didn't find a date - keep on feeding to current entry.
|
||||
current_entry.body += line + "\n"
|
||||
|
||||
# Append last entry
|
||||
if current_entry:
|
||||
|
@ -226,9 +243,6 @@ class Journal(object):
|
|||
|
||||
|
||||
class PlainJournal(Journal):
|
||||
def __init__(self, name='default', **kwargs):
|
||||
super(PlainJournal, self).__init__(name, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def _create(cls, filename):
|
||||
with codecs.open(filename, "a", "utf-8"):
|
||||
|
@ -243,10 +257,70 @@ class PlainJournal(Journal):
|
|||
f.write(text)
|
||||
|
||||
|
||||
def open_journal(name, config):
|
||||
class LegacyJournal(Journal):
|
||||
"""Legacy class to support opening journals formatted with the jrnl 1.x
|
||||
standard. Main difference here is that in 1.x, timestamps were not cuddled
|
||||
by square brackets, and the line break between the title and the rest of
|
||||
the entry was not enforced. You'll not be able to save these journals anymore."""
|
||||
def _load(self, filename):
|
||||
with codecs.open(filename, "r", "utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
def _parse(self, journal_txt):
|
||||
"""Parses a journal that's stored in a string and returns a list of entries"""
|
||||
# Entries start with a line that looks like 'date title' - let's figure out how
|
||||
# long the date will be by constructing one
|
||||
date_length = len(datetime.today().strftime(self.config['timeformat']))
|
||||
|
||||
# Initialise our current entry
|
||||
entries = []
|
||||
current_entry = None
|
||||
for line in journal_txt.splitlines():
|
||||
line = line.rstrip()
|
||||
try:
|
||||
# try to parse line as date => new entry begins
|
||||
new_date = datetime.strptime(line[:date_length], self.config['timeformat'])
|
||||
|
||||
# parsing successful => save old entry and create new one
|
||||
if new_date and current_entry:
|
||||
entries.append(current_entry)
|
||||
|
||||
if line.endswith("*"):
|
||||
starred = True
|
||||
line = line[:-1]
|
||||
else:
|
||||
starred = False
|
||||
|
||||
current_entry = Entry.Entry(self, date=new_date, title=line[date_length + 1:], starred=starred)
|
||||
except ValueError:
|
||||
# Happens when we can't parse the start of the line as an date.
|
||||
# In this case, just append line to our body.
|
||||
if current_entry:
|
||||
current_entry.body += line + u"\n"
|
||||
|
||||
# Append last entry
|
||||
if current_entry:
|
||||
entries.append(current_entry)
|
||||
for entry in entries:
|
||||
entry.parse_tags()
|
||||
return entries
|
||||
|
||||
|
||||
def open_journal(name, config, legacy=False):
|
||||
"""
|
||||
Creates a normal, encrypted or DayOne journal based on the passed config.
|
||||
If legacy is True, it will open Journals with legacy classes build for
|
||||
backwards compatibility with jrnl 1.x
|
||||
"""
|
||||
config = config.copy()
|
||||
journal_conf = config['journals'].get(name)
|
||||
if type(journal_conf) is dict: # We can override the default config on a by-journal basis
|
||||
log.debug('Updating configuration with specific journal overrides %s', journal_conf)
|
||||
config.update(journal_conf)
|
||||
else: # But also just give them a string to point to the journal file
|
||||
config['journal'] = journal_conf
|
||||
config['journal'] = os.path.expanduser(os.path.expandvars(config['journal']))
|
||||
|
||||
if os.path.isdir(config['journal']):
|
||||
if config['journal'].strip("/").endswith(".dayone") or "entries" in os.listdir(config['journal']):
|
||||
from . import DayOneJournal
|
||||
|
@ -256,7 +330,11 @@ def open_journal(name, config):
|
|||
sys.exit(1)
|
||||
|
||||
if not config['encrypt']:
|
||||
if legacy:
|
||||
return LegacyJournal(name, **config).open()
|
||||
return PlainJournal(name, **config).open()
|
||||
else:
|
||||
from . import EncryptedJournal
|
||||
if legacy:
|
||||
return EncryptedJournal.LegacyEncryptedJournal(name, **config).open()
|
||||
return EncryptedJournal.EncryptedJournal(name, **config).open()
|
||||
|
|
20
jrnl/cli.py
20
jrnl/cli.py
|
@ -13,7 +13,6 @@ from . import util
|
|||
from . import install
|
||||
from . import plugins
|
||||
import jrnl
|
||||
import os
|
||||
import argparse
|
||||
import sys
|
||||
import logging
|
||||
|
@ -102,13 +101,6 @@ def decrypt(journal, filename=None):
|
|||
util.prompt("Journal decrypted to {0}.".format(filename or new_journal.config['journal']))
|
||||
|
||||
|
||||
def touch_journal(filename):
|
||||
"""If filename does not exist, touch the file"""
|
||||
if not os.path.exists(filename):
|
||||
util.prompt("[Journal created at {0}]".format(filename))
|
||||
Journal.PlainJournal._create(filename)
|
||||
|
||||
|
||||
def list_journals(config):
|
||||
"""List the journals specified in the configuration file"""
|
||||
sep = "\n"
|
||||
|
@ -147,7 +139,6 @@ def run(manual_args=None):
|
|||
sys.exit(0)
|
||||
|
||||
config = install.load_or_install_jrnl()
|
||||
|
||||
if args.ls:
|
||||
util.prnt(u"Journals defined in {}".format(install.CONFIG_FILE_PATH))
|
||||
ml = min(max(len(k) for k in config['journals']), 20)
|
||||
|
@ -163,6 +154,7 @@ def run(manual_args=None):
|
|||
journal_name = args.text[0] if (args.text and args.text[0] in config['journals']) else 'default'
|
||||
if journal_name is not 'default':
|
||||
args.text = args.text[1:]
|
||||
|
||||
# If the first remaining argument looks like e.g. '-3', interpret that as a limiter
|
||||
if not args.limit and args.text and args.text[0].startswith("-"):
|
||||
try:
|
||||
|
@ -172,16 +164,7 @@ def run(manual_args=None):
|
|||
pass
|
||||
|
||||
log.debug('Using journal "%s"', journal_name)
|
||||
journal_conf = config['journals'].get(journal_name)
|
||||
if type(journal_conf) is dict: # We can override the default config on a by-journal basis
|
||||
log.debug('Updating configuration with specific jourlnal overrides %s', journal_conf)
|
||||
config.update(journal_conf)
|
||||
else: # But also just give them a string to point to the journal file
|
||||
config['journal'] = journal_conf
|
||||
config['journal'] = os.path.expanduser(os.path.expandvars(config['journal']))
|
||||
touch_journal(config['journal'])
|
||||
mode_compose, mode_export, mode_import = guess_mode(args, config)
|
||||
log.debug('Using journal path %(journal)s', config)
|
||||
|
||||
# How to quit writing?
|
||||
if "win32" in sys.platform:
|
||||
|
@ -206,6 +189,7 @@ def run(manual_args=None):
|
|||
else:
|
||||
mode_compose = False
|
||||
|
||||
# This is where we finally open the journal!
|
||||
journal = Journal.open_journal(journal_name, config)
|
||||
|
||||
# Import mode
|
||||
|
|
|
@ -65,7 +65,7 @@ def upgrade_config(config):
|
|||
for key in missing_keys:
|
||||
config[key] = default_config[key]
|
||||
save_config(config)
|
||||
print("[.jrnl_conf updated to newest version at {}]".format(CONFIG_FILE_PATH))
|
||||
print("[Configuration updated to newest version at {}]".format(CONFIG_FILE_PATH))
|
||||
|
||||
|
||||
def save_config(config):
|
||||
|
|
|
@ -32,15 +32,10 @@ class PluginMeta(type):
|
|||
else:
|
||||
return ', '.join(plugin_names[:-1]) + ", or " + plugin_names[-1]
|
||||
|
||||
|
||||
class BaseExporter(object):
|
||||
__metaclass__ = PluginMeta
|
||||
names = []
|
||||
|
||||
|
||||
class BaseImporter(object):
|
||||
__metaclass__ = PluginMeta
|
||||
names = []
|
||||
# This looks a bit arcane, but is basically bilingual speak for defining a
|
||||
# class with meta class 'PluginMeta' for both Python 2 and 3.
|
||||
BaseExporter = PluginMeta(str('BaseExporter'), (), {'names': []})
|
||||
BaseImporter = PluginMeta(str('BaseImporter'), (), {'names': []})
|
||||
|
||||
|
||||
for module in glob.glob(os.path.dirname(__file__) + "/*.py"):
|
||||
|
|
|
@ -14,13 +14,16 @@ class JSONExporter(TextExporter):
|
|||
|
||||
@classmethod
|
||||
def entry_to_dict(cls, entry):
|
||||
return {
|
||||
entry_dict = {
|
||||
'title': entry.title,
|
||||
'body': entry.body,
|
||||
'date': entry.date.strftime("%Y-%m-%d"),
|
||||
'time': entry.date.strftime("%H:%M"),
|
||||
'starred': entry.starred
|
||||
}
|
||||
if hasattr(entry, "uuid"):
|
||||
entry_dict['uuid'] = entry.uuid
|
||||
return entry_dict
|
||||
|
||||
@classmethod
|
||||
def export_entry(cls, entry):
|
||||
|
|
110
jrnl/upgrade.py
110
jrnl/upgrade.py
|
@ -1,37 +1,19 @@
|
|||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import padding
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
import hashlib
|
||||
import util
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
from . import __version__
|
||||
from . import EncryptedJournal
|
||||
from . import Journal
|
||||
from . import util
|
||||
from .EncryptedJournal import EncryptedJournal
|
||||
import sys
|
||||
from cryptography.fernet import Fernet
|
||||
import os
|
||||
|
||||
|
||||
def upgrade_encrypted_journal(filename, key_plain):
|
||||
"""Decrypts a journal in memory using the jrnl 1.x encryption scheme
|
||||
and returns it in plain text."""
|
||||
with open(filename) as f:
|
||||
iv_cipher = f.read()
|
||||
iv, cipher = iv_cipher[:16], iv_cipher[16:]
|
||||
decryption_key = hashlib.sha256(key_plain.encode('utf-8')).digest()
|
||||
decryptor = Cipher(algorithms.AES(decryption_key), modes.CBC(iv), default_backend()).decryptor()
|
||||
try:
|
||||
plain_padded = decryptor.update(cipher) + decryptor.finalize()
|
||||
if plain_padded[-1] == " ":
|
||||
# Ancient versions of jrnl. Do not judge me.
|
||||
plain = plain_padded.rstrip(" ")
|
||||
else:
|
||||
unpadder = padding.PKCS7(algorithms.AES.block_size).unpadder()
|
||||
plain = unpadder.update(plain_padded) + unpadder.finalize()
|
||||
except ValueError:
|
||||
return None
|
||||
key = EncryptedJournal.make_key(key_plain)
|
||||
journal = Fernet(key).encrypt(plain)
|
||||
with open(filename, 'w') as f:
|
||||
f.write(journal)
|
||||
return plain
|
||||
def backup(filename, binary=False):
|
||||
util.prompt(" Created a backup at {}.backup".format(filename))
|
||||
with open(filename, 'rb' if binary else 'r') as original:
|
||||
contents = original.read()
|
||||
with open(filename + ".backup", 'wb' if binary else 'w') as backup:
|
||||
backup.write(contents)
|
||||
|
||||
|
||||
def upgrade_jrnl_if_necessary(config_path):
|
||||
|
@ -57,41 +39,63 @@ Please note that jrnl 1.x is NOT forward compatible with this version of jrnl.
|
|||
If you choose to proceed, you will not be able to use your journals with
|
||||
older versions of jrnl anymore.
|
||||
""".format(__version__))
|
||||
|
||||
encrypted_journals = {}
|
||||
plain_journals = {}
|
||||
for journal, journal_conf in config['journals'].items():
|
||||
other_journals = {}
|
||||
|
||||
for journal_name, journal_conf in config['journals'].items():
|
||||
if isinstance(journal_conf, dict):
|
||||
if journal_conf.get("encrypt"):
|
||||
encrypted_journals[journal] = journal_conf.get("journal")
|
||||
else:
|
||||
plain_journals[journal] = journal_conf.get("journal")
|
||||
path = journal_conf.get("journal")
|
||||
encrypt = journal_conf.get("encrypt")
|
||||
else:
|
||||
if config.get('encrypt'):
|
||||
encrypted_journals[journal] = journal_conf
|
||||
else:
|
||||
plain_journals[journal] = journal_conf
|
||||
encrypt = config.get('encrypt')
|
||||
path = journal_conf
|
||||
|
||||
if encrypt:
|
||||
encrypted_journals[journal_name] = path
|
||||
elif os.path.isdir(path):
|
||||
other_journals[journal_name] = path
|
||||
else:
|
||||
plain_journals[journal_name] = path
|
||||
|
||||
longest_journal_name = max([len(journal) for journal in config['journals']])
|
||||
if encrypted_journals:
|
||||
longest_journal_name = max([len(journal) for journal in config['journals']])
|
||||
util.prompt("\nFollowing encrypted journals will be upgraded to jrnl {}:".format(__version__))
|
||||
for journal, path in encrypted_journals.items():
|
||||
util.prompt(" {:{pad}} -> {}".format(journal, path, pad=longest_journal_name))
|
||||
if plain_journals:
|
||||
util.prompt("\nFollowing plain text journals will be not be touched:")
|
||||
for journal, path in plain_journals.items():
|
||||
util.prompt(" {:{pad}} -> {}".format(journal, path, pad=longest_journal_name))
|
||||
|
||||
cont = util.yesno("Continue upgrading jrnl?", default=False)
|
||||
if plain_journals:
|
||||
util.prompt("\nFollowing plain text journals will upgraded to jrnl {}:".format(__version__))
|
||||
for journal, path in plain_journals.items():
|
||||
util.prompt(" {:{pad}} -> {}".format(journal, path, pad=longest_journal_name))
|
||||
|
||||
if other_journals:
|
||||
util.prompt("\nFollowing journals will be not be touched:")
|
||||
for journal, path in other_journals.items():
|
||||
util.prompt(" {:{pad}} -> {}".format(journal, path, pad=longest_journal_name))
|
||||
|
||||
cont = util.yesno("\nContinue upgrading jrnl?", default=False)
|
||||
if not cont:
|
||||
util.prompt("jrnl NOT upgraded, exiting.")
|
||||
sys.exit(1)
|
||||
|
||||
for journal, path in encrypted_journals.items():
|
||||
util.prompt("Enter password for {} journal (stored in {}).".format(journal, path))
|
||||
util.get_password(keychain=journal, validator=lambda pwd: upgrade_encrypted_journal(path, pwd))
|
||||
for journal_name, path in encrypted_journals.items():
|
||||
util.prompt("\nUpgrading encrypted '{}' journal stored in {}...".format(journal_name, path))
|
||||
backup(path, binary=True)
|
||||
old_journal = Journal.open_journal(journal_name, config, legacy=True)
|
||||
new_journal = EncryptedJournal.from_journal(old_journal)
|
||||
new_journal.write()
|
||||
util.prompt(" Done.")
|
||||
|
||||
with open(config_path + ".backup", 'w') as config_backup:
|
||||
config_backup.write(config_file)
|
||||
for journal_name, path in plain_journals.items():
|
||||
util.prompt("\nUpgrading plain text '{}' journal stored in {}...".format(journal_name, path))
|
||||
backup(path)
|
||||
old_journal = Journal.open_journal(journal_name, config, legacy=True)
|
||||
new_journal = Journal.PlainJournal.from_journal(old_journal)
|
||||
new_journal.write()
|
||||
util.prompt(" Done.")
|
||||
|
||||
util.prompt("""\n\nYour old config has been backed up to {}.backup.
|
||||
We're all done here and you can start enjoying jrnl 2.""".format(config_path))
|
||||
util.prompt("\nUpgrading config...")
|
||||
backup(config_path)
|
||||
|
||||
util.prompt("\nWe're all done here and you can start enjoying jrnl 2.".format(config_path))
|
||||
|
|
13
jrnl/util.py
13
jrnl/util.py
|
@ -75,10 +75,17 @@ def u(s):
|
|||
|
||||
|
||||
def py2encode(s):
|
||||
"""Encode in Python 2, but not in python 3."""
|
||||
"""Encodes to UTF-8 in Python 2 but not r."""
|
||||
return s.encode("utf-8") if PY2 and type(s) is unicode else s
|
||||
|
||||
|
||||
def bytes(s):
|
||||
"""Returns bytes, no matter what."""
|
||||
if PY3:
|
||||
return s.encode("utf-8") if type(s) is not bytes else s
|
||||
return s.encode("utf-8") if type(s) is unicode else s
|
||||
|
||||
|
||||
def prnt(s):
|
||||
"""Encode and print a string"""
|
||||
STDOUT.write(u(s + "\n"))
|
||||
|
@ -92,7 +99,7 @@ def prompt(msg):
|
|||
|
||||
|
||||
def py23_input(msg=""):
|
||||
STDERR.write(u(msg))
|
||||
prompt(msg)
|
||||
return STDIN.readline().strip()
|
||||
|
||||
|
||||
|
@ -114,7 +121,7 @@ def load_config(config_path):
|
|||
|
||||
|
||||
def get_text_from_editor(config, template=""):
|
||||
tmpfile = os.path.join(tempfile.mktemp(prefix="jrnl"))
|
||||
tmpfile = os.path.join(tempfile.mktemp(prefix="jrnl", suffix=".txt"))
|
||||
with codecs.open(tmpfile, 'w', "utf-8") as f:
|
||||
if template:
|
||||
f.write(template)
|
||||
|
|
18
setup.py
18
setup.py
|
@ -75,11 +75,11 @@ conditional_dependencies = {
|
|||
|
||||
|
||||
setup(
|
||||
name = "jrnl",
|
||||
version = get_version(),
|
||||
description = "A command line journal application that stores your journal in a plain text file",
|
||||
packages = ['jrnl'],
|
||||
install_requires = [
|
||||
name="jrnl",
|
||||
version=get_version(),
|
||||
description="A command line journal application that stores your journal in a plain text file",
|
||||
packages=['jrnl'],
|
||||
install_requires=[
|
||||
"pyxdg>=0.19",
|
||||
"parsedatetime>=1.2",
|
||||
"pytz>=2013b",
|
||||
|
@ -115,9 +115,9 @@ setup(
|
|||
'Topic :: Text Processing'
|
||||
],
|
||||
# metadata for upload to PyPI
|
||||
author = "Manuel Ebert",
|
||||
author_email = "manuel@1450.me",
|
||||
author="Manuel Ebert",
|
||||
author_email="manuel@1450.me",
|
||||
license="LICENSE",
|
||||
keywords = "journal todo todo.txt jrnl".split(),
|
||||
url = "http://www.jrnl.sh",
|
||||
keywords="journal todo todo.txt jrnl".split(),
|
||||
url="http://www.jrnl.sh",
|
||||
)
|
||||
|
|
Loading…
Add table
Reference in a new issue