degal.py
author terom
Thu, 08 Nov 2007 19:26:00 +0000
changeset 6 d9d1f8e5f384
parent 5 156cdfffef8e
child 7 235ae238f694
permissions -rwxr-xr-x
fix alignment of teh paginator, and make shorturl dirs better (no index.html + pagination)
#!/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('<!-- %s -->' % 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'

# how many image/page
IMAGE_COUNT = 50

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 <a href=""></a> link tag with the given values
    """

    return "<a href='%s'>%s</a>" % (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 <imgname>.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, 'index'

    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 _page_fname (self, page) :
        assert page >= 0

        if page > 0 :
            return  'index_%d.html' % page
        else :
            return 'index.html'

    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 <a href=""></a>'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 = "<ul>\n\t<li>%s</li>\n</ul>" % "</li>\n\t<li>".join(subdir_linkTags)
            else :
                directories = ''

            render.info("Rendering %s", self.path)

            # paginate!
            images = self.sorted_images
            image_count = len(images)
            pages = []
            
            while images :
                pages.append(images[:IMAGE_COUNT])
                images = images[IMAGE_COUNT:]

            pagination_required = len(pages) > 1

            if pagination_required :
                render.info("Index split into %d pages of %d images each", len(pages), IMAGE_COUNT)
            
            for cur_page, page in enumerate(pages) :
                if pagination_required :
                    pagination = "<ul>" # <li>Showing Images %d - %d of %d</li>" % (cur_page*IMAGE_COUNT, cur_page*IMAGE_COUNT+len(page), image_count)
                    
                    if cur_page > 0 :
                        pagination += '<li><a href="%s">&laquo; Prev</a></li>' % (self._page_fname(cur_page - 1))
                    else :
                        pagination += '<li><span>&laquo; Prev</span></li>'

                    for x in xrange(0, len(pages)) :
                        if x == cur_page :
                            pagination += '<li><strong>%s</strong></li>' % (x + 1)
                        else :
                            pagination += '<li><a href="%s">%s</a></li>' % (self._page_fname(x), x+1)

                    if cur_page < len(pages) - 1 :
                        pagination += '<li><a href="%s">Next &raquo;</a></li>' % (self._page_fname(cur_page + 1))
                    else :
                        pagination += '<li><span>Next &raquo;</span></li>'

                    pagination += "</ul>"
                    shorturl = "%s/%s" % (self.shorturl_code, cur_page+1)
                else :
                    pagination = ''
                    shorturl = self.shorturl_code

                # render to index.html
                gallery_tpl.renderTo(self.pathFor(self._page_fname(cur_page)), 
                    STYLE_URL=self.inRoot('style.css'),
                    BREADCRUMB=" &raquo; ".join([link(u, t) for (u, t) in self.breadcrumb()]),
                    TITLE=self.title,
                    DIRECTORIES=directories,
                    PAGINATION=pagination,
                    CONTENT="".join([i.thumbImgTag() for i in page]),
                    DESCR=self.descr,
                    SHORTURL=self.inRoot('s', shorturl),
                    SHORTURL_CODE=shorturl,
                )
        
        # 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 <a><img /></a> of this image's thumbnail. Path relative to directory we are in
        """
        return link(self.html_name, "<img src='%s' alt='%s' title='%s' />" % (os.path.join(THUMB_DIR, self.name), self.descr, self.title))

    def previewImgTag (self) :
        """
            a <a><img /></a> of this image's preview. Path relative to directory we are in
        """
        return link(self.name, "<img src='%s' alt='%s' title='%s' />" % (os.path.join(PREVIEW_DIR, self.name), self.descr, self.title))

    def linkTag (self) :
        """
            a <a></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=" &raquo; ".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', writeback=True)
    
    id = db.get('_id', 1)

    dirqueue = [root]

    # dict of path -> obj
    paths = {}

    index.info("Processing ShortURLs...")

    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.path] = img

    for key in db.keys() :
        if key.startswith('_') :
            continue

        type, dirpath, fname = db[key]
        
        path = os.path.join(dirpath, fname).rstrip('/')

        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)