changeset 0:e3823be6a423

initial commit of bitsyblog, from https://svn.openplans.org/svn/standalone/bitsyblog/trunk/
author k0s <k0scist@gmail.com>
date Sat, 12 Sep 2009 16:06:57 -0400
parents
children 2d69b26144b4
files FAQ.txt README.txt TODO.txt bitsyblog.dia bitsyblog/__init__.py bitsyblog/bitsyauth.py bitsyblog/bitsyblog.py bitsyblog/blog.py bitsyblog/factory.py bitsyblog/parser.py bitsyblog/settings.py bitsyblog/user.py bitsyblog/utils.py blog/bitsyblog.png blog/site.css blogme.py multiuser.ini roadmap.txt setup.py singleuser.ini
diffstat 20 files changed, 3378 insertions(+), 0 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/FAQ.txt	Sat Sep 12 16:06:57 2009 -0400
@@ -0,0 +1,54 @@
+Meet bitsyblog: bitsyblog doesn't do much, but it could do less
+
+Why yet another blogging app? Because I wanted to blog from
+the command line and emacs and stubbornly decided it was easier
+to write a new blog than to write a front-end script for 
+wordpress
+
+No, really!  In my cursory survey of blogging software, there
+wasn't anything out there that seemed RESTful and minimalist.
+I don't want a blog that does much;  I want to throw content (a
+little or much) on the web quickly and in the way I want to do
+it.  I also dislike that most blogging software seems monolithic.
+From the "less is better" school of thought, I tend to believe
+that a piece of software should do one thing and one thing well
+
+What does bitsyblog do?
+* blogging.  Only blogging
+* (...and setting user preferences)
+* (...and auth snuck in their too)
+
+What doesn't bitsyblog do?
+* commenting
+* tagging
+* uploading/managing files
+* turn all your text emoticons to animated gifs
+* all of these could be done with WSGI middleware
+
+Isn't WSGI middleware just another way of building a monolith?  Not if its
+done right.  If your app is dependent on middleware, or vice 
+versa, then yes.  Commenting on a blog post is the same as commmenting
+on a wiki page.  
+
+But what if the user wants to delete their blog post?
+Should the comments get deleted as well?  This points to a 
+possible coupling between the app and the client.  The solution
+of course is to not allow users to delete blog posts.
+(Incidentally, using HTTP's DELETE could be used to solve this.  Too bad this
+isn't doable in today's browsers)
+
+So how does bitsyblog work?
+* webob and paste are used to process and serve HTTP
+* restructured text is used for blog formatting
+* auth is built on paste.auth
+
+What is next for bitsyblog?  Feeds and some cleanup and little details.
+Then middleware (probably commenting first).
+
+What is up with the mice?  I caught them eating cheese in the cheeseshop,
+so I put them to work in my logo.
+
+How do I get bitsyblog?  Go to http://bitsyblog.biz/
+Also, its on the cheeseshop
+
+Your blog probably doesn't do much ... but could it do less?
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/README.txt	Sat Sep 12 16:06:57 2009 -0400
@@ -0,0 +1,124 @@
+bitsyblog
+=========
+
+*bitsyblog doesn't do much, but it could do less*
+
+
+Why another blog?
+-----------------
+
+My ideal blog would invoke my favorite editor, take a bunch of text, and 
+throw it on the web.  Sometimes I like to write long elaborate posts.
+Othertimes I just want to make a quick note.
+
+Meet `bitsyblog <http://bitsyblog.biz>`_, 
+a tiny tiny `python <http://python.org/>`_
+`weblog <http://pypi.python.org/pypi/bitsyblog/>`_.  
+Posting is done with a POST request, so while you can use
+a web form to do this, its just as easy to use curl, urllib, or anything else 
+to post.
+
+
+How does it work?
+-----------------
+
+A user URLs is like
+
+http://bitsyblog.biz/k0s
+
+k0s is my user name here .  If you are 1337h4x0r, this will be 
+http://bitsyblog.biz/1337h4x0r .
+Posting to this will take the body of the POST request and add a date stamp
+Blog entries are thrown in files and are displayed with markup 
+available with `restructured text 
+<http://docutils.sourceforge.net/docs/user/rst/quickref.html>`_.
+
+You can also get a more specific range of posts 
+by specifying up to the year, month
+and day in the URL:
+
+http://bitsyblog.biz/k0s/2008/2/1
+
+Not all of this needs to be specified.
+
+Permalinks are also available in the form of the date stamp:
+
+http://bitsyblog.biz/k0s/20080201141502
+
+You can find the permalink by clicking on the subject of the blog post.
+
+If you really want to post through the web, support is at 
+
+http://bitsyblog.biz/k0s/post
+
+If you're more friendly with python scripts, 
+`blogme.py <https://svn.openplans.org/svn/standalone/bitsyblog/trunk/blogme.py>`_
+is available: 
+https://svn.openplans.org/svn/standalone/bitsyblog/trunk/blogme.py
+
+
+Get me a blog!
+--------------
+
+Create an account at http://bitsyblog.biz/join .  All you need is a
+username and password (and I threw a 
+`CAPTCHA <http://skimpygimpy.sourceforge.net/>`_ in there at some
+point).
+Then...you're ready to blog!  The auth is a minimal thing
+I threw together out of `paste.auth <http://pythonpaste.org/>`_.
+
+Once you're signed in, you'll notice the navigation links at the top
+of the page have changed.  You can now post and change your preferences.
+
+In your preferences, you can change the date format and set the
+subject format of your blog posts.  You can also upload 
+verifiable CSS to theme your blog.  For the date format, I have patched
+`dateutil.parser <http://labix.org/python-dateutil>`_
+to return the format string that the date was originally in and hope
+that my changes can make it back to the source sometime.
+
+When posting, you have the option to make your post 'public' (everyone
+can see it), 'secret' (only your friends can see it), 'private' (only
+you can see it.  Friends are settable in your preferences.
+
+
+What bitsyblog doesn't do
+-------------------------
+
+* Commenting:  this should done with 
+`WSGI middlware
+<http://groovie.org/articles/2005/10/06/wsgi-and-wsgi-middleware-is-easy>`_.  
+There's nothing specific about commenting on a blog post that is any
+different from commenting on a paragraph in (for instance) a wiki article.
+
+* Tagging:  again, this should be done with middleware
+
+* Hosting files:  Its a blog, not a file repo!  Any markup doable with 
+`restructured text <http://docutils.sourceforge.net/rst.html>`_
+is doable with bitsyblog, but images, videos, whatever must
+be held off-site.
+
+
+What is next for bitsyblog?
+---------------------------
+
+Other than that, its a pretty small project.  No templates and
+currently about 700 lines of code. (I'll get it back to 500, I
+swear). bitsyblog is designed as a personal blog that should be strong
+in both workflow as well as "niceness" of code.
+
+I'm guessing your blog doesn't do much...but could it do less?
+
+
+bitsyblog is built on top of `paste <http://pythonpaste.org/>`_
+and `webob <http://pythonpaste.org/webob/>`_.  You'll need the 
+trunk version of paste for a change made to 
+`paste.auth.auth_tkt
+<http://svn.pythonpaste.org/Paste/trunk/paste/auth/auth_tkt.py>`_ 
+in order to have cookies work correctly (r7261).
+
+Thanks to `The Open Planning Project <http://www.openplans.org>`_ 
+and my friends there for making this possible.
+
+Please email jhammel at openplans dot org with any questions.
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/TODO.txt	Sat Sep 12 16:06:57 2009 -0400
@@ -0,0 +1,45 @@
+TODO
+----
+
+* DELETE posts (since browsers don't delete, maybe a POST request to
+  /k0s/20080302221048 might have to be used)
+
+* DELETE css files
+
+* User preference:  number of links to display in nav by default
+
+* How many posts to display by default at '/%(user)' ? 
+
+* it would be nice if the viewed user's blog was added to the site nav
+
+* years, months in a user's nav
+
+* method for getting blog post title
+
+* smarter permalinks;  should not use host_url
+
+* blog entries now record the user so methods should not need the user
+  to be passed in with the blog entry (code cleanup)
+
+* single user bitsyblog -> BitsierBlog
+
+* PUT to blog should edit (/k0s/20080302221048/edit should front end
+  this, which renders the post form but with the text already inside
+  and a datestamp specified)
+
+* markup.form should have different layouts
+
+* markup.form should (optionally?) use the Settings for validation, etc.
+
+* better atom feeds?
+
+* simple CAPTCHA auth for ephemeral users
+
+* code cleanup (the eternal chore)
+
+* middleware:
+** CSS zen garden (samadhi)
+** commenting
+** tagging
+
+>>> this is 2.0
Binary file bitsyblog.dia has changed
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bitsyblog/__init__.py	Sat Sep 12 16:06:57 2009 -0400
@@ -0,0 +1,1 @@
+#
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bitsyblog/bitsyauth.py	Sat Sep 12 16:06:57 2009 -0400
@@ -0,0 +1,368 @@
+import markup
+import random
+import re
+import sys
+
+from cStringIO import StringIO
+from markup.form import Form
+from paste.auth import basic, cookie, digest, form, multi, auth_tkt
+from webob import Request, Response, exc
+
+try:
+    from skimpyGimpy import skimpyAPI
+    CAPTCHA = True
+except ImportError:
+    CAPTCHA = False
+
+dictionary_file = '/usr/share/dict/american-english'
+
+def random_word():
+    """generate a random word for CAPTCHA auth"""
+    min_length = 5 # minimum word length
+    if not globals().has_key('dictionary'):
+        # read the dictionary -- this may be platform dependent
+        # XXX could use a backup dictionary
+        _dictionary = file(dictionary_file).readlines()
+        _dictionary = [ i.strip() for i in _dictionary ]
+        _dictionary = [ i.lower() for i in _dictionary
+                        if i.isalpha() and i > min_length ]
+        globals()['dictionary'] = _dictionary
+    return random.Random().choice(dictionary)
+
+class BitsyAuthInnerWare(object):
+    """inner auth;  does login checking"""
+
+    def __init__(self, app, passwords, newuser=None, site=None, realm=None):
+        """a simple reimplementation of auth
+        * app: the WSGI app to be wrapped
+        * passwords: callable that return a dictionary of {'user': 'password'}
+        * newuser: callable to make a new user, taking name + pw
+        * site: name of the site
+        * realm: realm for HTTP digest authentication
+        """
+
+        self.app = app
+        self.passwords = passwords
+        self.site = site or ''
+        self.realm = realm or self.site
+        self.captcha = True
+        self.redirect_to = '/' # redirect to site root        
+        self.urls = { 'login': '/login', 'join': '/join', }
+        self.keys = {} # keys, words for CAPTCHA request
+
+        self.content_type = { 'image_captcha': 'image/png',
+                              'wav_captcha': 'audio/wav' }
+
+        if newuser:
+            self.newuser = newuser
+        else:
+            self.urls.pop('join') # don't do joining
+
+        # WSGI app securely wrapped
+        self.wrapped_app = self.security_wrapper()
+
+        if not CAPTCHA:
+            self.captcha = False
+        
+    ### WSGI/HTTP layer
+
+    def __call__(self, environ, start_response):
+
+        self.request = Request(environ)
+        self.request.path_info = self.request.path_info.rstrip('/')
+
+        # URLs intrinsic to BitsyAuth
+        if self.request.path_info == '/logout':
+            response = self.redirect()
+            return response(self.request.environ, start_response)
+
+        if self.request.path_info in self.url_lookup():
+            response = self.make_response()
+            return response(self.request.environ, start_response)
+
+        # digest auth
+        if self.request.headers.has_key('Authorization'):
+            return self.wrapped_app(self.request.environ, start_response)
+
+        response = self.request.get_response(self.app)
+        # respond to 401s
+        if response.status_int == 401: # Unauthorized
+            if self.request.environ.get('REMOTE_USER'):
+                return exc.HTTPForbidden()
+            else:
+                response = self.request.get_response(self.wrapped_app)
+
+        user = self.request.environ.get('REMOTE_USER')
+        if user:
+            self.request.environ['paste.auth_tkt.set_user'](user)
+
+        return response(self.request.environ, start_response)
+
+    ### authentication function
+
+    def digest_authfunc(self, environ, realm, user):
+        return self.passwords()[user] # passwords stored in m5 digest
+
+    def authfunc(self, environ, user, password):
+        return self.hash(user, password) == self.passwords()[user]
+
+    def hash(self, user, password):
+        # use md5 digest for now
+        return digest.digest_password(self.realm, user, password)
+
+    def security_wrapper(self):
+        """return the app securely wrapped"""
+
+        multi_auth = multi.MultiHandler(self.app)
+
+        # digest authentication
+        multi_auth.add_method('digest', digest.middleware,
+                              self.realm, self.digest_authfunc)
+        multi_auth.set_query_argument('digest', key='auth')
+
+        # form authentication
+        template = self.login(wrap=True, action='%s')
+        multi_auth.add_method('form', form.middleware, self.authfunc,
+                              template=template)
+        multi_auth.set_default('form')
+
+        return multi_auth
+
+        # might have to wrap cookie.middleware(BitsyAuth(multi(app))) ::shrug::
+        return cookie.middleware(multi_auth)
+
+    ### methods dealing with intrinsic URLs
+
+    def url_lookup(self):
+        retval = dict([ (value, key) for key, value
+                        in self.urls.items() ])
+        if self.captcha:
+            retval.update(dict([(('/join/%s.png' % key), 'image_captcha')
+                                for key in self.keys]))
+        return retval
+        
+    def get_response(self, text, content_type='text/html'):
+        res = Response(content_type=content_type, body=text)
+        res.content_length = len(res.body)
+        return res
+
+    def make_response(self):
+        url_lookup = self.url_lookup()
+        path = self.request.path_info
+        assert path in url_lookup
+
+        # login and join shouldn't be accessible when logged in
+        if self.request.environ.get('REMOTE_USER'):
+            return self.redirect("You are already logged in")
+
+        handler = url_lookup[path]
+        function = getattr(self, handler)
+
+        if self.request.method == 'GET':
+            # XXX could/should do this with decorators            
+            return self.get_response(function(wrap=True),
+                                     content_type=self.content_type.get(handler,'text/html'))
+        if self.request.method == 'POST':
+            post_func = getattr(self, handler + "_post")
+            errors = post_func()
+            if errors:
+                return self.get_response(function(errors=errors, wrap=True))
+            else:
+                return self.redirect("Welcome!")
+
+    def redirect(self, message=''):
+        """redirect from instrinsic urls"""
+        return exc.HTTPSeeOther(message, location=self.redirect_to)
+
+    def image_captcha(self, wrap=True):
+        """return data for the image"""
+        key = self.request.path_info.split('/join/')[-1]
+        key = int(key.split('.png')[0])
+        return skimpyAPI.Png(self.keys[key], scale=3.0).data()
+                
+    ### forms and their display methods
+
+    ### login
+
+    def login_form(self, referer=None, action=None):
+        if action is None:
+            action = self.urls['login']
+        form = Form(action=action, submit='Login')
+        form.add_element('textfield', 'Name', input_name='username')
+        form.add_element('password', 'Password', input_name='password')
+        if referer is not None:
+            form.add_element('hidden', 'referer', value=referer)
+        return form
+
+    def login(self, errors=None, wrap=False, action=None):
+        """login div"""
+        form = self.login_form(action=action)
+        join = self.urls.get('join')
+        retval = form(errors)
+        if join:        
+            retval += '<br/>\n' + markup.a('join', href="%s" % join)
+        retval = markup.div(retval)
+        if wrap:
+            title = 'login'
+            if self.site:
+                pagetitle = '%s - %s' % (title, self.site)
+            retval = markup.wrap(markup.h1(title.title()) + retval,
+                                 pagetitle=pagetitle)
+
+        return retval
+
+    def login_post(self):
+        """handle a login POST request"""
+        user = self.request.POST.get('username')
+        password = self.request.POST.get('password')
+        passwords = self.passwords()
+        error = False
+        if user not in passwords:
+            error = True
+        else:
+            error = not self.authfunc(self.request.environ, user, password)
+        if error:
+            return { 'Name': 'Wrong username or password' }
+        self.request.environ['REMOTE_USER'] = user
+        self.request.environ['paste.auth_tkt.set_user'](user)
+
+    ### join
+
+    def captcha_pre(self, word, key):
+        """CAPTCHA with pre-formatted text"""
+        return skimpyAPI.Pre(word, scale=1.2).data()
+
+    def captcha_png(self, word, key):
+        """CAPTCHA with a PNG image"""
+        return markup.image('/join/%s.png' % key)
+
+    def join_form(self):
+        captcha = ''
+        if self.captcha:
+            # data for CAPTCHA
+            key = random.Random().randint(0, sys.maxint)
+            word = random_word()
+
+            self.keys[key] = word
+
+            captcha = StringIO()
+
+            captcha_text = "Please type the word below so I know you're not a computer:"
+            captcha_help = "(please %s if the page is unreadable)" % markup.link('/join?captcha=image', 'go here')
+
+            print >> captcha, markup.p('%s<br/> %s' % (captcha_text, 
+                                                       markup.i(captcha_help)))
+
+            # determine type of CAPTCHA
+            captchas = ' '.join(self.request.GET.getall('captcha'))
+            if not captchas:
+                captchas = 'pre'
+                
+            captcha_funcs=dict(pre=self.captcha_pre,
+                               image=self.captcha_png,)
+            captchas = [ captcha_funcs[i](word, key) for i in captchas.split()
+                         if i in captcha_funcs ]
+            captchas = '\n'.join([markup.p(i) for i in captchas])
+            print >> captcha, captchas
+            
+            print >> captcha, markup.p(markup.input(None, **dict(name='captcha', type='text')))
+            
+            captcha = captcha.getvalue()
+
+        form = Form(action=self.urls['join'], submit='Join', post_html=captcha)
+        form.add_element('textfield', 'Name')
+        form.add_password_confirmation()
+        form.add_element('hidden', 'key', value=str(key))
+        return form
+
+    def join(self, errors=None, wrap=False):
+        """join div or page if wrap"""
+        form = self.join_form()
+        retval = markup.div(form(errors))
+        if wrap:
+            pagetitle = title = 'join'
+            if self.site:
+                pagetitle = '%s - %s' % (title, self.site)
+            if self.captcha:
+                errors = errors or {}
+                captcha_err = errors.get('CAPTCHA', '')
+                if captcha_err:
+                    captcha_err = markup.p(markup.em(captcha_err),
+                                           **{'class': 'error'})
+            retval = markup.wrap(markup.h1(title.title()) + captcha_err + retval,
+                                 pagetitle=pagetitle)
+        return retval
+
+    def join_post(self):
+        """handle a join POST request"""
+        form = self.join_form()
+        errors = form.validate(self.request.POST)
+
+        # validate captcha
+        if CAPTCHA:
+            key = self.request.POST.get('key')
+            try:
+                key = int(key)
+            except ValueError:
+                key = None
+            if not key:
+                errors['CAPTCHA'] = 'Please type the funky looking word'
+            word = self.keys.pop(key, None)
+            if not word:
+                errors['CAPTCHA'] = 'Please type the funky looking word'
+            if word != self.request.POST.get('captcha','').lower():
+                errors['CAPTCHA'] = 'Sorry, you typed the wrong word'
+        
+        name = self.request.POST.get('Name', '')
+        if not name:
+            if not errors.has_key('Name'):
+                errors['Name'] = []
+            errors['Name'].append('Please enter a user name')
+        if name in self.passwords():
+            if not errors.has_key('Name'):
+                errors['Name'] = []
+            errors['Name'].append('The name %s is already taken' % name)
+
+        if not errors: # create a new user
+            self.newuser(name,
+                         self.hash(name, self.request.POST['Password']))
+            self.request.environ['REMOTE_USER'] = name # login the new user
+            self.request.environ['paste.auth_tkt.set_user'](name)
+        
+        return errors
+
+class BitsyAuth(object):
+    """outer middleware for auth;  does the cookie handling and wrapping"""
+    
+    def __init__(self, app, global_conf, passwords, newuser, site='', secret='secret'):
+        self.app = app
+        self.path = '/logout'
+        self.cookie = '__ac'
+        auth = BitsyAuthInnerWare(app, passwords=passwords, newuser=newuser, site=site)
+        self.hash = auth.hash
+        # paste.auth.cookie
+        #        self.cookie_handler = cookie.middleware(auth, cookie_name=self.cookie, timeout=90) # minutes
+
+        # paste.auth.auth_tkt
+        self.cookie_handler = auth_tkt.make_auth_tkt_middleware(auth, global_conf, secret, cookie_name=self.cookie, logout_path='/logout')
+
+    def __call__(self, environ, start_response):
+        if environ['PATH_INFO'] == '/logout':
+            pass        
+        return self.cookie_handler(environ, start_response)
+
+    def logout(self, environ):
+        req = Request(environ)
+        keys = [ 'REMOTE_USER' ]
+        #        keys = [ 'REMOTE_USER', 'AUTH_TYPE', 'paste.auth.cookie', 'paste.cookies', 'HTTP_COOKIE' ]  # XXX zealous kill
+        for key in keys:
+            req.environ.pop(key, None)
+
+        body = '<html><head><title>logout</title></head><body>logout</body></html>'
+        res = Response(content_type='text/html', body=body)
+        res.content_length = len(res.body)
+        req.cookies.pop(self.cookie, None)
+        res.delete_cookie(self.cookie)
+        res.unset_cookie(self.cookie)
+        return res(environ, start_response)
+        
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bitsyblog/bitsyblog.py	Sat Sep 12 16:06:57 2009 -0400
@@ -0,0 +1,863 @@
+"""
+a tiny tiny blog.  
+this is the view class and is more bitsyblog than anything
+else can claim to be
+"""
+
+### global variables
+
+# who can view which blog posts
+roles = { 'public': ( 'public', ),
+          'friend': ( 'public', 'secret' ),
+          'author': ( 'public', 'secret', 'private' ), }
+
+### imports
+
+import dateutil.parser  # XXX separate, for now
+import parser # bitsyblog dateutil parser
+
+import cgi
+import datetime
+import docutils
+import docutils.core
+import inspect
+import markup
+import os
+import PyRSS2Gen
+import re
+import utils
+
+from blog import FileBlog
+from docutils.utils import SystemMessage
+from lxml import etree
+from user import FilespaceUsers
+from markup.form import Form
+from cStringIO import StringIO
+from webob import Request, Response, exc
+
+### exceptions
+
+class BlogPathException(Exception):
+    """exception when trying to retrieve the blog"""
+
+### the main course
+
+class BitsyBlog(object):
+    """a very tiny blog"""
+
+    ### class level variables
+    defaults = { 'date_format': '%H:%M %F',
+                 'file_dir': os.path.dirname(__file__),
+                 'subject': '[ %(date)s ]:',
+                 'n_links': 5, # number of links for navigation
+                 'site_name': 'bitsyblog',
+                 'help_file': None
+                 }
+
+    def __init__(self, **kw):
+        for key in self.defaults:
+            setattr(self, key, kw.get(key, self.defaults[key]))
+        self.n_links = int(self.n_links) # could be a string from the .ini
+        self.response_functions = { 'GET': self.get,
+                                    'POST': self.post,
+                                    'PUT': self.put
+                                    }
+        
+        # abstract attributes
+        self.users = FilespaceUsers(self.file_dir)
+        self.blog = FileBlog(self.file_dir)
+        self.cooker = self.restructuredText
+
+        if hasattr(self, 'help_file') and os.path.exists(self.help_file):
+            help = file(self.help_file).read()
+            self.help = docutils.core.publish_string(help,
+                                                     writer_name='html',
+                                                     settings_overrides={'report_level': 5})
+
+
+        # for BitsyAuth
+        self.newuser = self.users.new
+
+    ### methods dealing with HTTP
+
+    def __call__(self, environ, start_response):
+        self.request = Request(environ)
+        res = self.make_response(self.request.method)
+        return res(environ, start_response)
+
+    def make_response(self, method):
+        return self.response_functions.get(method, self.error)()
+
+    def get_response(self, text, content_type='text/html'):
+        res = Response(content_type=content_type, body=text)
+        res.content_length = len(res.body)
+        return res
+
+    def get_index(self):
+        """returns material pertaining to the root of the site"""
+        path = self.request.path_info.strip('/')
+
+        n_links = self.number_of_links()
+
+        ### the front page
+        if not path: 
+            return self.get_response(self.index(n_links))
+
+        ### feeds
+
+        n_posts = self.number_of_posts()
+
+        # site rss
+        if path == 'rss':
+            if n_posts is None:
+                n_posts = 10
+            return self.get_response(self.site_rss(n_posts), content_type='text/xml')
+
+        # site atom
+        if path == 'atom':
+            if n_posts is None:
+                n_posts = 10
+            return self.get_response(self.atom(self.blog.latest(self.users.users(), n_posts)), content_type='text/xml')
+
+        ### help
+        if path == 'help' and hasattr(self, 'help'):
+            return self.get_response(self.help)
+
+        ### static files
+
+        # site.css 
+        if path == 'css/site.css':
+            css_file = os.path.join(self.file_dir, 'site.css')
+            return self.get_response(file(css_file).read(), content_type='text/css')
+
+        # logo
+        if path == 'bitsyblog.png':
+            logo = os.path.join(self.file_dir, 'bitsyblog.png')
+            return self.get_response(file(logo, 'rb').read(), content_type='image/png')        
+
+    def get_user_space(self, user, path):
+        self.request.user = self.users[user] # user whose blog is viewed
+        check = self.check_user(user) # is this the authenticated user?
+
+        feed = None # not an rss/atom feed by default (flag)
+        n_posts = self.number_of_posts(user)
+
+        # special paths
+        if path == [ 'post' ]:
+            if check is not None:
+                return check
+            return self.get_response(self.form_post(user))
+
+        if path == [ 'preferences' ]:
+            if check is not None:
+                return check
+            return self.get_response(self.preferences(user))
+        
+        if path == [ 'rss' ]:
+            feed = 'rss'
+            path = []
+            if n_posts is None:
+                n_posts = 10 # TODO: allow to be configurable
+
+        if path == [ 'atom' ]:
+            feed = 'atom'
+            path = []
+            if n_posts is None:
+                n_posts = 10 # TODO: allow to be configurable
+
+        if len(path) == 2:
+            if path[0] == 'css':
+                for i in self.request.user.settings['CSS']:
+                    # find the right CSS file
+                    if i['filename'] == path[1]:
+                        return self.get_response(i['css'], content_type='text/css')
+                else:
+                    return exc.HTTPNotFound('CSS "%s" not found' % path[1])
+
+        role = self.role(user)
+
+        # get the blog
+        try:
+            blog = self.get_blog(user, path, role, n_items=n_posts)
+        except BlogPathException, e:
+            return exc.HTTPNotFound(str(e))
+        except exc.HTTPException, e:
+            return e.wsgi_response
+
+        if feed == 'rss':
+            content = self.rss(user, blog) # XXX different signatures
+            return self.get_response(content, content_type='text/xml')
+
+        if feed == 'atom':
+            content = self.atom(blog, user) # XXX different signatures
+            return self.get_response(content, content_type='text/xml')
+        
+        # reverse the blog if necessary
+        if self.request.GET.get('sort') == 'forward':
+            blog = list(reversed(blog))
+
+        n_links = self.number_of_links(user)
+        # don't display navigation for short blogs
+        if len(blog) < 2:
+            n_links = 0
+            
+        # write the blog
+        content = self.write_blog(user, blog, self.request.path_info, n_links)
+
+        # return the content
+        return self.get_response(content)        
+
+    def get(self):
+        """
+        display the blog or respond to a get request
+        """
+        # front matter of the site
+        index = self.get_index()
+        if index is not None:
+            return index
+
+        ### user space
+        user, path = self.userpath()
+        if user not in self.users:
+            return exc.HTTPNotFound("No blog found for %s" % user)
+        return self.get_user_space(user, path)
+
+    def post(self):
+        """
+        write a blog entry and other POST requests
+        """
+        # TODO: posting to '/' ?
+        
+        # find user + path
+        user, path = self.userpath()
+
+        if user not in self.users:
+            return exc.HTTPNotFound("No blog found for %s" % user)
+        self.request.user = self.users[user]
+
+        check = self.check_user(user)
+        if check is not None:
+            return check
+
+        if len(path):
+            if path == [ 'preferences' ]:
+            
+                # make the data look like we want
+                settings = {}
+                settings['Date format'] = self.request.POST.get('Date format')
+                settings['Subject'] = '%(date)s'.join((self.request.POST['Subject-0'], self.request.POST['Subject-2']))
+                settings['Stylesheet'] = self.request.POST['Stylesheet']
+                settings['CSS file'] = self.request.POST.get('CSS file')
+                settings['Friends'] = ', '.join(self.request.POST.getall('Friends'))
+                
+                errors = self.users.write_settings(user, **settings)
+                if errors: # re-display form with errors                    
+                    return self.get_response(self.preferences(user, errors))
+                
+                return self.get_response(self.preferences(user, message='Changes saved'))
+            elif len(path) == 1 and self.isentry(path[0]):
+                entry = self.blog.entry(user, path[0], roles['author'])
+                if entry is None:
+                    return exc.HTTPNotFound("Blog entry %s not found %s" % path[0])
+                privacy = self.request.POST.get('privacy')
+                datestamp = entry.datestamp()
+                if privacy:
+                    self.blog.delete(user, datestamp)
+                    self.blog.post(user, datestamp, entry.body, privacy)
+                return exc.HTTPSeeOther("Settings updated", location='/%s/%s' % (user, datestamp))
+            else:
+                return exc.HTTPMethodNotAllowed("Not sure what you're trying to do")
+        
+        # get the body of the post
+        body = self.request.body
+        body = self.request.POST.get('form-post', body)
+        body = body.strip()
+        if not body:
+            return exc.HTTPSeeOther("Your post has no content!  No blog for you", 
+                                    location='/%s' % self.user_url(user, 'post'))
+
+        # determine if the post is secret or private
+        privacy = self.request.GET.get('privacy') or self.request.POST.get('privacy') or 'public'
+
+        # write the file
+        now = utils.datestamp(datetime.datetime.now())
+        location = "/%s" % self.user_url(user, now)
+        self.blog.post(user, now, body, privacy)
+
+        # point the user at the post
+        return exc.HTTPSeeOther("Post blogged by bitsy", location=location)
+
+    def put(self):
+        """
+        PUT several blog entries from a file
+        """
+
+        # find user + path
+        user, path = self.user()
+
+        if user not in self.users.users():
+            return exc.HTTPNotFound("No blog found for %s" % user)
+
+        if len(path):
+            return exc.HTTPMethodNotAllowed("Not sure what you're trying to do")
+        
+        # find the dates + entries in the file
+        regex = '\[.*\]:'
+        entries = re.split(regex, self.request.body)[1:]
+        dates = [ date.strip().strip(':').strip('[]').strip()
+                  for date in re.findall(regex, self.request.body) ]
+        dates = [ dateutil.parser.parse(date) for date in dates ]
+
+        # write to the blog
+        for i in range(len(entries)):
+            datestamp = utils.datestamp(dates[i])
+            self.blog.post(user, datestamp, entries[i], 'public')
+        
+        return exc.HTTPOk("%s posts blogged by bitsy" % len(entries))
+
+
+    def error(self):
+        """deal with non-supported methods"""
+        methods = ', '.join(self.response_functions.keys()[:1]) 
+        methods += ' and %s' % self.response_functions.keys()[-1] 
+        return exc.HTTPMethodNotAllowed("Only %s operations are allowed" % methods)
+
+    ### auth functions
+
+    def passwords(self):
+        return self.users.passwords()
+
+    def authenticated(self):
+        """return authenticated user"""
+        return self.request.environ.get('REMOTE_USER')
+
+    def check_user(self, user):
+        """
+        determine authenticated user
+        returns None on success
+        """
+        authenticated = self.authenticated()
+        if authenticated is None:
+            return exc.HTTPUnauthorized('Unauthorized')
+        elif user != authenticated:
+            return exc.HTTPForbidden("Forbidden")        
+
+    def role(self, user):
+        """
+        determine what role the authenticated member has
+        with respect to the user
+        """
+        auth = self.authenticated()
+        if not auth:
+            return 'public'
+        if auth == user:
+            return 'author'
+        else:
+            if auth in self.request.user.settings['Friends']:
+                return 'friend'
+            else:
+                return 'public'
+
+    ### user methods
+
+    def userpath(self):
+        """user who's blog one is viewing"""        
+        path = self.request.path_info.strip('/').split('/')
+        name = path[0]
+        path = path[1:]
+        if not name:
+            name = None
+        return name, path
+
+    ### date methods
+
+    def isentry(self, string): # TODO -> blog.py
+        return (len(string) == len(''.join(utils.timeformat))) and string.isdigit()
+
+    def user_url(self, user, *args, **kw):
+        permalink = kw.get('permalink')
+        if permalink:
+            _args = [ self.request.host_url, user ]
+        else:
+            _args = [ user ]
+        _args.extend(args)
+        return '/'.join([str(arg) for arg in _args])
+
+    def permalink(self, blogentry):
+        return self.user_url(blogentry.user, blogentry.datestamp(), permalink=True)
+
+    def entry_subject(self, blogentry):
+        if hasattr(self.request, 'user') and self.request.user.name == blogentry.user:
+            prefs = self.request.user.settings
+        else:
+            prefs = self.users[blogentry.user].settings
+        subject = prefs.get('Subject', self.subject)
+        date_format = prefs.get('Date format', self.date_format)
+        return subject % { 'date': blogentry.date.strftime(date_format) }
+
+    def mangledurl(self, blogentry):
+        return self.user_url(blogentry.user, 'x%x' % (int(blogentry.datestamp()) * self.users.secret(blogentry.user)), permalink=True)
+
+    def unmangleurl(self, url, user):
+        url = url.strip('x')
+        try:
+            value = int(url, 16)
+        except ValueError:
+            return None
+        
+        # XXX once one has a mangled url, one can obtain the secret
+        value /= self.users.secret(user) 
+
+        entry = str(value)
+        if self.isentry(entry):
+            return self.blog.entry(user, entry, ['public', 'secret', 'private'])
+        
+    ### blog retrival methods
+
+    def number_of_posts(self, user=None):
+        """return the number of blog posts to display"""
+        # determine number of posts to display (None -> all)
+        n_posts = self.request.GET.get('posts', None)
+        if n_posts is not None:
+            try:
+                n_posts = int(n_posts)
+                if n_links > 0 and n_links > n_posts:
+                    n_links = n_posts # don't display more links than posts
+            except (TypeError, ValueError):
+                n_posts = None
+        return n_posts
+
+    def number_of_links(self, user=None):
+        """return the number of links to display in the navigation"""
+        # determine number of navigation links to display
+        n_links = self.request.GET.get('n')
+        if n_links == 'all':
+            return -1
+        try:
+            n_links = int(n_links)
+        except (TypeError, ValueError):
+            n_links = self.n_links
+        return n_links
+
+    def get_blog(self, user, path, role='public', n_items=None):
+        """retrieve the blog entry based on the path"""
+
+        notfound = "blog entry not found"
+
+        # get permissions
+        allowed = roles[role]
+
+        # entire blog
+        if not path:
+            return self.blog(user, allowed, n_items)
+
+        # mangled urls 
+        if len(path) == 1 and path[0].startswith('x'):
+            entry = self.unmangleurl(path[0], user)
+            if entry:
+                return [ entry ]
+            else:
+                raise BlogPathException(notfound)
+            
+        # individual blog entry
+        if (len(path) == 1) and self.isentry(path[0]):
+            blog = self.blog.entry(user, path[0], allowed)
+            if not blog:
+                raise BlogPathException(notfound)
+            return [ blog ]
+            
+        # parse the path into a date path
+        n_date_vals = 3 # year, month, date
+        if len(path) > n_date_vals:
+            raise BlogPathException(notfound)
+
+        # ensure the path conforms to expected values (ints):
+        try:
+            [ int(i) for i in path]
+        except ValueError:
+            raise BlogPathException(notfound)
+
+        # get the blog
+        return self.blog.entries(user, allowed, *path)
+
+    ### methods that write HTML
+
+    def render(self, body, title=None, feedtitle=None):
+        """layout the page in a unified way"""
+        stylesheets = ()
+        user = getattr(self.request, 'user', None)
+        _title = [ self.site_name ]
+        if user:
+            stylesheets = self.request.user['CSS']
+            stylesheets = [ (("/%s" % self.user_url(user.name, 'css', css['filename'])),
+                             css['name']) for css in stylesheets ]
+            _title.insert(0, self.request.user.name)
+        else:
+            stylesheets = (("/css/site.css", "Default"),)
+            
+        if title:
+            _title.insert(0, title)
+            
+        title = ' - '.join(_title)
+        head_markup = ()
+        if feedtitle:
+            head_markup = ( '<link rel="alternate" type="application/atom+xml" title="%s" href="atom" />' % feedtitle, 
+                            '<link rel="alternate" type="application/rss+xml" title="%s" href="rss" />' % feedtitle,)
+        return markup.wrap(self.site_nav()+body, title, stylesheets, head_markup=head_markup)
+
+    def site_nav(self):
+        """returns HTML for site navigation"""
+        links = [('/',), ]
+        user = self.authenticated()
+        if user:
+            links.extend([('/%s' % user, user),
+                          ('/%s/post' % user, 'post'),
+                          ('/%s/preferences' % user, 'preferences'),
+                          ('/logout', 'logout')])
+        else:
+            links.extend([('/login', 'login'), ('/join', 'join')])
+
+        if hasattr(self, 'help'):
+            links.append(('/help', 'help'))
+
+        links = [ markup.link(*i) for i in links ]
+        return markup.listify(links, ordered=False, **{ 'class': 'site-nav'})
+
+    def index(self, n_links):
+        retval = StringIO()
+        print >> retval, '<h1><img src="bitsyblog.png" alt="bitsyblog"/></h1>'
+
+        # get the blogs
+        blogs = {}
+        for user in self.users:
+            blog = self.blog(user, ('public',), n_links)
+            if blog:
+                blogs[user] = blog
+        users = blogs.keys()
+
+        # display latest active user first
+        users.sort(key=lambda user: blogs[user][0].date, reverse=True)
+
+        # display users' blogs
+        for user in users:
+            print >> retval, '<div id="%s" class="user">' % user
+            print >> retval, '<a href="%s">%s</a>' % (user, user)
+            blog = blogs[user]
+            print >> retval, self.navigation(user, blog, '/%s' % user, n_links)
+            print >> retval, '</div>'
+
+        return self.render(retval.getvalue(), feedtitle=self.site_name)
+
+    def navigation(self, user, blog, path, n_links, n_char=80):
+        prefs = self.users[user].settings
+        
+        if n_links == 0 or not len(blog):
+            return ''
+        retval = StringIO()
+        print >> retval, '<div class="navigation">'
+        more = ''
+        if (n_links != -1) and (len(blog) > n_links):
+            more = '<a href="%s?n=all">more</a>' % path
+            blog = blog[:n_links]
+
+        entries = []
+        for entry in blog:
+            id = entry.datestamp()
+            format = prefs.get('Date format', self.date_format)
+            datestamp = entry.date.strftime(format)
+            synopsis = entry.title()
+            if synopsis:
+                synopsis = ': %s' % cgi.escape(synopsis)
+            entries.append(markup.link("%s#%s" % (path, id), datestamp) + synopsis)
+
+        print >> retval, markup.listify(entries)
+        print >> retval, more
+        print >> retval, '</div>'
+        return retval.getvalue()
+
+    def blog_entry(self, user, entry):
+        """given the content string, return a marked-up blog entry"""
+        # XXX no need to pass user
+
+        # user preferences
+        prefs = self.request.user.settings
+        format = prefs.get('Date format', self.date_format)
+        subject = prefs.get('Subject', self.subject)
+
+        role = self.role(user)
+        
+        subject = subject % { 'date' : entry.date.strftime(format) }
+        subject = cgi.escape(subject)
+        html = StringIO()
+        id = entry.datestamp()
+        print >> html, '<div id="%s" class="blog-entry">' % id
+        print >> html, '<a name="%s" />' % id
+        print >> html, '<div class="subject">'
+        print >> html, '<a href="/%s">%s</a>' % (self.user_url(user, id), subject)
+        if (entry.privacy == 'secret') and (role == 'friend'):
+            print >> html, '<em>secret</em>'
+        print >> html, '</div>'
+        print >> html, self.cooker(entry.body)
+
+        if role == 'author':
+            print >> html, '<div><form action="/%s" method="post">' % self.user_url(entry.user, id)
+            print >> html, self.privacy_settings(entry.privacy)
+            print >> html, '<input type="submit" name="submit" value="Change Privacy" />'
+            print >> html, '</form></div>'
+            if entry.privacy != 'public':
+                title = "You can give this URL so people may see this %s post without logging in" % entry.privacy
+                print >> html, '<div>'
+                print >> html, '<span title="%s">Mangled URL:</span>' % title
+                print >> html, markup.link(self.mangledurl(entry))
+                print >> html, '</div>'
+        
+        print >> html, '</div>'
+        return html.getvalue()
+        
+    def write_blog(self, user, blog, path, n_links):
+        """return the user's blog in HTML"""
+        # XXX no need to pass path or user!
+        retval = StringIO()
+        print >> retval,  self.navigation(user, blog, path, n_links, 0)
+        for entry in blog:
+            print >> retval, self.blog_entry(user, entry)
+        feedtitle=None
+        if self.request.path_info.strip('/') == user:
+            feedtitle = "%s's blog" % user
+        title = None
+        if len(blog) == 1:
+            format = self.request.user.settings.get('Date format', self.date_format)
+            title = blog[0].date.strftime(format)
+        return self.render(retval.getvalue(), title=title, feedtitle=feedtitle)
+                           
+    def restructuredText(self, string):
+        origstring = string
+        settings = { 'report_level': 5 }
+        string = string.strip()
+        try:
+            
+            parts = docutils.core.publish_parts(string.strip(),
+                                                writer_name='html',
+                                                settings_overrides=settings)
+            body = parts['body']
+        except SystemMessage, e:
+            lines = [ cgi.escape(i.strip()) for i in string.split('\n') ]
+            body = '<br/>\n'.join(lines)
+            
+
+        retval = '<div class="blog-body">%s</div>' % body
+        return retval
+
+        # this should be reenabled if 'system-message's again appear in the markup
+        try:            
+            foo = etree.fromstring(retval)
+        except etree.XMLSyntaxError:
+            return retval
+        # should cleanup the <div class="system-message">
+        for i in foo.getiterator():
+            if dict(i.items()).get('class') ==  'system-message':
+                i.clear()
+                
+        return etree.tostring(foo)
+
+    ### feeds
+
+    def site_rss(self, n_items=10):
+        blog = self.blog.latest(list(self.users.users()), n_items)
+        title = self.site_name + ' - rss'
+        link = self.request.host_url # XXX should be smarter
+        description = "latest scribblings on %s" % self.site_name
+        lastBuildDate = datetime.datetime.now()
+        items = [ self.rss_item(entry.user, entry) for entry in blog ]
+        rss = PyRSS2Gen.RSS2(title=title, 
+                             link=link,
+                             description=description,
+                             lastBuildDate=lastBuildDate,
+                             items=items)
+        return rss.to_xml()
+
+    def rss(self, user, blog):
+        """
+        rss feed for a user's blog
+        done with PyRSS2Gen:
+        http://www.dalkescientific.com/Python/PyRSS2Gen.html
+        """        
+        title = "%s's blog" % user
+        link = os.path.split(self.request.url)[0]
+        description = "latest blog entries for %s on %s" % (user, self.site_name)
+        lastBuildDate = datetime.datetime.now() # not sure what this means
+        
+        items = [ self.rss_item(user, entry) for entry in blog ]
+        rss = PyRSS2Gen.RSS2(title=title, 
+                             link=link,
+                             description=description,
+                             lastBuildDate=lastBuildDate,
+                             items=items)
+        return rss.to_xml()
+
+    def rss_item(self, user, entry):
+        if hasattr(self.request, 'user') and self.request.user.name == user:
+            prefs = self.request.user.settings
+        else:
+            prefs = self.users[user].settings
+        subject = prefs.get('Subject', self.subject)
+        date_format = prefs.get('Date format', self.date_format)
+        title = entry.title()
+        return PyRSS2Gen.RSSItem(title=title, #subject % { 'date': entry.date.strftime(date_format) },
+                                 link=self.permalink(entry),
+                                 description=unicode(entry.body, errors='replace'),
+                                 author=user,
+                                 guid=PyRSS2Gen.Guid(self.permalink(entry)),
+                                 pubDate=entry.date)
+
+
+    def atom(self, blog, author=None):
+        retval = StringIO()
+        print >> retval, """<?xml version="1.0" encoding="utf-8"?>
+<feed xmlns="http://www.w3.org/2005/Atom">
+"""
+        if author:
+            title = "%s's blog" % author
+            link = self.request.host_url + '/' + author
+        else:
+            title = self.site_name + ' - atom'
+            link = self.request.host_url
+            
+        date = blog[0].date.isoformat()
+
+        print >> retval, '<title>%s</title>' % title
+        print >> retval, '<link href="%s" />' % link
+        print >> retval, '<updated>%s</updated>' % date
+        if author:
+            print >> retval, """
+ <author>
+    <name>%s</name>
+ </author>""" % author
+ 
+        for entry in blog:
+            print >> retval, '<entry>'
+            print >> retval, '<title>%s</title>' % cgi.escape(entry.title())
+            print >> retval, '<link href="%s" />' % self.permalink(entry)
+            print >> retval, '<updated>%s</updated>' % entry.date.isoformat()
+            print >> retval, '<summary>%s</summary>' % cgi.escape(entry.body)
+
+            print >> retval, '</entry>'
+ 
+        print >> retval, '</feed>'
+        return retval.getvalue()
+
+    ### forms and accompanying display
+
+    def form_post(self, user):
+        retval = StringIO()
+        print >> retval, '<form action="/%s" method="post">' % self.user_url(user)
+        print >> retval, '<textarea cols="80" rows="25" name="form-post"></textarea><br/>'
+        print >> retval, self.privacy_settings()
+        print >> retval, '<input type="submit" name="submit" value="Post" />'
+        print >> retval, '</form>'
+        return self.render(retval.getvalue())
+
+    def preferences_form(self, user):
+        prefs = self.request.user.settings
+        form = Form()
+
+        # date format
+        format = prefs.get('Date format', self.date_format)
+        value = datetime.datetime.now().strftime(format)
+        form.add_element('textfield', 'Date format', value=value,
+                         help='how to display dates in your blog post subject')
+
+        # subject
+        subject = prefs.get('Subject', self.subject)
+        subject = subject.split('%(date)s', 1)
+        func = lambda name: value
+        form.add_elements('Subject',
+                          ['textfield', func, 'textfield' ],
+                          [ (), (), ()], # this is horrible!
+                          [ dict(value=subject[0]),
+                            {},
+                            dict(value=subject[1]) ],
+                          help='how to display the subject line of your blog post'
+                          )
+                          
+        # CSS files
+        css_files = [ i['name'] for i in prefs['CSS'] ]
+        form.add_element('menu', 'Stylesheet', css_files,
+                         help='which CSS file should be the default')
+
+        # or upload a CSS file
+        form.add_element('file_upload', 'CSS file',
+                         help='upload a CSS file to theme your webpage')
+
+        # Friends -- can see secret posts
+        users = [ i for i in list(self.users.users()) 
+                  if i != user ]
+        if users:
+            users.sort(key=str.lower)
+            form.add_element('checkboxes', 'Friends',
+                             users, prefs.get('Friends', set()),
+                             help='friends can see your secret posts')
+        
+        return form
+
+    def preferences(self, user, errors=None, message=None):
+        """user preferences form"""
+        body = self.preferences_form(user)(errors)
+        if message:
+            body = '%s\n%s' % ( markup.p(markup.strong(message)), body )
+        return self.render(body, title='preferences')
+
+    def privacy_settings(self, default='public'):
+        """HTML snippet for privacy settings"""
+        settings = (('public', 'viewable to everyone'),
+                    ('secret', 'viewable only to your friends'),
+                    ('private', 'viewable only to you'))
+        form = Form()
+        return form.radiobuttons('privacy', settings, checked=default, joiner=' ')
+
+class BitsierBlog(BitsyBlog):
+    """single user version of bitsyblog"""
+
+    def get(self):
+        ### user space
+        user, path = self.userpath()
+        if user not in self.users:
+            return exc.HTTPNotFound("No blog found for %s" % user)
+
+        return self.get_user_space(user, path)
+
+    def userpath(self):
+        path = self.request.path_info.strip('/').split('/')
+        if path == ['']:
+            path = []
+        return self.user, path
+
+    def user_url(self, user, *args, **kw):
+        permalink = kw.get('permalink')
+        if permalink:
+            _args = [ self.request.host_url ]
+        else:
+            _args = [ ]
+        _args.extend(args)
+        return '/'.join([str(arg) for arg in _args])
+
+    def passwords(self):
+        return { self.user: self.users.password(self.user) }
+
+    def site_nav(self):
+        """returns HTML for site navigation"""
+        links = [('/', self.user), ]
+        user = self.authenticated()
+        if user:
+            links.extend([
+                          ('/post', 'post'),
+                          ('/preferences', 'preferences'),
+                          ('/logout', 'logout')])
+        else:
+            links.append(('/login', 'login'))
+
+        if hasattr(self, 'help'):
+            links.append(('/help', 'help'))
+
+        links = [ markup.link(*i) for i in links ]
+        return markup.listify(links, ordered=False, **{ 'class': 'site-nav'})
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bitsyblog/blog.py	Sat Sep 12 16:06:57 2009 -0400
@@ -0,0 +1,155 @@
+"""blog interfaces to data for bitsy"""
+
+import os
+import utils
+
+from cStringIO import StringIO
+from glob import glob
+
+class BlogEntry(object):
+    """interface class for a blog entry"""
+    def __init__(self, date, body, privacy, user):
+        self.date = date
+        self.body = body
+        self.privacy = privacy
+        if user is not None:
+            self.user = user
+
+    def title(self, characters=80):
+        if '\n' in self.body:
+            lines = [i.strip() for i in self.body[:characters].split('\n')]
+            if len(lines[0]) > characters:
+                return self.snippet(charachters)
+            if len(lines) > 1 and not lines[1]:
+                return lines[0]
+        return self.snippet(characters)
+
+    def snippet(self, characters=80):
+        if characters:
+            if len(self.body) > characters:
+                
+
+                text = ' '.join(self.body[:characters].split()[:-1])
+                if '\n' in text:
+                    lines = [ i.strip() for i in text.split('\n') ]
+                    if '' in lines:
+                        return '\n'.join(lines[:lines.index('')])
+
+                if text:
+                    return '%s ...' % text
+            else:
+                return self.body
+        return ''
+
+    def datestamp(self):
+        return utils.datestamp(self.date)
+
+class Blog(object):
+    """abstract class for a users' blog"""
+
+    def __call__(self, user, permissions=('public',), number=None):
+        return self.blog(user, permissions, number=number)
+
+    def latest(self, users, number):
+        """return the lastest entries"""
+        blog = []
+        for user in users:
+            blog.extend(self.blog(user, ('public',), number))
+        blog.sort(key=lambda entry: entry.date, reverse=True)
+        return blog[:number]
+
+    # interfaces for subclasses
+
+    def blog(self, user, permissions, number=None):
+        """
+        return the user's blog sorted in reverse date order
+        if number is None, the entire blog is returned
+        """
+
+    def entry(self, user, datestamp, permissions):
+        """
+        return a single blog entry with the given datestamp:
+        'YYYYMMDDHHMMSS'
+        """
+
+    def entries(self, user, permissions, year=None, month=None, day=None):
+        """return entries by date"""
+
+
+    def post(self, user, date, text, privacy):
+        """post a new blog entry"""
+        
+
+    def delete(self, user, datestamp):
+        """remove a blog entry"""
+
+class FileBlog(Blog):
+    """a blog that lives on the filesystem"""
+
+    def __init__(self, directory):
+        self.directory = directory
+
+    def location(self, user, permission, *path):
+        """returns which directory files are in based on permission"""
+        return os.path.join(self.directory, user, 'entries', permission, *path)
+
+    def body(self, user, datestamp, permission):
+        return file(self.location(user, permission, datestamp)).read()
+
+    def get_entry(self, user, datestamp, permission):
+        return BlogEntry(utils.date(datestamp), 
+                         self.body(user, datestamp, permission),
+                         permission, user)
+
+    ### interfaces from Blog
+
+    def blog(self, user, permissions, number=None):        
+        entries = []
+        for permission in permissions:
+            entries.extend([ (entry, permission) 
+                             for entry in os.listdir(self.location(user, permission)) ])
+        entries.sort(key=lambda x: x[0], reverse=True)
+        
+        if number is not None:
+            entries = entries[:number]
+
+        return [ self.get_entry(user, x[0], x[1]) for x in entries ]
+
+    def entry(self, user, datestamp, permissions):
+        for permission in permissions:
+            filename = self.location(user, permission, datestamp)
+            if os.path.exists(filename):
+                return self.get_entry(user, datestamp, permission)
+
+    def entries(self, user, permissions, year=None, month=None, day=None):
+
+        # build a file glob expression
+        dateargs = [ year, month, day, None ]
+        glob_expr = ''
+        for index in range(len(dateargs)):
+            value = dateargs[index]
+            if value is None:
+                break
+            length = len(utils.timeformat[index])
+            glob_expr += '%0*d' % (length, int(value))
+        while index < len(utils.timeformat):
+            glob_expr += '[0-9]' * len(utils.timeformat[index])
+            index += 1
+
+        # get the blog entries
+        entries = []
+        for permission in permissions:
+            entries.extend([ (os.path.split(entry)[-1], permission) 
+                             for entry in glob(os.path.join(self.location(user, permission), glob_expr)) ])
+        entries.sort(key=lambda x: x[0], reverse=True)
+        return [ self.get_entry(user, x[0], x[1]) for x in entries ]
+
+    def post(self, user, datestamp, body, privacy):
+        blog = file(self.location(user, privacy, datestamp), 'w')
+        print >> blog, body
+
+    def delete(self, user, datestamp):
+        for permission in 'public', 'secret', 'private':
+            path = self.location(user, permission, datestamp)
+            if os.path.exists(path):
+                os.remove(path)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bitsyblog/factory.py	Sat Sep 12 16:06:57 2009 -0400
@@ -0,0 +1,36 @@
+from bitsyauth import BitsyAuth
+from bitsyblog import BitsyBlog, BitsierBlog
+from getpass import getpass 
+from paste.httpexceptions import HTTPExceptionHandler
+
+def factory(global_conf, **app_conf):
+    """make bitsyauth app and wrap it in middleware"""
+    
+    config = [ 'file_dir', 'date_format', 'subject', 'n_links', 'help_file' ]
+    key_str = 'bitsyblog.%s'
+    args = dict([ (key, app_conf[ key_str % key]) for key in config
+                  if app_conf.has_key(key_str % key) ])
+
+    app = BitsyBlog(**args)
+    secret = app_conf.get('secret', 'secret')
+    return BitsyAuth(HTTPExceptionHandler(app), global_conf, app.passwords, app.newuser, 'bitsyblog', secret)
+
+# TODO: use wsgifilter.proxyapp.DebugHeaders to debug the headers apache
+# doesn't like
+
+def bitsierfactory(global_conf, **app_conf):
+    """make single-user bitsyblog"""
+    config = [ 'file_dir', 'date_format', 'subject', 'n_links', 'help_file' ]
+    key_str = 'bitsyblog.%s'
+    args = dict([ (key, app_conf[ key_str % key]) for key in config
+                  if app_conf.has_key(key_str % key) ])
+
+    user = app_conf['bitsyblog.user']
+    app = BitsierBlog(**args)
+    app.user = user
+    secret = app_conf.get('secret', 'secret')
+    auth = BitsyAuth(HTTPExceptionHandler(app), global_conf, app.passwords, newuser=None, site='bitsyblog', secret=secret)
+    if not user in app.users:
+        pw = getpass('Enter password for %s: ' % user)
+        app.newuser(user, auth.hash(app.user, pw))
+    return auth
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bitsyblog/parser.py	Sat Sep 12 16:06:57 2009 -0400
@@ -0,0 +1,1001 @@
+# -*- coding:iso-8859-1 -*-
+"""
+Copyright (c) 2003-2005  Gustavo Niemeyer <gustavo@niemeyer.net>
+
+This module offers extensions to the standard python 2.3+
+datetime module.
+"""
+__author__ = "Gustavo Niemeyer <gustavo@niemeyer.net>"
+__license__ = "PSF License"
+
+import os.path
+import string
+import sys
+import time
+import math
+
+import datetime
+import dateutil.relativedelta as relativedelta
+import dateutil.tz as tz
+
+__all__ = ["parse", "parserinfo"]
+
+# Some pointers:
+#
+# http://www.cl.cam.ac.uk/~mgk25/iso-time.html
+# http://www.iso.ch/iso/en/prods-services/popstds/datesandtime.html
+# http://www.w3.org/TR/NOTE-datetime
+# http://ringmaster.arc.nasa.gov/tools/time_formats.html
+# http://search.cpan.org/author/MUIR/Time-modules-2003.0211/lib/Time/ParseDate.pm
+# http://stein.cshl.org/jade/distrib/docs/java.text.SimpleDateFormat.html
+
+try:
+    from cStringIO import StringIO
+except ImportError:
+    from StringIO import StringIO
+
+class _timelex:
+    def __init__(self, instream):
+        if isinstance(instream, basestring):
+            instream = StringIO(instream)
+        self.instream = instream
+        self.wordchars = ('abcdfeghijklmnopqrstuvwxyz'
+                          'ABCDEFGHIJKLMNOPQRSTUVWXYZ_'
+                          'ßàáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿ'
+                          'ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞ')
+        self.numchars = '0123456789'
+        self.whitespace = ' \t\r\n'
+        self.charstack = []
+        self.tokenstack = []
+        self.eof = False
+
+    def get_token(self):
+        if self.tokenstack:
+            return self.tokenstack.pop(0)
+        seenletters = False
+        token = None
+        state = None
+        wordchars = self.wordchars
+        numchars = self.numchars
+        whitespace = self.whitespace
+        while not self.eof:
+            if self.charstack:
+                nextchar = self.charstack.pop(0)
+            else:
+                nextchar = self.instream.read(1)
+                while nextchar == '\x00':
+                    nextchar = self.instream.read(1)
+            if not nextchar:
+                self.eof = True
+                break
+            elif not state:
+                token = nextchar
+                if nextchar in wordchars:
+                    state = 'a'
+                elif nextchar in numchars:
+                    state = '0'
+                elif nextchar in whitespace:
+                    token = ' '
+                    break # emit token
+                else:
+                    break # emit token
+            elif state == 'a':
+                seenletters = True
+                if nextchar in wordchars:
+                    token += nextchar
+                elif nextchar == '.':
+                    token += nextchar
+                    state = 'a.'
+                else:
+                    self.charstack.append(nextchar)
+                    break # emit token
+            elif state == '0':
+                if nextchar in numchars:
+                    token += nextchar
+                elif nextchar == '.':
+                    token += nextchar
+                    state = '0.'
+                else:
+                    self.charstack.append(nextchar)
+                    break # emit token
+            elif state == 'a.':
+                seenletters = True
+                if nextchar == '.' or nextchar in wordchars:
+                    token += nextchar
+                elif nextchar in numchars and token[-1] == '.':
+                    token += nextchar
+                    state = '0.'
+                else:
+                    self.charstack.append(nextchar)
+                    break # emit token
+            elif state == '0.':
+                if nextchar == '.' or nextchar in numchars:
+                    token += nextchar
+                elif nextchar in wordchars and token[-1] == '.':
+                    token += nextchar
+                    state = 'a.'
+                else:
+                    self.charstack.append(nextchar)
+                    break # emit token
+        if (state in ('a.', '0.') and
+            (seenletters or token.count('.') > 1 or token[-1] == '.')):
+            l = token.split('.')
+            token = l[0]
+            for tok in l[1:]:
+                self.tokenstack.append('.')
+                if tok:
+                    self.tokenstack.append(tok)
+        return token
+
+    def __iter__(self):
+        return self
+
+    def next(self):
+        token = self.get_token()
+        if token is None:
+            raise StopIteration
+        return token
+
+    def split(cls, s):
+        return list(cls(s))
+    split = classmethod(split)
+
+class _resultbase(object):
+
+    def __init__(self):
+        for attr in self.__slots__:
+            setattr(self, attr, None)
+
+    def _repr(self, classname):
+        l = []
+        for attr in self.__slots__:
+            value = getattr(self, attr)
+            if value is not None:
+                l.append("%s=%s" % (attr, `value`))
+        return "%s(%s)" % (classname, ", ".join(l))
+
+    def __repr__(self):
+        return self._repr(self.__class__.__name__)
+
+class parserinfo:
+
+    # m from a.m/p.m, t from ISO T separator
+    JUMP = [" ", ".", ",", ";", "-", "/", "'",
+            "at", "on", "and", "ad", "m", "t", "of",
+            "st", "nd", "rd", "th"] 
+
+    WEEKDAYS = [("Mon", "Monday"),
+                ("Tue", "Tuesday"),
+                ("Wed", "Wednesday"),
+                ("Thu", "Thursday"),
+                ("Fri", "Friday"),
+                ("Sat", "Saturday"),
+                ("Sun", "Sunday")]
+    MONTHS   = [("Jan", "January"),
+                ("Feb", "February"),
+                ("Mar", "March"),
+                ("Apr", "April"),
+                ("May", "May"),
+                ("Jun", "June"),
+                ("Jul", "July"),
+                ("Aug", "August"),
+                ("Sep", "September"),
+                ("Oct", "October"),
+                ("Nov", "November"),
+                ("Dec", "December")]
+    HMS = [("h", "hour", "hours"),
+           ("m", "minute", "minutes"),
+           ("s", "second", "seconds")]
+    AMPM = [("am", "a"),
+            ("pm", "p")]
+    UTCZONE = ["UTC", "GMT", "Z"]
+    PERTAIN = ["of"]
+    TZOFFSET = {}
+
+    def __init__(self, dayfirst=False, yearfirst=False):
+        self._jump = self._convert(self.JUMP)
+        self._weekdays = self._convert(self.WEEKDAYS)
+        self._months = self._convert(self.MONTHS)
+        self._hms = self._convert(self.HMS)
+        self._ampm = self._convert(self.AMPM)
+        self._utczone = self._convert(self.UTCZONE)
+        self._pertain = self._convert(self.PERTAIN)
+
+        self.dayfirst = dayfirst
+        self.yearfirst = yearfirst
+
+        self._year = time.localtime().tm_year
+        self._century = self._year/100*100
+
+    def _convert(self, lst):
+        dct = {}
+        for i in range(len(lst)):
+            v = lst[i]
+            if isinstance(v, tuple):
+                for v in v:
+                    dct[v.lower()] = i
+            else:
+                dct[v.lower()] = i
+        return dct
+
+    def jump(self, name):
+        return name.lower() in self._jump
+
+    def weekday(self, name):
+        if len(name) >= 3:
+            try:
+                return self._weekdays[name.lower()]
+            except KeyError:
+                pass
+        return None
+
+    def month(self, name):
+        if len(name) >= 3:
+            try:
+                return self._months[name.lower()]+1
+            except KeyError:
+                pass
+        return None
+
+    def hms(self, name):
+        try:
+            return self._hms[name.lower()]
+        except KeyError:
+            return None
+
+    def ampm(self, name):
+        try:
+            return self._ampm[name.lower()]
+        except KeyError:
+            return None
+
+    def pertain(self, name):
+        return name.lower() in self._pertain
+
+    def utczone(self, name):
+        return name.lower() in self._utczone
+
+    def tzoffset(self, name):
+        if name in self._utczone:
+            return 0
+        return self.TZOFFSET.get(name)
+
+    def convertyear(self, year):
+        if year < 100:
+            year += self._century
+            if abs(year-self._year) >= 50:
+                if year < self._year:
+                    year += 100
+                else:
+                    year -= 100
+        return year
+
+    def validate(self, res):
+        # move to info
+        if res.year is not None:
+            res.year = self.convertyear(res.year)
+        if res.tzoffset == 0 and not res.tzname or res.tzname == 'Z':
+            res.tzname = "UTC"
+            res.tzoffset = 0
+        elif res.tzoffset != 0 and res.tzname and self.utczone(res.tzname):
+            res.tzoffset = 0
+        return True
+
+
+class parser:
+
+    def __init__(self, info=parserinfo):
+        if issubclass(info, parserinfo):
+            self.info = parserinfo()
+        elif isinstance(info, parserinfo):
+            self.info = info
+        else:
+            raise TypeError, "Unsupported parserinfo type"
+
+    def parse(self, timestr, default=None,
+                    ignoretz=False, tzinfos=None,
+                    **kwargs):
+        if not default:
+            default = datetime.datetime.now().replace(hour=0, minute=0,
+                                                      second=0, microsecond=0)
+        res = self._parse(timestr, **kwargs)
+        if res is None:
+            raise ValueError, "unknown string format"
+        repl = {}
+        for attr in ["year", "month", "day", "hour",
+                     "minute", "second", "microsecond"]:
+            value = getattr(res, attr)
+            if value is not None:
+                repl[attr] = value
+        ret = default.replace(**repl)
+        if res.weekday is not None and not res.day:
+            ret = ret+relativedelta.relativedelta(weekday=res.weekday)
+        if not ignoretz:
+            if callable(tzinfos) or tzinfos and res.tzname in tzinfos:
+                if callable(tzinfos):
+                    tzdata = tzinfos(res.tzname, res.tzoffset)
+                else:
+                    tzdata = tzinfos.get(res.tzname)
+                if isinstance(tzdata, datetime.tzinfo):
+                    tzinfo = tzdata
+                elif isinstance(tzdata, basestring):
+                    tzinfo = tz.tzstr(tzdata)
+                elif isinstance(tzdata, int):
+                    tzinfo = tz.tzoffset(res.tzname, tzdata)
+                else:
+                    raise ValueError, "offset must be tzinfo subclass, " \
+                                      "tz string, or int offset"
+                ret = ret.replace(tzinfo=tzinfo)
+            elif res.tzname and res.tzname in time.tzname:
+                ret = ret.replace(tzinfo=tz.tzlocal())
+            elif res.tzoffset == 0:
+                ret = ret.replace(tzinfo=tz.tzutc())
+            elif res.tzoffset:
+                ret = ret.replace(tzinfo=tz.tzoffset(res.tzname, res.tzoffset))
+        return ret
+
+    class _result(_resultbase):
+        __slots__ = ["year", "month", "day", "weekday",
+                     "hour", "minute", "second", "microsecond",
+                     "tzname", "tzoffset"]
+
+    def _parse(self, timestr, dayfirst=None, yearfirst=None, fuzzy=False):
+        info = self.info
+        if dayfirst is None:
+            dayfirst = info.dayfirst
+        if yearfirst is None:
+            yearfirst = info.yearfirst
+        res = self._result()
+
+        res.format = []
+        ymd_index = [ None, None, None ]
+        
+        l = _timelex.split(timestr)
+        try:
+
+            # year/month/day list
+            ymd = []
+
+            # Index of the month string in ymd
+            mstridx = -1
+
+            len_l = len(l)
+            i = 0
+            while i < len_l:
+                # Check if it's a number
+                try:
+                    value = float(l[i])
+                except ValueError:
+                    value = None
+                if value is not None:
+                    # Token is a number
+                    len_li = len(l[i])
+                    i += 1
+                    if (len(ymd) == 3 and len_li in (2, 4)
+                        and (i >= len_l or (l[i] != ':' and
+                                            info.hms(l[i]) is None))):
+                        # 19990101T23[59]
+                        s = l[i-1]
+                        res.hour = int(s[:2])
+                        res.format.append('%H')
+                        if len_li == 4:
+                            res.minute = int(s[2:])
+                            res.format.append('%M')
+                    elif len_li == 6 or (len_li > 6 and l[i-1].find('.') == 6):
+                        # YYMMDD or HHMMSS[.ss]
+                        s = l[i-1] 
+                        if not ymd and l[i-1].find('.') == -1:
+                            ymd.append(info.convertyear(int(s[:2])))
+                            ymd.append(int(s[2:4]))
+                            ymd.append(int(s[4:]))
+                            res.format.append('%y%m%d') # XXX ?
+                        else:
+                            # 19990101T235959[.59]
+                            res.hour = int(s[:2])
+                            res.minute = int(s[2:4])
+                            value = float(s[4:])
+                            res.second = int(value)
+                            res.format.append('%H%M%S')
+                            if value%1:
+                                res.microsecond = int(1000000*(value%1))
+                    elif len_li == 8:
+                        # YYYYMMDD
+                        s = l[i-1]
+                        ymd.append(int(s[:4]))
+                        ymd.append(int(s[4:6]))
+                        ymd.append(int(s[6:]))
+                        res.format.append('%Y%m%d') # XXX ?
+                    elif len_li in (12, 14):
+                        # YYYYMMDDhhmm[ss]
+                        s = l[i-1]
+                        ymd.append(int(s[:4]))
+                        ymd.append(int(s[4:6]))
+                        ymd.append(int(s[6:8]))
+                        res.hour = int(s[8:10])
+                        res.minute = int(s[10:12])
+                        res.format.append('%Y%m%d%H%M') # XXX ? 
+                        if len_li == 14:
+                            res.second = int(s[12:])
+                            res.format.append('%S')
+                    elif ((i < len_l and info.hms(l[i]) is not None) or
+                          (i+1 < len_l and l[i] == ' ' and
+                           info.hms(l[i+1]) is not None)):
+                        # HH[ ]h or MM[ ]m or SS[.ss][ ]s
+                        space = ''
+                        if l[i] == ' ':
+                            i += 1
+                            space = ' '
+                        idx = info.hms(l[i])
+                        while True:
+                            if idx == 0:
+                                res.hour = int(value)
+                                res.format.append('%H')
+                                if value%1:                                    
+                                    res.minute = int(60*(value%1))
+                                    res.format.append('.%M')
+                            elif idx == 1:
+                                res.minute = int(value)
+                                res.format.append('%M')
+                                if value%1:
+                                    res.second = int(60*(value%1))
+                                    res.format.append('.%S')
+                            elif idx == 2:
+                                res.second = int(value)
+                                res.format.append('%S')
+                                if value%1:
+                                    res.microsecond = int(1000000*(value%1))
+
+                            # not sure if this is right
+                            res.format.append('%s%s' % (space, l[i]))
+                            space = ''
+                                    
+                            i += 1
+                            if i >= len_l or idx == 2:
+                                break
+                            # 12h00
+                            try:
+                                value = float(l[i])
+                            except ValueError:
+                                break
+                            else:
+                                i += 1
+                                idx += 1
+                                if i < len_l:
+                                    newidx = info.hms(l[i])
+                                    if newidx is not None:
+                                        idx = newidx
+                    elif i+1 < len_l and l[i] == ':':
+                        # HH:MM[:SS[.ss]]
+                        res.hour = int(value)
+                        i += 1
+                        value = float(l[i])
+                        res.minute = int(value)
+                        res.format.extend(('%H', ':%M'))
+                        if value%1:
+                            res.second = int(60*(value%1))
+                            res.format.append('.%S')
+                        i += 1
+                        if i < len_l and l[i] == ':':
+                            value = float(l[i+1])
+                            res.second = int(value)
+                            res.format.append(':%S')
+                            if value%1:
+                                res.microsecond = int(1000000*(value%1))
+                                # XXX no date format equivalent (nanoseconds?)
+                            i += 2
+                    elif i < len_l and l[i] in ('-', '/', '.'):
+                        sep = l[i]
+                        ymd.append(int(value))
+                        res.format.append('%d' + sep) # XXX ? 
+                        i += 1
+                        if i < len_l and not info.jump(l[i]):
+                            try:
+                                # 01-01[-01]
+                                ymd.append(int(l[i]))
+                                res.format.append('%m')
+                            except ValueError:
+                                # 01-Jan[-01]
+                                value = info.month(l[i])
+                                if value is not None:
+                                    ymd.append(value)
+                                    if len(l[i]) > 3:
+                                        res.format.append('%B')
+                                    else:
+                                        res.format.append('%b')
+                                    assert mstridx == -1
+                                    mstridx = len(ymd)-1
+                                else:
+                                    return None
+                            i += 1
+                            if i < len_l and l[i] == sep:
+                                res.format.append(sep)
+                                # We have three members
+                                i += 1
+                                value = info.month(l[i])
+                                if value is not None:
+                                    ymd.append(value)
+                                    if len(l[i]) > 3:
+                                        res.format.append('%B')
+                                    else:
+                                        res.format.append('%b')
+                                    mstridx = len(ymd)-1
+                                    assert mstridx == -1
+                                else:                                    
+                                    ymd.append(int(l[i]))
+                                    if len(l[i]) == 2:
+                                        res.format.append('%y')
+                                    else:
+                                        res.format.append('%Y')
+                                i += 1
+                    elif i >= len_l or info.jump(l[i]):
+                        if i+1 < len_l and info.ampm(l[i+1]) is not None:
+                            # 12 am
+                            res.hour = int(value)
+                            if res.hour < 12 and info.ampm(l[i+1]) == 1:
+                                res.hour += 12
+                            elif res.hour == 12 and info.ampm(l[i+1]) == 0:
+                                res.hour = 0
+                            res.format.append('%-I %P')
+                            i += 1
+                        else:
+                            # Year, month or day
+                            ymd.append(int(value))
+                            ymd_index[len(ymd)-1] = dict(index=len(res.format), value=int(value))
+                            res.format.append('')
+                            if i < len_l and info.jump(l[i]):
+                                res.format.append(l[i])
+                        i += 1
+                    elif info.ampm(l[i]) is not None:
+                        # 12am
+                        res.hour = int(value)
+                        if res.hour < 12 and info.ampm(l[i]) == 1:
+                            res.hour += 12
+                        elif res.hour == 12 and info.ampm(l[i]) == 0:
+                            res.hour = 0
+                        i += 1
+                        res.format.append('%-I%P')
+                    elif not fuzzy:
+                        return None
+                    else:
+                        i += 1
+                    continue
+
+                # Check weekday
+                value = info.weekday(l[i])
+                if value is not None:
+                    res.weekday = value
+                    if len(l[i]) > 3:
+                        res.format.append('%A')
+                    else:
+                        res.format.append('%a')
+                    i += 1
+                    continue
+
+                # Check month name
+                value = info.month(l[i])
+                if value is not None:
+                    ymd.append(value)
+                    if len(l[i]) > 3:
+                        res.format.append('%B')
+                    else:
+                        res.format.append('%b')
+                    
+                    assert mstridx == -1
+                    mstridx = len(ymd)-1
+                    i += 1
+                    if i < len_l:
+                        if l[i] in ('-', '/'):
+                            # Jan-01[-99]
+                            sep = l[i]
+                            res.format.append(sep)
+                            i += 1
+                            ymd.append(int(l[i])) # XXX ?
+                            i += 1
+                            if i < len_l and l[i] == sep:
+                                # Jan-01-99
+                                res.format.append(sep)
+                                i += 1
+                                ymd.append(int(l[i])) # XXX ?
+                                i += 1
+                        elif (i+3 < len_l and l[i] == l[i+2] == ' '
+                              and info.pertain(l[i+1])):
+                            # Jan of 01
+                            # In this case, 01 is clearly year
+                            try:
+                                value = int(l[i+3])
+                            except ValueError:
+                                # Wrong guess
+                                pass
+                            else:
+                                # Convert it here to become unambiguous
+                                ymd.append(info.convertyear(value)) # XXX ?
+                            i += 4
+                    continue
+
+                # Check am/pm
+                value = info.ampm(l[i])
+                if value is not None:
+                    if value == 1 and res.hour < 12:
+                        res.hour += 12
+                    elif value == 0 and res.hour == 12:
+                        res.hour = 0
+                    if l[i].isupper():                        
+                        res.format.append('%p')
+                    else:
+                        res.format.append('%P')
+                    index = res.format.index('%H')
+                    if index >= 0:
+                        res.format[index] = '%-I'
+                    i += 1
+                    continue
+
+                # Check for a timezone name
+                if (res.hour is not None and len(l[i]) <= 5 and
+                    res.tzname is None and res.tzoffset is None and
+                    not [x for x in l[i] if x not in string.ascii_uppercase]):
+                    res.tzname = l[i]
+                    res.tzoffset = info.tzoffset(res.tzname)
+                    i += 1
+
+                    # Check for something like GMT+3, or BRST+3. Notice
+                    # that it doesn't mean "I am 3 hours after GMT", but
+                    # "my time +3 is GMT". If found, we reverse the
+                    # logic so that timezone parsing code will get it
+                    # right.
+                    if i < len_l and l[i] in ('+', '-'):
+                        l[i] = ('+', '-')[l[i] == '+']
+                        res.tzoffset = None
+                        if info.utczone(res.tzname):
+                            # With something like GMT+3, the timezone
+                            # is *not* GMT.
+                            res.tzname = None
+
+                    continue
+
+                # Check for a numbered timezone
+                if res.hour is not None and l[i] in ('+', '-'):
+                    signal = (-1,1)[l[i] == '+']
+                    i += 1
+                    len_li = len(l[i])
+                    if len_li == 4:
+                        # -0300
+                        res.tzoffset = int(l[i][:2])*3600+int(l[i][2:])*60
+                    elif i+1 < len_l and l[i+1] == ':':
+                        # -03:00
+                        res.tzoffset = int(l[i])*3600+int(l[i+2])*60
+                        i += 2
+                    elif len_li <= 2:
+                        # -[0]3
+                        res.tzoffset = int(l[i][:2])*3600
+                    else:
+                        return None
+                    i += 1
+                    res.tzoffset *= signal
+
+                    # Look for a timezone name between parenthesis
+                    if (i+3 < len_l and
+                        info.jump(l[i]) and l[i+1] == '(' and l[i+3] == ')' and
+                        3 <= len(l[i+2]) <= 5 and
+                        not [x for x in l[i+2]
+                                if x not in string.ascii_uppercase]):
+                        # -0300 (BRST)
+                        res.tzname = l[i+2]
+                        i += 4
+                    continue
+
+                # Check jumps
+                if not (info.jump(l[i]) or fuzzy):
+                    return None
+                else:
+                    res.format.append(l[i])
+
+                i += 1
+
+            # Process year/month/day
+            len_ymd = len(ymd)
+            if len_ymd > 3:
+                # More than three members!?
+                return None
+            elif len_ymd == 1 or (mstridx != -1 and len_ymd == 2):
+                # One member, or two members with a month string
+                if mstridx != -1:
+                    res.month = ymd[mstridx]
+                    if ymd_index[mstridx] is not None:
+                        res.format[ymd_index[mstridx]['index']] = '%m'
+                    del ymd[mstridx]
+                    del ymd_index[mstridx]
+                if len_ymd > 1 or mstridx == -1:
+                    if ymd[0] > 31:
+                        res.year = ymd[0]
+                        if ymd_index[0] is not None:
+                            if int(math.log10(ymd_index[0]['value']) < 2):
+                                format = '%y'
+                            else:
+                                format = '%Y'
+                            res.format[ymd_index[0]['index']] = format
+                    else:
+                        res.day = ymd[0]
+                        if ymd_index[0] is not None:
+                            res.format[ymd_index[0]['index']] = '%-d'
+            elif len_ymd == 2:
+                # Two members with numbers
+                if ymd[0] > 31:
+                    # 99-01
+                    res.year, res.month = ymd
+                    if ymd_index[0] is not None:
+                        if int(math.log10(ymd_index[0]['value']) < 2):
+                            format = '%y'
+                        else:
+                            format = '%Y'
+                        res.format[ymd_index[0]['index']] = format
+                    if ymd_index[1] is not None:
+                        res.format[ymd_index[1]['index']] = '%m'
+                elif ymd[1] > 31:
+                    # 01-99
+                    res.month, res.year = ymd
+                    if ymd_index[0] is not None:
+                        res.format[ymd_index[0]['index']] = '%-m'
+                    if ymd_index[1] is not None:
+                        if int(math.log10(ymd_index[1]['value']) < 2):
+                            format = '%y'
+                        else:
+                            format = '%Y'
+                        res.format[ymd_index[1]['index']] = format
+                elif dayfirst and ymd[1] <= 12:
+                    # 13-01
+                    res.day, res.month = ymd
+                    if ymd_index[0] is not None:
+                        res.format[ymd_index[0]['index']] = '%-d'
+                    if ymd_index[1] is not None:
+                        res.format[ymd_index[1]['index']] = '%-m'
+                else:
+                    # 01-13
+                    res.month, res.day = ymd
+                    if ymd_index[0] is not None:
+                        res.format[ymd_index[0]['index']] = '%-m'
+                    if ymd_index[1] is not None:
+                        res.format[ymd_index[1]['index']] = '%-d'
+            if len_ymd == 3:
+                # Three members
+                if mstridx == 0:
+                    res.month, res.day, res.year = ymd
+                    if ymd_index[0] is not None:
+                        res.format[ymd_index[0]['index']] = '%-m'
+                    if ymd_index[1] is not None:
+                        res.format[ymd_index[1]['index']] = '%-d'
+                    if ymd_index[2] is not None:
+                        if int(math.log10(ymd_index[2]['value']) < 2):
+                            format = '%y'
+                        else:
+                            format = '%Y'
+                        res.format[ymd_index[2]['index']] = format
+                        
+                elif mstridx == 1:
+                    if ymd[0] > 31 or (yearfirst and ymd[2] <= 31):
+                        # 99-Jan-01
+                        res.year, res.month, res.day = ymd
+                    else:
+                        # 01-Jan-01
+                        # Give precendence to day-first, since
+                        # two-digit years is usually hand-written.
+                        res.day, res.month, res.year = ymd
+                elif mstridx == 2:
+                    # WTF!?
+                    if ymd[1] > 31:
+                        # 01-99-Jan
+                        res.day, res.year, res.month = ymd
+                    else:
+                        # 99-01-Jan
+                        res.year, res.day, res.month = ymd
+                else:
+                    if ymd[0] > 31 or \
+                       (yearfirst and ymd[1] <= 12 and ymd[2] <= 31):
+                        # 99-01-01
+                        res.year, res.month, res.day = ymd
+                        if ymd_index[0] is not None:
+                            if int(math.log10(ymd_index[0]['value']) < 2):
+                                format = '%y'
+                            else:
+                                format = '%Y'
+                            res.format[ymd_index[0]['index']] = format
+                        if ymd_index[1] is not None:
+                            res.format[ymd_index[1]['index']] = '%-m'
+                        if ymd_index[2] is not None:
+                            res.format[ymd_index[2]['index']] = '%-d'
+
+                    elif ymd[0] > 12 or (dayfirst and ymd[1] <= 12):
+                        # 13-01-01
+                        res.day, res.month, res.year = ymd
+                    else:
+                        # 01-13-01
+                        res.month, res.day, res.year = ymd
+
+        except (IndexError, ValueError, AssertionError):
+            return None
+
+        if not info.validate(res):
+            return None
+        res.format = ''.join(res.format)
+        return res
+
+DEFAULTPARSER = parser()
+def parse(timestr, parserinfo=None, **kwargs):
+    if parserinfo:
+        return parser(parserinfo).parse(timestr, **kwargs)
+    else:
+        return DEFAULTPARSER.parse(timestr, **kwargs)
+
+class _tzparser:
+
+    class _result(_resultbase):
+
+        __slots__ = ["stdabbr", "stdoffset", "dstabbr", "dstoffset",
+                     "start", "end"]
+
+        class _attr(_resultbase):
+            __slots__ = ["month", "week", "weekday",
+                         "yday", "jyday", "day", "time"]
+
+        def __repr__(self):
+            return self._repr("")
+
+        def __init__(self):
+            _resultbase.__init__(self)
+            self.start = self._attr()
+            self.end = self._attr()
+
+    def parse(self, tzstr):
+        res = self._result()
+        l = _timelex.split(tzstr)
+        try:
+
+            len_l = len(l)
+
+            i = 0
+            while i < len_l:
+                # BRST+3[BRDT[+2]]
+                j = i
+                while j < len_l and not [x for x in l[j]
+                                            if x in "0123456789:,-+"]:
+                    j += 1
+                if j != i:
+                    if not res.stdabbr:
+                        offattr = "stdoffset"
+                        res.stdabbr = "".join(l[i:j])
+                    else:
+                        offattr = "dstoffset"
+                        res.dstabbr = "".join(l[i:j])
+                    i = j
+                    if (i < len_l and
+                        (l[i] in ('+', '-') or l[i][0] in "0123456789")):
+                        if l[i] in ('+', '-'):
+                            signal = (1,-1)[l[i] == '+']
+                            i += 1
+                        else:
+                            signal = -1
+                        len_li = len(l[i])
+                        if len_li == 4:
+                            # -0300
+                            setattr(res, offattr,
+                                    (int(l[i][:2])*3600+int(l[i][2:])*60)*signal)
+                        elif i+1 < len_l and l[i+1] == ':':
+                            # -03:00
+                            setattr(res, offattr,
+                                    (int(l[i])*3600+int(l[i+2])*60)*signal)
+                            i += 2
+                        elif len_li <= 2:
+                            # -[0]3
+                            setattr(res, offattr,
+                                    int(l[i][:2])*3600*signal)
+                        else:
+                            return None
+                        i += 1
+                    if res.dstabbr:
+                        break
+                else:
+                    break
+
+            if i < len_l:
+                for j in range(i, len_l):
+                    if l[j] == ';': l[j] = ','
+
+                assert l[i] == ','
+
+                i += 1
+
+            if i >= len_l:
+                pass
+            elif (8 <= l.count(',') <= 9 and
+                not [y for x in l[i:] if x != ','
+                       for y in x if y not in "0123456789"]):
+                # GMT0BST,3,0,30,3600,10,0,26,7200[,3600]
+                for x in (res.start, res.end):
+                    x.month = int(l[i])
+                    i += 2
+                    if l[i] == '-':
+                        value = int(l[i+1])*-1
+                        i += 1
+                    else:
+                        value = int(l[i])
+                    i += 2
+                    if value:
+                        x.week = value
+                        x.weekday = (int(l[i])-1)%7
+                    else:
+                        x.day = int(l[i])
+                    i += 2
+                    x.time = int(l[i])
+                    i += 2
+                if i < len_l:
+                    if l[i] in ('-','+'):
+                        signal = (-1,1)[l[i] == "+"]
+                        i += 1
+                    else:
+                        signal = 1
+                    res.dstoffset = (res.stdoffset+int(l[i]))*signal
+            elif (l.count(',') == 2 and l[i:].count('/') <= 2 and
+                  not [y for x in l[i:] if x not in (',','/','J','M',
+                                                     '.','-',':')
+                         for y in x if y not in "0123456789"]):
+                for x in (res.start, res.end):
+                    if l[i] == 'J':
+                        # non-leap year day (1 based)
+                        i += 1
+                        x.jyday = int(l[i])
+                    elif l[i] == 'M':
+                        # month[-.]week[-.]weekday
+                        i += 1
+                        x.month = int(l[i])
+                        i += 1
+                        assert l[i] in ('-', '.')
+                        i += 1
+                        x.week = int(l[i])
+                        if x.week == 5:
+                            x.week = -1
+                        i += 1
+                        assert l[i] in ('-', '.')
+                        i += 1
+                        x.weekday = (int(l[i])-1)%7
+                    else:
+                        # year day (zero based)
+                        x.yday = int(l[i])+1
+
+                    i += 1
+
+                    if i < len_l and l[i] == '/':
+                        i += 1
+                        # start time
+                        len_li = len(l[i])
+                        if len_li == 4:
+                            # -0300
+                            x.time = (int(l[i][:2])*3600+int(l[i][2:])*60)
+                        elif i+1 < len_l and l[i+1] == ':':
+                            # -03:00
+                            x.time = int(l[i])*3600+int(l[i+2])*60
+                            i += 2
+                            if i+1 < len_l and l[i+1] == ':':
+                                i += 2
+                                x.time += int(l[i])
+                        elif len_li <= 2:
+                            # -[0]3
+                            x.time = (int(l[i][:2])*3600)
+                        else:
+                            return None
+                        i += 1
+
+                    assert i == len_l or l[i] == ','
+
+                    i += 1
+
+                assert i >= len_l
+
+        except (IndexError, ValueError, AssertionError):
+            return None
+
+        return res
+
+DEFAULTTZPARSER = _tzparser()
+def _parsetz(tzstr):
+    return DEFAULTTZPARSER.parse(tzstr)
+
+# vim:ts=4:sw=4:et
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bitsyblog/settings.py	Sat Sep 12 16:06:57 2009 -0400
@@ -0,0 +1,62 @@
+"""user settings / preferences"""
+
+import parser # dateutils.parser with mods to retain strftime format
+import urllib2
+import utils
+
+class InvalidSettingError(Exception):
+    """error when trying to validate a setting"""
+
+class Setting(object):
+    def __init__(self, name, value=None):
+        self.name = name
+        self.value = value
+        self.error_message = 'Invalid value for %s' % name
+
+    def validator(self, value):
+        return True
+
+    def set(self, value):
+        if not self.validator(value):
+            raise InvalidSettingError(self.error_message)
+        self.value = value
+
+class DateFormat(Setting):
+    def __init__(self):
+        Setting.__init__(self, 'Date format')
+
+    def set(self, format):
+        value = parser.parser()._parse(format)
+        if value:
+            self.value = value.format
+        else:
+            raise InvalidSettingError('unrecognized date format: %s' % format)
+
+    
+class CSSFile(Setting):
+    def __init__(self):
+        Setting.__init__(self, 'CSS file')
+
+    def set(self, value):
+        if not hasattr(value, 'file'):
+            return True # blank set: don't do anything
+        css = value.file.read()
+        try:
+            validcss = utils.validate_css(css)
+        except urllib2.URLError:
+            raise InvalidSettingError('Could not validate CSS (sorry!)')
+        if not validcss:
+            raise InvalidSettingError('%s is not valid css' % filename)
+        filename = value.filename
+        if not filename.endswith('.css'):
+            filename = '%s.css' % filename
+        self.value = dict(filename=filename, css=css)
+
+user = [ DateFormat(),
+         Setting('Subject'),
+         Setting('Friends'),
+         Setting('Stylesheet'),
+         ]
+
+form = user[:]
+form += [ CSSFile() ]
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bitsyblog/user.py	Sat Sep 12 16:06:57 2009 -0400
@@ -0,0 +1,212 @@
+import os
+import random
+import settings
+import shutil
+from bitsyblog import roles
+from webob import exc
+
+class BitsyUser(object):
+    """interface class for a bitsyblog user"""
+    settings = {} 
+    def __init__(self, name, password=''):
+        self.name = name
+        self.password = password
+
+    # user behaves like a dictionary of settings
+
+    def __getitem__(self, key):
+        return self.settings[key]
+
+    def get(self, key, default=None):
+        return self.settings.get(key, default)
+
+class BitsyUsers(object):
+    """abstract class for bitsyblog user management"""
+
+    def __iter__(self): return self.users()
+
+    def __contains__(self, user):
+        return user in self.users()
+
+    def __getitem__(self, user):
+        """return a user"""
+        if user not in self.users():
+            raise KeyError
+        user = BitsyUser(user, self.password(user))
+        user.settings = self.settings(user.name)
+        return user
+
+    def passwords(self):
+        """returns a dictionary of { user: password }"""
+        passwords = {}
+        for user in self.users():            
+            passwords[user] = self.password(user)
+        return passwords
+
+    ### interface methods to be specified by the child class
+
+    def new(self, name, password):
+        """create a new user"""
+
+    def users(self):
+        """returns the ids of all users (generator)"""
+
+    def password(self, user):
+        """return the password for the user"""
+
+    def settings(self, user):
+        """get user settings"""
+
+    def write_settings(self, user, **kw):
+        """set attributes on a user"""
+
+
+class FilespaceUsers(BitsyUsers):
+    """users that live on the filesystem"""
+
+    def __init__(self, directory):
+        BitsyUsers.__init__(self)
+        self.directory = directory # directory to store user information in
+
+    def home(self, user, *path):
+        return os.path.join(self.directory, user, *path)
+
+    def pw_file(self, user):
+        return self.home(user, '.password')
+
+    def secret(self, user):
+        secretfile = self.home(user, '.secret')
+        if os.path.exists(secretfile):
+            secret = int(file(secretfile).read().strip())
+        else:
+            secret = random.randint(1024, 1024**4)
+            secretfile = file(secretfile, 'w')
+            print >> secretfile, secret
+        return secret
+
+    def preferences_file(self, user):
+        return self.home(user, 'preferences.txt')
+
+    def css(self, user, default):
+        css_dir = self.home(user, 'css')
+        css_files = [ i for i in os.listdir(css_dir) if i.endswith('.css') ]
+        if default:
+            default = '%s.css' % default
+            try:
+                index = css_files.index(default)
+                css_files.insert(0, css_files.pop(index))
+            except ValueError:
+                pass
+        return [ dict(filename=i, name=i.rsplit('.css',1)[0],
+                      css=file(os.path.join(css_dir, i)).read())
+                 for i in css_files ]
+
+    ### interfaces for BitsyUsers
+
+    def new(self, name, password):
+        """create a new user account"""
+        # XXX this shouldn't use HTTP exceptions
+
+        if name in self.users():
+            raise exc.HTTPForbidden("The name %s is already taken" % name).exception
+
+        # characters forbidden in user name
+        forbidden = ' |<>./?,'
+        urls = [ 'join', 'login', 'logout', 'css', 'rss', 'atom', 'help' ]
+        if [ i for i in forbidden if i in name ]:
+            raise exc.HTTPForbidden("The name '%s' contains forbidden characters [%s]" % (user, forbidden)).exception
+        if name in urls:
+            raise exc.HTTPForbidden("The name '%s' is already used for a url"  % user).exception
+
+        home = self.home(name)
+        os.mkdir(home)
+        pw_file = file(self.pw_file(name), 'w')
+        print >> pw_file, password
+
+        # setup entries structure for blog
+        entries = os.path.join(home, 'entries') 
+        os.mkdir(entries)
+        for setting in roles['author']:
+            os.mkdir(os.path.join(entries, setting))
+
+        # setup user CSS
+        css_dir = os.path.join(home, 'css') 
+        os.mkdir(css_dir)
+        shutil.copyfile(os.path.join(self.directory, 'site.css'),
+                        os.path.join(css_dir, 'default.css'))
+
+
+    def users(self):
+        ignores = set(['.svn'])
+        for user in os.listdir(self.directory):
+            # ensure integrity of user folder
+            if user in ignores:
+                continue
+            if os.path.isdir(os.path.join(self.directory, user)):
+                yield user
+
+    def password(self, user):
+        pw_file = '.password' # name of the password file on the filesystem
+        password = self.home(user, pw_file)
+        if os.path.exists(password):
+            return file(password).read().strip()
+        return  '' # unspecified password
+
+    def settings(self, name):
+        """returns a dictionary of user preferences from a file"""
+        filename = self.home(name, 'preferences.txt')
+        prefs = {}
+        if os.path.exists(filename):
+            prefs = file(filename).read().split('\n')
+            prefs = [ i for i in prefs if i.strip() ]
+            prefs = [ [ j.strip() for j in i.split(':', 1) ] 
+                      for i in prefs if ':' in i]
+            prefs = dict(prefs)
+
+        # assemble friends from a list
+        friends = prefs.get('Friends') # can see secret blog posts
+        if friends:
+            prefs['Friends'] = friends.split(', ')
+        else:
+            prefs['Friends'] = []
+
+        # CSS files
+        prefs['CSS'] = self.css(name, prefs.get('Stylesheet'))
+        
+        return prefs
+
+    def write_settings(self, name, **kw):
+        """write user settings to disk"""
+        
+        # generic stuff; could factor out
+        
+        newsettings = {}
+        errors = {}
+
+        for setting in settings.form:
+            if kw.has_key(setting.name):
+                try:
+                    setting.set(kw[setting.name])
+                    newsettings[setting.name] = setting.value
+                except settings.InvalidSettingError, e:
+                    errors[setting.name] = str(e)
+
+        if errors:
+            return errors
+
+        # this makes the function depend on implemention
+        # i don't like this
+        new_css = newsettings.pop('CSS file')
+        if new_css:
+            filename = new_css['filename']
+            css_file = file(self.home(name, 'css', filename), 'w')
+            print >> css_file, new_css['css']
+            newsettings['CSS'] = filename.rsplit('.css', 1)[0]
+                
+        prefs = self.settings(name)
+        prefs.update(newsettings)
+
+        # write the preferences to a file
+        preferences = file(self.preferences_file(name), 'w')
+        for key, value in prefs.items():
+            print >> preferences, '%s: %s' % ( key, value )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bitsyblog/utils.py	Sat Sep 12 16:06:57 2009 -0400
@@ -0,0 +1,29 @@
+"""utlity functions for bitsyblog"""
+
+import datetime
+import os
+import urllib
+import urllib2
+
+# format to uniquely label blog posts
+timeformat = ( 'YYYY', 'MM', 'DD', 'HH', 'MM', 'SS' )
+timestamp = '%Y%m%d%H%M%S' # strftime representation
+
+def validate_css(css):
+    """use a webservice to determine if the argument is valid css"""    
+    url = 'http://jigsaw.w3.org/css-validator/validator?text=%s'
+    url = url % urllib.quote_plus(css)
+    foo = urllib2.urlopen(url)
+    text = foo.read()
+    return not 'We found the following errors' in text
+
+def date(datestamp):
+    datestamp = os.path.split(datestamp)[-1]
+    retval = []
+    for i in timeformat:
+        retval.append(int(datestamp[:len(i)]))
+        datestamp = datestamp[len(i):]
+    return datetime.datetime(*retval)
+
+def datestamp(date):
+    return date.strftime(timestamp)
Binary file blog/bitsyblog.png has changed
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/blog/site.css	Sat Sep 12 16:06:57 2009 -0400
@@ -0,0 +1,23 @@
+div { 
+border: thin solid;
+background-color: #edd;
+margin: 1em;
+ }
+
+.site-nav { 
+clear: both;
+list-style-type: none;
+display: inline;
+margin-top: 0em;
+padding-top: 0em;
+ }
+
+.site-nav a { 
+float: left;
+margin-left: 0.3em;
+margin-right: 0.3em;
+ }
+
+.error { 
+color: red; 
+} 
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/blogme.py	Sat Sep 12 16:06:57 2009 -0400
@@ -0,0 +1,102 @@
+#!/usr/bin/env python
+
+import optparse
+import os
+import subprocess
+import sys
+import tempfile
+import urllib2
+
+# global variables
+
+EDITOR='emacs -nw'
+SERVER='http://bitsyblog.biz'
+dotfile='.blogme'
+
+parser = optparse.OptionParser()
+parser.add_option('-s', '--server', default=SERVER)
+parser.add_option('-u', '--user')
+parser.add_option('-p', '--password')
+parser.add_option('--private', action='store_true', default=False)
+parser.add_option('--secret', action='store_true', default=False)
+
+options, args = parser.parse_args()
+
+if options.private and options.secret:
+    print "post can't be secret and private!"
+    sys.exit(1)
+
+# parse dotfile
+
+home = os.environ.get('HOME')
+if home:
+    dotfile = os.path.join(home, dotfile)
+    if os.path.exists(dotfile):
+        prefs = file(dotfile).read().split('\n')
+        prefs = [ i for i in prefs if i.strip() ]
+        prefs = [ [ j.strip() for j in i.split(':', 1) ] for i in prefs
+                  if ':' in i] # probably not necessary
+        prefs = dict(prefs)
+    else:
+        prefs = {}
+
+# determine user name and password
+fields = [ 'user', 'password' ]
+for field in fields:
+    globals()[field] = prefs.get(field)
+
+    optval = getattr(options, field)
+    if optval:
+        password = None # needed to ensure prompting for pw from command line
+        globals()[field] = optval
+
+    if globals()[field] is None:
+        globals()[field] = raw_input('%s: ' % field)
+assert user is not None
+assert password is not None
+
+# write the dotfile if it doesn't exist
+if not os.path.exists(dotfile):
+    preffile = file(dotfile, 'w')
+    print >> preffile, 'user: %s' % user
+    print >> preffile, 'password: %s' % password
+    preffile.close()
+    os.chmod(dotfile, 0600)
+        
+def tmpbuffer(editor=EDITOR):
+    """open an editor and retreive the resulting editted buffer"""
+    tmpfile = tempfile.mktemp(suffix='.txt')
+    cmdline = editor.split()
+    cmdline.append(tmpfile)
+    edit = subprocess.call(cmdline)
+    buffer = file(tmpfile).read().strip()
+    os.remove(tmpfile)
+    return buffer
+
+# get the blog
+
+if args:
+    msg = ' '.join(args)
+else:
+    msg = tmpbuffer()
+
+# open the url
+
+url = '/'.join((options.server, user))
+url += '?auth=digest' # specify authentication method
+
+if options.private:
+    url += '&privacy=private'
+if options.secret:
+    url += '&privacy=secret'
+
+authhandler = urllib2.HTTPDigestAuthHandler()
+authhandler.add_password('bitsyblog', url, user, password)
+opener = urllib2.build_opener(authhandler)
+urllib2.install_opener(opener)
+
+try:
+    url = urllib2.urlopen(url, data=msg)
+    print url.url # print the blog post's url
+except urllib2.HTTPError:
+    pass
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/multiuser.ini	Sat Sep 12 16:06:57 2009 -0400
@@ -0,0 +1,22 @@
+[DEFAULT]
+debug = true
+email_to = jhammel@openplans.org
+smtp_server = localhost
+error_email_from = paste@localhost
+
+[server:main]
+use = egg:Paste#http
+host = 0.0.0.0
+port = 8001
+
+[composite:main]
+use = egg:Paste#urlmap
+/ = bitsyblog
+
+set debug = false
+
+[app:bitsyblog]
+paste.app_factory = bitsyblog.factory:factory
+bitsyblog.file_dir = %(here)s/blog
+bitsyblog.date_format = %H:%M %A, %B %-d, %Y
+bitsyblog.help_file = %(here)s/README.txt
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/roadmap.txt	Sat Sep 12 16:06:57 2009 -0400
@@ -0,0 +1,208 @@
+bitsyblog, middleware, and the roadmap to the future
+
+the nice thing with working like software like bitsyblog is that the
+intent of the software is clear.  bitsyblog aims to provide a solution
+for users to keep a log of their activities on the web.  When thinking of
+software in terms of the narrow intent of a component, it becomes much
+easier to divine what sort of framework would look like around it and
+how this framework might be applicable to other types of software.
+
+Firstly, I noticed that the bitsyblog component app is not as small as
+it could be.  bitsyblog provides a multi-user solution to (personal)
+blogging.  This level of solution should exist, but thinking in terms
+of components it is easy to see that users may want a personal blog
+that is not multi-user.  lammy wants a blog on his desktop computer.
+When at his computer, he probably wants to be logged in all the time
+(always accept connections from 127.0.0.1).  He doesn't want other
+people to be able to make a blog there.  When not at his desktop, he
+wants to be able to login and post through the web and maybe used the
+blogme.py script too. In bitsyblog terms, '/lammy'
+becomes '/' and all urls ('preferences', 'post', 'css/') become
+relative to this.  Doing this involves separating out the individual
+blog components from the multi-user component and probably having
+different .ini files from which to run paste.
+
+I've also realized that the way I handle CSS is a solution to a
+problem that has nothing to do with bitsyblog.  The other items in
+preferences ('date', 'subject', but not 'friends' which has to do with
+roles) are about formatting the blog posts.  The CSS is about themeing
+pages which has nothing to do with blog posts.  So what is needed is a
+CSS component, which is possibly linked to from the preferences page.
+Lets put this at '/lammy/css'.  This pages may have the fields for
+preferences, but also provides an interface for managing the CSS.  A
+user may want to delete CSS or edit them, render the current page with
+a selected stylesheet or what's in the editor, or render an arbitrary web page
+This is like CSS zen garden, but more interactive, and
+(optionally) allowing the user to save the CSS and apply it to their
+pages.  I can also imagine, in a 2.0 version, an implementation that
+allows permits transforming of CSS to suitable to theming a page with
+a stylesheet intended for another page.  One has a stylesheet with a
+bunch of rules.  Using javascript, one mouses over the DOM of the
+other page.  The rules are modified in order to applied to the
+appropriate container on the page.  This sounds much like what
+deliverance is supposed to do, and maybe it could be utilized for this
+intent, though perhaps the intents are different and its better not to
+use it (to be investigated).
+
+Both of the above components fall into a general pattern:  there is a
+component which is the multi-user blog manager.  Lets call this
+bitsyblog.  There are microcomponents, the single-user blog (lets call
+this microblog) and the CSS zen garden component which lets you play
+with stylesheets (I dub thee samadhi).  microblog and samadhi can live
+on their own, in which case they live at '/', or they might be managed
+by an external component -- microblog by bitsyblog, or samadhi
+by...not really sure, either a microblog instance or bitsyblog.  In
+the latter case, they need to know where they live.  I'm not sure what
+provides the rewrite rule telling them where they live.  Ian?  Ethan?
+There are a few configuration type details -- like auth, whether
+samadhi needs to know about where to put these CSS files or how
+bitsyusers can fetch them, but essentially if you give these the
+appropriate path, they should be able to work relatively
+independently.
+
+In a different class of components are comments and tagging.  While it
+is easy to imagine bitsyblog managing comments and tags, it is also
+fairly easy to see that these services really don't need to know what
+is being commented or tagged on.  For commenting, I imagine an app
+similar to smokesignals, but with a different persistence model.  Lets
+call it commentator.  Commentator is a javascript app with no
+intrinsic URLs.  Given a set of rules (for instance, "allow commenting
+on <div>s with an id attribute) a block of text is shown as the last child
+of each <div> (or conditionally on mouseover).  This will initially
+say something like "No comments | Add a comment" where each of these
+do what you think ('N comments' displays the comments, 'Add a comment'
+gives a textarea where you can say your mind).  When a comment is
+added, it is put in a database (presumedly SQL, though really on a
+super-simple db is necessary) with the URL and/or id
+as its key.  When the page is loaded, the text of the page is examined
+on response and the appropriate comments are displayed.  It is also
+necessary to have some sort of mapping optionally between URL and id
+to another URI, as blog posts both are displayed as part of the user's
+entire blog and also have a unique URL.  Notice that nothing here is
+particularly unique to blogs.  Commenting on <div>s or <p>s in a wiki
+page is essentially the same (though in this case, it might be better
+to comment on the entire page, as these may not have ids and the page
+can be editted all willy-nilly like that.  Commenting on individual
+<p>s and <div>s is, however, a really nice thing to have, so in a 2.0
+type incarnation of commentator, this should be added regardless of
+the difficulty).  If bitsyblog was part of a
+site with a wiki, then commentator could deal with them both nicely.
+Optionally, one might specify the rule that if an item had comments,
+it either could not be deleted or that an alert would pop up before
+deletion with [Confirm] and [Cancel] buttons.  This is easy to do if a
+browser could do a DELETE request and things behaved all RESTful.
+Otherwise, this would also be a non-trivial problem.
+
+Tagging is very similar to commentator, though it might have a few
+intrinsic URLs too.  You specify a similar set of rules concerning
+what can be tagged ("all <div>s with ids in /%(user)s/ and
+/%(user)s/200*") and a method pointing the ids to URLs.  Then you have
+a database, which again is pretty simple and again in its simplest
+form could be a dictionary.  Though here, the tags are the keys and
+the URLs are the values.  You could also record number of tags per
+item, specify whether non-authenticated members can tag, etc.  As the
+first or last child of each (say) <div>, you have what it has been
+tagged with, maybe weighted by how many times it has been tagged.  You
+have a URL for searching by tags, maybe on a per-path basis ("I want
+to see all the tags under /k0s that have been tagged with 'cats' and
+'cute'").  You could also have a URL for the tag cloud.  Again, what
+is being tagged is pretty divorced from the tagging component itself
+(lets call it tagit, unless that name is taken).
+
+I have presented samadhi as being managed by bitsyblog or microblog
+and commentator and tagit living outside of bitsyblog in the
+middleware stack.  I've done this because this "feels right" to me
+right now, though I reseve my right to change my mind about it.  The
+thing I want emphasize is the difference in the two approaches:
+
+* When a WSGI app is "managed" by another component app, the manager
+  has to know about the managed app and dispatch the appropriate
+  information (request, etc) to it, though in doing so it has the
+  opportunity to have more control over the URL structure and what the
+  component app does.  It is also easier to return HTML this way.
+
+* When a WSGI "app" is used as middleware and needs to markup the
+  returned page in HTML, this is conducive to using javascript in
+  order to insert nodes into the DOM dynamically, presumedly based on
+  some rule set.  Alternatively, the middleware may decompose the page
+  with lxml and insert HTML where it needs to go that way.  In this
+  approach, the middleware need not have a URL (this may be
+  preferable).
+
+It is not necessarily clear to me at this time which approach is
+better for different apps.  I have put commenting and tagging in the
+latter category because commenting and tagging on web pages seems
+divorced (save by a rule set) from what is being commented on or
+tagged.  I have put microblog in the first category because it is
+clearly in the same layer of software as bitsyblog, and some
+management tasks will be necessary even for microblog.  samadhi I put
+in the first category as well (though I hesistate right now) because
+otherwise the rewriting of URLs makes me nervous.  Perhaps there is
+need of a WSGI dispatcher that fetches apps based on some sort of rule
+concerning the URL?  Also, it seems
+necessary to put the CSS on user data.  This could also be
+accomplished the other way:  1. abstract out members and roles
+(teamroler?) and store the data (CSS, etc) on them here; 2. have
+samadhi write a defined set of urls (how defined?  not sure) including
+the stylesheets that it knows about, presumedly putting the 'foo'
+stylesheet at '/lammy/css/foo.css'.  This still involves samadhi
+knowing something about the url structure, and now it is dependent on
+the user/role thing, though this could be a configuration option.
+
+This is the middleware story.  Other things could also be done
+internally to bitsyblog and its supporting software to make the
+experience more seamless.  
+
+I am bothered by the fact that the logic supporting the form and user
+settings is somewhat divorced.  Ideally, rendering the form should do
+all the validation and return the settings in a sensible way to use.
+I don't think this is hard -- the settings.py should just be
+incorporated into markup.form.  This goes some ways to reinventing
+things like formencode and zope.schema, so if we can get these to do
+what we want, maybe we shouldn't write our own.  But at cursory
+glance, formencode won't and zope.schema isn't easily usable outside
+of zope.  So maybe rolling our own isn't bad.
+
+There's more middleware, but its hidden and is a bad coupling (that
+is, the app will depend on the middleware unless its done cleverly).
+The site nav does not appear on '/login' or '/join' (or any url not
+specified internally to bitsyblog, for that matter).  It would be
+conceivable to have a SiteNav middleware that (either through
+javascript or lxml decomposition) writes the site nav at the top of
+the screen.  bistyblog and bitsyauth would have to know about this
+though, so that they could add the relevent links into environ.  Maybe
+this isn't horrible, but it seems so.  Likewise, the site css doesn't
+show up here.  Should it?  Is this another piece of middleware or part
+of samadhi?
+
+An important piece of middleware should involve members and roles.  I
+really don't want to write this one, but I would like to consume it.
+It should live just inside of bitsyauth and make the users.py file
+unnecessary.  It might be better to sit on this one until last.
+
+So should be bitsyblog 2.0.
+There are a few other streamlining things (see the TODO.txt), but if
+all of this was implemented, bitsyblog would be a rich web experience
+that was highly componentized with each of the components doing one
+thing well.  Moreso, since coupling is avoided, save through
+configuration, auth, and other loose links, each of these components
+could be used independently of bitsyblog or turned off without
+undesirable consequences.
+
+At least as much as being a good blogging app, bitsyblog is about
+elegant design and component architecture.  This roadmap shows what is
+possible in terms of having a fully functional blogging application
+with middleware that can be used outside of just bitsyblog.  To take
+the other end of the spectrum, wordpress says "Hey, write plugins that
+will make me work better."  As a programmer, I really hate this
+approach.  If I write a commenting plugin for wordpress, then I have
+just that.  If I want to use it to comment on a wiki page, I'm out of
+luck.  If I write commenting middleware, then it can be used for any
+WSGI app with minimal configuration.  As a programmer, I generally
+want the freedom to assemble a webapp however I want.  With this
+freedom comes some work, but for me its a worthy sacrifice.  As a web
+consumer, I understand that site administrators want out of the box
+solutions.  bitsyblog will offer this too.  The default deployment of
+bitsyblog will pull down commentator, samadhi, and tagit and have an
+.ini file pointing to the appropriate factory to create the chain of
+middleware to have all of these pieces in place.  There is no contradiction.
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/setup.py	Sat Sep 12 16:06:57 2009 -0400
@@ -0,0 +1,50 @@
+from setuptools import setup, find_packages
+import sys, os
+
+version = '1.1.1'
+
+try:
+    description = file('README.txt').read()
+except IOError:
+    description = """
+Meet bitsyblog.  Posting is done with a POST request, so while you can use
+a web form to do this, its just as easy to use curl, urllib, or anything else 
+to post.
+"""
+
+setup(name='bitsyblog',
+      version=version,
+      description="a tiny tiny blog",
+      long_description=description,
+      classifiers=[], # Get strings from http://pypi.python.org/pypi?%3Aaction=list_classifiers
+      keywords='blog',
+      author='Jeff Hammel',
+      author_email='jhammel@openplans.org',
+      url='http://bitsyblog.biz',
+      license='GPL',
+      packages=find_packages(exclude=['ez_setup', 'examples', 'tests']),
+      include_package_data=True,
+      zip_safe=False,
+      install_requires=[
+          # -*- Extra requirements: -*-
+          'WebOb',
+          'Paste',
+          'PasteScript',
+          'dateutil',
+          'markup',
+          'skimpygimpy',
+          'lxml',
+          'PyRSS2Gen'
+      ],
+      dependency_links=[ 
+        'https://svn.openplans.org/svn/standalone/markup#egg=markup',
+        'http://svn.pythonpaste.org/Paste/trunk#egg=Paste',
+        'http://downloads.sourceforge.net/skimpygimpy/skimpyGimpy_1_3.zip#egg=skimpygimpy',
+        'http://www.dalkescientific.com/Python/PyRSS2Gen-1.0.0.tar.gz#egg=PyRSS2Gen'
+        ],
+      entry_points="""
+      # -*- Entry points: -*-
+      [paste.app_factory]
+      main = bitsyblog.factory:factory
+      """,
+      )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/singleuser.ini	Sat Sep 12 16:06:57 2009 -0400
@@ -0,0 +1,23 @@
+[DEFAULT]
+debug = true
+email_to = jhammel@openplans.org
+smtp_server = localhost
+error_email_from = paste@localhost
+
+[server:main]
+use = egg:Paste#http
+host = 0.0.0.0
+port = 8003
+
+[composite:main]
+use = egg:Paste#urlmap
+/ = bitsyblog
+
+set debug = false
+
+[app:bitsyblog]
+paste.app_factory = bitsyblog.factory:bitsierfactory
+bitsyblog.file_dir = %(here)s/blog
+bitsyblog.date_format = %H:%M %A, %B %-d, %Y
+bitsyblog.help_file = %(here)s/README.txt
+bitsyblog.user = openplans
\ No newline at end of file