commit c480368a87ad0a22a68745d490baec0032e3e092 Author: Deployment Bot (from Travis CI) Date: Sat Oct 19 21:43:26 2019 +0000 Deploy jrnl-org/jrnl to github.com/jrnl-org/jrnl.git:gh-pages diff --git a/.github/stale.yml b/.github/stale.yml new file mode 100644 index 00000000..323b3928 --- /dev/null +++ b/.github/stale.yml @@ -0,0 +1,17 @@ +# Number of days of inactivity before an issue becomes stale +daysUntilStale: 60 +# Number of days of inactivity before a stale issue is closed +daysUntilClose: 7 +# Issues with these labels will never be considered stale +exemptLabels: + - ':star:' + - security +# Label to use when marking an issue as stale +staleLabel: stale +# Comment to post when marking an issue as stale. Set to `false` to disable +markComment: > + This issue has been automatically marked as stale because it has not had + recent activity. It will be closed if no further activity occurs. Thank you + for your contributions. +# Comment to post when closing a stale issue. Set to `false` to disable +closeComment: false diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..2382cabe --- /dev/null +++ b/.gitignore @@ -0,0 +1,54 @@ +*.py[cod] + +# C extensions +*.so +.python-version + +# Packages +*.egg +*.egg-info +dist +build +eggs +parts +bin +var +sdist +develop-eggs +.installed.cfg +lib +lib64 +.python-version + +# Installer logs +pip-log.txt +.DS_Store +.travis-solo +Icon + +# Documentation +_build +_sources +_static +objects.inv +searchindex.js + +# MS Visual Studio (PyTools) +obj +*.pyproj +*.sln +*.suo + +# virtaulenv +env/ +env*/ + +# PyCharm Project files +.idea/ + +# export testing director +exp/ + +_extras/ +*.sublime-* +site/ diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..786e88c6 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,29 @@ +dist: xenial # required for Python >= 3.7 +language: python +python: "3.7" +before_install: + - pip install poetry +install: + # we run `poetry version` here to appease poetry about '0.0.0-source' + - poetry version + - poetry install +script: + - poetry run python --version + - poetry run behave +before_deploy: + - pip install poetry + - poetry config http-basic.pypi $PYPI_USER $PYPI_PASS + - poetry version $TRAVIS_TAG + - poetry build +deploy: + - provider: script + script: poetry publish + skip_cleanup: true + on: + branch: master + tags: true + - provider: pages + skip_cleanup: true + github_token: $GITHUB_TOKEN + on: + branch: master diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..2c400b8c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,171 @@ +Changelog +========= + +## 2.0 + +* Cryptographical backend changed from PyCrypto to cryptography.io +* 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) + +* __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.3__ Fixed: Tags at the beginning of lines +* __1.9.2__ Fixed: Tag search ignores email-addresses (thanks to @mjhoffman65) +* __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.8 (May 22, 2014) + +* __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.5__ Fixed: file names when exporting to individual files contain full year (thanks to @jdevera) +* __1.8.4__ Improved: using external editors (thanks to @chrissexton) +* __1.8.3__ Fixed: export to text files and improves help (thanks to @igniteflow and @mpe) +* __1.8.2__ Better integration with environment variables (thanks to @ajaam and @matze) +* __1.8.1__ Minor bug fixes +* __1.8.0__ Official support for python 3.4 + +### 1.7 (December 22, 2013) + +* __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.20__ Minor fixes when parsing DayOne journals +* __1.7.19__ Creates full path to journal during installation if it doesn't exist yet +* __1.7.18__ Small update to parsing regex +* __1.7.17__ Fixes writing new lines between entries +* __1.7.16__ Even more unicode fixes! +* __1.7.15__ More unicode fixes +* __1.7.14__ Fix for trailing whitespaces (eg. when writing markdown code block) +* __1.7.13__ Fix for UTF-8 in DayOne journals +* __1.7.12__ Fixes a bug where filtering by tags didn't work for DayOne journals +* __1.7.11__ `-ls` will list all available journals (Thanks @jtan189) +* __1.7.10__ Supports `-3` as a shortcut for `-n 3` and updates to tzlocal 1.1 +* __1.7.9__ Fix a logic bug so that jrnl -h and jrnl -v are possible even if jrnl not configured yet. +* __1.7.8__ Upgrade to parsedatetime 1.2 +* __1.7.7__ Cleaned up imports, better unicode support +* __1.7.6__ Python 3 port for slugify +* __1.7.5__ Colorama is only needed on Windows. Smaller fixes +* __1.7.3__ Touches temporary files before opening them to allow more external editors. +* __1.7.2__ Dateutil added to requirements. +* __1.7.1__ Fixes issues with parsing time information in entries. +* __1.7.0__ Edit encrypted or DayOne journals with `jrnl --edit`. + + +### 1.6 (November 5, 2013) + +* __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.4__ Fixed a bug that caused creating encrypted journals to fail +* __1.6.3__ New, pretty, _useful_ documentation! +* __1.6.2__ Starring entries now works for plain-text journals too! +* __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.5 (August 6, 2013) + +* __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.5__ Fixed: Detects DayOne journals stored in `~/Library/Mobile Data` as well. +* __1.5.4__ DayOne journals can now handle tags +* __1.5.3__ Fixed: DayOne integration with older DayOne Journals +* __1.5.2__ Soft-deprecated `-to` for filtering by time and introduces `-until` instead. +* __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.4 (July 22, 2013) + +* __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.3 (July 17, 2013) + +* __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__ Feature to export to given output file + +### 1.2 (July 15, 2013) + +* __1.2.0__ Fixed: Timezone support for DayOne + + +### 1.1 (June 9, 2013) + +* __1.1.1__ Fixed: Unicode and Python3 issues resolved. +* __1.1.0__ + * JSON export exports tags as well. + * Nicer error message when there is a syntactical error in your config file. + * Unicode support + +### 1.0 (March 4, 2013) + +* __1.0.5__ Backwards compatibility with `parsedatetime` 0.8.7 +* __1.0.4__ + * Python 2.6 compatibility + * Better utf-8 support + * Python 3 compatibility + * Respects the `XDG_CONFIG_HOME` environment variable for storing your configuration file (Thanks [evaryont](https://github.com/evaryont)) + +* __1.0.3__ + * Removed clint in favour of colorama + * Fixed: Fixed a bug where showing tags failed when no tags are defined. + * Fixed: Improvements to config parsing (Thanks [alapolloni](https://github.com/alapolloni)) + * Fixed: Fixes readline support on Windows + * Fixed: Smaller fixes and typos +* __1.0.1__ (March 12, 2013) Fixed: Requires parsedatetime 1.1.2 or newer +* __1.0.0__ + * Integrates seamlessly with DayOne + * Each journal can have individual settings + * Fixed: A bug where jrnl would not go into compose mode + * Fixed: A bug where jrnl would not add entries without timestamp + * Fixed: Support for parsedatetime 1.x + +### 0.3 (May 24, 2012) + +* __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__ + * Supports deleting of last entry. + * Fixed: Fixes a bug where --encrypt or --decrypt without a target file would not work. + * Supports a config option for setting word wrap. + * Supports multiple journal files. +* __0.3.0__ + * Fixed: Dates such as "May 3" will now be interpreted as being in the past if the current day is at least 28 days in the future + * 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`) + +### 0.2 (April 16, 2012) + +* __0.2.4__ + * Fixed: Parsing of new lines in journal files and entries + * Adds support for encrypting and decrypting into new files +* __0.2.3__ + * Adds a `-short` option that will only display the titles of entries (or, when filtering by tags, the context of the tag) + * Adds tag export + * Adds coloured highlight of tags (by default, highlights all tags - when filtering by tags, only highlights search tags) + * `.jrnl_config` will get automatically updated when updating jrnl to a new version +* __0.2.2__ + * Adds --encrypt and --decrypt to encrypt / decrypt existing journal files + * Adds markdown export (kudos to dedan) +* __0.2.1__ Submitted to [PyPi](http://pypi.python.org/pypi/jrnl/0.2.1). +* __0.2.0__ + * 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.) + +### 0.1 (April 13, 2012) + + +* __0.1.1__ + * Fixed: Removed unnecessary print commands + * Created the documentation +* __0.1.0__ + * Supports encrypted journals using AES encryption + * Support external editors for composing entries +* __0.0.2__ + * Filtering by tags and dates + * Fixed: Now using dedicated classes for Journals and entries + +### 0.0 (March 29, 2012) + +* __0.0.1__ Composing entries works. That's pretty much it. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..dc9e89f1 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,66 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as contributors and maintainers +pledge to making participation in our project and our community a harassment-free experience for +everyone, regardless of age, body size, disability, ethnicity, sex characteristics, gender identity +and expression, level of experience, education, socio-economic status, nationality, personal +appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment include: + +* Using welcoming and inclusive language Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism Focusing on what is best for the community Showing +* empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or + advances +* Trolling, insulting/derogatory comments, and personal or political attacks Public or private +* harassment Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable behavior and are +expected to take appropriate and fair corrective action in response to any instances of unacceptable +behavior. + +Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, +code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or +to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies within all project spaces, and it also applies when an individual is +representing the project or its community in public spaces. Examples of representing a project or +community include using an official project e-mail address, posting via an official social media +account, or acting as an appointed representative at an online or offline event. Representation of a +project may be further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting +the project team. All complaints will be reviewed and investigated and will result in a response +that is deemed necessary and appropriate to the circumstances. The project team is obligated to +maintain confidentiality with regard to the reporter of an incident. Further details of specific +enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good faith may face +temporary or permanent repercussions as determined by other members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at +https://www.contributor-covenant.org/version/1/4/code-of-conduct.html + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see https://www.contributor-covenant.org/faq diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..989ccf7e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,35 @@ +Contributing +============ + +If you use jrnl, you can totally make our day by just saying "thanks for the code." It's your chance to make a programmer happy today! If you have a moment, let us know what you use jrnl for and how; it'll help us to make it even better! + + +Docs & Typos +------------ + +If you find a typo or a mistake in the docs, please fix it right away and send a pull request. The Right Way™ to fix the docs is to edit the `docs/*.md` files on the **master** branch. You can see the result if you run `make html` inside the project's root directory, which will open a browser that hot-reloads as you change the docs. This requires [mkdocs](https://www.mkdocs.org) to be installed. The `gh-pages` branch is automatically maintained and updates from `master`; you should never have to edit that. + +Bugs +---- + +Unfortunately, bugs happen. If you found one, please [open a new issue](https://github.com/jrnl-org/jrnl/issues/new) and describe it as well as possible. If you're a programmer with some time, go ahead and send us a pull request! We'll review as quickly as we can. + + +Feature requests and ideas +-------------------------- + +So, you have an idea for a great feature? Awesome! We'd love to hear from you! Please [open a new issue](https://github.com/jrnl-org/jrnl/issues) and describe the goal of the feature, and any relvant use cases. We'll discuss the issue with you, and decide if it's a good fit for the project. + +When discussing new features, please keep in mind our design goals. jrnl strives to do one thing well. To us, that means: + +* be _slim_ +* have a simple interface +* avoid dupicating functionality + + +A short note for new programmers and programmers new to python +-------------------------------------------------------------- + +Although jrnl has grown quite a bit since its inception. The overall complexity (for an end-user program) is fairly low, and we hope you'll find the code easy enough to understand. + +If you have a question, please don't hesitate to ask! Python is known for its welcoming community and openness to novice programmers, so feel free to fork the code and play around with it! If you create something you want to share with us, please create a pull request. We never expect pull requests to be perfect, idiomatic, instantly mergeable code. We can work through it together! diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..dc4571c0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014 Manuel Ebert + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..79803508 --- /dev/null +++ b/Makefile @@ -0,0 +1,29 @@ +# A Makefile for commands I run frequently: + +clean: + rm -rf dist + rm -rf _static + rm -rf jrnl.egg-info + rm -rf _build + rm -rf _sources + rm -rf _static + rm -rf site/ + rm -f *.html + +html: + poetry run mkdocs serve + +format: ## check style with flake8 + poetry run black features jrnl + +lint: ## check style with flake8 + poetry run flake8 jrnl features --ignore E501 + +test: ## Run behave tests + poetry run behave + +build: + poetry build + +install: clean ## install the package to the active Python's site-packages + poetry install diff --git a/README.md b/README.md new file mode 100644 index 00000000..0f444ab2 --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +jrnl [![Build Status](https://travis-ci.com/jrnl-org/jrnl.svg?branch=master)](https://travis-ci.com/jrnl-org/jrnl) [![Downloads](https://pepy.tech/badge/jrnl)](https://pepy.tech/project/jrnl) [![Version](http://img.shields.io/pypi/v/jrnl.svg?style=flat)](https://pypi.python.org/pypi/jrnl/) +==== + +_To get help, [submit an issue](https://github.com/jrnl-org/jrnl/issues/new) on Github._ + +*jrnl* is a simple journal application for your command line. Journals are stored as human readable plain text files - you can put them into a Dropbox folder for instant syncing and you can be assured that your journal will still be readable in 2050, when all your fancy iPad journal applications will long be forgotten. + +Optionally, your journal can be encrypted using the [256-bit AES](http://en.wikipedia.org/wiki/Advanced_Encryption_Standard). + +### Why keep a journal? + +Journals aren't just for people who have too much time on their summer vacation. A journal helps you to keep track of the things you get done and how you did them. Your imagination may be limitless, but your memory isn't. For personal use, make it a good habit to write at least 20 words a day. Just to reflect what made this day special, why you haven't wasted it. For professional use, consider a text-based journal to be the perfect complement to your GTD todo list - a documentation of what and how you've done it. + +In a Nutshell +------------- + +to make a new entry, just type + + jrnl yesterday: Called in sick. Used the time to clean the house and spent 4h on writing my book. + +and hit return. `yesterday:` will be interpreted as a timestamp. Everything until the first sentence mark (`.?!`) will be interpreted as the title, the rest as the body. In your journal file, the result will look like this: + + 2012-03-29 09:00 Called in sick. + Used the time to clean the house and spent 4h on writing my book. + +If you just call `jrnl`, you will be prompted to compose your entry - but you can also configure _jrnl_ to use your external editor. + +Known Issues +------------ +jrnl used to support integration with Day One, but no longer supports it since Day One 2 was released with a different backend. [See the GitHub issue for more information](https://github.com/jrnl-org/jrnl/issues/409). + +Authors +------- +Current maintainers: + + * Jonathan Wren ([wren](https://github.com/wren)) + * Micah Ellison ([micahellison](https://github.com/micahellison)) + +Original maintainer: + + * Manuel Ebert ([maebert](https://github.com/maebert)) diff --git a/docs/CNAME b/docs/CNAME new file mode 100644 index 00000000..027bab94 --- /dev/null +++ b/docs/CNAME @@ -0,0 +1,2 @@ +jrnl.sh + diff --git a/docs/advanced.md b/docs/advanced.md new file mode 100644 index 00000000..2a6fd6b4 --- /dev/null +++ b/docs/advanced.md @@ -0,0 +1,145 @@ +# Advanced Usage + +## Configuration File + +You can configure the way jrnl behaves in a configuration file. By +default, this is `~/.jrnl_config`. If you have the `XDG_CONFIG_HOME` +variable set, the configuration file will be saved as +`$XDG_CONFIG_HOME/jrnl/.jrnl_config`. + +!!! note + On Windows, The configuration file is typically found at `C:\Users\[Your Username]\.jrnl_config`. + +The configuration file is a simple JSON file with the following options +and can be edited with any plain text editor. + + - `journals` + paths to your journal files + - `editor` + if set, executes this command to launch an external editor for + writing your entries, e.g. `vim`. Some editors require special + options to work properly, see `FAQ ` for details. + - `encrypt` + if `true`, encrypts your journal using AES. + - `tagsymbols` + Symbols to be interpreted as tags. (See note below) + - `default_hour` and `default_minute` + if you supply a date, such as `last thursday`, but no specific + time, the entry will be created at this time + - `timeformat` + how to format the timestamps in your journal, see the [python docs](http://docs.python.org/library/time.html#time.strftime) for reference + - `highlight` + if `true`, tags will be highlighted in cyan. + - `linewrap` + controls the width of the output. Set to `false` if you don't + want to wrap long lines. + +!!! note + Although it seems intuitive to use the `#` + character for tags, there's a drawback: on most shells, this is + interpreted as a meta-character starting a comment. This means that if + you type + + > `jrnl Implemented endless scrolling on the #frontend of our website.` + + your bash will chop off everything after the `#` before passing it to + `jrnl`. To avoid this, wrap your input into quotation marks like + this: + + > `jrnl "Implemented endless scrolling on the #frontend of our website."` + + Or use the built-in prompt or an external editor to compose your + entries. + +## DayOne Integration + +Using your DayOne journal instead of a flat text file is dead simple -- +instead of pointing to a text file, change your `.jrnl_config` to point +to your DayOne journal. This is a folder named something like +`Journal_dayone` or `Journal.dayone`, and it's located at + + - `~/Library/Application Support/Day One/` by default + - `~/Dropbox/Apps/Day One/` if you're syncing with Dropbox and + - `~/Library/Mobile + Documents/5U8NS4GX82~com~dayoneapp~dayone/Documents/` if you're + syncing with iCloud. + +Instead of all entries being in a single file, each entry will live in a +separate `plist` file. So your `.jrnl_config` should look like this: + +``` javascript +{ + ... + "journals": { + "default": "~/journal.txt", + "dayone": "~/Library/Mobile Documents/5U8NS4GX82~com~dayoneapp~dayone/Documents/Journal_dayone" + } +} +``` + +## Multiple journal files + +You can configure `jrnl`to use with multiple journals (eg. +`private` and `work`) by defining more journals in your `.jrnl_config`, +for example: + +``` javascript +{ +... + "journals": { + "default": "~/journal.txt", + "work": "~/work.txt" + } +} +``` + +The `default` journal gets created the first time you start `jrnl` +Now you can access the `work` journal by using `jrnl work` instead of +`jrnl`, eg. + +``` sh +jrnl work at 10am: Meeting with @Steve +jrnl work -n 3 +``` + +will both use `~/work.txt`, while `jrnl -n 3` will display the last +three entries from `~/journal.txt` (and so does `jrnl default -n 3`). + +You can also override the default options for each individual journal. +If you `.jrnl_config` looks like this: + +``` javascript +{ + ... + "encrypt": false + "journals": { + "default": "~/journal.txt", + "work": { + "journal": "~/work.txt", + "encrypt": true + }, + "food": "~/my_recipes.txt", +} +``` + +Your `default` and your `food` journals won't be encrypted, however your +`work` journal will! You can override all options that are present at +the top level of `.jrnl_config`, just make sure that at the very least +you specify a `"journal": ...` key that points to the journal file of +that journal. + +!!! note + Changing `encrypt` to a different value will not encrypt or decrypt your + journal file, it merely says whether or not your journal + is encrypted. Hence manually changing + this option will most likely result in your journal file being + impossible to load. + +## Known Issues + +### Unicode on Windows + +The Windows shell prior to Windows 7 has issues with unicode encoding. +To use non-ascii characters, first tweak Python to recognize the encoding by adding `'cp65001': 'utf_8'`, to `Lib/encoding/aliases.py`. Then, change the codepage with `chcp 1252` before using `jrnl`. + +(Related issue: [#486](https://github.com/jrnl-org/jrnl/issues/486)) diff --git a/docs/assets/highlight.css b/docs/assets/highlight.css new file mode 100644 index 00000000..46eeb25c --- /dev/null +++ b/docs/assets/highlight.css @@ -0,0 +1,79 @@ +/* + +Atom One Dark With support for ReasonML by Gidi Morris, based off work by Daniel Gamage + +Original One Dark Syntax theme from https://github.com/atom/one-dark-syntax + +*/ +.hljs { + display: block; + overflow-x: auto; + padding: 0.5em; + line-height: 1.3em; + color: #e2e8f2; + background: #383e49; + border-radius: 5px; + font-size: 0.9rem; + line-height: 1.3rem; +} +.hljs-keyword, .hljs-operator { + color: #F92672; +} +.hljs-pattern-match { + color: #F92672; +} +.hljs-pattern-match .hljs-constructor { + color: #61aeee; +} +.hljs-function { + color: #61aeee; +} +.hljs-function .hljs-params { + color: #A6E22E; +} +.hljs-function .hljs-params .hljs-typing { + color: #FD971F; +} +.hljs-module-access .hljs-module { + color: #7e57c2; +} +.hljs-constructor { + color: #e2b93d; +} +.hljs-constructor .hljs-string { + color: #9CCC65; +} +.hljs-comment, .hljs-quote { + color: #b18eb1; + font-style: italic; +} +.hljs-doctag, .hljs-formula { + color: #c678dd; +} +.hljs-section, .hljs-name, .hljs-selector-tag, .hljs-deletion, .hljs-subst { + color: #e06c75; +} +.hljs-literal { + color: #56b6c2; +} +.hljs-string, .hljs-regexp, .hljs-addition, .hljs-attribute, .hljs-meta-string { + color: #98c379; +} +.hljs-built_in, .hljs-class .hljs-title { + color: #e6c07b; +} +.hljs-attr, .hljs-variable, .hljs-template-variable, .hljs-type, .hljs-selector-class, .hljs-selector-attr, .hljs-selector-pseudo, .hljs-number { + color: #d19a66; +} +.hljs-symbol, .hljs-bullet, .hljs-link, .hljs-meta, .hljs-selector-id, .hljs-title { + color: #61aeee; +} +.hljs-emphasis { + font-style: italic; +} +.hljs-strong { + font-weight: bold; +} +.hljs-link { + text-decoration: underline; +} diff --git a/docs/assets/theme.css b/docs/assets/theme.css new file mode 100644 index 00000000..a3de87a9 --- /dev/null +++ b/docs/assets/theme.css @@ -0,0 +1,196 @@ + +/* ------------------------------------------------------------ */ +/* Overrides for jrnl theme */ +/* ------------------------------------------------------------ */ + +:root { + --sidebar: #604385; + --sidebar-dark: #604385; + --off-white: rgba(255,255,255,.7); +} + +body.wy-body-for-nav, section.wy-nav-content-wrap { + background-color: rgb(252,252,252); +} + +pre { + background-color: transparent; + border: none; + margin: 1em -1em; +} + +pre code { + padding: 1em 1.5em !important; +} + +code { + background-color: transparent; +} + +h1,h2 ,h3, h4, h5, h6 { + font-family: "Open Sans", "Helvetica Neue", Helvetica, sans-serif; + font-weight: 600; + margin-top: 2rem; + margin-bottom: 0.5rem; +} +p, td, tr, div, li { + font-family: "Open Sans", "Helvetica Neue", Helvetica, sans-serif; + font-weight: 00; +} + +p { + margin: 1em 0em; +} + +/* No-one likes lines that are 400 characters long. */ +div.rst-content {max-width: 54em;} + +.wy-side-nav-search, .wy-nav-top, .wy-menu-vertical li.current { + background-color: transparent; +} +.wy-side-nav-search a.icon-home { + width: 100%; + max-width: 250px; + background-size: 100%; +} + +a.icon-home:before { + display: block; + width: 84px; + height: 70px; + content: ""; + background: url(../img/logo_white.svg) center center no-repeat; + margin: 10px auto; +} + +.wy-menu-vertical a, .wy-menu-vertical li ul li a {color: var(--off-white) !important; } + +.wy-menu-vertical a:hover, .wy-menu-vertical li.current a:hover { background-color: transparent !important; color: white !important;} +.wy-menu-vertical li.on a, .wy-menu-vertical li.current>a { + background: transparent; !important; + color: white !important; + border: none; + position: relative; + font-weight: 700 !important; + border-right: none !important; +} + +.wy-menu-vertical li.on a, .wy-menu-vertical li.current a { + border-right: none; +} + +.wy-menu-vertical li.on a, .wy-menu-vertical li.current>a:after { + display: block; + position: absolute; + right: 0em; + top: 0; + z-index: 999 !important; + content: ""; + width: 0; + height: 0; + border-top: 1em solid transparent; + border-bottom: 1em solid transparent; + border-right: 1em solid white; +} + +.rst-versions, .rst-versions .rst-current-version { display: none; } +.wy-menu-vertical span { + color: white !important; + font-size: 1.2em; + font-weight: 300 !important; +} +.wy-menu-vertical li a {color: var(--off-white) !important; font-weight: 300 !important;} + + +.wy-nav-side { + background-image: linear-gradient(211deg, #95699C 0%, #604385 100%); + font-weight: 300 !important; + height: 100%; +} + + +footer {display: none;} + +.wy-side-nav-search input[type=text], form .search-query { + background-color: rgba(0,0,0,.1) !important; + border: 1px solid rgba(255,255,255,.3); + box-shadow: none; + margin-bottom: 1em; + color: white !important; + font-weight: 500; +} + +.wy-side-nav-search input[type=text]::placeholder, form .search-query::placeholder { + color: var(--off-white) !important; +} + +.toctree-l2 a:first-child {display: none;} + +/* ------------------------------------------------------------ */ +/* Logo: ; */ +/* ------------------------------------------------------------ */ + +.logo { + width: 128px; + height: 128px; + vertical-align: middle; + margin-right: 1em; +} + +/* ------------------------------------------------------------ */ +/* Code blocks in callouts */ +/* ------------------------------------------------------------ */ + +div.admonition { + border-radius: 5px; + margin: 1em -1em; +} +div.admonition p.admonition-title { + border-top-left-radius: 5px; + border-top-right-radius: 5px; +} +div.admonition > p { + padding: 0em .5em; +} + + +div.admonition div.highlight { + background: none !important; +} +div.admonition div.highlight pre { + background-color: rgba(255,255,255,.5) !important; +} + +/* ------------------------------------------------------------ */ +/* Fancy ordered lists. */ +/* ------------------------------------------------------------ */ + +ol { + counter-reset:li; + margin-left: 0px; + padding: 0; +} + +ol li { + list-style: none !important; + margin-bottom: 1.5em; + margin-left: 3em !important; +} + +ol > li:before { + content:counter(li); + counter-increment:li; + background-color: var(--sidebar); + border-radius: 50%; + display: block; + float: left; + margin-left: -3em; + margin-top: -.3em; + width: 2em; + height: 2em; + color: var(--sidebar-dark); + text-align: center; + line-height: 2em; + font-weight: 600; +} + diff --git a/docs/encryption.md b/docs/encryption.md new file mode 100644 index 00000000..19bb178b --- /dev/null +++ b/docs/encryption.md @@ -0,0 +1,93 @@ +# Encryption + +## Encrypting and decrypting + +If you don't choose to encrypt your file when you run +`jrnl` for the first time, you can encrypt +your existing journal file or change its password using + +``` sh +jrnl --encrypt +``` + +If it is already encrypted, you will first be asked for the current +password. You can then enter a new password and your plain journal will +replaced by the encrypted file. Conversely, + +``` sh +jrnl --decrypt +``` + +will replace your encrypted journal file by a Journal in plain text. You +can also specify a filename, ie. `jrnl --decrypt plain_text_copy.txt`, +to leave your original file untouched. + +## Storing passwords in your keychain + +Whenever you encrypt your journal, you are asked whether you want to +store the encryption password in your keychain. If you do this, you +won't have to enter your password every time you want to write or read +your journal. + +If you don't initially store the password in the keychain but decide to +do so at a later point -- or maybe want to store it on one computer but +not on another -- you can simply run `jrnl --encrypt` on an encrypted +journal and use the same password again. + +## A note on security + +While jrnl follows best practises, true security is an illusion. +Specifically, jrnl will leave traces in your memory and your shell +history -- it's meant to keep journals secure in transit, for example +when storing it on an +[untrusted](http://techcrunch.com/2014/04/09/condoleezza-rice-joins-dropboxs-board/) +services such as Dropbox. If you're concerned about security, disable +history logging for journal in your `.bashrc` + +``` sh +HISTIGNORE="$HISTIGNORE:jrnl *" +``` + +If you are using zsh instead of bash, you can get the same behaviour +adding this to your `zshrc` + +``` sh +setopt HIST_IGNORE_SPACE +alias jrnl=" jrnl" +``` + +## Manual decryption + +Should you ever want to decrypt your journal manually, you can do so +with any program that supports the AES algorithm in CBC. The key used +for encryption is the SHA-256-hash of your password, the IV +(initialisation vector) is stored in the first 16 bytes of the encrypted +file. The plain text is encoded in UTF-8 and padded according to PKCS\#7 +before being encrypted. Here's a Python script that you can use to +decrypt your journal + +``` python +#!/usr/bin/env python3 + +import argparse +from Crypto.Cipher import AES +import getpass +import hashlib +import sys + +parser = argparse.ArgumentParser() +parser.add_argument("filepath", help="journal file to decrypt") +args = parser.parse_args() + +pwd = getpass.getpass() +key = hashlib.sha256(pwd.encode('utf-8')).digest() + +with open(args.filepath, 'rb') as f: + ciphertext = f.read() + +crypto = AES.new(key, AES.MODE_CBC, ciphertext[:16]) +plain = crypto.decrypt(ciphertext[16:]) +plain = plain.strip(plain[-1:]) +plain = plain.decode("utf-8") +print(plain) +``` diff --git a/docs/export.md b/docs/export.md new file mode 100644 index 00000000..f786a318 --- /dev/null +++ b/docs/export.md @@ -0,0 +1,76 @@ +# Import and Export + +## Tag export + +With + +``` sh +jrnl --tags +``` + +you'll get a list of all tags you used in your journal, sorted by most +frequent. Tags occurring several times in the same entry are only +counted as one. + +## List of all entries + +``` sh +jrnl --short +``` + +Will only display the date and title of each entry. + +## JSON export + +Can do + +``` sh +jrnl --export json +``` + +Why not create a [beautiful timeline](http://timeline.verite.co/) of +your journal? + +## Markdown export + +Use + +``` sh +jrnl --export markdown +``` + +Markdown is a simple markup language that is human readable and can be +used to be rendered to other formats (html, pdf). This README for +example is formatted in markdown and github makes it look nice. + +## Text export + +``` sh +jrnl --export text +``` + +Pretty-prints your entire journal. + +## Export to files + +You can specify the output file of your exported journal using the +`-o` argument + +``` sh +jrnl --export md -o journal.md +``` + +The above command will generate a file named `journal.md`. If the`-o` argument is a directory, jrnl will export each entry into an individual file + +``` sh +jrnl --export json -o my_entries/ +``` + +The contents of `my\_entries/` will then look like this: + +``` output +my_entries/ +|- 2013_06_03_a-beautiful-day.json +|- 2013_06_07_dinner-with-gabriel.json +|- ... +``` diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 00000000..36b694ce --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,55 @@ +# Getting started + +## Installation + +On OS X, the easiest way to install *jrnl* is using +[Homebrew](http://brew.sh/) + +``` sh +brew install jrnl +``` + +On other platforms, install *jrnl* using pip + +``` sh +pip install jrnl +``` + +Or, if you want the option to encrypt your journal, + +``` sh +pip install jrnl[encrypted] +``` + +to install the dependencies for encrypting journals as well. + + +!!! note + Installing the encryption library, `pycrypto`, requires a `gcc` compiler. For this reason, jrnl will + not install `pycrypto` unless explicitly told so like this. You can [install PyCrypto manually](https://www.dlitz.net/software/pycrypto/) + first or install it with `pip install pycrypto` if you have a `gcc` compiler. + Also note that when using zsh, the correct syntax is `pip install "jrnl[encrypted]"` (note the quotes). + +The first time you run `jrnl` you will be asked where your journal file +should be created and whether you wish to encrypt it. + +## Quickstart + +to make a new entry, just type + +``` sh +jrnl yesterday: Called in sick. Used the time to clean the house and spent 4h on writing my book. +``` + +and hit return. `yesterday:` will be interpreted as a time stamp. +Everything until the first sentence mark (`.?!:`) will be interpreted as +the title, the rest as the body. In your journal file, the result will +look like this: + +``` output +2012-03-29 09:00 Called in sick. +Used the time to clean the house and spent 4h on writing my book. +``` + +If you just call `jrnl`, you will be prompted to compose your entry - +but you can also configure *jrnl* to use your external editor. diff --git a/docs/overview.md b/docs/overview.md new file mode 100644 index 00000000..8a0ec10f --- /dev/null +++ b/docs/overview.md @@ -0,0 +1,31 @@ +# Overview + +## What is jrnl? + +`jrnl` is a simple journal application for +your command line. Journals are stored as human readable plain text +files - you can put them into a Dropbox folder for instant syncing and +you can be assured that your journal will still be readable in 2050, +when all your fancy iPad journal applications will long be forgotten. + +`jrnl` also plays nice with the fabulous +[DayOne](http://dayoneapp.com) and can read and write directly from and +to DayOne Journals. + +Optionally, your journal can be encrypted using the [256-bit +AES](http://en.wikipedia.org/wiki/Advanced_Encryption_Standard). + +## Why keep a journal? + +Journals aren't just for people who have too much +time on their summer vacation. A journal helps you to keep track of the +things you get done and how you did them. Your imagination may be +limitless, but your memory isn't. + +For personal use, make it a good habit to write at least 20 words a day. +Just to reflect what made this day special, why you haven't wasted it. + +For professional use, consider a text-based journal to be the perfect +complement to your GTD todo list - a documentation of what and how +you've done it. Or use it as a quick way to keep a change log. Or use it +to keep a lab book. diff --git a/docs/recipes.md b/docs/recipes.md new file mode 100644 index 00000000..7af5b246 --- /dev/null +++ b/docs/recipes.md @@ -0,0 +1,197 @@ +# FAQ + +## Recipes + +### Co-occurrence of tags + +If I want to find out how often I mentioned my flatmates Alberto and +Melo in the same entry, I run + +``` sh +jrnl @alberto --tags | grep @melo +``` + +And will get something like `@melo: 9`, meaning there are 9 entries +where both `@alberto` and `@melo` are tagged. How does this work? First, +`jrnl @alberto` will filter the journal to only entries containing the +tag `@alberto`, and then the `--tags` option will print out how often +each tag occurred in this filtered journal. Finally, we pipe this to +`grep` which will only display the line containing `@melo`. + +### Combining filters + +You can do things like + +``` sh +jrnl @fixed -starred -n 10 -until "jan 2013" --short +``` + +To get a short summary of the 10 most recent, favourited entries before +January 1, 2013 that are tagged with `@fixed`. + +### Statistics + +How much did I write last year? + +``` sh +jrnl -from "jan 1 2013" -until "dec 31 2013" | wc -w +``` + +Will give you the number of words you wrote in 2013. How long is my +average entry? + +``` sh +expr $(jrnl --export text | wc -w) / $(jrnl --short | wc -l) +``` + +This will first get the total number of words in the journal and divide +it by the number of entries (this works because `jrnl --short` will +print exactly one line per entry). + +### Importing older files + +If you want to import a file as an entry to jrnl, you can just do `jrnl +< entry.ext`. But what if you want the modification date of the file to +be the date of the entry in jrnl? Try this + +``` sh +echo `stat -f %Sm -t '%d %b %Y at %H:%M: ' entry.txt` `cat entry.txt` | jrnl +``` + +The first part will format the modification date of `entry.txt`, and +then combine it with the contents of the file before piping it to jrnl. +If you do that often, consider creating a function in your `.bashrc` or +`.bash_profile` + +``` sh +jrnlimport () { + echo `stat -f %Sm -t '%d %b %Y at %H:%M: ' $1` `cat $1` | jrnl +} +``` + +### Using templates + +Say you always want to use the same template for creating new entries. +If you have an [external editor](../advanced) set up, you can use this: + +```sh +jrnl < my_template.txt +jrnl -1 --edit +``` + +Another nice solution that allows you to define individual prompts comes +from [Jacobo de +Vera](https://github.com/maebert/jrnl/issues/194#issuecomment-47402869): + +``` sh +function log_question() +{ + echo $1 + read + jrnl today: ${1}. $REPLY +} +log_question 'What did I achieve today?' +log_question 'What did I make progress with?' +``` + +## External editors + +To use external editors for writing and editing journal entries, set +them up in your `.jrnl_config` (see `advanced usage ` for +details). Generally, after writing an entry, you will have to save and +close the file to save the changes to jrnl. + +### Sublime Text + +To use Sublime Text, install the command line tools for Sublime Text and +configure your `.jrnl_config` like this: + +``` json +{ + "editor": "subl -w" +} +``` + +Note the `-w` flag to make sure jrnl waits for Sublime Text to close the +file before writing into the journal. + +### MacVim + +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 +back to journal. In the case of MacVim, this is `-f`: + +``` json +{ + "editor": "mvim -f" +} +``` + +### iA Writer + +On OS X, you can use the fabulous [iA +Writer](http://www.iawriter.com/mac) to write entries. Configure your +`.jrnl_config` like this: + +``` json +{ + "editor": "open -b pro.writer.mac -Wn" +} +``` + +What does this do? `open -b ...` opens a file using the application +identified by the bundle identifier (a unique string for every app out +there). `-Wn` tells the application to wait until it's closed before +passing back control, and to use a new instance of the application. + +If the `pro.writer.mac` bundle identifier is not found on your system, +you can find the right string to use by inspecting iA Writer's +`Info.plist` file in your shell: + +``` sh +grep -A 1 CFBundleIdentifier /Applications/iA\ Writer.app/Contents/Info.plist +``` + +### Notepad++ on Windows + +To set [Notepad++](http://notepad-plus-plus.org/) as your editor, edit +the jrnl config file (`.jrnl_config`) like this: + +``` json +{ + "editor": "C:\\Program Files (x86)\\Notepad++\\notepad++.exe -multiInst -nosession", +} +``` + +The double backslashes are needed so jrnl can read the file path +correctly. The `-multiInst -nosession` options will cause jrnl to open +its own Notepad++ window. + +### Visual Studio Code + +To set [Visual Studo Code](https://code.visualstudio.com) as your editor on Linux, edit `.jrnl_config` like this: + +```json +{ + "editor": "/usr/bin/code --wait", +} +``` + +The `--wait` argument tells VS Code to wait for files to be written out before handing back control to jrnl. + +On MacOS you will need to add VS Code to your PATH. You can do that by adding: + +```sh +export PATH="\$PATH:/Applications/Visual Studio Code.app/Contents/Resources/app/bin" +``` +to your `.bash_profile`, or by running the **Install 'code' command in PATH** command from the command pallet in VS Code. + +Then you can add: + +```javascript +{ + "editor": "code --wait", +} +``` + +to ``.jrnl_config``. See also the [Visual Studio Code documentation](https://code.visualstudio.com/docs/setup/mac) diff --git a/docs/theme/img/banner_og.png b/docs/theme/img/banner_og.png new file mode 100644 index 00000000..9c0ec3d4 Binary files /dev/null and b/docs/theme/img/banner_og.png differ diff --git a/docs/theme/img/banner_twitter.png b/docs/theme/img/banner_twitter.png new file mode 100644 index 00000000..eab327cb Binary files /dev/null and b/docs/theme/img/banner_twitter.png differ diff --git a/docs/theme/img/favicon.ico b/docs/theme/img/favicon.ico new file mode 100644 index 00000000..feee4906 Binary files /dev/null and b/docs/theme/img/favicon.ico differ diff --git a/docs/theme/img/favicon.svg b/docs/theme/img/favicon.svg new file mode 100644 index 00000000..21beb0db --- /dev/null +++ b/docs/theme/img/favicon.svg @@ -0,0 +1,15 @@ + + + + favicon + Created with Sketch. + + + + + + + + + + \ No newline at end of file diff --git a/docs/theme/img/jrnl_white.svg b/docs/theme/img/jrnl_white.svg new file mode 100644 index 00000000..c324ab91 --- /dev/null +++ b/docs/theme/img/jrnl_white.svg @@ -0,0 +1,10 @@ + + + + jrnl_white + Created with Sketch. + + + + + \ No newline at end of file diff --git a/docs/theme/img/logo.svg b/docs/theme/img/logo.svg new file mode 100644 index 00000000..b843bed8 --- /dev/null +++ b/docs/theme/img/logo.svg @@ -0,0 +1,16 @@ + + + + logo + Created with Sketch. + + + + + + + + \ No newline at end of file diff --git a/docs/theme/img/logo_white.svg b/docs/theme/img/logo_white.svg new file mode 100644 index 00000000..e4e8e078 --- /dev/null +++ b/docs/theme/img/logo_white.svg @@ -0,0 +1,9 @@ + + + + logo_white + Created with Sketch. + + + + \ No newline at end of file diff --git a/docs/theme/img/sprites.svg b/docs/theme/img/sprites.svg new file mode 100644 index 00000000..32091555 --- /dev/null +++ b/docs/theme/img/sprites.svg @@ -0,0 +1,18 @@ + + + + sprites + Created with Sketch. + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/theme/index.css b/docs/theme/index.css new file mode 100644 index 00000000..db3fb0c5 --- /dev/null +++ b/docs/theme/index.css @@ -0,0 +1,307 @@ +article,aside,details,figcaption,figure,footer,header,hgroup,main,nav,section,summary{display:block}audio,canvas,video{display:inline-block}audio:not([controls]){display:none;height:0}[hidden]{display:none}html{font-family:sans-serif;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}a:focus{outline:thin dotted}a:active,a:hover{outline:0}h1{font-size:2em;margin:.67em 0}abbr[title]{border-bottom:1px dotted}b,strong{font-weight:bold}dfn{font-style:italic}hr{-moz-box-sizing:content-box;box-sizing:content-box;height:0}mark{background:#ff0;color:#000}code,kbd,pre,samp{font-family:monospace,serif;font-size:1em}pre{white-space:pre-wrap}q{quotes:"\201C" "\201D" "\2018" "\2019"}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sup{top:-0.5em}sub{bottom:-0.25em}img{border:0}svg:not(:root){overflow:hidden}figure{margin:0}fieldset{border:1px solid silver;margin:0 2px;padding:.35em .625em .75em}legend{border:0;padding:0}button,input,select,textarea{font-family:inherit;font-size:100%;margin:0}button,input{line-height:normal}button,select{text-transform:none}button,html input[type="button"],input[type="reset"],input[type="submit"]{-webkit-appearance:button;cursor:pointer}button[disabled],html input[disabled]{cursor:default}input[type="checkbox"],input[type="radio"]{box-sizing:border-box;padding:0}input[type="search"]{-webkit-appearance:textfield;-moz-box-sizing:content-box;-webkit-box-sizing:content-box;box-sizing:content-box}input[type="search"]::-webkit-search-cancel-button,input[type="search"]::-webkit-search-decoration{-webkit-appearance:none}button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0}textarea{overflow:auto;vertical-align:top}table{border-collapse:collapse;border-spacing:0} + +body +{ + background-color: #FAFDFE; + background-color: #f7f8f9; + font-family: "Open Sans", "Helvetica Neue", sans-serif; + font-weight: 300; +} + +.icon { + background-image: url("img/sprites.svg"); + width: 32px; + height: 32px; + display: inline-block; + font-size: 40px; + background-size: 200px 80px; +} + +h3 { font-weight: 400; } + +.icon.secure { + background-position: 0em 0em; +} + +.icon.future { + background-position: -1em 0em; +} + +.icon.search { + background-position: -2em 0em; +} + +.icon.nli { + background-position: -3em 0em; +} + +.icon.share { + background-position: 0em -1em; +} + +.icon.sync { + background-position: 0em -1em; +} + +.icon.dayone { + background-position: -1em -1em; +} + +.icon.github { + background-position: -2em -1em; +} + +.icon.folders { + background-position: -3em -1em; +} + +.icon.twitter { + background-position: -4em -1em; +} + +header { + background-image: linear-gradient(211deg, #95699C 0%, #604385 100%); + color: white; + border: 0px solid transparent; + display: relative; + padding-top: 150px; + overflow: hidden; +} + +#terminal { + background: #1B1C2E; + max-width: 520px; + box-shadow: 0 -2px 16px 0 rgba(0,0,0,0.35); + border-radius: 6px 6px 0 0; + min-height: 100px; + margin: 0px auto; + position: relative; + /*transform: translateY(40px);*/ + color: #f7f8f9; + font-family: "Monaco", "Courier New"; + font-size: 12pt; + padding: 45px 20px 0px 20px; + line-height: 165%; +} + +#terminal b { + font-weight: normal; + color: #C2CDD9; +} + +#terminal i { + font-style: normal; + color: #BB97BA; +} + +#terminal:before { + content: ''; + position: absolute; + top: 15px; + left: 15px; + display: inline-block; + width: 15px; + height: 15px; + border-radius: 50%; + background: #3B3B4A; + box-shadow: 25px 0 0 #3B3B4A, 50px 0 0 #3B3B4A; +} + +#typed:before { + content: "$ "; + color: #A879A7; +} + +#twitter { + display: block; + position: absolute; + text-decoration: none; + top: 20px; + right: 20px; + border: 1px solid white; + padding: 5px 10px; + color: white; + border-radius: 3px; + opacity: .7; +} + +#twitter .icon { + transform: scale(.5); + vertical-align: -18%; + margin: 0; + padding: 0; +} + +#twitter:hover, +#twitter:active { + opacity: 1; + text-decoration: none; +} + +#title { + max-width: 630px; + margin: 0 auto; + padding: 0px 20px; +} + +#prompt { + max-width: 700px; + margin: 100px auto 0px auto; + padding: 0px 20px; +} + +header img { + float: left; + margin-right: 30px; +} + +h1 { + color: white; + font-weight: 300; +} + +nav { + text-align: center; +} + +nav a#twitter-nav { + display: none; +} + +a { + color: #684688; + text-decoration: underline; +} + +nav a { + font-size: 14pt; + line-height: 40pt; + margin: 0 40px; +} + +a:hover { + color: #A3629F; +} + +nav a.cta { + display: inline-block; + color: white; + background-image: linear-gradient(259deg, #A3629F 0%, #604385 100%); + box-shadow: 0 2px 8px 0 rgba(0,0,0,0.25); + border-radius: 50px; + padding: 0px 30pt; + white-space: nowrap; + transition: all .1s ease; + font-weight: 600; + text-decoration: none; +} + +nav a.cta:hover { + text-decoration: none; + background-image: linear-gradient(259deg, #AE57A8 0%, #68419C 100%); + box-shadow: 0 4px 16px 0 rgba(0,0,0,0.25); + color: #f7f8f9; +} + +main { + padding: 50px 0 0 0; +} + + +.flex { + display: flex; + margin: 0 auto; + max-width: 920px; + flex-wrap: wrap; + padding: 20px 20px; + justify-content: space-between; + +} + +.flex section { + /*margin: 20px;*/ + margin-top: 40px; + width: 32%; +} + +.flex section:first-child { + margin-left: 0px; +} +.flex section:last-child { + margin-right: 0px; +} + + +.flex section i { + float: left; + left: 0; + display: block; + margin: 0px auto 10px auto; +} + +.flex section h3 { + margin-top: 0; + font-size: 14pt; + color: #684688; + margin-bottom: .5em; + font-weight: 300; + margin-left: 40px; +} + +.flex section p { + padding-left: 40px; + color: #888; + font-size: 12pt; + margin: 0; +} + +footer { + max-width: 700px; + margin: 20px auto; + padding: 0 20px 20px 20px; + font-size: 10pt; + opacity: .5; + text-align: center; +} + +@media screen and (max-width: 680px) { + .flex { + display: block; + padding: 0; + } + .flex section { + width: 100%; + } + + main { + padding: 20px; + margin: 0; + width: calc(100% - 40px); + } + + nav a, + nav a#twitter-nav { + display: inline-block; + margin: 0px 10px; + } + + nav a.cta { + display: block; + margin: 20px; + } + + header #twitter { + display: none; + } + + header #logo { + display: block; + float: none; + margin: 0px auto; + } + + header #title br { + display: none; + } + +} diff --git a/docs/theme/index.html b/docs/theme/index.html new file mode 100755 index 00000000..0c179210 --- /dev/null +++ b/docs/theme/index.html @@ -0,0 +1,126 @@ + + + + + + jrnl - The Command Line Journal + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +

Collect your thoughts and notes
without leaving the command line.

+
+
+
+
+
+
+
+
+ +
+
+ +

Human friendly.

+

jrnl has a natural-language interface so you don't have to remember cryptic shortcuts when you're writing down your thoughts.

+
+
+ +

Future-proof.

+

Your journals are stored in plain-text files that will still be readable in 50 years when all your fancy iPad apps will have gone the way of the Dodo.

+
+
+ +

Secure.

+

Encrypt your journals with the industry-strength AES encryption. The NSA won't be able to read your dirty secrets.

+
+
+ +

Accessible anywhere.

+

Sync your journals with Dropbox and capture your thoughts where ever you are

+
+
+ +

DayOne compatible.

+

Read, write and search your DayOne journal from the command line.

+
+
+ +

Free & Open Source.

+

jrnl is made by a bunch of really friendly and remarkably attractive people. Maybe even you?

+
+
+ +

For work and play.

+

Effortlessly access several journals for all parts of your life.

+
+
+
+ + + + + + diff --git a/docs/theme/index.js b/docs/theme/index.js new file mode 100644 index 00000000..7ad89342 --- /dev/null +++ b/docs/theme/index.js @@ -0,0 +1 @@ +var typed2 = diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 00000000..fa5050a0 --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,182 @@ +# Basic Usage + +`jrnl` has two modes: **composing** and **viewing**. Basically, whenever +you *don't* supply any arguments that start +with a dash or double-dash, you're in composing mode, meaning you can +write your entry on the command line or an editor of your choice. + +We intentionally break a convention on command line arguments: all +arguments starting with a *single dash* +will *filter* your journal before viewing +it, and can be combined arbitrarily. Arguments with a +*double dash* will control how your journal +is displayed or exported and are mutually exclusive (ie. you can only +specify one way to display or export your journal at a time). + +## Listing Journals + +You can list the journals accessible by jrnl + +``` sh +jrnl -ls +``` + +The journals displayed correspond to those specified in the jrnl +configuration file. + +## Composing Entries + +Composing mode is entered by either starting `jrnl` without any +arguments -- which will prompt you to write an entry or launch your +editor -- or by just writing an entry on the prompt, such as + +``` sh +jrnl today at 3am: I just met Steve Buscemi in a bar! He looked funny. +``` + +!!! note + Most shell contains a certain number of reserved characters, such as `#` + and `*`. Unbalanced quotes, parenthesis, and so on will also get into + the way of your editing. + For writing longer entries, just enter `jrnl` + and hit `return`. Only then enter the text of your journal entry. + Alternatively, `use an external editor `). + +You can also import an entry directly from a file + +``` sh +jrnl < my_entry.txt +``` + +### Smart timestamps + +Timestamps that work: + + - at 6am + - yesterday + - last monday + - sunday at noon + - 2 march 2012 + - 7 apr + - 5/20/1998 at 23:42 + +### Starring entries + +To mark an entry as a favourite, simply "star" it + +``` sh +jrnl last sunday *: Best day of my life. +``` + +If you don't want to add a date (ie. your entry will be dated as now), +The following options are equivalent: + + - `jrnl *: Best day of my life.` + - `jrnl *Best day of my life.` + - `jrnl Best day of my life.*` + +!!! note + Just make sure that the asterisk sign is **not** surrounded by + 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). + +## Viewing + +``` sh +jrnl -n 10 +``` + +will list you the ten latest entries (if you're lazy, `jrnl -10` will do +the same), + +``` sh +jrnl -from "last year" -until march +``` + +everything that happened from the start of last year to the start of +last march. To only see your favourite entries, use + +``` sh +jrnl -starred +``` + +## Using Tags + +Keep track of people, projects or locations, by tagging them with an `@` +in your entries + +``` sh +jrnl Had a wonderful day on the @beach with @Tom and @Anna. +``` + +You can filter your journal entries just like this: + +``` sh +jrnl @pinkie @WorldDomination +``` + +Will print all entries in which either `@pinkie` or `@WorldDomination` +occurred. + +``` sh +jrnl -n 5 -and @pineapple @lubricant +``` + +the last five entries containing both `@pineapple` **and** `@lubricant`. +You can change which symbols you'd like to use for tagging in the +configuration. + +!!! note + `jrnl @pinkie @WorldDomination` will switch to viewing mode because + although **no** command line arguments are given, all the input strings + look like tags - *jrnl* will assume you want to filter by tag. + +## Editing older entries + +You can edit selected entries after you wrote them. This is particularly +useful when your journal file is encrypted or if you're using a DayOne +journal. To use this feature, you need to have an editor configured in +your journal configuration file (see `advanced usage `) + +``` sh +jrnl -until 1950 @texas -and @history --edit +``` + +Will open your editor with all entries tagged with `@texas` and +`@history` before 1950. You can make any changes to them you want; after +you save the file and close the editor, your journal will be updated. + +Of course, if you are using multiple journals, you can also edit e.g. +the latest entry of your work journal with `jrnl work -n 1 --edit`. In +any case, this will bring up your editor and save (and, if applicable, +encrypt) your edited journal after you save and exit the editor. + +You can also use this feature for deleting entries from your journal + +``` sh +jrnl @girlfriend -until 'june 2012' --edit +``` + +Just select all text, press delete, and everything is gone... + +### Editing DayOne Journals + +DayOne journals can be edited exactly the same way, however the output +looks a little bit different because of the way DayOne stores its +entries: + +```md +# af8dbd0d43fb55458f11aad586ea2abf +2013-05-02 15:30 I told everyone I built my @robot wife for sex. +But late at night when we're alone we mostly play Battleship. + +# 2391048fe24111e1983ed49a20be6f9e +2013-08-10 03:22 I had all kinds of plans in case of a @zombie attack. +I just figured I'd be on the other side. +``` + +The long strings starting with hash symbol are the so-called UUIDs, +unique identifiers for each entry. Don't touch them. If you do, then the +old entry would get deleted and a new one written, which means that you +could lose DayOne data that jrnl can't handle (such as as the entry's +geolocation). diff --git a/features/core.feature b/features/core.feature new file mode 100644 index 00000000..ab86da42 --- /dev/null +++ b/features/core.feature @@ -0,0 +1,60 @@ +Feature: Basic reading and writing to a journal + + Scenario: Loading a sample journal + Given we use the config "basic.yaml" + When we run "jrnl -n 2" + Then we should get no error + and the output should be + """ + 2013-06-09 15:39 My first entry. + | Everything is alright + + 2013-06-10 15:40 Life is good. + | But I'm better. + """ + + Scenario: Writing an entry from command line + 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." + Then we should see the message "Entry added" + When we run "jrnl -n 1" + Then the output should contain "2013-07-23 09:00 A cold and stormy day." + + Scenario: Filtering for dates + Given we use the config "basic.yaml" + When we run "jrnl -on 2013-06-10 --short" + Then the output should be "2013-06-10 15:40 Life is good." + When we run "jrnl -on 'june 6 2013' --short" + Then the output should be "2013-06-10 15:40 Life is good." + + Scenario: Emoji support + Given we use the config "basic.yaml" + When we run "jrnl 23 july 2013: 🌞 sunny day. Saw an 🐘" + Then we should see the message "Entry added" + When we run "jrnl -n 1" + Then the output should contain "🌞" + and the output should contain "🐘" + + Scenario: Writing an entry at the prompt + Given we use the config "basic.yaml" + When we run "jrnl" and enter "25 jul 2013: I saw Elvis. He's alive." + Then we should get no error + and the journal should contain "[2013-07-25 09:00] I saw Elvis." + and the journal should contain "He's alive." + + Scenario: Displaying the version number + Given we use the config "basic.yaml" + When we run "jrnl -v" + Then we should get no error + Then the output should contain "version" + + Scenario: --short displays the short version of entries (only the title) + Given we use the config "basic.yaml" + When we run "jrnl -on 2013-06-10 --short" + Then the output should be "2013-06-10 15:40 Life is good." + + Scenario: -s displays the short version of entries (only the title) + Given we use the config "basic.yaml" + When we run "jrnl -on 2013-06-10 -s" + Then the output should be "2013-06-10 15:40 Life is good." + diff --git a/features/data/configs/basic.yaml b/features/data/configs/basic.yaml new file mode 100644 index 00000000..9111b561 --- /dev/null +++ b/features/data/configs/basic.yaml @@ -0,0 +1,12 @@ +default_hour: 9 +default_minute: 0 +editor: "" +encrypt: false +highlight: true +journals: + default: features/journals/simple.journal +linewrap: 80 +tagsymbols: "@" +template: false +timeformat: "%Y-%m-%d %H:%M" +indent_character: "|" diff --git a/features/data/configs/brackets.yaml b/features/data/configs/brackets.yaml new file mode 100644 index 00000000..e658947c --- /dev/null +++ b/features/data/configs/brackets.yaml @@ -0,0 +1,12 @@ +default_hour: 9 +default_minute: 0 +editor: "" +encrypt: false +highlight: true +journals: + default: features/journals/brackets.journal +linewrap: 80 +tagsymbols: "@" +template: false +timeformat: "%Y-%m-%d %H:%M" +indent_character: "|" diff --git a/features/data/configs/bug153.yaml b/features/data/configs/bug153.yaml new file mode 100644 index 00000000..765185c3 --- /dev/null +++ b/features/data/configs/bug153.yaml @@ -0,0 +1,13 @@ +default_hour: 9 +default_minute: 0 +editor: '' +encrypt: false +highlight: true +journals: + default: features/journals/bug153.dayone +linewrap: 80 +password: '' +tagsymbols: '@' +template: false +timeformat: '%Y-%m-%d %H:%M' +indent_character: "|" diff --git a/features/data/configs/bug343.yaml b/features/data/configs/bug343.yaml new file mode 100644 index 00000000..a4e25d8a --- /dev/null +++ b/features/data/configs/bug343.yaml @@ -0,0 +1,13 @@ +default_hour: 9 +default_minute: 0 +editor: '' +template: false +encrypt: false +highlight: true +journals: + simple: features/journals/simple.journal + work: features/journals/work.journal +linewrap: 80 +tagsymbols: '@' +timeformat: '%Y-%m-%d %H:%M' +indent_character: "|" diff --git a/features/data/configs/dayone.yaml b/features/data/configs/dayone.yaml new file mode 100644 index 00000000..e7331733 --- /dev/null +++ b/features/data/configs/dayone.yaml @@ -0,0 +1,13 @@ +default_hour: 9 +default_minute: 0 +editor: '' +template: false +encrypt: false +highlight: true +journals: + default: features/journals/dayone.dayone +linewrap: 80 +password: '' +tagsymbols: '@' +timeformat: '%Y-%m-%d %H:%M' +indent_character: "|" diff --git a/features/data/configs/empty_folder.yaml b/features/data/configs/empty_folder.yaml new file mode 100644 index 00000000..ee401ada --- /dev/null +++ b/features/data/configs/empty_folder.yaml @@ -0,0 +1,13 @@ +default_hour: 9 +default_minute: 0 +editor: '' +template: false +encrypt: false +highlight: true +journals: + default: features/journals/empty_folder +linewrap: 80 +password: '' +tagsymbols: '@' +timeformat: '%Y-%m-%d %H:%M' +indent_character: "|" diff --git a/features/data/configs/encrypted.yaml b/features/data/configs/encrypted.yaml new file mode 100644 index 00000000..254b81d9 --- /dev/null +++ b/features/data/configs/encrypted.yaml @@ -0,0 +1,13 @@ +default_hour: 9 +default_minute: 0 +editor: '' +encrypt: true +template: false +highlight: true +journals: + default: features/journals/encrypted.journal +linewrap: 80 +password: '' +tagsymbols: '@' +timeformat: '%Y-%m-%d %H:%M' +indent_character: "|" diff --git a/features/data/configs/encrypted_old.json b/features/data/configs/encrypted_old.json new file mode 100644 index 00000000..e69d9b79 --- /dev/null +++ b/features/data/configs/encrypted_old.json @@ -0,0 +1,13 @@ +{ + "default_hour": 9, + "default_minute": 0, + "editor": "", + "encrypt": true, + "highlight": true, + "journals": { + "default": "features/journals/encrypted_jrnl-1-9-5.journal" + }, + "linewrap": 80, + "tagsymbols": "@", + "timeformat": "%Y-%m-%d %H:%M" +} diff --git a/features/data/configs/encrypted_old.yaml b/features/data/configs/encrypted_old.yaml new file mode 100644 index 00000000..bc7b1440 --- /dev/null +++ b/features/data/configs/encrypted_old.yaml @@ -0,0 +1,11 @@ +default_hour: 9 +default_minute: 0 +editor: '' +encrypt: true +highlight: true +journals: + default: features/journals/encrypted_jrnl1-9-5.journal +linewrap: 80 +tagsymbols: '@' +timeformat: '%Y-%m-%d %H:%M' +indent_character: "|" diff --git a/features/data/configs/markdown-headings-335.yaml b/features/data/configs/markdown-headings-335.yaml new file mode 100644 index 00000000..dafed64b --- /dev/null +++ b/features/data/configs/markdown-headings-335.yaml @@ -0,0 +1,13 @@ +default_hour: 9 +default_minute: 0 +editor: '' +encrypt: false +highlight: true +template: false +journals: + default: features/journals/markdown-headings-335.journal +linewrap: 80 +password: '' +tagsymbols: '@' +timeformat: '%Y-%m-%d %H:%M' +indent_character: "|" diff --git a/features/data/configs/mostlyreadabledates.yaml b/features/data/configs/mostlyreadabledates.yaml new file mode 100644 index 00000000..5e3e1a15 --- /dev/null +++ b/features/data/configs/mostlyreadabledates.yaml @@ -0,0 +1,12 @@ +default_hour: 9 +default_minute: 0 +editor: "" +encrypt: false +highlight: true +journals: + default: features/journals/mostlyreadabledates.journal +linewrap: 80 +tagsymbols: "@" +template: false +timeformat: "%Y-%m-%d %H:%M" +indent_character: "|" diff --git a/features/data/configs/multiple.yaml b/features/data/configs/multiple.yaml new file mode 100644 index 00000000..2e282232 --- /dev/null +++ b/features/data/configs/multiple.yaml @@ -0,0 +1,19 @@ +default_hour: 9 +default_minute: 0 +editor: '' +encrypt: false +highlight: true +template: false +journals: + default: features/journals/simple.journal + ideas: features/journals/nothing.journal + simple: features/journals/simple.journal + work: features/journals/work.journal + new_encrypted: + encrypt: true + journal: features/journals/new_encrypted.journal +linewrap: 80 +password: '' +tagsymbols: '@' +timeformat: '%Y-%m-%d %H:%M' +indent_character: "|" diff --git a/features/data/configs/tags-216.yaml b/features/data/configs/tags-216.yaml new file mode 100644 index 00000000..73523c50 --- /dev/null +++ b/features/data/configs/tags-216.yaml @@ -0,0 +1,13 @@ +default_hour: 9 +default_minute: 0 +editor: '' +encrypt: false +highlight: true +template: false +journals: + default: features/journals/tags-216.journal +linewrap: 80 +password: '' +tagsymbols: '@' +timeformat: '%Y-%m-%d %H:%M' +indent_character: "|" diff --git a/features/data/configs/tags-237.yaml b/features/data/configs/tags-237.yaml new file mode 100644 index 00000000..2b360f6f --- /dev/null +++ b/features/data/configs/tags-237.yaml @@ -0,0 +1,13 @@ +default_hour: 9 +default_minute: 0 +editor: '' +encrypt: false +highlight: true +template: false +journals: + default: features/journals/tags-237.journal +linewrap: 80 +password: '' +tagsymbols: '@' +timeformat: '%Y-%m-%d %H:%M' +indent_character: "|" diff --git a/features/data/configs/tags.yaml b/features/data/configs/tags.yaml new file mode 100644 index 00000000..51a2b8b2 --- /dev/null +++ b/features/data/configs/tags.yaml @@ -0,0 +1,13 @@ +default_hour: 9 +default_minute: 0 +editor: '' +encrypt: false +highlight: true +template: false +journals: + default: features/journals/tags.journal +linewrap: 80 +password: '' +tagsymbols: '@' +timeformat: '%Y-%m-%d %H:%M' +indent_character: "|" diff --git a/features/data/configs/unreadabledates.yaml b/features/data/configs/unreadabledates.yaml new file mode 100644 index 00000000..474e7aae --- /dev/null +++ b/features/data/configs/unreadabledates.yaml @@ -0,0 +1,12 @@ +default_hour: 9 +default_minute: 0 +editor: "" +encrypt: false +highlight: true +journals: + default: features/journals/unreadabledates.journal +linewrap: 80 +tagsymbols: "@" +template: false +timeformat: "%Y-%m-%d %H:%M" +indent_character: "|" diff --git a/features/data/configs/upgrade_from_195.json b/features/data/configs/upgrade_from_195.json new file mode 100644 index 00000000..ec380372 --- /dev/null +++ b/features/data/configs/upgrade_from_195.json @@ -0,0 +1,11 @@ +{ +"default_hour": 9, +"timeformat": "%Y-%m-%d %H:%M", +"linewrap": 80, +"encrypt": false, +"editor": "", +"default_minute": 0, +"highlight": true, +"journals": {"default": "features/journals/simple_jrnl-1-9-5.journal"}, +"tagsymbols": "@" +} diff --git a/features/data/journals/brackets.journal b/features/data/journals/brackets.journal new file mode 100644 index 00000000..4649ea3e --- /dev/null +++ b/features/data/journals/brackets.journal @@ -0,0 +1,2 @@ +[2019-07-08 05:42] Entry subject +[1] line starting with 1 diff --git a/features/data/journals/bug153.dayone/entries/B40EE704E15846DE8D45C44118A4D511.doentry b/features/data/journals/bug153.dayone/entries/B40EE704E15846DE8D45C44118A4D511.doentry new file mode 100644 index 00000000..066821bb --- /dev/null +++ b/features/data/journals/bug153.dayone/entries/B40EE704E15846DE8D45C44118A4D511.doentry @@ -0,0 +1,56 @@ + + + + + Creation Date + 2013-10-27T02:27:27Z + Creator + + Device Agent + iPhone/iPhone3,1 + Generation Date + 2013-10-27T07:02:27Z + Host Name + omrt104001 + OS Agent + iOS/7.0.3 + Software Agent + Day One (iOS)/1.11.4 + + Entry Text + Some text. + Location + + Administrative Area + Östergötlands län + Country + Sverige + Latitude + 58.383400000000000 + Locality + City + Longitude + 15.577170000000000 + Place Name + Street + + Starred + + Time Zone + Europe/Stockholm + UUID + B40EE704E15846DE8D45C44118A4D511 + Weather + + Celsius + 12 + Description + Clear + Fahrenheit + 54 + IconName + sunnyn.png + + + diff --git a/features/data/journals/bug153.dayone/entries/B40EE704E15846DE8D45C44118A4D512.doentry b/features/data/journals/bug153.dayone/entries/B40EE704E15846DE8D45C44118A4D512.doentry new file mode 100644 index 00000000..ea3efec5 --- /dev/null +++ b/features/data/journals/bug153.dayone/entries/B40EE704E15846DE8D45C44118A4D512.doentry @@ -0,0 +1,52 @@ + + Creation Date + 2013-10-27T02:27:27Z + Creator + + Device Agent + iPhone/iPhone3,1 + Generation Date + 2013-10-27T07:02:27Z + Host Name + omrt104001 + OS Agent + iOS/7.0.3 + Software Agent + Day One (iOS)/1.11.4 + + Entry Text + This is not a valid plist. + Location + + Administrative Area + Östergötlands län + Country + Sverige + Latitude + 58.383400000000000 + Locality + City + Longitude + 15.577170000000000 + Place Name + Street + + Starred + + Time Zone + Europe/Stockholm + UUID + B40EE704E15846DE8D45C44118A4D511 + Weather + + Celsius + 12 + Description + Clear + Fahrenheit + 54 + IconName + sunnyn.png + + + diff --git a/features/data/journals/dayone.dayone/entries/044F3747A38546168B572C2E3F217FA2.doentry b/features/data/journals/dayone.dayone/entries/044F3747A38546168B572C2E3F217FA2.doentry new file mode 100644 index 00000000..1ac26242 --- /dev/null +++ b/features/data/journals/dayone.dayone/entries/044F3747A38546168B572C2E3F217FA2.doentry @@ -0,0 +1,34 @@ + + + + + Creation Date + 2013-05-17T18:39:20Z + Creator + + Device Agent + Macintosh/MacBookAir5,2 + Generation Date + 2013-08-17T18:39:20Z + Host Name + Egeria + OS Agent + Mac OS X/10.8.4 + Software Agent + Day One (Mac)/1.8 + + Entry Text + This entry has tags! + Starred + + Tags + + work + PLaY + + Time Zone + America/Los_Angeles + UUID + 044F3747A38546168B572C2E3F217FA2 + + diff --git a/features/data/journals/dayone.dayone/entries/0BDDD6CDA43C4A9AA2681517CC35AD9D.doentry b/features/data/journals/dayone.dayone/entries/0BDDD6CDA43C4A9AA2681517CC35AD9D.doentry new file mode 100644 index 00000000..927de884 --- /dev/null +++ b/features/data/journals/dayone.dayone/entries/0BDDD6CDA43C4A9AA2681517CC35AD9D.doentry @@ -0,0 +1,46 @@ + + + + + Creation Date + 2013-06-17T18:38:29Z + Creator + + Device Agent + Macintosh/MacBookAir5,2 + Generation Date + 2013-08-17T18:38:29Z + Host Name + Egeria + OS Agent + Mac OS X/10.8.4 + Software Agent + Day One (Mac)/1.8 + + Entry Text + This entry has a location. + Location + + Administrative Area + California + Country + Germany + Latitude + 52.4979764 + Locality + Berlin + Longitude + 13.2404758 + Place Name + Abandoned Spy Tower + + Starred + + Tags + + Time Zone + Europe/Berlin + UUID + 0BDDD6CDA43C4A9AA2681517CC35AD9D + + diff --git a/features/data/journals/dayone.dayone/entries/422BC895507944A291E6FC44FC6B8BFC.doentry b/features/data/journals/dayone.dayone/entries/422BC895507944A291E6FC44FC6B8BFC.doentry new file mode 100644 index 00000000..16260763 --- /dev/null +++ b/features/data/journals/dayone.dayone/entries/422BC895507944A291E6FC44FC6B8BFC.doentry @@ -0,0 +1,31 @@ + + + + + Creation Date + 2013-07-17T18:38:08Z + Creator + + Device Agent + Macintosh/MacBookAir5,2 + Generation Date + 2013-08-17T18:38:08Z + Host Name + Egeria + OS Agent + Mac OS X/10.8.4 + Software Agent + Day One (Mac)/1.8 + + Entry Text + This entry is starred! + Starred + + Tags + + Time Zone + America/Los_Angeles + UUID + 422BC895507944A291E6FC44FC6B8BFC + + diff --git a/features/data/journals/dayone.dayone/entries/4BB1F46946AD439996C9B59DE7C4DDC1.doentry b/features/data/journals/dayone.dayone/entries/4BB1F46946AD439996C9B59DE7C4DDC1.doentry new file mode 100644 index 00000000..9ebaf538 --- /dev/null +++ b/features/data/journals/dayone.dayone/entries/4BB1F46946AD439996C9B59DE7C4DDC1.doentry @@ -0,0 +1,29 @@ + + + + + Creation Date + 2013-01-17T18:37:50Z + Creator + + Device Agent + Macintosh/MacBookAir5,2 + Generation Date + 2013-08-17T18:37:50Z + Host Name + Egeria + OS Agent + Mac OS X/10.8.4 + Software Agent + Day One (Mac)/1.8 + + Entry Text + This is a DayOne entry without Timezone. + Starred + + Tags + + UUID + 4BB1F46946AD439996C9B59DE7C4DDC1 + + diff --git a/features/data/journals/empty_folder/empty.txt b/features/data/journals/empty_folder/empty.txt new file mode 100644 index 00000000..175b82b5 --- /dev/null +++ b/features/data/journals/empty_folder/empty.txt @@ -0,0 +1 @@ +Nothing to see here diff --git a/features/data/journals/encrypted.journal b/features/data/journals/encrypted.journal new file mode 100644 index 00000000..d2a5fcbe --- /dev/null +++ b/features/data/journals/encrypted.journal @@ -0,0 +1 @@ +gAAAAABVIHB7tnwKExG7aC5ZbAbBL9SG2oY2GENeoOJ22i1PZigOvCYvrQN3kpsu0KGr7ay5K-_46R5YFlqJvtQ8anPH2FSITsaZy-l5Lz_5quw3rmzhLwAR1tc0icgtR4MEpXEdsuQ7cyb12Xq-JLDrnATs0id5Vow9Ri_tE7Xe4BXgXaySn3aRPwWKoninVxVPVvETY3MXHSUEXV9OZ-pH5kYBLGYbLA== diff --git a/features/data/journals/encrypted_jrnl-1-9-5.journal b/features/data/journals/encrypted_jrnl-1-9-5.journal new file mode 100644 index 00000000..339b47ba Binary files /dev/null and b/features/data/journals/encrypted_jrnl-1-9-5.journal differ diff --git a/features/data/journals/markdown-headings-335.journal b/features/data/journals/markdown-headings-335.journal new file mode 100644 index 00000000..30f592ef --- /dev/null +++ b/features/data/journals/markdown-headings-335.journal @@ -0,0 +1,42 @@ +[2015-04-14 13:23] Heading Test + +H1-1 += + +H1-2 +=== + +H1-3 +============================ + +H2-1 +- + +H2-2 +--- + +H2-3 +---------------------------------- + +Horizontal Rules (ignore) + +--- + +=== + +# ATX H1 + +## ATX H2 + +### ATX H3 + +#### ATX H4 + +##### ATX H5 + +###### ATX H6 + +Stuff + +More stuff +more stuff again diff --git a/features/data/journals/mostlyreadabledates.journal b/features/data/journals/mostlyreadabledates.journal new file mode 100644 index 00000000..bd211bf5 --- /dev/null +++ b/features/data/journals/mostlyreadabledates.journal @@ -0,0 +1,8 @@ +[2019-07-18 14:23] Entry subject +Time machines are possible. I know, because I've built one in my garage. + +[2019-07-19 14:23] Entry subject +I'm going to activate the machine. Nobody knows what comes next after this. Or before this? + +[2019-07 14:23] Entry subject +I've crossed so many timelines. Is there any going back? diff --git a/features/data/journals/simple.journal b/features/data/journals/simple.journal new file mode 100644 index 00000000..8336068e --- /dev/null +++ b/features/data/journals/simple.journal @@ -0,0 +1,5 @@ +[2013-06-09 15:39] My first entry. +Everything is alright + +[2013-06-10 15:40] Life is good. +But I'm better. diff --git a/features/data/journals/simple_jrnl-1-9-5.journal b/features/data/journals/simple_jrnl-1-9-5.journal new file mode 100644 index 00000000..7bb6c5ac --- /dev/null +++ b/features/data/journals/simple_jrnl-1-9-5.journal @@ -0,0 +1,13 @@ +2010-06-10 15:00 A life without chocolate is like a bad analogy. + +2013-06-10 15:40 He said "[this] is the best time to be alive". +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent malesuada +quis est ac dignissim. Aliquam dignissim rutrum pretium. Phasellus pellentesque +augue et venenatis facilisis. + +[2019-08-03 12:55] Some chat log or something + +Suspendisse potenti. Sed dignissim sed nisl eu consequat. Aenean ante ex, +elementum ut interdum et, mattis eget lacus. In commodo nulla nec tellus +placerat, sed ultricies metus bibendum. Duis eget venenatis erat. In at dolor +dui. diff --git a/features/data/journals/tags-216.journal b/features/data/journals/tags-216.journal new file mode 100644 index 00000000..08b6d630 --- /dev/null +++ b/features/data/journals/tags-216.journal @@ -0,0 +1,2 @@ +[2013-06-10 15:40] I programmed for @OS/2. +Almost makes me want to go back to @C++, though. (Still better than @C#). diff --git a/features/data/journals/tags-237.journal b/features/data/journals/tags-237.journal new file mode 100644 index 00000000..be050652 --- /dev/null +++ b/features/data/journals/tags-237.journal @@ -0,0 +1,3 @@ +[2014-07-22 11:11] This entry has an email. +@Newline tag should show as a tag. +Kyla's @email is kyla@clevelandunderdog.org and Guinness's is guinness@fortheloveofpits.org. diff --git a/features/data/journals/tags.journal b/features/data/journals/tags.journal new file mode 100644 index 00000000..a28f3159 --- /dev/null +++ b/features/data/journals/tags.journal @@ -0,0 +1,8 @@ +[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 diff --git a/features/data/journals/unreadabledates.journal b/features/data/journals/unreadabledates.journal new file mode 100644 index 00000000..53ef1d60 --- /dev/null +++ b/features/data/journals/unreadabledates.journal @@ -0,0 +1,5 @@ +[ashasd7zdskhz7asdkjasd] Entry subject +I've lost track of time. + +[sadfhakjsdf88sdf7sdff] Entry subject +Time has no meaning. diff --git a/features/data/journals/work.journal b/features/data/journals/work.journal new file mode 100644 index 00000000..e69de29b diff --git a/features/data/templates/sample.template b/features/data/templates/sample.template new file mode 100644 index 00000000..a356d823 --- /dev/null +++ b/features/data/templates/sample.template @@ -0,0 +1,19 @@ +--- +extension: txt +--- + +{% block journal %} +{% for entry in entries %} +{% include entry %} +{% endfor %} + +{% endblock %} + +{% block entry %} +{{ entry.title }} +{{ "-" * len(entry.title) }} + +{{ entry.body }} + +{% endblock %} +` diff --git a/features/encryption.feature b/features/encryption.feature new file mode 100644 index 00000000..82d971eb --- /dev/null +++ b/features/encryption.feature @@ -0,0 +1,31 @@ + Feature: Encrypted journals + Scenario: Loading an encrypted journal + Given we use the config "encrypted.yaml" + When we run "jrnl -n 1" and enter "bad doggie no biscuit" + Then we should see the message "Password" + and the output should contain "2013-06-10 15:40 Life is good" + + Scenario: Decrypting a journal + Given we use the config "encrypted.yaml" + 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 we should see the message "Journal decrypted" + and the journal should have 2 entries + + Scenario: Encrypting a journal + Given we use the config "basic.yaml" + When we run "jrnl --encrypt" and enter "swordfish" + Then 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 we should see the message "Password" + and the output should contain "2013-06-10 15:40 Life is good" + + Scenario: Storing a password in Keychain + Given we use the config "multiple.yaml" + When we run "jrnl simple --encrypt" and enter "sabertooth" + When we set the keychain password of "simple" to "sabertooth" + Then the config for journal "simple" should have "encrypt" set to "bool:True" + When we run "jrnl simple -n 1" + Then we should not see the message "Password" + and the output should contain "2013-06-10 15:40 Life is good" diff --git a/features/environment.py b/features/environment.py new file mode 100644 index 00000000..6f9ac5df --- /dev/null +++ b/features/environment.py @@ -0,0 +1,42 @@ +from behave import * +import shutil +import os +import jrnl +try: + from io import StringIO +except ImportError: + from cStringIO import StringIO + +def before_scenario(context, scenario): + """Before each scenario, backup all config and journal test data.""" + context.messages = StringIO() + jrnl.util.STDERR = context.messages + jrnl.util.TEST = True + + # Clean up in case something went wrong + for folder in ("configs", "journals"): + working_dir = os.path.join("features", folder) + if os.path.exists(working_dir): + shutil.rmtree(working_dir) + + + for folder in ("configs", "journals"): + original = os.path.join("features", "data", folder) + working_dir = os.path.join("features", folder) + if not os.path.exists(working_dir): + os.mkdir(working_dir) + for filename in os.listdir(original): + source = os.path.join(original, filename) + if os.path.isdir(source): + shutil.copytree(source, os.path.join(working_dir, filename)) + else: + shutil.copy2(source, working_dir) + +def after_scenario(context, scenario): + """After each scenario, restore all test data and remove working_dirs.""" + context.messages.close() + context.messages = None + for folder in ("configs", "journals"): + working_dir = os.path.join("features", folder) + if os.path.exists(working_dir): + shutil.rmtree(working_dir) diff --git a/features/exporting.feature b/features/exporting.feature new file mode 100644 index 00000000..db2ef5b3 --- /dev/null +++ b/features/exporting.feature @@ -0,0 +1,85 @@ +Feature: Exporting a Journal + + Scenario: Exporting to json + Given we use the config "tags.yaml" + When we run "jrnl --export json" + Then we should get no error + and the output should be parsable as json + 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 "@journal" + and "tags" in the json output should contain "@dan" + + Scenario: Exporting using filters should only export parts of the journal + Given we use the config "tags.yaml" + When we run "jrnl -until 'may 2013' --export json" + # Then we should get no error + Then the output should be parsable as json + 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 "@journal" + and "tags" in the json output should not contain "@dan" + + Scenario: Exporting using custom templates + Given we use the config "basic.yaml" + Given we load template "sample.template" + When we run "jrnl --export sample" + Then the output should be + """ + My first entry. + --------------- + + Everything is alright + + Life is good. + ------------- + + But I'm better. + """ + + Scenario: Increasing Headings on Markdown export + Given we use the config "markdown-headings-335.yaml" + When we run "jrnl --export markdown" + Then the output should be + """ + # 2015 + + ## April + + ### 2015-04-14 13:23 Heading Test + + #### H1-1 + + #### H1-2 + + #### H1-3 + + ##### H2-1 + + ##### H2-2 + + ##### H2-3 + + Horizontal Rules (ignore) + + --- + + === + + #### ATX H1 + + ##### ATX H2 + + ###### ATX H3 + + ####### ATX H4 + + ######## ATX H5 + + ######### ATX H6 + + Stuff + + More stuff + more stuff again + """ diff --git a/features/multiple_journals.feature b/features/multiple_journals.feature new file mode 100644 index 00000000..1d4943ee --- /dev/null +++ b/features/multiple_journals.feature @@ -0,0 +1,46 @@ +Feature: Multiple journals + + Scenario: Loading a config with two journals + Given we use the config "multiple.yaml" + Then journal "default" should have 2 entries + and journal "work" should have 0 entries + + Scenario: Write to default config by default + Given we use the config "multiple.yaml" + When we run "jrnl this goes to default" + Then journal "default" should have 3 entries + and journal "work" should have 0 entries + + Scenario: Write to specified journal + Given we use the config "multiple.yaml" + When we run "jrnl work a long day in the office" + Then journal "default" should have 2 entries + and journal "work" should have 1 entry + + Scenario: Tell user which journal was used + Given we use the config "multiple.yaml" + When we run "jrnl work a long day in the office" + Then we should see the message "Entry added to work journal" + + Scenario: Write to specified journal with a timestamp + Given we use the config "multiple.yaml" + When we run "jrnl work 23 july 2012: a long day in the office" + Then journal "default" should have 2 entries + and journal "work" should have 1 entry + and journal "work" should contain "2012-07-23" + + Scenario: Create new journals as required + Given we use the config "multiple.yaml" + Then journal "ideas" should not exist + When we run "jrnl ideas 23 july 2012: sell my junk on ebay and make lots of money" + Then journal "ideas" should have 1 entry + + Scenario: Don't crash if no default journal is specified + Given we use the config "bug343.yaml" + When we run "jrnl a long day in the office" + Then we should see the message "No default journal configured" + + Scenario: Don't crash if no file exists for a configured encrypted journal + Given we use the config "multiple.yaml" + When we run "jrnl new_encrypted Adding first entry" and enter "these three eyes" + Then we should see the message "Journal 'new_encrypted' created" diff --git a/features/regression.feature b/features/regression.feature new file mode 100644 index 00000000..3e644b19 --- /dev/null +++ b/features/regression.feature @@ -0,0 +1,64 @@ +Feature: Zapped bugs should stay dead. + + Scenario: Writing an entry does not print the entire journal + # https://github.com/maebert/jrnl/issues/87 + 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." + Then we should see the message "Entry added" + When we run "jrnl -n 1" + Then the output should not contain "Life is good" + + Scenario: Date with time should be parsed correctly + # https://github.com/maebert/jrnl/issues/117 + Given we use the config "basic.yaml" + When we run "jrnl 2013-11-30 15:42: Project Started." + Then we should see the message "Entry added" + and the journal should contain "[2013-11-30 15:42] Project Started." + + 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 + #https://github.com/maebert/jrnl/issues/153 + Given we use the config "bug153.yaml" + When we run "jrnl -1" + Then we should get no error + and the output should be + """ + 2013-10-27 03:27 Some text. + """ + + Scenario: Title with an embedded period. + Given we use the config "basic.yaml" + When we run "jrnl 04-24-2014: Created a new website - empty.com. Hope to get a lot of traffic." + Then we should see the message "Entry added" + When we run "jrnl -1" + Then the output should be + """ + 2014-04-24 09:00 Created a new website - empty.com. + | Hope to get a lot of traffic. + """ + + Scenario: Integers in square brackets should not be read as dates + Given we use the config "brackets.yaml" + When we run "jrnl -1" + Then the output should contain "[1] line starting with 1" + + Scenario: Journals with unreadable dates should still be viewable + Given we use the config "unreadabledates.yaml" + When we run "jrnl -2" + Then the output should contain "I've lost track of time." + Then the output should contain "Time has no meaning." + + Scenario: Journals with readable dates AND unreadable dates should still contain all data. + Given we use the config "mostlyreadabledates.yaml" + When we run "jrnl -3" + Then the output should contain "Time machines are possible." + When we run "jrnl -1" + Then the output should contain "I'm going to activate the machine." + Then the output should contain "I've crossed so many timelines. Is there any going back?" + diff --git a/features/starring.feature b/features/starring.feature new file mode 100644 index 00000000..18113dc3 --- /dev/null +++ b/features/starring.feature @@ -0,0 +1,20 @@ +Feature: Starring entries + + Scenario: Starring an entry will mark it in the journal file + Given we use the config "basic.yaml" + When we run "jrnl 20 july 2013 *: Best day of my life!" + Then we should see the message "Entry added" + and the journal should contain "[2013-07-20 09:00] Best day of my life! *" + + Scenario: Filtering by starred entries + Given we use the config "basic.yaml" + When we run "jrnl -starred" + Then the output should be + """ + """ + When we run "jrnl 20 july 2013 *: Best day of my life!" + When we run "jrnl -starred" + Then the output should be + """ + 2013-07-20 09:00 Best day of my life! + """ diff --git a/features/steps/core.py b/features/steps/core.py new file mode 100644 index 00000000..83981d13 --- /dev/null +++ b/features/steps/core.py @@ -0,0 +1,260 @@ +from __future__ import unicode_literals +from __future__ import absolute_import + +from behave import given, when, then +from jrnl import cli, install, Journal, util, plugins +from jrnl import __version__ +from dateutil import parser as date_parser +from collections import defaultdict +import os +import json +import yaml +import keyring + + +class TestKeyring(keyring.backend.KeyringBackend): + """A test keyring that just stores its valies in a hash""" + + priority = 1 + keys = defaultdict(dict) + + def set_password(self, servicename, username, password): + self.keys[servicename][username] = password + + def get_password(self, servicename, username): + return self.keys[servicename].get(username) + + def delete_password(self, servicename, username, password): + self.keys[servicename][username] = None + +# set the keyring for keyring lib +keyring.set_keyring(TestKeyring()) + + +try: + from io import StringIO +except ImportError: + from cStringIO import StringIO +import tzlocal +import shlex +import sys + + +def ushlex(command): + if sys.version_info[0] == 3: + return shlex.split(command) + return map(lambda s: s.decode('UTF8'), shlex.split(command.encode('utf8'))) + + +def read_journal(journal_name="default"): + config = util.load_config(install.CONFIG_FILE_PATH) + with open(config['journals'][journal_name]) as journal_file: + journal = journal_file.read() + return journal + + +def open_journal(journal_name="default"): + config = util.load_config(install.CONFIG_FILE_PATH) + journal_conf = config['journals'][journal_name] + if type(journal_conf) is dict: # We can override the default config on a by-journal basis + config.update(journal_conf) + else: # But also just give them a string to point to the journal file + config['journal'] = journal_conf + return Journal.open_journal(journal_name, config) + + +@given('we use the config "{config_file}"') +def set_config(context, config_file): + full_path = os.path.join("features/configs", config_file) + install.CONFIG_FILE_PATH = os.path.abspath(full_path) + if config_file.endswith("yaml"): + # Add jrnl version to file for 2.x journals + with open(install.CONFIG_FILE_PATH, 'a') as cf: + cf.write("version: {}".format(__version__)) + + +@when('we run "{command}" and enter') +@when('we run "{command}" and enter "{inputs}"') +def run_with_input(context, command, inputs=None): + text = inputs or context.text + args = ushlex(command)[1:] + buffer = StringIO(text.strip()) + util.STDIN = buffer + try: + cli.run(args or []) + context.exit_status = 0 + except SystemExit as e: + context.exit_status = e.code + + +@when('we run "{command}"') +def run(context, command): + args = ushlex(command)[1:] + try: + cli.run(args or None) + context.exit_status = 0 + except SystemExit as e: + context.exit_status = e.code + + +@given('we load template "{filename}"') +def load_template(context, filename): + full_path = os.path.join("features/data/templates", filename) + exporter = plugins.template_exporter.__exporter_from_file(full_path) + plugins.__exporter_types[exporter.names[0]] = exporter + + +@when('we set the keychain password of "{journal}" to "{password}"') +def set_keychain(context, journal, password): + keyring.set_password('jrnl', journal, password) + + +@then('we should get an error') +def has_error(context): + assert context.exit_status != 0, context.exit_status + + +@then('we should get no error') +def no_error(context): + assert context.exit_status is 0, context.exit_status + + +@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') +@then('the output should be "{text}"') +def check_output(context, text=None): + text = (text or context.text).strip().splitlines() + out = context.stdout_capture.getvalue().strip().splitlines() + assert len(text) == len(out), "Output has {} lines (expected: {})".format(len(out), len(text)) + for line_text, line_out in zip(text, out): + assert line_text.strip() == line_out.strip(), [line_text.strip(), line_out.strip()] + + +@then('the output should contain "{text}" in the local time') +def check_output_time_inline(context, text): + out = context.stdout_capture.getvalue() + local_tz = tzlocal.get_localzone() + utc_time = date_parser.parse(text) + local_date = utc_time.astimezone(local_tz).strftime("%Y-%m-%d %H:%M") + assert local_date in out, local_date + + +@then('the output should contain') +@then('the output should contain "{text}"') +def check_output_inline(context, text=None): + text = text or context.text + out = context.stdout_capture.getvalue() + if isinstance(out, bytes): + out = out.decode('utf-8') + assert text in out, text + + +@then('the output should not contain "{text}"') +def check_output_not_inline(context, text): + out = context.stdout_capture.getvalue() + if isinstance(out, bytes): + out = out.decode('utf-8') + assert text not in out + + +@then('we should see the message "{text}"') +def check_message(context, text): + out = context.messages.getvalue() + assert text in out, [text, out] + + +@then('we should not see the message "{text}"') +def check_not_message(context, text): + out = context.messages.getvalue() + assert text not in out, [text, out] + + +@then('the journal should contain "{text}"') +@then('journal "{journal_name}" should contain "{text}"') +def check_journal_content(context, text, journal_name="default"): + journal = read_journal(journal_name) + assert text in journal, journal + + +@then('journal "{journal_name}" should not exist') +def journal_doesnt_exist(context, journal_name="default"): + with open(install.CONFIG_FILE_PATH) as config_file: + config = yaml.load(config_file, Loader=yaml.FullLoader) + journal_path = config['journals'][journal_name] + assert not os.path.exists(journal_path) + + +@then('the config 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): + t, value = value.split(":") + value = { + "bool": lambda v: v.lower() == "true", + "int": int, + "str": str + }[t](value) + config = util.load_config(install.CONFIG_FILE_PATH) + if journal: + config = config["journals"][journal] + assert key in config + assert config[key] == value + + +@then('the journal should have {number:d} entries') +@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} entry') +def check_journal_entries(context, number, journal_name="default"): + journal = open_journal(journal_name) + assert len(journal.entries) == number + + +@then('fail') +def debug_fail(context): + assert False diff --git a/features/tagging.feature b/features/tagging.feature new file mode 100644 index 00000000..2cbf7ce1 --- /dev/null +++ b/features/tagging.feature @@ -0,0 +1,84 @@ +Feature: Tagging + + Scenario: Displaying tags + Given we use the config "tags.yaml" + When we run "jrnl --tags" + Then we should get no error + and the output should be + """ + @idea : 2 + @journal : 1 + @dan : 1 + """ + + Scenario: Filtering journals should also filter tags + Given we use the config "tags.yaml" + When we run "jrnl -from 'may 2013' --tags" + Then we should get no error + and the output should be + """ + @idea : 1 + @dan : 1 + """ + + Scenario: Tags should allow certain special characters + Given we use the config "tags-216.yaml" + When we run "jrnl --tags" + Then we should get no error + and the output should be + """ + @os/2 : 1 + @c++ : 1 + @c# : 1 + """ + Scenario: An email should not be a tag + Given we use the config "tags-237.yaml" + When we run "jrnl --tags" + Then we should get no error + and the output should be + """ + @newline : 1 + @email : 1 + """ + + Scenario: Entry cans start and end with tags + Given we use the config "basic.yaml" + When we run "jrnl today: @foo came over, we went to a @bar" + When we run "jrnl --tags" + Then the output should be + """ + @foo : 1 + @bar : 1 + """ + + Scenario: Excluding a tag should filter it + Given we use the config "basic.yaml" + When we run "jrnl today: @foo came over, we went to a bar" + When we run "jrnl I have decided I did not enjoy that @bar" + When we run "jrnl --tags -not @bar" + Then the output should be + """ + @foo : 1 + """ + + Scenario: Excluding a tag should filter an entry, even if an unfiltered tag is in that entry + Given we use the config "basic.yaml" + When we run "jrnl today: I do @not think this will show up @thought" + When we run "jrnl today: I think this will show up @thought" + When we run "jrnl --tags -not @not" + Then the output should be + """ + @thought : 1 + """ + + Scenario: Excluding multiple tags should filter them + Given we use the config "basic.yaml" + When we run "jrnl today: I do @not think this will show up @thought" + When we run "jrnl today: I think this will show up @thought" + When we run "jrnl today: This should @never show up @thought" + When we run "jrnl today: What a nice day for filtering @thought" + When we run "jrnl --tags -not @not @never" + Then the output should be + """ + @thought : 2 + """ diff --git a/features/upgrade.feature b/features/upgrade.feature new file mode 100644 index 00000000..bce026b8 --- /dev/null +++ b/features/upgrade.feature @@ -0,0 +1,23 @@ +Feature: Upgrading Journals from 1.x.x to 2.x.x + + Scenario: Upgrade and parse journals with square brackets + Given we use the config "upgrade_from_195.json" + When we run "jrnl -9" and enter "Y" + Then the output should contain + """ + 2010-06-10 15:00 A life without chocolate is like a bad analogy. + + 2013-06-10 15:40 He said "[this] is the best time to be alive". + """ + Then the journal should have 2 entries + + Scenario: Upgrading a journal encrypted with jrnl 1.x + Given we use the config "encrypted_old.json" + When we run "jrnl -n 1" and enter + """ + Y + bad doggie no biscuit + bad doggie no biscuit + """ + Then we should see the message "Password" + and the output should contain "2013-06-10 15:40 Life is good" diff --git a/jrnl/DayOneJournal.py b/jrnl/DayOneJournal.py new file mode 100644 index 00000000..9e988f78 --- /dev/null +++ b/jrnl/DayOneJournal.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python +# encoding: utf-8 + +from __future__ import absolute_import, unicode_literals +from . import Entry +from . import Journal +from . import time as jrnl_time +import os +import re +from datetime import datetime +import time +import fnmatch +import plistlib +import pytz +import uuid +import tzlocal +from xml.parsers.expat import ExpatError + + +class DayOne(Journal.Journal): + """A special Journal handling DayOne files""" + + # InvalidFileException was added to plistlib in Python3.4 + PLIST_EXCEPTIONS = (ExpatError, plistlib.InvalidFileException) if hasattr(plistlib, "InvalidFileException") else ExpatError + + def __init__(self, **kwargs): + self.entries = [] + self._deleted_entries = [] + super(DayOne, self).__init__(**kwargs) + + 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 = [] + for root, dirnames, f in os.walk(self.config['journal']): + for filename in fnmatch.filter(f, '*.doentry'): + filenames.append(os.path.join(root, filename)) + self.entries = [] + for filename in filenames: + with open(filename, 'rb') as plist_entry: + try: + dict_entry = plistlib.readPlist(plist_entry) + except self.PLIST_EXCEPTIONS: + pass + else: + try: + timezone = pytz.timezone(dict_entry['Time Zone']) + except (KeyError, pytz.exceptions.UnknownTimeZoneError): + timezone = tzlocal.get_localzone() + date = dict_entry['Creation Date'] + 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._tags = [self.config['tagsymbols'][0] + tag.lower() for tag in dict_entry.get("Tags", [])] + + self.entries.append(entry) + self.sort() + return self + + def write(self): + """Writes only the entries that have been modified into plist files.""" + for entry in self.entries: + if entry.modified: + utc_time = datetime.utcfromtimestamp(time.mktime(entry.date.timetuple())) + + if not hasattr(entry, "uuid"): + entry.uuid = uuid.uuid1().hex + + filename = os.path.join(self.config['journal'], "entries", entry.uuid.upper() + ".doentry") + + entry_plist = { + 'Creation Date': utc_time, + 'Starred': entry.starred if hasattr(entry, 'starred') else False, + 'Entry Text': entry.title + "\n" + entry.body, + 'Time Zone': str(tzlocal.get_localzone()), + 'UUID': entry.uuid.upper(), + 'Tags': [tag.strip(self.config['tagsymbols']).replace("_", " ") for tag in entry.tags] + } + plistlib.writePlist(entry_plist, filename) + for entry in self._deleted_entries: + filename = os.path.join(self.config['journal'], "entries", entry.uuid + ".doentry") + os.remove(filename) + + def editable_str(self): + """Turns the journal into a string of entries that can be edited + manually and later be parsed with eslf.parse_editable_str.""" + return "\n".join(["# {0}\n{1}".format(e.uuid, e.__unicode__()) for e in self.entries]) + + def parse_editable_str(self, edited): + """Parses the output of self.editable_str and updates its entries.""" + # Method: create a new list of entries from the edited text, then match + # UUIDs of the new entries against self.entries, updating the entries + # if the edited entries differ, and deleting entries from self.entries + # if they don't show up in the edited entries anymore. + + # Initialise our current entry + entries = [] + current_entry = None + + for line in edited.splitlines(): + # try to parse line as UUID => new entry begins + line = line.rstrip() + m = re.match("# *([a-f0-9]+) *$", line.lower()) + if m: + if current_entry: + entries.append(current_entry) + current_entry = Entry.Entry(self) + current_entry.modified = False + current_entry.uuid = m.group(1).lower() + else: + date_blob_re = re.compile("^\[[^\\]]+\] ") + date_blob = date_blob_re.findall(line) + if date_blob: + date_blob = date_blob[0] + new_date = jrnl_time.parse(date_blob.strip(" []")) + if line.endswith("*"): + current_entry.starred = True + line = line[:-1] + current_entry.title = line[len(date_blob) - 1:] + current_entry.date = new_date + elif current_entry: + current_entry.body += line + "\n" + + # Append last entry + if current_entry: + entries.append(current_entry) + + # Now, update our current entries if they changed + for entry in entries: + entry._parse_text() + matched_entries = [e for e in self.entries if e.uuid.lower() == entry.uuid] + if matched_entries: + # This entry is an existing entry + match = matched_entries[0] + if match != entry: + self.entries.remove(match) + entry.modified = True + self.entries.append(entry) + else: + # This entry seems to be new... save it. + entry.modified = True + self.entries.append(entry) + # Remove deleted entries + edited_uuids = [e.uuid for e in entries] + self._deleted_entries = [e for e in self.entries if e.uuid not in edited_uuids] + self.entries[:] = [e for e in self.entries if e.uuid in edited_uuids] + return entries diff --git a/jrnl/EncryptedJournal.py b/jrnl/EncryptedJournal.py new file mode 100644 index 00000000..a83651e4 --- /dev/null +++ b/jrnl/EncryptedJournal.py @@ -0,0 +1,127 @@ +from . import Journal, util +from cryptography.fernet import Fernet, InvalidToken +from cryptography.hazmat.primitives import hashes, padding +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +import hashlib +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC +from cryptography.hazmat.backends import default_backend +import sys +import os +import base64 +import getpass +import logging + +log = logging.getLogger() + + +def make_key(password): + password = util.bytes(password) + kdf = PBKDF2HMAC( + algorithm=hashes.SHA256(), + length=32, + # Salt is hard-coded + salt=b'\xf2\xd5q\x0e\xc1\x8d.\xde\xdc\x8e6t\x89\x04\xce\xf8', + iterations=100000, + backend=default_backend() + ) + key = kdf.derive(password) + return base64.urlsafe_b64encode(key) + + +class EncryptedJournal(Journal.Journal): + def __init__(self, name='default', **kwargs): + super(EncryptedJournal, self).__init__(name, **kwargs) + self.config['encrypt'] = True + + def open(self, filename=None): + """Opens the journal file defined in the config and parses it into a list of Entries. + Entries have the form (date, title, body).""" + filename = filename or self.config['journal'] + + if not os.path.exists(filename): + password = util.getpass("Enter password for new journal: ") + if password: + if util.yesno("Do you want to store the password in your keychain?", default=True): + util.set_keychain(self.name, password) + else: + util.set_keychain(self.name, None) + self.config['password'] = password + text = "" + self._store(filename, text) + util.prompt("[Journal '{0}' created at {1}]".format(self.name, filename)) + else: + util.prompt("No password supplied for encrypted journal") + sys.exit(1) + else: + text = self._load(filename) + self.entries = self._parse(text) + self.sort() + log.debug("opened %s with %d entries", self.__class__.__name__, len(self)) + return self + + + def _load(self, filename, password=None): + """Loads an encrypted journal from a file and tries to decrypt it. + 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. + If the password is provided but wrong (or corrupt), this will simply + return None.""" + with open(filename, 'rb') as f: + journal_encrypted = f.read() + + def validate_password(password): + key = make_key(password) + try: + plain = Fernet(key).decrypt(journal_encrypted).decode('utf-8') + self.config['password'] = password + return plain + except (InvalidToken, IndexError): + return None + if password: + return validate_password(password) + return util.get_password(keychain=self.name, validator=validate_password) + + def _store(self, filename, text): + key = make_key(self.config['password']) + journal = Fernet(key).encrypt(text.encode('utf-8')) + with open(filename, 'wb') as f: + f.write(journal) + + @classmethod + def _create(cls, filename, password): + key = make_key(password) + dummy = Fernet(key).encrypt(b"") + with open(filename, 'wb') as f: + f.write(dummy) + + +class LegacyEncryptedJournal(Journal.LegacyJournal): + """Legacy class to support opening journals encrypted with the jrnl 1.x + standard. You'll not be able to save these journals anymore.""" + def __init__(self, name='default', **kwargs): + super(LegacyEncryptedJournal, self).__init__(name, **kwargs) + self.config['encrypt'] = True + + def _load(self, filename, password=None): + with open(filename, 'rb') as f: + journal_encrypted = f.read() + iv, cipher = journal_encrypted[:16], journal_encrypted[16:] + + def validate_password(password): + decryption_key = hashlib.sha256(password.encode('utf-8')).digest() + decryptor = Cipher(algorithms.AES(decryption_key), modes.CBC(iv), default_backend()).decryptor() + try: + plain_padded = decryptor.update(cipher) + decryptor.finalize() + self.config['password'] = password + if plain_padded[-1] in (" ", 32): + # Ancient versions of jrnl. Do not judge me. + return plain_padded.decode('utf-8').rstrip(" ") + else: + unpadder = padding.PKCS7(algorithms.AES.block_size).unpadder() + plain = unpadder.update(plain_padded) + unpadder.finalize() + return plain.decode('utf-8') + except ValueError: + return None + if password: + return validate_password(password) + return util.get_password(keychain=self.name, validator=validate_password) diff --git a/jrnl/Entry.py b/jrnl/Entry.py new file mode 100755 index 00000000..1306cef5 --- /dev/null +++ b/jrnl/Entry.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python +# encoding: utf-8 + +from __future__ import unicode_literals +import re +import textwrap +from datetime import datetime +from .util import split_title + + +class Entry: + def __init__(self, journal, date=None, text="", starred=False): + self.journal = journal # Reference to journal mainly to access its config + self.date = date or datetime.now() + self.text = text + self._title = self._body = self._tags = None + self.starred = starred + self.modified = False + + @property + def fulltext(self): + return self.title + " " + self.body + + def _parse_text(self): + raw_text = self.text + lines = raw_text.splitlines() + if lines[0].strip().endswith("*"): + self.starred = True + raw_text = lines[0].strip("\n *") + "\n" + "\n".join(lines[1:]) + self._title, self._body = split_title(raw_text) + if self._tags is None: + self._tags = list(self._parse_tags()) + + @property + def title(self): + if self._title is None: + self._parse_text() + return self._title + + @property + def body(self): + if self._body is None: + self._parse_text() + return self._body + + @property + def tags(self): + if self._tags is None: + self._parse_text() + return self._tags + + @staticmethod + def tag_regex(tagsymbols): + pattern = r'(?u)(?:^|\s)([{tags}][-+*#/\w]+)'.format(tags=tagsymbols) + return re.compile(pattern, re.UNICODE) + + def _parse_tags(self): + tagsymbols = self.journal.config['tagsymbols'] + return set(tag.lower() for tag in re.findall(Entry.tag_regex(tagsymbols), self.text)) + + def __unicode__(self): + """Returns a string representation of the entry to be written into a journal file.""" + date_str = self.date.strftime(self.journal.config['timeformat']) + title = "[{}] {}".format(date_str, self.title.rstrip("\n ")) + if self.starred: + title += " *" + return "{title}{sep}{body}\n".format( + title=title, + sep="\n" if self.body.rstrip("\n ") else "", + body=self.body.rstrip("\n ") + ) + + def pprint(self, short=False): + """Returns a pretty-printed version of the entry. + If short is true, only print the title.""" + date_str = self.date.strftime(self.journal.config['timeformat']) + if self.journal.config['indent_character']: + indent = self.journal.config['indent_character'].rstrip() + " " + else: + indent = "" + if not short and self.journal.config['linewrap']: + title = textwrap.fill(date_str + " " + self.title, self.journal.config['linewrap']) + body = "\n".join([ + textwrap.fill( + line, + self.journal.config['linewrap'], + initial_indent=indent, + subsequent_indent=indent, + drop_whitespace=True) or indent + for line in self.body.rstrip(" \n").splitlines() + ]) + else: + title = date_str + " " + self.title.rstrip("\n ") + body = self.body.rstrip("\n ") + + # 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) + + if short: + return title + else: + return "{title}{sep}{body}\n".format( + title=title, + sep="\n" if has_body else "", + body=body if has_body else "", + ) + + def __repr__(self): + return "".format(self.title.strip(), self.date.strftime("%Y-%m-%d %H:%M")) + + def __hash__(self): + return hash(self.__repr__()) + + def __eq__(self, other): + if not isinstance(other, Entry) \ + or self.title.strip() != other.title.strip() \ + or self.body.rstrip() != other.body.rstrip() \ + or self.date != other.date \ + or self.starred != other.starred: + return False + return True + + def __ne__(self, other): + return not self.__eq__(other) diff --git a/jrnl/Journal.py b/jrnl/Journal.py new file mode 100644 index 00000000..72fe94b1 --- /dev/null +++ b/jrnl/Journal.py @@ -0,0 +1,364 @@ +#!/usr/bin/env python +# encoding: utf-8 + +from __future__ import absolute_import, unicode_literals +from . import Entry +from . import util +from . import time +import os +import sys +import codecs +import re +from datetime import datetime +import logging + +log = logging.getLogger(__name__) + + +class Tag(object): + def __init__(self, name, count=0): + self.name = name + self.count = count + + def __str__(self): + return self.name + + def __repr__(self): + return "".format(self.name) + + +class Journal(object): + def __init__(self, name='default', **kwargs): + self.config = { + 'journal': "journal.txt", + 'encrypt': False, + 'default_hour': 9, + 'default_minute': 0, + 'timeformat': "%Y-%m-%d %H:%M", + 'tagsymbols': '@', + 'highlight': True, + 'linewrap': 80, + 'indent_character': '|', + } + self.config.update(kwargs) + # Set up date parser + self.search_tags = None # Store tags we're highlighting + self.name = name + + def __len__(self): + """Returns the number of entries""" + return len(self.entries) + + def __iter__(self): + """Iterates over the journal's entries.""" + return (entry for entry in self.entries) + + @classmethod + def from_journal(cls, other): + """Creates a new journal by copying configuration and entries from + another journal object""" + new_journal = cls(other.name, **other.config) + new_journal.entries = other.entries + log.debug("Imported %d entries from %s to %s", len(new_journal), other.__class__.__name__, cls.__name__) + return new_journal + + def import_(self, other_journal_txt): + self.entries = list(frozenset(self.entries) | frozenset(self._parse(other_journal_txt))) + self.sort() + + def open(self, filename=None): + """Opens the journal file defined in the config and parses it into a list of Entries. + Entries have the form (date, title, body).""" + filename = filename or self.config['journal'] + + if not os.path.exists(filename): + util.prompt("[Journal '{0}' created at {1}]".format(self.name, filename)) + self._create(filename) + + text = self._load(filename) + self.entries = self._parse(text) + self.sort() + log.debug("opened %s with %d entries", self.__class__.__name__, len(self)) + return self + + def write(self, filename=None): + """Dumps the journal into the config file, overwriting it""" + filename = filename or self.config['journal'] + text = self._to_text() + self._store(filename, text) + + def validate_parsing(self): + """Confirms that the jrnl is still parsed correctly after being dumped to text.""" + new_entries = self._parse(self._to_text()) + for i, entry in enumerate(self.entries): + if entry != new_entries[i]: + return False + return True + + def _to_text(self): + return "\n".join([e.__unicode__() for e in self.entries]) + + def _load(self, filename): + raise NotImplementedError + + def _store(self, filename, text): + raise NotImplementedError + + @classmethod + def _create(cls, filename): + raise NotImplementedError + + def _parse(self, journal_txt): + """Parses a journal that's stored in a string and returns a list of entries""" + + # Return empty array if the journal is blank + if not journal_txt: + return [] + + # Initialise our current entry + entries = [] + + date_blob_re = re.compile("(?:^|\n)\[([^\\]]+)\] ") + last_entry_pos = 0 + for match in date_blob_re.finditer(journal_txt): + date_blob = match.groups()[0] + new_date = time.parse(date_blob) + if new_date: + if entries: + entries[-1].text = journal_txt[last_entry_pos:match.start()] + last_entry_pos = match.end() + entries.append(Entry.Entry(self, date=new_date)) + + # If no entries were found, treat all the existing text as an entry made now + if not entries: + entries.append(Entry.Entry(self, date=time.parse("now"))) + + # Fill in the text of the last entry + entries[-1].text = journal_txt[last_entry_pos:] + + for entry in entries: + entry._parse_text() + return entries + + def __unicode__(self): + return self.pprint() + + def pprint(self, short=False): + """Prettyprints the journal's entries""" + sep = "\n" + pp = sep.join([e.pprint(short=short) for e in self.entries]) + if self.config['highlight']: # highlight tags + if self.search_tags: + for tag in self.search_tags: + tagre = re.compile(re.escape(tag), re.IGNORECASE) + pp = re.sub(tagre, + lambda match: util.colorize(match.group(0)), + pp, re.UNICODE) + else: + pp = re.sub( + Entry.Entry.tag_regex(self.config['tagsymbols']), + lambda match: util.colorize(match.group(0)), + pp + ) + return pp + + def __repr__(self): + return "".format(len(self.entries)) + + def sort(self): + """Sorts the Journal's entries by date""" + self.entries = sorted(self.entries, key=lambda entry: entry.date) + + def limit(self, n=None): + """Removes all but the last n entries""" + if n: + self.entries = self.entries[-n:] + + @property + def tags(self): + """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 + # I came across this construction, worry not and embrace the ensuing moment of enlightment. + tags = [tag + 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] + tag_counts = set([(tags.count(tag), tag) for tag in tags]) + 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=[]): + """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 + tag symbols defined in the config, e.g. ["@John", "#WorldDomination"]. + + start_date and end_date define a timespan by which to filter. + + starred limits journal to starred entries + + If strict is True, all tags must be present in an entry. If false, the + + exclude is a list of the tags which should not appear in the results. + entry is kept if any tag is present, unless they appear in exclude.""" + self.search_tags = set([tag.lower() for tag in tags]) + excluded_tags = set([tag.lower() for tag in exclude]) + end_date = time.parse(end_date, inclusive=True) + start_date = time.parse(start_date) + + # 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 + excluded = lambda tags: len([tag for tag in tags if tag in excluded_tags]) > 0 + result = [ + entry for entry in self.entries + if (not tags or tagged(entry.tags)) + and (not starred or entry.starred) + and (not start_date or entry.date >= start_date) + and (not end_date or entry.date <= end_date) + and (not exclude or not excluded(entry.tags)) + ] + + self.entries = result + + def new_entry(self, raw, date=None, sort=True): + """Constructs a new entry from some raw text input. + If a date is given, it will parse and use this, otherwise scan for a date in the input first.""" + + raw = raw.replace('\\n ', '\n').replace('\\n', '\n') + starred = False + # Split raw text into title and body + sep = re.search("\n|[\?!.]+ +\n?", raw) + first_line = raw[:sep.end()].strip() if sep else raw + starred = False + + if not date: + colon_pos = first_line.find(": ") + if colon_pos > 0: + date = time.parse( + raw[:colon_pos], + default_hour=self.config['default_hour'], + default_minute=self.config['default_minute'] + ) + if date: # Parsed successfully, strip that from the raw text + starred = raw[:colon_pos].strip().endswith("*") + raw = raw[colon_pos + 1:].strip() + starred = starred or first_line.startswith("*") or first_line.endswith("*") + if not date: # Still nothing? Meh, just live in the moment. + date = time.parse("now") + entry = Entry.Entry(self, date, raw, starred=starred) + entry.modified = True + self.entries.append(entry) + if sort: + self.sort() + return entry + + def editable_str(self): + """Turns the journal into a string of entries that can be edited + manually and later be parsed with eslf.parse_editable_str.""" + return "\n".join([e.__unicode__() for e in self.entries]) + + def parse_editable_str(self, edited): + """Parses the output of self.editable_str and updates it's entries.""" + mod_entries = self._parse(edited) + # Match those entries that can be found in self.entries and set + # these to modified, so we can get a count of how many entries got + # modified and how many got deleted later. + for entry in mod_entries: + entry.modified = not any(entry == old_entry for old_entry in self.entries) + self.entries = mod_entries + + +class PlainJournal(Journal): + @classmethod + def _create(cls, filename): + with codecs.open(filename, "a", "utf-8"): + pass + + def _load(self, filename): + with codecs.open(filename, "r", "utf-8") as f: + return f.read() + + def _store(self, filename, text): + with codecs.open(filename, 'w', "utf-8") as f: + f.write(text) + + +class LegacyJournal(Journal): + """Legacy class to support opening journals formatted with the jrnl 1.x + standard. Main difference here is that in 1.x, timestamps were not cuddled + by square brackets. You'll not be able to save these journals anymore.""" + def _load(self, filename): + with codecs.open(filename, "r", "utf-8") as f: + return f.read() + + def _parse(self, journal_txt): + """Parses a journal that's stored in a string and returns a list of entries""" + # Entries start with a line that looks like 'date title' - let's figure out how + # long the date will be by constructing one + date_length = len(datetime.today().strftime(self.config['timeformat'])) + + # Initialise our current entry + entries = [] + current_entry = None + new_date_format_regex = re.compile(r'(^\[[^\]]+\].*?$)') + for line in journal_txt.splitlines(): + line = line.rstrip() + try: + # try to parse line as date => new entry begins + new_date = datetime.strptime(line[:date_length], self.config['timeformat']) + + # parsing successful => save old entry and create new one + if new_date and current_entry: + entries.append(current_entry) + + if line.endswith("*"): + starred = True + line = line[:-1] + else: + starred = False + + current_entry = Entry.Entry(self, date=new_date, text=line[date_length + 1:], starred=starred) + except ValueError: + # Happens when we can't parse the start of the line as an date. + # In this case, just append line to our body (after some + # escaping for the new format). + line = new_date_format_regex.sub(r' \1', line) + if current_entry: + current_entry.text += line + u"\n" + + # Append last entry + if current_entry: + entries.append(current_entry) + for entry in entries: + entry._parse_text() + return entries + + +def open_journal(name, config, legacy=False): + """ + Creates a normal, encrypted or DayOne journal based on the passed config. + If legacy is True, it will open Journals with legacy classes build for + backwards compatibility with jrnl 1.x + """ + config = config.copy() + config['journal'] = os.path.expanduser(os.path.expandvars(config['journal'])) + + if os.path.isdir(config['journal']): + if config['journal'].strip("/").endswith(".dayone") or "entries" in os.listdir(config['journal']): + from . import DayOneJournal + return DayOneJournal.DayOne(**config).open() + else: + util.prompt( + u"[Error: {0} is a directory, but doesn't seem to be a DayOne journal either.".format(config['journal']) + ) + + sys.exit(1) + + if not config['encrypt']: + if legacy: + return LegacyJournal(name, **config).open() + return PlainJournal(name, **config).open() + else: + from . import EncryptedJournal + if legacy: + return EncryptedJournal.LegacyEncryptedJournal(name, **config).open() + return EncryptedJournal.EncryptedJournal(name, **config).open() diff --git a/jrnl/__init__.py b/jrnl/__init__.py new file mode 100644 index 00000000..57664dbb --- /dev/null +++ b/jrnl/__init__.py @@ -0,0 +1,9 @@ +#!/usr/bin/env python +# encoding: utf-8 + +import pkg_resources + +dist = pkg_resources.get_distribution('jrnl') +__title__ = dist.project_name +__version__ = dist.version + diff --git a/jrnl/__main__.py b/jrnl/__main__.py new file mode 100644 index 00000000..73e08b33 --- /dev/null +++ b/jrnl/__main__.py @@ -0,0 +1,8 @@ +#!/usr/bin/env python +# encoding: utf-8 +from __future__ import absolute_import, unicode_literals +from . import cli + + +if __name__ == "__main__": + cli.run() diff --git a/jrnl/cli.py b/jrnl/cli.py new file mode 100644 index 00000000..65a53516 --- /dev/null +++ b/jrnl/cli.py @@ -0,0 +1,296 @@ +#!/usr/bin/env python +# encoding: utf-8 + +""" + jrnl + + license: MIT, see LICENSE for more details. +""" + +from __future__ import unicode_literals +from __future__ import absolute_import +from . import Journal +from . import util +from . import install +from . import plugins +from .util import ERROR_COLOR, RESET_COLOR, UserAbort +import jrnl +import argparse +import sys +import logging + +log = logging.getLogger(__name__) +logging.getLogger("keyring.backend").setLevel(logging.ERROR) + + +def parse_args(args=None): + parser = argparse.ArgumentParser() + parser.add_argument('-v', '--version', dest='version', action="store_true", help="prints version information and exits") + parser.add_argument('-ls', dest='ls', action="store_true", help="displays accessible journals") + parser.add_argument('-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.add_argument('text', metavar='', nargs="*") + + reading = parser.add_argument_group('Reading', 'Specifying either of these parameters will display posts of your journal') + reading.add_argument('-from', dest='start_date', metavar="DATE", help='View entries after this date') + reading.add_argument('-until', '-to', dest='end_date', metavar="DATE", help='View entries before this date') + 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") + + return parser.parse_args(args) + + +def guess_mode(args, config): + """Guesses the mode (compose, read or export) from the given arguments""" + compose = True + export = False + import_ = False + if args.import_ is not False: + compose = False + export = False + 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)): + compose = False + export = True + elif any((args.start_date, args.end_date, args.on_date, args.limit, args.strict, args.starred)): + # Any sign of displaying stuff? + compose = False + elif args.text and all(word[0] in config['tagsymbols'] for word in " ".join(args.text).split()): + # No date and only tags? + compose = False + + return compose, export, import_ + + +def encrypt(journal, filename=None): + """ Encrypt into new file. If filename is not set, we encrypt the journal file itself. """ + from . import EncryptedJournal + + journal.config['password'] = util.getpass("Enter new password: ") + journal.config['encrypt'] = True + + new_journal = EncryptedJournal.EncryptedJournal(None, **journal.config) + new_journal.entries = journal.entries + new_journal.write(filename) + + if util.yesno("Do you want to store the password in your keychain?", default=True): + util.set_keychain(journal.name, journal.config['password']) + + util.prompt("Journal encrypted to {0}.".format(filename or new_journal.config['journal'])) + + +def decrypt(journal, filename=None): + """ Decrypts into new file. If filename is not set, we encrypt the journal file itself. """ + journal.config['encrypt'] = False + journal.config['password'] = "" + + new_journal = Journal.PlainJournal(filename, **journal.config) + new_journal.entries = journal.entries + new_journal.write(filename) + util.prompt("Journal decrypted to {0}.".format(filename or new_journal.config['journal'])) + + +def list_journals(config): + """List the journals specified in the configuration file""" + result = "Journals defined in {}\n".format(install.CONFIG_FILE_PATH) + ml = min(max(len(k) for k in config['journals']), 20) + for journal, cfg in config['journals'].items(): + result += " * {:{}} -> {}\n".format(journal, ml, cfg['journal'] if isinstance(cfg, dict) else cfg) + return result + + +def update_config(config, new_config, scope, force_local=False): + """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 within the scope""" + if scope and type(config['journals'][scope]) is dict: # Update to journal specific + config['journals'][scope].update(new_config) + elif scope and force_local: # Convert to dict + config['journals'][scope] = {"journal": config['journals'][scope]} + config['journals'][scope].update(new_config) + else: + config.update(new_config) + + +def configure_logger(debug=False): + logging.basicConfig( + level=logging.DEBUG if debug else logging.INFO, + format='%(levelname)-8s %(name)-12s %(message)s' + ) + logging.getLogger('parsedatetime').setLevel(logging.INFO) # disable parsedatetime debug logging + + +def run(manual_args=None): + args = parse_args(manual_args) + configure_logger(args.debug) + args.text = [p.decode('utf-8') if util.PY2 and not isinstance(p, unicode) else p for p in args.text] + if args.version: + version_str = "{0} version {1}".format(jrnl.__title__, jrnl.__version__) + print(util.py2encode(version_str)) + sys.exit(0) + + try: + config = install.load_or_install_jrnl() + except UserAbort as err: + util.prompt("\n{}".format(err)) + sys.exit(1) + + if args.ls: + util.prnt(list_journals(config)) + sys.exit(0) + + log.debug('Using configuration "%s"', config) + original_config = config.copy() + + # If the first textual argument points to a journal file, + # use this! + journal_name = args.text[0] if (args.text and args.text[0] in config['journals']) else 'default' + + if journal_name is not 'default': + args.text = args.text[1:] + elif "default" not in config['journals']: + util.prompt("No default journal configured.") + util.prompt(list_journals(config)) + sys.exit(1) + + config = util.scope_config(config, journal_name) + + # If the first remaining argument looks like e.g. '-3', interpret that as a limiter + if not args.limit and args.text and args.text[0].startswith("-"): + try: + args.limit = int(args.text[0].lstrip("-")) + args.text = args.text[1:] + except: + pass + + log.debug('Using journal "%s"', journal_name) + mode_compose, mode_export, mode_import = guess_mode(args, config) + + # How to quit writing? + if "win32" in sys.platform: + _exit_multiline_code = "on a blank line, press Ctrl+Z and then Enter" + else: + _exit_multiline_code = "press Ctrl+D" + + if mode_compose and not args.text: + if not sys.stdin.isatty(): + # Piping data into jrnl + raw = util.py23_read() + elif config['editor']: + template = "" + if config['template']: + try: + template = open(config['template']).read() + except: + util.prompt("[Could not read template at '']".format(config['template'])) + sys.exit(1) + raw = util.get_text_from_editor(config, template) + else: + try: + raw = util.py23_read("[Compose Entry; " + _exit_multiline_code + " to finish writing]\n") + except KeyboardInterrupt: + util.prompt("[Entry NOT saved to journal.]") + sys.exit(0) + if raw: + args.text = [raw] + else: + mode_compose = False + + # This is where we finally open the journal! + try: + journal = Journal.open_journal(journal_name, config) + except KeyboardInterrupt: + util.prompt("[Interrupted while opening journal]".format(journal_name)) + sys.exit(1) + + # Import mode + if mode_import: + plugins.get_importer(args.import_).import_(journal, args.input) + + # Writing mode + elif mode_compose: + raw = " ".join(args.text).strip() + if util.PY2 and type(raw) is not unicode: + raw = raw.decode(sys.getfilesystemencoding()) + log.debug('Appending raw line "%s" to journal "%s"', raw, journal_name) + journal.new_entry(raw) + util.prompt("[Entry added to {0} journal]".format(journal_name)) + journal.write() + + if not mode_compose: + old_entries = journal.entries + if args.on_date: + args.start_date = args.end_date = args.on_date + journal.filter(tags=args.text, + start_date=args.start_date, end_date=args.end_date, + strict=args.strict, + short=args.short, + starred=args.starred, + exclude=args.excluded) + journal.limit(args.limit) + + # Reading mode + if not mode_compose and not mode_export and not mode_import: + print(util.py2encode(journal.pprint())) + + # Various export modes + elif args.short: + print(util.py2encode(journal.pprint(short=True))) + + elif args.tags: + print(util.py2encode(plugins.get_exporter("tags").export(journal))) + + elif args.export is not False: + exporter = plugins.get_exporter(args.export) + print(exporter.export(journal, args.output)) + + elif args.encrypt is not False: + encrypt(journal, filename=args.encrypt) + # Not encrypting to a separate file: update config! + if not args.encrypt: + update_config(original_config, {"encrypt": True}, journal_name, force_local=True) + install.save_config(original_config) + + elif args.decrypt is not False: + decrypt(journal, filename=args.decrypt) + # Not decrypting to a separate file: update config! + if not args.decrypt: + update_config(original_config, {"encrypt": False}, journal_name, force_local=True) + install.save_config(original_config) + + elif args.edit: + if not config['editor']: + util.prompt("[{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)) + sys.exit(1) + other_entries = [e for e in old_entries if e not in journal.entries] + # Edit + old_num_entries = len(journal) + edited = util.get_text_from_editor(config, journal.editable_str()) + journal.parse_editable_str(edited) + num_deleted = old_num_entries - len(journal) + num_edited = len([e for e in journal.entries if e.modified]) + prompts = [] + if num_deleted: + prompts.append("{0} {1} deleted".format(num_deleted, "entry" if num_deleted == 1 else "entries")) + if num_edited: + prompts.append("{0} {1} modified".format(num_edited, "entry" if num_deleted == 1 else "entries")) + if prompts: + util.prompt("[{0}]".format(", ".join(prompts).capitalize())) + journal.entries += other_entries + journal.sort() + journal.write() diff --git a/jrnl/export.py b/jrnl/export.py new file mode 100644 index 00000000..d4873314 --- /dev/null +++ b/jrnl/export.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python +# encoding: utf-8 + +from __future__ import absolute_import, unicode_literals +from .util import ERROR_COLOR, RESET_COLOR +from .util import slugify, u +from .template import Template +import os +import codecs + + +class Exporter(object): + """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 unicode representation of a single entry.""" + return entry.__unicode__() + + def _get_vars(self, journal): + return { + 'journal': journal, + 'entries': journal.entries, + 'tags': journal.tags + } + + def export_journal(self, journal): + """Returns a unicode 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 codecs.open(path, "w", "utf-8") as f: + f.write(self.export_journal(journal)) + return "[Journal exported to {0}]".format(path) + except IOError as e: + return "[{2}ERROR{3}: {0} {1}]".format(e.filename, e.strerror, ERROR_COLOR, RESET_COLOR) + + def make_filename(self, entry): + return entry.date.strftime("%Y-%m-%d_{0}.{1}".format(slugify(u(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 codecs.open(full_path, "w", "utf-8") as f: + f.write(self.export_entry(entry)) + except IOError as e: + return "[{2}ERROR{3}: {0} {1}]".format(e.filename, e.strerror, ERROR_COLOR, RESET_COLOR) + return "[Journal exported to {0}]".format(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 unicode 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) diff --git a/jrnl/install.py b/jrnl/install.py new file mode 100644 index 00000000..5a80562f --- /dev/null +++ b/jrnl/install.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python +# encoding: utf-8 + +from __future__ import absolute_import +import readline +import glob +import getpass +import os +import xdg.BaseDirectory +from . import util +from . import upgrade +from . import __version__ +from .Journal import PlainJournal +from .EncryptedJournal import EncryptedJournal +from .util import UserAbort +import yaml +import logging +import sys + +DEFAULT_CONFIG_NAME = 'jrnl.yaml' +DEFAULT_JOURNAL_NAME = 'journal.txt' +XDG_RESOURCE = 'jrnl' + +USER_HOME = os.path.expanduser('~') + +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_FALLBACK = os.path.join(USER_HOME, ".jrnl_config") + +JOURNAL_PATH = xdg.BaseDirectory.save_data_path(XDG_RESOURCE) or USER_HOME +JOURNAL_FILE_PATH = os.path.join(JOURNAL_PATH, DEFAULT_JOURNAL_NAME) + +log = logging.getLogger(__name__) + + +def module_exists(module_name): + """Checks if a module exists and can be imported""" + try: + __import__(module_name) + except ImportError: + return False + else: + return True + +default_config = { + 'version': __version__, + 'journals': { + "default": JOURNAL_FILE_PATH + }, + 'editor': os.getenv('VISUAL') or os.getenv('EDITOR') or "", + 'encrypt': False, + 'template': False, + 'default_hour': 9, + 'default_minute': 0, + 'timeformat': "%Y-%m-%d %H:%M", + 'tagsymbols': '@', + 'highlight': True, + 'linewrap': 79, + 'indent_character': '|', +} + + +def upgrade_config(config): + """Checks if there are keys missing in a given config dict, and if so, updates the config file accordingly. + This essentially automatically ports jrnl installations if new config parameters are introduced in later + versions.""" + missing_keys = set(default_config).difference(config) + if missing_keys: + for key in missing_keys: + config[key] = default_config[key] + save_config(config) + print("[Configuration updated to newest version at {}]".format(CONFIG_FILE_PATH)) + + +def save_config(config): + config['version'] = __version__ + with open(CONFIG_FILE_PATH, 'w') as f: + yaml.safe_dump(config, f, encoding='utf-8', allow_unicode=True, default_flow_style=False) + + +def load_or_install_jrnl(): + """ + If jrnl is already installed, loads and returns a config object. + Else, perform various prompts to install jrnl. + """ + config_path = CONFIG_FILE_PATH if os.path.exists(CONFIG_FILE_PATH) else CONFIG_FILE_PATH_FALLBACK + if os.path.exists(config_path): + log.debug('Reading configuration from file %s', config_path) + config = util.load_config(config_path) + + try: + upgrade.upgrade_jrnl_if_necessary(config_path) + except upgrade.UpgradeValidationException: + util.prompt("Aborting upgrade.") + util.prompt("Please tell us about this problem at the following URL:") + util.prompt("https://github.com/jrnl-org/jrnl/issues/new?title=UpgradeValidationException") + util.prompt("Exiting.") + sys.exit(1) + + upgrade_config(config) + + return config + else: + log.debug('Configuration file not found, installing jrnl...') + try: + config = install() + except KeyboardInterrupt: + raise UserAbort("Installation aborted") + return config + + +def install(): + def autocomplete(text, state): + expansions = glob.glob(os.path.expanduser(os.path.expandvars(text)) + '*') + expansions = [e + "/" if os.path.isdir(e) else e for e in expansions] + expansions.append(None) + return expansions[state] + readline.set_completer_delims(' \t\n;') + readline.parse_and_bind("tab: complete") + readline.set_completer(autocomplete) + + # Where to create the journal? + path_query = 'Path to your journal file (leave blank for {}): '.format(JOURNAL_FILE_PATH) + journal_path = util.py23_input(path_query).strip() or JOURNAL_FILE_PATH + default_config['journals']['default'] = 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 + try: + os.makedirs(path) + except OSError: + pass + + # Encrypt it? + password = getpass.getpass("Enter password for journal (leave blank for no encryption): ") + if password: + default_config['encrypt'] = True + if util.yesno("Do you want to store the password in your keychain?", default=True): + util.set_keychain("default", password) + else: + util.set_keychain("default", None) + EncryptedJournal._create(default_config['journals']['default'], password) + print("Journal will be encrypted.") + else: + PlainJournal._create(default_config['journals']['default']) + + config = default_config + save_config(config) + if password: + config['password'] = password + return config diff --git a/jrnl/plugins/__init__.py b/jrnl/plugins/__init__.py new file mode 100644 index 00000000..64d7b3ba --- /dev/null +++ b/jrnl/plugins/__init__.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python +# encoding: utf-8 + +from __future__ import absolute_import, unicode_literals + +from .text_exporter import TextExporter +from .jrnl_importer import JRNLImporter +from .json_exporter import JSONExporter +from .markdown_exporter import MarkdownExporter +from .tag_exporter import TagExporter +from .xml_exporter import XMLExporter +from .yaml_exporter import YAMLExporter +from .template_exporter import __all__ as template_exporters + +__exporters =[JSONExporter, MarkdownExporter, TagExporter, TextExporter, XMLExporter, YAMLExporter] + template_exporters +__importers =[JRNLImporter] + +__exporter_types = dict([(name, plugin) for plugin in __exporters for name in plugin.names]) +__importer_types = dict([(name, plugin) for plugin in __importers for name in plugin.names]) + +EXPORT_FORMATS = sorted(__exporter_types.keys()) +IMPORT_FORMATS = sorted(__importer_types.keys()) + +def get_exporter(format): + for exporter in __exporters: + if hasattr(exporter, "names") and format in exporter.names: + return exporter + return None + + +def get_importer(format): + for importer in __importers: + if hasattr(importer, "names") and format in importer.names: + return importer + return None diff --git a/jrnl/plugins/jrnl_importer.py b/jrnl/plugins/jrnl_importer.py new file mode 100644 index 00000000..85615e75 --- /dev/null +++ b/jrnl/plugins/jrnl_importer.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python +# encoding: utf-8 + +from __future__ import absolute_import, unicode_literals +import codecs +import sys +from .. import util + +class JRNLImporter(object): + """This plugin imports entries from other jrnl files.""" + names = ["jrnl"] + + @staticmethod + def import_(journal, input=None): + """Imports from an existing file if input is specified, and + standard input otherwise.""" + old_cnt = len(journal.entries) + old_entries = journal.entries + if input: + with codecs.open(input, "r", "utf-8") as f: + other_journal_txt = f.read() + else: + try: + other_journal_txt = util.py23_read() + except KeyboardInterrupt: + util.prompt("[Entries NOT imported into journal.]") + sys.exit(0) + journal.import_(other_journal_txt) + new_cnt = len(journal.entries) + util.prompt("[{0} imported to {1} journal]".format(new_cnt - old_cnt, journal.name)) + journal.write() diff --git a/jrnl/plugins/json_exporter.py b/jrnl/plugins/json_exporter.py new file mode 100644 index 00000000..5abaf916 --- /dev/null +++ b/jrnl/plugins/json_exporter.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python +# encoding: utf-8 + +from __future__ import absolute_import, unicode_literals +from .text_exporter import TextExporter +import json +from .util import get_tags_count + + +class JSONExporter(TextExporter): + """This Exporter can convert entries and journals into json.""" + names = ["json"] + extension = "json" + + @classmethod + def entry_to_dict(cls, entry): + entry_dict = { + 'title': entry.title, + 'body': entry.body, + 'date': entry.date.strftime("%Y-%m-%d"), + 'time': entry.date.strftime("%H:%M"), + 'starred': entry.starred + } + if hasattr(entry, "uuid"): + entry_dict['uuid'] = entry.uuid + return entry_dict + + @classmethod + def export_entry(cls, entry): + """Returns a json representation of a single entry.""" + return json.dumps(cls.entry_to_dict(entry), indent=2) + "\n" + + @classmethod + def export_journal(cls, journal): + """Returns a json representation of an entire journal.""" + tags = get_tags_count(journal) + result = { + "tags": dict((tag, count) for count, tag in tags), + "entries": [cls.entry_to_dict(e) for e in journal.entries] + } + return json.dumps(result, indent=2) diff --git a/jrnl/plugins/markdown_exporter.py b/jrnl/plugins/markdown_exporter.py new file mode 100644 index 00000000..19b5404d --- /dev/null +++ b/jrnl/plugins/markdown_exporter.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python +# encoding: utf-8 + +from __future__ import absolute_import, unicode_literals, print_function +from .text_exporter import TextExporter +import os +import re +import sys +from ..util import WARNING_COLOR, RESET_COLOR + + +class MarkdownExporter(TextExporter): + """This Exporter can convert entries and journals into Markdown.""" + names = ["md", "markdown"] + extension = "md" + + @classmethod + def export_entry(cls, entry, to_multifile=True): + """Returns a markdown representation of a single entry.""" + date_str = entry.date.strftime(entry.journal.config['timeformat']) + body_wrapper = "\n" if entry.body else "" + body = body_wrapper + entry.body + + if to_multifile is True: + heading = '#' + else: + heading = '###' + + '''Increase heading levels in body text''' + newbody = '' + previous_line = '' + warn_on_heading_level = False + for line in body.splitlines(True): + if re.match(r"^#+ ", line): + """ATX style headings""" + newbody = newbody + previous_line + heading + line + if re.match(r"^#######+ ", heading + line): + warn_on_heading_level = True + line = '' + elif re.match(r"^=+$", line.rstrip()) and not re.match(r"^$", previous_line.strip()): + """Setext style H1""" + newbody = newbody + heading + "# " + previous_line + line = '' + elif re.match(r"^-+$", line.rstrip()) and not re.match(r"^$", previous_line.strip()): + """Setext style H2""" + newbody = newbody + heading + "## " + previous_line + line = '' + else: + newbody = newbody + previous_line + previous_line = line + newbody = newbody + previous_line # add very last line + + 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) + + return "{md} {date} {title}\n{body} {space}".format( + md=heading, + date=date_str, + title=entry.title, + body=newbody, + space="" + ) + + @classmethod + def export_journal(cls, journal): + """Returns a Markdown representation of an entire journal.""" + out = [] + year, month = -1, -1 + for e in journal.entries: + if not e.date.year == year: + year = e.date.year + out.append("# " + str(year)) + out.append("") + if not e.date.month == month: + month = e.date.month + out.append("## " + e.date.strftime("%B")) + out.append("") + out.append(cls.export_entry(e, False)) + result = "\n".join(out) + return result diff --git a/jrnl/plugins/tag_exporter.py b/jrnl/plugins/tag_exporter.py new file mode 100644 index 00000000..439bac7c --- /dev/null +++ b/jrnl/plugins/tag_exporter.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python +# encoding: utf-8 + +from __future__ import absolute_import, unicode_literals +from .text_exporter import TextExporter +from .util import get_tags_count + + +class TagExporter(TextExporter): + """This Exporter can lists the tags for entries and journals, exported as a plain text file.""" + names = ["tags"] + extension = "tags" + + @classmethod + def export_entry(cls, entry): + """Returns a list of tags for a single entry.""" + return ", ".join(entry.tags) + + @classmethod + def export_journal(cls, journal): + """Returns a list of tags and their frequency for an entire journal.""" + tag_counts = get_tags_count(journal) + result = "" + if not tag_counts: + return '[No tags found in journal.]' + elif min(tag_counts)[0] == 0: + tag_counts = filter(lambda x: x[0] > 1, tag_counts) + result += '[Removed tags that appear only once.]\n' + result += "\n".join("{0:20} : {1}".format(tag, n) for n, tag in sorted(tag_counts, reverse=True)) + return result diff --git a/jrnl/plugins/template.py b/jrnl/plugins/template.py new file mode 100644 index 00000000..21fb2896 --- /dev/null +++ b/jrnl/plugins/template.py @@ -0,0 +1,124 @@ +import re +import asteval +import yaml + +VAR_RE = r"[_a-zA-Z][a-zA-Z0-9_]*" +EXPRESSION_RE = r"[\[\]():.a-zA-Z0-9_]*" +PRINT_RE = r"{{ *(.+?) *}}" +START_BLOCK_RE = r"{% *(if|for) +(.+?) *%}" +END_BLOCK_RE = r"{% *end(for|if) *%}" +FOR_RE = r"{{% *for +({varname}) +in +([^%]+) *%}}".format(varname=VAR_RE, expression=EXPRESSION_RE) +IF_RE = r"{% *if +(.+?) *%}" +BLOCK_RE = r"{% *block +(.+?) *%}((?:.|\n)+?){% *endblock *%}" +INCLUDE_RE = r"{% *include +(.+?) *%}" + + +class Template(object): + def __init__(self, template): + self.template = template + self.clean_template = None + self.blocks = {} + + @classmethod + def from_file(cls, filename): + with open(filename) as f: + front_matter, body = f.read().strip("-\n").split("---", 2) + front_matter = yaml.load(front_matter, Loader=yaml.FullLoader) + template = cls(body) + template.__dict__.update(front_matter) + return template + + def render(self, **vars): + if self.clean_template is None: + self._get_blocks() + return self._expand(self.clean_template, **vars) + + def render_block(self, block, **vars): + if self.clean_template is None: + self._get_blocks() + return self._expand(self.blocks[block], **vars) + + def _eval_context(self, vars): + e = asteval.Interpreter(use_numpy=False, writer=None) + e.symtable.update(vars) + e.symtable['__last_iteration'] = vars.get("__last_iteration", False) + return e + + def _get_blocks(self): + def s(match): + name, contents = match.groups() + self.blocks[name] = self._strip_single_nl(contents) + return "" + self.clean_template = re.sub(BLOCK_RE, s, self.template, flags=re.MULTILINE) + + def _expand(self, template, **vars): + 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)] + ) + + last_nesting, nesting = 0, 0 + start = 0 + result = "" + block_type = None + if not stack: + return self._expand_vars(template, **vars) + + for pos, indent, typ in stack: + nesting += indent + if nesting == 1 and last_nesting == 0: + block_type = typ + result += self._expand_vars(template[start:pos], **vars) + start = pos + elif nesting == 0 and last_nesting == 1: + if block_type == "if": + result += self._expand_cond(template[start:pos], **vars) + elif block_type == "for": + result += self._expand_loops(template[start:pos], **vars) + elif block_type == "block": + result += self._save_block(template[start:pos], **vars) + start = pos + last_nesting = nesting + + result += self._expand_vars(template[stack[-1][0]:], **vars) + return result + + def _expand_vars(self, template, **vars): + safe_eval = self._eval_context(vars) + 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) + + def _expand_cond(self, template, **vars): + start_block = re.search(IF_RE, template, re.M) + end_block = list(re.finditer(END_BLOCK_RE, template, re.M))[-1] + expression = start_block.groups()[0] + sub_template = self._strip_single_nl(template[start_block.end():end_block.start()]) + + safe_eval = self._eval_context(vars) + if safe_eval(expression): + return self._expand(sub_template) + return "" + + def _strip_single_nl(self, template, strip_r=True): + if template[0] == "\n": + template = template[1:] + if strip_r and template[-1] == "\n": + template = template[:-1] + return template + + def _expand_loops(self, template, **vars): + start_block = re.search(FOR_RE, template, re.M) + end_block = list(re.finditer(END_BLOCK_RE, template, re.M))[-1] + var_name, iterator = start_block.groups() + sub_template = self._strip_single_nl(template[start_block.end():end_block.start()], strip_r=False) + + safe_eval = self._eval_context(vars) + + result = '' + items = safe_eval(iterator) + for idx, var in enumerate(items): + vars[var_name] = var + vars['__last_iteration'] = idx == len(items) - 1 + result += self._expand(sub_template, **vars) + del vars[var_name] + return self._strip_single_nl(result) diff --git a/jrnl/plugins/template_exporter.py b/jrnl/plugins/template_exporter.py new file mode 100644 index 00000000..85aa2236 --- /dev/null +++ b/jrnl/plugins/template_exporter.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python +# encoding: utf-8 + +from __future__ import absolute_import, unicode_literals + +from .text_exporter import TextExporter +from .template import Template +import os +from glob import glob + + +class GenericTemplateExporter(TextExporter): + """This Exporter can convert entries and journals into text files.""" + + @classmethod + def export_entry(cls, entry): + """Returns a unicode representation of a single entry.""" + vars = { + 'entry': entry, + 'tags': entry.tags + } + return cls.template.render_block("entry", **vars) + + @classmethod + def export_journal(cls, journal): + """Returns a unicode representation of an entire journal.""" + vars = { + 'journal': journal, + 'entries': journal.entries, + 'tags': journal.tags + } + return cls.template.render_block("journal", **vars) + + +def __exporter_from_file(template_file): + """Create a template class from a file""" + name = os.path.basename(template_file).replace(".template", "") + template = Template.from_file(template_file) + return type(str("{}Exporter".format(name.title())), (GenericTemplateExporter, ), { + "names": [name], + "extension": template.extension, + "template": template + }) + +__all__ = [] + +# Factory pattern to create Exporter classes for all available templates +for template_file in glob("jrnl/templates/*.template"): + __all__.append(__exporter_from_file(template_file)) diff --git a/jrnl/plugins/text_exporter.py b/jrnl/plugins/text_exporter.py new file mode 100644 index 00000000..dbb54d04 --- /dev/null +++ b/jrnl/plugins/text_exporter.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python +# encoding: utf-8 + +from __future__ import absolute_import, unicode_literals +import codecs +from ..util import u, slugify +import os +from ..util import ERROR_COLOR, RESET_COLOR + + +class TextExporter(object): + """This Exporter can convert entries and journals into text files.""" + names = ["text", "txt"] + extension = "txt" + + @classmethod + def export_entry(cls, entry): + """Returns a unicode representation of a single entry.""" + return entry.__unicode__() + + @classmethod + def export_journal(cls, journal): + """Returns a unicode representation of an entire journal.""" + return "\n".join(cls.export_entry(entry) for entry in journal) + + @classmethod + def write_file(cls, journal, path): + """Exports a journal into a single file.""" + try: + with codecs.open(path, "w", "utf-8") as f: + f.write(cls.export_journal(journal)) + return "[Journal exported to {0}]".format(path) + except IOError as e: + return "[{2}ERROR{3}: {0} {1}]".format(e.filename, e.strerror, ERROR_COLOR, RESET_COLOR) + + @classmethod + def make_filename(cls, entry): + return entry.date.strftime("%Y-%m-%d_{0}.{1}".format(slugify(u(entry.title)), cls.extension)) + + @classmethod + def write_files(cls, journal, path): + """Exports a journal into individual files for each entry.""" + for entry in journal.entries: + try: + full_path = os.path.join(path, cls.make_filename(entry)) + with codecs.open(full_path, "w", "utf-8") as f: + f.write(cls.export_entry(entry)) + except IOError as e: + return "[{2}ERROR{3}: {0} {1}]".format(e.filename, e.strerror, ERROR_COLOR, RESET_COLOR) + return "[Journal exported to {0}]".format(path) + + @classmethod + def export(cls, journal, 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 unicode if output is None.""" + if output and os.path.isdir(output): # multiple files + return cls.write_files(journal, output) + elif output: # single file + return cls.write_file(journal, output) + else: + return cls.export_journal(journal) diff --git a/jrnl/plugins/util.py b/jrnl/plugins/util.py new file mode 100644 index 00000000..0a642cb2 --- /dev/null +++ b/jrnl/plugins/util.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python +# encoding: utf-8 + + +def get_tags_count(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 + # I came across this construction, worry not and embrace the ensuing moment of enlightment. + tags = [tag + 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] + tag_counts = set([(tags.count(tag), tag) for tag in tags]) + return tag_counts + + +def oxford_list(lst): + """Return Human-readable list of things obeying the object comma)""" + lst = sorted(lst) + if not lst: + return "(nothing)" + elif len(lst) == 1: + return lst[0] + elif len(lst) == 2: + return lst[0] + " or " + lst[1] + else: + return ', '.join(lst[:-1]) + ", or " + lst[-1] diff --git a/jrnl/plugins/xml_exporter.py b/jrnl/plugins/xml_exporter.py new file mode 100644 index 00000000..0af2ed47 --- /dev/null +++ b/jrnl/plugins/xml_exporter.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python +# encoding: utf-8 + +from __future__ import absolute_import, unicode_literals +from .json_exporter import JSONExporter +from .util import get_tags_count +from ..util import u +from xml.dom import minidom + + +class XMLExporter(JSONExporter): + """This Exporter can convert entries and journals into XML.""" + names = ["xml"] + extension = "xml" + + @classmethod + def export_entry(cls, entry, doc=None): + """Returns an XML representation of a single entry.""" + doc_el = doc or minidom.Document() + entry_el = doc_el.createElement('entry') + for key, value in cls.entry_to_dict(entry).items(): + elem = doc_el.createElement(key) + elem.appendChild(doc_el.createTextNode(u(value))) + entry_el.appendChild(elem) + if not doc: + doc_el.appendChild(entry_el) + return doc_el.toprettyxml() + else: + return entry_el + + @classmethod + def entry_to_xml(cls, entry, doc): + entry_el = doc.createElement('entry') + entry_el.setAttribute('date', entry.date.isoformat()) + if hasattr(entry, "uuid"): + entry_el.setAttribute('uuid', u(entry.uuid)) + entry_el.setAttribute('starred', u(entry.starred)) + entry_el.appendChild(doc.createTextNode(entry.fulltext)) + return entry_el + + @classmethod + def export_journal(cls, journal): + """Returns an XML representation of an entire journal.""" + tags = get_tags_count(journal) + doc = minidom.Document() + xml = doc.createElement('journal') + tags_el = doc.createElement('tags') + entries_el = doc.createElement('entries') + for count, tag in tags: + tag_el = doc.createElement('tag') + tag_el.setAttribute('name', tag) + count_node = doc.createTextNode(u(count)) + tag_el.appendChild(count_node) + tags_el.appendChild(tag_el) + for entry in journal.entries: + entries_el.appendChild(cls.entry_to_xml(entry, doc)) + xml.appendChild(entries_el) + xml.appendChild(tags_el) + doc.appendChild(xml) + return doc.toprettyxml() diff --git a/jrnl/plugins/yaml_exporter.py b/jrnl/plugins/yaml_exporter.py new file mode 100644 index 00000000..c0735811 --- /dev/null +++ b/jrnl/plugins/yaml_exporter.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python +# encoding: utf-8 + +from __future__ import absolute_import, unicode_literals, print_function +from .text_exporter import TextExporter +import os +import re +import sys +from ..util import WARNING_COLOR, ERROR_COLOR, RESET_COLOR + + +class YAMLExporter(TextExporter): + """This Exporter can convert entries and journals into Markdown formatted text with YAML front matter.""" + names = ["yaml"] + extension = "md" + + @classmethod + def export_entry(cls, entry, to_multifile=True): + """Returns a markdown representation of a single entry, with YAML front matter.""" + if to_multifile is False: + print("{}ERROR{}: YAML export must be to individual files. Please specify a directory to export to.".format("\033[31m", "\033[0m", file=sys.stderr)) + return + + date_str = entry.date.strftime(entry.journal.config['timeformat']) + body_wrapper = "\n" if entry.body else "" + body = body_wrapper + entry.body + + tagsymbols = entry.journal.config['tagsymbols'] + # see also Entry.Entry.rag_regex + multi_tag_regex = re.compile(r'(?u)^\s*([{tags}][-+*#/\w]+\s*)+$'.format(tags=tagsymbols), re.UNICODE) + + '''Increase heading levels in body text''' + newbody = '' + heading = '#' + previous_line = '' + warn_on_heading_level = False + for line in entry.body.splitlines(True): + if re.match(r"^#+ ", line): + """ATX style headings""" + newbody = newbody + previous_line + heading + line + if re.match(r"^#######+ ", heading + line): + warn_on_heading_level = True + line = '' + elif re.match(r"^=+$", line.rstrip()) and not re.match(r"^$", previous_line.strip()): + """Setext style H1""" + newbody = newbody + heading + "# " + previous_line + line = '' + elif re.match(r"^-+$", line.rstrip()) and not re.match(r"^$", previous_line.strip()): + """Setext style H2""" + newbody = newbody + heading + "## " + previous_line + line = '' + elif multi_tag_regex.match(line): + """Tag only lines""" + line = '' + else: + newbody = newbody + previous_line + previous_line = line + newbody = newbody + previous_line # add very last line + + 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) + + dayone_attributes = '' + if hasattr(entry, "uuid"): + dayone_attributes += 'uuid: ' + entry.uuid + '\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( + date = date_str, + title = entry.title, + stared = entry.starred, + tags = ', '.join([tag[1:] for tag in entry.tags]), + dayone = dayone_attributes, + body = newbody, + space="" + ) + + @classmethod + def export_journal(cls, journal): + """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) + return diff --git a/jrnl/templates/sample.template b/jrnl/templates/sample.template new file mode 100644 index 00000000..983d6af3 --- /dev/null +++ b/jrnl/templates/sample.template @@ -0,0 +1,18 @@ +--- +extension: txt +--- + +{% block journal %} +{% for entry in entries %} +{% include entry %} +{% endfor %} + +{% endblock %} + +{% block entry %} +{{ entry.title }} +{{ "-" * len(entry.title) }} + +{{ entry.body }} + +{% endblock %} diff --git a/jrnl/time.py b/jrnl/time.py new file mode 100644 index 00000000..9ff125aa --- /dev/null +++ b/jrnl/time.py @@ -0,0 +1,65 @@ +from datetime import datetime +from dateutil.parser import parse as dateparse +try: import parsedatetime.parsedatetime_consts as pdt +except ImportError: import parsedatetime as pdt + +FAKE_YEAR = 9999 +DEFAULT_FUTURE = datetime(FAKE_YEAR, 12, 31, 23, 59, 59) +DEFAULT_PAST = datetime(FAKE_YEAR, 1, 1, 0, 0) + +consts = pdt.Constants(usePyICU=False) +consts.DOWParseStyle = -1 # "Monday" will be either today or the last Monday +CALENDAR = pdt.Calendar(consts) + + +def parse(date_str, inclusive=False, default_hour=None, default_minute=None): + """Parses a string containing a fuzzy date and returns a datetime.datetime object""" + if not date_str: + return None + elif isinstance(date_str, datetime): + return date_str + + # Don't try to parse anything with 6 or less characters. It's probably a markdown footnote + if len(date_str) <= 6: + return None + + default_date = DEFAULT_FUTURE if inclusive else DEFAULT_PAST + date = None + year_present = False + while not date: + try: + date = dateparse(date_str, default=default_date) + if date.year == FAKE_YEAR: + date = datetime(datetime.now().year, date.timetuple()[1:6]) + else: + year_present = True + flag = 1 if date.hour == date.minute == 0 else 2 + date = date.timetuple() + except Exception as e: + if e.args[0] == 'day is out of range for month': + y, m, d, H, M, S = default_date.timetuple()[:6] + default_date = datetime(y, m, d - 1, H, M, S) + else: + date, flag = CALENDAR.parse(date_str) + + if not flag: # Oops, unparsable. + try: # Try and parse this as a single year + year = int(date_str) + return datetime(year, 1, 1) + except ValueError: + return None + except TypeError: + return None + + if flag is 1: # Date found, but no time. Use the default time. + date = datetime(*date[:3], hour=default_hour or 0, minute=default_minute or 0) + else: + date = datetime(*date[:6]) + + # Ugly heuristic: if the date is more than 4 weeks in the future, we got the year wrong. + # Rather then this, we would like to see parsedatetime patched so we can tell it to prefer + # past dates + dt = datetime.now() - date + if dt.days < -28 and not year_present: + date = date.replace(date.year - 1) + return date diff --git a/jrnl/upgrade.py b/jrnl/upgrade.py new file mode 100644 index 00000000..f2b80af3 --- /dev/null +++ b/jrnl/upgrade.py @@ -0,0 +1,120 @@ +from __future__ import absolute_import, unicode_literals + +from . import __version__ +from . import Journal +from . import util +from .EncryptedJournal import EncryptedJournal +from .util import UserAbort +import os +import codecs + + +def backup(filename, binary=False): + util.prompt(" Created a backup at {}.backup".format(filename)) + filename = os.path.expanduser(os.path.expandvars(filename)) + with open(filename, 'rb' if binary else 'r') as original: + contents = original.read() + with open(filename + ".backup", 'wb' if binary else 'w') as backup: + backup.write(contents) + + +def upgrade_jrnl_if_necessary(config_path): + with codecs.open(config_path, "r", "utf-8") as f: + config_file = f.read() + if not config_file.strip().startswith("{"): + return + + config = util.load_config(config_path) + + util.prompt("""Welcome to jrnl {}. + +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 +you can enjoy all of the great new features that come with jrnl 2: + +- Support for storing your journal in multiple files +- Faster reading and writing for large journals +- New encryption back-end that makes installing jrnl much easier +- Tons of bug fixes + +Please note that jrnl 1.x is NOT forward compatible with this version of jrnl. +If you choose to proceed, you will not be able to use your journals with +older versions of jrnl anymore. +""".format(__version__)) + encrypted_journals = {} + plain_journals = {} + other_journals = {} + all_journals = [] + + for journal_name, journal_conf in config['journals'].items(): + if isinstance(journal_conf, dict): + path = journal_conf.get("journal") + encrypt = journal_conf.get("encrypt") + else: + encrypt = config.get('encrypt') + path = journal_conf + + if encrypt: + encrypted_journals[journal_name] = path + elif os.path.isdir(path): + other_journals[journal_name] = path + else: + plain_journals[journal_name] = path + + longest_journal_name = max([len(journal) for journal in config['journals']]) + if encrypted_journals: + util.prompt("\nFollowing encrypted journals will be upgraded to jrnl {}:".format(__version__)) + for journal, path in encrypted_journals.items(): + util.prompt(" {:{pad}} -> {}".format(journal, path, pad=longest_journal_name)) + + if plain_journals: + util.prompt("\nFollowing plain text journals will upgraded to jrnl {}:".format(__version__)) + for journal, path in plain_journals.items(): + util.prompt(" {:{pad}} -> {}".format(journal, path, pad=longest_journal_name)) + + if other_journals: + util.prompt("\nFollowing journals will be not be touched:") + for journal, path in other_journals.items(): + util.prompt(" {:{pad}} -> {}".format(journal, path, pad=longest_journal_name)) + + try: + cont = util.yesno("\nContinue upgrading jrnl?", default=False) + if not cont: + raise KeyboardInterrupt + except KeyboardInterrupt: + raise UserAbort("jrnl NOT upgraded, exiting.") + + for journal_name, path in encrypted_journals.items(): + util.prompt("\nUpgrading encrypted '{}' journal stored in {}...".format(journal_name, path)) + backup(path, binary=True) + old_journal = Journal.open_journal(journal_name, util.scope_config(config, journal_name), legacy=True) + all_journals.append(EncryptedJournal.from_journal(old_journal)) + + for journal_name, path in plain_journals.items(): + util.prompt("\nUpgrading plain text '{}' journal stored in {}...".format(journal_name, path)) + backup(path) + old_journal = Journal.open_journal(journal_name, util.scope_config(config, journal_name), legacy=True) + all_journals.append(Journal.PlainJournal.from_journal(old_journal)) + + # loop through lists to validate + failed_journals = [j for j in all_journals if not j.validate_parsing()] + + if len(failed_journals) > 0: + util.prompt("\nThe following journal{} failed to upgrade:\n{}".format( + 's' if len(failed_journals) > 1 else '', "\n".join(j.name for j in failed_journals)) + ) + + raise UpgradeValidationException + + # write all journals - or - don't + for j in all_journals: + j.write() + + util.prompt("\nUpgrading config...") + backup(config_path) + + util.prompt("\nWe're all done here and you can start enjoying jrnl 2.".format(config_path)) + +class UpgradeValidationException(Exception): + """Raised when the contents of an upgraded journal do not match the old journal""" + pass diff --git a/jrnl/util.py b/jrnl/util.py new file mode 100644 index 00000000..bc36ba9b --- /dev/null +++ b/jrnl/util.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python +# encoding: utf-8 + +from __future__ import unicode_literals +from __future__ import absolute_import + +import sys +import os +import getpass as gp +import yaml +if "win32" in sys.platform: + import colorama + colorama.init() +import re +import tempfile +import subprocess +import codecs +import unicodedata +import shlex +import logging + +log = logging.getLogger(__name__) + + +PY3 = sys.version_info[0] == 3 +PY2 = sys.version_info[0] == 2 +STDIN = sys.stdin +STDERR = sys.stderr +STDOUT = sys.stdout +TEST = False +__cached_tz = None + +WARNING_COLOR = "\033[33m" +ERROR_COLOR = "\033[31m" +RESET_COLOR = "\033[0m" + +# Based on Segtok by Florian Leitner +# https://github.com/fnl/segtok +SENTENCE_SPLITTER = re.compile(r""" +( # 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, + [\'\u2019\"\u201D]? # an optional right quote, + [\]\)]* # optional closing brackets and + \s+ # a sequence of required spaces. +| # Otherwise, + \n # a sentence also terminates newlines. +)""", re.UNICODE | re.VERBOSE) + + +class UserAbort(Exception): + pass + + +def getpass(prompt="Password: "): + if not TEST: + return gp.getpass(bytes(prompt)) + else: + return py23_input(prompt) + + +def get_password(validator, keychain=None, max_attempts=3): + pwd_from_keychain = keychain and get_keychain(keychain) + password = pwd_from_keychain or getpass() + result = validator(password) + # Password is bad: + if result is None and pwd_from_keychain: + set_keychain(keychain, None) + attempt = 1 + while result is None and attempt < max_attempts: + prompt("Wrong password, try again.") + password = getpass() + result = validator(password) + attempt += 1 + if result is not None: + return result + else: + prompt("Extremely wrong password.") + sys.exit(1) + + +def get_keychain(journal_name): + import keyring + return keyring.get_password('jrnl', journal_name) + + +def set_keychain(journal_name, password): + import keyring + if password is None: + try: + keyring.delete_password('jrnl', journal_name) + except: + pass + elif not TEST: + keyring.set_password('jrnl', journal_name, password) + + +def u(s): + """Mock unicode function for python 2 and 3 compatibility.""" + if not isinstance(s, str): + s = str(s) + return s if PY3 or type(s) is unicode else s.decode("utf-8") + + +def py2encode(s): + """Encodes to UTF-8 in Python 2 but not in Python 3.""" + return s.encode("utf-8") if PY2 and type(s) is unicode else s + + +def bytes(s): + """Returns bytes, no matter what.""" + if PY3: + return s.encode("utf-8") if type(s) is not bytes else s + return s.encode("utf-8") if type(s) is unicode else s + + +def prnt(s): + """Encode and print a string""" + STDOUT.write(u(s + "\n")) + + +def prompt(msg): + """Prints a message to the std err stream defined in util.""" + if not msg.endswith("\n"): + msg += "\n" + STDERR.write(u(msg)) + + +def py23_input(msg=""): + prompt(msg) + return STDIN.readline().strip() + + +def py23_read(msg=""): + print(msg) + return STDIN.read() + + +def yesno(prompt, default=True): + prompt = prompt.strip() + (" [Y/n]" if default else " [y/N]") + raw = py23_input(prompt) + return {'y': True, 'n': False}.get(raw.lower(), default) + + +def load_config(config_path): + """Tries to load a config file from YAML. + """ + with open(config_path) as f: + return yaml.load(f, Loader=yaml.FullLoader) + + +def scope_config(config, journal_name): + if journal_name not in config['journals']: + return config + config = config.copy() + journal_conf = config['journals'].get(journal_name) + if type(journal_conf) is dict: # We can override the default config on a by-journal basis + log.debug('Updating configuration with specific journal overrides %s', journal_conf) + config.update(journal_conf) + else: # But also just give them a string to point to the journal file + config['journal'] = journal_conf + config.pop('journals') + return config + + +def get_text_from_editor(config, template=""): + filehandle, tmpfile = tempfile.mkstemp(prefix="jrnl", text=True, suffix=".txt") + with codecs.open(tmpfile, 'w', "utf-8") as f: + if template: + f.write(template) + try: + subprocess.call(shlex.split(config['editor'], posix="win" not in sys.platform) + [tmpfile]) + except AttributeError: + subprocess.call(config['editor'] + [tmpfile]) + with codecs.open(tmpfile, "r", "utf-8") as f: + raw = f.read() + os.close(filehandle) + os.remove(tmpfile) + if not raw: + prompt('[Nothing saved to file]') + return raw + + +def colorize(string): + """Returns the string wrapped in cyan ANSI escape""" + return u"\033[36m{}\033[39m".format(string) + + +def slugify(string): + """Slugifies a string. + Based on public domain code from https://github.com/zacharyvoase/slugify + and ported to deal with all kinds of python 2 and 3 strings + """ + string = u(string) + ascii_string = str(unicodedata.normalize('NFKD', string).encode('ascii', 'ignore')) + if PY3: + ascii_string = ascii_string[1:] # removed the leading 'b' + no_punctuation = re.sub(r'[^\w\s-]', '', ascii_string).strip().lower() + slug = re.sub(r'[-\s]+', '-', no_punctuation) + return u(slug) + + +def int2byte(i): + """Converts an integer to a byte. + This is equivalent to chr() in Python 2 and bytes((i,)) in Python 3.""" + return chr(i) if PY2 else bytes((i,)) + + +def byte2int(b): + """Converts a byte to an integer. + This is equivalent to ord(bs[0]) on Python 2 and bs[0] on Python 3.""" + return ord(b)if PY2 else b + + +def split_title(text): + """Splits the first sentence off from a text.""" + punkt = SENTENCE_SPLITTER.search(text) + if not punkt: + return text, "" + return text[:punkt.end()].strip(), text[punkt.end():].strip() diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 00000000..ed1795d1 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,23 @@ +site_name: jrnl +theme: + name: readthedocs + custom_dir: docs/theme + static_templates: + - index.html +extra_css: + - https://fonts.googleapis.com/css?family=Open+Sans:300,600 + - assets/theme.css + - assets/highlight.css +markdown_extensions: + - admonition +repo_url: https://github.com/jrnl-org/jrnl/ +site_author: Manuel Ebert +site_description: Never Worry about Money Again. +nav: + - Overview: overview.md + - Quickstart: installation.md + - Basic Usage: usage.md + - Encryption: encryption.md + - Import and Export: export.md + - Advanced Usage: advanced.md + - Recipes: recipes.md diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 00000000..e772b2d2 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,429 @@ +[[package]] +category = "dev" +description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +name = "appdirs" +optional = false +python-versions = "*" +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]] +category = "main" +description = "Safe, minimalistic evaluator of python expression using ast module" +name = "asteval" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +version = "0.9.14" + +[package.dependencies] +numpy = "*" +six = "*" + +[[package]] +category = "dev" +description = "Classes Without Boilerplate" +name = "attrs" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "19.1.0" + +[[package]] +category = "dev" +description = "behave is behaviour-driven development, Python style" +name = "behave" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +version = "1.2.6" + +[package.dependencies] +parse = ">=1.8.2" +parse-type = ">=0.4.2" +six = ">=1.11" + +[[package]] +category = "dev" +description = "The uncompromising code formatter." +name = "black" +optional = false +python-versions = ">=3.6" +version = "18.9b0" + +[package.dependencies] +appdirs = "*" +attrs = ">=17.4.0" +click = ">=6.5" +toml = ">=0.9.4" + +[[package]] +category = "main" +description = "Foreign Function Interface for Python calling C code." +name = "cffi" +optional = false +python-versions = "*" +version = "1.12.3" + +[package.dependencies] +pycparser = "*" + +[[package]] +category = "dev" +description = "Composable command line interface toolkit" +name = "click" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "7.0" + +[[package]] +category = "main" +description = "Cross-platform colored terminal text." +marker = "sys_platform == \"win32\"" +name = "colorama" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "0.4.1" + +[[package]] +category = "main" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +name = "cryptography" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" +version = "2.7" + +[package.dependencies] +asn1crypto = ">=0.21.0" +cffi = ">=1.8,<1.11.3 || >1.11.3" +six = ">=1.4.1" + +[[package]] +category = "main" +description = "Discover and load entry points from installed packages." +name = "entrypoints" +optional = false +python-versions = ">=2.7" +version = "0.3" + +[[package]] +category = "dev" +description = "the modular source code checker: pep8, pyflakes and co" +name = "flake8" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "3.7.8" + +[package.dependencies] +entrypoints = ">=0.3.0,<0.4.0" +mccabe = ">=0.6.0,<0.7.0" +pycodestyle = ">=2.5.0,<2.6.0" +pyflakes = ">=2.1.0,<2.2.0" + +[[package]] +category = "main" +description = "Clean single-source support for Python 3 and 2" +name = "future" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +version = "0.17.1" + +[[package]] +category = "main" +description = "Low-level, pure Python DBus protocol wrapper." +marker = "sys_platform == \"linux\"" +name = "jeepney" +optional = false +python-versions = ">=3.5" +version = "0.4" + +[[package]] +category = "dev" +description = "A small but fast and easy to use stand-alone template engine written in pure python." +name = "jinja2" +optional = false +python-versions = "*" +version = "2.10.1" + +[package.dependencies] +MarkupSafe = ">=0.23" + +[[package]] +category = "main" +description = "Store and access your passwords safely." +name = "keyring" +optional = false +python-versions = ">=3.5" +version = "19.0.2" + +[package.dependencies] +entrypoints = "*" +pywin32-ctypes = "<0.1.0 || >0.1.0,<0.1.1 || >0.1.1" +secretstorage = "*" + +[[package]] +category = "dev" +description = "Python LiveReload is an awesome tool for web developers" +name = "livereload" +optional = false +python-versions = "*" +version = "2.6.1" + +[package.dependencies] +six = "*" +tornado = "*" + +[[package]] +category = "dev" +description = "Python implementation of Markdown." +name = "markdown" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*" +version = "3.1.1" + +[package.dependencies] +setuptools = ">=36" + +[[package]] +category = "dev" +description = "Safely add untrusted strings to HTML/XML markup." +name = "markupsafe" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" +version = "1.1.1" + +[[package]] +category = "dev" +description = "McCabe checker, plugin for flake8" +name = "mccabe" +optional = false +python-versions = "*" +version = "0.6.1" + +[[package]] +category = "dev" +description = "Project documentation with Markdown." +name = "mkdocs" +optional = false +python-versions = ">=2.7.9,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" +version = "1.0.4" + +[package.dependencies] +Jinja2 = ">=2.7.1" +Markdown = ">=2.3.1" +PyYAML = ">=3.10" +click = ">=3.3" +livereload = ">=2.5.1" +tornado = ">=5.0" + +[[package]] +category = "main" +description = "NumPy is the fundamental package for array computing with Python." +name = "numpy" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" +version = "1.16.4" + +[[package]] +category = "dev" +description = "parse() is the opposite of format()" +name = "parse" +optional = false +python-versions = "*" +version = "1.12.0" + +[[package]] +category = "dev" +description = "Simplifies to build parse types based on the parse module" +name = "parse-type" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*" +version = "0.4.2" + +[package.dependencies] +parse = ">=1.8" +six = ">=1.11" + +[[package]] +category = "main" +description = "Parse human-readable date/time text." +name = "parsedatetime" +optional = false +python-versions = "*" +version = "2.4" + +[package.dependencies] +future = "*" + +[[package]] +category = "main" +description = "comprehensive password hashing framework supporting over 30 schemes" +name = "passlib" +optional = false +python-versions = "*" +version = "1.7.1" + +[[package]] +category = "dev" +description = "Python style guide checker" +name = "pycodestyle" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "2.5.0" + +[[package]] +category = "main" +description = "C parser in Python" +name = "pycparser" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "2.19" + +[[package]] +category = "dev" +description = "passive checker of Python programs" +name = "pyflakes" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "2.1.1" + +[[package]] +category = "main" +description = "Extensions to the standard Python datetime module" +name = "python-dateutil" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +version = "2.8.0" + +[package.dependencies] +six = ">=1.5" + +[[package]] +category = "main" +description = "World timezone definitions, modern and historical" +name = "pytz" +optional = false +python-versions = "*" +version = "2019.1" + +[[package]] +category = "main" +description = "" +marker = "sys_platform == \"win32\"" +name = "pywin32-ctypes" +optional = false +python-versions = "*" +version = "0.2.0" + +[[package]] +category = "main" +description = "PyXDG contains implementations of freedesktop.org standards in python." +name = "pyxdg" +optional = false +python-versions = "*" +version = "0.26" + +[[package]] +category = "main" +description = "YAML parser and emitter for Python" +name = "pyyaml" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "5.1.2" + +[[package]] +category = "main" +description = "Python bindings to FreeDesktop.org Secret Service API" +marker = "sys_platform == \"linux\"" +name = "secretstorage" +optional = false +python-versions = ">=3.5" +version = "3.1.1" + +[package.dependencies] +cryptography = "*" +jeepney = "*" + +[[package]] +category = "main" +description = "Python 2 and 3 compatibility utilities" +name = "six" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*" +version = "1.12.0" + +[[package]] +category = "dev" +description = "Python Library for Tom's Obvious, Minimal Language" +name = "toml" +optional = false +python-versions = "*" +version = "0.10.0" + +[[package]] +category = "dev" +description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed." +name = "tornado" +optional = false +python-versions = ">= 3.5" +version = "6.0.3" + +[[package]] +category = "main" +description = "tzinfo object for the local timezone" +name = "tzlocal" +optional = false +python-versions = "*" +version = "1.5.1" + +[package.dependencies] +pytz = "*" + +[metadata] +content-hash = "9896cf59c7552b6ad95219ee5555c7445a3fab39c2e4f4c6f3d991a36635e44b" +python-versions = "^3.7" + +[metadata.hashes] +appdirs = ["9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92", "d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e"] +asn1crypto = ["2f1adbb7546ed199e3c90ef23ec95c5cf3585bac7d11fb7eb562a3fe89c64e87", "9d5c20441baf0cb60a4ac34cc447c6c189024b6b4c6cd7877034f4965c464e49"] +asteval = ["7c81fee6707a7a28e8beae891b858535a7e61f9ce275a0a4cf5f428fbc934cb8"] +attrs = ["69c0dbf2ed392de1cb5ec704444b08a5ef81680a61cb899dc08127123af36a79", "f0b870f674851ecbfbbbd364d6b5cbdff9dcedbc7f3f5e18a6891057f21fe399"] +behave = ["b9662327aa53294c1351b0a9c369093ccec1d21026f050c3bd9b3e5cccf81a86", "ebda1a6c9e5bfe95c5f9f0a2794e01c7098b3dde86c10a95d8621c5907ff6f1c"] +black = ["817243426042db1d36617910df579a54f1afd659adb96fc5032fcf4b36209739", "e030a9a28f542debc08acceb273f228ac422798e5215ba2a791a6ddeaaca22a5"] +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"] +click = ["2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13", "5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7"] +colorama = ["05eed71e2e327246ad6b38c540c4a3117230b19679b875190486ddd2d721422d", "f8ac84de7840f5b9c4e3347b3c1eaa50f7e49c2b07596221daec5edaabbd7c48"] +cryptography = ["24b61e5fcb506424d3ec4e18bca995833839bf13c59fc43e530e488f28d46b8c", "25dd1581a183e9e7a806fe0543f485103232f940fcfc301db65e630512cce643", "3452bba7c21c69f2df772762be0066c7ed5dc65df494a1d53a58b683a83e1216", "41a0be220dd1ed9e998f5891948306eb8c812b512dc398e5a01846d855050799", "5751d8a11b956fbfa314f6553d186b94aa70fdb03d8a4d4f1c82dcacf0cbe28a", "5f61c7d749048fa6e3322258b4263463bfccefecb0dd731b6561cb617a1d9bb9", "72e24c521fa2106f19623a3851e9f89ddfdeb9ac63871c7643790f872a305dfc", "7b97ae6ef5cba2e3bb14256625423413d5ce8d1abb91d4f29b6d1a081da765f8", "961e886d8a3590fd2c723cf07be14e2a91cf53c25f02435c04d39e90780e3b53", "96d8473848e984184b6728e2c9d391482008646276c3ff084a1bd89e15ff53a1", "ae536da50c7ad1e002c3eee101871d93abdc90d9c5f651818450a0d3af718609", "b0db0cecf396033abb4a93c95d1602f268b3a68bb0a9cc06a7cff587bb9a7292", "cfee9164954c186b191b91d4193989ca994703b2fff406f71cf454a2d3c7327e", "e6347742ac8f35ded4a46ff835c60e68c22a536a8ae5c4422966d06946b6d4c6", "f27d93f0139a3c056172ebb5d4f9056e770fdf0206c2f422ff2ebbad142e09ed", "f57b76e46a58b63d1c6375017f4564a28f19a5ca912691fd2e4261b3414b618d"] +entrypoints = ["589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19", "c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451"] +flake8 = ["19241c1cbc971b9962473e4438a2ca19749a7dd002dd1a946eaba171b4114548", "8e9dfa3cecb2400b3738a42c54c3043e821682b9c840b0448c0503f781130696"] +future = ["67045236dcfd6816dc439556d009594abf643e5eb48992e36beac09c2ca659b8"] +jeepney = ["6089412a5de162c04747f0220f6b2223b8ba660acd041e52a76426ca550e3c70", "f6f8b1428403b4afad04b6b82f9ab9fc426c253d7504c9031c41712a2c01dc74"] +jinja2 = ["065c4f02ebe7f7cf559e49ee5a95fb800a9e4528727aec6f24402a5374c65013", "14dd6caf1527abb21f08f86c784eac40853ba93edb79552aa1e4b8aef1b61c7b"] +keyring = ["1b74595f7439e4581a11d4f9a12790ac34addce64ca389c86272ff465f5e0b90", "afbfe7bc9bdba69d25c551b0c738adde533d87e0b51ad6bbe332cbea19ad8476"] +livereload = ["78d55f2c268a8823ba499305dcac64e28ddeb9a92571e12d543cd304faf5817b", "89254f78d7529d7ea0a3417d224c34287ebfe266b05e67e51facaf82c27f0f66"] +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"] +mccabe = ["ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", "dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"] +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"] +parse = ["1b68657434d371e5156048ca4a0c5aea5afc6ca59a2fea4dd1a575354f617142"] +parse-type = ["6e906a66f340252e4c324914a60d417d33a4bea01292ea9bbf68b4fc123be8c9", "f596bdc75d3dd93036fbfe3d04127da9f6df0c26c36e01e76da85adef4336b3c"] +parsedatetime = ["3d817c58fb9570d1eec1dd46fa9448cd644eeed4fb612684b02dfda3a79cb84b", "9ee3529454bf35c40a77115f5a596771e59e1aee8c53306f346c461b8e913094"] +passlib = ["3d948f64138c25633613f303bcc471126eae67c04d5e3f6b7b8ce6242f8653e0", "43526aea08fa32c6b6dbbbe9963c4c767285b78147b7437597f992812f69d280"] +pycodestyle = ["95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56", "e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c"] +pycparser = ["a988718abfad80b6b157acce7bf130a30876d27603738ac39f140993246b25b3"] +pyflakes = ["17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0", "d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2"] +python-dateutil = ["7e6584c74aeed623791615e26efd690f29817a27c73085b78e4bad02493df2fb", "c89805f6f4d64db21ed966fda138f8a5ed7a4fdbc1a8ee329ce1b74e3c74da9e"] +pytz = ["303879e36b721603cc54604edcac9d20401bdbe31e1e4fdee5b9f98d5d31dfda", "d747dd3d23d77ef44c6a3526e274af6efeb0a6f1afd5a69ba4d5be4098c8e141"] +pywin32-ctypes = ["24ffc3b341d457d48e8922352130cf2644024a4ff09762a2261fd34c36ee5942", "9dc2d991b3479cc2df15930958b674a48a227d5361d413827a4cfd0b5876fc98"] +pyxdg = ["1948ff8e2db02156c0cccd2529b43c0cff56ebaa71f5f021bbd755bc1419190e", "fe2928d3f532ed32b39c32a482b54136fe766d19936afc96c8f00645f9da1a06"] +pyyaml = ["0113bc0ec2ad727182326b61326afa3d1d8280ae1122493553fd6f4397f33df9", "01adf0b6c6f61bd11af6e10ca52b7d4057dd0be0343eb9283c878cf3af56aee4", "5124373960b0b3f4aa7df1707e63e9f109b5263eca5976c66e08b1c552d4eaf8", "5ca4f10adbddae56d824b2c09668e91219bb178a1eee1faa56af6f99f11bf696", "7907be34ffa3c5a32b60b95f4d95ea25361c951383a894fec31be7252b2b6f34", "7ec9b2a4ed5cad025c2278a1e6a19c011c80a3caaac804fd2d329e9cc2c287c9", "87ae4c829bb25b9fe99cf71fbb2140c448f534e24c998cc60f39ae4f94396a73", "9de9919becc9cc2ff03637872a440195ac4241c80536632fffeb6a1e25a74299", "a5a85b10e450c66b49f98846937e8cfca1db3127a9d5d1e31ca45c3d0bef4c5b", "b0997827b4f6a7c286c01c5f60384d218dca4ed7d9efa945c3e1aa623d5709ae", "b631ef96d3222e62861443cc89d6563ba3eeb816eeb96b2629345ab795e53681", "bf47c0607522fdbca6c9e817a6e81b08491de50f3766a7a0e6a5be7905961b41", "f81025eddd0327c7d4cfe9b62cf33190e1e736cc6e97502b3ec425f574b3e7a8"] +secretstorage = ["20c797ae48a4419f66f8d28fc221623f11fc45b6828f96bdb1ad9990acb59f92", "7a119fb52a88e398dbb22a4b3eb39b779bfbace7e4153b7bc6e5954d86282a8a"] +six = ["3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", "d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73"] +toml = ["229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c", "235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e", "f1db651f9657708513243e61e6cc67d101a39bad662eaa9b5546f789338e07a3"] +tornado = ["349884248c36801afa19e342a77cc4458caca694b0eda633f5878e458a44cb2c", "398e0d35e086ba38a0427c3b37f4337327231942e731edaa6e9fd1865bbd6f60", "4e73ef678b1a859f0cb29e1d895526a20ea64b5ffd510a2307b5998c7df24281", "559bce3d31484b665259f50cd94c5c28b961b09315ccd838f284687245f416e5", "abbe53a39734ef4aba061fca54e30c6b4639d3e1f59653f0da37a0003de148c7", "c845db36ba616912074c5b1ee897f8e0124df269468f25e4fe21fe72f6edd7a9", "c9399267c926a4e7c418baa5cbe91c7d1cf362d505a1ef898fde44a07c9dd8a5"] +tzlocal = ["4ebeb848845ac898da6519b9b31879cf13b6626f7184c496037b818e238f2c4e"] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..c107dfd6 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,41 @@ +[tool.poetry] +name = "jrnl" +version = "0.0.2" +description = "Collect your thoughts and notes without leaving the command line." +authors = [ + "Manuel Ebert ", + "Jonathan Wren ", + "Micah Ellison " +] +license = "MIT" +readme = "README.md" +homepage = "https://jrnl.sh" +repository = "https://github.com/jrnl-org/jrnl" + +[tool.poetry.dependencies] +python = "^3.7" +pyxdg = "^0.26.0" +cryptography = "^2.7" +passlib = "^1.7" +parsedatetime = "^2.4" +keyring = "^19.0" +pytz = "^2019.1" +tzlocal = "^1.5" +asteval = "^0.9.14" +colorama = {version = "^0.4.1",platform = "win32"} +python-dateutil = "^2.8" +pyyaml = "^5.1" + +[tool.poetry.dev-dependencies] +behave = "^1.2" +mkdocs = "^1.0" +flake8 = "^3.7" +black = {version = "^18.3-alpha.0",allows-prereleases = true} + +[tool.poetry.scripts] +jrnl = 'jrnl.cli:run' + +[build-system] +requires = ["poetry>=0.12"] +build-backend = "poetry.masonry.api" +