Compare commits
45 commits
Author | SHA1 | Date | |
---|---|---|---|
|
ba34ba2e58 | ||
|
a7b98f28e1 | ||
|
4ba577db33 | ||
|
b2ddd22e50 | ||
|
cf6bc9c051 | ||
|
976a6faaa8 | ||
|
54d36297a8 | ||
|
5dccceff78 | ||
|
7f8ebcbca6 | ||
|
4a89f92f97 | ||
|
7c1d552a35 | ||
|
f5f301c519 | ||
|
b99c82f9be | ||
|
ef0227cf89 | ||
|
d0b476187f | ||
|
10235b77ce | ||
|
3323db3e2a | ||
|
270157010c | ||
|
12d34667fd | ||
|
79eb1bb757 | ||
|
44c74d509c | ||
|
d216dcdbd2 | ||
|
75eaf275b4 | ||
|
a43cf16395 | ||
|
73d9882b64 | ||
|
e77037b0f1 | ||
|
b45e52f743 | ||
|
474bf0a71a | ||
|
799ff762b2 | ||
|
43f1166ecf | ||
|
acf41a153a | ||
|
19c57ecf70 | ||
|
94b53b9247 | ||
|
9603f7d3ad | ||
|
cde55ad540 | ||
|
1013d77173 | ||
|
76881f66e9 | ||
|
e6cfeabc17 | ||
|
17b439eba4 | ||
|
c8e5d1ff34 | ||
|
bd6edffc81 | ||
|
e9f691e399 | ||
|
05e63dc76a | ||
|
c81f0e0c1d | ||
|
afcccb78c1 |
|
@ -1,12 +1,11 @@
|
||||||
language: python
|
language: python
|
||||||
python:
|
python:
|
||||||
- "2.6"
|
|
||||||
- "2.7"
|
- "2.7"
|
||||||
- "3.3"
|
- "3.3"
|
||||||
- "3.4"
|
- "3.4"
|
||||||
install:
|
install:
|
||||||
- "pip install -e . --use-mirrors"
|
- "pip install -e ."
|
||||||
- "pip install pycrypto>=2.6 --use-mirrors"
|
- "pip install pycrypto>=2.6"
|
||||||
- "pip install -q behave"
|
- "pip install -q behave"
|
||||||
# command to run tests
|
# command to run tests
|
||||||
script:
|
script:
|
||||||
|
|
|
@ -4,6 +4,9 @@ Changelog
|
||||||
|
|
||||||
### 1.9 (July 21, 2014)
|
### 1.9 (July 21, 2014)
|
||||||
|
|
||||||
|
* __1.9.8__ Fixes a problem with temporary files on windows
|
||||||
|
* __1.9.7__ Fixes writing non-ascii entries on the prompt
|
||||||
|
* __1.9.6__ Fuzzy time parsing improvements (thanks to @pcarranza)
|
||||||
* __1.9.5__ Multi-word tags for DayOne Journals
|
* __1.9.5__ Multi-word tags for DayOne Journals
|
||||||
* __1.9.4__ Fixed: Order of journal entries in file correct after --edit'ing
|
* __1.9.4__ Fixed: Order of journal entries in file correct after --edit'ing
|
||||||
* __1.9.3__ Fixed: Tags at the beginning of lines
|
* __1.9.3__ Fixed: Tags at the beginning of lines
|
||||||
|
|
|
@ -3,6 +3,9 @@ Contributing
|
||||||
|
|
||||||
If you use jrnl, you can totally make my day by just saying "thanks for the code" or by [tweeting about jrnl](https://twitter.com/intent/tweet?text=Write+your+memoirs+on+the+command+line.+Like+a+boss.+%23jrnl&url=http%3A%2F%2Fmaebert.github.io%2Fjrnl&via=maebert). It's your chance to make a programmer happy today! If you have a minute or two, let me know what you use jrnl for and how, it'll help me to make it even better. If you blog about jrnl, I'll send you a post card!
|
If you use jrnl, you can totally make my day by just saying "thanks for the code" or by [tweeting about jrnl](https://twitter.com/intent/tweet?text=Write+your+memoirs+on+the+command+line.+Like+a+boss.+%23jrnl&url=http%3A%2F%2Fmaebert.github.io%2Fjrnl&via=maebert). It's your chance to make a programmer happy today! If you have a minute or two, let me know what you use jrnl for and how, it'll help me to make it even better. If you blog about jrnl, I'll send you a post card!
|
||||||
|
|
||||||
|
> # Important:
|
||||||
|
> ### Please develop new features against the `2.0-rc1` branch. PRs to the `master` branch will not get merged until version 2.0 is released.
|
||||||
|
|
||||||
|
|
||||||
Docs & Typos
|
Docs & Typos
|
||||||
------------
|
------------
|
||||||
|
@ -12,7 +15,7 @@ If you find a typo or a mistake in the docs, just fix it right away and send a p
|
||||||
Bugs
|
Bugs
|
||||||
----
|
----
|
||||||
|
|
||||||
They unfortunately happen. Specifically, I don't have a Windows machine to test on, so expect a few rough spots. If you found a bug, please [open a new issue](https://www.github.com/maebert/jrnl/issues/new) and describe it as well as possible. If you're a programmer and have a little time time spare, go ahead, fork the code and fix bugs you spot, it'll be much appreciated!
|
They unfortunately happen. Specifically, I don't have a Windows machine to test on, so expect a few rough spots. If you found a bug, please [open a new issue](https://www.github.com/maebert/jrnl/issues/new) and describe it as well as possible. If you're a programmer and have a little time to spare, go ahead, fork the code and fix bugs you spot, it'll be much appreciated!
|
||||||
|
|
||||||
|
|
||||||
Feature requests and ideas
|
Feature requests and ideas
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
jrnl [](https://travis-ci.org/maebert/jrnl) [](https://pypi.python.org/pypi/jrnl/) [](https://pypi.python.org/pypi/jrnl/)
|
jrnl [](https://travis-ci.org/maebert/jrnl) [](https://pypi.python.org/pypi/jrnl/) [](https://pypi.python.org/pypi/jrnl/)
|
||||||
====
|
====
|
||||||
|
|
||||||
_For news on updates or to get help, [read the docs](http://maebert.github.io/jrnl), follow [@maebert](https://twitter.com/maebert) or [submit an issue](https://github.com/maebert/jrnl/issues/new) on Github._
|
_For news on updates or to get help, [read the docs](http://maebert.github.io/jrnl/overview.html), follow [@maebert](https://twitter.com/maebert) or [submit an issue](https://github.com/maebert/jrnl/issues/new) on Github._
|
||||||
|
|
||||||
*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.
|
*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.
|
||||||
|
|
||||||
|
@ -39,3 +39,6 @@ Or, if you want the option to encrypt your journal,
|
||||||
|
|
||||||
pip install jrnl[encrypted]
|
pip install jrnl[encrypted]
|
||||||
|
|
||||||
|
Alternatively, on OS X with [Homebrew](http://brew.sh/) installed:
|
||||||
|
|
||||||
|
brew install jrnl
|
||||||
|
|
BIN
docs/_themes/jrnl/static/img/favicon-152.png
vendored
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 12 KiB |
BIN
docs/_themes/jrnl/static/img/favicon.ico
vendored
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 5.4 KiB |
BIN
docs/_themes/jrnl/static/img/logo.png
vendored
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 5.3 KiB |
BIN
docs/_themes/jrnl/static/img/logo@2x.png
vendored
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 14 KiB |
|
@ -30,18 +30,39 @@ A note on security
|
||||||
|
|
||||||
While jrnl follows best practises, true security is an illusion. Specifically, jrnl will leave traces in your memory and your shell history -- it's meant to keep journals secure in transit, for example when storing it on an `untrusted <http://techcrunch.com/2014/04/09/condoleezza-rice-joins-dropboxs-board/>`_ services such as Dropbox. If you're concerned about security, disable history logging for journal in your ``.bashrc`` ::
|
While jrnl follows best practises, true security is an illusion. Specifically, jrnl will leave traces in your memory and your shell history -- it's meant to keep journals secure in transit, for example when storing it on an `untrusted <http://techcrunch.com/2014/04/09/condoleezza-rice-joins-dropboxs-board/>`_ services such as Dropbox. If you're concerned about security, disable history logging for journal in your ``.bashrc`` ::
|
||||||
|
|
||||||
HISTINGNORE="jrnl *"
|
HISTIGNORE="$HISTIGNORE:jrnl *"
|
||||||
|
|
||||||
|
If you are using zsh instead of bash, you can get the same behaviour adding this to your ``zshrc`` ::
|
||||||
|
|
||||||
|
setopt HIST_IGNORE_SPACE
|
||||||
|
alias jrnl=" jrnl"
|
||||||
|
|
||||||
Manual decryption
|
Manual decryption
|
||||||
-----------------
|
-----------------
|
||||||
|
|
||||||
Should you ever want to decrypt your journal manually, you can do so with any program that supports the AES algorithm in CBC. The key used for encryption is the SHA-256-hash of your password, the IV (initialisation vector) is stored in the first 16 bytes of the encrypted file. The plain text is encoded in UTF-8 and padded according to PKCS#7 before being encrypted. So, to decrypt a journal file in python, run::
|
Should you ever want to decrypt your journal manually, you can do so with any program that supports the AES algorithm in CBC. The key used for encryption is the SHA-256-hash of your password, the IV (initialisation vector) is stored in the first 16 bytes of the encrypted file. The plain text is encoded in UTF-8 and padded according to PKCS#7 before being encrypted. Here's a Python script that you can use to decrypt your journal::
|
||||||
|
|
||||||
import hashlib, Crypto.Cipher
|
#!/usr/bin/env python3
|
||||||
key = hashlib.sha256(my_password).digest()
|
|
||||||
with open("my_journal.txt") as f:
|
import argparse
|
||||||
cipher = f.read()
|
from Crypto.Cipher import AES
|
||||||
crypto = AES.new(key, AES.MODE_CBC, iv = cipher[:16])
|
import getpass
|
||||||
plain = crypto.decrypt(cipher[16:])
|
import hashlib
|
||||||
plain = plain.strip(plain[-1])
|
import sys
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("filepath", help="journal file to decrypt")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
pwd = getpass.getpass()
|
||||||
|
key = hashlib.sha256(pwd.encode('utf-8')).digest()
|
||||||
|
|
||||||
|
with open(args.filepath, 'rb') as f:
|
||||||
|
ciphertext = f.read()
|
||||||
|
|
||||||
|
crypto = AES.new(key, AES.MODE_CBC, ciphertext[:16])
|
||||||
|
plain = crypto.decrypt(ciphertext[16:])
|
||||||
|
plain = plain.strip(plain[-1:])
|
||||||
plain = plain.decode("utf-8")
|
plain = plain.decode("utf-8")
|
||||||
|
print(plain)
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,11 @@ Getting started
|
||||||
Installation
|
Installation
|
||||||
------------
|
------------
|
||||||
|
|
||||||
Install *jrnl* using pip ::
|
On OS X, the easiest way to install *jrnl* is using `Homebrew <http://brew.sh/>`_ ::
|
||||||
|
|
||||||
|
brew install jrnl
|
||||||
|
|
||||||
|
On other platforms, install *jrnl* using pip ::
|
||||||
|
|
||||||
pip install jrnl
|
pip install jrnl
|
||||||
|
|
||||||
|
@ -20,7 +24,7 @@ to install the dependencies for encrypting journals as well.
|
||||||
|
|
||||||
Installing the encryption library, `pycrypto`, requires a `gcc` compiler. For this reason, jrnl will not install `pycrypto` unless explicitly told so like this. You can `install PyCrypto manually <https://www.dlitz.net/software/pycrypto/>`_ first or install it with ``pip install pycrypto`` if you have a `gcc` compiler.
|
Installing the encryption library, `pycrypto`, requires a `gcc` compiler. For this reason, jrnl will not install `pycrypto` unless explicitly told so like this. You can `install PyCrypto manually <https://www.dlitz.net/software/pycrypto/>`_ first or install it with ``pip install pycrypto`` if you have a `gcc` compiler.
|
||||||
|
|
||||||
Also note that when using zsh, you the correct syntax is ``pip install "jrnl[encrypted]"`` (note the quotes).
|
Also note that when using zsh, the correct syntax is ``pip install "jrnl[encrypted]"`` (note the quotes).
|
||||||
|
|
||||||
The first time you run ``jrnl`` you will be asked where your journal file should be created and whether you wish to encrypt it.
|
The first time you run ``jrnl`` you will be asked where your journal file should be created and whether you wish to encrypt it.
|
||||||
|
|
||||||
|
|
|
@ -108,10 +108,17 @@ On OS X, you can use the fabulous `iA Writer <http://www.iawriter.com/mac>`_ to
|
||||||
|
|
||||||
.. code-block:: javascript
|
.. code-block:: javascript
|
||||||
|
|
||||||
"editor": "open -b jp.informationarchitects.WriterForMacOSX -Wn"
|
"editor": "open -b pro.writer.mac -Wn"
|
||||||
|
|
||||||
What does this do? ``open -b ...`` opens a file using the application identified by the bundle identifier (a unique string for every app out there). ``-Wn`` tells the application to wait until it's closed before passing back control, and to use a new instance of the application.
|
What does this do? ``open -b ...`` opens a file using the application identified by the bundle identifier (a unique string for every app out there). ``-Wn`` tells the application to wait until it's closed before passing back control, and to use a new instance of the application.
|
||||||
|
|
||||||
|
If the ``pro.writer.mac`` bundle identifier is not found on your system, you can find the right string to use by inspecting iA Writer's ``Info.plist`` file in your shell:
|
||||||
|
|
||||||
|
.. code-block:: sh
|
||||||
|
|
||||||
|
$ grep -A 1 CFBundleIdentifier /Applications/iA\ Writer.app/Contents/Info.plist
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>pro.writer.mac</string>
|
||||||
|
|
||||||
Notepad++ on Windows
|
Notepad++ on Windows
|
||||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
16
features/data/configs/multiple_without_default.json
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"default_hour": 9,
|
||||||
|
"timeformat": "%Y-%m-%d %H:%M",
|
||||||
|
"linewrap": 80,
|
||||||
|
"encrypt": false,
|
||||||
|
"editor": "",
|
||||||
|
"default_minute": 0,
|
||||||
|
"highlight": true,
|
||||||
|
"password": "",
|
||||||
|
"journals": {
|
||||||
|
"simple": "features/journals/simple.journal",
|
||||||
|
"work": "features/journals/work.journal",
|
||||||
|
"ideas": "features/journals/nothing.journal"
|
||||||
|
},
|
||||||
|
"tagsymbols": "@"
|
||||||
|
}
|
|
@ -34,3 +34,8 @@ Feature: Multiple journals
|
||||||
Then journal "ideas" should not exist
|
Then journal "ideas" should not exist
|
||||||
When we run "jrnl ideas 23 july 2012: sell my junk on ebay and make lots of money"
|
When we run "jrnl ideas 23 july 2012: sell my junk on ebay and make lots of money"
|
||||||
Then journal "ideas" should have 1 entry
|
Then journal "ideas" should have 1 entry
|
||||||
|
|
||||||
|
Scenario: Gracefully handle a config without a default journal
|
||||||
|
Given we use the config "multiple_without_default.json"
|
||||||
|
When we run "jrnl fork this repo and fix something"
|
||||||
|
Then we should see the message "You have not specified a journal. Either provide a default journal in your config file, or specify one of your journals on the command line."
|
||||||
|
|
|
@ -59,3 +59,10 @@ Feature: Zapped bugs should stay dead.
|
||||||
2014-04-24 09:00 Ran 6.2 miles today in 1:02:03.
|
2014-04-24 09:00 Ran 6.2 miles today in 1:02:03.
|
||||||
| I'm feeling sore because I forgot to stretch.
|
| I'm feeling sore because I forgot to stretch.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
Scenario: Writing an entry at the prompt with non-ascii characters
|
||||||
|
# https://github.com/maebert/jrnl/issues/295
|
||||||
|
Given we use the config "basic.json"
|
||||||
|
When we run "jrnl" and enter "Crème brûlée & Mötorhead"
|
||||||
|
Then we should get no error
|
||||||
|
and the journal should contain "Crème brûlée & Mötorhead"
|
||||||
|
|
|
@ -2,11 +2,11 @@ from behave import *
|
||||||
from jrnl import cli, Journal, util
|
from jrnl import cli, Journal, util
|
||||||
from dateutil import parser as date_parser
|
from dateutil import parser as date_parser
|
||||||
import os
|
import os
|
||||||
import sys
|
import codecs
|
||||||
import json
|
import json
|
||||||
import pytz
|
|
||||||
import keyring
|
import keyring
|
||||||
keyring.set_keyring(keyring.backends.file.PlaintextKeyring())
|
import keyrings
|
||||||
|
keyring.set_keyring(keyrings.alt.file.PlaintextKeyring())
|
||||||
try:
|
try:
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
except ImportError:
|
except ImportError:
|
||||||
|
@ -30,7 +30,7 @@ def _parse_args(command):
|
||||||
def read_journal(journal_name="default"):
|
def read_journal(journal_name="default"):
|
||||||
with open(cli.CONFIG_PATH) as config_file:
|
with open(cli.CONFIG_PATH) as config_file:
|
||||||
config = json.load(config_file)
|
config = json.load(config_file)
|
||||||
with open(config['journals'][journal_name]) as journal_file:
|
with codecs.open(config['journals'][journal_name], 'r', 'utf-8') as journal_file:
|
||||||
journal = journal_file.read()
|
journal = journal_file.read()
|
||||||
return journal
|
return journal
|
||||||
|
|
||||||
|
@ -57,7 +57,7 @@ def run_with_input(context, command, inputs=None):
|
||||||
buffer = StringIO(text.strip())
|
buffer = StringIO(text.strip())
|
||||||
util.STDIN = buffer
|
util.STDIN = buffer
|
||||||
try:
|
try:
|
||||||
cli.run(args or None)
|
cli.run(args)
|
||||||
context.exit_status = 0
|
context.exit_status = 0
|
||||||
except SystemExit as e:
|
except SystemExit as e:
|
||||||
context.exit_status = e.code
|
context.exit_status = e.code
|
||||||
|
@ -66,7 +66,7 @@ def run_with_input(context, command, inputs=None):
|
||||||
def run(context, command):
|
def run(context, command):
|
||||||
args = _parse_args(command)
|
args = _parse_args(command)
|
||||||
try:
|
try:
|
||||||
cli.run(args or None)
|
cli.run(args)
|
||||||
context.exit_status = 0
|
context.exit_status = 0
|
||||||
except SystemExit as e:
|
except SystemExit as e:
|
||||||
context.exit_status = e.code
|
context.exit_status = e.code
|
||||||
|
@ -124,10 +124,8 @@ def check_output(context, text=None):
|
||||||
def check_output_time_inline(context, text):
|
def check_output_time_inline(context, text):
|
||||||
out = context.stdout_capture.getvalue()
|
out = context.stdout_capture.getvalue()
|
||||||
local_tz = tzlocal.get_localzone()
|
local_tz = tzlocal.get_localzone()
|
||||||
utc_time = date_parser.parse(text)
|
local_time = date_parser.parse(text).astimezone(local_tz).strftime("%Y-%m-%d %H:%M")
|
||||||
date = utc_time + local_tz._utcoffset
|
assert local_time in out, local_time
|
||||||
local_date = date.strftime("%Y-%m-%d %H:%M")
|
|
||||||
assert local_date in out, local_date
|
|
||||||
|
|
||||||
@then('the output should contain "{text}"')
|
@then('the output should contain "{text}"')
|
||||||
def check_output_inline(context, text):
|
def check_output_inline(context, text):
|
||||||
|
@ -186,7 +184,7 @@ def config_var(context, key, value, journal=None):
|
||||||
@then('the journal should have {number:d} entry')
|
@then('the journal should have {number:d} entry')
|
||||||
@then('journal "{journal_name}" should have {number:d} entries')
|
@then('journal "{journal_name}" should have {number:d} entries')
|
||||||
@then('journal "{journal_name}" should have {number:d} entry')
|
@then('journal "{journal_name}" should have {number:d} entry')
|
||||||
def check_journal_content(context, number, journal_name="default"):
|
def check_num_entries(context, number, journal_name="default"):
|
||||||
journal = open_journal(journal_name)
|
journal = open_journal(journal_name)
|
||||||
assert len(journal.entries) == number
|
assert len(journal.entries) == number
|
||||||
|
|
||||||
|
|
|
@ -30,6 +30,8 @@ class DayOne(Journal.Journal):
|
||||||
filenames = [os.path.join(self.config['journal'], "entries", f) for f in os.listdir(os.path.join(self.config['journal'], "entries"))]
|
filenames = [os.path.join(self.config['journal'], "entries", f) for f in os.listdir(os.path.join(self.config['journal'], "entries"))]
|
||||||
self.entries = []
|
self.entries = []
|
||||||
for filename in filenames:
|
for filename in filenames:
|
||||||
|
if os.path.isdir(filename):
|
||||||
|
continue
|
||||||
with open(filename, 'rb') as plist_entry:
|
with open(filename, 'rb') as plist_entry:
|
||||||
try:
|
try:
|
||||||
dict_entry = plistlib.readPlist(plist_entry)
|
dict_entry = plistlib.readPlist(plist_entry)
|
||||||
|
@ -58,7 +60,10 @@ class DayOne(Journal.Journal):
|
||||||
if not hasattr(entry, "uuid"):
|
if not hasattr(entry, "uuid"):
|
||||||
entry.uuid = uuid.uuid1().hex
|
entry.uuid = uuid.uuid1().hex
|
||||||
utc_time = datetime.utcfromtimestamp(time.mktime(entry.date.timetuple()))
|
utc_time = datetime.utcfromtimestamp(time.mktime(entry.date.timetuple()))
|
||||||
filename = os.path.join(self.config['journal'], "entries", entry.uuid + ".doentry")
|
# make sure to upper() the uuid since uuid.uuid1 returns a lowercase string by default
|
||||||
|
# while dayone uses uppercase by default. On fully case preserving filesystems (e.g.
|
||||||
|
# linux) this results in duplicated entries when we save the file
|
||||||
|
filename = os.path.join(self.config['journal'], "entries", entry.uuid.upper() + ".doentry")
|
||||||
entry_plist = {
|
entry_plist = {
|
||||||
'Creation Date': utc_time,
|
'Creation Date': utc_time,
|
||||||
'Starred': entry.starred if hasattr(entry, 'starred') else False,
|
'Starred': entry.starred if hasattr(entry, 'starred') else False,
|
||||||
|
|
|
@ -8,7 +8,7 @@ jrnl is a simple journal application for your command line.
|
||||||
from __future__ import absolute_import
|
from __future__ import absolute_import
|
||||||
|
|
||||||
__title__ = 'jrnl'
|
__title__ = 'jrnl'
|
||||||
__version__ = '1.9.5'
|
__version__ = '1.9.8'
|
||||||
__author__ = 'Manuel Ebert'
|
__author__ = 'Manuel Ebert'
|
||||||
__license__ = 'MIT License'
|
__license__ = 'MIT License'
|
||||||
__copyright__ = 'Copyright 2013 - 2014 Manuel Ebert'
|
__copyright__ = 'Copyright 2013 - 2014 Manuel Ebert'
|
||||||
|
|
51
jrnl/cli.py
|
@ -17,16 +17,19 @@ import jrnl
|
||||||
import os
|
import os
|
||||||
import argparse
|
import argparse
|
||||||
import sys
|
import sys
|
||||||
|
import logging
|
||||||
|
|
||||||
xdg_config = os.environ.get('XDG_CONFIG_HOME')
|
xdg_config = os.environ.get('XDG_CONFIG_HOME')
|
||||||
CONFIG_PATH = os.path.join(xdg_config, "jrnl") if xdg_config else os.path.expanduser('~/.jrnl_config')
|
CONFIG_PATH = os.path.join(xdg_config, "jrnl") if xdg_config else os.path.expanduser('~/.jrnl_config')
|
||||||
PYCRYPTO = install.module_exists("Crypto")
|
PYCRYPTO = install.module_exists("Crypto")
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def parse_args(args=None):
|
def parse_args(args=None):
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument('-v', '--version', dest='version', action="store_true", help="prints version information and exits")
|
parser.add_argument('-v', '--version', dest='version', action="store_true", help="prints version information and exits")
|
||||||
parser.add_argument('-ls', dest='ls', action="store_true", help="displays accessible journals")
|
parser.add_argument('-ls', dest='ls', action="store_true", help="displays accessible journals")
|
||||||
|
parser.add_argument('-d', '--debug', dest='debug', action='store_true', help='execute in debug mode')
|
||||||
|
|
||||||
composing = parser.add_argument_group('Composing', 'To write an entry simply write it on the command line, e.g. "jrnl yesterday at 1pm: Went to the gym."')
|
composing = parser.add_argument_group('Composing', 'To write an entry simply write it on the command line, e.g. "jrnl yesterday at 1pm: Went to the gym."')
|
||||||
composing.add_argument('text', metavar='', nargs="*")
|
composing.add_argument('text', metavar='', nargs="*")
|
||||||
|
@ -90,6 +93,7 @@ def decrypt(journal, filename=None):
|
||||||
def touch_journal(filename):
|
def touch_journal(filename):
|
||||||
"""If filename does not exist, touch the file"""
|
"""If filename does not exist, touch the file"""
|
||||||
if not os.path.exists(filename):
|
if not os.path.exists(filename):
|
||||||
|
log.debug('Creating journal file %s', filename)
|
||||||
util.prompt("[Journal created at {0}]".format(filename))
|
util.prompt("[Journal created at {0}]".format(filename))
|
||||||
open(filename, 'a').close()
|
open(filename, 'a').close()
|
||||||
|
|
||||||
|
@ -114,8 +118,15 @@ def update_config(config, new_config, scope, force_local=False):
|
||||||
config.update(new_config)
|
config.update(new_config)
|
||||||
|
|
||||||
|
|
||||||
|
def configure_logger(debug=False):
|
||||||
|
logging.basicConfig(level=logging.DEBUG if debug else logging.INFO,
|
||||||
|
format='%(levelname)-8s %(name)-12s %(message)s')
|
||||||
|
logging.getLogger('parsedatetime').setLevel(logging.INFO) # disable parsedatetime debug logging
|
||||||
|
|
||||||
|
|
||||||
def run(manual_args=None):
|
def run(manual_args=None):
|
||||||
args = parse_args(manual_args)
|
args = parse_args(manual_args)
|
||||||
|
configure_logger(args.debug)
|
||||||
args.text = [p.decode('utf-8') if util.PY2 and not isinstance(p, unicode) else p for p in args.text]
|
args.text = [p.decode('utf-8') if util.PY2 and not isinstance(p, unicode) else p for p in args.text]
|
||||||
if args.version:
|
if args.version:
|
||||||
version_str = "{0} version {1}".format(jrnl.__title__, jrnl.__version__)
|
version_str = "{0} version {1}".format(jrnl.__title__, jrnl.__version__)
|
||||||
|
@ -123,8 +134,10 @@ def run(manual_args=None):
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
if not os.path.exists(CONFIG_PATH):
|
if not os.path.exists(CONFIG_PATH):
|
||||||
|
log.debug('Configuration file not found, installing jrnl...')
|
||||||
config = install.install_jrnl(CONFIG_PATH)
|
config = install.install_jrnl(CONFIG_PATH)
|
||||||
else:
|
else:
|
||||||
|
log.debug('Reading configuration from file %s', CONFIG_PATH)
|
||||||
config = util.load_and_fix_json(CONFIG_PATH)
|
config = util.load_and_fix_json(CONFIG_PATH)
|
||||||
install.upgrade_config(config, config_path=CONFIG_PATH)
|
install.upgrade_config(config, config_path=CONFIG_PATH)
|
||||||
|
|
||||||
|
@ -132,6 +145,7 @@ def run(manual_args=None):
|
||||||
print(util.py2encode(list_journals(config)))
|
print(util.py2encode(list_journals(config)))
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
|
log.debug('Using configuration "%s"', config)
|
||||||
original_config = config.copy()
|
original_config = config.copy()
|
||||||
# check if the configuration is supported by available modules
|
# check if the configuration is supported by available modules
|
||||||
if config['encrypt'] and not PYCRYPTO:
|
if config['encrypt'] and not PYCRYPTO:
|
||||||
|
@ -151,15 +165,34 @@ def run(manual_args=None):
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
log.debug('Using journal "%s"', journal_name)
|
||||||
journal_conf = config['journals'].get(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
|
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)
|
config.update(journal_conf)
|
||||||
else: # But also just give them a string to point to the journal file
|
else: # But also just give them a string to point to the journal file
|
||||||
config['journal'] = journal_conf
|
config['journal'] = journal_conf
|
||||||
|
|
||||||
|
if config['journal'] is None:
|
||||||
|
util.prompt("You have not specified a journal. Either provide a default journal in your config file, or specify one of your journals on the command line.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
config['journal'] = os.path.expanduser(os.path.expandvars(config['journal']))
|
config['journal'] = os.path.expanduser(os.path.expandvars(config['journal']))
|
||||||
touch_journal(config['journal'])
|
touch_journal(config['journal'])
|
||||||
|
log.debug('Using journal path %(journal)s', config)
|
||||||
mode_compose, mode_export = guess_mode(args, config)
|
mode_compose, mode_export = guess_mode(args, config)
|
||||||
|
|
||||||
|
# open journal file or folder
|
||||||
|
if os.path.isdir(config['journal']):
|
||||||
|
if config['journal'].strip("/").endswith(".dayone") or \
|
||||||
|
"entries" in os.listdir(config['journal']):
|
||||||
|
journal = DayOneJournal.DayOne(**config)
|
||||||
|
else:
|
||||||
|
util.prompt("[Error: {0} is a directory, but doesn't seem to be a DayOne journal either.".format(config['journal']))
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
journal = Journal.Journal(journal_name, **config)
|
||||||
|
|
||||||
# How to quit writing?
|
# How to quit writing?
|
||||||
if "win32" in sys.platform:
|
if "win32" in sys.platform:
|
||||||
_exit_multiline_code = "on a blank line, press Ctrl+Z and then Enter"
|
_exit_multiline_code = "on a blank line, press Ctrl+Z and then Enter"
|
||||||
|
@ -183,22 +216,12 @@ def run(manual_args=None):
|
||||||
else:
|
else:
|
||||||
mode_compose = False
|
mode_compose = False
|
||||||
|
|
||||||
# open journal file or folder
|
|
||||||
if os.path.isdir(config['journal']):
|
|
||||||
if config['journal'].strip("/").endswith(".dayone") or \
|
|
||||||
"entries" in os.listdir(config['journal']):
|
|
||||||
journal = DayOneJournal.DayOne(**config)
|
|
||||||
else:
|
|
||||||
util.prompt("[Error: {0} is a directory, but doesn't seem to be a DayOne journal either.".format(config['journal']))
|
|
||||||
sys.exit(1)
|
|
||||||
else:
|
|
||||||
journal = Journal.Journal(journal_name, **config)
|
|
||||||
|
|
||||||
# Writing mode
|
# Writing mode
|
||||||
if mode_compose:
|
if mode_compose:
|
||||||
raw = " ".join(args.text).strip()
|
raw = " ".join(args.text).strip()
|
||||||
if util.PY2 and type(raw) is not unicode:
|
if util.PY2 and type(raw) is not unicode:
|
||||||
raw = raw.decode(sys.getfilesystemencoding())
|
raw = raw.decode(sys.getfilesystemencoding())
|
||||||
|
log.debug('Appending raw line "%s" to journal "%s"', raw, journal_name)
|
||||||
journal.new_entry(raw)
|
journal.new_entry(raw)
|
||||||
util.prompt("[Entry added to {0} journal]".format(journal_name))
|
util.prompt("[Entry added to {0} journal]".format(journal_name))
|
||||||
journal.write()
|
journal.write()
|
||||||
|
@ -233,14 +256,14 @@ def run(manual_args=None):
|
||||||
elif args.encrypt is not False:
|
elif args.encrypt is not False:
|
||||||
encrypt(journal, filename=args.encrypt)
|
encrypt(journal, filename=args.encrypt)
|
||||||
# Not encrypting to a separate file: update config!
|
# Not encrypting to a separate file: update config!
|
||||||
if not args.encrypt:
|
if not args.encrypt or args.encrypt == config['journal']:
|
||||||
update_config(original_config, {"encrypt": True}, journal_name, force_local=True)
|
update_config(original_config, {"encrypt": True}, journal_name, force_local=True)
|
||||||
install.save_config(original_config, config_path=CONFIG_PATH)
|
install.save_config(original_config, config_path=CONFIG_PATH)
|
||||||
|
|
||||||
elif args.decrypt is not False:
|
elif args.decrypt is not False:
|
||||||
decrypt(journal, filename=args.decrypt)
|
decrypt(journal, filename=args.decrypt)
|
||||||
# Not decrypting to a separate file: update config!
|
# Not decrypting to a separate file: update config!
|
||||||
if not args.decrypt:
|
if not args.decrypt or args.decrypt == config['journal']:
|
||||||
update_config(original_config, {"encrypt": False}, journal_name, force_local=True)
|
update_config(original_config, {"encrypt": False}, journal_name, force_local=True)
|
||||||
install.save_config(original_config, config_path=CONFIG_PATH)
|
install.save_config(original_config, config_path=CONFIG_PATH)
|
||||||
|
|
||||||
|
@ -259,7 +282,7 @@ def run(manual_args=None):
|
||||||
if num_deleted:
|
if num_deleted:
|
||||||
prompts.append("{0} {1} deleted".format(num_deleted, "entry" if num_deleted == 1 else "entries"))
|
prompts.append("{0} {1} deleted".format(num_deleted, "entry" if num_deleted == 1 else "entries"))
|
||||||
if num_edited:
|
if num_edited:
|
||||||
prompts.append("{0} {1} modified".format(num_edited, "entry" if num_deleted == 1 else "entries"))
|
prompts.append("{0} {1} modified".format(num_edited, "entry" if num_edited == 1 else "entries"))
|
||||||
if prompts:
|
if prompts:
|
||||||
util.prompt("[{0}]".format(", ".join(prompts).capitalize()))
|
util.prompt("[{0}]".format(", ".join(prompts).capitalize()))
|
||||||
journal.entries += other_entries
|
journal.entries += other_entries
|
||||||
|
|
|
@ -87,6 +87,7 @@ def install_jrnl(config_path='~/.jrnl_config'):
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
if not os.path.isdir(path): # if it's a directory and exists (e.g. a DayOne journal, let it be)
|
||||||
open(default_config['journals']['default'], 'a').close() # Touch to make sure it's there
|
open(default_config['journals']['default'], 'a').close() # Touch to make sure it's there
|
||||||
|
|
||||||
# Write config to ~/.jrnl_conf
|
# Write config to ~/.jrnl_conf
|
||||||
|
|
|
@ -48,7 +48,10 @@ def parse(date_str, inclusive=False, default_hour=None, default_minute=None):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if flag is 1: # Date found, but no time. Use the default time.
|
if flag is 1: # Date found, but no time. Use the default time.
|
||||||
date = datetime(*date[:3], hour=default_hour or 0, minute=default_minute or 0)
|
date = datetime(*date[:3],
|
||||||
|
hour=23 if inclusive else default_hour or 0,
|
||||||
|
minute=59 if inclusive else default_minute or 0,
|
||||||
|
second=59 if inclusive else 0)
|
||||||
else:
|
else:
|
||||||
date = datetime(*date[:6])
|
date = datetime(*date[:6])
|
||||||
|
|
||||||
|
|
23
jrnl/util.py
|
@ -13,6 +13,7 @@ import tempfile
|
||||||
import subprocess
|
import subprocess
|
||||||
import codecs
|
import codecs
|
||||||
import unicodedata
|
import unicodedata
|
||||||
|
import logging
|
||||||
|
|
||||||
PY3 = sys.version_info[0] == 3
|
PY3 = sys.version_info[0] == 3
|
||||||
PY2 = sys.version_info[0] == 2
|
PY2 = sys.version_info[0] == 2
|
||||||
|
@ -22,6 +23,8 @@ STDOUT = sys.stdout
|
||||||
TEST = False
|
TEST = False
|
||||||
__cached_tz = None
|
__cached_tz = None
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def getpass(prompt="Password: "):
|
def getpass(prompt="Password: "):
|
||||||
if not TEST:
|
if not TEST:
|
||||||
|
@ -71,16 +74,19 @@ def py2encode(s):
|
||||||
|
|
||||||
def prompt(msg):
|
def prompt(msg):
|
||||||
"""Prints a message to the std err stream defined in util."""
|
"""Prints a message to the std err stream defined in util."""
|
||||||
|
if not msg:
|
||||||
|
return
|
||||||
if not msg.endswith("\n"):
|
if not msg.endswith("\n"):
|
||||||
msg += "\n"
|
msg += "\n"
|
||||||
STDERR.write(u(msg))
|
STDERR.write(u(msg))
|
||||||
|
|
||||||
def py23_input(msg=""):
|
def py23_input(msg=""):
|
||||||
STDERR.write(u(msg))
|
prompt(msg)
|
||||||
return STDIN.readline().strip()
|
return u(STDIN.readline()).strip()
|
||||||
|
|
||||||
def py23_read(msg=""):
|
def py23_read(msg=""):
|
||||||
return STDIN.read()
|
prompt(msg)
|
||||||
|
return u(STDIN.read())
|
||||||
|
|
||||||
def yesno(prompt, default=True):
|
def yesno(prompt, default=True):
|
||||||
prompt = prompt.strip() + (" [Y/n]" if default else " [y/N]")
|
prompt = prompt.strip() + (" [Y/n]" if default else " [y/N]")
|
||||||
|
@ -93,27 +99,34 @@ def load_and_fix_json(json_path):
|
||||||
"""
|
"""
|
||||||
with open(json_path) as f:
|
with open(json_path) as f:
|
||||||
json_str = f.read()
|
json_str = f.read()
|
||||||
config = fixed = None
|
log.debug('Configuration file %s read correctly', json_path)
|
||||||
|
config = None
|
||||||
try:
|
try:
|
||||||
return json.loads(json_str)
|
return json.loads(json_str)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
log.debug('Could not parse configuration %s: %s', json_str, e,
|
||||||
|
exc_info=True)
|
||||||
# Attempt to fix extra ,
|
# Attempt to fix extra ,
|
||||||
json_str = re.sub(r",[ \n]*}", "}", json_str)
|
json_str = re.sub(r",[ \n]*}", "}", json_str)
|
||||||
# Attempt to fix missing ,
|
# Attempt to fix missing ,
|
||||||
json_str = re.sub(r"([^{,]) *\n *(\")", r"\1,\n \2", json_str)
|
json_str = re.sub(r"([^{,]) *\n *(\")", r"\1,\n \2", json_str)
|
||||||
try:
|
try:
|
||||||
|
log.debug('Attempting to reload automatically fixed configuration file %s',
|
||||||
|
json_str)
|
||||||
config = json.loads(json_str)
|
config = json.loads(json_str)
|
||||||
with open(json_path, 'w') as f:
|
with open(json_path, 'w') as f:
|
||||||
json.dump(config, f, indent=2)
|
json.dump(config, f, indent=2)
|
||||||
|
log.debug('Fixed configuration saved in file %s', json_path)
|
||||||
prompt("[Some errors in your jrnl config have been fixed for you.]")
|
prompt("[Some errors in your jrnl config have been fixed for you.]")
|
||||||
return config
|
return config
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
log.debug('Could not load fixed configuration: %s', e, exc_info=True)
|
||||||
prompt("[There seems to be something wrong with your jrnl config at {0}: {1}]".format(json_path, e.message))
|
prompt("[There seems to be something wrong with your jrnl config at {0}: {1}]".format(json_path, e.message))
|
||||||
prompt("[Entry was NOT added to your journal]")
|
prompt("[Entry was NOT added to your journal]")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
def get_text_from_editor(config, template=""):
|
def get_text_from_editor(config, template=""):
|
||||||
tmpfile = os.path.join(tempfile.mktemp(prefix="jrnl"))
|
_, tmpfile = tempfile.mkstemp(prefix="jrnl", text=True, suffix=".txt")
|
||||||
with codecs.open(tmpfile, 'w', "utf-8") as f:
|
with codecs.open(tmpfile, 'w', "utf-8") as f:
|
||||||
if template:
|
if template:
|
||||||
f.write(template)
|
f.write(template)
|
||||||
|
|
103
setup.py
|
@ -51,13 +51,8 @@ except ImportError:
|
||||||
readline_available = False
|
readline_available = False
|
||||||
|
|
||||||
|
|
||||||
if sys.argv[-1] == 'publish':
|
|
||||||
os.system("python setup.py sdist upload")
|
|
||||||
sys.exit()
|
|
||||||
|
|
||||||
base_dir = os.path.dirname(os.path.abspath(__file__))
|
base_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
|
|
||||||
def get_version(filename="jrnl/__init__.py"):
|
def get_version(filename="jrnl/__init__.py"):
|
||||||
with open(os.path.join(base_dir, filename)) as initfile:
|
with open(os.path.join(base_dir, filename)) as initfile:
|
||||||
for line in initfile.readlines():
|
for line in initfile.readlines():
|
||||||
|
@ -65,6 +60,71 @@ def get_version(filename="jrnl/__init__.py"):
|
||||||
if m:
|
if m:
|
||||||
return m.group(1)
|
return m.group(1)
|
||||||
|
|
||||||
|
|
||||||
|
def get_changelog(filename="CHANGELOG.md"):
|
||||||
|
changelog = {}
|
||||||
|
current_version = None
|
||||||
|
with open(os.path.join(base_dir, filename)) as changelog_file:
|
||||||
|
for line in changelog_file.readlines():
|
||||||
|
if line.startswith("* __"):
|
||||||
|
parts = line.strip("* ").split(" ", 1)
|
||||||
|
if len(parts) == 2:
|
||||||
|
current_version, changes = parts[0].strip("_\n"), parts[1]
|
||||||
|
changelog[current_version] = [changes.strip()]
|
||||||
|
else:
|
||||||
|
current_version = parts[0].strip("_\n")
|
||||||
|
changelog[current_version] = []
|
||||||
|
elif line.strip() and current_version and not line.startswith("#"):
|
||||||
|
changelog[current_version].append(line.strip(" *\n"))
|
||||||
|
return changelog
|
||||||
|
|
||||||
|
def dist_pypi():
|
||||||
|
os.system("python setup.py sdist upload")
|
||||||
|
sys.exit()
|
||||||
|
|
||||||
|
def dist_github():
|
||||||
|
"""Creates a release on the maebert/jrnl repository on github"""
|
||||||
|
import requests
|
||||||
|
import keyring
|
||||||
|
import getpass
|
||||||
|
version = get_version()
|
||||||
|
version_tuple = version.split(".")
|
||||||
|
changes_since_last_version = ["* __{}__: {}".format(key, "\n".join(changes)) for key, changes in get_changelog().items() if key.startswith("{}.{}".format(*version_tuple))]
|
||||||
|
changes_since_last_version = "\n".join(sorted(changes_since_last_version, reverse=True))
|
||||||
|
payload = {
|
||||||
|
"tag_name": version,
|
||||||
|
"target_commitish": "master",
|
||||||
|
"name": version,
|
||||||
|
"body": "Changes in Version {}.{}: \n\n{}".format(version_tuple[0], version_tuple[1], changes_since_last_version)
|
||||||
|
}
|
||||||
|
print("Preparing release {}...".format(version))
|
||||||
|
username = keyring.get_password("github", "__default_user") or raw_input("Github username: ")
|
||||||
|
password = keyring.get_password("github", username) or getpass.getpass()
|
||||||
|
otp = raw_input("One Time Token: ")
|
||||||
|
response = requests.post("https://api.github.com/repos/maebert/jrnl/releases", headers={"X-GitHub-OTP": otp}, json=payload, auth=(username, password))
|
||||||
|
if response.status_code in (403, 404):
|
||||||
|
print("Authentication error.")
|
||||||
|
else:
|
||||||
|
keyring.set_password("github", "__default_user", username)
|
||||||
|
keyring.set_password("github", username, password)
|
||||||
|
if response.status_code > 299:
|
||||||
|
if "message" in response.json():
|
||||||
|
print("Error: {}".format(response.json()['message']))
|
||||||
|
for error_dict in response.json().get('errors', []):
|
||||||
|
print("*", error_dict)
|
||||||
|
else:
|
||||||
|
print("Unkown error")
|
||||||
|
print(response.text)
|
||||||
|
else:
|
||||||
|
print("Release created.")
|
||||||
|
sys.exit()
|
||||||
|
|
||||||
|
if sys.argv[-1] == 'publish':
|
||||||
|
dist_pypi()
|
||||||
|
|
||||||
|
if sys.argv[-1] == 'github_release':
|
||||||
|
dist_github()
|
||||||
|
|
||||||
conditional_dependencies = {
|
conditional_dependencies = {
|
||||||
"pyreadline>=2.0": not readline_available and "win32" in sys.platform,
|
"pyreadline>=2.0": not readline_available and "win32" in sys.platform,
|
||||||
"readline>=6.2": not readline_available and "win32" not in sys.platform,
|
"readline>=6.2": not readline_available and "win32" not in sys.platform,
|
||||||
|
@ -86,30 +146,31 @@ setup(
|
||||||
"six>=1.6.1",
|
"six>=1.6.1",
|
||||||
"tzlocal>=1.1",
|
"tzlocal>=1.1",
|
||||||
"keyring>=3.3",
|
"keyring>=3.3",
|
||||||
|
"keyrings.alt>=1.3",
|
||||||
] + [p for p, cond in conditional_dependencies.items() if cond],
|
] + [p for p, cond in conditional_dependencies.items() if cond],
|
||||||
extras_require = {
|
extras_require = {
|
||||||
"encrypted": "pycrypto>=2.6"
|
"encrypted": "pycrypto>=2.6"
|
||||||
},
|
},
|
||||||
long_description=__doc__,
|
long_description=__doc__,
|
||||||
entry_points={
|
entry_points={
|
||||||
'console_scripts': [
|
"console_scripts": [
|
||||||
'jrnl = jrnl:run',
|
"jrnl = jrnl:run",
|
||||||
],
|
]
|
||||||
},
|
},
|
||||||
classifiers=[
|
classifiers=[
|
||||||
'Development Status :: 5 - Production/Stable',
|
"Development Status :: 5 - Production/Stable",
|
||||||
'Environment :: Console',
|
"Environment :: Console",
|
||||||
'Intended Audience :: End Users/Desktop',
|
"Intended Audience :: End Users/Desktop",
|
||||||
'License :: OSI Approved :: MIT License',
|
"License :: OSI Approved :: MIT License",
|
||||||
'Natural Language :: English',
|
"Natural Language :: English",
|
||||||
'Operating System :: OS Independent',
|
"Operating System :: OS Independent",
|
||||||
'Programming Language :: Python',
|
"Programming Language :: Python",
|
||||||
'Programming Language :: Python :: 2.6',
|
"Programming Language :: Python :: 2.6",
|
||||||
'Programming Language :: Python :: 2.7',
|
"Programming Language :: Python :: 2.7",
|
||||||
'Programming Language :: Python :: 3.3',
|
"Programming Language :: Python :: 3.3",
|
||||||
'Programming Language :: Python :: 3.4',
|
"Programming Language :: Python :: 3.4",
|
||||||
'Topic :: Office/Business :: News/Diary',
|
"Topic :: Office/Business :: News/Diary",
|
||||||
'Topic :: Text Processing'
|
"Topic :: Text Processing"
|
||||||
],
|
],
|
||||||
# metadata for upload to PyPI
|
# metadata for upload to PyPI
|
||||||
author = "Manuel Ebert",
|
author = "Manuel Ebert",
|
||||||
|
|