#!/usr/bin/perl -w
=head1 NAME

uffizi - Generate photo galleries from directories of image files

=head1 SYNOPSIS

uffizi [options] directory ...

    (type 'uffizi --help' for option listing)

=head1 DESCRIPTION

I've tried lots of 'photo album' web apps.  All are either (a) CGI
or similar 'fried' server-side code, which I don't want to use for
something as simple as a photo album, or (b) are inflexible and
ugly in their output.  So here's YA album script.

Its good points are:

=over 4

=item very self-contained, apart from dependencies on C<Image::Size> and the ImageMagick C<convert> command

=item fast, efficient incremental rebuilding

=item generates full CSS-styled, templated and valid HTML

=item every part of the generated HTML can be modified through the templates

=item generates reasonably-sized images as well as thumbnails, with a link to the full-sized image

=back

Its bad points:

=over 4

=item it's written in perl.

=back

But if you ask me, that's a good point ;)

=head1 NOTES

Two external helper applications will be used if available; C<jpegtran> is used
to make progressive jpeg images. C<jhead> is used to copy over EXIF metadata
into scaled image copies.   If either or both are not available, the script
will skip that functionality and otherwise work perfectly.

=head1 CREDITS

It owes quite a bit of inspiration regarding the basic HTML layout, output
filesystem structure, and so on to C<album> from 'Dave's Marginal Hacks'
(C<http://MarginalHacks.com/>).  (thx Dave, nice script!)

The name is a reference to Florence's Galleria degli Uffizi
(C<http://www.uffizi.firenze.it/>), because this app generates galleries
of images ;)

=head1 AUTHOR

Justin Mason, uffizi at jmason dot org

=head1 VERSION

0.5 Jan  4 2006 jm

=cut

my $GENERATOR = "uffizi/0.4 (http://jmason.org/software/uffizi/)";

my $KNOWN_IMG_EXTNS = qr{
    jpe?g | jpe | gif | png | bmp | icon? | miff |
    mpeg | p[bgpn]m | tiff?
}ix;

my $DEFAULTS = {
    up_name         => 'Albums',
    title_prefix    => 'Album: ',
    index_filename  => 'index.html',
    toplevel_reverse_order => 1,
    clean           => 0,   # rebuild from scratch

    target          => '',
    metadata        => 'metadata.txt',

    jpegtran        => 'jpegtran',
    jhead           => 'jhead',

    thumbnail_format => 'jpg',
    thumbnail_convert_args => 'convert -scale __WIDTH__x__HEIGHT__ '.
                                '-border 1x1 -bordercolor black',
    thumbnail_convert_args_jpg => '-quality 70% -interlace Line',
    thumbnail_convert_args_png => '',
    thumbnail_max_h => 200, # max height for thumbs
    thumbnail_max_w => 200, # max width for thumbs

    scaled_format   => 'jpg',
    scaled_convert_args => 'convert -scale __WIDTH__x__HEIGHT__',
    scaled_convert_args_jpg => '-quality 95% -interlace Line',
    scaled_convert_args_png => '',
    scaled_max_h    => 640, # max height for scaled images
    scaled_max_w    => 640, # max width for scaled images

    contents_columns => 3,  # columns in the "contents" table
    fastnav_columns => 10   # columns in the "fast navigation" table
};

###########################################################################

use File::Find;
use Image::Size;
use strict;

my %template = ();
my %found_dirs = ();
my %found_metadata = ();
# my %found_thumbs = ();
my %toplevel_images = ();
my %cached_imgsizes = ();
my @dirs;
my %images;
my %metadata = ();
my @uffizi_metadata_dirs = ();
my %uffizi_metadata = ();

my $gendate = scalar localtime;

my $ctrl = $DEFAULTS;
use Getopt::Long;

use vars qw(
    $opt_help $opt_dumptemplates $opt_template
);

my %opts = (
  'help|h' => \$opt_help,
  'dumptemplates' => \$opt_dumptemplates,
  'template=s' => \$opt_template
);
foreach my $ctrlitem (sort keys %{$ctrl}) {
  $opts{"$ctrlitem=s"} = \$ctrl->{$ctrlitem};
}
GetOptions (%opts) or usage();
usage() if ($opt_help);
dump_templates() if ($opt_dumptemplates);
usage() if (scalar @ARGV <= 0);

check_helpers();
read_template();
read_target();
search_dirs();
read_metadata();
sort_images();
gen_thumbs();
gen_toplevel();
exit;

sub read_target {
  my $tgt = $ctrl->{target};
  return unless $tgt;

  my $html;
  if (-f $tgt && open IN, "<$tgt") {
    $html = join('', <IN>); close IN;
  }
  elsif ($tgt =~ /^https?:/i) {
    print "target HTTP GET: $tgt\n";
    eval q{
      use LWP::Simple;
      $html = get $tgt;
      if (!defined($html)) {
        getprint $tgt;
        die "GET failed";
      }
    };
    if ($@) {
      die "GET $tgt failed: $@ $!";
    }
  }

  return unless $html =~ m{
    <!--\s*<uffizi_metadata>(.*?)</uffizi_metadata>\s*-->
  }isx;

  foreach my $line (split(/\n/s, $1)) {
    $line =~ s/^\s+//s;
    $line =~ s/\s+$//s;
    my %mdset = ();
    foreach my $mditem (split(/\|/, $line)) {
      next unless ($mditem =~ /^(.*?)=(.*)$/);
      $mdset{$1} = $2;
    }

    next unless defined($mdset{dir});
    print "remote set: $mdset{dir}\n";
    push @uffizi_metadata_dirs, $mdset{dir};
    $uffizi_metadata{$mdset{dir}} = \%mdset;
  }
}

###########################################################################

sub usage {
  my $opts =<<EOUSAGE;
  --help           display help message
  --dumptemplates  output the default template file to STDOUT and exit
  --template file  read templates from 'file', instead of using default
EOUSAGE

  foreach my $ctrlitem (sort keys %{$ctrl}) {
    $opts .= "  --$ctrlitem (default: \"".$ctrl->{$ctrlitem}."\")\n";
  }
  die "usage: uffizi [options] directory ...\n\n".$opts."\n";
}

###########################################################################

sub check_helpers {
  foreach my $helper (qw(jpegtran jhead))
  {
    my $hpath = $ctrl->{$helper};
    if ($hpath =~ /\//) {
      next if (-x $hpath);

    } else {
      # in the path
      system ("$helper < /dev/null > /dev/null 2>&1");
      next if ($?>>8 != 127);   # 127 == "command not found"
    }

    $ctrl->{$helper} = '';      # unset, so it's not used
  }
}

###########################################################################

sub read_template {
  my $tmpltext;
  if ($opt_template) {
    open (IN, "<$opt_template")
            or die "cannot read $opt_template";
    $tmpltext = join ('', <IN>);
    close IN;
  } else {
    $tmpltext = join ('', <DATA>);
  }

  $tmpltext =~ s/^.*?<uffizitheme>//is;
  $tmpltext =~ s/<\/uffizitheme>.*?$//is;

  1 while ($tmpltext =~ s{
            \s*
            (?:<!--.*?-->\s*)*
            <templatechunk\s+name=[\"\'](\S+)[\"\']\s*>\s*
            (.*?)\s*
            <\/\s*templatechunk\s*>\s*
         }{ parse_tmpl_block($1, $2); }exsig);

  if ($tmpltext =~ /\S/) {
    die "failed to parse template: unparseable: '$tmpltext'\n";
  }
}

sub dump_templates {
  while (<DATA>) {
    print STDOUT;
  }
  exit;
}

sub parse_tmpl_block {
  my ($name, $chunk) = @_;
  $template{$name} = $chunk;
  return '';
}

sub search_dirs {
  foreach my $dir (@ARGV) {
    File::Find::find ({ wanted => \&wanted, no_chdir => 1 }, $dir);
  }
}

sub wanted {
  return unless (-f $_);
  return if ($File::Find::name =~ /[\/\\](?:\.xvpics)[\/\\]/);

  if ($File::Find::name =~ /^(.*)[\/\\]tn[\/\\](.+)\.index\.html/) {
    # $found_thumbs{$1} ||= { };
    # $found_thumbs{$1}->{$2} = 1;
  }
  elsif ($File::Find::name =~ /^(.*)[\/\\]tn[\/\\](.+\.${KNOWN_IMG_EXTNS})$/) {
    # $found_thumbs{$1} ||= { };
    # $found_thumbs{$1}->{$2} = 1;
  }
  elsif ($File::Find::name =~ /^(.*)[\/\\](.+\.${KNOWN_IMG_EXTNS})$/) {
    $found_dirs{$1} ||= [ ];
    push (@{$found_dirs{$1}}, {
                name => $2,
                dir => $1,
                textname => name_to_text ($2)
            });
  } elsif ($File::Find::name =~ /^(.*)[\/\\]([^\/\\]+)$/) {
    my $dir = $1;
    my $fname = lc $2;
    my $mdata = lc $ctrl->{metadata};       # lowercase both
    if ($fname eq $mdata) {
      $found_metadata{$dir} = $fname;
    }
  } else {
    # warn "ignored $File::Find::name";
  }
}

sub read_metadata {
  foreach my $dir (keys %found_metadata) {
    my $metafname = $found_metadata{$dir};
    my ($k, $v);
    if (open (IN, "<$dir/$metafname")) {
      while (<IN>) {
        s/\#.*$//; s/^\s+//; s/\s+$//;
        next if (/^$/);

        if (/(.+?)\s*[\t\|\,\:\=]\s*(.*)$/) {
          $k = $1; $v = $2;
        }
        elsif (/^(\S+)\s*(.*)$/) {
          $k = $1; $v = $2;
        }
        else {
          warn "$dir/$metafname: unparsed line: $_\n";
          next;
        }
        $metadata{"$dir/$k"} = $v;
      }
      close IN;
    }
  }
}

sub dir_name_to_text {
  my $dir = shift;
  if ($dir eq '.') {
    return '';
  } else {
    return name_to_text ($dir);
  }
}

sub name_to_text {
  my $fname = shift;
  $fname =~ s/[- _]+/ /gs;
  $fname =~ s/\.${KNOWN_IMG_EXTNS}$//g;
  return $fname;
}

sub sort_images {
  @dirs = sort { $a cmp $b } keys %found_dirs;
  foreach my $dir (@dirs) {
    sort_images_1_dir($dir);
  }
}

sub sort_images_1_dir {
  my $dir = shift;

  print "dir: $dir\n";

  # my $thumbs = $found_thumbs{$dir};
  my @ary = sort {
              $a->{name} cmp $b->{name}
          } @{$found_dirs{$dir}};

  my $list = [ ];
  my $prev;
  my $next;
  my $i;

  my $numitems = scalar @ary;
  for ($i = 0; $i < $numitems; $i++) {
    my $image = $ary[$i];
    my $nextimg = $ary[($i+1) % $numitems];
    my $previmg = $ary[($i-1) % $numitems];
    my $obj = {
      previmg => $previmg,
      nextimg => $nextimg,
      thisimg => $image,
    };

    my $mdata = $metadata{$dir."/".$image->{name}};
    if ($mdata) {
      $image->{description} = $mdata;
    }

    # my $thumb = $thumbs->{$image};
    # if ($thumb && check_thumb_validity($dir, $thumb, $image)) {
    # $obj->{thumbvalid} = 1;
    # }

    push (@{$list}, $obj);
  }

  $images{$dir} = $list;
}

sub check_thumb_validity {
  my ($dir, $thumb, $image) = @_;
  if ($ctrl->{clean}) { return 0; }
  return 1;     # TODO
}

sub gen_thumbs {
  foreach my $dir (@dirs) {
    my $img;
    foreach $img (@{$images{$dir}}) {
      gen_thumbs_1_image ($dir, $img);
      gen_scaled_1_image ($dir, $img);
    }
    foreach $img (@{$images{$dir}}) {
      gen_thumb_page ($dir, $img);
    }
    gen_dirindex ($dir, 0);
  }
}

sub gen_toplevel {
  @{$images{'.'}} = ();
  my @tldirs = (sort(@uffizi_metadata_dirs), @dirs);
  if ($ctrl->{'toplevel_reverse_order'}) { @tldirs = reverse @tldirs; }

  my %done = ();
  foreach my $dir (@tldirs) {
    next if $done{$dir};
    $done{$dir}++;

    my $obj = $toplevel_images{$dir};

    if ($uffizi_metadata{$dir}) {
      $obj = {
        thisimg => $uffizi_metadata{$dir}
      };
      $obj->{thisimg}->{isremote} = 1;
    }

    push (@{$images{'.'}}, $obj);
  }

  gen_dirindex ('.', 1);
}

sub gen_thumbs_1_image {
  my ($dir, $imgobj) = @_;

  my $maxh = $ctrl->{thumbnail_max_h};
  my $maxw = $ctrl->{thumbnail_max_w};

  my $img = $imgobj->{thisimg}->{name};

  my $fmt = $ctrl->{thumbnail_format};
  my $thumbrelname = $img."_".$maxw."x".$maxh.".".$fmt;

  my $thumbfullname = "$dir/tn/$thumbrelname";
  my $thumbpage = gen_thumbpage_fname ($dir, $img);

  # set these so that the dir index can use them
  $imgobj->{thisimg}->{thumbrelname} = $thumbrelname;
  $imgobj->{thisimg}->{thumbfullname} = $thumbfullname;
  $imgobj->{thisimg}->{thumbpage} = $thumbpage;

  if (!$toplevel_images{$dir}) {
    $toplevel_images{$dir} = $imgobj;
  }

  # and generate the dirs and files, if needed
  (-d $dir) or mkdir ($dir) or die "$dir: cannot mkdir: $!\n";
  (-d "$dir/tn") or mkdir ("$dir/tn") or die "$dir/tn: cannot mkdir: $!\n";;
  if (!gen_thumbnail_image ($dir, $img, $thumbfullname, $maxh, $maxw)) {
    return 0;
  }
  return 1;
}

sub gen_scaled_1_image {
  my ($dir, $imgobj) = @_;

  my ($scaledrelname, $scaledfullname, $needsscaling);
  my $img = $imgobj->{thisimg}->{name};

  my $maxh = $ctrl->{scaled_max_h};
  my $maxw = $ctrl->{scaled_max_w};
  my ($imgw, $imgh) = myimgsize ("$dir/$img");
  my $urlscaledrelname;

  if ($imgw <= $maxw && $imgh <= $maxw) {
    $scaledrelname = "../$img";     # relative to "$dir/tn"
    $urlscaledrelname = "../".urlencode($img);
    $scaledfullname = "$dir/$img";
    $needsscaling = 0;              # original will work fine, thx
  }
  else {
    my $fmt = $ctrl->{scaled_format};
    $scaledrelname = $img."_".$maxw."x".$maxh.".".$fmt;
    $urlscaledrelname = urlencode($scaledrelname);
    $scaledfullname = "$dir/tn/$scaledrelname";
    $needsscaling = 1;
  }

  # set these so that the dir index can use them
  $imgobj->{thisimg}->{scaledrelname} = $scaledrelname;
  $imgobj->{thisimg}->{urlscaledrelname} = $urlscaledrelname;
  $imgobj->{thisimg}->{scaledfullname} = $scaledfullname;

  # and generate the dirs and files, if needed
  if ($needsscaling) {
    (-d $dir) or mkdir ($dir) or die "$dir: cannot mkdir: $!\n";
    (-d "$dir/tn") or mkdir ("$dir/tn") or die "$dir/tn: cannot mkdir: $!\n";;
    if (!gen_scaled_image ($dir, $img, $scaledfullname, $maxh, $maxw)) {
      return 0;
    }
  }
  return 1;
}

sub gen_thumb_page {
  my ($dir, $imgobj) = @_;

  my $img = $imgobj->{thisimg}->{name};
  my $thumbpage = $imgobj->{thisimg}->{thumbpage};

  my $imgfilepath = "$dir/$img";

  # check file modtimes
  if (!$ctrl->{clean} && -f $thumbpage && -f $imgfilepath
        && -M $thumbpage < -M $imgfilepath)
  {
    print "unchanged_mtime: $thumbpage\n";
    return 0;
  }

  my $thumbrelname = $imgobj->{thisimg}->{thumbrelname};    # no /s
  my $thumbfullname = $imgobj->{thisimg}->{thumbfullname};

  # write the image page
  my $prevhref = '';
  my $nexthref = '';
  my $prevtext = '';
  my $nexttext = '';

  if ($imgobj->{previmg}) {
    $prevhref = gen_thumbpage_fname ('.', $imgobj->{previmg}->{name});
    $prevhref =~ s/^\..tn.//;
    $prevtext = get_textname_from_image ($imgobj->{previmg});
  }

  if ($imgobj->{nextimg}) {
    $nexthref = gen_thumbpage_fname ('.', $imgobj->{nextimg}->{name});
    $nexthref =~ s/^\..tn.//;
    $nexttext = get_textname_from_image ($imgobj->{nextimg});
  }

  my $thistext = get_textname_from_image ($imgobj->{thisimg});

  my @navtrail = '';
  my @path = split (/[\/\\]+/, $ctrl->{up_name}.'/'.$dir);
  my $levels = scalar @path;
  my $isroot = 1;
  foreach my $elem (@path) {
    my $opts = {
      HREF => (($levels ? ('../' x $levels) : '').$ctrl->{index_filename}),
      NAME => dir_name_to_text($elem)
    };
    $levels--;
    if ($isroot) {
      push (@navtrail, fill_tmpl ($template{'img_navtrail_root'}, $opts));
      $isroot = 0;   # not any more you ain't
    } else {
      push (@navtrail, fill_tmpl ($template{'img_navtrail_trunk'}, $opts));
    }
  }
  push (@navtrail, fill_tmpl ($template{'img_navtrail_leaf'}, {
        NAME => $thistext
      }));

  my ($imgw, $imgh) = myimgsize ($imgobj->{thisimg}->{scaledfullname});
  my ($origw, $origh) = myimgsize ("$dir/$img");

  my $opts = {
    PAGETITLE => $ctrl->{title_prefix}.$dir.": ".$thistext,
    GENERATOR => $GENERATOR,
    IMAGE_WIDTH => $imgw,
    IMAGE_HEIGHT => $imgh,
    IMAGE_PAGE_NAV => join ('', @navtrail),
    IMAGE_PAGE_BACK_LINK => "../".$ctrl->{index_filename},
    IMAGE_BACK_HREF => $prevhref,
    IMAGE_BACK_NAME => $prevtext,
    IMAGE_FWD_HREF => $nexthref,
    IMAGE_FWD_NAME => $nexttext,
    IMAGE_FILE_HREF => "../".urlencode($img),
    IMAGE_FILE_WIDTH => $origw,
    IMAGE_FILE_HEIGHT => $origh,
    IMAGE_FILE_SRC => $imgobj->{thisimg}->{urlscaledrelname},
    IMAGE_FILE_ALT => $thistext,
    FAST_NAV_TABLE => fast_nav_gen($dir, $imgobj),
    GEN_DATE => $gendate,
  };

  write_html_if_different_from_disk ('image', $thumbpage,
            fill_tmpl ($template{'image_page'}, $opts));
}

sub gen_dirindex {
  my ($dir, $istoplevel) = @_;

  my $dirindexfile = $dir."/".$ctrl->{index_filename};
  my $maxh = $ctrl->{thumbnail_max_h};
  my $maxw = $ctrl->{thumbnail_max_w};

  # check modtimes against all source image files
  my $canskip = (!$ctrl->{clean} && -f $dirindexfile);
  my $built_modtime = (-M $dirindexfile);

  if ($canskip) {
    foreach my $imgobj (@{$images{$dir}}) {
      my $obj = $imgobj->{thisimg};
      if ($obj->{isremote}) {
        $canskip = 0;       # any remotes mean we have to rebuild the idx
        next;
      }

      my $origfile = $obj->{dir}.'/'.$obj->{name};
      if (!(-f $origfile && $built_modtime < -M $origfile)) {
        $canskip = 0;
        last;
      }
    }

    if ($canskip) {
      print "unchanged_mtime: $dirindexfile\n";
      return 0;
    }
  }

  my @trs = ();
  my @tds = ();
  my $onlinesofar = 0;
  foreach my $imgobj (@{$images{$dir}}) {
    my $obj = $imgobj->{thisimg};
    my $origfile;
    my $sizekb;
    my $thumbpage;
    my $thumbsrc;
    my $textname;
    my $numimgs;

    if ($obj->{isremote}) {
      $origfile = undef;
      ($maxw, $maxh) = ($obj->{maxw}, $obj->{maxh});
      $sizekb = -23;

    } else {
      $origfile = $obj->{dir}.'/'.$obj->{name};
      ($maxw, $maxh) = myimgsize ($obj->{thumbfullname});
      $sizekb = int (((-s $origfile) + 1023) / 1024);
    }
    
    if ($obj->{isremote}) {
      $thumbpage = $obj->{thumbpage};
      $thumbsrc = $obj->{thumbsrc};
      $textname = $obj->{textname};
      $numimgs = $obj->{numimgs};

    } elsif ($istoplevel) {
      $thumbpage = $obj->{dir}.'/'.$ctrl->{index_filename};
      $thumbsrc = $obj->{dir}.'/tn/'.urlencode($obj->{thumbrelname});
      $textname = dir_name_to_text($obj->{dir});
      $numimgs = scalar @{$images{$obj->{dir}}};

    } else {
      $thumbpage = $obj->{thumbpage};

      my $depth = 0;
      my $countslashes = $obj->{dir};
      while ($countslashes =~ s/^[^\\\/]+(?:[\\\/]+|$)//) { $depth++; }
      for (1 .. $depth) { $thumbpage =~ s/^[^\\\/]+[\\\/]+//; }

      $thumbsrc = "tn/".urlencode($obj->{thumbrelname});
      $textname = get_textname_from_image ($obj);
      $numimgs = 1;
    }

    my $str = fill_tmpl ($template{'contents_td'}, {
      ISTOPLEVEL => $istoplevel,
      CONTENTS_IMG_PAGE_HREF => $thumbpage,
      CONTENTS_IMG_WIDTH => $maxw,
      CONTENTS_IMG_HEIGHT => $maxh,
      CONTENTS_IMG_BORDER => 0,
      CONTENTS_THUMB_SRC => $thumbsrc,
      CONTENTS_THUMB_ALT => $textname,
      CONTENTS_IMG_NAME => $textname,
      IMG_SIZE_KB => $sizekb,
      NUM_IMAGES => $numimgs,
      CONTENTS_IMG_DESCRIPTION => ''
    });
    push (@tds, $str);

    $onlinesofar++;
    if ($onlinesofar >= $ctrl->{contents_columns}) {
      push (@trs, fill_tmpl ($template{'contents_tr'}, {
        CONTENTS_TDS => join ('', @tds)
      }));
      @tds = (); $onlinesofar = 0;
    }

    if ($istoplevel) {
      add_uffizi_metadata(
        dir => $obj->{dir},
        name => $obj->{name},
        numimgs => $numimgs,
        thumbpage => $thumbpage,
        thumbsrc => $thumbsrc,
        textname => $textname,
        maxw => $maxw,
        maxh => $maxh
      );
    }
  }

  push (@trs, fill_tmpl ($template{'contents_tr'}, {
    CONTENTS_TDS => join ('', @tds)
  }));

  # now the general page stuff
  my @navtrail = '';
  my @path = split (/[\/\\]+/, $ctrl->{up_name}.'/'.$dir);
  my $lastelem = pop @path;    # drop the last item, this dir's name
  my $levels = scalar @path;
  my $isroot = 1;
  foreach my $elem (@path) {
    my $opts = {
      HREF => (($levels ? ('../' x $levels) : '').$ctrl->{index_filename}),
      NAME => dir_name_to_text($elem)
    };
    $levels--;
    if ($isroot) {
      push (@navtrail, fill_tmpl ($template{'contents_navtrail_root'}, $opts));
      $isroot = 0;   # not any more you ain't
    } else {
      push (@navtrail, fill_tmpl ($template{'contents_navtrail_trunk'}, $opts));
    }
  }
  my $thisdirastext = dir_name_to_text($lastelem);
  push (@navtrail, fill_tmpl ($template{'contents_navtrail_leaf'}, {
        NAME => $thisdirastext
      }));

  my $opts = {
    PAGETITLE => $istoplevel ? "top level" : $ctrl->{title_prefix}.$thisdirastext,
    GENERATOR => $GENERATOR,
    CONTENT_NAV => join ('', @navtrail),
    CONTENT_BACK_LINK => '../'.$ctrl->{index_filename},
    CONTENTS_TRS => join ('', @trs),
    GEN_DATE => $gendate,
    UFFIZI_METADATA => $istoplevel ? get_uffizi_metadata() : ''
  };

  write_html_if_different_from_disk ('index', $dirindexfile,
            fill_tmpl ($template{'contents_page'}, $opts));
}

sub add_uffizi_metadata {
  my %opts = @_;
  push @uffizi_metadata_dirs, $opts{dir};
  $uffizi_metadata{$opts{dir}} = \%opts;
}

sub get_uffizi_metadata {
  my %done = ();
  my $str = '';
  foreach my $dir (@uffizi_metadata_dirs) {
    next if $done{$dir};
    $done{$dir} = 1;
    $str .= join ('|', map {
      $_."=".$uffizi_metadata{$dir}->{$_}
    } (keys %{$uffizi_metadata{$dir}}))."\n";
  }
  return $str;
}

sub write_html_if_different_from_disk {
  my ($type, $file, $contents) = @_;

  # compare with disk
  if (open (IN, "<$file")) {
    my $ondisk = join ('', <IN>); close IN;
    my $update = $contents;
    
    $update =~ s/\s+//gs; $update =~ s/<!--VOLATILE-->.*?<!--\/VOLATILE-->//gs;
    $ondisk =~ s/\s+//gs; $ondisk =~ s/<!--VOLATILE-->.*?<!--\/VOLATILE-->//gs;

    if ($ondisk eq $update) {
      print "unchanged_html: $file\n";
      return 0;
    }
  }

  $contents =~ s/^\s+//gm;      # whitespace at SOL
  $contents =~ s/\s+$//gm;      # whitespace at EOL
  $contents =~ s/\n\s+\n/\n/gs; # multiple newlines

  # OK, rewrite it
  if (!open (IDXPAGE, ">$file")) {
    warn "$file: failed to open: $!\n";
    return -1;
  }
  print IDXPAGE $contents;
  if (!close IDXPAGE) {
    warn "$file: failed to write: $!\n";
    return -1;
  }

  print "$type: $file\n";
  return 1;
}

sub gen_thumbpage_fname {
  my ($dir, $img) = @_;
  $img =~ s/[^-_=\.A-Za-z0-9]+/_/gs;
  "$dir/tn/$img.index.html";
}

sub fast_nav_gen {
  my ($dir, $img) = @_;

  my @trs = ();
  my @tds = ();
  my $onlinesofar = 0;

  foreach my $oimg (@{$images{$dir}}) {
    # use Data::Dumper; # print Dumper $oimg; # print Dumper $img;
    my $obj = $oimg->{thisimg};

    my $thumbpage = $obj->{thumbpage};
    $thumbpage =~ s/^[^\\\/]+[\\\/]tn[\\\/]//;
    # thumbpage: restaurant-1.jpg.png.index.html
    # thumbrelname: restaurant-1.jpg_200x200.png

    my $opts = {
      FAST_NAV_NAME => get_textname_from_image ($obj),
      FAST_NAV_HREF => $thumbpage,
      FAST_NAV_THUMB_SRC => urlencode($obj->{thumbrelname}),
    };

    if ($img eq $oimg) {
      push (@tds, fill_tmpl ($template{'fast_nav_td_on'}, $opts));
    } else {
      push (@tds, fill_tmpl ($template{'fast_nav_td_off'}, $opts));
    }

    $onlinesofar++;
    if ($onlinesofar >= $ctrl->{fastnav_columns}) {
      push (@trs, fill_tmpl ($template{'fast_nav_trs'}, {
            FAST_NAV_TDS => join ('', @tds)
      }));
      @tds = (); $onlinesofar = 0;
    }
  }
  push (@trs, fill_tmpl ($template{'fast_nav_trs'}, {
        FAST_NAV_TDS => join ('', @tds)
  }));
  return fill_tmpl ($template{'fast_nav_table'}, {
        FAST_NAV_TRS => join ('', @trs)
  });
}

sub gen_thumbnail_image {
  my ($dir, $img, $thumbname, $maxh, $maxw) = @_;

  return if (!$ctrl->{clean} && -f $thumbname && -s $thumbname);

  my $imgpath = "$dir/$img";

  my $fmt = $ctrl->{thumbnail_format};
  my $tmpthumbname = "$dir/tn/tmp.$img.$fmt";
  my $tmp2thumbname = "$dir/tn/tmp2.$img.$fmt";

  my $args = $ctrl->{thumbnail_convert_args};
  if ($ctrl->{'thumbnail_convert_args_'.$fmt}) {
    $args .= ' '.$ctrl->{'thumbnail_convert_args_'.$fmt};
  }
  $args =~ s/__WIDTH__/$maxw/g;
  $args =~ s/__HEIGHT__/$maxh/g;
  my @cmd = split (' ', $args);

  push (@cmd, $imgpath, $tmpthumbname);

  print "thumb: ".join (' ', @cmd)."\n";
  system (@cmd);
  if ($? >> 8 != 0) {
    warn "$thumbname: failed to generate due to 'convert' failure\n";
    return 0;
  }

  if ($fmt eq 'jpg' && $ctrl->{jpegtran}) {
    @cmd = ($ctrl->{jpegtran}, "-progressive",
                         "-outfile", $tmp2thumbname,
                         $tmpthumbname);
    print "jpegtran: ".join (' ', @cmd)."\n";
    system (@cmd);
    if ($? >> 8 != 0) {
      warn "$thumbname: failed to generate due to '$ctrl->{jpegtran}' failure\n";
      return 0;
    }
    rename ($tmp2thumbname, $tmpthumbname) or die "rename failed";
  }

  if (!rename $tmpthumbname, $thumbname) {
    warn "$thumbname: rename from $tmpthumbname failed: $!\n";
    return 0;
  }
  return 1;
}

sub gen_scaled_image {
  my ($dir, $img, $scaledname, $maxh, $maxw) = @_;

  return if (!$ctrl->{clean} && -f $scaledname && -s $scaledname);

  my $imgpath = "$dir/$img";

  my $fmt = $ctrl->{scaled_format};
  my $tmpscaledname = "$dir/tn/tmp.$img.$fmt";
  my $tmp2scaledname = "$dir/tn/tmp2.$img.$fmt";

  my $args = $ctrl->{scaled_convert_args};
  if ($ctrl->{'scaled_convert_args_'.$fmt}) {
    $args .= ' '.$ctrl->{'scaled_convert_args_'.$fmt};
  }
  $args =~ s/__WIDTH__/$maxw/g;
  $args =~ s/__HEIGHT__/$maxh/g;
  my @cmd = split (' ', $args);

  push (@cmd, $imgpath, $tmpscaledname);

  print "scaled: ".join (' ', @cmd)."\n";
  system (@cmd);
  if ($? >> 8 != 0) {
    warn "$scaledname: failed to generate due to 'convert' failure\n";
    return 0;
  }

  # jpegtran -progressive input > output ; mv output input
  # jhead -cl "comment" -dt -te orig input
  # - (jpegtran: used to make jpeg images progressive)
  # - (jhead: used to copy over EXIF metadata)

  if ($fmt eq 'jpg' && $ctrl->{jpegtran}) {
    @cmd = ($ctrl->{jpegtran}, "-progressive",
                         "-outfile", $tmp2scaledname,
                         $tmpscaledname);
    print "jpegtran: ".join (' ', @cmd)."\n";
    system (@cmd);
    if ($? >> 8 != 0) {
      warn "$scaledname: failed to generate due to '$ctrl->{jpegtran}' failure\n";
      return 0;
    }
    rename ($tmp2scaledname, $tmpscaledname) or die "rename failed";
  }

  if ($fmt eq 'jpg' && $ctrl->{jhead}) {
    @cmd = ($ctrl->{jhead}, "-dt", "-te", $imgpath, $tmpscaledname);
    print "jhead: ".join (' ', @cmd)."\n";
    system (@cmd);
    if ($? >> 8 != 0) {
      warn "$scaledname: failed to generate due to '$ctrl->{jhead}' failure\n";
      return 0;
    }
  }

  if (!rename ($tmpscaledname, $scaledname)) {
    warn "$scaledname: rename from $tmpscaledname failed: $!\n";
    return 0;
  }

  return 1;
}

sub urlencode {
  my $str = shift;
  $str =~ s{([\x00-\x2b\x2f\x3a-\x40\x5b-\x60\x7b-\xff])}{
    sprintf "%%%02x", ord $1;
  }ge;
  $str;
}

sub get_textname_from_image {
  my $obj = shift;
  if ($obj->{description}) {
    return $obj->{description};
  } else {
    return $obj->{textname};
  }
}

sub myimgsize {
  my ($file) = @_;
  if ($cached_imgsizes{$file}) {
    return @{$cached_imgsizes{$file}};
  } else {
    my @sizes = imgsize($file);
    $cached_imgsizes{$file} = \@sizes;
    return @sizes;
  }
}


sub fill_tmpl {
  my ($tmpl, $opts) = @_;
  my $out = $tmpl;

  1 while $out =~ s!__TEMPLATE:([_a-zA-Z0-9]+)__! $template{$1} !xgs;

  # conditionals
  if ($out =~ /<__COND_IF_/) {
    $out =~ s{<__COND_IF_\(([_a-zA-Z0-9]+)\)__>
                (.*?)
                <__COND_ELSE__>
                (.*?)
                <\/__COND_IF_\(\1\)__>}{ tmpl_cond_if ($opts->{$1}, $2, $3); }xges;
    $out =~ s{<__COND_IF_\(([_a-zA-Z0-9]+)\)__>
                (.*?)
                <\/__COND_IF_\(\1\)__>}{ tmpl_cond_if ($opts->{$1}, $2); }xges;
  }

  foreach my $k (keys %$opts) {
    $out =~ s/__${k}__/
            if (defined $opts->{$k}) {
              $opts->{$k};
            } else {
              warn "undefined template variable: $k\n";
              '';
            }
            /ges;
  }

  return $out;
}

sub tmpl_cond_if {
  my ($cond, $textiftrue, $textiffalse) = @_;
  if ($cond) {
    return $textiftrue;
  } else {
    return $textiffalse ? $textiffalse : '';
  }
}


__DATA__
<uffizitheme>
<templatechunk name="contents_page">
  __TEMPLATE:htmlhead__
  <title>__PAGETITLE__</title></head> <body bgcolor='#fff'>

    <table class=contents_nav_table>
      <tr>
        <td align='left'>
          <div class=contents_nav> __CONTENT_NAV__ </div>
        </td>
        <td align='right'>
          <div class=contents_back_link>
            <a href='__CONTENT_BACK_LINK__' class=contents_back_href>Up</a>
          </div>
        </td>
      </tr>
    </table>

    <hr class=contents_top_hr />

    <table class=contents_table>
          __CONTENTS_TRS__
    </table>

    <hr class=contents_bot_hr />

    <table class=contents_nav_table>
      <tr>
        <td align='left'>
          <div class=contents_nav> __CONTENT_NAV__ </div>
        </td>
        <td align='right'>
          <div class=contents_back_link>
            <a href='__CONTENT_BACK_LINK__' class=contents_back_href>Up</a>
          </div>
        </td>
      </tr>
    </table>

    <div class=contents_footer_text>
      <!-- (last updated <!--VOLATILE-->__GEN_DATE__<!--/VOLATILE-->) -->
      <!-- <uffizi_metadata>
        __UFFIZI_METADATA__
      </uffizi_metadata> -->
    </div>

  </body>
  </html>
</templatechunk>

<templatechunk name="contents_tr">
  <tr class=contents_tr>
      __CONTENTS_TDS__
  </tr>
</templatechunk>

<templatechunk name="contents_td">
  <td align='center' valign='top' class=contents_td>
    <a href='__CONTENTS_IMG_PAGE_HREF__' class=contents_img_link>
      <img width='__CONTENTS_IMG_WIDTH__' height='__CONTENTS_IMG_HEIGHT__'
              border='__CONTENTS_IMG_BORDER__' src='__CONTENTS_THUMB_SRC__'
              alt='__CONTENTS_THUMB_ALT__' class=contents_img></a>
    <br />
    <div class=contents_img_name>__CONTENTS_IMG_NAME__</div>

    <div class=contents_img_size>
      <__COND_IF_(ISTOPLEVEL)__>
        __NUM_IMAGES__ pictures
      <__COND_ELSE__>
        __IMG_SIZE_KB__Kb
      </__COND_IF_(ISTOPLEVEL)__>
    </div>
    <br />
    <div class=contents_img_desc>__CONTENTS_IMG_DESCRIPTION__</div>
  </td>
</templatechunk>

<templatechunk name="htmlhead">
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"
                            "http://www.w3.org/TR/html4/loose.dtd">
  <!--
  ---- Generated with Uffizi: http://jmason.org/software/uffizi/
  ---- Last updated: <!--VOLATILE-->__GEN_DATE__<!--/VOLATILE-->
  -->
  <html><head>
  <meta name='Generator' content='__GENERATOR__' />
  <style type="text/css" media="all">__TEMPLATE:styles__</style>
</templatechunk>

<templatechunk name="image_page">
  __TEMPLATE:htmlhead__
  <title>__PAGETITLE__</title></head> <body bgcolor='#fff'>

    <table class=img_nav_table>
      <tr>
        <td align='left'>
          <div class=img_page_nav>
          __IMAGE_PAGE_NAV__
          </div>
        </td>
        <td align='right'>
          <div class=img_page_back_link>
          <a href='__IMAGE_PAGE_BACK_LINK__' class=thumb_back_href>Back</a>
          </div>
        </td>
      </tr>
    </table>

    <hr class=img_top_hr />

    <table class=img_nav_top>
      <tr>
        <td align='left' class=img_nav_top_td>
          <div class=img_nav_href><a class=img_nav_href
              href='__IMAGE_BACK_HREF__'>__IMAGE_BACK_NAME__</a></div>
        </td>
        <td align='right' class=img_nav_top_td>
          <div class=img_nav_href><a class=img_nav_href
              href='__IMAGE_FWD_HREF__'>__IMAGE_FWD_NAME__</a></div>
        </td>
      </tr>
    </table>

    <div class=img_block>
      <a href='__IMAGE_FILE_HREF__' class=img_block_href><img
          border='0' src='__IMAGE_FILE_SRC__' alt='__IMAGE_FILE_ALT__'
          width='__IMAGE_WIDTH__' height='__IMAGE_HEIGHT__'
          class=img_block /></a><br />
    </div>

    <p class=img_original_p><a href='__IMAGE_FILE_HREF__'
    class=img_original_href>click on image to view the full-size version
    (__IMAGE_FILE_WIDTH__ x __IMAGE_FILE_HEIGHT__)</a></p>

    <table class=img_nav_bot>
      <tr>
        <td align='left' class=img_nav_bot_td>
          <div class=img_nav_href><a class=img_nav_href
              href='__IMAGE_BACK_HREF__'>__IMAGE_BACK_NAME__</a></div>
        </td>
        <td align='right' class=img_nav_bot_td>
          <div class=img_nav_href><a class=img_nav_href
              href='__IMAGE_FWD_HREF__'>__IMAGE_FWD_NAME__</a></div>
        </td>
      </tr>
    </table>

    __FAST_NAV_TABLE__

    <hr class=img_bot_hr />

    <div class=img_footer_text>
      <!-- (last updated <!--VOLATILE-->__GEN_DATE__<!--/VOLATILE-->) -->
    </div>
  </body>
  </html>
</templatechunk>

<templatechunk name="fast_nav_table">
  <table class=fast_nav_table>
    __FAST_NAV_TRS__
  </table>
</templatechunk>

<templatechunk name="fast_nav_trs">
  <tr class=fast_nav_tr>
    __FAST_NAV_TDS__
  </tr>
</templatechunk>

<templatechunk name="fast_nav_td_off">
  <td width="30" height="30" class=fast_nav_td_off>
  <a href="__FAST_NAV_HREF__"><img
      title="__FAST_NAV_NAME__"
      src="__FAST_NAV_THUMB_SRC__" width="28" height="28" border="0"
      /></a>
  </td>
</templatechunk>

<templatechunk name="fast_nav_td_on">
  <td width="50" height="50" class=fast_nav_td_on>
  <a href="__FAST_NAV_HREF__"><img
      title="__FAST_NAV_NAME__"
      src="__FAST_NAV_THUMB_SRC__" width="50" height="50" border="0"
      /></a>
  </td>
</templatechunk>

<templatechunk name="img_navtrail_root">
  <span class=img_navtrail_root>
  <a href='__HREF__' class=img_navtrail_root>__NAME__</a> 
  </span>
</templatechunk>
<templatechunk name="img_navtrail_trunk">
  <span class=img_navtrail_trunk>
  : <a href='__HREF__' class=img_navtrail_trunk>__NAME__</a> 
  </span>
</templatechunk>
<templatechunk name="img_navtrail_leaf">
  <br />
  <span class=img_navtrail_leaf>
  __NAME__
  </span>
</templatechunk>

<templatechunk name="contents_navtrail_root">
  <span class=contents_navtrail_root>
  <a href='__HREF__' class=contents_navtrail_root>__NAME__</a> 
  </span>
</templatechunk>
<templatechunk name="contents_navtrail_trunk">
  <span class=contents_navtrail_trunk>
  : <a href='__HREF__' class=contents_navtrail_trunk>__NAME__</a> 
  </span>
</templatechunk>
<templatechunk name="contents_navtrail_leaf">
  <br />
  <span class=contents_navtrail_leaf>
  __NAME__
  </span>
</templatechunk>

<templatechunk name="styles">
  body {
    background-color: #fff;
    color: #000;
    font-family: verdana,lucida,helvetica,sans-serif;
    font-size: 12px;
    line-height: 1.5em;
    margin-left: 8px;
    margin-right: 8px;
  }
  a:link {
    font-weight: bold;
    color: #004000;
    text-decoration: underline;
  }
  a:visited {
    font-weight: bold;
    color: #008000;
    text-decoration: underline;
  }
  a:active {
    font-weight: bold;
    color: #800000;
    text-decoration: underline;
  }

  div.img_block {
    text-align: center;
    padding: 10px 10px 10px 10px;
    margin: 0px 0px 0px 0px;
  }

  img.img_block {
    font-size: 110%;
    font-style: italic;
    color: #888;
    border: thin solid #333;
  }

  div.img_nav_href {
    padding: 5px;
    white-space: nowrap;
  }
  a.img_nav_href {
    text-decoration: none;
    font-size: 120%;
    border-color: #aaa;
    border-style: solid;
    border-width: 1px;
    padding: 3px 30px 5px 30px;
  }

  div.img_page_nav {
    font-size: 140%;
  }
  div.contents_nav {
    font-size: 140%;
  }

  div.img_page_back_link {
    font-size: 150%;
  }
  div.contents_back_link {
    font-size: 150%;
  }

  table.img_nav_top {
    width: 100%;
  }
  table.img_nav_bot {
    width: 100%;
  }
  td.img_nav_top_td {
    padding: 10px;
  }
  td.img_nav_bot_td {
    padding: 10px;
  }

  table.img_nav_table {
    width: 100%;
    padding: 10px;
    border-color: #aaa;
    border-style: solid;
    border-width: 1px;
  }
  table.contents_nav_table {
    width: 100%;
    padding: 10px;
    border-color: #aaa;
    border-style: solid;
    border-width: 1px;
  }

  div.contents_footer_text {
    font-size: 50%;
    font-style: italic;
    text-align: right;
  }
  div.img_footer_text {
    font-size: 50%;
    font-style: italic;
    text-align: right;
  }

  table.contents_table {
    width: 95%;
  }
  tr.contents_tr {
  }
  td.contents_td {
    padding: 10px;
  }

  div.contents_img_name {
    font-size: 90%;
    font-weight: bold;
  }

  div.contents_img_size {
    font-size: 50%;
    font-style: italic;
    color: #999;
  }

  div.contents_img_desc {
    font-size: 80%;
  }

  table.fast_nav_table {
    width: 100%;
    padding: 8px;
    border-color: #aaa;
    border-style: solid;
    border-width: 1px;
  }
  tr.fast_nav_tr {
  }
  td.fast_nav_td_off {
    padding: 10px;
  }
  td.fast_nav_td_on {
    padding: 0px;
  }

  hr.img_top_hr { border: 0; width: 10%; height: 1px; }
  hr.img_bot_hr { border: 0; width: 10%; height: 1px; }
  hr.contents_top_hr { border: 0; width: 10%; height: 1px; }
  hr.contents_bot_hr { border: 0; width: 10%; height: 1px; }

  span.img_navtrail_root {
    font-size: 50%;
  }
  a.img_navtrail_root {
    color: #999;
  }
  span.img_navtrail_trunk {
    font-size: 50%;
    color: #999;
  }
  a.img_navtrail_trunk {
    color: #999;
  }
  span.img_navtrail_leaf {
    font-size: 100%;
    font-weight: bold;
  }

  span.contents_navtrail_root {
    font-size: 50%;
  }
  a.contents_navtrail_root {
    color: #999;
  }
  span.contents_navtrail_trunk {
    font-size: 50%;
    color: #999;
  }
  a.contents_navtrail_trunk {
    color: #999;
  }
  span.contents_navtrail_leaf {
    font-size: 100%;
    font-weight: bold;
  }

  a.thumb_back_href {
    color: #999;
  }
  a.contents_back_href {
    color: #999;
  }
  a.img_original_href {
    color: #999;
    font-weight: normal;
  }
  p.img_original_p {
    text-align: right;
  }
</templatechunk>
</uffizitheme>