#!/usr/bin/env python #coding: utf8 # blitiri - A single-file blog engine. # Alberto Bertogli (albertito@gmail.com) # # Configuration section # # You can edit these values, or create a file named "config.py" and put them # there to make updating easier. The ones in config.py take precedence. # # Directory where entries are stored data_path = "/tmp/blog/data" # Path where templates are stored. Use an empty string for the built-in # default templates. If they're not found, the built-in ones will be used. templates_path = "/tmp/blog/templates" # URL to the blog, including the name. Can be a full URL or just the path. blog_url = "/blog/blitiri.cgi" # Style sheet (CSS) URL. Can be relative or absolute. To use the built-in # default, set it to blog_url + "/style". css_url = blog_url + "/style" # Blog title title = "I don't like blogs" # Default author author = "Hartmut Kegan" # Article encoding encoding = "utf8" # # End of configuration # DO *NOT* EDIT ANYTHING PAST HERE # import sys import os import time import datetime import calendar import zlib import urllib import cgi from docutils.core import publish_parts # Before importing the config, add our cwd to the Python path sys.path.append(os.getcwd()) # Load the config file, if there is one try: from config import * except: pass # Pimp *_path config variables to support relative paths data_path = os.path.realpath(data_path) templates_path = os.path.realpath(templates_path) # Default template default_main_header = """\ %(title)s

%(title)s

""" default_main_footer = """
""" default_article_header = """

%(arttitle)s

by %(author)s on %(cyear)04d-\ %(cmonth)02d-\ %(cday)02d\ %(chour)02d:%(cminute)02d (updated on %(uyear)04d-\ %(umonth)02d-\ %(uday)02d\ %(uhour)02d:%(uminute)02d)
tagged %(tags)s

""" default_article_footer = """

""" # Default CSS default_css = """ body { font-family: sans-serif; font-size: small; width: 52em; } div.content { width: 96%; } h1 { font-size: large; border-bottom: 2px solid #99F; width: 100%; margin-bottom: 1em; } h2 { font-size: medium; font-weigth: none; margin-bottom: 1pt; border-bottom: 1px solid #99C; } h1 a, h2 a { text-decoration: none; color: black; } span.artinfo { font-size: xx-small; } span.artinfo a { text-decoration: none; color: #339; } span.artinfo a:hover { text-decoration: none; color: blue; } div.artbody { margin-left: 1em; } div.article { margin-bottom: 2em; } div.footer { margin-top: 1em; padding-top: 0.4em; width: 100%; border-top: 2px solid #99F; font-size: x-small; } div.footer a { text-decoration: none; } /* Articles are enclosed in
*/ div.section h1 { font-size: small; font-weigth: none; width: 100%; margin-bottom: 1pt; border-bottom: 1px dotted #99C; } """ # helper functions def rst_to_html(rst): settings = { 'input_encoding': encoding, 'output_encoding': 'utf8', } parts = publish_parts(rst, settings_overrides = settings, writer_name = "html") return parts['body'].encode('utf8') def sanitize(obj): if isinstance(obj, basestring): return cgi.escape(obj, True) return obj # find out our URL, needed for syndication try: n = os.environ['SERVER_NAME'] p = os.environ['SERVER_PORT'] s = os.environ['SCRIPT_NAME'] if p == '80': p = '' else: p = ':' + p full_url = 'http://%s%s%s' % (n, p, s) except KeyError: full_url = 'Not needed' class Templates (object): def __init__(self, tpath, db, showyear = None): self.tpath = tpath self.db = db now = datetime.datetime.now() if not showyear: showyear = now.year self.vars = { 'css_url': css_url, 'title': title, 'url': blog_url, 'fullurl': full_url, 'year': now.year, 'month': now.month, 'day': now.day, 'showyear': showyear, 'monthlinks': ' '.join(db.get_month_links(showyear)), 'yearlinks': ' '.join(db.get_year_links()), } def get_template(self, page_name, default_template, extra_vars = None): if extra_vars is None: vars = self.vars else: vars = self.vars.copy() vars.update(extra_vars) p = '%s/%s.html' % (self.tpath, page_name) if os.path.isfile(p): return open(p).read() % vars return default_template % vars def get_main_header(self): return self.get_template('header', default_main_header) def get_main_footer(self): return self.get_template('footer', default_main_footer) def get_article_header(self, article): return self.get_template( 'art_header', default_article_header, article.to_vars()) def get_article_footer(self, article): return self.get_template( 'art_footer', default_article_footer, article.to_vars()) class Article (object): def __init__(self, path, created = None, updated = None): self.path = path self.created = created self.updated = updated self.uuid = "%08x" % zlib.crc32(self.path) self.loaded = False # loaded on demand self._title = 'Removed post' self._author = author self._tags = [] self._raw_content = '' def get_title(self): if not self.loaded: self.load() return self._title title = property(fget = get_title) def get_author(self): if not self.loaded: self.load() return self._author author = property(fget = get_author) def get_tags(self): if not self.loaded: self.load() return self._tags tags = property(fget = get_tags) def get_raw_content(self): if not self.loaded: self.load() return self._raw_content raw_content = property(fget = get_raw_content) def __cmp__(self, other): if self.path == other.path: return 0 if not self.created: return 1 if not other.created: return -1 if self.created < other.created: return -1 return 1 def title_cmp(self, other): return cmp(self.title, other.title) def load(self): # os.path.join ignore other paths if one starts with a slash filename = os.path.join(data_path, self.path[1:]) try: raw = open(filename).readlines() except: return count = 0 for l in raw: if ':' in l: name, value = l.split(':', 1) if name.lower() == 'title': self._title = value.strip() elif name.lower() == 'author': self._author = value.strip() elif name.lower() == 'tags': ts = value.split(',') ts = [t.strip() for t in ts] self._tags = set(ts) elif l == '\n': # end of header break count += 1 self._raw_content = ''.join(raw[count + 1:]) self.loaded = True def to_html(self): return rst_to_html(self.raw_content) def to_vars(self): return { 'arttitle': sanitize(self.title), 'author': sanitize(self.author), 'date': self.created.isoformat(' '), 'uuid': self.uuid, 'tags': self.get_tags_links(), 'created': self.created.isoformat(' '), 'ciso': self.created.isoformat(), 'cyear': self.created.year, 'cmonth': self.created.month, 'cday': self.created.day, 'chour': self.created.hour, 'cminute': self.created.minute, 'csecond': self.created.second, 'updated': self.updated.isoformat(' '), 'uiso': self.updated.isoformat(), 'uyear': self.updated.year, 'umonth': self.updated.month, 'uday': self.updated.day, 'uhour': self.updated.hour, 'uminute': self.updated.minute, 'usecond': self.updated.second, } def get_tags_links(self): l = [] tags = list(self.tags) tags.sort() for t in tags: l.append('%s' % \ (blog_url, urllib.quote(t), sanitize(t) )) return ', '.join(l) class ArticleDB (object): def __init__(self, dbpath): self.dbpath = dbpath self.articles = [] self.uuids = {} self.actyears = set() self.actmonths = set() self.load() def get_articles(self, year = 0, month = 0, day = 0, tags = None): l = [] for a in self.articles: if year and a.created.year != year: continue if month and a.created.month != month: continue if day and a.created.day != day: continue if tags and not tags.issubset(a.tags): continue l.append(a) return l def get_article(self, uuid): return self.uuids[uuid] def load(self): try: f = open(self.dbpath) except: return for l in f: # Each line has the following comma separated format: # path (relative to data_path), \ # created (epoch), \ # updated (epoch) try: l = l.split(',') except: continue a = Article(l[0], datetime.datetime.fromtimestamp(float(l[1])), datetime.datetime.fromtimestamp(float(l[2]))) self.uuids[a.uuid] = a self.actyears.add(a.created.year) self.actmonths.add((a.created.year, a.created.month)) self.articles.append(a) def save(self): f = open(self.dbpath + '.tmp', 'w') for a in self.articles: s = '' s += a.path + ', ' s += str(time.mktime(a.created.timetuple())) + ', ' s += str(time.mktime(a.updated.timetuple())) + '\n' f.write(s) f.close() os.rename(self.dbpath + '.tmp', self.dbpath) def get_year_links(self): yl = list(self.actyears) yl.sort(reverse = True) return [ '%d' % (blog_url, y, y) for y in yl ] def get_month_links(self, year): am = [ i[1] for i in self.actmonths if i[0] == year ] ml = [] for i in range(1, 13): name = calendar.month_name[i][:3] if i in am: s = '%s' % \ ( blog_url, year, i, name ) else: s = name ml.append(s) return ml # # Main # def render_html(articles, db, actyear = None): template = Templates(templates_path, db, actyear) print 'Content-type: text/html; charset=utf-8\n' print template.get_main_header() for a in articles: print template.get_article_header(a) print a.to_html() print template.get_article_footer(a) print template.get_main_footer() def render_artlist(articles, db, actyear = None): template = Templates(templates_path, db, actyear) print 'Content-type: text/html; charset=utf-8\n' print template.get_main_header() print '

Articles

' for a in articles: print '
  • %(title)s
  • ' \ % { 'url': blog_url, 'uuid': a.uuid, 'title': a.title, 'author': a.author, } print template.get_main_footer() def render_atom(articles): if len(articles) > 0: updated = articles[0].updated.isoformat() else: updated = datetime.datetime.now().isoformat() print 'Content-type: application/atom+xml; charset=utf-8\n' print """ %(title)s %(url)s %(updated)sZ """ % { 'title': title, 'url': full_url, 'updated': updated, } for a in articles: vars = a.to_vars() vars.update( { 'url': full_url, 'contents': a.to_html(), } ) print """ %(arttitle)s %(author)s %(url)s/post/%(uuid)s %(arttitle)s %(ciso)sZ %(uiso)sZ

    %(contents)s

    """ % vars print "
    " def render_style(): print 'Content-type: text/css\r\n\r\n', print default_css def handle_cgi(): import cgitb; cgitb.enable() form = cgi.FieldStorage() year = int(form.getfirst("year", 0)) month = int(form.getfirst("month", 0)) day = int(form.getfirst("day", 0)) tags = set(form.getlist("tag")) uuid = None atom = False style = False post = False artlist = False if os.environ.has_key('PATH_INFO'): path_info = os.environ['PATH_INFO'] style = path_info == '/style' atom = path_info == '/atom' tag = path_info.startswith('/tag/') post = path_info.startswith('/post/') artlist = path_info.startswith('/list') if not style and not atom and not post and not tag \ and not artlist: date = path_info.split('/')[1:] try: if len(date) > 1 and date[0]: year = int(date[0]) if len(date) > 2 and date[1]: month = int(date[1]) if len(date) > 3 and date[2]: day = int(date[2]) except ValueError: pass elif post: uuid = path_info.replace('/post/', '') uuid = uuid.replace('/', '') elif tag: t = path_info.replace('/tag/', '') t = t.replace('/', '') t = urllib.unquote_plus(t) tags = set((t,)) db = ArticleDB(os.path.join(data_path, 'db')) if atom: articles = db.get_articles(tags = tags) articles.sort(reverse = True) render_atom(articles[:10]) elif style: render_style() elif post: render_html( [db.get_article(uuid)], db, year ) elif artlist: articles = db.get_articles() articles.sort(cmp = Article.title_cmp) render_artlist(articles, db) else: articles = db.get_articles(year, month, day, tags) articles.sort(reverse = True) if not year and not month and not day and not tags: articles = articles[:10] render_html(articles, db, year) def usage(): print 'Usage: %s {add|rm|update} article_path' % sys.argv[0] def handle_cmd(): if len(sys.argv) != 3: usage() return 1 cmd = sys.argv[1] art_path = os.path.realpath(sys.argv[2]) if os.path.commonprefix([data_path, art_path]) != data_path: print "Error: article (%s) must be inside data_path (%s)" % \ (art_path, data_path) return 1 art_path = art_path[len(data_path):] db_filename = os.path.join(data_path, 'db') if not os.path.isfile(db_filename): open(db_filename, 'w').write('') db = ArticleDB(db_filename) if cmd == 'add': article = Article(art_path, datetime.datetime.now(), datetime.datetime.now()) for a in db.articles: if a == article: print 'Error: article already exists' return 1 db.articles.append(article) db.save() elif cmd == 'rm': article = Article(art_path) for a in db.articles: if a == article: break else: print "Error: no such article" return 1 db.articles.remove(a) db.save() elif cmd == 'update': article = Article(art_path) for a in db.articles: if a == article: break else: print "Error: no such article" return 1 a.updated = datetime.datetime.now() db.save() else: usage() return 1 return 0 if os.environ.has_key('GATEWAY_INTERFACE'): handle_cgi() else: sys.exit(handle_cmd())