├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── example.conf └── jour /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017, Ben Sima 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | * Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above copyright 9 | notice, this list of conditions and the following disclaimer in the 10 | documentation and/or other materials provided with the distribution. 11 | * Neither the name of the organization nor the names of its 12 | contributors may be used to endorse or promote products derived 13 | from this software without specific prior written permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 16 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 17 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 18 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL BEN SIMA BE 19 | LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 20 | CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 21 | SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR 22 | BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 23 | WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE 24 | OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN 25 | IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: install clean 2 | 3 | cwd = $(shell pwd) 4 | install_path = /usr/local/bin/jour 5 | 6 | install: 7 | ln -s $(cwd)/jour $(install_path) 8 | clean: 9 | rm $(install_path) 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `jour` 2 | 3 | > **the simplest possible journal.** 4 | 5 | install: 6 | 7 | ```sh 8 | $ git clone git@github.com:bsima/jour.git ~/jour 9 | $ cd jour 10 | $ sudo make install 11 | $ jour --help 12 | ``` 13 | 14 | - - - 15 | 16 | I've kept paper journals, digital journals in evernote and wordpress, 17 | etc. I've learned *not* to trust digital journals because the formats 18 | change, or I lose the data in "the cloud" somewhere. The only digital 19 | journal I trust nowadays is my own collection of [text 20 | files](http://graydon.livejournal.com/196162.html), stored in a git 21 | repo on my own server. 22 | 23 | In any case, the point of this very small CLI program is to show just 24 | how simple a journal can be. Personally I use this weekly for 25 | diary-like entries and saving links or thoughts while I'm working. I 26 | also keep a few paper journals around for less-structured notes, 27 | mobile notes (I've never found a good note-taking app for phones), or 28 | random sketching. 29 | 30 | The point of this isn’t to be a generally useful tool for 31 | everyone. Rather, it’s just to show that something like this is super 32 | simple, and you shouldn’t look for a fancy tool to help you keep a 33 | journal. Just use the simplest thing that gets the job done. This 34 | script could also be a shell script or a few lines of vimscript/elisp, 35 | but `jour` works for me so I share it :) 36 | 37 | Another great thing for notetaking: [Ryan Holiday's notecard 38 | system](http://ryanholiday.net/the-notecard-system-the-key-for-remembering-organizing-and-using-everything-you-read/), 39 | which ends up resembling a relational database when taken to the 40 | extreme (see the Robert Greene blockquote in that link). -------------------------------------------------------------------------------- /example.conf: -------------------------------------------------------------------------------- 1 | # Example jour configuration file. The default location of this file is 2 | # $XDG_CONFIG_HOME/jour.conf 3 | # 4 | # All of the settings in this file are optional - the values here are the 5 | # defaults that will be used if no config file or environment variables are 6 | # provided. 7 | 8 | [main] 9 | # The filename format is a Python format string where {y}, {m}, and {d} 10 | # represent the year, month, and day, respectively. 11 | # 12 | # Examples: 13 | # {d}-{m}-{y} -- day/month/year format with hyphens 14 | # {m}-{d}-{y}.txt -- US-style date with .txt extension 15 | # Journal {y}-{m}-{d}.md -- Markdown file with a fixed prefix an .md extension 16 | # * note that the space is *not* escaped * 17 | filename_format = {y}.{m}.{d} 18 | 19 | # journal_dir is the folder that contains journal entries 20 | journal_dir = ~/journal 21 | 22 | # editor and pager work just like the $EDITOR and $PAGER environment variables 23 | editor = /usr/bin/vi 24 | pager = /bin/more 25 | -------------------------------------------------------------------------------- /jour: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3.5 2 | import argparse 3 | import configparser 4 | from datetime import datetime, timedelta 5 | import logging 6 | import os 7 | from os.path import expanduser 8 | import subprocess 9 | import sys 10 | 11 | __doc__ = \ 12 | """ 13 | Write, read, and edit your journal. Calling jour with no arguments will open 14 | today's journal entry for writing. For configuration options, see example.conf 15 | """ 16 | 17 | EDITOR = os.environ.get('EDITOR', '/usr/bin/vi') 18 | PAGER = os.environ.get('PAGER', '/bin/more') 19 | JOURNAL_DIR = os.environ.get('JOURNAL_DIR', expanduser("~/journal")) 20 | CONFIG_DIR = os.environ.get('XDG_CONFIG_HOME', expanduser("~/.config")) 21 | CONFIG_FILE_NAME = 'jour.conf' 22 | DEFAULT_FORMAT = '{y}.{m}.{d}' 23 | 24 | 25 | class Entry(object): 26 | """Represents a journal entry.""" 27 | def __init__(self, date, journal_dir): 28 | """ 29 | Create a new Entry object initialized to the given date and the 30 | corresponding journal file if it exists. 31 | """ 32 | self.date = date 33 | self.path = os.path.join(os.sep, journal_dir, date) 34 | self.content = self._parse_content() 35 | self.tags = self._parse_tags() if self.content is not None else None 36 | 37 | def _parse_content(self): 38 | """Parses the file to a list of lines.""" 39 | if os.path.exists(self.path): 40 | with open(self.path) as f: 41 | return f.readlines() 42 | else: 43 | return None 44 | 45 | def _parse_tags(self): 46 | """Checks the file for a tags section and lists the tags if any.""" 47 | tags = [ 48 | [w.strip() for w in line.strip("tags:").split(",")] 49 | for line in self.content if "tags" in line 50 | ] 51 | return tags[0] if len(tags) > 0 else None 52 | 53 | 54 | class dateAction(argparse.Action): 55 | "TODO" 56 | def __init__(self, option_strings, dest, **kwargs): 57 | pass 58 | 59 | def __call__(self, parser, namespace, values, option_string): 60 | pass 61 | 62 | 63 | def askToMakeDir(journal_dir, retries=4, reminder='Please try again!'): 64 | """ 65 | Asks the user to create the journal directory. 66 | 67 | Will ask the user (retries) times or until they enter y or n. 68 | If the user enters anything other than y or n (reminder) will be printed. 69 | """ 70 | while True: 71 | ok = input('Can not find journal dir. Wanna make it? ') 72 | if ok in ('y', 'ye', 'yes', 'Y'): 73 | os.makedirs(journal_dir) 74 | return True 75 | if ok in ('n', 'no', 'nop', 'nope', 'N'): 76 | raise NotADirectoryError("{0} does not exist.".format(journal_dir)) 77 | return False 78 | retries = retries - 1 79 | if retries < 0: 80 | raise ValueError('invalid user response') 81 | print(reminder) 82 | 83 | 84 | def check(journal_dir): 85 | """ 86 | Checks whether the journal directory exists and prompts the user to create 87 | it if it doesn't. 88 | """ 89 | if not os.path.exists(journal_dir): 90 | askToMakeDir(journal_dir) 91 | 92 | 93 | def format_date(dt, conf): 94 | """Takes a datetime object and returns a string using the configured format.""" 95 | def fmt(n): 96 | return "{0:02d}".format(n) 97 | 98 | s = list(map(fmt, [dt.year, dt.month, dt.day])) 99 | format_string = get_config_option(conf, 'filename_format') 100 | return format_string.format(y=s[0], m=s[1], d=s[2]) 101 | 102 | 103 | def load_config(config_file=None): 104 | """ 105 | Load a configuration file and return a config parser (i.e. dictionary) with 106 | the settings. 107 | """ 108 | # Set default config options 109 | config = configparser.ConfigParser(defaults={ 110 | 'filename_format': DEFAULT_FORMAT, 111 | 'journal_dir': JOURNAL_DIR, 112 | 'editor': EDITOR, 113 | 'pager': PAGER 114 | }) 115 | 116 | if config_file is None: 117 | config_file = os.path.join(os.sep, CONFIG_DIR, CONFIG_FILE_NAME) 118 | config.read(config_file) 119 | 120 | return config 121 | 122 | 123 | def get_config_option(conf, key): 124 | """ 125 | Get a configuration option from the config dictionary. 126 | 127 | Uses either the main section (if it exists) or the DEFAULT section if there 128 | is no main section. Will raise an exception if the key isn't found. 129 | """ 130 | if 'main' in conf: 131 | return conf['main'].get(key) 132 | else: 133 | return conf['DEFAULT'].get(key) 134 | 135 | 136 | # Commands ################################################################### 137 | def list_entries(args, conf): 138 | """Prints a list of journal entries.""" 139 | journal_dir = get_config_option(conf, 'journal_dir') 140 | entries = os.listdir(journal_dir) 141 | for e in sorted(entries): 142 | entry = Entry(e, journal_dir) 143 | if entry.tags is not None: 144 | print("{0} : {1}".format(entry.date, ", ".join(entry.tags))) 145 | else: 146 | print(entry.date) 147 | 148 | exit(0) 149 | 150 | 151 | def read_entry(args, conf): 152 | """Prints today's journal entry.""" 153 | journal_dir = get_config_option(conf, 'journal_dir') 154 | pager = get_config_option(conf, 'pager') 155 | e = Entry(args.date, journal_dir) 156 | if e.content is not None: 157 | print(e.path) 158 | subprocess.call([pager, e.path]) 159 | exit(0) 160 | else: 161 | print("No journal entry found for {}.".format(args.date)) 162 | exit(1) 163 | 164 | 165 | def write_entry(args, conf): 166 | """Opens today's journal entry in the editor.""" 167 | journal_dir = get_config_option(conf, 'journal_dir') 168 | editor = get_config_option(conf, 'editor') 169 | e = Entry(args.date, journal_dir) 170 | subprocess.call([editor, e.path]) 171 | exit(0) 172 | 173 | 174 | # CLI ####################################################################### 175 | def main(): 176 | """Main function""" 177 | cli = argparse.ArgumentParser( 178 | formatter_class=argparse.RawDescriptionHelpFormatter, 179 | description=__doc__ 180 | ) 181 | 182 | cli.add_argument( 183 | '-d', 184 | '--date', 185 | default=datetime.now().strftime('%Y-%m-%d'), 186 | help="Set the date for the entry (YYYY-MM-DD). Defaults to today." 187 | ) 188 | 189 | cli.add_argument( 190 | '-c', 191 | '--config', 192 | default=None, 193 | help="Full path to configuration file." 194 | ) 195 | 196 | cli.add_argument( 197 | '-y', 198 | '--yesterday', 199 | action='store_true', 200 | help="Open yesterday's journal entry." 201 | ) 202 | 203 | commands = cli.add_subparsers(dest="command") 204 | 205 | commands.add_parser('ls').set_defaults(command=list_entries) 206 | commands.add_parser('read').set_defaults(command=read_entry) 207 | 208 | args = cli.parse_args() 209 | 210 | conf = load_config(args.config) 211 | 212 | # Reformat the date according to the config file 213 | if args.yesterday: 214 | args.date = format_date(datetime.now() - timedelta(days=1), conf) 215 | elif args.date: 216 | args.date = format_date(datetime.strptime(args.date, '%Y-%m-%d'), conf) 217 | 218 | if not args.command: 219 | check(get_config_option(conf, 'journal_dir')) 220 | write_entry(args, conf) 221 | else: 222 | args.command(args, conf) 223 | 224 | if __name__ == '__main__': 225 | main() 226 | --------------------------------------------------------------------------------