#!/usr/bin/python
# Copyright 2010-2015 Lars Wirzenius
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
import cliapp
import os
import re
import shutil
import string
import time
__version__ = '0.5'
class DraftsDirectory(object):
def __init__(self, dirname):
self.dirname = dirname
def create_if_missing(self):
if not os.path.exists(self.dirname):
os.mkdir(self.dirname)
def get_draft_pathname(self, draft_id):
return os.path.join(self.dirname, '%s.mdwn' % draft_id)
def get_draft_attachments_dirname(self, draft_id):
return os.path.join(self.dirname, '%s' % draft_id)
def create_draft(self, content):
draft_id = self._pick_available_draft_id()
pathname = self.get_draft_pathname(draft_id)
with open(pathname, 'w') as f:
f.write(content)
return draft_id
def _pick_available_draft_id(self):
for i in range(1000):
pathname = self.get_draft_pathname(i)
if not os.path.exists(pathname):
return i
raise cliapp.AppException('ERROR: too many existing drafts')
def get_drafts(self):
for basename in os.listdir(self.dirname):
# .# is what Emacs autosave files start with.
if basename.endswith('.mdwn') and not basename.startswith('.#'):
suffixless = basename[:-len('.mdwn')]
pathname = os.path.join(self.dirname, basename)
yield suffixless, pathname
def remove_draft(self, draft_id):
filename = self.get_draft_pathname(draft_id)
os.remove(filename)
dirname = self.get_draft_attachments_dirname(draft_id)
if os.path.exists(dirname):
shutil.rmtree(dirname)
def get_draft_title(self, draft_id):
pathname = self.get_draft_pathname(draft_id)
with open(pathname) as f:
return self._get_title_from_open_file(f)
def _get_title_from_open_file(self, f):
for line in f:
m = re.match(r'\[\[!meta title="(?P
.*)("\]\])$', line)
if m:
return m.group('title')
return None
def commit_to_git(source_dir, pathnames):
cliapp.runcmd(['git', 'add'] + pathnames, cwd=source_dir)
cliapp.runcmd(['git', 'commit', '-m', 'Publish log entry'], cwd=source_dir)
def push_git(source_dir):
cliapp.runcmd(['git', 'push', 'origin', 'HEAD'], cwd=source_dir)
class Command(object):
def __init__(self, app):
self._app = app
def run(self, args):
raise NotImplementedError()
class NewCommand(Command):
_default_new_note_template = '''\
[[!meta title="%(title)s"]]
[[!tag ]]
[[!meta date="%(date)s"]]
%(topiclink)s
'''
def run(self, args):
self._check_args_and_settings(args)
topic = self._app.settings['topic']
values = {
'title': args[0],
'date': time.strftime('%Y-%m-%d %H:%M', self._app.now_tuple),
'topiclink': self._get_topic_link(topic),
}
drafts_dir = DraftsDirectory(self._app.drafts_dir())
drafts_dir.create_if_missing()
draft_id = drafts_dir.create_draft(
self._get_new_note_template() % values)
self._app.edit_file(drafts_dir.get_draft_pathname(draft_id))
def _check_args_and_settings(self, args):
if not args:
raise cliapp.AppException('Usage: journal-note new TITLE')
self._app.settings.require('source')
topic = self._app.settings['topic']
if topic and not self._topic_page_exists(topic):
raise cliapp.AppException('Topic %s does not exist yet' % topic)
def _topic_page_exists(self, topic):
pathname = os.path.join(self._app.settings['source'], topic + '.mdwn')
return os.path.exists(pathname)
def _get_topic_link(self, topic):
if topic:
return '[[!meta link="%s"]]' % topic
else:
return ''
def _get_new_note_template(self):
filename = self._app.settings['new-note-template']
if filename:
with open(filename) as f:
return f.read()
else:
return self._default_new_note_template
class ListCommand(Command):
def run(self, args):
drafts_dir = DraftsDirectory(self._app.drafts_dir())
for draft_id, _ in drafts_dir.get_drafts():
print draft_id, drafts_dir.get_draft_title(draft_id)
class EditCommand(Command):
def run(self, args):
if len(args) > 1:
raise cliapp.AppException('Must be given at most one draft ID')
drafts_dir = DraftsDirectory(self._app.drafts_dir())
_, pathname = self._app.choose_draft(drafts_dir, args)
self._app.edit_file(pathname)
class AttachCommand(Command):
def run(self, args):
if len(args) < 2:
raise cliapp.AppException('Usage: journal-note attach ID file...')
drafts_dir = DraftsDirectory(self._app.draft_dir())
dirname = drafts_dir.get_draft_attachments_dirname(args[0])
if not os.path.exists(dirname):
os.mkdir(dirname)
for filename in args[1:]:
shutil.copy(filename, dirname)
class RemoveCommand(Command):
def run(self, args):
if not args:
raise cliapp.AppException('Usage: journal-note remove ID')
drafts_dir = DraftsDirectory(self._app.drafts_dir())
drafts_dir.remove_draft(args[0])
class FinishCommand(Command):
def run(self, args):
drafts_dir = DraftsDirectory(self._app.drafts_dir())
draft_id, draft_mdwn = self._app.choose_draft(drafts_dir, args)
draft_attch = drafts_dir.get_draft_attachments_dirname(draft_id)
title = drafts_dir.get_draft_title(draft_id)
if not title:
raise Exception("%s has no title" % draft_mdwn)
pub_attch = os.path.join(
self._published_dir(),
self._summarise_title(title))
pub_mdwn = pub_attch + '.mdwn'
if os.path.exists(pub_mdwn):
raise cliapp.AppException('%s already exists' % pub_mdwn)
self._publish_draft(draft_mdwn, draft_attch, pub_mdwn, pub_attch)
if self._app.settings['git']:
if os.path.exists(pub_attch):
self._commit_to_git([pub_mdwn, pub_attch])
else:
self._commit_to_git([pub_mdwn])
if self._app.settings['push']:
self._push_git()
def _published_dir(self):
subdir = time.strftime('notes/%Y/%m/%d', self._app.now_tuple)
return os.path.join(self._app.settings['source'], subdir)
def _summarise_title(self, title):
basename = ''
acceptable = set(string.ascii_letters + string.digits + '-_')
for c in title.lower():
if c in acceptable:
basename += c
elif not basename.endswith('_'):
basename += '_'
return basename
def _publish_draft(self, draft_mdwn, draft_attch, pub_mdwn, pub_attch):
parent_dir = os.path.dirname(pub_mdwn)
if not os.path.exists(parent_dir):
os.makedirs(parent_dir)
os.rename(draft_mdwn, pub_mdwn)
if os.path.exists(draft_attch):
os.rename(draft_attch, pub_attch)
def _commit_to_git(self, pathnames):
commit_to_git(self._app.settings['source'], pathnames)
def _push_git(self):
push_git(self._app.settings['source'])
class NewTopicCommand(Command):
def run(self, args):
if len(args) != 2:
raise cliapp.AppException(
'Must be given two args (page path, title) (%r)' % args)
pathname = self._topic_pathname(args[0])
self._create_topic_page(pathname, args[1])
self._app.edit_file(pathname)
def _topic_pathname(self, page_path):
return os.path.join(self._app.settings['source'], page_path + '.mdwn')
def _create_topic_page(self, pathname, title):
dirname = os.path.dirname(pathname)
if not os.path.exists(dirname):
os.makedirs(dirname)
template = '''\
[[!meta title="%(title)s"]]
[[!inline pages="link(.)" archive=yes reverse=yes trail=yes]]
'''
with open(pathname, 'w') as f:
f.write(template % {'title': title})
class NewPersonCommand(Command):
def run(self, args):
if len(args) != 1:
raise cliapp.AppException(
'Need the name of a person (in Last, First form)')
def normalise(name):
s = name.lower()
s = ' '.join(s.split(','))
s = '.'.join(s.split())
return s
name = args[0]
basename = normalise(name)
pathname = os.path.join(
self._app.settings['source'], 'people', basename + '.mdwn')
if os.path.exists(pathname):
raise cliapp.AppException('File %s already exists' % pathname)
template = '''\
[[!meta title="%(name)s"]]
[[!inline archive=yes pages="link(.)"]]
'''
with open(pathname, 'w') as f:
f.write(
template %
{
'name': name,
'basename': basename,
})
class JournalTool(cliapp.Application):
cmd_synopsis = {
'attach': 'DRAFT-ID [FILE]...',
'edit': '[DRAFT-ID]',
'finish': '[DRAFT-ID]',
'list': '',
'new': 'TITLE',
'remove': 'DRAFT-ID',
}
def add_settings(self):
self.settings.string(
['source'],
'use journal source tree in DIR',
metavar='DIR')
self.settings.boolean(
['git'],
'add entries to git automatically',
default=True)
self.settings.string(
['editor'],
'editor to launch for journal entries. Must include %s to '
'indicate where the filename goes',
default='sensible-editor %s')
self.settings.boolean(
['push'],
'push finished articles with git?')
self.settings.string(
['topic'],
'new entry belongs to TOPIC',
metavar='TOPIC')
self.settings.string(
['new-note-template'],
'use FILE as the template for new journal notes',
metavar='FILE')
self.settings.string(
['pretend-time'],
'pretend that the time is NOW (form: YYYY-MM-DD HH:MM:DD form)',
metavar='NOW')
def process_args(self, args):
if self.settings['pretend-time']:
self.now_tuple = time.strptime(
self.settings['pretend-time'], '%Y-%m-%d %H:%M:%S')
else:
self.now_tuple = time.localtime()
cliapp.Application.process_args(self, args)
def cmd_new(self, args):
'''Create a new journal entry draft.'''
NewCommand(self).run(args)
def cmd_list(self, args):
'''List journal entry drafts.'''
ListCommand(self).run(args)
def cmd_edit(self, args):
'''Edit a draft journal entry.'''
EditCommand(self).run(args)
def cmd_attach(self, args):
'''Attach files to a journal entry draft.'''
AttachCommand(self).run(args)
def cmd_remove(self, args):
'''Remove a draft.'''
RemoveCommand(self).run(args)
def cmd_finish(self, args):
'''Publish a draft journal entry.'''
FinishCommand(self).run(args)
def cmd_new_topic(self, args):
'''Create a new topic page.'''
NewTopicCommand(self).run(args)
def cmd_new_person(self, args):
'''Create a page to list all notes referring to a person.
This is probably only useful to Lars's personal journal.
'''
NewPersonCommand(self).run(args)
def drafts_dir(self):
return os.path.join(self.settings['source'], 'drafts')
def edit_file(self, pathname):
safe_pathname = cliapp.shell_quote(pathname)
cmdline = ['sh', '-c', self.settings['editor'] % safe_pathname]
self.runcmd(cmdline, stdin=None, stdout=None, stderr=None)
def choose_draft(self, drafts_dir, args):
if len(args) == 0:
drafts = list(drafts_dir.get_drafts())
if len(drafts) == 1:
draft_id, filename = drafts[0]
return draft_id, filename
elif len(drafts) == 0:
raise cliapp.AppException('No drafts to choose from')
else:
raise cliapp.AppException(
'Cannot choose entry draft automatically')
elif len(args) == 1:
pathname = drafts_dir.get_draft_pathname(args[0])
if not os.path.exists(pathname):
raise cliapp.AppException('draft %s does not exist' % args[0])
return args[0], pathname
elif len(args) > 1:
raise cliapp.AppException('Must give at most one draft number')
JournalTool(version=__version__).run()