diff bitsyblog/bitsyblog.py @ 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 1368be6c3b70
line wrap: on
line diff
--- /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'})