#!/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 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): cliapp.runcmd( ['git', 'add'] + pathnames, cwd=self._app.settings['source']) cliapp.runcmd( ['git', 'commit', '-m', 'Publish log entry'], cwd=self._app.settings['source']) def _push_git(self): cliapp.runcmd( ['git', 'push', 'origin', 'HEAD'], cwd=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()