# HG changeset patch # User terom # Date 1194541563 0 # Node ID 740133ab63536f5e1e81228ecccf7e08ab44cb0c # Parent 5dbdcb79024bbabbe1661e1b225774c1fc132818 initial code, somewhere between 0.2 and 0.5 diff -r 5dbdcb79024b -r 740133ab6353 degal.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/degal.py Thu Nov 08 17:06:03 2007 +0000 @@ -0,0 +1,685 @@ +#!/usr/bin/env python2.4 +# +# DeGAL - A pretty simple web image gallery +# Copyright (C) 2007 Tero Marttila +# http://marttila.de/~terom/degal/ +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the +# Free Software Foundation, Inc., +# 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. +# + +import os.path, os +import urllib +import logging +import string +from datetime import datetime +import struct +import base64 +import shelve + +import PIL +import PIL.Image + +import utils + +__version__ = '0.2' + +TEMPLATE_DIR='templates' +TEMPLATE_EXT='html' + +logging.basicConfig( + level=logging.INFO, + format="%(name)8s %(levelname)8s %(lineno)3d %(message)s", + +) + +tpl = logging.getLogger('tpl') +index = logging.getLogger('index') +prepare = logging.getLogger('prepare') +render = logging.getLogger('render') + +tpl.setLevel(logging.WARNING) + +#for l in (tpl, index, prepare, render) : +# l.setLevel(logging.DEBUG) + +def readFile (path) : + fo = open(path, 'r') + data = fo.read() + fo.close() + + return data + +class Template (object) : + GLOBALS = dict( + VERSION=__version__, + ) + + def __init__ (self, name) : + self.name = name + self.path = os.path.join(TEMPLATE_DIR, "%s.%s" % (name, TEMPLATE_EXT)) + + tpl.debug("Template %s at %s", name, self.path) + + self.content = readFile(self.path) + + def render (self, **vars) : + content = self.content + + vars.update(self.GLOBALS) + + for name, value in vars.iteritems() : + content = content.replace('' % name, str(value)) + + return content + + def renderTo (self, path, **vars) : + tpl.info("Render %s to %s", self.name, path) + + fo = open(path, 'w') + fo.write(self.render(**vars)) + fo.close() + +gallery_tpl = Template('gallery') +image_tpl = Template('image') + +IMAGE_EXTS = ('jpg', 'jpeg', 'png', 'gif', 'bmp') + +THUMB_DIR = 'thumbs' +PREVIEW_DIR = 'previews' +TITLE_FILE = 'title.txt' + +THUMB_GEOM = (160, 120) +PREVIEW_GEOM = (640, 480) + +DEFAULT_TITLE = 'Image gallery' + +def isImage (fname) : + """ + Is the given filename likely to be an image file? + """ + + fname = fname.lower() + base, ext = os.path.splitext(fname) + ext = ext.lstrip('.') + + return ext in IMAGE_EXTS + +def link (url, title) : + """ + Returns an link tag with the given values + """ + + return "%s" % (urllib.quote(url), title) + +def dirUp (count=1) : + """ + Returns a relative path to the directly count levels above the current one + """ + + if not count : + return '.' + + return os.path.join(*(['..']*count)) + +def readTitleDescr (path) : + """ + Read a title.txt or .txt file + """ + + if os.path.exists(path) : + content = readFile(path) + + if '---' in content : + title, descr = content.split('---', 1) + else : + title, descr = content, '' + + return title.strip(), descr.strip() + + return None, None + +class Folder (object) : + def __init__ (self, name='.', parent=None) : + # the directory name + self.name = name + + # our parent Folder, or None + self.parent = parent + + # the path to this dir, as a relative path to the root of the image gallery, always starts with . + if parent and name : + self.path = parent.pathFor(name) + else : + self.path = name + + # the url-path to the index.html file + self.html_path = self.path + + # dict of fname -> Folder + self.subdirs = {} + + # dict of fname -> Image + self.images = {} + + # our human-friendly title + self.title = None + + # our long-winded description + self.descr = '' + + # is this folder non-empty? + self.alive = None + + # self.images.values(), but sorted by filename + self.sorted_images = [] + + # the ShortURL key to this dir + self.shorturl_code = None + + # were we filtered out? + self.filtered = False + + def pathFor (self, *fnames) : + """ + Return a root-relative path to the given path inside this dir + """ + return os.path.join(self.path, *fnames) + + def index (self, filters=None) : + """ + Look for other dirs and images inside this dir. Filters must be either None, + whereupon all files will be included, or a dict of {filename -> next_filter}. + If given, only filenames that are present in the dict will be indexed, and in + the case of dirs, the next_filter will be passed on to that Folder's index + method. + """ + + index.info("Indexing %s", self.path) + + if filters : + self.filtered = True + + # iterate through listdir + for fname in os.listdir(self.path) : + # the full filesystem path to it + fpath = self.pathFor(fname) + + # ignore dotfiles + if fname.startswith('.') : + index.debug("Skipping dotfile %s", fname) + continue + + # apply filters + if filters : + if fname in filters : + next_filter = filters[fname] + else : + index.debug("Skip `%s' as we have a filter", fname) + continue + else : + next_filter = None + + # recurse into subdirs, but not thumbs/previews + if os.path.isdir(fpath) and fname not in (THUMB_DIR, PREVIEW_DIR) : + index.debug("Found subdir %s", fpath) + f = self.subdirs[fname] = Folder(fname, self) + f.index(next_filter) # recursion + + # handle images + elif os.path.isfile(fpath) and isImage(fname) : + index.debug("Found image %s", fname) + self.images[fname] = Image(self, fname) + + # ignore everything else + else : + index.debug("Ignoring file %s", fname) + + def prepare (self) : + """ + Prepare the dir, i.e. sort+prepare the images, as well as recurse + into subdirs + """ + + prepare.info("Preparing dir %s", self.path) + + # is this folder non-empty? + alive = False + + # only create thumbs/previews dirs if we have images in here + if self.images : + # folder is non-empty + alive = True + prepare.info("Have %d images", len(self.images)) + + # create the thumb/preview dirs if needed + for dir in (THUMB_DIR, PREVIEW_DIR) : + prepare.debug("Checking for existance of %s dir", dir) + path = self.pathFor(dir) + + if not os.path.isdir(path) : + prepare.info("Creating dir %s", path) + os.mkdir(path) + + # sort the images + fnames = self.images.keys() + fnames.sort() + + prev = None + + # link them together and prepare them + for fname in fnames : + img = self.images[fname] + + prepare.debug("Linking %s <-> %s", prev, img) + + img.prev = prev + + if prev : + prev.next = img + + prev = img + + img.prepare() + + # add to the sorted images list + self.sorted_images.append(img) + + # prepare subdirs + if self.subdirs : + prepare.info("Have %d subdirs", len(self.subdirs)) + + # just recurse into them, we're alive if one of them is + for dir in self.subdirs.itervalues() : + if dir.prepare() : + alive = True + + # figure out our title + title_path = self.pathFor(TITLE_FILE) + + title, descr = readTitleDescr(title_path) + + if title : + prepare.info("Found title/descr") + self.title = title + self.descr = descr + alive = True + + # default title for the root dir + elif self.name == '.' : + self.title = 'Index' + + else : + self.title = self.name + + prepare.debug("Our title is '%s'", self.title) + + # lol ded + if not alive : + prepare.info("Dir %s is not alive", self.path) + + self.alive = alive + + return alive + + def getObjInfo (self) : + """ + Metadata for shorturls2.db + """ + return 'dir', self.path, '' + + def linkTag (self) : + """ + A text-link to this dir + """ + + return link(self.path, self.title) + + def breadcrumb (self) : + """ + Returns a [(fname, title)] list of this dir's parent dirs + """ + + f = self + b = [] + d = 0 + + while f : + b.insert(0, (dirUp(d), f.title)) + + d += 1 + f = f.parent + + return b + + def inRoot (self, *fnames) : + """ + Return a relative URL from this dir to the given path in the root dir + """ + + c = len(self.path.split('/')) - 1 + + return os.path.join(*((['..']*c) + list(fnames))) + + def render (self) : + """ + Render the index.html, Images, and recurse into subdirs + """ + + # ded folders are skipped + if not self.alive : + render.info("Skipping dir %s", self.path) + return + + # if this dir's contents were filtered out, then we can't render the index.html, as we aren't aware of all the images in here + if self.filtered : + render.warning("Dir `%s' contents were filtered, so we won't render the gallery index again", self.path) + + else : + # sort the subdirs + subdirs = self.subdirs.items() + subdirs.sort() + + # generate the 's for the subdirs + subdir_linkTags = [link(f.name, f.title) for fname, f in subdirs if f.alive] + + # stick them into a list + if subdir_linkTags : + directories = "" % "\n\t
  • ".join(subdir_linkTags) + else : + directories = '' + + render.info("Rendering %s", self.path) + + # render to index.html + gallery_tpl.renderTo(self.pathFor('index.html'), + STYLE_URL=self.inRoot('style.css'), + BREADCRUMB=" » ".join([link(u, t) for (u, t) in self.breadcrumb()]), + TITLE=self.title, + DIRECTORIES=directories, + CONTENT="".join([i.thumbImgTag() for i in self.sorted_images]), + DESCR=self.descr, + SHORTURL=self.inRoot('s', self.shorturl_code), + SHORTURL_CODE=self.shorturl_code, + ) + + # render images + for img in self.images.itervalues() : + img.render() + + # recurse into subdirs + for dir in self.subdirs.itervalues() : + dir.render() + +class Image (object) : + def __init__ (self, dir, name) : + # the image filename, e.g. DSC3948.JPG + self.name = name + + # the Folder object that we are in + self.dir = dir + + # the relative path from the root to us + self.path = dir.pathFor(name) + + # the basename+ext, e.g. DSCR3948, .JPG + self.base_name, self.ext = os.path.splitext(name) + + # the root-relative paths to the thumb and preview images + self.thumb_path = self.dir.pathFor(THUMB_DIR, self.name) + self.preview_path = self.dir.pathFor(PREVIEW_DIR, self.name) + + # our user-friendly title + self.title = name + + # our long-winded description + self.descr = '' + + # the image before and after us, both may be None + self.prev = self.next = None + + # the name of the .html gallery view thing for this image, *always* self.name + ".html" + self.html_name = self.name + ".html" + + # the root-relative path to the gallery view + self.html_path = self.dir.pathFor(self.html_name) + + # + # Figured out after prepare + # + + # (w, h) tuple + self.img_size = None + + # the ShortURL code for this image + self.shorturl_code = None + + # what to use in the rendered templates, intended to be overridden by subclasses + self.series_act = "add" + self.series_verb = "Add to" + + def prepare (self) : + """ + Generate the thumbnail/preview views if needed, get the image info, and look for the title + """ + + prepare.info("Preparing image %s", self.path) + + # stat the image file to get the filesize and mtime + st = os.stat(self.path) + + self.filesize = st.st_size + self.timestamp = st.st_mtime + + # open the image in PIL to get image attributes + generate thumbnails + img = PIL.Image.open(self.path) + + self.img_size = img.size + + for out_path, geom in ((self.thumb_path, THUMB_GEOM), (self.preview_path, PREVIEW_GEOM)) : + # if it doesn't exist, or it's older than the image itself, generate + if not (os.path.exists(out_path) and os.stat(out_path).st_mtime > self.timestamp) : + prepare.info("Create thumbnailed image at %s with geom %s", out_path, geom) + + # XXX: is this the most efficient way to do this? + out_img = img.copy() + out_img.thumbnail(geom, resample=True) + out_img.save(out_path) + + # look for the metadata file + title_path = self.dir.pathFor(self.base_name + '.txt') + prepare.debug("Looking for title at %s", title_path) + + title, descr = readTitleDescr(title_path) + + if title : + self.title = title + self.descr = descr + + prepare.info("Found title `%s'", self.title) + + def getObjInfo (self) : + """ + Metadata for shorturl2.db + """ + return 'img', self.dir.path, self.name + + def thumbImgTag (self) : + """ + a of this image's thumbnail. Path relative to directory we are in + """ + return link(self.html_name, "%s" % (os.path.join(THUMB_DIR, self.name), self.descr, self.title)) + + def previewImgTag (self) : + """ + a of this image's preview. Path relative to directory we are in + """ + return link(self.name, "%s" % (os.path.join(PREVIEW_DIR, self.name), self.descr, self.title)) + + def linkTag (self) : + """ + a text-link to this image + """ + return link(self.html_name, self.title) + + def breadcrumb (self) : + """ + Returns a [(fname, title)] list of this image's parents + """ + + f = self.dir + b = [(self.html_name, self.title)] + d = 0 + + while f : + b.insert(0, (dirUp(d), f.title)) + + d += 1 + f = f.parent + + return b + + def render (self) : + """ + Write out the .html file + """ + + render.info("Rendering image %s", self.path) + + image_tpl.renderTo(self.html_path, + STYLE_URL=self.dir.inRoot('style.css'), + UP_URL=('.'), + PREV_URL=(self.prev and self.prev.html_name or ''), + NEXT_URL=(self.next and self.next.html_name or ''), + FILE=self.name, + BREADCRUMB=" » ".join([link(u, t) for u, t in self.breadcrumb()]), + TITLE=self.title, + PREVIOUS_THUMB=(self.prev and self.prev.thumbImgTag() or ''), + IMAGE=self.previewImgTag(), + NEXT_THUMB=(self.next and self.next.thumbImgTag() or ''), + DESCRIPTION=self.descr, + IMGSIZE="%dx%d" % self.img_size, + FILESIZE=fmtFilesize(self.filesize), + TIMESTAMP=fmtTimestamp(self.timestamp), + SHORTURL=self.dir.inRoot('s', self.shorturl_code), + SHORTURL_CODE=self.shorturl_code, + SERIES_URL=self.dir.inRoot('series/%s/%s' % (self.series_act, self.shorturl_code)), + SERIES_VERB=self.series_verb, + ) + + def __str__ (self) : + return "Image `%s' in `%s'" % (self.name, self.dir.path) + +def int2key (id) : + """ + Turn an integer into a short-as-possible url-safe string + """ + for type in ('B', 'H', 'I') : + try : + return base64.b64encode(struct.pack(type, id), '-_').rstrip('=') + except struct.error : + continue + + raise Exception("ID overflow: %s" % id) + +def updateShorturlDb (root) : + """ + DeGAL <= 0.2 used a simple key => path mapping, but now we use + something more structured, key => (type, dirpath, fname), where + + type - one of 'img', 'dir' + dirpath - the path to the directory, e.g. '.', './foobar', './foobar/quux' + fname - the filename, one of '', 'DSC9839.JPG', 'this.png', etc. + """ + + db = shelve.open('shorturls2', 'c') + + id = db.get('_id', 1) + + dirqueue = [root] + + # dict of path -> obj + paths = {} + + while dirqueue : + dir = dirqueue.pop(0) + + dirqueue.extend(dir.subdirs.itervalues()) + + if dir.alive : + paths[dir.path] = dir + + for img in dir.images.itervalues() : + paths[img.html_path] = img + + for key in db.keys() : + if key.startswith('_') : + continue + + type, dirpath, fname = db[key] + + path = os.path.join(dirpath, fname) + + try : + paths.pop(path).shorturl_code = key + index.debug("Code for `%s' is %s", path, key) + + except KeyError : + index.debug("Path `%s' in DB does not exist?", path) + + for obj in paths.itervalues() : + key = int2key(id) + id += 1 + + index.info("Alloc code `%s' for `%s'", key, obj.html_path) + + obj.shorturl_code = key + + db[key] = obj.getObjInfo() + + db['_id'] = id + db.close() + +def main (targets=()) : + root_filter = {} + + for target in targets : + f = root_filter + for path_part in target.split('/') : + if path_part : + if path_part not in f : + f[path_part] = {} + f = f[path_part] + + index.debug('Filter: %s', root_filter) + + root = Folder() + root.index(root_filter) + root.prepare() + updateShorturlDb(root) + root.render() + +def fmtFilesize (size) : + return utils.formatbytes(size, forcekb=False, largestonly=True, kiloname='K', meganame='M', bytename='B', nospace=True) + +def fmtTimestamp (ts) : + return datetime.fromtimestamp(ts).strftime("%Y/%m/%d %H:%M") + +if __name__ == '__main__' : + from sys import argv + argv.pop(0) + + main(argv) + diff -r 5dbdcb79024b -r 740133ab6353 series.cgi --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/series.cgi Thu Nov 08 17:06:03 2007 +0000 @@ -0,0 +1,182 @@ +#!/usr/bin/env python2.4 + +import dbm +import cgi +import Cookie +import os, os.path +#import pprint + +import degal + +# +# load request params +# +vars = cgi.FieldStorage() + +# keys = what photos are in the series +keys = vars["keys"].value.split() + +# index = what photo to show, or the index +if 'index' in vars : + index = vars["index"].value +else : + index = None + +if keys and keys[0] in ('add', 'del', 'clear', 'view') or index == 'load' : + cookie = Cookie.SimpleCookie(os.environ.get('HTTP_COOKIE', None)) + act = keys[0] + _keys = keys + + if 'series' in cookie : + keys = cookie["series"].value.split() + else : + keys = [] + + if index == 'load' : + keys = _keys + elif act == 'add' and index not in keys : + keys.append(index) + elif act == 'del' : + keys.remove(index) + elif act == 'clear' : + keys = [] + elif act == 'view' : + pass + + cookie['series'] = ' '.join(keys) + cookie['series']['path'] = '/' + + if keys : + redirect_to = "../%s/" % ('+'.join(keys)) + else : + redirect_to = "../.." + + print "Status: 302" + print "Location: %s" % redirect_to + print cookie + print + print "Redirect..." +else : + cookie = Cookie.SimpleCookie(os.environ.get('HTTP_COOKIE', None)) + + my_series = 'series' in cookie and cookie['series'].value.split() == keys + + if index : + index = int(index) + + # + # load DB + # + db = dbm.open('shorturls', 'r') + + # + # get the Image objects + # + photos = [] + + INDEX_URL = "/series/%s/" + IMAGE_URL = "/series/%s/%d" + + # monkey-patch the templates + rendered_templates = [] + + def _myRenderTo (self, path, **vars) : + rendered_templates.append(self.render(**vars)) + + degal.Template.renderTo = _myRenderTo + + # our own version of Folder + class Series (degal.Folder) : + def __init__ (self) : + super(Series, self).__init__() + + self.alive = True + self.filtered = False + self.subdirs = {} + self.images = {} + self.sorted_images = [] + self.title = "Series" + + if my_series : + self.descr = 'Clear your series' + else : + self.descr = 'Load as your series' + + self.shorturl_code = '' + + def breadcrumb (self) : + return [('../..', 'Gallery'), ('.', 'Series')] + + def inRoot (self, *fnames) : + return os.path.join('../..', *fnames) + + # def pathFor (self, *fnames) : + # return os.path.join(fnames) + + class Image (degal.Image) : + def __init__ (self, series, key, i) : + path = db[key] + path = path.rstrip(".html") + self.dir_name, self.image_name = os.path.split(path) + + super(Image, self).__init__(series, self.image_name) + + self.path = path + self.shorturl_code = key + self.html_path = self.html_name = str(i + 1) + self.title = 'Image %d' % (i + 1, ) + self.descr = 'Standalone image: %s' % (self._path(), self._path().lstrip('./')) + self.img_size = (-1, -1) + self.filesize = 0 + self.timestamp = 0 + + if my_series : + self.series_act = "del" + self.series_verb = "Remove from" + else : + self.series_act = self.series_verb = "" + + def breadcrumb (self) : + return [('../..', 'Gallery'), ('.', 'Series'), (self.html_name, self.title)] + + def _path (self, blaa='') : + return os.path.join('../..', self.dir_name, blaa, self.image_name) + + def thumbImgTag (self) : + return degal.link(self.html_name, "%s" % (os.path.join('../..', self.dir_name, degal.THUMB_DIR, self.image_name), '', self.title)) + + def previewImgTag (self) : + return degal.link(os.path.join('../..', self.dir_name, self.image_name), "%s" % (os.path.join('../..', self.dir_name, degal.PREVIEW_DIR, self.image_name), '', self.title)) + + + + series = Series() + + try : + prev = None + + for i, key in enumerate(keys) : + img = Image(series, key, i) + + if prev : + prev.next = img + + img.prev = prev + prev = img + + # series.images[str(i + 1)] = img + series.sorted_images.append(img) + finally : + db.close() + + if index : + img = series.sorted_images[index - 1] + + img.render() + else : + series.render() + + print "Content-Type: text/html" + print + print rendered_templates[0] + diff -r 5dbdcb79024b -r 740133ab6353 shorturl.cgi --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/shorturl.cgi Thu Nov 08 17:06:03 2007 +0000 @@ -0,0 +1,22 @@ +#!/usr/bin/env python2.5 + +import dbm +import cgi +import os, os.path +#import pprint + +vars = cgi.FieldStorage() + +key = vars['key'].value +db = dbm.open('shorturls', 'r') + +try : + path = db[key] +finally : + db.close() + +print "Status: 302" +print "Location: ../%s" % path +print +print "../%s" % path + diff -r 5dbdcb79024b -r 740133ab6353 style.css --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/style.css Thu Nov 08 17:06:03 2007 +0000 @@ -0,0 +1,57 @@ +body { + background-color: #333333; + color: #cccccc; + font-family: "Arial", sans-serif; + font-size: small; +} + +a { + color: #ff8800; + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +#thumbnails, #image, #description, h1 { + text-align: center; +} + +#thumbnails img { + margin: 0.2em; +} + +img { + border: 1px solid #666666; +} + +a:focus img { + border: 1px solid #cccccc; +} + +img:hover, a:focus img:hover { + border: 1px solid #ff8800; +} + +div#breadcrumb { + +} + +div#info { + font-size: x-small; + color: #666666; +} + +div#info p { + padding: 0px; + margin: 0px; +} + +p#about { + padding-top: 50px; + font-size: xx-small; + text-align: center; + +} + diff -r 5dbdcb79024b -r 740133ab6353 templates/gallery.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/templates/gallery.html Thu Nov 08 17:06:03 2007 +0000 @@ -0,0 +1,23 @@ + + + + + + <!-- TITLE --> + + + + +

    +
    +
    + +
    +

    +
    +

    ShortURL:

    +
    +

    DeGAL

    + + diff -r 5dbdcb79024b -r 740133ab6353 templates/image.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/templates/image.html Thu Nov 08 17:06:03 2007 +0000 @@ -0,0 +1,30 @@ + + + + + + <!-- TITLE --> + + + + + + + +
    +

    +

    +

    +
    +
    +

    +

    +

    +

    +

    ShortURL:

    +

    series

    +
    +

    DeGAL

    + +