--- a/alexandria.py Wed Apr 30 10:41:18 2008 -0400
+++ b/alexandria.py Sat May 17 04:25:28 2008 -0400
@@ -2,10 +2,20 @@
import os, sys, os.path
import hashlib
+import terminal
from optparse import OptionParser
from models import Author, Paper, Tag, AuthorNickname, initDatabase, session
db_filename = 'alexandria.db'
+term = terminal.TerminalController()
+color = {'title': term.GREEN + term.BOLD,
+ 'author': term.YELLOW + term.BOLD,
+ 'label': term.CYAN + term.BOLD,
+ 'path': term.NORMAL,
+ 'hash': term.RED + term.BOLD,
+ 'error': term.RED + term.BOLD,
+ 'normal': term.NORMAL}
+default_viewer = '/usr/bin/evince'
def find_database(starting_path = None):
if not starting_path: starting_path = '.'
@@ -24,41 +34,77 @@
def add(args, options):
path = args[0]
if not os.path.exists(path):
- print "Path %s does not exist. Cannot add paper"
+ print _colorize_string('error', "Path %s does not exist. Cannot add paper" % path)
return
- path = os.path.abspath(path)
m = hashlib.md5()
fd = open(path, 'r')
m.update(fd.read())
fd.close()
- p = Paper(title = unicode(options.title), path = path, md5 = m.hexdigest())
-
- for label in options.labels:
- t = Tag.get_by_or_init(name = unicode(label))
- t.papers.append(p)
- for author_with_commas in options.authors:
- authors = author_with_commas.split(',')
- for author in authors:
- author = author.strip()
- an = AuthorNickname.get_by(name = unicode(author))
- if an: a = an.author
- else: a = Author.get_by_or_init(name = unicode(author))
- a.papers.append(p)
+ path = os.path.abspath(path)
+ p = Paper.get_by(path = path) or Paper.get_by(md5 = m.hexdigest())
+ if p is not None:
+ print _colorize_string('error', "Paper already exists, use update")
+ print '--------------------------------'
+ _show_paper(p)
+ return
+
+ p = Paper(path = path, md5 = m.hexdigest())
+ _set_options(p, options, required = ['title'])
session.flush()
_show_paper(p)
+def update(args, options):
+ p = Paper.query.filter(Paper.md5.startswith(args[0])).one()
+ _set_options(p, options)
+ session.flush()
+ _show_paper(p)
+
+def view(args, options):
+ if len(args) < 1: return
+ p = Paper.query.filter(Paper.md5.startswith(args[0])).one()
+ if not p: return
+ if len(args) > 1: viewer = args[1]
+ else: viewer = default_viewer
+ os.system('%s %s' % (viewer, p.path))
+
+def remove(args, options):
+ if len(args) < 1: return
+ p = Paper.query.filter(Paper.md5.startswith(args[0])).one()
+ if not p: return
+ print "Removing"
+ _show_paper(p)
+ p.delete()
+ session.flush()
+
def list(args, options):
- papers = Paper.query.all()
- for p in papers:
+ papers = Paper.query
+
+ # Refactor with _set_options()
+ for label_with_commas in (options.labels or []):
+ labels = label_with_commas.split(',')
+ for label in labels:
+ label = unicode(label.strip())
+ papers = papers.filter(Paper.tags.any(name = label))
+
+ for author_with_commas in (options.authors or []):
+ authors = author_with_commas.split(',')
+ for author in authors:
+ author = unicode(author.strip())
+ an = AuthorNickname.get_by(name = author)
+ if an: a = an.author
+ else: a = Author.get_by_or_init(name = author)
+ papers = papers.filter(Paper.authors.any(name = unicode(a)))
+
+ for p in papers.all():
_show_paper(p)
print
def alias(args, options):
- if len(args) > 0 and len(options.authors) > 0:
- a = Author.get_by_or_init(name = unicode(options.authors[0]))
+ if len(args) > 1:
+ a = Author.get_by_or_init(name = unicode(args[1]))
an = AuthorNickname.get_by_or_init(name = unicode(args[0]))
an.author = a
session.flush()
@@ -67,34 +113,67 @@
for an in AuthorNickname.query.all():
print ' %s: %s' % (an.name, an.author.name)
+def _set_options(p, options, required = []):
+ title = options.title or ('title' in required) and raw_input("Enter title: ")
+ if title:
+ p.title = unicode(title)
+
+ for label_with_commas in (options.labels or []):
+ labels = label_with_commas.split(',')
+ for label in labels:
+ label = unicode(label.strip())
+ if label[0] == '-': # remove label
+ t = Tag.get_by(name = label[1:])
+ t.papers.remove(p)
+ else: # add label
+ t = Tag.get_by_or_init(name = label)
+ t.papers.append(p)
+
+ for author_with_commas in (options.authors or []):
+ authors = author_with_commas.split(',')
+ for author in authors:
+ author = unicode(author.strip())
+ an = AuthorNickname.get_by(name = author)
+ if an: a = an.author
+ else: a = Author.get_by_or_init(name = author)
+ a.papers.append(p)
+
def _sort_authors(authors):
authors.sort() # FIXME: deal with firstname lastname issues
def _show_paper(paper):
- print paper.title
+ print _colorize_string('title', paper.title)
authors = [str(a) for a in paper.authors]
_sort_authors(authors)
for author in authors[:-1]:
- print '%s,' % author,
- print '%s' % authors[-1]
- print 'Labels:',
+ print '%s,' % _colorize_string('author', author),
+ print '%s' % _colorize_string('author', authors[-1])
+ print 'Labels:',
for tag in paper.tags:
- print tag,
- print
- print "Path: %s" % paper.path
+ print '+%s' % _colorize_string('label', tag),
+ print color['normal']
+ print "Path: %s" % _colorize_string('path', paper.path)
+ print "Hash: %s" % _colorize_string('hash', paper.md5)
+
+def _colorize_string(clr, str):
+ return '%s%s%s' % (color[clr], str, color['normal'])
if __name__ == "__main__":
usage = '%s COMMAND OPTIONS\n' % sys.argv[0]
usage += 'Commands:\n'
usage += ' add - add a paper to the database\n'
usage += ' list - list papers in the database\n'
- usage += ' alias - add or list author nicknames'
+ usage += ' alias - add or list author nicknames\n'
+ usage += ' update - update paper by hash\n'
+ usage += ' view - view paper by hash\n'
+ usage += ' remove - remove paper by hash'
# Parse options
parser = OptionParser(usage = usage)
parser.add_option('-a', '--author', action='append', dest='authors', help='author')
parser.add_option('-t', '--title', dest='title', help='title')
parser.add_option('-l', '--label', action='append', dest='labels', help='label')
+ parser.add_option('-s', '--hash', dest='hash', help='hash (only for list)')
parser.add_option('-D', '--database', dest='database', help='directory with the database')
(options, args) = parser.parse_args()
@@ -103,9 +182,15 @@
initDatabase(path, not found)
if len(args) == 0: sys.exit()
- if args[0] == 'add':
+ if 'add'.startswith(args[0]):
add(args[1:], options)
- elif args[0] == 'list':
+ elif 'list'.startswith(args[0]):
list(args[1:], options)
- elif args[0] == 'alias':
+ elif 'alias'.startswith(args[0]):
alias(args[1:], options)
+ elif 'update'.startswith(args[0]):
+ update(args[1:], options)
+ elif 'view'.startswith(args[0]):
+ view(args[1:], options)
+ elif 'remove'.startswith(args[0]):
+ remove(args[1:], options)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/terminal.py Sat May 17 04:25:28 2008 -0400
@@ -0,0 +1,195 @@
+# TerminalController from ASPN. Submitted there by Edward Loper
+# http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/475116
+
+import sys, re
+
+class TerminalController:
+ """
+ A class that can be used to portably generate formatted output to
+ a terminal.
+
+ `TerminalController` defines a set of instance variables whose
+ values are initialized to the control sequence necessary to
+ perform a given action. These can be simply included in normal
+ output to the terminal:
+
+ >>> term = TerminalController()
+ >>> print 'This is '+term.GREEN+'green'+term.NORMAL
+
+ Alternatively, the `render()` method can used, which replaces
+ '${action}' with the string required to perform 'action':
+
+ >>> term = TerminalController()
+ >>> print term.render('This is ${GREEN}green${NORMAL}')
+
+ If the terminal doesn't support a given action, then the value of
+ the corresponding instance variable will be set to ''. As a
+ result, the above code will still work on terminals that do not
+ support color, except that their output will not be colored.
+ Also, this means that you can test whether the terminal supports a
+ given action by simply testing the truth value of the
+ corresponding instance variable:
+
+ >>> term = TerminalController()
+ >>> if term.CLEAR_SCREEN:
+ ... print 'This terminal supports clearning the screen.'
+
+ Finally, if the width and height of the terminal are known, then
+ they will be stored in the `COLS` and `LINES` attributes.
+ """
+ # Cursor movement:
+ BOL = '' #: Move the cursor to the beginning of the line
+ UP = '' #: Move the cursor up one line
+ DOWN = '' #: Move the cursor down one line
+ LEFT = '' #: Move the cursor left one char
+ RIGHT = '' #: Move the cursor right one char
+
+ # Deletion:
+ CLEAR_SCREEN = '' #: Clear the screen and move to home position
+ CLEAR_EOL = '' #: Clear to the end of the line.
+ CLEAR_BOL = '' #: Clear to the beginning of the line.
+ CLEAR_EOS = '' #: Clear to the end of the screen
+
+ # Output modes:
+ BOLD = '' #: Turn on bold mode
+ BLINK = '' #: Turn on blink mode
+ DIM = '' #: Turn on half-bright mode
+ REVERSE = '' #: Turn on reverse-video mode
+ NORMAL = '' #: Turn off all modes
+
+ # Cursor display:
+ HIDE_CURSOR = '' #: Make the cursor invisible
+ SHOW_CURSOR = '' #: Make the cursor visible
+
+ # Terminal size:
+ COLS = None #: Width of the terminal (None for unknown)
+ LINES = None #: Height of the terminal (None for unknown)
+
+ # Foreground colors:
+ BLACK = BLUE = GREEN = CYAN = RED = MAGENTA = YELLOW = WHITE = ''
+
+ # Background colors:
+ BG_BLACK = BG_BLUE = BG_GREEN = BG_CYAN = ''
+ BG_RED = BG_MAGENTA = BG_YELLOW = BG_WHITE = ''
+
+ _STRING_CAPABILITIES = """
+ BOL=cr UP=cuu1 DOWN=cud1 LEFT=cub1 RIGHT=cuf1
+ CLEAR_SCREEN=clear CLEAR_EOL=el CLEAR_BOL=el1 CLEAR_EOS=ed BOLD=bold
+ BLINK=blink DIM=dim REVERSE=rev UNDERLINE=smul NORMAL=sgr0
+ HIDE_CURSOR=cinvis SHOW_CURSOR=cnorm""".split()
+ _COLORS = """BLACK BLUE GREEN CYAN RED MAGENTA YELLOW WHITE""".split()
+ _ANSICOLORS = "BLACK RED GREEN YELLOW BLUE MAGENTA CYAN WHITE".split()
+
+ def __init__(self, term_stream=sys.stdout):
+ """
+ Create a `TerminalController` and initialize its attributes
+ with appropriate values for the current terminal.
+ `term_stream` is the stream that will be used for terminal
+ output; if this stream is not a tty, then the terminal is
+ assumed to be a dumb terminal (i.e., have no capabilities).
+ """
+ # Curses isn't available on all platforms
+ try: import curses
+ except: return
+
+ # If the stream isn't a tty, then assume it has no capabilities.
+ if not term_stream.isatty(): return
+
+ # Check the terminal type. If we fail, then assume that the
+ # terminal has no capabilities.
+ try: curses.setupterm()
+ except: return
+
+ # Look up numeric capabilities.
+ self.COLS = curses.tigetnum('cols')
+ self.LINES = curses.tigetnum('lines')
+
+ # Look up string capabilities.
+ for capability in self._STRING_CAPABILITIES:
+ (attrib, cap_name) = capability.split('=')
+ setattr(self, attrib, self._tigetstr(cap_name) or '')
+
+ # Colors
+ set_fg = self._tigetstr('setf')
+ if set_fg:
+ for i,color in zip(range(len(self._COLORS)), self._COLORS):
+ setattr(self, color, curses.tparm(set_fg, i) or '')
+ set_fg_ansi = self._tigetstr('setaf')
+ if set_fg_ansi:
+ for i,color in zip(range(len(self._ANSICOLORS)), self._ANSICOLORS):
+ setattr(self, color, curses.tparm(set_fg_ansi, i) or '')
+ set_bg = self._tigetstr('setb')
+ if set_bg:
+ for i,color in zip(range(len(self._COLORS)), self._COLORS):
+ setattr(self, 'BG_'+color, curses.tparm(set_bg, i) or '')
+ set_bg_ansi = self._tigetstr('setab')
+ if set_bg_ansi:
+ for i,color in zip(range(len(self._ANSICOLORS)), self._ANSICOLORS):
+ setattr(self, 'BG_'+color, curses.tparm(set_bg_ansi, i) or '')
+
+ def _tigetstr(self, cap_name):
+ # String capabilities can include "delays" of the form "$<2>".
+ # For any modern terminal, we should be able to just ignore
+ # these, so strip them out.
+ import curses
+ cap = curses.tigetstr(cap_name) or ''
+ return re.sub(r'\$<\d+>[/*]?', '', cap)
+
+ def render(self, template):
+ """
+ Replace each $-substitutions in the given template string with
+ the corresponding terminal control string (if it's defined) or
+ '' (if it's not).
+ """
+ return re.sub(r'\$\$|\${\w+}', self._render_sub, template)
+
+ def _render_sub(self, match):
+ s = match.group()
+ if s == '$$': return s
+ else: return getattr(self, s[2:-1])
+
+#######################################################################
+# Example use case: progress bar
+#######################################################################
+
+class ProgressBar:
+ """
+ A 3-line progress bar, which looks like::
+
+ Header
+ 20% [===========----------------------------------]
+ progress message
+
+ The progress bar is colored, if the terminal supports color
+ output; and adjusts to the width of the terminal.
+ """
+ BAR = '%3d%% ${GREEN}[${BOLD}%s%s${NORMAL}${GREEN}]${NORMAL}\n'
+ HEADER = '${BOLD}${CYAN}%s${NORMAL}\n\n'
+
+ def __init__(self, term, header):
+ self.term = term
+ if not (self.term.CLEAR_EOL and self.term.UP and self.term.BOL):
+ raise ValueError("Terminal isn't capable enough -- you "
+ "should use a simpler progress dispaly.")
+ self.width = self.term.COLS or 75
+ self.bar = term.render(self.BAR)
+ self.header = self.term.render(self.HEADER % header.center(self.width))
+ self.cleared = 1 #: true if we haven't drawn the bar yet.
+ self.update(0, '')
+
+ def update(self, percent, message):
+ if self.cleared:
+ sys.stdout.write(self.header)
+ self.cleared = 0
+ n = int((self.width-10)*percent)
+ sys.stdout.write(
+ self.term.BOL + self.term.UP + self.term.CLEAR_EOL +
+ (self.bar % (100*percent, '='*n, '-'*(self.width-10-n))) +
+ self.term.CLEAR_EOL + message.center(self.width))
+
+ def clear(self):
+ if not self.cleared:
+ sys.stdout.write(self.term.BOL + self.term.CLEAR_EOL +
+ self.term.UP + self.term.CLEAR_EOL +
+ self.term.UP + self.term.CLEAR_EOL)
+ self.cleared = 1