Merge branch 'develop' into interactive_delete

This commit is contained in:
dbxnr 2020-02-12 04:06:19 +00:00
commit 972a00f478
58 changed files with 1910 additions and 840 deletions

64
.build/generate_changelog.sh Executable file
View file

@ -0,0 +1,64 @@
#!/usr/bin/env bash
BRANCH=$TRAVIS_BRANCH
if [[ $TRAVIS_BRANCH == $TRAVIS_TAG ]]; then
BRANCH='master'
fi
# Check if branch has been updated since this build started
# This tends to happen if multiple things have been merged in at the same time.
if [[ -z $TRAVIS_TAG ]]; then
git fetch origin
if [[ $(git rev-parse "origin/${BRANCH}") != $TRAVIS_COMMIT ]]; then
echo "${BRANCH} has been updated since build started. Aborting changelog."
exit 0
fi
fi
FILENAME='CHANGELOG.md'
# get the latest git tags
releases="$(git tag --sort=-creatordate | grep -Ev '(alpha|beta|rc)')"
release_latest=$(printf '%s' "$releases" | awk 'NR==1')
release_secondlatest=$(printf '%s' "$releases" | awk 'NR==2')
echo "release_latest: ${release_latest}"
echo "release_secondlatest: ${release_secondlatest}"
# delete generated line (or it will be added multiple times)
sed -i '/This Changelog was automatically generated by/d' "$FILENAME"
# delete trailing empty lines
sed -i -e :a -e '/^\n*$/{$d;N;};/\n$/ba' "$FILENAME"
# determine correct tag to go back to
if [[ $TRAVIS_TAG == $release_latest ]]; then
echo "release build"
gittag=${release_secondlatest}
elif [[ ! -z $TRAVIS_TAG ]]; then
echo "beta elease"
gittag=${release_latest}
else
echo "merge into master or develop"
gittag=${release_latest}
fi
echo "gittag: ${gittag}"
# find the line the tag starts on, and subtract 1
tagline=$(grep -n "^## \[\?$gittag\]\?" "$FILENAME" | awk '{print $1}' FS=':' | head -1)
echo "tagline: ${tagline}"
[[ ! -z $tagline ]] && sed -i "1,$(expr $tagline - 1)d" "$FILENAME"
# generate the changelog
docker run -it --rm -v "$(pwd)":/usr/local/src/your-app ferrarimarco/github-changelog-generator -t $GITHUB_TOKEN --since-tag $gittag
# Put back our link (instead of the broken one)
sed -i 's!https://pypi.org/project/jrnl/HEAD/!https://github.com/jrnl-org/jrnl/!' "$FILENAME"
git config --global user.email "jrnl.bot@gmail.com"
git config --global user.name "Jrnl Bot"
git checkout $BRANCH
git add "$FILENAME"
git commit -m "Updating changelog [ci skip]"
git push https://${GITHUB_TOKEN}@github.com/jrnl-org/jrnl.git $BRANCH

View file

@ -0,0 +1,11 @@
project=jrnl
user=jrnl-org
base=CHANGELOG.md
issues=false
issues-wo-labels=false
include-labels=bug,enhancement,documentation,build,deprecated
release-url=https://pypi.org/project/jrnl/%s/
add-sections={ "build": { "prefix": "**Build:**", "labels": ["build"]}, "docs": { "prefix": "**Updated documentation:**", "labels": ["documentation"]}}
exclude-tags-regex=(alpha|beta|rc)
verbose=false

View file

@ -1,34 +1,149 @@
dist: xenial # required for Python >= 3.7 dist: xenial # required for Python >= 3.7
os: linux
language: python language: python
python:
- 3.6 cache:
- 3.7 - pip
git: git:
depth: false depth: false
autocrlf: false
before_install: before_install:
- pip install poetry~=0.12.17 - date
install: install:
# we run `poetry version` here to appease poetry about '0.0.0-source' - pip install poetry
- poetry version
- poetry install - poetry install
script:
- poetry run python --version - poetry run python --version
script:
- poetry run behave - poetry run behave
before_deploy:
- poetry config http-basic.pypi $PYPI_USER $PYPI_PASS aliases:
- poetry version $TRAVIS_TAG test_mac: &test_mac
- poetry build os: osx
deploy: language: shell
- provider: script osx_image: xcode11.2
script: poetry publish cache:
skip_cleanup: true directories:
on: - $HOME/.pyenv/versions
branch: master before_install:
tags: true - eval "$(pyenv init -)"
after_deploy: - pyenv install -s $JRNL_PYTHON_VERSION
- git config --global user.email "jrnl.bot@gmail.com" - pyenv global $JRNL_PYTHON_VERSION
- git config --global user.name "Jrnl Bot" - pip install --upgrade pip
- git checkout master - pip --version
- git add pyproject.toml test_windows: &test_windows
- git commit -m "Incrementing version to ${TRAVIS_TAG}" os: windows
- git push https://${GITHUB_TOKEN}@github.com/jrnl-org/jrnl.git master language: shell
cache:
directories:
- /c/Python36
- /c/Python37
- /c/Python38
before_install:
- choco install python --version $JRNL_PYTHON_VERSION
- python -m pip install --upgrade pip
- pip --version
jobs:
fast_finish: true
allow_failures:
- python: nightly
include:
- name: Lint, via Black
python: 3.8
script:
- black --version
- black --check . --verbose --diff
# Python 3.6 Tests
- name: Python 3.6 on Linux
python: 3.6
- <<: *test_mac
name: Python 3.6 on MacOS
python: 3.6
env:
- JRNL_PYTHON_VERSION=3.6.8
- <<: *test_windows
name: Python 3.6 on Windows
python: 3.6
env:
- JRNL_PYTHON_VERSION=3.6.8
- PATH=/c/Python36:/c/Python36/Scripts:$PATH
- PYTHONIOENCODING=UTF-8
# Python 3.7 Tests
- name: Python 3.7 on Linux
python: 3.7
- <<: *test_mac
name: Python 3.7 on MacOS
python: 3.7
env:
- JRNL_PYTHON_VERSION=3.7.5
- <<: *test_windows
name: Python 3.7 on Windows
python: 3.7
env:
- JRNL_PYTHON_VERSION=3.7.5
- PATH=/c/Python37:/c/Python37/Scripts:$PATH
- PYTHONIOENCODING=UTF-8
# Python 3.8 Tests
- name: Python 3.8 on Linux
python: 3.8
- <<: *test_mac
name: Python 3.8 on MacOS
python: 3.8
env:
- JRNL_PYTHON_VERSION=3.8.0
- <<: *test_windows
name: Python 3.8 on Windows
python: 3.8
env:
- JRNL_PYTHON_VERSION=3.8.0
- PATH=/c/Python38:/c/Python38/Scripts:$PATH
- PYTHONIOENCODING=UTF-8
# ... and beyond!
- name: Python nightly on Linux
python: nightly
# Specialty tests
- name: Python 3.7 on Linux, not UTC
python: 3.7
env:
- TZ=America/Edmonton
# Changelog for Unreleased changes
- stage: Update Changelog
if: (tag IS present) OR (branch = develop AND type NOT IN (pull_request))
install:
- echo 'Skipping install'
script:
- ./.build/generate_changelog.sh
- stage: Deploy
if: tag IS present
before_deploy:
- poetry version "$TRAVIS_TAG"
- echo __version__ = \"$TRAVIS_TAG\" > jrnl/__version__.py
- poetry build
script:
- echo "Deployment starting..."
deploy:
- provider: script
script: poetry publish
skip_cleanup: true
on:
branch: master
tags: true
after_deploy:
- git config --global user.email "jrnl.bot@gmail.com"
- git config --global user.name "Jrnl Bot"
- git checkout master
- git add pyproject.toml
- git commit -m "Incrementing version to ${TRAVIS_TAG} [ci skip]"
- git push https://${GITHUB_TOKEN}@github.com/jrnl-org/jrnl.git master

View file

@ -1,14 +1,171 @@
Changelog # Changelog
=========
## 2.0 ## [Unreleased](https://github.com/jrnl-org/jrnl/)
* Cryptographic backend changed from PyCrypto to cryptography.io [Full Changelog](https://github.com/jrnl-org/jrnl/compare/v2.2...HEAD)
* Config now respects XDG conventions and may move accordingly
* Config now saved as YAML
* Config name changed from `journals.jrnl_name.journal` to `journals.jrnl_name.path`
### 1.9 (July 21, 2014) **Implemented enhancements:**
- Update YAML exporter to handle Dayone format [\#773](https://github.com/jrnl-org/jrnl/pull/773) ([MinchinWeb](https://github.com/MinchinWeb))
**Fixed bugs:**
- Listing all entries in DayOne Classic journal throws IndexError [\#786](https://github.com/jrnl-org/jrnl/pull/786) ([MinchinWeb](https://github.com/MinchinWeb))
- Add UTC support for failing DayOne tests [\#785](https://github.com/jrnl-org/jrnl/pull/785) ([MinchinWeb](https://github.com/MinchinWeb))
**Build:**
- Stop multipe changelog generators from crashing into each other [\#845](https://github.com/jrnl-org/jrnl/pull/845) ([wren](https://github.com/wren))
- Don't re-run tests on deployment [\#839](https://github.com/jrnl-org/jrnl/pull/839) ([wren](https://github.com/wren))
- Put back build lines in Poetry config [\#838](https://github.com/jrnl-org/jrnl/pull/838) ([wren](https://github.com/wren))
- Restore emoji test [\#837](https://github.com/jrnl-org/jrnl/pull/837) ([micahellison](https://github.com/micahellison))
- Fix crashing unicode Travis tests on Windows and fail build if Windows tests fail [\#836](https://github.com/jrnl-org/jrnl/pull/836) ([micahellison](https://github.com/micahellison))
- Remove poetry from build system in pyproject config to fix `brew install` [\#830](https://github.com/jrnl-org/jrnl/pull/830) ([wren](https://github.com/wren))
- Fix all skipped tests on Travis Windows builds by preserving newlines [\#823](https://github.com/jrnl-org/jrnl/pull/823) ([micahellison](https://github.com/micahellison))
**Updated documentation:**
- Update site description [\#841](https://github.com/jrnl-org/jrnl/pull/841) ([wren](https://github.com/wren))
- Get rid of dumb sex joke [\#840](https://github.com/jrnl-org/jrnl/pull/840) ([wren](https://github.com/wren))
- Updating/clarifying template explanation [\#829](https://github.com/jrnl-org/jrnl/pull/829) ([heymajor](https://github.com/heymajor))
## [v2.2](https://pypi.org/project/jrnl/v2.2/) (2020-02-01)
[Full Changelog](https://github.com/jrnl-org/jrnl/compare/v2.1.1...v2.2)
**Implemented enhancements:**
- Update YAML exporter to handle Dayone format [\#773](https://github.com/jrnl-org/jrnl/pull/773) ([MinchinWeb](https://github.com/MinchinWeb))
- Full text search \(case insensitive\) with "-contains" [\#740](https://github.com/jrnl-org/jrnl/pull/740) ([empireshades](https://github.com/empireshades))
- Reduce startup time by 55% [\#719](https://github.com/jrnl-org/jrnl/pull/719) ([maebert](https://github.com/maebert))
- Refactor password logic to prevent accidental password leakage [\#708](https://github.com/jrnl-org/jrnl/pull/708) ([pspeter](https://github.com/pspeter))
- Password confirmation [\#706](https://github.com/jrnl-org/jrnl/pull/706) ([pspeter](https://github.com/pspeter))
**Fixed bugs:**
- Close temp file before passing it to editor to prevent file locking issues in Windows [\#792](https://github.com/jrnl-org/jrnl/pull/792) ([micahellison](https://github.com/micahellison))
- Fix crash while encrypting a journal on first run without saving password [\#789](https://github.com/jrnl-org/jrnl/pull/789) ([dbxnr](https://github.com/dbxnr))
**Build:**
- Fix issue where jrnl would always out 'source' for version, fix Poetry config to build and publish properly [\#820](https://github.com/jrnl-org/jrnl/pull/820) ([wren](https://github.com/wren))
- Unpin poetry [\#808](https://github.com/jrnl-org/jrnl/pull/808) ([wren](https://github.com/wren))
- Fix all skipped tests on Travis Windows builds by preserving newlines [\#823](https://github.com/jrnl-org/jrnl/pull/823) ([micahellison](https://github.com/micahellison))
- Change PyPI auth method in build pipeline [\#807](https://github.com/jrnl-org/jrnl/pull/807) ([wren](https://github.com/wren))
- Automagically update the changelog you see before your very eyes! [\#806](https://github.com/jrnl-org/jrnl/pull/806) ([wren](https://github.com/wren))
- Update Black version and lock file to fix builds on develop branch [\#784](https://github.com/jrnl-org/jrnl/pull/784) ([wren](https://github.com/wren))
- Run black formatter on codebase for standardization [\#778](https://github.com/jrnl-org/jrnl/pull/778) ([wren](https://github.com/wren))
- Skip Broken Windows Tests [\#772](https://github.com/jrnl-org/jrnl/pull/772) ([wren](https://github.com/wren))
- Black Formatter [\#769](https://github.com/jrnl-org/jrnl/pull/769) ([MinchinWeb](https://github.com/MinchinWeb))
- Update lock file and testing suite for Python 3.8 [\#765](https://github.com/jrnl-org/jrnl/pull/765) ([wren](https://github.com/wren))
- Fix CI config to only deploy once [\#761](https://github.com/jrnl-org/jrnl/pull/761) ([wren](https://github.com/wren))
- More Travis-CI Testing [\#759](https://github.com/jrnl-org/jrnl/pull/759) ([MinchinWeb](https://github.com/MinchinWeb))
**Updated documentation:**
- Explain how fish can be configured to exclude jrnl commands from history by default [\#809](https://github.com/jrnl-org/jrnl/pull/809) ([aureooms](https://github.com/aureooms))
- Remove merge marker in recipes.md [\#782](https://github.com/jrnl-org/jrnl/pull/782) ([markphelps](https://github.com/markphelps))
- Fix merge conflict left-over [\#767](https://github.com/jrnl-org/jrnl/pull/767) ([thejspr](https://github.com/thejspr))
- Display header in docs on mobile devices [\#763](https://github.com/jrnl-org/jrnl/pull/763) ([maebert](https://github.com/maebert))
## [v2.1.1](https://pypi.org/project/jrnl/v2.1.1/) (2019-11-26)
[Full Changelog](https://github.com/jrnl-org/jrnl/compare/v2.1.post2...v2.1.1)
**Implemented enhancements:**
- Support Python 3.6+ [\#710](https://github.com/jrnl-org/jrnl/pull/710) ([pspeter](https://github.com/pspeter))
- Drop Python 2 support, add mocks in tests [\#705](https://github.com/jrnl-org/jrnl/pull/705) ([pspeter](https://github.com/pspeter))
**Fixed bugs:**
- Prevent readline usage on Windows, which was causing Active Python crashes on install [\#751](https://github.com/jrnl-org/jrnl/pull/751) ([micahellison](https://github.com/micahellison))
- Exit jrnl if no text entered into editor [\#744](https://github.com/jrnl-org/jrnl/pull/744) ([alichtman](https://github.com/alichtman))
- Fix crash when no keyring backend available [\#699](https://github.com/jrnl-org/jrnl/pull/699) ([pspeter](https://github.com/pspeter))
- Fix parsing Journals using a little-endian date format [\#694](https://github.com/jrnl-org/jrnl/pull/694) ([pspeter](https://github.com/pspeter))
**Updated documentation:**
- Update developer documentation [\#752](https://github.com/jrnl-org/jrnl/pull/752) ([micahellison](https://github.com/micahellison))
- Create templates for issues and pull requests [\#679](https://github.com/jrnl-org/jrnl/pull/679) ([C0DK](https://github.com/C0DK))
- Smaller doc fixes [\#649](https://github.com/jrnl-org/jrnl/pull/649) ([maebert](https://github.com/maebert))
- Move to mkdocs [\#611](https://github.com/jrnl-org/jrnl/pull/611) ([maebert](https://github.com/maebert))
## [v2.1.post2](https://pypi.org/project/jrnl/v2.1.post2/) (2019-11-11)
[Full Changelog](https://github.com/jrnl-org/jrnl/compare/v2.0.1...v2.1.post2)
**Fixed bugs:**
- Expand paths that use ~ to full path [\#704](https://github.com/jrnl-org/jrnl/pull/704) ([MinchinWeb](https://github.com/MinchinWeb))
**Build:**
- Separate local dev from pipeline releases [\#684](https://github.com/jrnl-org/jrnl/pull/684) ([wren](https://github.com/wren))
- Update version handling in source and travis deployments [\#683](https://github.com/jrnl-org/jrnl/pull/683) ([wren](https://github.com/wren))
- Use Poetry for dependency management and deployments [\#612](https://github.com/jrnl-org/jrnl/pull/612) ([maebert](https://github.com/maebert))
**Updated documentation:**
- Fix typos, spelling [\#734](https://github.com/jrnl-org/jrnl/pull/734) ([MinchinWeb](https://github.com/MinchinWeb))
## [v2.0.1](https://pypi.org/project/jrnl/v2.0.1/) (2019-09-26)
[Full Changelog](https://github.com/jrnl-org/jrnl/compare/v2.0.0...v2.0.1)
**Implemented enhancements:**
- Switch to hashmark Markdown headers on export \(Mk II\) [\#639](https://github.com/jrnl-org/jrnl/pull/639) ([MinchinWeb](https://github.com/MinchinWeb))
- Add '-not' flag for excluding tags from filter [\#637](https://github.com/jrnl-org/jrnl/pull/637) ([jprof](https://github.com/jprof))
- Handle KeyboardInterrupt when installing journal [\#550](https://github.com/jrnl-org/jrnl/pull/550) ([silenc3r](https://github.com/silenc3r))
**Fixed bugs:**
- Change pyYAML required version [\#660](https://github.com/jrnl-org/jrnl/pull/660) ([etnnth](https://github.com/etnnth))
**Updated documentation:**
- Fix references to Sphinx in CONTRIBUTING.md [\#655](https://github.com/jrnl-org/jrnl/pull/655) ([maebert](https://github.com/maebert))
## [v2.0.0](https://pypi.org/project/jrnl/v2.0.0/) (2019-08-24)
[Full Changelog](https://github.com/jrnl-org/jrnl/compare/1.9.8...v2.0.0)
🚨 **BREAKING CHANGES** 🚨
**Implemented enhancements:**
- Change cryptographic backend from PyCrypto to cryptography.io
- Config now respects XDG conventions and may move accordingly
- Config name changed from `journals.jrnl_name.journal` to `journals.jrnl_name.path`
**Fixed bugs:**
- Confirm that each journal can be parsed during upgrade, and abort upgrade if not [\#650](https://github.com/jrnl-org/jrnl/pull/650) ([micahellison](https://github.com/micahellison))
- Escape dates in square brackets [\#644](https://github.com/jrnl-org/jrnl/pull/644) ([wren](https://github.com/wren))
- Create encrypted journal [\#641](https://github.com/jrnl-org/jrnl/pull/641) ([gregorybodnar](https://github.com/gregorybodnar))
- Resolve issues around unreadable dates to allow markdown footnotes and prevent accidental deletion [\#623](https://github.com/jrnl-org/jrnl/pull/623) ([micahellison](https://github.com/micahellison))
- Update crypto module \#610 [\#621](https://github.com/jrnl-org/jrnl/pull/621) ([wren](https://github.com/wren))
- Fix issue \#584 YAMLLoadWarning [\#585](https://github.com/jrnl-org/jrnl/pull/585) ([wren](https://github.com/wren))
**Deprecated:**
- Deprecate Python 2 [\#624](https://github.com/jrnl-org/jrnl/pull/624) ([micahellison](https://github.com/micahellison))
- Config now saved as YAML (no more JSON)
**Build:**
- change pinned label to a super cool emoji ⭐️ [\#646](https://github.com/jrnl-org/jrnl/pull/646) ([wren](https://github.com/wren))
- Update Travis build badge and restore pypi badges [\#603](https://github.com/jrnl-org/jrnl/pull/603) ([micahellison](https://github.com/micahellison))
**Updated documentation:**
- Mention lack of Day One support and relevant history in readme [\#608](https://github.com/jrnl-org/jrnl/pull/608) ([micahellison](https://github.com/micahellison))
- Add a code of conduct file \(rather than adding to contributing\) [\#604](https://github.com/jrnl-org/jrnl/pull/604) ([wren](https://github.com/wren))
- Update docs to reflect merging jrnl-plus fork back upstream [\#601](https://github.com/jrnl-org/jrnl/pull/601) ([micahellison](https://github.com/micahellison))
- Add instructions for VS Code [\#544](https://github.com/jrnl-org/jrnl/pull/544) ([emceeaich](https://github.com/emceeaich))
## v1.9 (2014-07-21)
* __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
@ -17,7 +174,7 @@ Changelog
* __1.9.1__ Fixed: Dates in the future can be parsed as well. * __1.9.1__ Fixed: Dates in the future can be parsed as well.
* __1.9.0__ Improved: Greatly improved date parsing. Also added an `-on` option for filtering * __1.9.0__ Improved: Greatly improved date parsing. Also added an `-on` option for filtering
### 1.8 (May 22, 2014) ## v1.8 (2014-05-22)
* __1.8.7__ Fixed: -from and -to filters are inclusive (thanks to @grplyler) * __1.8.7__ Fixed: -from and -to filters are inclusive (thanks to @grplyler)
* __1.8.6__ Improved: Tags like @C++ and @OS/2 work, too (thanks to @chaitan94) * __1.8.6__ Improved: Tags like @C++ and @OS/2 work, too (thanks to @chaitan94)
@ -28,7 +185,7 @@ Changelog
* __1.8.1__ Minor bug fixes * __1.8.1__ Minor bug fixes
* __1.8.0__ Official support for python 3.4 * __1.8.0__ Official support for python 3.4
### 1.7 (December 22, 2013) ## v1.7 (2013-12-22)
* __1.7.22__ Fixed an issue with writing files when exporting entries containing non-ascii characters. * __1.7.22__ Fixed an issue with writing files when exporting entries containing non-ascii characters.
* __1.7.21__ jrnl now uses PKCS#7 padding. * __1.7.21__ jrnl now uses PKCS#7 padding.
@ -54,7 +211,7 @@ Changelog
* __1.7.0__ Edit encrypted or DayOne journals with `jrnl --edit`. * __1.7.0__ Edit encrypted or DayOne journals with `jrnl --edit`.
### 1.6 (November 5, 2013) ## v1.6 (2013-11-05)
* __1.6.6__ -v prints the current version, also better strings for windows users. Furthermore, jrnl/jrnl.py moved to jrnl/cli.py * __1.6.6__ -v prints the current version, also better strings for windows users. Furthermore, jrnl/jrnl.py moved to jrnl/cli.py
* __1.6.5__ Allows composing multi-line entries on the command line or importing files * __1.6.5__ Allows composing multi-line entries on the command line or importing files
@ -64,7 +221,7 @@ Changelog
* __1.6.1__ Attempts to fix broken config files automatically * __1.6.1__ Attempts to fix broken config files automatically
* __1.6.0__ Passwords are now saved in the key-chain. The `password` field in `.jrnl_config` is soft-deprecated. * __1.6.0__ Passwords are now saved in the key-chain. The `password` field in `.jrnl_config` is soft-deprecated.
### 1.5 (August 6, 2013) ## v1.5 (2013-08-06)
* __1.5.7__ The `~` in journal config paths will now expand properly to e.g. `/Users/maebert` * __1.5.7__ The `~` in journal config paths will now expand properly to e.g. `/Users/maebert`
* __1.5.6__ Fixed: Fixed a bug where on OS X, the timezone could only be accessed on administrator accounts. * __1.5.6__ Fixed: Fixed a bug where on OS X, the timezone could only be accessed on administrator accounts.
@ -75,23 +232,23 @@ Changelog
* __1.5.1__ Fixed: Fixed a bug introduced in 1.5.0 that caused the entire journal to be printed after composing an entry * __1.5.1__ Fixed: Fixed a bug introduced in 1.5.0 that caused the entire journal to be printed after composing an entry
* __1.5.0__ Exporting, encrypting and displaying tags now takes your filter options into account. So you could export everything before May 2012: `jrnl -to 'may 2012' --export json`. Or encrypt all entries tagged with `@work` into a new journal: `jrnl @work --encrypt work_journal.txt`. Or display all tags of posts where Bob is also tagged: `jrnl @bob --tags` * __1.5.0__ Exporting, encrypting and displaying tags now takes your filter options into account. So you could export everything before May 2012: `jrnl -to 'may 2012' --export json`. Or encrypt all entries tagged with `@work` into a new journal: `jrnl @work --encrypt work_journal.txt`. Or display all tags of posts where Bob is also tagged: `jrnl @bob --tags`
### 1.4 (July 22, 2013) ## v1.4 (2013-07-22)
* __1.4.2__ Fixed: Tagging works again * __1.4.2__ Fixed: Tagging works again
* __1.4.0__ Unifies encryption between Python 2 and 3. If you have problems reading encrypted journals afterwards, first decrypt your journal with the __old__ jrnl version (install with `pip install jrnl==1.3.1`, then `jrnl --decrypt`), upgrade jrnl (`pip install jrnl --upgrade`) and encrypt it again (`jrnl --encrypt`). * __1.4.0__ Unifies encryption between Python 2 and 3. If you have problems reading encrypted journals afterwards, first decrypt your journal with the __old__ jrnl version (install with `pip install jrnl==1.3.1`, then `jrnl --decrypt`), upgrade jrnl (`pip install jrnl --upgrade`) and encrypt it again (`jrnl --encrypt`).
### 1.3 (July 17, 2013) ## v1.3 (2013-07-17)
* __1.3.2__ Everything that is not direct output of jrnl will be written stderr to improve integration * __1.3.2__ Everything that is not direct output of jrnl will be written stderr to improve integration
* __1.3.0__ Export to multiple files * __1.3.0__ Export to multiple files
* __1.3.0__ Feature to export to given output file * __1.3.0__ Feature to export to given output file
### 1.2 (July 15, 2013) ## v1.2 (2013-07-15)
* __1.2.0__ Fixed: Timezone support for DayOne * __1.2.0__ Fixed: Timezone support for DayOne
### 1.1 (June 9, 2013) ## v1.1 (2013-06-09)
* __1.1.1__ Fixed: Unicode and Python3 issues resolved. * __1.1.1__ Fixed: Unicode and Python3 issues resolved.
* __1.1.0__ * __1.1.0__
@ -99,7 +256,7 @@ Changelog
* Nicer error message when there is a syntactical error in your config file. * Nicer error message when there is a syntactical error in your config file.
* Unicode support * Unicode support
### 1.0 (March 4, 2013) ## v1.0 (2013-03-04)
* __1.0.5__ Backwards compatibility with `parsedatetime` 0.8.7 * __1.0.5__ Backwards compatibility with `parsedatetime` 0.8.7
* __1.0.4__ * __1.0.4__
@ -122,7 +279,7 @@ Changelog
* Fixed: A bug where jrnl would not add entries without timestamp * Fixed: A bug where jrnl would not add entries without timestamp
* Fixed: Support for parsedatetime 1.x * Fixed: Support for parsedatetime 1.x
### 0.3 (May 24, 2012) ## v0.3 (2012-05-24)
* __0.3.2__ Converts `\n` to new lines (if using directly on a command line, make sure to wrap your entry with quotes). * __0.3.2__ Converts `\n` to new lines (if using directly on a command line, make sure to wrap your entry with quotes).
* __0.3.1__ * __0.3.1__
@ -135,7 +292,7 @@ Changelog
* Fixed: Bug where composed entry is lost when the journal file fails to load * Fixed: Bug where composed entry is lost when the journal file fails to load
* Changed directory structure and install scripts (removing the necessity to make an alias from `jrnl` to `jrnl.py`) * Changed directory structure and install scripts (removing the necessity to make an alias from `jrnl` to `jrnl.py`)
### 0.2 (April 16, 2012) ## v0.2 (2012-04-16)
* __0.2.4__ * __0.2.4__
* Fixed: Parsing of new lines in journal files and entries * Fixed: Parsing of new lines in journal files and entries
@ -153,7 +310,7 @@ Changelog
* Encrypts using CBC * Encrypts using CBC
* Fixed: `key` has been renamed to `password` in config to avoid confusion. (The key use to encrypt and decrypt a journal is the SHA256-hash of the password.) * Fixed: `key` has been renamed to `password` in config to avoid confusion. (The key use to encrypt and decrypt a journal is the SHA256-hash of the password.)
### 0.1 (April 13, 2012) ## v0.1 (2012-04-13)
* __0.1.1__ * __0.1.1__
@ -166,6 +323,9 @@ Changelog
* Filtering by tags and dates * Filtering by tags and dates
* Fixed: Now using dedicated classes for Journals and entries * Fixed: Now using dedicated classes for Journals and entries
### 0.0 (March 29, 2012) ## v0.0 (2012-03-29)
* __0.0.1__ Composing entries works. That's pretty much it. * __0.0.1__ Composing entries works. That's pretty much it.
\* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)*

View file

@ -45,9 +45,17 @@ p {
/* No-one likes lines that are 400 characters long. */ /* No-one likes lines that are 400 characters long. */
div.rst-content {max-width: 54em;} div.rst-content {max-width: 54em;}
.wy-side-nav-search, .wy-nav-top, .wy-menu-vertical li.current { .wy-side-nav-search, .wy-menu-vertical li.current {
background-color: transparent; background-color: transparent;
} }
.wy-nav-top {
background-image: linear-gradient(-211deg, #95699C 0%, #604385 100%);
}
.wy-nav-top .fa-bars {
line-height: 50px;
}
.wy-side-nav-search a.icon-home { .wy-side-nav-search a.icon-home {
width: 100%; width: 100%;
max-width: 250px; max-width: 250px;

View file

@ -56,10 +56,14 @@ setopt HIST_IGNORE_SPACE
alias jrnl=" jrnl" alias jrnl=" jrnl"
``` ```
The fish shell does not support automatically preventing logging like If you are using `fish` instead of `bash` or `zsh`, you can get the same behaviour by
this. To prevent `jrnl` commands being logged by fish, you must make adding this to your `fish` configuration:
sure to type a space before every `jrnl` command you enter. To delete
existing `jrnl` commands from fishs history, run ``` sh
abbr jrnl " jrnl"
```
To delete existing `jrnl` commands from `fish`s history, run
`history delete --prefix 'jrnl '`. `history delete --prefix 'jrnl '`.
## Manual decryption ## Manual decryption

View file

@ -70,17 +70,59 @@ jrnlimport () {
### Using templates ### Using templates
Say you always want to use the same template for creating new entries. !!! note
If you have an [external editor](../advanced) set up, you can use this: Templates require an [external editor](../advanced) be configured.
A template is a code snippet that makes it easier to enter use repeated text
each time a new journal entry is started. There are two ways you can utilize
templates in your entries.
#### 1. Command line arguments
If you had a `template.txt` file with the following contents:
```sh ```sh
jrnl < my_template.txt My Personal Journal
jrnl -1 --edit Title:
Body:
``` ```
Another nice solution that allows you to define individual prompts comes The `template.txt` file could be used to create a new entry with these
from [Jacobo de command line arguements:
Vera](https://github.com/maebert/jrnl/issues/194#issuecomment-47402869):
```sh
jrnl < template.txt # Imports template.txt as the most recent entry
jrnl -1 --edit # Opens the most recent entry in the editor
```
#### 2. Include the template file in `jrnl.yaml`
A more efficient way to work with a template file is to declare the file
in your config file by changing the `template` setting from `false` to the
template file's path in double quotes:
```sh
...
template: "/path/to/template.txt"
...
```
Changes can be saved as you continue writing the journal entry and will be
logged as a new entry in the journal you specified in the original argument.
!!! tip
To read your journal entry or to verify the entry saved, you can use this
command: `jrnl -n 1` (Check out [Import and Export](../export/#export-to-files) for more export options).
```sh
jrnl -n 1
```
### Prompts on shell reload
If you'd like to be prompted each time you refresh your shell, you can include
this in your `.bash_profile`:
```sh ```sh
function log_question() function log_question()
@ -93,6 +135,11 @@ log_question 'What did I achieve today?'
log_question 'What did I make progress with?' log_question 'What did I make progress with?'
``` ```
Whenever your shell is reloaded, you will be prompted to answer each of the
questions in the example above. Each answer will be logged as a separate
journal entry at the `default_hour` and `default_minute` listed in your
`jrnl.yaml` [config file](../advanced/#configuration-file).
### Display random entry ### Display random entry
You can use this to select one title at random and then display the whole You can use this to select one title at random and then display the whole
@ -107,10 +154,11 @@ jrnl -on "$(jrnl --short | shuf -n 1 | cut -d' ' -f1,2)"
## External editors ## External editors
To use external editors for writing and editing journal entries, set Configure your preferred external editor by updating the `editor` option
them up in your `jrnl.yaml` (see `advanced usage <advanced>` for in your `jrnl.yaml` file. (See [advanced usage](../advanced) for details).
details). Generally, after writing an entry, you will have to save and
close the file to save the changes to jrnl. !!! note
To save and log any entry edits, save and close the file.
### Sublime Text ### Sublime Text
@ -130,8 +178,6 @@ Similar to Sublime Text, MacVim must be started with a flag that tells
the the process to wait until the file is closed before passing control the the process to wait until the file is closed before passing control
back to journal. In the case of MacVim, this is `-f`: back to journal. In the case of MacVim, this is `-f`:
<<<<<<< HEAD
```yaml ```yaml
editor: "mvim -f" editor: "mvim -f"
``` ```

View file

@ -34,7 +34,7 @@
"operatingSystem": ["macOS", "Windows", "Linux"], "operatingSystem": ["macOS", "Windows", "Linux"],
"thumbnailUrl": "https://jrnl.sh/img/banner_og.png", "thumbnailUrl": "https://jrnl.sh/img/banner_og.png",
"installUrl": "https://jrnl.sh/installation", "installUrl": "https://jrnl.sh/installation",
"softwareVersion": "2.0.0rc2" "softwareVersion": "2.2"
} }
</script> </script>
</head> </head>
@ -42,7 +42,7 @@
<body> <body>
<header> <header>
<aside> <aside>
<a id="twitter" href="https://twitter.com/intent/tweet?text=Write+your+memoirs+on+the+command+line.+Like+a+boss.+%23jrnl&url=http%3A%2F%2Fjrnl.sh&via=maebert"><i class="icon twitter"></i>Tell your friends</a> <a id="twitter" href="https://twitter.com/intent/tweet?text=Write+your+memoirs+on+the+command+line.+Like+a+boss.+%23jrnl&url=http%3A%2F%2Fjrnl.sh"><i class="icon twitter"></i>Tell your friends</a>
</aside> </aside>
<div id="title"> <div id="title">
<img id="logo" src="img/jrnl_white.svg" width="90px" height="98px" title="jrnl" /> <img id="logo" src="img/jrnl_white.svg" width="90px" height="98px" title="jrnl" />
@ -58,7 +58,7 @@
<nav> <nav>
<a href="overview">Documentation</a> <a href="overview">Documentation</a>
<a href="http://github.com/jrnl-org/jrnl" title="View on Github">Fork me on GitHub</a> <a href="http://github.com/jrnl-org/jrnl" title="View on Github">Fork me on GitHub</a>
<a id="twitter-nav" href="https://twitter.com/intent/tweet?text=Write+your+memoirs+on+the+command+line.+Like+a+boss.+%23jrnl&url=http%3A%2F%2Fjrnl.sh&via=maebert">Tell your friends on twitter</a> <a id="twitter-nav" href="https://twitter.com/intent/tweet?text=Write+your+memoirs+on+the+command+line.+Like+a+boss.+%23jrnl&url=http%3A%2F%2Fjrnl.sh">Tell your friends on twitter</a>
<a href="installation" class="cta">Download</a> <a href="installation" class="cta">Download</a>
</nav> </nav>
<div class="flex"> <div class="flex">

View file

@ -35,12 +35,12 @@ jrnl today at 3am: I just met Steve Buscemi in a bar! He looked funny.
``` ```
!!! note !!! note
Most shell contains a certain number of reserved characters, such as `#` Most shell contains a certain number of reserved characters, such as `#`
and `*`. Unbalanced quotes, parenthesis, and so on will also get into and `*`. Unbalanced quotes, parenthesis, and so on will also get into
the way of your editing. the way of your editing.
For writing longer entries, just enter `jrnl` For writing longer entries, just enter `jrnl`
and hit `return`. Only then enter the text of your journal entry. and hit `return`. Only then enter the text of your journal entry.
Alternatively, `use an external editor <advanced>`). Alternatively, `use an external editor <advanced>`).
You can also import an entry directly from a file You can also import an entry directly from a file
@ -76,9 +76,9 @@ The following options are equivalent:
- `jrnl Best day of my life.*` - `jrnl Best day of my life.*`
!!! note !!! note
Just make sure that the asterisk sign is **not** surrounded by Just make sure that the asterisk sign is **not** surrounded by
whitespaces, e.g. `jrnl Best day of my life! *` will **not** work (the whitespaces, e.g. `jrnl Best day of my life! *` will **not** work (the
reason being that the `*` sign has a special meaning on most shells). reason being that the `*` sign has a special meaning on most shells).
## Viewing ## Viewing
@ -119,17 +119,17 @@ Will print all entries in which either `@pinkie` or `@WorldDomination`
occurred. occurred.
```sh ```sh
jrnl -n 5 -and @pineapple @lubricant jrnl -n 5 -and @pinkie @WorldDomination
``` ```
the last five entries containing both `@pineapple` **and** `@lubricant`. the last five entries containing both `@pinkie` **and** `@worldDomination`.
You can change which symbols you'd like to use for tagging in the You can change which symbols you'd like to use for tagging in the
configuration. configuration.
!!! note !!! note
`jrnl @pinkie @WorldDomination` will switch to viewing mode because `jrnl @pinkie @WorldDomination` will switch to viewing mode because
although **no** command line arguments are given, all the input strings although **no** command line arguments are given, all the input strings
look like tags - _jrnl_ will assume you want to filter by tag. look like tags - _jrnl_ will assume you want to filter by tag.
## Editing older entries ## Editing older entries
@ -154,7 +154,7 @@ encrypt) your edited journal after you save and exit the editor.
You can also use this feature for deleting entries from your journal You can also use this feature for deleting entries from your journal
```sh ```sh
jrnl @girlfriend -until 'june 2012' --edit jrnl @texas -until 'june 2012' --edit
``` ```
Just select all text, press delete, and everything is gone... Just select all text, press delete, and everything is gone...

29
features/contains.feature Normal file
View file

@ -0,0 +1,29 @@
Feature: Contains
Scenario: Searching for a string
Given we use the config "basic.yaml"
When we run "jrnl -contains life"
Then we should get no error
and the output should be
"""
2013-06-10 15:40 Life is good.
| But I'm better.
"""
Scenario: Searching for a string within tag results
Given we use the config "tags.yaml"
When we run "jrnl @idea -contains software"
Then we should get no error
and the output should contain "software"
Scenario: Searching for a string within AND tag results
Given we use the config "tags.yaml"
When we run "jrnl -and @journal @idea -contains software"
Then we should get no error
and the output should contain "software"
Scenario: Searching for a string within NOT tag results
Given we use the config "tags.yaml"
When we run "jrnl -not @dan -contains software"
Then we should get no error
and the output should contain "software"

View file

@ -20,6 +20,7 @@ Feature: Basic reading and writing to a journal
When we run "jrnl -n 1" When we run "jrnl -n 1"
Then the output should contain "2013-07-23 09:00 A cold and stormy day." Then the output should contain "2013-07-23 09:00 A cold and stormy day."
@skip_win
Scenario: Writing an empty entry from the editor Scenario: Writing an empty entry from the editor
Given we use the config "editor.yaml" Given we use the config "editor.yaml"
When we open the editor and enter "" When we open the editor and enter ""

View file

@ -6,7 +6,6 @@ highlight: true
journals: journals:
default: features/journals/bug153.dayone default: features/journals/bug153.dayone
linewrap: 80 linewrap: 80
password: ''
tagsymbols: '@' tagsymbols: '@'
template: false template: false
timeformat: '%Y-%m-%d %H:%M' timeformat: '%Y-%m-%d %H:%M'

View file

@ -0,0 +1,12 @@
default_hour: 9
default_minute: 0
editor: ''
encrypt: false
highlight: true
journals:
default: features/journals/bug780.dayone
linewrap: 80
tagsymbols: '@'
template: false
timeformat: '%Y-%m-%d %H:%M'
indent_character: "|"

View file

@ -7,7 +7,6 @@ highlight: true
journals: journals:
default: features/journals/dayone.dayone default: features/journals/dayone.dayone
linewrap: 80 linewrap: 80
password: ''
tagsymbols: '@' tagsymbols: '@'
timeformat: '%Y-%m-%d %H:%M' timeformat: '%Y-%m-%d %H:%M'
indent_character: "|" indent_character: "|"

View file

@ -7,7 +7,6 @@ highlight: true
journals: journals:
default: features/journals/empty_folder default: features/journals/empty_folder
linewrap: 80 linewrap: 80
password: ''
tagsymbols: '@' tagsymbols: '@'
timeformat: '%Y-%m-%d %H:%M' timeformat: '%Y-%m-%d %H:%M'
indent_character: "|" indent_character: "|"

View file

@ -7,7 +7,6 @@ highlight: true
journals: journals:
default: features/journals/encrypted.journal default: features/journals/encrypted.journal
linewrap: 80 linewrap: 80
password: ''
tagsymbols: '@' tagsymbols: '@'
timeformat: '%Y-%m-%d %H:%M' timeformat: '%Y-%m-%d %H:%M'
indent_character: "|" indent_character: "|"

View file

@ -7,7 +7,6 @@ template: false
journals: journals:
default: features/journals/markdown-headings-335.journal default: features/journals/markdown-headings-335.journal
linewrap: 80 linewrap: 80
password: ''
tagsymbols: '@' tagsymbols: '@'
timeformat: '%Y-%m-%d %H:%M' timeformat: '%Y-%m-%d %H:%M'
indent_character: "|" indent_character: "|"

View file

@ -13,7 +13,6 @@ journals:
encrypt: true encrypt: true
journal: features/journals/new_encrypted.journal journal: features/journals/new_encrypted.journal
linewrap: 80 linewrap: 80
password: ''
tagsymbols: '@' tagsymbols: '@'
timeformat: '%Y-%m-%d %H:%M' timeformat: '%Y-%m-%d %H:%M'
indent_character: "|" indent_character: "|"

View file

@ -7,7 +7,6 @@ template: false
journals: journals:
default: features/journals/tags-216.journal default: features/journals/tags-216.journal
linewrap: 80 linewrap: 80
password: ''
tagsymbols: '@' tagsymbols: '@'
timeformat: '%Y-%m-%d %H:%M' timeformat: '%Y-%m-%d %H:%M'
indent_character: "|" indent_character: "|"

View file

@ -7,7 +7,6 @@ template: false
journals: journals:
default: features/journals/tags-237.journal default: features/journals/tags-237.journal
linewrap: 80 linewrap: 80
password: ''
tagsymbols: '@' tagsymbols: '@'
timeformat: '%Y-%m-%d %H:%M' timeformat: '%Y-%m-%d %H:%M'
indent_character: "|" indent_character: "|"

View file

@ -7,7 +7,6 @@ template: false
journals: journals:
default: features/journals/tags.journal default: features/journals/tags.journal
linewrap: 80 linewrap: 80
password: ''
tagsymbols: '@' tagsymbols: '@'
timeformat: '%Y-%m-%d %H:%M' timeformat: '%Y-%m-%d %H:%M'
indent_character: "|" indent_character: "|"

View file

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Activity</key>
<string>Stationary</string>
<key>Creation Date</key>
<date>2019-12-30T21:28:54Z</date>
<key>Entry Text</key>
<string></string>
<key>Starred</key>
<false />
<key>UUID</key>
<string>48A25033B34047C591160A4480197D8B</string>
<key>Creator</key>
<dict>
<key>Device Agent</key>
<string>PC</string>
<key>Generation Date</key>
<date>2019-12-30T21:28:54Z</date>
<key>Host Name</key>
<string>LE-TREPORT</string>
<key>OS Agent</key>
<string>Microsoft Windows/10 Home</string>
<key>Software Agent</key>
<string>Journaley/2.1</string>
</dict>
<key>Tags</key>
<array>
<string>i_have_no_body</string>
</array>
</dict>
</plist>

View file

@ -1,7 +1,5 @@
Feature: Dayone specific implementation details. Feature: Dayone specific implementation details.
# fails when system time is UTC (as on Travis-CI)
@skip
Scenario: Loading a DayOne Journal Scenario: Loading a DayOne Journal
Given we use the config "dayone.yaml" Given we use the config "dayone.yaml"
When we run "jrnl -from 'feb 2013'" When we run "jrnl -from 'feb 2013'"
@ -15,7 +13,7 @@ Feature: Dayone specific implementation details.
2013-07-17 11:38 This entry is starred! 2013-07-17 11:38 This entry is starred!
""" """
# fails when system time is UTC (as on Travis-CI) # broken still
@skip @skip
Scenario: Entries without timezone information will be interpreted as in the current timezone Scenario: Entries without timezone information will be interpreted as in the current timezone
Given we use the config "dayone.yaml" Given we use the config "dayone.yaml"
@ -23,7 +21,6 @@ Feature: Dayone specific implementation details.
Then we should get no error Then we should get no error
and the output should contain "2013-01-17T18:37Z" in the local time and the output should contain "2013-01-17T18:37Z" in the local time
@skip
Scenario: Writing into Dayone Scenario: Writing into Dayone
Given we use the config "dayone.yaml" Given we use the config "dayone.yaml"
When we run "jrnl 01 may 1979: Being born hurts." When we run "jrnl 01 may 1979: Being born hurts."
@ -33,8 +30,6 @@ Feature: Dayone specific implementation details.
1979-05-01 09:00 Being born hurts. 1979-05-01 09:00 Being born hurts.
""" """
# fails when system time is UTC (as on Travis-CI)
@skip
Scenario: Loading tags from a DayOne Journal Scenario: Loading tags from a DayOne Journal
Given we use the config "dayone.yaml" Given we use the config "dayone.yaml"
When we run "jrnl --tags" When we run "jrnl --tags"
@ -44,8 +39,6 @@ Feature: Dayone specific implementation details.
@play : 1 @play : 1
""" """
# fails when system time is UTC (as on Travis-CI)
@skip
Scenario: Saving tags from a DayOne Journal Scenario: Saving tags from a DayOne Journal
Given we use the config "dayone.yaml" Given we use the config "dayone.yaml"
When we run "jrnl A hard day at @work" When we run "jrnl A hard day at @work"
@ -56,8 +49,6 @@ Feature: Dayone specific implementation details.
@play : 1 @play : 1
""" """
# fails when system time is UTC (as on Travis-CI)
@skip
Scenario: Filtering by tags from a DayOne Journal Scenario: Filtering by tags from a DayOne Journal
Given we use the config "dayone.yaml" Given we use the config "dayone.yaml"
When we run "jrnl @work" When we run "jrnl @work"
@ -66,8 +57,6 @@ Feature: Dayone specific implementation details.
2013-05-17 11:39 This entry has tags! 2013-05-17 11:39 This entry has tags!
""" """
# fails when system time is UTC (as on Travis-CI)
@skip
Scenario: Exporting dayone to json Scenario: Exporting dayone to json
Given we use the config "dayone.yaml" Given we use the config "dayone.yaml"
When we run "jrnl --export json" When we run "jrnl --export json"

View file

@ -13,16 +13,16 @@ Feature: Zapped Dayone bugs stay dead!
# fails when system time is UTC (as on Travis-CI) # fails when system time is UTC (as on Travis-CI)
@skip @skip
Scenario: Title with an embedded period on DayOne journal Scenario: Title with an embedded period on DayOne journal
Given we use the config "dayone.yaml" Given we use the config "dayone.yaml"
When we run "jrnl 04-24-2014: "Ran 6.2 miles today in 1:02:03. I'm feeling sore because I forgot to stretch."" When we run "jrnl 04-24-2014: "Ran 6.2 miles today in 1:02:03. I'm feeling sore because I forgot to stretch.""
Then we should see the message "Entry added" Then we should see the message "Entry added"
When we run "jrnl -1" When we run "jrnl -1"
Then the output should be Then the output should be
""" """
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: Opening an folder that's not a DayOne folder gives a nice error message Scenario: Opening an folder that's not a DayOne folder gives a nice error message
Given we use the config "empty_folder.yaml" Given we use the config "empty_folder.yaml"

View file

@ -3,29 +3,55 @@
Given we use the config "encrypted.yaml" Given we use the config "encrypted.yaml"
When we run "jrnl -n 1" and enter "bad doggie no biscuit" When we run "jrnl -n 1" and enter "bad doggie no biscuit"
Then the output should contain "Password" Then the output should contain "Password"
and the output should contain "2013-06-10 15:40 Life is good" And the output should contain "2013-06-10 15:40 Life is good"
Scenario: Decrypting a journal Scenario: Decrypting a journal
Given we use the config "encrypted.yaml" Given we use the config "encrypted.yaml"
When we run "jrnl --decrypt" and enter "bad doggie no biscuit" When we run "jrnl --decrypt" and enter "bad doggie no biscuit"
Then the config for journal "default" should have "encrypt" set to "bool:False" Then the config for journal "default" should have "encrypt" set to "bool:False"
Then we should see the message "Journal decrypted" Then we should see the message "Journal decrypted"
and the journal should have 2 entries And the journal should have 2 entries
Scenario: Encrypting a journal Scenario: Encrypting a journal
Given we use the config "basic.yaml" Given we use the config "basic.yaml"
When we run "jrnl --encrypt" and enter "swordfish" and "n" When we run "jrnl --encrypt" and enter
"""
swordfish
swordfish
n
"""
Then we should see the message "Journal encrypted" Then we should see the message "Journal encrypted"
and the config for journal "default" should have "encrypt" set to "bool:True" And the config for journal "default" should have "encrypt" set to "bool:True"
When we run "jrnl -n 1" and enter "swordfish" When we run "jrnl -n 1" and enter "swordfish"
Then the output should contain "Password" Then the output should contain "Password"
and the output should contain "2013-06-10 15:40 Life is good" And the output should contain "2013-06-10 15:40 Life is good"
Scenario: Mistyping your password
Given we use the config "basic.yaml"
When we run "jrnl --encrypt" and enter
"""
swordfish
sordfish
swordfish
swordfish
n
"""
Then we should see the message "Passwords did not match"
And we should see the message "Journal encrypted"
And the config for journal "default" should have "encrypt" set to "bool:True"
When we run "jrnl -n 1" and enter "swordfish"
Then the output should contain "Password"
And the output should contain "2013-06-10 15:40 Life is good"
Scenario: Storing a password in Keychain Scenario: Storing a password in Keychain
Given we use the config "multiple.yaml" Given we use the config "multiple.yaml"
When we run "jrnl simple --encrypt" and enter "sabertooth" and "y" When we run "jrnl simple --encrypt" and enter
"""
sabertooth
sabertooth
y
"""
When we set the keychain password of "simple" to "sabertooth" When we set the keychain password of "simple" to "sabertooth"
Then the config for journal "simple" should have "encrypt" set to "bool:True" Then the config for journal "simple" should have "encrypt" set to "bool:True"
When we run "jrnl simple -n 1" When we run "jrnl simple -n 1"
Then we should not see the message "Password" Then the output should contain "2013-06-10 15:40 Life is good"
and the output should contain "2013-06-10 15:40 Life is good"

View file

@ -1,5 +1,6 @@
import shutil import shutil
import os import os
import sys
def before_feature(context, feature): def before_feature(context, feature):
@ -9,6 +10,10 @@ def before_feature(context, feature):
feature.skip("Marked with @skip") feature.skip("Marked with @skip")
return return
if "skip_win" in feature.tags and "win32" in sys.platform:
feature.skip("Skipping on Windows")
return
def before_scenario(context, scenario): def before_scenario(context, scenario):
"""Before each scenario, backup all config and journal test data.""" """Before each scenario, backup all config and journal test data."""
@ -36,6 +41,10 @@ def before_scenario(context, scenario):
scenario.skip("Marked with @skip") scenario.skip("Marked with @skip")
return return
if "skip_win" in scenario.effective_tags and "win32" in sys.platform:
scenario.skip("Skipping on Windows")
return
def after_scenario(context, scenario): def after_scenario(context, scenario):
"""After each scenario, restore all test data and remove working_dirs.""" """After each scenario, restore all test data and remove working_dirs."""

View file

@ -4,21 +4,20 @@ Feature: Exporting a Journal
Given we use the config "tags.yaml" Given we use the config "tags.yaml"
When we run "jrnl --export json" When we run "jrnl --export json"
Then we should get no error Then we should get no error
and the output should be parsable as json And the output should be parsable as json
and "entries" in the json output should have 2 elements And "entries" in the json output should have 2 elements
and "tags" in the json output should contain "@idea" And "tags" in the json output should contain "@idea"
and "tags" in the json output should contain "@journal" And "tags" in the json output should contain "@journal"
and "tags" in the json output should contain "@dan" And "tags" in the json output should contain "@dan"
Scenario: Exporting using filters should only export parts of the journal Scenario: Exporting using filters should only export parts of the journal
Given we use the config "tags.yaml" Given we use the config "tags.yaml"
When we run "jrnl -until 'may 2013' --export json" When we run "jrnl -until 'may 2013' --export json"
# Then we should get no error
Then the output should be parsable as json Then the output should be parsable as json
and "entries" in the json output should have 1 element And "entries" in the json output should have 1 element
and "tags" in the json output should contain "@idea" And "tags" in the json output should contain "@idea"
and "tags" in the json output should contain "@journal" And "tags" in the json output should contain "@journal"
and "tags" in the json output should not contain "@dan" And "tags" in the json output should not contain "@dan"
Scenario: Exporting using custom templates Scenario: Exporting using custom templates
Given we use the config "basic.yaml" Given we use the config "basic.yaml"
@ -83,3 +82,57 @@ Feature: Exporting a Journal
More stuff More stuff
more stuff again more stuff again
""" """
Scenario: Exporting to XML
Given we use the config "tags.yaml"
When we run "jrnl --export xml"
Then the output should be a valid XML string
And "entries" node in the xml output should have 2 elements
And "tags" in the xml output should contain ["@idea", "@journal", "@dan"]
Scenario: Exporting tags
Given we use the config "tags.yaml"
When we run "jrnl --export tags"
Then the output should be
"""
@idea : 2
@journal : 1
@dan : 1
"""
Scenario: Exporting fancy
Given we use the config "tags.yaml"
When we run "jrnl --export fancy"
Then the output should be
"""
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.
As alway's he shared his latest @idea on how to rule the world with me.
inst
"""
Scenario: Export to yaml
Given we use the config "tags.yaml"
And we created a directory named "exported_journal"
When we run "jrnl --export yaml -o exported_journal"
Then "exported_journal" should contain the files ["2013-04-09_i-have-an-idea.md", "2013-06-10_i-met-with-dan.md"]
And the content of exported yaml "exported_journal/2013-04-09_i-have-an-idea.md" should be
"""
title: I have an @idea:
date: 2013-04-09 15:39
stared: False
tags: idea, journal
(1) write a command line @journal software
(2) ???
(3) PROFIT!
"""

View file

@ -42,5 +42,10 @@ Feature: Multiple journals
Scenario: Don't crash if no file exists for a configured encrypted journal Scenario: Don't crash if no file exists for a configured encrypted journal
Given we use the config "multiple.yaml" Given we use the config "multiple.yaml"
When we run "jrnl new_encrypted Adding first entry" and enter "these three eyes" and "y" When we run "jrnl new_encrypted Adding first entry" and enter
Then we should see the message "Journal 'new_encrypted' created" """
these three eyes
these three eyes
n
"""
Then we should see the message "Encrypted journal 'new_encrypted' created"

View file

@ -1,7 +1,7 @@
Feature: Zapped bugs should stay dead. Feature: Zapped bugs should stay dead.
Scenario: Writing an entry does not print the entire journal Scenario: Writing an entry does not print the entire journal
# https://github.com/maebert/jrnl/issues/87 # https://github.com/jrnl-org/jrnl/issues/87
Given we use the config "basic.yaml" Given we use the config "basic.yaml"
When we run "jrnl 23 july 2013: A cold and stormy day. I ate crisps on the sofa." When we run "jrnl 23 july 2013: A cold and stormy day. I ate crisps on the sofa."
Then we should see the message "Entry added" Then we should see the message "Entry added"
@ -9,21 +9,14 @@ Feature: Zapped bugs should stay dead.
Then the output should not contain "Life is good" Then the output should not contain "Life is good"
Scenario: Date with time should be parsed correctly Scenario: Date with time should be parsed correctly
# https://github.com/maebert/jrnl/issues/117 # https://github.com/jrnl-org/jrnl/issues/117
Given we use the config "basic.yaml" Given we use the config "basic.yaml"
When we run "jrnl 2013-11-30 15:42: Project Started." When we run "jrnl 2013-11-30 15:42: Project Started."
Then we should see the message "Entry added" 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?"
Scenario: Loading entry with ambiguous time stamp Scenario: Loading entry with ambiguous time stamp
#https://github.com/maebert/jrnl/issues/153 #https://github.com/jrnl-org/jrnl/issues/153
Given we use the config "bug153.yaml" Given we use the config "bug153.yaml"
When we run "jrnl -1" When we run "jrnl -1"
Then we should get no error Then we should get no error
@ -32,6 +25,19 @@ Feature: Zapped bugs should stay dead.
2013-10-27 03:27 Some text. 2013-10-27 03:27 Some text.
""" """
Scenario: Date in the future should be parsed correctly
# https://github.com/jrnl-org/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?"
Scenario: Empty DayOne entry bodies should not error
# https://github.com/jrnl-org/jrnl/issues/780
Given we use the config "bug780.yaml"
When we run "jrnl --short"
Then we should get no error
Scenario: Title with an embedded period. Scenario: Title with an embedded period.
Given we use the config "basic.yaml" 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." When we run "jrnl 04-24-2014: Created a new website - empty.com. Hope to get a lot of traffic."

View file

@ -3,10 +3,12 @@ from unittest.mock import patch
from behave import given, when, then from behave import given, when, then
from jrnl import cli, install, Journal, util, plugins from jrnl import cli, install, Journal, util, plugins
from jrnl import __version__ from jrnl import __version__
from dateutil import parser as date_parser
from collections import defaultdict from collections import defaultdict
try: import parsedatetime.parsedatetime_consts as pdt
except ImportError: import parsedatetime as pdt try:
import parsedatetime.parsedatetime_consts as pdt
except ImportError:
import parsedatetime as pdt
import time import time
import os import os
import json import json
@ -17,7 +19,7 @@ import shlex
import sys import sys
consts = pdt.Constants(usePyICU=False) consts = pdt.Constants(usePyICU=False)
consts.DOWParseStyle = -1 # Prefers past weekdays consts.DOWParseStyle = -1 # Prefers past weekdays
CALENDAR = pdt.Calendar(consts) CALENDAR = pdt.Calendar(consts)
@ -33,7 +35,7 @@ class TestKeyring(keyring.backend.KeyringBackend):
def get_password(self, servicename, username): def get_password(self, servicename, username):
return self.keys[servicename].get(username) return self.keys[servicename].get(username)
def delete_password(self, servicename, username, password): def delete_password(self, servicename, username):
self.keys[servicename][username] = None self.keys[servicename][username] = None
@ -44,23 +46,27 @@ keyring.set_keyring(TestKeyring())
def ushlex(command): def ushlex(command):
if sys.version_info[0] == 3: if sys.version_info[0] == 3:
return shlex.split(command) return shlex.split(command)
return map(lambda s: s.decode('UTF8'), shlex.split(command.encode('utf8'))) return map(lambda s: s.decode("UTF8"), shlex.split(command.encode("utf8")))
def read_journal(journal_name="default"): def read_journal(journal_name="default"):
config = util.load_config(install.CONFIG_FILE_PATH) config = util.load_config(install.CONFIG_FILE_PATH)
with open(config['journals'][journal_name]) as journal_file: with open(config["journals"][journal_name]) as journal_file:
journal = journal_file.read() journal = journal_file.read()
return journal return journal
def open_journal(journal_name="default"): def open_journal(journal_name="default"):
config = util.load_config(install.CONFIG_FILE_PATH) config = util.load_config(install.CONFIG_FILE_PATH)
journal_conf = config['journals'][journal_name] journal_conf = config["journals"][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
config.update(journal_conf) config.update(journal_conf)
else: # But also just give them a string to point to the journal file else:
config['journal'] = journal_conf # But also just give them a string to point to the journal file
config["journal"] = journal_conf
return Journal.open_journal(journal_name, config) return Journal.open_journal(journal_name, config)
@ -70,14 +76,15 @@ def set_config(context, config_file):
install.CONFIG_FILE_PATH = os.path.abspath(full_path) install.CONFIG_FILE_PATH = os.path.abspath(full_path)
if config_file.endswith("yaml"): if config_file.endswith("yaml"):
# Add jrnl version to file for 2.x journals # Add jrnl version to file for 2.x journals
with open(install.CONFIG_FILE_PATH, 'a') as cf: with open(install.CONFIG_FILE_PATH, "a") as cf:
cf.write("version: {}".format(__version__)) cf.write("version: {}".format(__version__))
@when('we open the editor and enter ""') @when('we open the editor and enter ""')
@when('we open the editor and enter "{text}"') @when('we open the editor and enter "{text}"')
def open_editor_and_enter(context, text=""): def open_editor_and_enter(context, text=""):
text = (text or context.text) text = text or context.text
def _mock_editor_function(command): def _mock_editor_function(command):
tmpfile = command[-1] tmpfile = command[-1]
with open(tmpfile, "w+") as f: with open(tmpfile, "w+") as f:
@ -88,7 +95,7 @@ def open_editor_and_enter(context, text=""):
return tmpfile return tmpfile
with patch('subprocess.call', side_effect=_mock_editor_function): with patch("subprocess.call", side_effect=_mock_editor_function):
run(context, "jrnl") run(context, "jrnl")
@ -96,6 +103,7 @@ def _mock_getpass(inputs):
def prompt_return(prompt="Password: "): def prompt_return(prompt="Password: "):
print(prompt) print(prompt)
return next(inputs) return next(inputs)
return prompt_return return prompt_return
@ -104,35 +112,44 @@ def _mock_input(inputs):
val = next(inputs) val = next(inputs)
print(prompt, val) print(prompt, val)
return val return val
return prompt_return return prompt_return
@when('we run "{command}" and enter') @when('we run "{command}" and enter')
@when('we run "{command}" and enter ""') @when('we run "{command}" and enter ""')
@when('we run "{command}" and enter "{inputs1}"') @when('we run "{command}" and enter "{inputs}"')
@when('we run "{command}" and enter "{inputs1}" and "{inputs2}"') def run_with_input(context, command, inputs=""):
def run_with_input(context, command, inputs1="", inputs2=""):
# create an iterator through all inputs. These inputs will be fed one by one # create an iterator through all inputs. These inputs will be fed one by one
# to the mocked calls for 'input()', 'util.getpass()' and 'sys.stdin.read()' # to the mocked calls for 'input()', 'util.getpass()' and 'sys.stdin.read()'
if inputs1: if context.text:
text = iter((inputs1, inputs2))
elif context.text:
text = iter(context.text.split("\n")) text = iter(context.text.split("\n"))
else: else:
text = iter(("", "")) text = iter([inputs])
args = ushlex(command)[1:] args = ushlex(command)[1:]
with patch("builtins.input", side_effect=_mock_input(text)) as mock_input:
with patch("jrnl.util.getpass", side_effect=_mock_getpass(text)) as mock_getpass:
with patch("sys.stdin.read", side_effect=text) as mock_read:
try:
cli.run(args or [])
context.exit_status = 0
except SystemExit as e:
context.exit_status = e.code
# assert at least one of the mocked input methods got called # fmt: off
assert mock_input.called or mock_getpass.called or mock_read.called # see: https://github.com/psf/black/issues/557
with patch("builtins.input", side_effect=_mock_input(text)) as mock_input, \
patch("getpass.getpass", side_effect=_mock_getpass(text)) as mock_getpass, \
patch("sys.stdin.read", side_effect=text) as mock_read:
try:
cli.run(args or [])
context.exit_status = 0
except SystemExit as e:
context.exit_status = e.code
# at least one of the mocked input methods got called
assert mock_input.called or mock_getpass.called or mock_read.called
# all inputs were used
try:
next(text)
assert False, "Not all inputs were consumed"
except StopIteration:
pass
# fmt: on
@when('we run "{command}"') @when('we run "{command}"')
@ -154,74 +171,32 @@ def load_template(context, filename):
@when('we set the keychain password of "{journal}" to "{password}"') @when('we set the keychain password of "{journal}" to "{password}"')
def set_keychain(context, journal, password): def set_keychain(context, journal, password):
keyring.set_password('jrnl', journal, password) keyring.set_password("jrnl", journal, password)
@then('we should get an error') @then("we should get an error")
def has_error(context): def has_error(context):
assert context.exit_status != 0, context.exit_status assert context.exit_status != 0, context.exit_status
@then('we should get no error') @then("we should get no error")
def no_error(context): def no_error(context):
assert context.exit_status is 0, context.exit_status assert context.exit_status == 0, context.exit_status
@then('the output should be parsable as json') @then("the output should be")
def check_output_json(context):
out = context.stdout_capture.getvalue()
assert json.loads(out), out
@then('"{field}" in the json output should have {number:d} elements')
@then('"{field}" in the json output should have 1 element')
def check_output_field(context, field, number=1):
out = context.stdout_capture.getvalue()
out_json = json.loads(out)
assert field in out_json, [field, out_json]
assert len(out_json[field]) == number, len(out_json[field])
@then('"{field}" in the json output should not contain "{key}"')
def check_output_field_not_key(context, field, key):
out = context.stdout_capture.getvalue()
out_json = json.loads(out)
assert field in out_json
assert key not in out_json[field]
@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]
@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}"') @then('the output should be "{text}"')
def check_output(context, text=None): def check_output(context, text=None):
text = (text or context.text).strip().splitlines() text = (text or context.text).strip().splitlines()
out = context.stdout_capture.getvalue().strip().splitlines() out = context.stdout_capture.getvalue().strip().splitlines()
assert len(text) == len(out), "Output has {} lines (expected: {})".format(len(out), len(text)) assert len(text) == len(out), "Output has {} lines (expected: {})".format(
len(out), len(text)
)
for line_text, line_out in zip(text, out): for line_text, line_out in zip(text, out):
assert line_text.strip() == line_out.strip(), [line_text.strip(), line_out.strip()] assert line_text.strip() == line_out.strip(), [
line_text.strip(),
line_out.strip(),
]
@then('the output should contain "{text}" in the local time') @then('the output should contain "{text}" in the local time')
@ -229,11 +204,11 @@ 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()
date, flag = CALENDAR.parse(text) date, flag = CALENDAR.parse(text)
output_date = time.strftime("%Y-%m-%d %H:%M",date) output_date = time.strftime("%Y-%m-%d %H:%M", date)
assert output_date in out, output_date assert output_date in out, output_date
@then('the output should contain') @then("the output should contain")
@then('the output should contain "{text}"') @then('the output should contain "{text}"')
def check_output_inline(context, text=None): def check_output_inline(context, text=None):
text = text or context.text text = text or context.text
@ -270,7 +245,7 @@ def check_journal_content(context, text, journal_name="default"):
def journal_doesnt_exist(context, journal_name="default"): def journal_doesnt_exist(context, journal_name="default"):
with open(install.CONFIG_FILE_PATH) as config_file: with open(install.CONFIG_FILE_PATH) as config_file:
config = yaml.load(config_file, Loader=yaml.FullLoader) config = yaml.load(config_file, Loader=yaml.FullLoader)
journal_path = config['journals'][journal_name] journal_path = config["journals"][journal_name]
assert not os.path.exists(journal_path) assert not os.path.exists(journal_path)
@ -278,11 +253,7 @@ def journal_doesnt_exist(context, journal_name="default"):
@then('the config for journal "{journal}" should have "{key}" set to "{value}"') @then('the config for journal "{journal}" should have "{key}" set to "{value}"')
def config_var(context, key, value, journal=None): def config_var(context, key, value, journal=None):
t, value = value.split(":") t, value = value.split(":")
value = { value = {"bool": lambda v: v.lower() == "true", "int": int, "str": str}[t](value)
"bool": lambda v: v.lower() == "true",
"int": int,
"str": str
}[t](value)
config = util.load_config(install.CONFIG_FILE_PATH) config = util.load_config(install.CONFIG_FILE_PATH)
if journal: if journal:
config = config["journals"][journal] config = config["journals"][journal]
@ -290,8 +261,8 @@ def config_var(context, key, value, journal=None):
assert config[key] == value assert config[key] == value
@then('the journal should have {number:d} entries') @then("the journal should have {number:d} entries")
@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_entries(context, number, journal_name="default"): def check_journal_entries(context, number, journal_name="default"):
@ -299,6 +270,6 @@ def check_journal_entries(context, number, journal_name="default"):
assert len(journal.entries) == number assert len(journal.entries) == number
@then('fail') @then("fail")
def debug_fail(context): def debug_fail(context):
assert False assert False

View file

@ -0,0 +1,124 @@
import json
import os
import shutil
from xml.etree import ElementTree
from behave import then, given
@then("the output should be parsable as json")
def check_output_json(context):
out = context.stdout_capture.getvalue()
assert json.loads(out), out
@then('"{field}" in the json output should have {number:d} elements')
@then('"{field}" in the json output should have 1 element')
def check_output_field(context, field, number=1):
out = context.stdout_capture.getvalue()
out_json = json.loads(out)
assert field in out_json, [field, out_json]
assert len(out_json[field]) == number, len(out_json[field])
@then('"{field}" in the json output should not contain "{key}"')
def check_output_field_not_key(context, field, key):
out = context.stdout_capture.getvalue()
out_json = json.loads(out)
assert field in out_json
assert key not in out_json[field]
@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]
@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 a valid XML string")
def assert_valid_xml_string(context):
output = context.stdout_capture.getvalue()
xml_tree = ElementTree.fromstring(output)
assert xml_tree, output
@then('"entries" node in the xml output should have {number:d} elements')
def assert_xml_output_entries_count(context, number):
output = context.stdout_capture.getvalue()
xml_tree = ElementTree.fromstring(output)
xml_tags = (node.tag for node in xml_tree)
assert "entries" in xml_tags, str(list(xml_tags))
actual_entry_count = len(xml_tree.find("entries"))
assert actual_entry_count == number, actual_entry_count
@then('"tags" in the xml output should contain {expected_tags_json_list}')
def assert_xml_output_tags(context, expected_tags_json_list):
output = context.stdout_capture.getvalue()
xml_tree = ElementTree.fromstring(output)
xml_tags = (node.tag for node in xml_tree)
assert "tags" in xml_tags, str(list(xml_tags))
expected_tags = json.loads(expected_tags_json_list)
actual_tags = set(t.attrib["name"] for t in xml_tree.find("tags"))
assert actual_tags == set(expected_tags), [actual_tags, set(expected_tags)]
@given('we created a directory named "{dir_name}"')
def create_directory(context, dir_name):
if os.path.exists(dir_name):
shutil.rmtree(dir_name)
os.mkdir(dir_name)
@then('"{dir_name}" should contain the files {expected_files_json_list}')
def assert_dir_contains_files(context, dir_name, expected_files_json_list):
actual_files = os.listdir(dir_name)
expected_files = json.loads(expected_files_json_list)
assert actual_files == expected_files, [actual_files, expected_files]
@then('the content of exported yaml "{file_path}" should be')
def assert_exported_yaml_file_content(context, file_path):
expected_content = context.text.strip().splitlines()
with open(file_path, "r") as f:
actual_content = f.read().strip().splitlines()
for actual_line, expected_line in zip(actual_content, expected_content):
if actual_line.startswith("tags: ") and expected_line.startswith("tags: "):
assert_equal_tags_ignoring_order(actual_line, expected_line)
else:
assert actual_line.strip() == expected_line.strip(), [
actual_line.strip(),
expected_line.strip(),
]
def assert_equal_tags_ignoring_order(actual_line, expected_line):
actual_tags = set(tag.strip() for tag in actual_line[len("tags: ") :].split(","))
expected_tags = set(
tag.strip() for tag in expected_line[len("tags: ") :].split(",")
)
assert actual_tags == expected_tags, [actual_tags, expected_tags]

View file

@ -19,7 +19,11 @@ class DayOne(Journal.Journal):
"""A special Journal handling DayOne files""" """A special Journal handling DayOne files"""
# InvalidFileException was added to plistlib in Python3.4 # InvalidFileException was added to plistlib in Python3.4
PLIST_EXCEPTIONS = (ExpatError, plistlib.InvalidFileException) if hasattr(plistlib, "InvalidFileException") else ExpatError PLIST_EXCEPTIONS = (
(ExpatError, plistlib.InvalidFileException)
if hasattr(plistlib, "InvalidFileException")
else ExpatError
)
def __init__(self, **kwargs): def __init__(self, **kwargs):
self.entries = [] self.entries = []
@ -27,28 +31,43 @@ class DayOne(Journal.Journal):
super().__init__(**kwargs) super().__init__(**kwargs)
def open(self): def open(self):
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"))
]
filenames = [] filenames = []
for root, dirnames, f in os.walk(self.config['journal']): for root, dirnames, f in os.walk(self.config["journal"]):
for filename in fnmatch.filter(f, '*.doentry'): for filename in fnmatch.filter(f, "*.doentry"):
filenames.append(os.path.join(root, filename)) filenames.append(os.path.join(root, filename))
self.entries = [] self.entries = []
for filename in filenames: for filename in filenames:
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)
except self.PLIST_EXCEPTIONS: except self.PLIST_EXCEPTIONS:
pass pass
else: else:
try: try:
timezone = pytz.timezone(dict_entry['Time Zone']) timezone = pytz.timezone(dict_entry["Time Zone"])
except (KeyError, pytz.exceptions.UnknownTimeZoneError): except (KeyError, pytz.exceptions.UnknownTimeZoneError):
timezone = tzlocal.get_localzone() timezone = tzlocal.get_localzone()
date = dict_entry['Creation Date'] date = dict_entry["Creation Date"]
date = date + timezone.utcoffset(date, is_dst=False) # convert the date to UTC rather than keep messing with
entry = Entry.Entry(self, date, text=dict_entry['Entry Text'], starred=dict_entry["Starred"]) # timezones
if timezone.zone != "UTC":
date = date + timezone.utcoffset(date, is_dst=False)
entry = Entry.Entry(
self,
date,
text=dict_entry["Entry Text"],
starred=dict_entry["Starred"],
)
entry.uuid = dict_entry["UUID"] entry.uuid = dict_entry["UUID"]
entry._tags = [self.config['tagsymbols'][0] + tag.lower() for tag in dict_entry.get("Tags", [])] entry._tags = [
self.config["tagsymbols"][0] + tag.lower()
for tag in dict_entry.get("Tags", [])
]
self.entries.append(entry) self.entries.append(entry)
self.sort() self.sort()
@ -58,24 +77,33 @@ class DayOne(Journal.Journal):
"""Writes only the entries that have been modified into plist files.""" """Writes only the entries that have been modified into plist files."""
for entry in self.entries: for entry in self.entries:
if entry.modified: if entry.modified:
utc_time = datetime.utcfromtimestamp(time.mktime(entry.date.timetuple())) utc_time = datetime.utcfromtimestamp(
time.mktime(entry.date.timetuple())
)
if not hasattr(entry, "uuid"): if not hasattr(entry, "uuid"):
entry.uuid = uuid.uuid1().hex entry.uuid = uuid.uuid1().hex
filename = os.path.join(self.config['journal'], "entries", entry.uuid.upper() + ".doentry") 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,
'Entry Text': entry.title + "\n" + entry.body, "Entry Text": entry.title + "\n" + entry.body,
'Time Zone': str(tzlocal.get_localzone()), "Time Zone": str(tzlocal.get_localzone()),
'UUID': entry.uuid.upper(), "UUID": entry.uuid.upper(),
'Tags': [tag.strip(self.config['tagsymbols']).replace("_", " ") for tag in entry.tags] "Tags": [
tag.strip(self.config["tagsymbols"]).replace("_", " ")
for tag in entry.tags
],
} }
plistlib.writePlist(entry_plist, filename) plistlib.writePlist(entry_plist, filename)
for entry in self._deleted_entries: for entry in self._deleted_entries:
filename = os.path.join(self.config['journal'], "entries", entry.uuid + ".doentry") filename = os.path.join(
self.config["journal"], "entries", entry.uuid + ".doentry"
)
os.remove(filename) os.remove(filename)
def editable_str(self): def editable_str(self):
@ -113,7 +141,7 @@ class DayOne(Journal.Journal):
if line.endswith("*"): if line.endswith("*"):
current_entry.starred = True current_entry.starred = True
line = line[:-1] line = line[:-1]
current_entry.title = line[len(date_blob) - 1:] current_entry.title = line[len(date_blob) - 1 :]
current_entry.date = new_date current_entry.date = new_date
elif current_entry: elif current_entry:
current_entry.body += line + "\n" current_entry.body += line + "\n"

View file

@ -1,4 +1,5 @@
from . import Journal, util from . import util
from .Journal import Journal, LegacyJournal
from cryptography.fernet import Fernet, InvalidToken from cryptography.fernet import Fernet, InvalidToken
from cryptography.hazmat.primitives import hashes, padding from cryptography.hazmat.primitives import hashes, padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
@ -9,6 +10,8 @@ import sys
import os import os
import base64 import base64
import logging import logging
from typing import Optional
log = logging.getLogger() log = logging.getLogger()
@ -19,107 +22,111 @@ def make_key(password):
algorithm=hashes.SHA256(), algorithm=hashes.SHA256(),
length=32, length=32,
# Salt is hard-coded # Salt is hard-coded
salt=b'\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, iterations=100000,
backend=default_backend() backend=default_backend(),
) )
key = kdf.derive(password) key = kdf.derive(password)
return base64.urlsafe_b64encode(key) return base64.urlsafe_b64encode(key)
class EncryptedJournal(Journal.Journal): class EncryptedJournal(Journal):
def __init__(self, name='default', **kwargs): def __init__(self, name="default", **kwargs):
super().__init__(name, **kwargs) super().__init__(name, **kwargs)
self.config['encrypt'] = True self.config["encrypt"] = True
self.password = None
def open(self, filename=None): def open(self, filename=None):
"""Opens the journal file defined in the config and parses it into a list of Entries. """Opens the journal file defined in the config and parses it into a list of Entries.
Entries have the form (date, title, body).""" Entries have the form (date, title, body)."""
filename = filename or self.config['journal'] filename = filename or self.config["journal"]
if not os.path.exists(filename): if not os.path.exists(filename):
password = util.getpass("Enter password for new journal: ") self.create_file(filename)
if password: self.password = util.create_password(self.name)
if util.yesno("Do you want to store the password in your keychain?", default=True): print(
util.set_keychain(self.name, password) f"Encrypted journal '{self.name}' created at {filename}",
else: file=sys.stderr,
util.set_keychain(self.name, None) )
self.config['password'] = password
text = "" text = self._load(filename)
self._store(filename, text)
print(f"[Journal '{self.name}' created at {filename}]", file=sys.stderr)
else:
print("No password supplied for encrypted journal", file=sys.stderr)
sys.exit(1)
else:
text = self._load(filename)
self.entries = self._parse(text) self.entries = self._parse(text)
self.sort() self.sort()
log.debug("opened %s with %d entries", self.__class__.__name__, len(self)) log.debug("opened %s with %d entries", self.__class__.__name__, len(self))
return self return self
def _load(self, filename, password=None): def _load(self, filename):
"""Loads an encrypted journal from a file and tries to decrypt it. """Loads an encrypted journal from a file and tries to decrypt it.
If password is not provided, will look for password in the keychain If password is not provided, will look for password in the keychain
and otherwise ask the user to enter a password up to three times. 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 If the password is provided but wrong (or corrupt), this will simply
return None.""" return None."""
with open(filename, 'rb') as f: with open(filename, "rb") as f:
journal_encrypted = f.read() journal_encrypted = f.read()
def validate_password(password): def decrypt_journal(password):
key = make_key(password) key = make_key(password)
try: try:
plain = Fernet(key).decrypt(journal_encrypted).decode('utf-8') plain = Fernet(key).decrypt(journal_encrypted).decode("utf-8")
self.config['password'] = password self.password = password
return plain return plain
except (InvalidToken, IndexError): except (InvalidToken, IndexError):
return None return None
if password:
return validate_password(password) if self.password:
return util.get_password(keychain=self.name, validator=validate_password) return decrypt_journal(self.password)
return util.decrypt_content(keychain=self.name, decrypt_func=decrypt_journal)
def _store(self, filename, text): def _store(self, filename, text):
key = make_key(self.config['password']) key = make_key(self.password)
journal = Fernet(key).encrypt(text.encode('utf-8')) journal = Fernet(key).encrypt(text.encode("utf-8"))
with open(filename, 'wb') as f: with open(filename, "wb") as f:
f.write(journal) f.write(journal)
@classmethod @classmethod
def _create(cls, filename, password): def from_journal(cls, other: Journal):
key = make_key(password) new_journal = super().from_journal(other)
dummy = Fernet(key).encrypt(b"") new_journal.password = (
with open(filename, 'wb') as f: other.password
f.write(dummy) if hasattr(other, "password")
else util.create_password(other.name)
)
return new_journal
class LegacyEncryptedJournal(Journal.LegacyJournal): class LegacyEncryptedJournal(LegacyJournal):
"""Legacy class to support opening journals encrypted with the jrnl 1.x """Legacy class to support opening journals encrypted with the jrnl 1.x
standard. You'll not be able to save these journals anymore.""" standard. You'll not be able to save these journals anymore."""
def __init__(self, name='default', **kwargs):
super().__init__(name, **kwargs)
self.config['encrypt'] = True
def _load(self, filename, password=None): def __init__(self, name="default", **kwargs):
with open(filename, 'rb') as f: super().__init__(name, **kwargs)
self.config["encrypt"] = True
self.password = None
def _load(self, filename):
with open(filename, "rb") as f:
journal_encrypted = f.read() journal_encrypted = f.read()
iv, cipher = journal_encrypted[:16], journal_encrypted[16:] iv, cipher = journal_encrypted[:16], journal_encrypted[16:]
def validate_password(password): def decrypt_journal(password):
decryption_key = hashlib.sha256(password.encode('utf-8')).digest() decryption_key = hashlib.sha256(password.encode("utf-8")).digest()
decryptor = Cipher(algorithms.AES(decryption_key), modes.CBC(iv), default_backend()).decryptor() decryptor = Cipher(
algorithms.AES(decryption_key), modes.CBC(iv), default_backend()
).decryptor()
try: try:
plain_padded = decryptor.update(cipher) + decryptor.finalize() plain_padded = decryptor.update(cipher) + decryptor.finalize()
self.config['password'] = password self.password = password
if plain_padded[-1] in (" ", 32): if plain_padded[-1] in (" ", 32):
# Ancient versions of jrnl. Do not judge me. # Ancient versions of jrnl. Do not judge me.
return plain_padded.decode('utf-8').rstrip(" ") return plain_padded.decode("utf-8").rstrip(" ")
else: else:
unpadder = padding.PKCS7(algorithms.AES.block_size).unpadder() unpadder = padding.PKCS7(algorithms.AES.block_size).unpadder()
plain = unpadder.update(plain_padded) + unpadder.finalize() plain = unpadder.update(plain_padded) + unpadder.finalize()
return plain.decode('utf-8') return plain.decode("utf-8")
except ValueError: except ValueError:
return None return None
if password:
return validate_password(password) if self.password:
return util.get_password(keychain=self.name, validator=validate_password) return decrypt_journal(self.password)
return util.decrypt_content(keychain=self.name, decrypt_func=decrypt_journal)

View file

@ -22,7 +22,7 @@ class Entry:
def _parse_text(self): def _parse_text(self):
raw_text = self.text raw_text = self.text
lines = raw_text.splitlines() lines = raw_text.splitlines()
if lines[0].strip().endswith("*"): if lines and lines[0].strip().endswith("*"):
self.starred = True self.starred = True
raw_text = lines[0].strip("\n *") + "\n" + "\n".join(lines[1:]) raw_text = lines[0].strip("\n *") + "\n" + "\n".join(lines[1:])
self._title, self._body = split_title(raw_text) self._title, self._body = split_title(raw_text)
@ -49,72 +49,84 @@ class Entry:
@staticmethod @staticmethod
def tag_regex(tagsymbols): def tag_regex(tagsymbols):
pattern = fr'(?u)(?:^|\s)([{tagsymbols}][-+*#/\w]+)' pattern = fr"(?u)(?:^|\s)([{tagsymbols}][-+*#/\w]+)"
return re.compile(pattern) return re.compile(pattern)
def _parse_tags(self): def _parse_tags(self):
tagsymbols = self.journal.config['tagsymbols'] tagsymbols = self.journal.config["tagsymbols"]
return {tag.lower() for tag in re.findall(Entry.tag_regex(tagsymbols), self.text)} return {
tag.lower() for tag in re.findall(Entry.tag_regex(tagsymbols), self.text)
}
def __str__(self): def __str__(self):
"""Returns a string representation of the entry to be written into a journal file.""" """Returns a string representation of the entry to be written into a journal file."""
date_str = self.date.strftime(self.journal.config['timeformat']) date_str = self.date.strftime(self.journal.config["timeformat"])
title = "[{}] {}".format(date_str, self.title.rstrip("\n ")) title = "[{}] {}".format(date_str, self.title.rstrip("\n "))
if self.starred: if self.starred:
title += " *" title += " *"
return "{title}{sep}{body}\n".format( return "{title}{sep}{body}\n".format(
title=title, title=title,
sep="\n" if self.body.rstrip("\n ") else "", sep="\n" if self.body.rstrip("\n ") else "",
body=self.body.rstrip("\n ") body=self.body.rstrip("\n "),
) )
def pprint(self, short=False): def pprint(self, short=False):
"""Returns a pretty-printed version of the entry. """Returns a pretty-printed version of the entry.
If short is true, only print the title.""" If short is true, only print the title."""
date_str = self.date.strftime(self.journal.config['timeformat']) date_str = self.date.strftime(self.journal.config["timeformat"])
if self.journal.config['indent_character']: if self.journal.config["indent_character"]:
indent = self.journal.config['indent_character'].rstrip() + " " indent = self.journal.config["indent_character"].rstrip() + " "
else: else:
indent = "" indent = ""
if not short and self.journal.config['linewrap']: if not short and self.journal.config["linewrap"]:
title = textwrap.fill(date_str + " " + self.title, self.journal.config['linewrap']) title = textwrap.fill(
body = "\n".join([ date_str + " " + self.title, self.journal.config["linewrap"]
textwrap.fill( )
line, body = "\n".join(
self.journal.config['linewrap'], [
initial_indent=indent, textwrap.fill(
subsequent_indent=indent, line,
drop_whitespace=True) or indent self.journal.config["linewrap"],
for line in self.body.rstrip(" \n").splitlines() initial_indent=indent,
]) subsequent_indent=indent,
drop_whitespace=True,
)
or indent
for line in self.body.rstrip(" \n").splitlines()
]
)
else: else:
title = date_str + " " + self.title.rstrip("\n ") title = date_str + " " + self.title.rstrip("\n ")
body = self.body.rstrip("\n ") body = self.body.rstrip("\n ")
# Suppress bodies that are just blanks and new lines. # Suppress bodies that are just blanks and new lines.
has_body = len(self.body) > 20 or not all(char in (" ", "\n") for char in self.body) has_body = len(self.body) > 20 or not all(
char in (" ", "\n") for char in self.body
)
if short: if short:
return title return title
else: else:
return "{title}{sep}{body}\n".format( return "{title}{sep}{body}\n".format(
title=title, title=title, sep="\n" if has_body else "", body=body if has_body else ""
sep="\n" if has_body else "",
body=body if has_body else "",
) )
def __repr__(self): def __repr__(self):
return "<Entry '{}' on {}>".format(self.title.strip(), self.date.strftime("%Y-%m-%d %H:%M")) return "<Entry '{}' on {}>".format(
self.title.strip(), self.date.strftime("%Y-%m-%d %H:%M")
)
def __hash__(self): def __hash__(self):
return hash(self.__repr__()) return hash(self.__repr__())
def __eq__(self, other): def __eq__(self, other):
if not isinstance(other, Entry) \ if (
or self.title.strip() != other.title.strip() \ not isinstance(other, Entry)
or self.body.rstrip() != other.body.rstrip() \ or self.title.strip() != other.title.strip()
or self.date != other.date \ or self.body.rstrip() != other.body.rstrip()
or self.starred != other.starred: or self.date != other.date
or self.starred != other.starred
):
return False return False
return True return True

View file

@ -25,17 +25,17 @@ class Tag:
class Journal: class Journal:
def __init__(self, name='default', **kwargs): def __init__(self, name="default", **kwargs):
self.config = { self.config = {
'journal': "journal.txt", "journal": "journal.txt",
'encrypt': False, "encrypt": False,
'default_hour': 9, "default_hour": 9,
'default_minute': 0, "default_minute": 0,
'timeformat': "%Y-%m-%d %H:%M", "timeformat": "%Y-%m-%d %H:%M",
'tagsymbols': '@', "tagsymbols": "@",
'highlight': True, "highlight": True,
'linewrap': 80, "linewrap": 80,
'indent_character': '|', "indent_character": "|",
} }
self.config.update(kwargs) self.config.update(kwargs)
# Set up date parser # Set up date parser
@ -57,21 +57,28 @@ class Journal:
another journal object""" another journal object"""
new_journal = cls(other.name, **other.config) new_journal = cls(other.name, **other.config)
new_journal.entries = other.entries new_journal.entries = other.entries
log.debug("Imported %d entries from %s to %s", len(new_journal), other.__class__.__name__, cls.__name__) log.debug(
"Imported %d entries from %s to %s",
len(new_journal),
other.__class__.__name__,
cls.__name__,
)
return new_journal return new_journal
def import_(self, other_journal_txt): def import_(self, other_journal_txt):
self.entries = list(frozenset(self.entries) | frozenset(self._parse(other_journal_txt))) self.entries = list(
frozenset(self.entries) | frozenset(self._parse(other_journal_txt))
)
self.sort() self.sort()
def open(self, filename=None): def open(self, filename=None):
"""Opens the journal file defined in the config and parses it into a list of Entries. """Opens the journal file defined in the config and parses it into a list of Entries.
Entries have the form (date, title, body).""" Entries have the form (date, title, body)."""
filename = filename or self.config['journal'] filename = filename or self.config["journal"]
if not os.path.exists(filename): if not os.path.exists(filename):
self.create_file(filename)
print(f"[Journal '{self.name}' created at {filename}]", file=sys.stderr) print(f"[Journal '{self.name}' created at {filename}]", file=sys.stderr)
self._create(filename)
text = self._load(filename) text = self._load(filename)
self.entries = self._parse(text) self.entries = self._parse(text)
@ -81,7 +88,7 @@ class Journal:
def write(self, filename=None): def write(self, filename=None):
"""Dumps the journal into the config file, overwriting it""" """Dumps the journal into the config file, overwriting it"""
filename = filename or self.config['journal'] filename = filename or self.config["journal"]
text = self._to_text() text = self._to_text()
self._store(filename, text) self._store(filename, text)
@ -93,6 +100,11 @@ class Journal:
return False return False
return True return True
@staticmethod
def create_file(filename):
with open(filename, "w"):
pass
def _to_text(self): def _to_text(self):
return "\n".join([str(e) for e in self.entries]) return "\n".join([str(e) for e in self.entries])
@ -102,10 +114,6 @@ class Journal:
def _store(self, filename, text): def _store(self, filename, text):
raise NotImplementedError raise NotImplementedError
@classmethod
def _create(cls, filename):
raise NotImplementedError
def _parse(self, journal_txt): def _parse(self, journal_txt):
"""Parses a journal that's stored in a string and returns a list of entries""" """Parses a journal that's stored in a string and returns a list of entries"""
@ -128,7 +136,7 @@ class Journal:
if new_date: if new_date:
if entries: if entries:
entries[-1].text = journal_txt[last_entry_pos:match.start()] entries[-1].text = journal_txt[last_entry_pos : match.start()]
last_entry_pos = match.end() last_entry_pos = match.end()
entries.append(Entry.Entry(self, date=new_date)) entries.append(Entry.Entry(self, date=new_date))
@ -147,18 +155,16 @@ class Journal:
"""Prettyprints the journal's entries""" """Prettyprints the journal's entries"""
sep = "\n" sep = "\n"
pp = sep.join([e.pprint(short=short) for e in self.entries]) pp = sep.join([e.pprint(short=short) for e in self.entries])
if self.config['highlight']: # highlight tags if self.config["highlight"]: # highlight tags
if self.search_tags: if self.search_tags:
for tag in self.search_tags: for tag in self.search_tags:
tagre = re.compile(re.escape(tag), re.IGNORECASE) tagre = re.compile(re.escape(tag), re.IGNORECASE)
pp = re.sub(tagre, pp = re.sub(tagre, lambda match: util.colorize(match.group(0)), pp)
lambda match: util.colorize(match.group(0)),
pp)
else: else:
pp = re.sub( pp = re.sub(
Entry.Entry.tag_regex(self.config['tagsymbols']), Entry.Entry.tag_regex(self.config["tagsymbols"]),
lambda match: util.colorize(match.group(0)), lambda match: util.colorize(match.group(0)),
pp pp,
) )
return pp return pp
@ -182,14 +188,22 @@ class Journal:
"""Returns a set of tuples (count, tag) for all tags present in the journal.""" """Returns a set of tuples (count, tag) for all tags present in the journal."""
# Astute reader: should the following line leave you as puzzled as me the first time # Astute reader: should the following line leave you as puzzled as me the first time
# I came across this construction, worry not and embrace the ensuing moment of enlightment. # I came across this construction, worry not and embrace the ensuing moment of enlightment.
tags = [tag tags = [tag for entry in self.entries for tag in set(entry.tags)]
for entry in self.entries
for tag in set(entry.tags)]
# To be read: [for entry in journal.entries: for tag in set(entry.tags): tag] # To be read: [for entry in journal.entries: for tag in set(entry.tags): tag]
tag_counts = {(tags.count(tag), tag) for tag in tags} tag_counts = {(tags.count(tag), tag) for tag in tags}
return [Tag(tag, count=count) for count, tag in sorted(tag_counts)] return [Tag(tag, count=count) for count, tag in sorted(tag_counts)]
def filter(self, tags=[], start_date=None, end_date=None, starred=False, strict=False, short=False, exclude=[]): def filter(
self,
tags=[],
start_date=None,
end_date=None,
starred=False,
strict=False,
short=False,
contains=None,
exclude=[],
):
"""Removes all entries from the journal that don't match the filter. """Removes all entries from the journal that don't match the filter.
tags is a list of tags, each being a string that starts with one of the tags is a list of tags, each being a string that starts with one of the
@ -211,13 +225,24 @@ class Journal:
# If strict mode is on, all tags have to be present in entry # If strict mode is on, all tags have to be present in entry
tagged = self.search_tags.issubset if strict else self.search_tags.intersection tagged = self.search_tags.issubset if strict else self.search_tags.intersection
excluded = lambda tags: len([tag for tag in tags if tag in excluded_tags]) > 0 excluded = lambda tags: len([tag for tag in tags if tag in excluded_tags]) > 0
if contains:
contains_lower = contains.casefold()
result = [ result = [
entry for entry in self.entries entry
for entry in self.entries
if (not tags or tagged(entry.tags)) if (not tags or tagged(entry.tags))
and (not starred or entry.starred) and (not starred or entry.starred)
and (not start_date or entry.date >= start_date) and (not start_date or entry.date >= start_date)
and (not end_date or entry.date <= end_date) and (not end_date or entry.date <= end_date)
and (not exclude or not excluded(entry.tags)) and (not exclude or not excluded(entry.tags))
and (
not contains
or (
contains_lower in entry.title.casefold()
or contains_lower in entry.body.casefold()
)
)
] ]
self.entries = result self.entries = result
@ -240,7 +265,7 @@ class Journal:
raw = raw.replace('\\n ', '\n').replace('\\n', '\n') raw = raw.replace('\\n ', '\n').replace('\\n', '\n')
# Split raw text into title and body # Split raw text into title and body
sep = re.search(r"\n|[?!.]+ +\n?", raw) sep = re.search(r"\n|[?!.]+ +\n?", raw)
first_line = raw[:sep.end()].strip() if sep else raw first_line = raw[: sep.end()].strip() if sep else raw
starred = False starred = False
if not date: if not date:
@ -248,12 +273,12 @@ class Journal:
if colon_pos > 0: if colon_pos > 0:
date = time.parse( date = time.parse(
raw[:colon_pos], raw[:colon_pos],
default_hour=self.config['default_hour'], default_hour=self.config["default_hour"],
default_minute=self.config['default_minute'] default_minute=self.config["default_minute"],
) )
if date: # Parsed successfully, strip that from the raw text if date: # Parsed successfully, strip that from the raw text
starred = raw[:colon_pos].strip().endswith("*") starred = raw[:colon_pos].strip().endswith("*")
raw = raw[colon_pos + 1:].strip() raw = raw[colon_pos + 1 :].strip()
starred = starred or first_line.startswith("*") or first_line.endswith("*") starred = starred or first_line.startswith("*") or first_line.endswith("*")
if not date: # Still nothing? Meh, just live in the moment. if not date: # Still nothing? Meh, just live in the moment.
date = time.parse("now") date = time.parse("now")
@ -281,17 +306,12 @@ class Journal:
class PlainJournal(Journal): class PlainJournal(Journal):
@classmethod
def _create(cls, filename):
with open(filename, "a", encoding="utf-8"):
pass
def _load(self, filename): def _load(self, filename):
with open(filename, "r", encoding="utf-8") as f: with open(filename, "r", encoding="utf-8") as f:
return f.read() return f.read()
def _store(self, filename, text): def _store(self, filename, text):
with open(filename, 'w', encoding="utf-8") as f: with open(filename, "w", encoding="utf-8") as f:
f.write(text) f.write(text)
@ -299,6 +319,7 @@ class LegacyJournal(Journal):
"""Legacy class to support opening journals formatted with the jrnl 1.x """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 standard. Main difference here is that in 1.x, timestamps were not cuddled
by square brackets. You'll not be able to save these journals anymore.""" by square brackets. You'll not be able to save these journals anymore."""
def _load(self, filename): def _load(self, filename):
with open(filename, "r", encoding="utf-8") as f: with open(filename, "r", encoding="utf-8") as f:
return f.read() return f.read()
@ -307,17 +328,19 @@ class LegacyJournal(Journal):
"""Parses a journal that's stored in a string and returns a list of entries""" """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 # Entries start with a line that looks like 'date title' - let's figure out how
# long the date will be by constructing one # long the date will be by constructing one
date_length = len(datetime.today().strftime(self.config['timeformat'])) date_length = len(datetime.today().strftime(self.config["timeformat"]))
# Initialise our current entry # Initialise our current entry
entries = [] entries = []
current_entry = None current_entry = None
new_date_format_regex = re.compile(r'(^\[[^\]]+\].*?$)') new_date_format_regex = re.compile(r"(^\[[^\]]+\].*?$)")
for line in journal_txt.splitlines(): for line in journal_txt.splitlines():
line = line.rstrip() line = line.rstrip()
try: try:
# try to parse line as date => new entry begins # try to parse line as date => new entry begins
new_date = datetime.strptime(line[:date_length], self.config['timeformat']) new_date = datetime.strptime(
line[:date_length], self.config["timeformat"]
)
# parsing successful => save old entry and create new one # parsing successful => save old entry and create new one
if new_date and current_entry: if new_date and current_entry:
@ -329,12 +352,14 @@ class LegacyJournal(Journal):
else: else:
starred = False starred = False
current_entry = Entry.Entry(self, date=new_date, text=line[date_length + 1:], starred=starred) current_entry = Entry.Entry(
self, date=new_date, text=line[date_length + 1 :], starred=starred
)
except ValueError: except ValueError:
# Happens when we can't parse the start of the line as an date. # Happens when we can't parse the start of the line as an date.
# In this case, just append line to our body (after some # In this case, just append line to our body (after some
# escaping for the new format). # escaping for the new format).
line = new_date_format_regex.sub(r' \1', line) line = new_date_format_regex.sub(r" \1", line)
if current_entry: if current_entry:
current_entry.text += line + "\n" current_entry.text += line + "\n"
@ -353,26 +378,30 @@ def open_journal(name, config, legacy=False):
backwards compatibility with jrnl 1.x backwards compatibility with jrnl 1.x
""" """
config = config.copy() config = config.copy()
config['journal'] = os.path.expanduser(os.path.expandvars(config['journal'])) config["journal"] = os.path.expanduser(os.path.expandvars(config["journal"]))
if os.path.isdir(config['journal']): if os.path.isdir(config["journal"]):
if config['journal'].strip("/").endswith(".dayone") or "entries" in os.listdir(config['journal']): if config["journal"].strip("/").endswith(".dayone") or "entries" in os.listdir(
config["journal"]
):
from . import DayOneJournal from . import DayOneJournal
return DayOneJournal.DayOne(**config).open() return DayOneJournal.DayOne(**config).open()
else: else:
print( print(
f"[Error: {config['journal']} is a directory, but doesn't seem to be a DayOne journal either.", f"[Error: {config['journal']} is a directory, but doesn't seem to be a DayOne journal either.",
file=sys.stderr file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
if not config['encrypt']: if not config["encrypt"]:
if legacy: if legacy:
return LegacyJournal(name, **config).open() return LegacyJournal(name, **config).open()
return PlainJournal(name, **config).open() return PlainJournal(name, **config).open()
else: else:
from . import EncryptedJournal from . import EncryptedJournal
if legacy: if legacy:
return EncryptedJournal.LegacyEncryptedJournal(name, **config).open() return EncryptedJournal.LegacyEncryptedJournal(name, **config).open()
return EncryptedJournal.EncryptedJournal(name, **config).open() return EncryptedJournal.EncryptedJournal(name, **config).open()

View file

@ -1,8 +1,9 @@
#!/usr/bin/env python #!/usr/bin/env python
import pkg_resources import os
dist = pkg_resources.get_distribution('jrnl')
__title__ = dist.project_name
__version__ = dist.version
try:
from .__version__ import __version__
except ImportError:
__version__ = "source"
__title__ = "jrnl"

1
jrnl/__version__.py Normal file
View file

@ -0,0 +1 @@
__version__ = "v2.2-beta"

View file

@ -6,7 +6,8 @@
license: MIT, see LICENSE for more details. license: MIT, see LICENSE for more details.
""" """
from . import Journal from .Journal import PlainJournal, open_journal
from .EncryptedJournal import EncryptedJournal
from . import util from . import util
from . import install from . import install
from . import plugins from . import plugins
@ -22,34 +23,157 @@ logging.getLogger("keyring.backend").setLevel(logging.ERROR)
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(
parser.add_argument('-ls', dest='ls', action="store_true", help="displays accessible journals") "-v",
parser.add_argument('-d', '--debug', dest='debug', action='store_true', help='execute in debug mode') "--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(
"-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.add_argument('text', metavar='', nargs="*") "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="*")
reading = parser.add_argument_group('Reading', 'Specifying either of these parameters will display posts of your journal') reading = parser.add_argument_group(
reading.add_argument('-from', dest='start_date', metavar="DATE", help='View entries after this date') "Reading",
reading.add_argument('-until', '-to', dest='end_date', metavar="DATE", help='View entries before this date') "Specifying either of these parameters will display posts of your journal",
reading.add_argument('-on', dest='on_date', metavar="DATE", help='View entries on this date') )
reading.add_argument('-and', dest='strict', action="store_true", help='Filter by tags using AND (default: OR)') reading.add_argument(
reading.add_argument('-starred', dest='starred', action="store_true", help='Show only starred entries') "-from", dest="start_date", metavar="DATE", help="View entries after this date"
reading.add_argument('-n', dest='limit', default=None, metavar="N", help="Shows the last n entries matching the filter. '-n 3' and '-3' have the same effect.", nargs="?", type=int) )
reading.add_argument('-not', dest='excluded', nargs='+', default=[], metavar="E", help="Exclude entries with these tags") reading.add_argument(
"-until",
"-to",
dest="end_date",
metavar="DATE",
help="View entries before this date",
)
reading.add_argument(
"-contains", dest="contains", help="View entries containing a specific string"
)
reading.add_argument(
"-on", dest="on_date", metavar="DATE", help="View entries on this date"
)
reading.add_argument(
"-and",
dest="strict",
action="store_true",
help="Filter by tags using AND (default: OR)",
)
reading.add_argument(
"-starred",
dest="starred",
action="store_true",
help="Show only starred entries",
)
reading.add_argument(
"-n",
dest="limit",
default=None,
metavar="N",
help="Shows the last n entries matching the filter. '-n 3' and '-3' have the same effect.",
nargs="?",
type=int,
)
reading.add_argument(
"-not",
dest="excluded",
nargs="+",
default=[],
metavar="E",
help="Exclude entries with these tags",
)
exporting = parser.add_argument_group(
"Export / Import", "Options for transmogrifying your journal"
)
exporting.add_argument(
"-s",
"--short",
dest="short",
action="store_true",
help="Show only titles or line containing the search tags",
)
exporting.add_argument(
"--tags",
dest="tags",
action="store_true",
help="Returns a list of all tags and number of occurences",
)
exporting.add_argument(
"--export",
metavar="TYPE",
dest="export",
choices=plugins.EXPORT_FORMATS,
help="Export your journal. TYPE can be {}.".format(
plugins.util.oxford_list(plugins.EXPORT_FORMATS)
),
default=False,
const=None,
)
exporting.add_argument(
"-o",
metavar="OUTPUT",
dest="output",
help="Optionally specifies output file when using --export. If OUTPUT is a directory, exports each entry into an individual file instead.",
default=False,
const=None,
)
exporting.add_argument(
"--import",
metavar="TYPE",
dest="import_",
choices=plugins.IMPORT_FORMATS,
help="Import entries into your journal. TYPE can be {}, and it defaults to jrnl if nothing else is specified.".format(
plugins.util.oxford_list(plugins.IMPORT_FORMATS)
),
default=False,
const="jrnl",
nargs="?",
)
exporting.add_argument(
"-i",
metavar="INPUT",
dest="input",
help="Optionally specifies input file when using --import.",
default=False,
const=None,
)
exporting.add_argument(
"--encrypt",
metavar="FILENAME",
dest="encrypt",
help="Encrypts your existing journal with a new password",
nargs="?",
default=False,
const=None,
)
exporting.add_argument(
"--decrypt",
metavar="FILENAME",
dest="decrypt",
help="Decrypts your journal and stores it in plain text",
nargs="?",
default=False,
const=None,
)
exporting.add_argument(
"--edit",
dest="edit",
help="Opens your editor to edit the selected entries.",
action="store_true",
)
exporting = parser.add_argument_group('Export / Import', 'Options for transmogrifying your journal')
exporting.add_argument('-s', '--short', dest='short', action="store_true", help='Show only titles or line containing the search tags')
exporting.add_argument('--tags', dest='tags', action="store_true", help='Returns a list of all tags and number of occurences')
exporting.add_argument('--export', metavar='TYPE', dest='export', choices=plugins.EXPORT_FORMATS, help='Export your journal. TYPE can be {}.'.format(plugins.util.oxford_list(plugins.EXPORT_FORMATS)), default=False, const=None)
exporting.add_argument('-o', metavar='OUTPUT', dest='output', help='Optionally specifies output file when using --export. If OUTPUT is a directory, exports each entry into an individual file instead.', default=False, const=None)
exporting.add_argument('--import', metavar='TYPE', dest='import_', choices=plugins.IMPORT_FORMATS, help='Import entries into your journal. TYPE can be {}, and it defaults to jrnl if nothing else is specified.'.format(plugins.util.oxford_list(plugins.IMPORT_FORMATS)), default=False, const='jrnl', nargs='?')
exporting.add_argument('-i', metavar='INPUT', dest='input', help='Optionally specifies input file when using --import.', default=False, const=None)
exporting.add_argument('--encrypt', metavar='FILENAME', dest='encrypt', help='Encrypts your existing journal with a new password', nargs='?', default=False, const=None)
exporting.add_argument('--decrypt', metavar='FILENAME', dest='decrypt', help='Decrypts your journal and stores it in plain text', nargs='?', default=False, const=None)
exporting.add_argument('--edit', dest='edit', help='Opens your editor to edit the selected entries.', action="store_true")
exporting.add_argument('--delete', dest='delete', action="store_true", help='Opens an interactive interface for deleting entries.') exporting.add_argument('--delete', dest='delete', action="store_true", help='Opens an interactive interface for deleting entries.')
return parser.parse_args(args) return parser.parse_args(args)
@ -62,13 +186,30 @@ def guess_mode(args, config):
compose = False compose = False
export = False export = False
import_ = True import_ = True
elif args.decrypt is not False or args.encrypt is not False or args.export is not False or any((args.short, args.tags, args.edit, args.delete)): elif (
args.decrypt is not False
or args.encrypt is not False
or args.export is not False
or any((args.short, args.tags, args.edit, args.delete))
):
compose = False compose = False
export = True export = True
elif any((args.start_date, args.end_date, args.on_date, args.limit, args.strict, args.starred)): elif any(
(
args.start_date,
args.end_date,
args.on_date,
args.limit,
args.strict,
args.starred,
args.contains,
)
):
# Any sign of displaying stuff? # Any sign of displaying stuff?
compose = False compose = False
elif args.text and all(word[0] in config['tagsymbols'] for word in " ".join(args.text).split()): elif args.text and all(
word[0] in config["tagsymbols"] for word in " ".join(args.text).split()
):
# No date and only tags? # No date and only tags?
compose = False compose = False
@ -77,38 +218,37 @@ def guess_mode(args, config):
def encrypt(journal, filename=None): def encrypt(journal, filename=None):
""" Encrypt into new file. If filename is not set, we encrypt the journal file itself. """ """ Encrypt into new file. If filename is not set, we encrypt the journal file itself. """
from . import EncryptedJournal journal.config["encrypt"] = True
journal.config['password'] = util.getpass("Enter new password: ") new_journal = EncryptedJournal.from_journal(journal)
journal.config['encrypt'] = True
new_journal = EncryptedJournal.EncryptedJournal(None, **journal.config)
new_journal.entries = journal.entries
new_journal.write(filename) new_journal.write(filename)
if util.yesno("Do you want to store the password in your keychain?", default=True): print(
util.set_keychain(journal.name, journal.config['password']) "Journal encrypted to {}.".format(filename or new_journal.config["journal"]),
file=sys.stderr,
print("Journal encrypted to {}.".format(filename or new_journal.config['journal']), file=sys.stderr) )
def decrypt(journal, filename=None): def decrypt(journal, filename=None):
""" Decrypts into new file. If filename is not set, we encrypt the journal file itself. """ """ Decrypts into new file. If filename is not set, we encrypt the journal file itself. """
journal.config['encrypt'] = False journal.config["encrypt"] = False
journal.config['password'] = ""
new_journal = Journal.PlainJournal(filename, **journal.config) new_journal = PlainJournal.from_journal(journal)
new_journal.entries = journal.entries
new_journal.write(filename) new_journal.write(filename)
print("Journal decrypted to {}.".format(filename or new_journal.config['journal']), file=sys.stderr) print(
"Journal decrypted to {}.".format(filename or new_journal.config["journal"]),
file=sys.stderr,
)
def list_journals(config): def list_journals(config):
"""List the journals specified in the configuration file""" """List the journals specified in the configuration file"""
result = f"Journals defined in {install.CONFIG_FILE_PATH}\n" result = f"Journals defined in {install.CONFIG_FILE_PATH}\n"
ml = min(max(len(k) for k in config['journals']), 20) ml = min(max(len(k) for k in config["journals"]), 20)
for journal, cfg in config['journals'].items(): for journal, cfg in config["journals"].items():
result += " * {:{}} -> {}\n".format(journal, ml, cfg['journal'] if isinstance(cfg, dict) else cfg) result += " * {:{}} -> {}\n".format(
journal, ml, cfg["journal"] if isinstance(cfg, dict) else cfg
)
return result return result
@ -116,11 +256,11 @@ def update_config(config, new_config, scope, force_local=False):
"""Updates a config dict with new values - either global if scope is None """Updates a config dict with new values - either global if scope is None
or config['journals'][scope] is just a string pointing to a journal file, or config['journals'][scope] is just a string pointing to a journal file,
or within the scope""" or within the scope"""
if scope and type(config['journals'][scope]) is dict: # Update to journal specific if scope and type(config["journals"][scope]) is dict: # Update to journal specific
config['journals'][scope].update(new_config) config["journals"][scope].update(new_config)
elif scope and force_local: # Convert to dict elif scope and force_local: # Convert to dict
config['journals'][scope] = {"journal": config['journals'][scope]} config["journals"][scope] = {"journal": config["journals"][scope]}
config['journals'][scope].update(new_config) config["journals"][scope].update(new_config)
else: else:
config.update(new_config) config.update(new_config)
@ -128,9 +268,11 @@ def update_config(config, new_config, scope, force_local=False):
def configure_logger(debug=False): def configure_logger(debug=False):
logging.basicConfig( logging.basicConfig(
level=logging.DEBUG if debug else logging.INFO, level=logging.DEBUG if debug else logging.INFO,
format='%(levelname)-8s %(name)-12s %(message)s' format="%(levelname)-8s %(name)-12s %(message)s",
) )
logging.getLogger('parsedatetime').setLevel(logging.INFO) # disable parsedatetime debug logging logging.getLogger("parsedatetime").setLevel(
logging.INFO
) # disable parsedatetime debug logging
def run(manual_args=None): def run(manual_args=None):
@ -156,11 +298,12 @@ def run(manual_args=None):
# If the first textual argument points to a journal file, # If the first textual argument points to a journal file,
# use this! # use this!
journal_name = args.text[0] if (args.text and args.text[0] in config['journals']) else 'default'
if journal_name != 'default': journal_name = install.DEFAULT_JOURNAL_KEY
if args.text and args.text[0] in config["journals"]:
journal_name = args.text[0]
args.text = args.text[1:] args.text = args.text[1:]
elif "default" not in config['journals']: elif install.DEFAULT_JOURNAL_KEY not in config["journals"]:
print("No default journal configured.", file=sys.stderr) print("No default journal configured.", file=sys.stderr)
print(list_journals(config), file=sys.stderr) print(list_journals(config), file=sys.stderr)
sys.exit(1) sys.exit(1)
@ -188,18 +331,24 @@ def run(manual_args=None):
if not sys.stdin.isatty(): if not sys.stdin.isatty():
# Piping data into jrnl # Piping data into jrnl
raw = sys.stdin.read() raw = sys.stdin.read()
elif config['editor']: elif config["editor"]:
template = "" template = ""
if config['template']: if config["template"]:
try: try:
template = open(config['template']).read() template = open(config["template"]).read()
except OSError: except OSError:
print(f"[Could not read template at '{config['template']}']", file=sys.stderr) print(
f"[Could not read template at '{config['template']}']",
file=sys.stderr,
)
sys.exit(1) sys.exit(1)
raw = util.get_text_from_editor(config, template) raw = util.get_text_from_editor(config, template)
else: else:
try: try:
print("[Compose Entry; " + _exit_multiline_code + " to finish writing]\n", file=sys.stderr) print(
"[Compose Entry; " + _exit_multiline_code + " to finish writing]\n",
file=sys.stderr,
)
raw = sys.stdin.read() raw = sys.stdin.read()
except KeyboardInterrupt: except KeyboardInterrupt:
print("[Entry NOT saved to journal.]", file=sys.stderr) print("[Entry NOT saved to journal.]", file=sys.stderr)
@ -211,7 +360,7 @@ def run(manual_args=None):
# This is where we finally open the journal! # This is where we finally open the journal!
try: try:
journal = Journal.open_journal(journal_name, config) journal = open_journal(journal_name, config)
except KeyboardInterrupt: except KeyboardInterrupt:
print(f"[Interrupted while opening journal]", file=sys.stderr) print(f"[Interrupted while opening journal]", file=sys.stderr)
sys.exit(1) sys.exit(1)
@ -232,12 +381,16 @@ def run(manual_args=None):
old_entries = journal.entries old_entries = journal.entries
if args.on_date: if args.on_date:
args.start_date = args.end_date = args.on_date args.start_date = args.end_date = args.on_date
journal.filter(tags=args.text, journal.filter(
start_date=args.start_date, end_date=args.end_date, tags=args.text,
strict=args.strict, start_date=args.start_date,
short=args.short, end_date=args.end_date,
starred=args.starred, strict=args.strict,
exclude=args.excluded) short=args.short,
starred=args.starred,
exclude=args.excluded,
contains=args.contains,
)
journal.limit(args.limit) journal.limit(args.limit)
# Reading mode # Reading mode
@ -259,20 +412,28 @@ def run(manual_args=None):
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:
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) install.save_config(original_config)
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:
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) install.save_config(original_config)
elif args.edit: elif args.edit:
if not config['editor']: if not config["editor"]:
print("[{1}ERROR{2}: You need to specify an editor in {0} to use the --edit function.]" print(
.format(install.CONFIG_FILE_PATH, ERROR_COLOR, RESET_COLOR), file=sys.stderr) "[{1}ERROR{2}: You need to specify an editor in {0} to use the --edit function.]".format(
install.CONFIG_FILE_PATH, ERROR_COLOR, RESET_COLOR
),
file=sys.stderr,
)
sys.exit(1) sys.exit(1)
other_entries = [e for e in old_entries if e not in journal.entries] other_entries = [e for e in old_entries if e not in journal.entries]
# Edit # Edit
@ -283,9 +444,17 @@ def run(manual_args=None):
num_edited = len([e for e in journal.entries if e.modified]) num_edited = len([e for e in journal.entries if e.modified])
prompts = [] prompts = []
if num_deleted: if num_deleted:
prompts.append("{} {} deleted".format(num_deleted, "entry" if num_deleted == 1 else "entries")) prompts.append(
"{} {} deleted".format(
num_deleted, "entry" if num_deleted == 1 else "entries"
)
)
if num_edited: if num_edited:
prompts.append("{} {} modified".format(num_edited, "entry" if num_deleted == 1 else "entries")) prompts.append(
"{} {} modified".format(
num_edited, "entry" if num_deleted == 1 else "entries"
)
)
if prompts: if prompts:
print("[{}]".format(", ".join(prompts).capitalize()), file=sys.stderr) print("[{}]".format(", ".join(prompts).capitalize()), file=sys.stderr)
journal.entries += other_entries journal.entries += other_entries

View file

@ -1,63 +0,0 @@
#!/usr/bin/env python
from .util import ERROR_COLOR, RESET_COLOR
from .util import slugify
from .plugins.template import Template
import os
class Exporter:
"""This Exporter can convert entries and journals into text files."""
def __init__(self, format):
with open("jrnl/templates/" + format + ".template") as f:
front_matter, body = f.read().strip("-\n").split("---", 2)
self.template = Template(body)
def export_entry(self, entry):
"""Returns a string representation of a single entry."""
return str(entry)
def _get_vars(self, journal):
return {
'journal': journal,
'entries': journal.entries,
'tags': journal.tags
}
def export_journal(self, journal):
"""Returns a string representation of an entire journal."""
return self.template.render_block("journal", **self._get_vars(journal))
def write_file(self, journal, path):
"""Exports a journal into a single file."""
try:
with open(path, "w", encoding="utf-8") as f:
f.write(self.export_journal(journal))
return f"[Journal exported to {path}]"
except OSError as e:
return f"[{ERROR_COLOR}ERROR{RESET_COLOR}: {e.filename} {e.strerror}]"
def make_filename(self, entry):
return entry.date.strftime("%Y-%m-%d_{}.{}".format(slugify(entry.title), self.extension))
def write_files(self, journal, path):
"""Exports a journal into individual files for each entry."""
for entry in journal.entries:
try:
full_path = os.path.join(path, self.make_filename(entry))
with open(full_path, "w", encoding="utf-8") as f:
f.write(self.export_entry(entry))
except OSError as e:
return f"[{ERROR_COLOR}ERROR{RESET_COLOR}: {e.filename} {e.strerror}]"
return f"[Journal exported to {path}]"
def export(self, journal, format="text", output=None):
"""Exports to individual files if output is an existing path, or into
a single file if output is a file name, or returns the exporter's
representation as string if output is None."""
if output and os.path.isdir(output): # multiple files
return self.write_files(journal, output)
elif output: # single file
return self.write_file(journal, output)
else:
return self.export_journal(journal)

View file

@ -13,15 +13,17 @@ from .util import UserAbort
import yaml import yaml
import logging import logging
import sys import sys
if "win32" not in sys.platform: if "win32" not in sys.platform:
# readline is not included in Windows Active Python # readline is not included in Windows Active Python
import readline import readline
DEFAULT_CONFIG_NAME = 'jrnl.yaml' DEFAULT_CONFIG_NAME = "jrnl.yaml"
DEFAULT_JOURNAL_NAME = 'journal.txt' DEFAULT_JOURNAL_NAME = "journal.txt"
XDG_RESOURCE = 'jrnl' DEFAULT_JOURNAL_KEY = "default"
XDG_RESOURCE = "jrnl"
USER_HOME = os.path.expanduser('~') USER_HOME = os.path.expanduser("~")
CONFIG_PATH = xdg.BaseDirectory.save_config_path(XDG_RESOURCE) or USER_HOME CONFIG_PATH = xdg.BaseDirectory.save_config_path(XDG_RESOURCE) or USER_HOME
CONFIG_FILE_PATH = os.path.join(CONFIG_PATH, DEFAULT_CONFIG_NAME) CONFIG_FILE_PATH = os.path.join(CONFIG_PATH, DEFAULT_CONFIG_NAME)
@ -42,21 +44,20 @@ def module_exists(module_name):
else: else:
return True return True
default_config = { default_config = {
'version': __version__, "version": __version__,
'journals': { "journals": {DEFAULT_JOURNAL_KEY: JOURNAL_FILE_PATH},
"default": JOURNAL_FILE_PATH "editor": os.getenv("VISUAL") or os.getenv("EDITOR") or "",
}, "encrypt": False,
'editor': os.getenv('VISUAL') or os.getenv('EDITOR') or "", "template": False,
'encrypt': False, "default_hour": 9,
'template': False, "default_minute": 0,
'default_hour': 9, "timeformat": "%Y-%m-%d %H:%M",
'default_minute': 0, "tagsymbols": "@",
'timeformat': "%Y-%m-%d %H:%M", "highlight": True,
'tagsymbols': '@', "linewrap": 79,
'highlight': True, "indent_character": "|",
'linewrap': 79,
'indent_character': '|',
} }
@ -69,13 +70,18 @@ def upgrade_config(config):
for key in missing_keys: for key in missing_keys:
config[key] = default_config[key] config[key] = default_config[key]
save_config(config) save_config(config)
print(f"[Configuration updated to newest version at {CONFIG_FILE_PATH}]", file=sys.stderr) print(
f"[Configuration updated to newest version at {CONFIG_FILE_PATH}]",
file=sys.stderr,
)
def save_config(config): def save_config(config):
config['version'] = __version__ config["version"] = __version__
with open(CONFIG_FILE_PATH, 'w') as f: with open(CONFIG_FILE_PATH, "w") as f:
yaml.safe_dump(config, f, encoding='utf-8', allow_unicode=True, default_flow_style=False) yaml.safe_dump(
config, f, encoding="utf-8", allow_unicode=True, default_flow_style=False
)
def load_or_install_jrnl(): def load_or_install_jrnl():
@ -83,17 +89,27 @@ def load_or_install_jrnl():
If jrnl is already installed, loads and returns a config object. If jrnl is already installed, loads and returns a config object.
Else, perform various prompts to install jrnl. Else, perform various prompts to install jrnl.
""" """
config_path = CONFIG_FILE_PATH if os.path.exists(CONFIG_FILE_PATH) else CONFIG_FILE_PATH_FALLBACK config_path = (
CONFIG_FILE_PATH
if os.path.exists(CONFIG_FILE_PATH)
else CONFIG_FILE_PATH_FALLBACK
)
if os.path.exists(config_path): if os.path.exists(config_path):
log.debug('Reading configuration from file %s', config_path) log.debug("Reading configuration from file %s", config_path)
config = util.load_config(config_path) config = util.load_config(config_path)
try: try:
upgrade.upgrade_jrnl_if_necessary(config_path) upgrade.upgrade_jrnl_if_necessary(config_path)
except upgrade.UpgradeValidationException: except upgrade.UpgradeValidationException:
print("Aborting upgrade.", file=sys.stderr) print("Aborting upgrade.", file=sys.stderr)
print("Please tell us about this problem at the following URL:", file=sys.stderr) print(
print("https://github.com/jrnl-org/jrnl/issues/new?title=UpgradeValidationException", file=sys.stderr) "Please tell us about this problem at the following URL:",
file=sys.stderr,
)
print(
"https://github.com/jrnl-org/jrnl/issues/new?title=UpgradeValidationException",
file=sys.stderr,
)
print("Exiting.", file=sys.stderr) print("Exiting.", file=sys.stderr)
sys.exit(1) sys.exit(1)
@ -101,7 +117,7 @@ def load_or_install_jrnl():
return config return config
else: else:
log.debug('Configuration file not found, installing jrnl...') log.debug("Configuration file not found, installing jrnl...")
try: try:
config = install() config = install()
except KeyboardInterrupt: except KeyboardInterrupt:
@ -111,42 +127,39 @@ def load_or_install_jrnl():
def install(): def install():
if "win32" not in sys.platform: if "win32" not in sys.platform:
readline.set_completer_delims(' \t\n;') readline.set_completer_delims(" \t\n;")
readline.parse_and_bind("tab: complete") readline.parse_and_bind("tab: complete")
readline.set_completer(autocomplete) readline.set_completer(autocomplete)
# Where to create the journal? # Where to create the journal?
path_query = f'Path to your journal file (leave blank for {JOURNAL_FILE_PATH}): ' path_query = f"Path to your journal file (leave blank for {JOURNAL_FILE_PATH}): "
journal_path = input(path_query).strip() or JOURNAL_FILE_PATH journal_path = input(path_query).strip() or JOURNAL_FILE_PATH
default_config['journals']['default'] = os.path.expanduser(os.path.expandvars(journal_path)) default_config["journals"][DEFAULT_JOURNAL_KEY] = os.path.expanduser(
os.path.expandvars(journal_path)
)
path = os.path.split(default_config['journals']['default'])[0] # If the folder doesn't exist, create it # If the folder doesn't exist, create it
path = os.path.split(default_config["journals"][DEFAULT_JOURNAL_KEY])[0]
try: try:
os.makedirs(path) os.makedirs(path)
except OSError: except OSError:
pass pass
# Encrypt it? # Encrypt it?
password = getpass.getpass("Enter password for journal (leave blank for no encryption): ") encrypt = util.yesno(
if password: "Do you want to encrypt your journal? You can always change this later",
default_config['encrypt'] = True default=False,
if util.yesno("Do you want to store the password in your keychain?", default=True): )
util.set_keychain("default", password) if encrypt:
else: default_config["encrypt"] = True
util.set_keychain("default", None)
EncryptedJournal._create(default_config['journals']['default'], password)
print("Journal will be encrypted.", file=sys.stderr) print("Journal will be encrypted.", file=sys.stderr)
else:
PlainJournal._create(default_config['journals']['default'])
config = default_config save_config(default_config)
save_config(config) return default_config
if password:
config['password'] = password
return config
def autocomplete(text, state): def autocomplete(text, state):
expansions = glob.glob(os.path.expanduser(os.path.expandvars(text)) + '*') expansions = glob.glob(os.path.expanduser(os.path.expandvars(text)) + "*")
expansions = [e + "/" if os.path.isdir(e) else e for e in expansions] expansions = [e + "/" if os.path.isdir(e) else e for e in expansions]
expansions.append(None) expansions.append(None)
return expansions[state] return expansions[state]

View file

@ -11,8 +11,16 @@ from .yaml_exporter import YAMLExporter
from .template_exporter import __all__ as template_exporters from .template_exporter import __all__ as template_exporters
from .fancy_exporter import FancyExporter from .fancy_exporter import FancyExporter
__exporters =[JSONExporter, MarkdownExporter, TagExporter, TextExporter, XMLExporter, YAMLExporter, FancyExporter] + template_exporters __exporters = [
__importers =[JRNLImporter] JSONExporter,
MarkdownExporter,
TagExporter,
TextExporter,
XMLExporter,
YAMLExporter,
FancyExporter,
] + template_exporters
__importers = [JRNLImporter]
__exporter_types = {name: plugin for plugin in __exporters for name in plugin.names} __exporter_types = {name: plugin for plugin in __exporters for name in plugin.names}
__importer_types = {name: plugin for plugin in __importers for name in plugin.names} __importer_types = {name: plugin for plugin in __importers for name in plugin.names}
@ -20,6 +28,7 @@ __importer_types = {name: plugin for plugin in __importers for name in plugin.na
EXPORT_FORMATS = sorted(__exporter_types.keys()) EXPORT_FORMATS = sorted(__exporter_types.keys())
IMPORT_FORMATS = sorted(__importer_types.keys()) IMPORT_FORMATS = sorted(__importer_types.keys())
def get_exporter(format): def get_exporter(format):
for exporter in __exporters: for exporter in __exporters:
if hasattr(exporter, "names") and format in exporter.names: if hasattr(exporter, "names") and format in exporter.names:

View file

@ -8,46 +8,64 @@ from textwrap import TextWrapper
class FancyExporter(TextExporter): class FancyExporter(TextExporter):
"""This Exporter can convert entries and journals into text with unicode box drawing characters.""" """This Exporter can convert entries and journals into text with unicode box drawing characters."""
names = ["fancy", "boxed"] names = ["fancy", "boxed"]
extension = "txt" extension = "txt"
border_a="" border_a = ""
border_b="" border_b = ""
border_c="" border_c = ""
border_d="" border_d = ""
border_e="" border_e = ""
border_f="" border_f = ""
border_g="" border_g = ""
border_h="" border_h = ""
border_i="" border_i = ""
border_j="" border_j = ""
border_k="" border_k = ""
border_l="" border_l = ""
border_m="" border_m = ""
@classmethod @classmethod
def export_entry(cls, entry): def export_entry(cls, entry):
"""Returns a fancy unicode representation of a single entry.""" """Returns a fancy unicode representation of a single entry."""
date_str = entry.date.strftime(entry.journal.config['timeformat']) date_str = entry.date.strftime(entry.journal.config["timeformat"])
linewrap = entry.journal.config['linewrap'] or 78 linewrap = entry.journal.config["linewrap"] or 78
initial_linewrap = linewrap - len(date_str) - 2 initial_linewrap = linewrap - len(date_str) - 2
body_linewrap = linewrap - 2 body_linewrap = linewrap - 2
card = [cls.border_a + cls.border_b*(initial_linewrap) + cls.border_c + date_str] card = [
w = TextWrapper(width=initial_linewrap, initial_indent=cls.border_g+' ', subsequent_indent=cls.border_g+' ') cls.border_a + cls.border_b * (initial_linewrap) + cls.border_c + date_str
]
w = TextWrapper(
width=initial_linewrap,
initial_indent=cls.border_g + " ",
subsequent_indent=cls.border_g + " ",
)
title_lines = w.wrap(entry.title) title_lines = w.wrap(entry.title)
card.append(title_lines[0].ljust(initial_linewrap+1) + cls.border_d + cls.border_e*(len(date_str)-1) + cls.border_f) card.append(
title_lines[0].ljust(initial_linewrap + 1)
+ cls.border_d
+ cls.border_e * (len(date_str) - 1)
+ cls.border_f
)
w.width = body_linewrap w.width = body_linewrap
if len(title_lines) > 1: if len(title_lines) > 1:
for line in w.wrap(' '.join([title_line[len(w.subsequent_indent):] for line in w.wrap(
for title_line in title_lines[1:]])): " ".join(
card.append(line.ljust(body_linewrap+1) + cls.border_h) [
title_line[len(w.subsequent_indent) :]
for title_line in title_lines[1:]
]
)
):
card.append(line.ljust(body_linewrap + 1) + cls.border_h)
if entry.body: if entry.body:
card.append(cls.border_i + cls.border_j*body_linewrap + cls.border_k) card.append(cls.border_i + cls.border_j * body_linewrap + cls.border_k)
for line in entry.body.splitlines(): for line in entry.body.splitlines():
body_lines = w.wrap(line) or [cls.border_g] body_lines = w.wrap(line) or [cls.border_g]
for body_line in body_lines: for body_line in body_lines:
card.append(body_line.ljust(body_linewrap+1) + cls.border_h) card.append(body_line.ljust(body_linewrap + 1) + cls.border_h)
card.append(cls.border_l + cls.border_b*body_linewrap + cls.border_m) card.append(cls.border_l + cls.border_b * body_linewrap + cls.border_m)
return "\n".join(card) return "\n".join(card)
@classmethod @classmethod

View file

@ -4,8 +4,10 @@
import sys import sys
from .. import util from .. import util
class JRNLImporter: class JRNLImporter:
"""This plugin imports entries from other jrnl files.""" """This plugin imports entries from other jrnl files."""
names = ["jrnl"] names = ["jrnl"]
@staticmethod @staticmethod
@ -25,5 +27,8 @@ class JRNLImporter:
sys.exit(0) sys.exit(0)
journal.import_(other_journal_txt) journal.import_(other_journal_txt)
new_cnt = len(journal.entries) new_cnt = len(journal.entries)
print("[{} imported to {} journal]".format(new_cnt - old_cnt, journal.name), file=sys.stderr) print(
"[{} imported to {} journal]".format(new_cnt - old_cnt, journal.name),
file=sys.stderr,
)
journal.write() journal.write()

View file

@ -8,20 +8,21 @@ from .util import get_tags_count
class JSONExporter(TextExporter): class JSONExporter(TextExporter):
"""This Exporter can convert entries and journals into json.""" """This Exporter can convert entries and journals into json."""
names = ["json"] names = ["json"]
extension = "json" extension = "json"
@classmethod @classmethod
def entry_to_dict(cls, entry): def entry_to_dict(cls, entry):
entry_dict = { entry_dict = {
'title': entry.title, "title": entry.title,
'body': entry.body, "body": entry.body,
'date': entry.date.strftime("%Y-%m-%d"), "date": entry.date.strftime("%Y-%m-%d"),
'time': entry.date.strftime("%H:%M"), "time": entry.date.strftime("%H:%M"),
'starred': entry.starred "starred": entry.starred,
} }
if hasattr(entry, "uuid"): if hasattr(entry, "uuid"):
entry_dict['uuid'] = entry.uuid entry_dict["uuid"] = entry.uuid
return entry_dict return entry_dict
@classmethod @classmethod
@ -35,6 +36,6 @@ class JSONExporter(TextExporter):
tags = get_tags_count(journal) tags = get_tags_count(journal)
result = { result = {
"tags": {tag: count for count, tag in tags}, "tags": {tag: count for count, tag in tags},
"entries": [cls.entry_to_dict(e) for e in journal.entries] "entries": [cls.entry_to_dict(e) for e in journal.entries],
} }
return json.dumps(result, indent=2) return json.dumps(result, indent=2)

View file

@ -10,24 +10,25 @@ from ..util import WARNING_COLOR, RESET_COLOR
class MarkdownExporter(TextExporter): class MarkdownExporter(TextExporter):
"""This Exporter can convert entries and journals into Markdown.""" """This Exporter can convert entries and journals into Markdown."""
names = ["md", "markdown"] names = ["md", "markdown"]
extension = "md" extension = "md"
@classmethod @classmethod
def export_entry(cls, entry, to_multifile=True): def export_entry(cls, entry, to_multifile=True):
"""Returns a markdown representation of a single entry.""" """Returns a markdown representation of a single entry."""
date_str = entry.date.strftime(entry.journal.config['timeformat']) date_str = entry.date.strftime(entry.journal.config["timeformat"])
body_wrapper = "\n" if entry.body else "" body_wrapper = "\n" if entry.body else ""
body = body_wrapper + entry.body body = body_wrapper + entry.body
if to_multifile is True: if to_multifile is True:
heading = '#' heading = "#"
else: else:
heading = '###' heading = "###"
'''Increase heading levels in body text''' """Increase heading levels in body text"""
newbody = '' newbody = ""
previous_line = '' previous_line = ""
warn_on_heading_level = False warn_on_heading_level = False
for line in body.splitlines(True): for line in body.splitlines(True):
if re.match(r"^#+ ", line): if re.match(r"^#+ ", line):
@ -35,24 +36,30 @@ class MarkdownExporter(TextExporter):
newbody = newbody + previous_line + heading + line newbody = newbody + previous_line + heading + line
if re.match(r"^#######+ ", heading + line): if re.match(r"^#######+ ", heading + line):
warn_on_heading_level = True warn_on_heading_level = True
line = '' line = ""
elif re.match(r"^=+$", line.rstrip()) and not re.match(r"^$", previous_line.strip()): elif re.match(r"^=+$", line.rstrip()) and not re.match(
r"^$", previous_line.strip()
):
"""Setext style H1""" """Setext style H1"""
newbody = newbody + heading + "# " + previous_line newbody = newbody + heading + "# " + previous_line
line = '' line = ""
elif re.match(r"^-+$", line.rstrip()) and not re.match(r"^$", previous_line.strip()): elif re.match(r"^-+$", line.rstrip()) and not re.match(
r"^$", previous_line.strip()
):
"""Setext style H2""" """Setext style H2"""
newbody = newbody + heading + "## " + previous_line newbody = newbody + heading + "## " + previous_line
line = '' line = ""
else: else:
newbody = newbody + previous_line newbody = newbody + previous_line
previous_line = line previous_line = line
newbody = newbody + previous_line # add very last line newbody = newbody + previous_line # add very last line
if warn_on_heading_level is True: if warn_on_heading_level is True:
print(f"{WARNING_COLOR}WARNING{RESET_COLOR}: " print(
f"Headings increased past H6 on export - {date_str} {entry.title}", f"{WARNING_COLOR}WARNING{RESET_COLOR}: "
file=sys.stderr) f"Headings increased past H6 on export - {date_str} {entry.title}",
file=sys.stderr,
)
return f"{heading} {date_str} {entry.title}\n{newbody} " return f"{heading} {date_str} {entry.title}\n{newbody} "

View file

@ -7,6 +7,7 @@ from .util import get_tags_count
class TagExporter(TextExporter): class TagExporter(TextExporter):
"""This Exporter can lists the tags for entries and journals, exported as a plain text file.""" """This Exporter can lists the tags for entries and journals, exported as a plain text file."""
names = ["tags"] names = ["tags"]
extension = "tags" extension = "tags"
@ -21,9 +22,11 @@ class TagExporter(TextExporter):
tag_counts = get_tags_count(journal) tag_counts = get_tags_count(journal)
result = "" result = ""
if not tag_counts: if not tag_counts:
return '[No tags found in journal.]' return "[No tags found in journal.]"
elif min(tag_counts)[0] == 0: elif min(tag_counts)[0] == 0:
tag_counts = filter(lambda x: x[0] > 1, tag_counts) tag_counts = filter(lambda x: x[0] > 1, tag_counts)
result += '[Removed tags that appear only once.]\n' result += "[Removed tags that appear only once.]\n"
result += "\n".join("{:20} : {}".format(tag, n) for n, tag in sorted(tag_counts, reverse=True)) result += "\n".join(
"{:20} : {}".format(tag, n) for n, tag in sorted(tag_counts, reverse=True)
)
return result return result

View file

@ -1,5 +1,4 @@
import re import re
import asteval
import yaml import yaml
VAR_RE = r"[_a-zA-Z][a-zA-Z0-9_]*" VAR_RE = r"[_a-zA-Z][a-zA-Z0-9_]*"
@ -7,7 +6,9 @@ EXPRESSION_RE = r"[\[\]():.a-zA-Z0-9_]*"
PRINT_RE = r"{{ *(.+?) *}}" PRINT_RE = r"{{ *(.+?) *}}"
START_BLOCK_RE = r"{% *(if|for) +(.+?) *%}" START_BLOCK_RE = r"{% *(if|for) +(.+?) *%}"
END_BLOCK_RE = r"{% *end(for|if) *%}" END_BLOCK_RE = r"{% *end(for|if) *%}"
FOR_RE = r"{{% *for +({varname}) +in +([^%]+) *%}}".format(varname=VAR_RE, expression=EXPRESSION_RE) FOR_RE = r"{{% *for +({varname}) +in +([^%]+) *%}}".format(
varname=VAR_RE, expression=EXPRESSION_RE
)
IF_RE = r"{% *if +(.+?) *%}" IF_RE = r"{% *if +(.+?) *%}"
BLOCK_RE = r"{% *block +(.+?) *%}((?:.|\n)+?){% *endblock *%}" BLOCK_RE = r"{% *block +(.+?) *%}((?:.|\n)+?){% *endblock *%}"
INCLUDE_RE = r"{% *include +(.+?) *%}" INCLUDE_RE = r"{% *include +(.+?) *%}"
@ -39,9 +40,11 @@ class Template:
return self._expand(self.blocks[block], **vars) return self._expand(self.blocks[block], **vars)
def _eval_context(self, vars): def _eval_context(self, vars):
import asteval
e = asteval.Interpreter(use_numpy=False, writer=None) e = asteval.Interpreter(use_numpy=False, writer=None)
e.symtable.update(vars) e.symtable.update(vars)
e.symtable['__last_iteration'] = vars.get("__last_iteration", False) e.symtable["__last_iteration"] = vars.get("__last_iteration", False)
return e return e
def _get_blocks(self): def _get_blocks(self):
@ -49,12 +52,19 @@ class Template:
name, contents = match.groups() name, contents = match.groups()
self.blocks[name] = self._strip_single_nl(contents) self.blocks[name] = self._strip_single_nl(contents)
return "" return ""
self.clean_template = re.sub(BLOCK_RE, s, self.template, flags=re.MULTILINE) self.clean_template = re.sub(BLOCK_RE, s, self.template, flags=re.MULTILINE)
def _expand(self, template, **vars): def _expand(self, template, **vars):
stack = sorted( stack = sorted(
[(m.start(), 1, m.groups()[0]) for m in re.finditer(START_BLOCK_RE, template)] + [
[(m.end(), -1, m.groups()[0]) for m in re.finditer(END_BLOCK_RE, template)] (m.start(), 1, m.groups()[0])
for m in re.finditer(START_BLOCK_RE, template)
]
+ [
(m.end(), -1, m.groups()[0])
for m in re.finditer(END_BLOCK_RE, template)
]
) )
last_nesting, nesting = 0, 0 last_nesting, nesting = 0, 0
@ -80,19 +90,23 @@ class Template:
start = pos start = pos
last_nesting = nesting last_nesting = nesting
result += self._expand_vars(template[stack[-1][0]:], **vars) result += self._expand_vars(template[stack[-1][0] :], **vars)
return result return result
def _expand_vars(self, template, **vars): def _expand_vars(self, template, **vars):
safe_eval = self._eval_context(vars) safe_eval = self._eval_context(vars)
expanded = re.sub(INCLUDE_RE, lambda m: self.render_block(m.groups()[0], **vars), template) expanded = re.sub(
INCLUDE_RE, lambda m: self.render_block(m.groups()[0], **vars), template
)
return re.sub(PRINT_RE, lambda m: str(safe_eval(m.groups()[0])), expanded) return re.sub(PRINT_RE, lambda m: str(safe_eval(m.groups()[0])), expanded)
def _expand_cond(self, template, **vars): def _expand_cond(self, template, **vars):
start_block = re.search(IF_RE, template, re.M) start_block = re.search(IF_RE, template, re.M)
end_block = list(re.finditer(END_BLOCK_RE, template, re.M))[-1] end_block = list(re.finditer(END_BLOCK_RE, template, re.M))[-1]
expression = start_block.groups()[0] expression = start_block.groups()[0]
sub_template = self._strip_single_nl(template[start_block.end():end_block.start()]) sub_template = self._strip_single_nl(
template[start_block.end() : end_block.start()]
)
safe_eval = self._eval_context(vars) safe_eval = self._eval_context(vars)
if safe_eval(expression): if safe_eval(expression):
@ -110,15 +124,17 @@ class Template:
start_block = re.search(FOR_RE, template, re.M) start_block = re.search(FOR_RE, template, re.M)
end_block = list(re.finditer(END_BLOCK_RE, template, re.M))[-1] end_block = list(re.finditer(END_BLOCK_RE, template, re.M))[-1]
var_name, iterator = start_block.groups() var_name, iterator = start_block.groups()
sub_template = self._strip_single_nl(template[start_block.end():end_block.start()], strip_r=False) sub_template = self._strip_single_nl(
template[start_block.end() : end_block.start()], strip_r=False
)
safe_eval = self._eval_context(vars) safe_eval = self._eval_context(vars)
result = '' result = ""
items = safe_eval(iterator) items = safe_eval(iterator)
for idx, var in enumerate(items): for idx, var in enumerate(items):
vars[var_name] = var vars[var_name] = var
vars['__last_iteration'] = idx == len(items) - 1 vars["__last_iteration"] = idx == len(items) - 1
result += self._expand(sub_template, **vars) result += self._expand(sub_template, **vars)
del vars[var_name] del vars[var_name]
return self._strip_single_nl(result) return self._strip_single_nl(result)

View file

@ -13,20 +13,13 @@ class GenericTemplateExporter(TextExporter):
@classmethod @classmethod
def export_entry(cls, entry): def export_entry(cls, entry):
"""Returns a string representation of a single entry.""" """Returns a string representation of a single entry."""
vars = { vars = {"entry": entry, "tags": entry.tags}
'entry': entry,
'tags': entry.tags
}
return cls.template.render_block("entry", **vars) return cls.template.render_block("entry", **vars)
@classmethod @classmethod
def export_journal(cls, journal): def export_journal(cls, journal):
"""Returns a string representation of an entire journal.""" """Returns a string representation of an entire journal."""
vars = { vars = {"journal": journal, "entries": journal.entries, "tags": journal.tags}
'journal': journal,
'entries': journal.entries,
'tags': journal.tags
}
return cls.template.render_block("journal", **vars) return cls.template.render_block("journal", **vars)
@ -34,11 +27,12 @@ def __exporter_from_file(template_file):
"""Create a template class from a file""" """Create a template class from a file"""
name = os.path.basename(template_file).replace(".template", "") name = os.path.basename(template_file).replace(".template", "")
template = Template.from_file(template_file) template = Template.from_file(template_file)
return type(str(f"{name.title()}Exporter"), (GenericTemplateExporter, ), { return type(
"names": [name], str(f"{name.title()}Exporter"),
"extension": template.extension, (GenericTemplateExporter,),
"template": template {"names": [name], "extension": template.extension, "template": template},
}) )
__all__ = [] __all__ = []

View file

@ -8,6 +8,7 @@ from ..util import ERROR_COLOR, RESET_COLOR
class TextExporter: class TextExporter:
"""This Exporter can convert entries and journals into text files.""" """This Exporter can convert entries and journals into text files."""
names = ["text", "txt"] names = ["text", "txt"]
extension = "txt" extension = "txt"
@ -33,7 +34,9 @@ class TextExporter:
@classmethod @classmethod
def make_filename(cls, entry): def make_filename(cls, entry):
return entry.date.strftime("%Y-%m-%d_{}.{}".format(slugify(str(entry.title)), cls.extension)) return entry.date.strftime(
"%Y-%m-%d_{}.{}".format(slugify(str(entry.title)), cls.extension)
)
@classmethod @classmethod
def write_files(cls, journal, path): def write_files(cls, journal, path):
@ -44,7 +47,9 @@ class TextExporter:
with open(full_path, "w", encoding="utf-8") as f: with open(full_path, "w", encoding="utf-8") as f:
f.write(cls.export_entry(entry)) f.write(cls.export_entry(entry))
except IOError as e: except IOError as e:
return "[{2}ERROR{3}: {0} {1}]".format(e.filename, e.strerror, ERROR_COLOR, RESET_COLOR) return "[{2}ERROR{3}: {0} {1}]".format(
e.filename, e.strerror, ERROR_COLOR, RESET_COLOR
)
return "[Journal exported to {}]".format(path) return "[Journal exported to {}]".format(path)
@classmethod @classmethod
@ -54,7 +59,7 @@ class TextExporter:
representation as string if output is None.""" representation as string if output is None."""
if output and os.path.isdir(output): # multiple files if output and os.path.isdir(output): # multiple files
return cls.write_files(journal, output) return cls.write_files(journal, output)
elif output: # single file elif output: # single file
return cls.write_file(journal, output) return cls.write_file(journal, output)
else: else:
return cls.export_journal(journal) return cls.export_journal(journal)

View file

@ -6,9 +6,7 @@ def get_tags_count(journal):
"""Returns a set of tuples (count, tag) for all tags present in the journal.""" """Returns a set of tuples (count, tag) for all tags present in the journal."""
# Astute reader: should the following line leave you as puzzled as me the first time # Astute reader: should the following line leave you as puzzled as me the first time
# I came across this construction, worry not and embrace the ensuing moment of enlightment. # I came across this construction, worry not and embrace the ensuing moment of enlightment.
tags = [tag tags = [tag for entry in journal.entries for tag in set(entry.tags)]
for entry in journal.entries
for tag in set(entry.tags)]
# To be read: [for entry in journal.entries: for tag in set(entry.tags): tag] # To be read: [for entry in journal.entries: for tag in set(entry.tags): tag]
tag_counts = {(tags.count(tag), tag) for tag in tags} tag_counts = {(tags.count(tag), tag) for tag in tags}
return tag_counts return tag_counts
@ -24,4 +22,4 @@ def oxford_list(lst):
elif len(lst) == 2: elif len(lst) == 2:
return lst[0] + " or " + lst[1] return lst[0] + " or " + lst[1]
else: else:
return ', '.join(lst[:-1]) + ", or " + lst[-1] return ", ".join(lst[:-1]) + ", or " + lst[-1]

View file

@ -8,6 +8,7 @@ from xml.dom import minidom
class XMLExporter(JSONExporter): class XMLExporter(JSONExporter):
"""This Exporter can convert entries and journals into XML.""" """This Exporter can convert entries and journals into XML."""
names = ["xml"] names = ["xml"]
extension = "xml" extension = "xml"
@ -15,7 +16,7 @@ class XMLExporter(JSONExporter):
def export_entry(cls, entry, doc=None): def export_entry(cls, entry, doc=None):
"""Returns an XML representation of a single entry.""" """Returns an XML representation of a single entry."""
doc_el = doc or minidom.Document() doc_el = doc or minidom.Document()
entry_el = doc_el.createElement('entry') entry_el = doc_el.createElement("entry")
for key, value in cls.entry_to_dict(entry).items(): for key, value in cls.entry_to_dict(entry).items():
elem = doc_el.createElement(key) elem = doc_el.createElement(key)
elem.appendChild(doc_el.createTextNode(value)) elem.appendChild(doc_el.createTextNode(value))
@ -28,11 +29,11 @@ class XMLExporter(JSONExporter):
@classmethod @classmethod
def entry_to_xml(cls, entry, doc): def entry_to_xml(cls, entry, doc):
entry_el = doc.createElement('entry') entry_el = doc.createElement("entry")
entry_el.setAttribute('date', entry.date.isoformat()) entry_el.setAttribute("date", entry.date.isoformat())
if hasattr(entry, "uuid"): if hasattr(entry, "uuid"):
entry_el.setAttribute('uuid', entry.uuid) entry_el.setAttribute("uuid", entry.uuid)
entry_el.setAttribute('starred', entry.starred) entry_el.setAttribute("starred", entry.starred)
entry_el.appendChild(doc.createTextNode(entry.fulltext)) entry_el.appendChild(doc.createTextNode(entry.fulltext))
return entry_el return entry_el
@ -41,12 +42,12 @@ class XMLExporter(JSONExporter):
"""Returns an XML representation of an entire journal.""" """Returns an XML representation of an entire journal."""
tags = get_tags_count(journal) tags = get_tags_count(journal)
doc = minidom.Document() doc = minidom.Document()
xml = doc.createElement('journal') xml = doc.createElement("journal")
tags_el = doc.createElement('tags') tags_el = doc.createElement("tags")
entries_el = doc.createElement('entries') entries_el = doc.createElement("entries")
for count, tag in tags: for count, tag in tags:
tag_el = doc.createElement('tag') tag_el = doc.createElement("tag")
tag_el.setAttribute('name', tag) tag_el.setAttribute("name", tag)
count_node = doc.createTextNode(str(count)) count_node = doc.createTextNode(str(count))
tag_el.appendChild(count_node) tag_el.appendChild(count_node)
tags_el.appendChild(tag_el) tags_el.appendChild(tag_el)

View file

@ -10,6 +10,7 @@ from ..util import WARNING_COLOR, ERROR_COLOR, RESET_COLOR
class YAMLExporter(TextExporter): class YAMLExporter(TextExporter):
"""This Exporter can convert entries and journals into Markdown formatted text with YAML front matter.""" """This Exporter can convert entries and journals into Markdown formatted text with YAML front matter."""
names = ["yaml"] names = ["yaml"]
extension = "md" extension = "md"
@ -17,68 +18,109 @@ class YAMLExporter(TextExporter):
def export_entry(cls, entry, to_multifile=True): def export_entry(cls, entry, to_multifile=True):
"""Returns a markdown representation of a single entry, with YAML front matter.""" """Returns a markdown representation of a single entry, with YAML front matter."""
if to_multifile is False: if to_multifile is False:
print("{}ERROR{}: YAML export must be to individual files. " print(
"Please specify a directory to export to.".format("\033[31m", "\033[0m"), file=sys.stderr) "{}ERROR{}: YAML export must be to individual files. Please \
specify a directory to export to.".format(
ERROR_COLOR, RESET_COLOR, file=sys.stderr
)
)
return return
date_str = entry.date.strftime(entry.journal.config['timeformat']) date_str = entry.date.strftime(entry.journal.config["timeformat"])
body_wrapper = "\n" if entry.body else "" body_wrapper = "\n" if entry.body else ""
body = body_wrapper + entry.body body = body_wrapper + entry.body
tagsymbols = entry.journal.config['tagsymbols'] tagsymbols = entry.journal.config["tagsymbols"]
# see also Entry.Entry.rag_regex # see also Entry.Entry.rag_regex
multi_tag_regex = re.compile(r'(?u)^\s*([{tags}][-+*#/\w]+\s*)+$'.format(tags=tagsymbols)) multi_tag_regex = re.compile(fr"(?u)^\s*([{tagsymbols}][-+*#/\w]+\s*)+$")
'''Increase heading levels in body text''' """Increase heading levels in body text"""
newbody = '' newbody = ""
heading = '#' heading = "#"
previous_line = '' previous_line = ""
warn_on_heading_level = False warn_on_heading_level = False
for line in entry.body.splitlines(True): for line in body.splitlines(True):
if re.match(r"^#+ ", line): if re.match(r"^#+ ", line):
"""ATX style headings""" """ATX style headings"""
newbody = newbody + previous_line + heading + line newbody = newbody + previous_line + heading + line
if re.match(r"^#######+ ", heading + line): if re.match(r"^#######+ ", heading + line):
warn_on_heading_level = True warn_on_heading_level = True
line = '' line = ""
elif re.match(r"^=+$", line.rstrip()) and not re.match(r"^$", previous_line.strip()): elif re.match(r"^=+$", line.rstrip()) and not re.match(
r"^$", previous_line.strip()
):
"""Setext style H1""" """Setext style H1"""
newbody = newbody + heading + "# " + previous_line newbody = newbody + heading + "# " + previous_line
line = '' line = ""
elif re.match(r"^-+$", line.rstrip()) and not re.match(r"^$", previous_line.strip()): elif re.match(r"^-+$", line.rstrip()) and not re.match(
r"^$", previous_line.strip()
):
"""Setext style H2""" """Setext style H2"""
newbody = newbody + heading + "## " + previous_line newbody = newbody + heading + "## " + previous_line
line = '' line = ""
elif multi_tag_regex.match(line): elif multi_tag_regex.match(line):
"""Tag only lines""" """Tag only lines"""
line = '' line = ""
else: else:
newbody = newbody + previous_line newbody = newbody + previous_line
previous_line = line previous_line = line
newbody = newbody + previous_line # add very last line newbody = newbody + previous_line # add very last line
if warn_on_heading_level is True: if warn_on_heading_level is True:
print("{}WARNING{}: Headings increased past H6 on export - {} {}".format(WARNING_COLOR, RESET_COLOR, date_str, entry.title), file=sys.stderr) print(
"{}WARNING{}: Headings increased past H6 on export - {} {}".format(
WARNING_COLOR, RESET_COLOR, date_str, entry.title
),
file=sys.stderr,
)
dayone_attributes = '' dayone_attributes = ""
if hasattr(entry, "uuid"): if hasattr(entry, "uuid"):
dayone_attributes += 'uuid: ' + entry.uuid + '\n' dayone_attributes += "uuid: " + entry.uuid + "\n"
# TODO: copy over pictures, if present if (
# source directory is entry.journal.config['journal'] hasattr(entry, "creator_device_agent")
# output directory is...? or hasattr(entry, "creator_generation_date")
or hasattr(entry, "creator_host_name")
or hasattr(entry, "creator_os_agent")
or hasattr(entry, "creator_software_agent")
):
dayone_attributes += "creator:\n"
if hasattr(entry, "creator_device_agent"):
dayone_attributes += f" device agent: {entry.creator_device_agent}\n"
if hasattr(entry, "creator_generation_date"):
dayone_attributes += " generation date: {}\n".format(
str(entry.creator_generation_date)
)
if hasattr(entry, "creator_host_name"):
dayone_attributes += f" host name: {entry.creator_host_name}\n"
if hasattr(entry, "creator_os_agent"):
dayone_attributes += f" os agent: {entry.creator_os_agent}\n"
if hasattr(entry, "creator_software_agent"):
dayone_attributes += (
f" software agent: {entry.creator_software_agent}\n"
)
# TODO: copy over pictures, if present
# source directory is entry.journal.config['journal']
# output directory is...?
return "title: {title}\ndate: {date}\nstared: {stared}\ntags: {tags}\n{dayone} {body} {space}".format( return "title: {title}\ndate: {date}\nstared: {stared}\ntags: {tags}\n{dayone} {body} {space}".format(
date = date_str, date=date_str,
title = entry.title, title=entry.title,
stared = entry.starred, stared=entry.starred,
tags = ', '.join([tag[1:] for tag in entry.tags]), tags=", ".join([tag[1:] for tag in entry.tags]),
dayone = dayone_attributes, dayone=dayone_attributes,
body = newbody, body=newbody,
space="" space="",
) )
@classmethod @classmethod
def export_journal(cls, journal): def export_journal(cls, journal):
"""Returns an error, as YAML export requires a directory as a target.""" """Returns an error, as YAML export requires a directory as a target."""
print("{}ERROR{}: YAML export must be to individual files. Please specify a directory to export to.".format(ERROR_COLOR, RESET_COLOR), file=sys.stderr) print(
"{}ERROR{}: YAML export must be to individual files. Please specify a directory to export to.".format(
ERROR_COLOR, RESET_COLOR
),
file=sys.stderr,
)
return return

View file

@ -1,7 +1,10 @@
from datetime import datetime from datetime import datetime
from dateutil.parser import parse as dateparse from dateutil.parser import parse as dateparse
try: import parsedatetime.parsedatetime_consts as pdt
except ImportError: import parsedatetime as pdt try:
import parsedatetime.parsedatetime_consts as pdt
except ImportError:
import parsedatetime as pdt
FAKE_YEAR = 9999 FAKE_YEAR = 9999
DEFAULT_FUTURE = datetime(FAKE_YEAR, 12, 31, 23, 59, 59) DEFAULT_FUTURE = datetime(FAKE_YEAR, 12, 31, 23, 59, 59)
@ -12,14 +15,16 @@ consts.DOWParseStyle = -1 # "Monday" will be either today or the last Monday
CALENDAR = pdt.Calendar(consts) CALENDAR = pdt.Calendar(consts)
def parse(date_str, inclusive=False, default_hour=None, default_minute=None, bracketed=False): def parse(
date_str, inclusive=False, default_hour=None, default_minute=None, bracketed=False
):
"""Parses a string containing a fuzzy date and returns a datetime.datetime object""" """Parses a string containing a fuzzy date and returns a datetime.datetime object"""
if not date_str: if not date_str:
return None return None
elif isinstance(date_str, datetime): elif isinstance(date_str, datetime):
return date_str return date_str
# Don't try to parse anything with 6 or less characters and was parsed from the existing journal. # Don't try to parse anything with 6 or less characters and was parsed from the existing journal.
# It's probably a markdown footnote # It's probably a markdown footnote
if len(date_str) <= 6 and bracketed: if len(date_str) <= 6 and bracketed:
return None return None
@ -37,7 +42,7 @@ def parse(date_str, inclusive=False, default_hour=None, default_minute=None, bra
flag = 1 if date.hour == date.minute == 0 else 2 flag = 1 if date.hour == date.minute == 0 else 2
date = date.timetuple() date = date.timetuple()
except Exception as e: except Exception as e:
if e.args[0] == 'day is out of range for month': if e.args[0] == "day is out of range for month":
y, m, d, H, M, S = default_date.timetuple()[:6] y, m, d, H, M, S = default_date.timetuple()[:6]
default_date = datetime(y, m, d - 1, H, M, S) default_date = datetime(y, m, d - 1, H, M, S)
else: else:
@ -53,10 +58,12 @@ def parse(date_str, inclusive=False, default_hour=None, default_minute=None, bra
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], date = datetime(
hour=23 if inclusive else default_hour or 0, *date[:3],
minute=59 if inclusive else default_minute or 0, hour=23 if inclusive else default_hour or 0,
second=59 if inclusive else 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])

View file

@ -11,9 +11,9 @@ import os
def backup(filename, binary=False): def backup(filename, binary=False):
print(f" Created a backup at {filename}.backup", file=sys.stderr) print(f" Created a backup at {filename}.backup", file=sys.stderr)
filename = os.path.expanduser(os.path.expandvars(filename)) filename = os.path.expanduser(os.path.expandvars(filename))
with open(filename, 'rb' if binary else 'r') as original: with open(filename, "rb" if binary else "r") as original:
contents = original.read() contents = original.read()
with open(filename + ".backup", 'wb' if binary else 'w') as backup: with open(filename + ".backup", "wb" if binary else "w") as backup:
backup.write(contents) backup.write(contents)
@ -25,7 +25,8 @@ def upgrade_jrnl_if_necessary(config_path):
config = util.load_config(config_path) config = util.load_config(config_path)
print("""Welcome to jrnl {}. print(
f"""Welcome to jrnl {__version__}.
It looks like you've been using an older version of jrnl until now. That's It looks like you've been using an older version of jrnl until now. That's
okay - jrnl will now upgrade your configuration and journal files. Afterwards okay - jrnl will now upgrade your configuration and journal files. Afterwards
@ -39,18 +40,20 @@ you can enjoy all of the great new features that come with jrnl 2:
Please note that jrnl 1.x is NOT forward compatible with this version of jrnl. 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 If you choose to proceed, you will not be able to use your journals with
older versions of jrnl anymore. older versions of jrnl anymore.
""".format(__version__)) """
)
encrypted_journals = {} encrypted_journals = {}
plain_journals = {} plain_journals = {}
other_journals = {} other_journals = {}
all_journals = [] all_journals = []
for journal_name, journal_conf in config['journals'].items(): for journal_name, journal_conf in config["journals"].items():
if isinstance(journal_conf, dict): if isinstance(journal_conf, dict):
path = journal_conf.get("journal") path = journal_conf.get("journal")
encrypt = journal_conf.get("encrypt") encrypt = journal_conf.get("encrypt")
else: else:
encrypt = config.get('encrypt') encrypt = config.get("encrypt")
path = journal_conf path = journal_conf
path = os.path.expanduser(path) path = os.path.expanduser(path)
@ -62,21 +65,36 @@ older versions of jrnl anymore.
else: else:
plain_journals[journal_name] = path plain_journals[journal_name] = path
longest_journal_name = max([len(journal) for journal in config['journals']]) longest_journal_name = max([len(journal) for journal in config["journals"]])
if encrypted_journals: if encrypted_journals:
print(f"\nFollowing encrypted journals will be upgraded to jrnl {__version__}:", file=sys.stderr) print(
f"\nFollowing encrypted journals will be upgraded to jrnl {__version__}:",
file=sys.stderr,
)
for journal, path in encrypted_journals.items(): for journal, path in encrypted_journals.items():
print(" {:{pad}} -> {}".format(journal, path, pad=longest_journal_name), file=sys.stderr) print(
" {:{pad}} -> {}".format(journal, path, pad=longest_journal_name),
file=sys.stderr,
)
if plain_journals: if plain_journals:
print(f"\nFollowing plain text journals will upgraded to jrnl {__version__}:", file=sys.stderr) print(
f"\nFollowing plain text journals will upgraded to jrnl {__version__}:",
file=sys.stderr,
)
for journal, path in plain_journals.items(): for journal, path in plain_journals.items():
print(" {:{pad}} -> {}".format(journal, path, pad=longest_journal_name), file=sys.stderr) print(
" {:{pad}} -> {}".format(journal, path, pad=longest_journal_name),
file=sys.stderr,
)
if other_journals: if other_journals:
print("\nFollowing journals will be not be touched:", file=sys.stderr) print("\nFollowing journals will be not be touched:", file=sys.stderr)
for journal, path in other_journals.items(): for journal, path in other_journals.items():
print(" {:{pad}} -> {}".format(journal, path, pad=longest_journal_name), file=sys.stderr) print(
" {:{pad}} -> {}".format(journal, path, pad=longest_journal_name),
file=sys.stderr,
)
try: try:
cont = util.yesno("\nContinue upgrading jrnl?", default=False) cont = util.yesno("\nContinue upgrading jrnl?", default=False)
@ -86,24 +104,37 @@ older versions of jrnl anymore.
raise UserAbort("jrnl NOT upgraded, exiting.") raise UserAbort("jrnl NOT upgraded, exiting.")
for journal_name, path in encrypted_journals.items(): for journal_name, path in encrypted_journals.items():
print(f"\nUpgrading encrypted '{journal_name}' journal stored in {path}...", file=sys.stderr) print(
f"\nUpgrading encrypted '{journal_name}' journal stored in {path}...",
file=sys.stderr,
)
backup(path, binary=True) backup(path, binary=True)
old_journal = Journal.open_journal(journal_name, util.scope_config(config, journal_name), legacy=True) old_journal = Journal.open_journal(
journal_name, util.scope_config(config, journal_name), legacy=True
)
all_journals.append(EncryptedJournal.from_journal(old_journal)) all_journals.append(EncryptedJournal.from_journal(old_journal))
for journal_name, path in plain_journals.items(): for journal_name, path in plain_journals.items():
print(f"\nUpgrading plain text '{journal_name}' journal stored in {path}...", file=sys.stderr) print(
f"\nUpgrading plain text '{journal_name}' journal stored in {path}...",
file=sys.stderr,
)
backup(path) backup(path)
old_journal = Journal.open_journal(journal_name, util.scope_config(config, journal_name), legacy=True) old_journal = Journal.open_journal(
journal_name, util.scope_config(config, journal_name), legacy=True
)
all_journals.append(Journal.PlainJournal.from_journal(old_journal)) all_journals.append(Journal.PlainJournal.from_journal(old_journal))
# loop through lists to validate # loop through lists to validate
failed_journals = [j for j in all_journals if not j.validate_parsing()] failed_journals = [j for j in all_journals if not j.validate_parsing()]
if len(failed_journals) > 0: if len(failed_journals) > 0:
print("\nThe following journal{} failed to upgrade:\n{}".format( print(
's' if len(failed_journals) > 1 else '', "\n".join(j.name for j in failed_journals)), "\nThe following journal{} failed to upgrade:\n{}".format(
file=sys.stderr "s" if len(failed_journals) > 1 else "",
"\n".join(j.name for j in failed_journals),
),
file=sys.stderr,
) )
raise UpgradeValidationException raise UpgradeValidationException
@ -120,4 +151,5 @@ older versions of jrnl anymore.
class UpgradeValidationException(Exception): class UpgradeValidationException(Exception):
"""Raised when the contents of an upgraded journal do not match the old journal""" """Raised when the contents of an upgraded journal do not match the old journal"""
pass pass

View file

@ -4,8 +4,10 @@ import sys
import os import os
import getpass as gp import getpass as gp
import yaml import yaml
if "win32" in sys.platform: if "win32" in sys.platform:
import colorama import colorama
colorama.init() colorama.init()
import re import re
import tempfile import tempfile
@ -13,6 +15,7 @@ import subprocess
import unicodedata import unicodedata
import shlex import shlex
import logging import logging
from typing import Optional, Callable
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -22,7 +25,8 @@ RESET_COLOR = "\033[0m"
# Based on Segtok by Florian Leitner # Based on Segtok by Florian Leitner
# https://github.com/fnl/segtok # https://github.com/fnl/segtok
SENTENCE_SPLITTER = re.compile(r""" SENTENCE_SPLITTER = re.compile(
r"""
( # A sentence ends at one of two sequences: ( # A sentence ends at one of two sequences:
[.!?\u203C\u203D\u2047\u2048\u2049\u3002\uFE52\uFE57\uFF01\uFF0E\uFF1F\uFF61] # Either, a sequence starting with a sentence terminal, [.!?\u203C\u203D\u2047\u2048\u2049\u3002\uFE52\uFE57\uFF01\uFF0E\uFF1F\uFF61] # Either, a sequence starting with a sentence terminal,
[\'\u2019\"\u201D]? # an optional right quote, [\'\u2019\"\u201D]? # an optional right quote,
@ -30,20 +34,44 @@ SENTENCE_SPLITTER = re.compile(r"""
\s+ # a sequence of required spaces. \s+ # a sequence of required spaces.
| # Otherwise, | # Otherwise,
\n # a sentence also terminates newlines. \n # a sentence also terminates newlines.
)""", re.VERBOSE) )""",
re.VERBOSE,
)
class UserAbort(Exception): class UserAbort(Exception):
pass pass
getpass = gp.getpass def create_password(
journal_name: str, prompt: str = "Enter password for new journal: "
) -> str:
while True:
pw = gp.getpass(prompt)
if not pw:
print("Password can't be an empty string!", file=sys.stderr)
continue
elif pw == gp.getpass("Enter password again: "):
break
print("Passwords did not match, please try again", file=sys.stderr)
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
def get_password(validator, keychain=None, max_attempts=3): def decrypt_content(
decrypt_func: Callable[[str], Optional[str]],
keychain: str = None,
max_attempts: int = 3,
) -> str:
pwd_from_keychain = keychain and get_keychain(keychain) pwd_from_keychain = keychain and get_keychain(keychain)
password = pwd_from_keychain or getpass() password = pwd_from_keychain or gp.getpass()
result = validator(password) result = decrypt_func(password)
# Password is bad: # Password is bad:
if result is None and pwd_from_keychain: if result is None and pwd_from_keychain:
set_keychain(keychain, None) set_keychain(keychain, None)
@ -51,7 +79,7 @@ def get_password(validator, keychain=None, max_attempts=3):
while result is None and attempt < max_attempts: while result is None and attempt < max_attempts:
print("Wrong password, try again.", file=sys.stderr) print("Wrong password, try again.", file=sys.stderr)
password = gp.getpass() password = gp.getpass()
result = validator(password) result = decrypt_func(password)
attempt += 1 attempt += 1
if result is not None: if result is not None:
return result return result
@ -62,21 +90,23 @@ def get_password(validator, keychain=None, max_attempts=3):
def get_keychain(journal_name): def get_keychain(journal_name):
import keyring import keyring
try: try:
return keyring.get_password('jrnl', journal_name) return keyring.get_password("jrnl", journal_name)
except RuntimeError: except RuntimeError:
return "" return ""
def set_keychain(journal_name, password): def set_keychain(journal_name, password):
import keyring import keyring
if password is None: if password is None:
try: try:
keyring.delete_password('jrnl', journal_name) keyring.delete_password("jrnl", journal_name)
except RuntimeError: except keyring.errors.PasswordDeleteError:
pass pass
else: else:
keyring.set_password('jrnl', journal_name, password) keyring.set_password("jrnl", journal_name, password)
def yesno(prompt, default=True): def yesno(prompt, default=True):
@ -93,34 +123,45 @@ def load_config(config_path):
def scope_config(config, journal_name): def scope_config(config, journal_name):
if journal_name not in config['journals']: if journal_name not in config["journals"]:
return config return config
config = config.copy() config = config.copy()
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 (
log.debug('Updating configuration with specific journal overrides %s', journal_conf) 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) 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
config.pop('journals') config.pop("journals")
return config return config
def get_text_from_editor(config, template=""): def get_text_from_editor(config, template=""):
filehandle, tmpfile = tempfile.mkstemp(prefix="jrnl", text=True, suffix=".txt") filehandle, tmpfile = tempfile.mkstemp(prefix="jrnl", text=True, suffix=".txt")
with open(tmpfile, 'w', encoding="utf-8") as f: os.close(filehandle)
with open(tmpfile, "w", encoding="utf-8") as f:
if template: if template:
f.write(template) f.write(template)
try: try:
subprocess.call(shlex.split(config['editor'], posix="win" not in sys.platform) + [tmpfile]) subprocess.call(
shlex.split(config["editor"], posix="win" not in sys.platform) + [tmpfile]
)
except AttributeError: except AttributeError:
subprocess.call(config['editor'] + [tmpfile]) subprocess.call(config["editor"] + [tmpfile])
with open(tmpfile, "r", encoding="utf-8") as f: with open(tmpfile, "r", encoding="utf-8") as f:
raw = f.read() raw = f.read()
os.close(filehandle)
os.remove(tmpfile) os.remove(tmpfile)
if not raw: if not raw:
print('[Nothing saved to file]', file=sys.stderr) print("[Nothing saved to file]", file=sys.stderr)
return raw return raw
@ -133,9 +174,9 @@ def slugify(string):
"""Slugifies a string. """Slugifies a string.
Based on public domain code from https://github.com/zacharyvoase/slugify Based on public domain code from https://github.com/zacharyvoase/slugify
""" """
normalized_string = str(unicodedata.normalize('NFKD', string)) normalized_string = str(unicodedata.normalize("NFKD", string))
no_punctuation = re.sub(r'[^\w\s-]', '', normalized_string).strip().lower() no_punctuation = re.sub(r"[^\w\s-]", "", normalized_string).strip().lower()
slug = re.sub(r'[-\s]+', '-', no_punctuation) slug = re.sub(r"[-\s]+", "-", no_punctuation)
return slug return slug
@ -144,4 +185,4 @@ def split_title(text):
punkt = SENTENCE_SPLITTER.search(text) punkt = SENTENCE_SPLITTER.search(text)
if not punkt: if not punkt:
return text, "" return text, ""
return text[:punkt.end()].strip(), text[punkt.end():].strip() return text[: punkt.end()].strip(), text[punkt.end() :].strip()

View file

@ -12,7 +12,7 @@ markdown_extensions:
- admonition - admonition
repo_url: https://github.com/jrnl-org/jrnl/ repo_url: https://github.com/jrnl-org/jrnl/
site_author: Manuel Ebert site_author: Manuel Ebert
site_description: Never Worry about Money Again. site_description: Collect your thoughts and notes without leaving the command line.
nav: nav:
- Overview: overview.md - Overview: overview.md
- Quickstart: installation.md - Quickstart: installation.md

180
poetry.lock generated
View file

@ -6,25 +6,13 @@ optional = false
python-versions = "*" python-versions = "*"
version = "1.4.3" version = "1.4.3"
[[package]]
category = "main"
description = "Fast ASN.1 parser and serializer with definitions for private keys, public keys, certificates, CRL, OCSP, CMS, PKCS#3, PKCS#7, PKCS#8, PKCS#12, PKCS#5, X.509 and TSP"
name = "asn1crypto"
optional = false
python-versions = "*"
version = "0.24.0"
[[package]] [[package]]
category = "main" category = "main"
description = "Safe, minimalistic evaluator of python expression using ast module" description = "Safe, minimalistic evaluator of python expression using ast module"
name = "asteval" name = "asteval"
optional = false optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" python-versions = ">=3.5"
version = "0.9.14" version = "0.9.18"
[package.dependencies]
numpy = "*"
six = "*"
[[package]] [[package]]
category = "dev" category = "dev"
@ -32,7 +20,7 @@ description = "Classes Without Boilerplate"
name = "attrs" name = "attrs"
optional = false optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
version = "19.1.0" version = "19.3.0"
[[package]] [[package]]
category = "dev" category = "dev"
@ -53,13 +41,16 @@ description = "The uncompromising code formatter."
name = "black" name = "black"
optional = false optional = false
python-versions = ">=3.6" python-versions = ">=3.6"
version = "18.9b0" version = "19.10b0"
[package.dependencies] [package.dependencies]
appdirs = "*" appdirs = "*"
attrs = ">=17.4.0" attrs = ">=18.1.0"
click = ">=6.5" click = ">=6.5"
pathspec = ">=0.6,<1"
regex = "*"
toml = ">=0.9.4" toml = ">=0.9.4"
typed-ast = ">=1.4.0"
[[package]] [[package]]
category = "main" category = "main"
@ -67,7 +58,7 @@ description = "Foreign Function Interface for Python calling C code."
name = "cffi" name = "cffi"
optional = false optional = false
python-versions = "*" python-versions = "*"
version = "1.12.3" version = "1.13.2"
[package.dependencies] [package.dependencies]
pycparser = "*" pycparser = "*"
@ -86,8 +77,8 @@ description = "Cross-platform colored terminal text."
marker = "sys_platform == \"win32\"" marker = "sys_platform == \"win32\""
name = "colorama" name = "colorama"
optional = false optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
version = "0.4.1" version = "0.4.3"
[[package]] [[package]]
category = "main" category = "main"
@ -95,15 +86,14 @@ description = "cryptography is a package which provides cryptographic recipes an
name = "cryptography" name = "cryptography"
optional = false optional = false
python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*"
version = "2.7" version = "2.8"
[package.dependencies] [package.dependencies]
asn1crypto = ">=0.21.0"
cffi = ">=1.8,<1.11.3 || >1.11.3" cffi = ">=1.8,<1.11.3 || >1.11.3"
six = ">=1.4.1" six = ">=1.4.1"
[[package]] [[package]]
category = "main" category = "dev"
description = "Discover and load entry points from installed packages." description = "Discover and load entry points from installed packages."
name = "entrypoints" name = "entrypoints"
optional = false optional = false
@ -116,7 +106,7 @@ description = "the modular source code checker: pep8, pyflakes and co"
name = "flake8" name = "flake8"
optional = false optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
version = "3.7.8" version = "3.7.9"
[package.dependencies] [package.dependencies]
entrypoints = ">=0.3.0,<0.4.0" entrypoints = ">=0.3.0,<0.4.0"
@ -126,11 +116,15 @@ pyflakes = ">=2.1.0,<2.2.0"
[[package]] [[package]]
category = "main" category = "main"
description = "Clean single-source support for Python 3 and 2" description = "Read metadata from Python packages"
name = "future" marker = "python_version < \"3.8\""
name = "importlib-metadata"
optional = false optional = false
python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
version = "0.17.1" version = "1.3.0"
[package.dependencies]
zipp = ">=0.5"
[[package]] [[package]]
category = "main" category = "main"
@ -139,15 +133,15 @@ marker = "sys_platform == \"linux\""
name = "jeepney" name = "jeepney"
optional = false optional = false
python-versions = ">=3.5" python-versions = ">=3.5"
version = "0.4" version = "0.4.2"
[[package]] [[package]]
category = "dev" category = "dev"
description = "A small but fast and easy to use stand-alone template engine written in pure python." description = "A very fast and expressive template engine."
name = "jinja2" name = "jinja2"
optional = false optional = false
python-versions = "*" python-versions = "*"
version = "2.10.1" version = "2.10.3"
[package.dependencies] [package.dependencies]
MarkupSafe = ">=0.23" MarkupSafe = ">=0.23"
@ -158,13 +152,16 @@ description = "Store and access your passwords safely."
name = "keyring" name = "keyring"
optional = false optional = false
python-versions = ">=3.5" python-versions = ">=3.5"
version = "19.0.2" version = "19.3.0"
[package.dependencies] [package.dependencies]
entrypoints = "*"
pywin32-ctypes = "<0.1.0 || >0.1.0,<0.1.1 || >0.1.1" pywin32-ctypes = "<0.1.0 || >0.1.0,<0.1.1 || >0.1.1"
secretstorage = "*" secretstorage = "*"
[package.dependencies.importlib-metadata]
python = "<3.8"
version = "*"
[[package]] [[package]]
category = "dev" category = "dev"
description = "Python LiveReload is an awesome tool for web developers" description = "Python LiveReload is an awesome tool for web developers"
@ -222,11 +219,12 @@ tornado = ">=5.0"
[[package]] [[package]]
category = "main" category = "main"
description = "NumPy is the fundamental package for array computing with Python." description = "More routines for operating on iterables, beyond itertools"
name = "numpy" marker = "python_version < \"3.8\""
name = "more-itertools"
optional = false optional = false
python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" python-versions = ">=3.5"
version = "1.16.4" version = "8.0.2"
[[package]] [[package]]
category = "dev" category = "dev"
@ -234,7 +232,7 @@ description = "parse() is the opposite of format()"
name = "parse" name = "parse"
optional = false optional = false
python-versions = "*" python-versions = "*"
version = "1.12.0" version = "1.14.0"
[[package]] [[package]]
category = "dev" category = "dev"
@ -242,10 +240,10 @@ description = "Simplifies to build parse types based on the parse module"
name = "parse-type" name = "parse-type"
optional = false optional = false
python-versions = ">=2.6, !=3.0.*, !=3.1.*" python-versions = ">=2.6, !=3.0.*, !=3.1.*"
version = "0.4.2" version = "0.5.2"
[package.dependencies] [package.dependencies]
parse = ">=1.8" parse = ">=1.8.4"
six = ">=1.11" six = ">=1.11"
[[package]] [[package]]
@ -254,10 +252,7 @@ description = "Parse human-readable date/time text."
name = "parsedatetime" name = "parsedatetime"
optional = false optional = false
python-versions = "*" python-versions = "*"
version = "2.4" version = "2.5"
[package.dependencies]
future = "*"
[[package]] [[package]]
category = "main" category = "main"
@ -265,7 +260,15 @@ description = "comprehensive password hashing framework supporting over 30 schem
name = "passlib" name = "passlib"
optional = false optional = false
python-versions = "*" python-versions = "*"
version = "1.7.1" version = "1.7.2"
[[package]]
category = "dev"
description = "Utility library for gitignore style pattern matching of file paths."
name = "pathspec"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
version = "0.7.0"
[[package]] [[package]]
category = "dev" category = "dev"
@ -296,8 +299,8 @@ category = "main"
description = "Extensions to the standard Python datetime module" description = "Extensions to the standard Python datetime module"
name = "python-dateutil" name = "python-dateutil"
optional = false optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
version = "2.8.0" version = "2.8.1"
[package.dependencies] [package.dependencies]
six = ">=1.5" six = ">=1.5"
@ -308,7 +311,7 @@ description = "World timezone definitions, modern and historical"
name = "pytz" name = "pytz"
optional = false optional = false
python-versions = "*" python-versions = "*"
version = "2019.1" version = "2019.3"
[[package]] [[package]]
category = "main" category = "main"
@ -333,7 +336,15 @@ description = "YAML parser and emitter for Python"
name = "pyyaml" name = "pyyaml"
optional = false optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
version = "5.1.2" version = "5.2"
[[package]]
category = "dev"
description = "Alternative regular expression module, to replace re."
name = "regex"
optional = false
python-versions = "*"
version = "2019.12.20"
[[package]] [[package]]
category = "main" category = "main"
@ -354,7 +365,7 @@ description = "Python 2 and 3 compatibility utilities"
name = "six" name = "six"
optional = false optional = false
python-versions = ">=2.6, !=3.0.*, !=3.1.*" python-versions = ">=2.6, !=3.0.*, !=3.1.*"
version = "1.12.0" version = "1.13.0"
[[package]] [[package]]
category = "dev" category = "dev"
@ -372,6 +383,14 @@ optional = false
python-versions = ">= 3.5" python-versions = ">= 3.5"
version = "6.0.3" version = "6.0.3"
[[package]]
category = "dev"
description = "a fork of Python 2 and 3 ast modules with type comment support"
name = "typed-ast"
optional = false
python-versions = "*"
version = "1.4.0"
[[package]] [[package]]
category = "main" category = "main"
description = "tzinfo object for the local timezone" description = "tzinfo object for the local timezone"
@ -383,47 +402,62 @@ version = "1.5.1"
[package.dependencies] [package.dependencies]
pytz = "*" pytz = "*"
[[package]]
category = "main"
description = "Backport of pathlib-compatible object wrapper for zip files"
marker = "python_version < \"3.8\""
name = "zipp"
optional = false
python-versions = ">=2.7"
version = "0.6.0"
[package.dependencies]
more-itertools = "*"
[metadata] [metadata]
content-hash = "9896cf59c7552b6ad95219ee5555c7445a3fab39c2e4f4c6f3d991a36635e44b" content-hash = "98e23837423d5d8621f14cbe592d209ef98e1926b7a3f94e0f88bb6be908aae8"
python-versions = ">=3.6.0, <3.8.0" python-versions = ">=3.6.0, <3.9.0"
[metadata.hashes] [metadata.hashes]
appdirs = ["9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92", "d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e"] appdirs = ["9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92", "d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e"]
asn1crypto = ["2f1adbb7546ed199e3c90ef23ec95c5cf3585bac7d11fb7eb562a3fe89c64e87", "9d5c20441baf0cb60a4ac34cc447c6c189024b6b4c6cd7877034f4965c464e49"] asteval = ["5d64e18b8a72c2c7ae8f9b70d1f80b68bbcaa98c1c0d7047c35489d03209bc86"]
asteval = ["7c81fee6707a7a28e8beae891b858535a7e61f9ce275a0a4cf5f428fbc934cb8"] attrs = ["08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", "f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"]
attrs = ["69c0dbf2ed392de1cb5ec704444b08a5ef81680a61cb899dc08127123af36a79", "f0b870f674851ecbfbbbd364d6b5cbdff9dcedbc7f3f5e18a6891057f21fe399"]
behave = ["b9662327aa53294c1351b0a9c369093ccec1d21026f050c3bd9b3e5cccf81a86", "ebda1a6c9e5bfe95c5f9f0a2794e01c7098b3dde86c10a95d8621c5907ff6f1c"] behave = ["b9662327aa53294c1351b0a9c369093ccec1d21026f050c3bd9b3e5cccf81a86", "ebda1a6c9e5bfe95c5f9f0a2794e01c7098b3dde86c10a95d8621c5907ff6f1c"]
black = ["817243426042db1d36617910df579a54f1afd659adb96fc5032fcf4b36209739", "e030a9a28f542debc08acceb273f228ac422798e5215ba2a791a6ddeaaca22a5"] black = ["1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b", "c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539"]
cffi = ["041c81822e9f84b1d9c401182e174996f0bae9991f33725d059b771744290774", "046ef9a22f5d3eed06334d01b1e836977eeef500d9b78e9ef693f9380ad0b83d", "066bc4c7895c91812eff46f4b1c285220947d4aa46fa0a2651ff85f2afae9c90", "066c7ff148ae33040c01058662d6752fd73fbc8e64787229ea8498c7d7f4041b", "2444d0c61f03dcd26dbf7600cf64354376ee579acad77aef459e34efcb438c63", "300832850b8f7967e278870c5d51e3819b9aad8f0a2c8dbe39ab11f119237f45", "34c77afe85b6b9e967bd8154e3855e847b70ca42043db6ad17f26899a3df1b25", "46de5fa00f7ac09f020729148ff632819649b3e05a007d286242c4882f7b1dc3", "4aa8ee7ba27c472d429b980c51e714a24f47ca296d53f4d7868075b175866f4b", "4d0004eb4351e35ed950c14c11e734182591465a33e960a4ab5e8d4f04d72647", "4e3d3f31a1e202b0f5a35ba3bc4eb41e2fc2b11c1eff38b362de710bcffb5016", "50bec6d35e6b1aaeb17f7c4e2b9374ebf95a8975d57863546fa83e8d31bdb8c4", "55cad9a6df1e2a1d62063f79d0881a414a906a6962bc160ac968cc03ed3efcfb", "5662ad4e4e84f1eaa8efce5da695c5d2e229c563f9d5ce5b0113f71321bcf753", "59b4dc008f98fc6ee2bb4fd7fc786a8d70000d058c2bbe2698275bc53a8d3fa7", "73e1ffefe05e4ccd7bcea61af76f36077b914f92b76f95ccf00b0c1b9186f3f9", "a1f0fd46eba2d71ce1589f7e50a9e2ffaeb739fb2c11e8192aa2b45d5f6cc41f", "a2e85dc204556657661051ff4bab75a84e968669765c8a2cd425918699c3d0e8", "a5457d47dfff24882a21492e5815f891c0ca35fefae8aa742c6c263dac16ef1f", "a8dccd61d52a8dae4a825cdbb7735da530179fea472903eb871a5513b5abbfdc", "ae61af521ed676cf16ae94f30fe202781a38d7178b6b4ab622e4eec8cefaff42", "b012a5edb48288f77a63dba0840c92d0504aa215612da4541b7b42d849bc83a3", "d2c5cfa536227f57f97c92ac30c8109688ace8fa4ac086d19d0af47d134e2909", "d42b5796e20aacc9d15e66befb7a345454eef794fdb0737d1af593447c6c8f45", "dee54f5d30d775f525894d67b1495625dd9322945e7fee00731952e0368ff42d", "e070535507bd6aa07124258171be2ee8dfc19119c28ca94c9dfb7efd23564512", "e1ff2748c84d97b065cc95429814cdba39bcbd77c9c85c89344b317dc0d9cbff", "ed851c75d1e0e043cbf5ca9a8e1b13c4c90f3fbd863dacb01c0808e2b5204201"] cffi = ["0b49274afc941c626b605fb59b59c3485c17dc776dc3cc7cc14aca74cc19cc42", "0e3ea92942cb1168e38c05c1d56b0527ce31f1a370f6117f1d490b8dcd6b3a04", "135f69aecbf4517d5b3d6429207b2dff49c876be724ac0c8bf8e1ea99df3d7e5", "19db0cdd6e516f13329cba4903368bff9bb5a9331d3410b1b448daaadc495e54", "2781e9ad0e9d47173c0093321bb5435a9dfae0ed6a762aabafa13108f5f7b2ba", "291f7c42e21d72144bb1c1b2e825ec60f46d0a7468f5346841860454c7aa8f57", "2c5e309ec482556397cb21ede0350c5e82f0eb2621de04b2633588d118da4396", "2e9c80a8c3344a92cb04661115898a9129c074f7ab82011ef4b612f645939f12", "32a262e2b90ffcfdd97c7a5e24a6012a43c61f1f5a57789ad80af1d26c6acd97", "3c9fff570f13480b201e9ab69453108f6d98244a7f495e91b6c654a47486ba43", "415bdc7ca8c1c634a6d7163d43fb0ea885a07e9618a64bda407e04b04333b7db", "42194f54c11abc8583417a7cf4eaff544ce0de8187abaf5d29029c91b1725ad3", "4424e42199e86b21fc4db83bd76909a6fc2a2aefb352cb5414833c030f6ed71b", "4a43c91840bda5f55249413037b7a9b79c90b1184ed504883b72c4df70778579", "599a1e8ff057ac530c9ad1778293c665cb81a791421f46922d80a86473c13346", "5c4fae4e9cdd18c82ba3a134be256e98dc0596af1e7285a3d2602c97dcfa5159", "5ecfa867dea6fabe2a58f03ac9186ea64da1386af2159196da51c4904e11d652", "62f2578358d3a92e4ab2d830cd1c2049c9c0d0e6d3c58322993cc341bdeac22e", "6471a82d5abea994e38d2c2abc77164b4f7fbaaf80261cb98394d5793f11b12a", "6d4f18483d040e18546108eb13b1dfa1000a089bcf8529e30346116ea6240506", "71a608532ab3bd26223c8d841dde43f3516aa5d2bf37b50ac410bb5e99053e8f", "74a1d8c85fb6ff0b30fbfa8ad0ac23cd601a138f7509dc617ebc65ef305bb98d", "7b93a885bb13073afb0aa73ad82059a4c41f4b7d8eb8368980448b52d4c7dc2c", "7d4751da932caaec419d514eaa4215eaf14b612cff66398dd51129ac22680b20", "7f627141a26b551bdebbc4855c1157feeef18241b4b8366ed22a5c7d672ef858", "8169cf44dd8f9071b2b9248c35fc35e8677451c52f795daa2bb4643f32a540bc", "aa00d66c0fab27373ae44ae26a66a9e43ff2a678bf63a9c7c1a9a4d61172827a", "ccb032fda0873254380aa2bfad2582aedc2959186cce61e3a17abc1a55ff89c3", "d754f39e0d1603b5b24a7f8484b22d2904fa551fe865fd0d4c3332f078d20d4e", "d75c461e20e29afc0aee7172a0950157c704ff0dd51613506bd7d82b718e7410", "dcd65317dd15bc0451f3e01c80da2216a31916bdcffd6221ca1202d96584aa25", "e570d3ab32e2c2861c4ebe6ffcad6a8abf9347432a37608fe1fbd157b3f0036b", "fd43a88e045cf992ed09fa724b5315b790525f2676883a6ea64e3263bae6549d"]
click = ["2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13", "5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7"] click = ["2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13", "5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7"]
colorama = ["05eed71e2e327246ad6b38c540c4a3117230b19679b875190486ddd2d721422d", "f8ac84de7840f5b9c4e3347b3c1eaa50f7e49c2b07596221daec5edaabbd7c48"] colorama = ["7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff", "e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1"]
cryptography = ["24b61e5fcb506424d3ec4e18bca995833839bf13c59fc43e530e488f28d46b8c", "25dd1581a183e9e7a806fe0543f485103232f940fcfc301db65e630512cce643", "3452bba7c21c69f2df772762be0066c7ed5dc65df494a1d53a58b683a83e1216", "41a0be220dd1ed9e998f5891948306eb8c812b512dc398e5a01846d855050799", "5751d8a11b956fbfa314f6553d186b94aa70fdb03d8a4d4f1c82dcacf0cbe28a", "5f61c7d749048fa6e3322258b4263463bfccefecb0dd731b6561cb617a1d9bb9", "72e24c521fa2106f19623a3851e9f89ddfdeb9ac63871c7643790f872a305dfc", "7b97ae6ef5cba2e3bb14256625423413d5ce8d1abb91d4f29b6d1a081da765f8", "961e886d8a3590fd2c723cf07be14e2a91cf53c25f02435c04d39e90780e3b53", "96d8473848e984184b6728e2c9d391482008646276c3ff084a1bd89e15ff53a1", "ae536da50c7ad1e002c3eee101871d93abdc90d9c5f651818450a0d3af718609", "b0db0cecf396033abb4a93c95d1602f268b3a68bb0a9cc06a7cff587bb9a7292", "cfee9164954c186b191b91d4193989ca994703b2fff406f71cf454a2d3c7327e", "e6347742ac8f35ded4a46ff835c60e68c22a536a8ae5c4422966d06946b6d4c6", "f27d93f0139a3c056172ebb5d4f9056e770fdf0206c2f422ff2ebbad142e09ed", "f57b76e46a58b63d1c6375017f4564a28f19a5ca912691fd2e4261b3414b618d"] cryptography = ["02079a6addc7b5140ba0825f542c0869ff4df9a69c360e339ecead5baefa843c", "1df22371fbf2004c6f64e927668734070a8953362cd8370ddd336774d6743595", "369d2346db5934345787451504853ad9d342d7f721ae82d098083e1f49a582ad", "3cda1f0ed8747339bbdf71b9f38ca74c7b592f24f65cdb3ab3765e4b02871651", "44ff04138935882fef7c686878e1c8fd80a723161ad6a98da31e14b7553170c2", "4b1030728872c59687badcca1e225a9103440e467c17d6d1730ab3d2d64bfeff", "58363dbd966afb4f89b3b11dfb8ff200058fbc3b947507675c19ceb46104b48d", "6ec280fb24d27e3d97aa731e16207d58bd8ae94ef6eab97249a2afe4ba643d42", "7270a6c29199adc1297776937a05b59720e8a782531f1f122f2eb8467f9aab4d", "73fd30c57fa2d0a1d7a49c561c40c2f79c7d6c374cc7750e9ac7c99176f6428e", "7f09806ed4fbea8f51585231ba742b58cbcfbfe823ea197d8c89a5e433c7e912", "90df0cc93e1f8d2fba8365fb59a858f51a11a394d64dbf3ef844f783844cc793", "971221ed40f058f5662a604bd1ae6e4521d84e6cad0b7b170564cc34169c8f13", "a518c153a2b5ed6b8cc03f7ae79d5ffad7315ad4569b2d5333a13c38d64bd8d7", "b0de590a8b0979649ebeef8bb9f54394d3a41f66c5584fff4220901739b6b2f0", "b43f53f29816ba1db8525f006fa6f49292e9b029554b3eb56a189a70f2a40879", "d31402aad60ed889c7e57934a03477b572a03af7794fa8fb1780f21ea8f6551f", "de96157ec73458a7f14e3d26f17f8128c959084931e8997b9e655a39c8fde9f9", "df6b4dca2e11865e6cfbfb708e800efb18370f5a46fd601d3755bc7f85b3a8a2", "ecadccc7ba52193963c0475ac9f6fa28ac01e01349a2ca48509667ef41ffd2cf", "fb81c17e0ebe3358486cd8cc3ad78adbae58af12fc2bf2bc0bb84e8090fa5ce8"]
entrypoints = ["589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19", "c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451"] entrypoints = ["589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19", "c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451"]
flake8 = ["19241c1cbc971b9962473e4438a2ca19749a7dd002dd1a946eaba171b4114548", "8e9dfa3cecb2400b3738a42c54c3043e821682b9c840b0448c0503f781130696"] flake8 = ["45681a117ecc81e870cbf1262835ae4af5e7a8b08e40b944a8a6e6b895914cfb", "49356e766643ad15072a789a20915d3c91dc89fd313ccd71802303fd67e4deca"]
future = ["67045236dcfd6816dc439556d009594abf643e5eb48992e36beac09c2ca659b8"] importlib-metadata = ["073a852570f92da5f744a3472af1b61e28e9f78ccf0c9117658dc32b15de7b45", "d95141fbfa7ef2ec65cfd945e2af7e5a6ddbd7c8d9a25e66ff3be8e3daf9f60f"]
jeepney = ["6089412a5de162c04747f0220f6b2223b8ba660acd041e52a76426ca550e3c70", "f6f8b1428403b4afad04b6b82f9ab9fc426c253d7504c9031c41712a2c01dc74"] jeepney = ["0ba6d8c597e9bef1ebd18aaec595f942a264e25c1a48f164d46120eacaa2e9bb", "6f45dce1125cf6c58a1c88123d3831f36a789f9204fbad3172eac15f8ccd08d0"]
jinja2 = ["065c4f02ebe7f7cf559e49ee5a95fb800a9e4528727aec6f24402a5374c65013", "14dd6caf1527abb21f08f86c784eac40853ba93edb79552aa1e4b8aef1b61c7b"] jinja2 = ["74320bb91f31270f9551d46522e33af46a80c3d619f4a4bf42b3164d30b5911f", "9fe95f19286cfefaa917656583d020be14e7859c6b0252588391e47db34527de"]
keyring = ["1b74595f7439e4581a11d4f9a12790ac34addce64ca389c86272ff465f5e0b90", "afbfe7bc9bdba69d25c551b0c738adde533d87e0b51ad6bbe332cbea19ad8476"] keyring = ["9b80469783d3f6106bce1d389c6b8b20c8d4d739943b1b8cd0ddc2a45d065f9d", "ee3d35b7f1ac3cb69e9a1e4323534649d3ab2fea402738a77e4250c152970fed"]
livereload = ["78d55f2c268a8823ba499305dcac64e28ddeb9a92571e12d543cd304faf5817b", "89254f78d7529d7ea0a3417d224c34287ebfe266b05e67e51facaf82c27f0f66"] livereload = ["78d55f2c268a8823ba499305dcac64e28ddeb9a92571e12d543cd304faf5817b", "89254f78d7529d7ea0a3417d224c34287ebfe266b05e67e51facaf82c27f0f66"]
markdown = ["2e50876bcdd74517e7b71f3e7a76102050edec255b3983403f1a63e7c8a41e7a", "56a46ac655704b91e5b7e6326ce43d5ef72411376588afa1dd90e881b83c7e8c"] markdown = ["2e50876bcdd74517e7b71f3e7a76102050edec255b3983403f1a63e7c8a41e7a", "56a46ac655704b91e5b7e6326ce43d5ef72411376588afa1dd90e881b83c7e8c"]
markupsafe = ["00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473", "09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161", "09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235", "1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5", "24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff", "29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b", "43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1", "46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e", "500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183", "535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66", "62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1", "6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1", "717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e", "79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b", "7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905", "88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735", "8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d", "98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e", "9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d", "9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c", "ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21", "b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2", "b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5", "b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b", "ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6", "c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f", "cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f", "e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7"] markupsafe = ["00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473", "09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161", "09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235", "1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5", "24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff", "29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b", "43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1", "46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e", "500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183", "535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66", "62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1", "6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1", "717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e", "79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b", "7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905", "88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735", "8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d", "98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e", "9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d", "9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c", "ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21", "b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2", "b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5", "b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b", "ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6", "c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f", "cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f", "e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7"]
mccabe = ["ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", "dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"] mccabe = ["ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", "dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"]
mkdocs = ["17d34329aad75d5de604b9ed4e31df3a4d235afefdc46ce7b1964fddb2e1e939", "8cc8b38325456b9e942c981a209eaeb1e9f3f77b493ad755bfef889b9c8d356a"] mkdocs = ["17d34329aad75d5de604b9ed4e31df3a4d235afefdc46ce7b1964fddb2e1e939", "8cc8b38325456b9e942c981a209eaeb1e9f3f77b493ad755bfef889b9c8d356a"]
numpy = ["0778076e764e146d3078b17c24c4d89e0ecd4ac5401beff8e1c87879043a0633", "141c7102f20abe6cf0d54c4ced8d565b86df4d3077ba2343b61a6db996cefec7", "14270a1ee8917d11e7753fb54fc7ffd1934f4d529235beec0b275e2ccf00333b", "27e11c7a8ec9d5838bc59f809bfa86efc8a4fd02e58960fa9c49d998e14332d5", "2a04dda79606f3d2f760384c38ccd3d5b9bb79d4c8126b67aff5eb09a253763e", "3c26010c1b51e1224a3ca6b8df807de6e95128b0908c7e34f190e7775455b0ca", "52c40f1a4262c896420c6ea1c6fda62cf67070e3947e3307f5562bd783a90336", "6e4f8d9e8aa79321657079b9ac03f3cf3fd067bf31c1cca4f56d49543f4356a5", "7242be12a58fec245ee9734e625964b97cf7e3f2f7d016603f9e56660ce479c7", "7dc253b542bfd4b4eb88d9dbae4ca079e7bf2e2afd819ee18891a43db66c60c7", "94f5bd885f67bbb25c82d80184abbf7ce4f6c3c3a41fbaa4182f034bba803e69", "a89e188daa119ffa0d03ce5123dee3f8ffd5115c896c2a9d4f0dbb3d8b95bfa3", "ad3399da9b0ca36e2f24de72f67ab2854a62e623274607e37e0ce5f5d5fa9166", "b0348be89275fd1d4c44ffa39530c41a21062f52299b1e3ee7d1c61f060044b8", "b5554368e4ede1856121b0dfa35ce71768102e4aa55e526cb8de7f374ff78722", "cbddc56b2502d3f87fda4f98d948eb5b11f36ff3902e17cb6cc44727f2200525", "d79f18f41751725c56eceab2a886f021d70fd70a6188fd386e29a045945ffc10", "dc2ca26a19ab32dc475dbad9dfe723d3a64c835f4c23f625c2b6566ca32b9f29", "dd9bcd4f294eb0633bb33d1a74febdd2b9018b8b8ed325f861fffcd2c7660bb8", "e8baab1bc7c9152715844f1faca6744f2416929de10d7639ed49555a85549f52", "ec31fe12668af687b99acf1567399632a7c47b0e17cfb9ae47c098644ef36797", "f12b4f7e2d8f9da3141564e6737d79016fe5336cc92de6814eba579744f65b0a", "f58ac38d5ca045a377b3b377c84df8175ab992c970a53332fa8ac2373df44ff7"] more-itertools = ["b84b238cce0d9adad5ed87e745778d20a3f8487d0f0cb8b8a586816c7496458d", "c833ef592a0324bcc6a60e48440da07645063c453880c9477ceb22490aec1564"]
parse = ["1b68657434d371e5156048ca4a0c5aea5afc6ca59a2fea4dd1a575354f617142"] parse = ["95a4f4469e37c57b5e924629ac99926f28bee7da59515dc5b8078c4c3e779249"]
parse-type = ["6e906a66f340252e4c324914a60d417d33a4bea01292ea9bbf68b4fc123be8c9", "f596bdc75d3dd93036fbfe3d04127da9f6df0c26c36e01e76da85adef4336b3c"] parse-type = ["089a471b06327103865dfec2dd844230c3c658a4a1b5b4c8b6c16c8f77577f9e", "7f690b18d35048c15438d6d0571f9045cffbec5907e0b1ccf006f889e3a38c0b"]
parsedatetime = ["3d817c58fb9570d1eec1dd46fa9448cd644eeed4fb612684b02dfda3a79cb84b", "9ee3529454bf35c40a77115f5a596771e59e1aee8c53306f346c461b8e913094"] parsedatetime = ["3b835fc54e472c17ef447be37458b400e3fefdf14bb1ffdedb5d2c853acf4ba1", "d2e9ddb1e463de871d32088a3f3cea3dc8282b1b2800e081bd0ef86900451667"]
passlib = ["3d948f64138c25633613f303bcc471126eae67c04d5e3f6b7b8ce6242f8653e0", "43526aea08fa32c6b6dbbbe9963c4c767285b78147b7437597f992812f69d280"] passlib = ["68c35c98a7968850e17f1b6892720764cc7eed0ef2b7cb3116a89a28e43fe177", "8d666cef936198bc2ab47ee9b0410c94adf2ba798e5a84bf220be079ae7ab6a8"]
pathspec = ["163b0632d4e31cef212976cf57b43d9fd6b0bac6e67c26015d611a647d5e7424", "562aa70af2e0d434367d9790ad37aed893de47f1693e4201fd1d3dca15d19b96"]
pycodestyle = ["95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56", "e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c"] pycodestyle = ["95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56", "e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c"]
pycparser = ["a988718abfad80b6b157acce7bf130a30876d27603738ac39f140993246b25b3"] pycparser = ["a988718abfad80b6b157acce7bf130a30876d27603738ac39f140993246b25b3"]
pyflakes = ["17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0", "d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2"] pyflakes = ["17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0", "d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2"]
python-dateutil = ["7e6584c74aeed623791615e26efd690f29817a27c73085b78e4bad02493df2fb", "c89805f6f4d64db21ed966fda138f8a5ed7a4fdbc1a8ee329ce1b74e3c74da9e"] python-dateutil = ["73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c", "75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"]
pytz = ["303879e36b721603cc54604edcac9d20401bdbe31e1e4fdee5b9f98d5d31dfda", "d747dd3d23d77ef44c6a3526e274af6efeb0a6f1afd5a69ba4d5be4098c8e141"] pytz = ["1c557d7d0e871de1f5ccd5833f60fb2550652da6be2693c1e02300743d21500d", "b02c06db6cf09c12dd25137e563b31700d3b80fcc4ad23abb7a315f2789819be"]
pywin32-ctypes = ["24ffc3b341d457d48e8922352130cf2644024a4ff09762a2261fd34c36ee5942", "9dc2d991b3479cc2df15930958b674a48a227d5361d413827a4cfd0b5876fc98"] pywin32-ctypes = ["24ffc3b341d457d48e8922352130cf2644024a4ff09762a2261fd34c36ee5942", "9dc2d991b3479cc2df15930958b674a48a227d5361d413827a4cfd0b5876fc98"]
pyxdg = ["1948ff8e2db02156c0cccd2529b43c0cff56ebaa71f5f021bbd755bc1419190e", "fe2928d3f532ed32b39c32a482b54136fe766d19936afc96c8f00645f9da1a06"] pyxdg = ["1948ff8e2db02156c0cccd2529b43c0cff56ebaa71f5f021bbd755bc1419190e", "fe2928d3f532ed32b39c32a482b54136fe766d19936afc96c8f00645f9da1a06"]
pyyaml = ["0113bc0ec2ad727182326b61326afa3d1d8280ae1122493553fd6f4397f33df9", "01adf0b6c6f61bd11af6e10ca52b7d4057dd0be0343eb9283c878cf3af56aee4", "5124373960b0b3f4aa7df1707e63e9f109b5263eca5976c66e08b1c552d4eaf8", "5ca4f10adbddae56d824b2c09668e91219bb178a1eee1faa56af6f99f11bf696", "7907be34ffa3c5a32b60b95f4d95ea25361c951383a894fec31be7252b2b6f34", "7ec9b2a4ed5cad025c2278a1e6a19c011c80a3caaac804fd2d329e9cc2c287c9", "87ae4c829bb25b9fe99cf71fbb2140c448f534e24c998cc60f39ae4f94396a73", "9de9919becc9cc2ff03637872a440195ac4241c80536632fffeb6a1e25a74299", "a5a85b10e450c66b49f98846937e8cfca1db3127a9d5d1e31ca45c3d0bef4c5b", "b0997827b4f6a7c286c01c5f60384d218dca4ed7d9efa945c3e1aa623d5709ae", "b631ef96d3222e62861443cc89d6563ba3eeb816eeb96b2629345ab795e53681", "bf47c0607522fdbca6c9e817a6e81b08491de50f3766a7a0e6a5be7905961b41", "f81025eddd0327c7d4cfe9b62cf33190e1e736cc6e97502b3ec425f574b3e7a8"] pyyaml = ["0e7f69397d53155e55d10ff68fdfb2cf630a35e6daf65cf0bdeaf04f127c09dc", "2e9f0b7c5914367b0916c3c104a024bb68f269a486b9d04a2e8ac6f6597b7803", "35ace9b4147848cafac3db142795ee42deebe9d0dad885ce643928e88daebdcc", "38a4f0d114101c58c0f3a88aeaa44d63efd588845c5a2df5290b73db8f246d15", "483eb6a33b671408c8529106df3707270bfacb2447bf8ad856a4b4f57f6e3075", "4b6be5edb9f6bb73680f5bf4ee08ff25416d1400fbd4535fe0069b2994da07cd", "7f38e35c00e160db592091751d385cd7b3046d6d51f578b29943225178257b31", "8100c896ecb361794d8bfdb9c11fce618c7cf83d624d73d5ab38aef3bc82d43f", "c0ee8eca2c582d29c3c2ec6e2c4f703d1b7f1fb10bc72317355a746057e7346c", "e4c015484ff0ff197564917b4b4246ca03f411b9bd7f16e02a2f586eb48b6d04", "ebc4ed52dcc93eeebeae5cf5deb2ae4347b3a81c3fa12b0b8c976544829396a4"]
regex = ["032fdcc03406e1a6485ec09b826eac78732943840c4b29e503b789716f051d8d", "0e6cf1e747f383f52a0964452658c04300a9a01e8a89c55ea22813931b580aa8", "106e25a841921d8259dcef2a42786caae35bc750fb996f830065b3dfaa67b77e", "1768cf42a78a11dae63152685e7a1d90af7a8d71d2d4f6d2387edea53a9e0588", "27d1bd20d334f50b7ef078eba0f0756a640fd25f5f1708d3b5bed18a5d6bced9", "29b20f66f2e044aafba86ecf10a84e611b4667643c42baa004247f5dfef4f90b", "4850c78b53acf664a6578bba0e9ebeaf2807bb476c14ec7e0f936f2015133cae", "57eacd38a5ec40ed7b19a968a9d01c0d977bda55664210be713e750dd7b33540", "724eb24b92fc5fdc1501a1b4df44a68b9c1dda171c8ef8736799e903fb100f63", "77ae8d926f38700432807ba293d768ba9e7652df0cbe76df2843b12f80f68885", "78b3712ec529b2a71731fbb10b907b54d9c53a17ca589b42a578bc1e9a2c82ea", "7bbbdbada3078dc360d4692a9b28479f569db7fc7f304b668787afc9feb38ec8", "8d9ef7f6c403e35e73b7fc3cde9f6decdc43b1cb2ff8d058c53b9084bfcb553e", "a83049eb717ae828ced9cf607845929efcb086a001fc8af93ff15c50012a5716", "adc35d38952e688535980ae2109cad3a109520033642e759f987cf47fe278aa1", "c29a77ad4463f71a506515d9ec3a899ed026b4b015bf43245c919ff36275444b", "cfd31b3300fefa5eecb2fe596c6dee1b91b3a05ece9d5cfd2631afebf6c6fadd", "d3ee0b035816e0520fac928de31b6572106f0d75597f6fa3206969a02baba06f", "d508875793efdf6bab3d47850df8f40d4040ae9928d9d80864c1768d6aeaf8e3", "ef0b828a7e22e58e06a1cceddba7b4665c6af8afeb22a0d8083001330572c147", "faad39fdbe2c2ccda9846cd21581063086330efafa47d87afea4073a08128656"]
secretstorage = ["20c797ae48a4419f66f8d28fc221623f11fc45b6828f96bdb1ad9990acb59f92", "7a119fb52a88e398dbb22a4b3eb39b779bfbace7e4153b7bc6e5954d86282a8a"] secretstorage = ["20c797ae48a4419f66f8d28fc221623f11fc45b6828f96bdb1ad9990acb59f92", "7a119fb52a88e398dbb22a4b3eb39b779bfbace7e4153b7bc6e5954d86282a8a"]
six = ["3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", "d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73"] six = ["1f1b7d42e254082a9db6279deae68afb421ceba6158efa6131de7b3003ee93fd", "30f610279e8b2578cab6db20741130331735c781b56053c59c4076da27f06b66"]
toml = ["229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c", "235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e", "f1db651f9657708513243e61e6cc67d101a39bad662eaa9b5546f789338e07a3"] toml = ["229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c", "235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e", "f1db651f9657708513243e61e6cc67d101a39bad662eaa9b5546f789338e07a3"]
tornado = ["349884248c36801afa19e342a77cc4458caca694b0eda633f5878e458a44cb2c", "398e0d35e086ba38a0427c3b37f4337327231942e731edaa6e9fd1865bbd6f60", "4e73ef678b1a859f0cb29e1d895526a20ea64b5ffd510a2307b5998c7df24281", "559bce3d31484b665259f50cd94c5c28b961b09315ccd838f284687245f416e5", "abbe53a39734ef4aba061fca54e30c6b4639d3e1f59653f0da37a0003de148c7", "c845db36ba616912074c5b1ee897f8e0124df269468f25e4fe21fe72f6edd7a9", "c9399267c926a4e7c418baa5cbe91c7d1cf362d505a1ef898fde44a07c9dd8a5"] tornado = ["349884248c36801afa19e342a77cc4458caca694b0eda633f5878e458a44cb2c", "398e0d35e086ba38a0427c3b37f4337327231942e731edaa6e9fd1865bbd6f60", "4e73ef678b1a859f0cb29e1d895526a20ea64b5ffd510a2307b5998c7df24281", "559bce3d31484b665259f50cd94c5c28b961b09315ccd838f284687245f416e5", "abbe53a39734ef4aba061fca54e30c6b4639d3e1f59653f0da37a0003de148c7", "c845db36ba616912074c5b1ee897f8e0124df269468f25e4fe21fe72f6edd7a9", "c9399267c926a4e7c418baa5cbe91c7d1cf362d505a1ef898fde44a07c9dd8a5"]
typed-ast = ["1170afa46a3799e18b4c977777ce137bb53c7485379d9706af8a59f2ea1aa161", "18511a0b3e7922276346bcb47e2ef9f38fb90fd31cb9223eed42c85d1312344e", "262c247a82d005e43b5b7f69aff746370538e176131c32dda9cb0f324d27141e", "2b907eb046d049bcd9892e3076c7a6456c93a25bebfe554e931620c90e6a25b0", "354c16e5babd09f5cb0ee000d54cfa38401d8b8891eefa878ac772f827181a3c", "48e5b1e71f25cfdef98b013263a88d7145879fbb2d5185f2a0c79fa7ebbeae47", "4e0b70c6fc4d010f8107726af5fd37921b666f5b31d9331f0bd24ad9a088e631", "630968c5cdee51a11c05a30453f8cd65e0cc1d2ad0d9192819df9978984529f4", "66480f95b8167c9c5c5c87f32cf437d585937970f3fc24386f313a4c97b44e34", "71211d26ffd12d63a83e079ff258ac9d56a1376a25bc80b1cdcdf601b855b90b", "7954560051331d003b4e2b3eb822d9dd2e376fa4f6d98fee32f452f52dd6ebb2", "838997f4310012cf2e1ad3803bce2f3402e9ffb71ded61b5ee22617b3a7f6b6e", "95bd11af7eafc16e829af2d3df510cecfd4387f6453355188342c3e79a2ec87a", "bc6c7d3fa1325a0c6613512a093bc2a2a15aeec350451cbdf9e1d4bffe3e3233", "cc34a6f5b426748a507dd5d1de4c1978f2eb5626d51326e43280941206c209e1", "d755f03c1e4a51e9b24d899561fec4ccaf51f210d52abdf8c07ee2849b212a36", "d7c45933b1bdfaf9f36c579671fec15d25b06c8398f113dab64c18ed1adda01d", "d896919306dd0aa22d0132f62a1b78d11aaf4c9fc5b3410d3c666b818191630a", "fdc1c9bbf79510b76408840e009ed65958feba92a88833cdceecff93ae8fff66", "ffde2fbfad571af120fcbfbbc61c72469e72f550d676c3342492a9dfdefb8f12"]
tzlocal = ["4ebeb848845ac898da6519b9b31879cf13b6626f7184c496037b818e238f2c4e"] tzlocal = ["4ebeb848845ac898da6519b9b31879cf13b6626f7184c496037b818e238f2c4e"]
zipp = ["3718b1cbcd963c7d4c5511a8240812904164b7f381b647143a89d3b98f9bcd8e", "f06903e9f1f43b12d371004b4ac7b06ab39a44adc747266928ae6debfa7b3335"]

View file

@ -1,19 +1,22 @@
[tool.poetry] [tool.poetry]
name = "jrnl" name = "jrnl"
version = "v2.1.1" version = "v2.2.1-beta2"
description = "Collect your thoughts and notes without leaving the command line." description = "Collect your thoughts and notes without leaving the command line."
authors = [ authors = [
"Manuel Ebert <manuel@1450.me>", "Manuel Ebert <manuel@1450.me>",
"Jonathan Wren <jonathan@nowandwren.com>", "Jonathan Wren <jonathan@nowandwren.com>",
"Micah Ellison <micahellison@gmail.com>" "Micah Ellison <micahellison@gmail.com>"
] ]
maintainers = [
"Jonathan Wren and Micah Ellison <jrnl-sh@googlegroups.com>",
]
license = "MIT" license = "MIT"
readme = "README.md" readme = "README.md"
homepage = "https://jrnl.sh" homepage = "https://jrnl.sh"
repository = "https://github.com/jrnl-org/jrnl" repository = "https://github.com/jrnl-org/jrnl"
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = ">=3.6.0, <3.8.0" python = ">=3.6.0, <3.9.0"
pyxdg = "^0.26.0" pyxdg = "^0.26.0"
cryptography = "^2.7" cryptography = "^2.7"
passlib = "^1.7" passlib = "^1.7"
@ -30,7 +33,7 @@ pyyaml = "^5.1"
behave = "^1.2" behave = "^1.2"
mkdocs = "^1.0" mkdocs = "^1.0"
flake8 = "^3.7" flake8 = "^3.7"
black = {version = "^18.3-alpha.0",allows-prereleases = true} black = {version = "^19.10b0",allow-prereleases = true}
[tool.poetry.scripts] [tool.poetry.scripts]
jrnl = 'jrnl.cli:run' jrnl = 'jrnl.cli:run'