aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--IkiWiki/Plugin/album.pm559
-rw-r--r--doc/style.css110
-rw-r--r--doc/templates.mdwn4
-rwxr-xr-xt/album.t153
-rw-r--r--templates/albumitem.tmpl34
-rw-r--r--templates/albumnext.tmpl6
-rw-r--r--templates/albumprev.tmpl6
-rw-r--r--templates/albumviewer.tmpl16
8 files changed, 845 insertions, 43 deletions
diff --git a/IkiWiki/Plugin/album.pm b/IkiWiki/Plugin/album.pm
new file mode 100644
index 000000000..16de8eb97
--- /dev/null
+++ b/IkiWiki/Plugin/album.pm
@@ -0,0 +1,559 @@
+#!/usr/bin/perl
+# Copyright © 2009-2011 Simon McVittie <http://smcv.pseudorandom.co.uk/>
+# Licensed under the GNU GPL, version 2, or any later version published by the
+# Free Software Foundation
+package IkiWiki::Plugin::album;
+
+use warnings;
+use strict;
+use IkiWiki 3.00;
+
+sub import {
+ hook(type => "getsetup", id => "album", call => \&getsetup);
+ hook(type => "needsbuild", id => "album", call => \&needsbuild);
+ hook(type => "preprocess", id => "album",
+ call => \&preprocess_album, scan => 1);
+ hook(type => "preprocess", id => "albumsection",
+ call => \&preprocess_albumsection, scan => 1);
+ hook(type => "preprocess", id => "albumimage",
+ call => \&preprocess_albumimage, scan => 1);
+ hook(type => "pagetemplate", id => "album", call => \&pagetemplate);
+
+ # We need these plugins. Additionally, meta is recommended.
+ IkiWiki::loadplugin("filecheck");
+ IkiWiki::loadplugin("img");
+ IkiWiki::loadplugin("inline");
+ IkiWiki::loadplugin("trail");
+ IkiWiki::loadplugin("transient");
+}
+
+sub getsetup () {
+ return
+ plugin => {
+ safe => 1,
+ rebuild => undef,
+ section => "widget",
+ },
+ # FIXME: unimplemented
+ album_copydir => {
+ type => "string",
+ example => "$ENV{HOME}/photos-to-upload",
+ description => "if set, copy photos to this directory at reduced size",
+ advanced => 1,
+ safe => 0,
+ rebuild => 1,
+ },
+}
+
+# Terminology:
+#
+# album - main page for an album/gallery, contains a list of inlined viewers
+# viewer - page generated to contain/display/represent one image
+# section - a subset of the viewers in an album
+#
+# Page state for albums:
+#
+# size - default size for viewers in this album
+# thumbnailsize - size to resize thumbnails to
+# viewertemplate - template to use for viewers
+# nexttemplate - template for "next image", as embedded in viewers
+# prevtemplate - template for "previous image", as embedded in viewers
+# sort - as for inline
+# sections - ref to array of pagespecs representing sections
+# viewers - list of viewers' names
+#
+# Page state for image viewers:
+#
+# album - full name of associated album
+# image - full name of image file
+# caption - caption if any
+# size, thumbnailsize, viewertemplate, nexttemplate, prevtemplate -
+# override the corresponding option for the album
+
+sub isalbumableimage ($) {
+ my $file=shift;
+
+ return $file =~ /\.(png|gif|jpg|jpeg|mov)$/i;
+}
+
+sub needsbuild {
+ my $needsbuild = shift;
+ my $deleted = shift;
+
+ foreach my $page (@$needsbuild, @$deleted) {
+ # it's neither an album nor a viewer, unless it later says
+ # it is
+ delete $pagestate{$page}{album};
+ }
+
+ return $needsbuild;
+}
+
+sub thumbnail {
+ my $viewer = shift;
+ my $destpage = shift;
+ my $thumbnailsize = shift;
+ my $image = $pagestate{$viewer}{album}{image};
+
+ # img requires that each file is generated by one page, so nominate
+ # the viewer as the page that makes the thumbnail
+ my $img = "";
+ my $title = IkiWiki::Plugin::trail::title_of($viewer);
+
+ if (IkiWiki::isinlinableimage($image)) {
+ $img = IkiWiki::Plugin::img::preprocess(
+ "$image" => undef,
+ link => "/$viewer",
+ size => ($thumbnailsize or '96x96'),
+ alt => $title,
+ title => $title,
+ page => $viewer,
+ destpage => $destpage);
+ }
+
+ return $img;
+}
+
+sub show_in_album {
+ my $viewers = shift;
+ my %params = @_;
+
+ return IkiWiki::preprocess_inline(
+ pagenames => join(" ", @$viewers),
+ actions => "no",
+ feeds => "no",
+ template => "albumitem",
+ %params);
+}
+
+# album => [ list of viewers ]
+my %scanned;
+
+sub create_viewer {
+ my ($viewer, $image, $album) = @_;
+
+ my $vfile = newpagefile($viewer, $config{default_pageext});
+
+ # FIXME: try to read creation date, copyright etc. from EXIF tags
+
+ add_autofile($vfile, "album", sub {
+ my $message = sprintf(gettext("creating album page %s"), $viewer);
+ debug($message);
+
+ my $content = <<"END";
+[[!albumimage
+ title=""
+ caption=""
+ date=""
+ updated=""
+ author=""
+ authorurl=""
+ copyright=""
+ license=""
+ description=""
+ ]]
+END
+ # size, thumbnailsize, viewertemplate, prevtemplate,
+ # nexttemplate aren't in the generated page because
+ # they're not expected to be commonly used - setting
+ # them for an entire album is likely to be more useful
+
+ writefile($vfile, $IkiWiki::Plugin::transient::transientdir,
+ $content);
+ });
+}
+
+sub scan_images {
+ my $album = $_[0];
+
+ # There are two purposes to this, optimization (don't bother
+ # re-scanning images) and correctness (when we're preprocessing
+ # after the scan stage, we don't want to re-run scan_binary because
+ # it would override the metadata from [[!albumimage]]).
+ return @{$scanned{$album}} if exists $scanned{$album};
+
+ # All the images that are attached to an album or its subpages count as
+ # (potential) members of it. For each one, we synthesize a page if no
+ # page exists.
+ my $regexp = qr{^\Q$album\E/}i;
+ my @viewers;
+
+ # collect the images
+ foreach my $candidate (keys %pagesources) {
+ if ($candidate =~ $regexp &&
+ isalbumableimage($candidate)) {
+ my $viewer = $candidate;
+ $viewer =~ s/\.[^.]+$//;
+
+ if (exists $IkiWiki::pagecase{lc $viewer}) {
+ $viewer = $IkiWiki::pagecase{lc $viewer};
+ }
+ else {
+ create_viewer($viewer, $candidate, $album);
+ }
+
+ push @viewers, $viewer;
+
+ # FIXME: what if albums are nested? Current resolution
+ # is that the image goes in a randomly selected album.
+ # Because pages are scanned in arbitrary order, I
+ # don't think we can do better.
+ $pagestate{$viewer}{album}{album} = $album;
+
+ # FIXME: what if there's more than one image file
+ # with the same basename? Current resolution is that
+ # a random one "wins".
+ $pagestate{$viewer}{album}{image} = $candidate;
+ }
+ }
+
+ $scanned{$album} = \@viewers;
+ return @viewers;
+}
+
+# These hashes are populated by collect_images whenever an album, or any
+# image in that album, has changed.
+
+# album => { filter => array of viewers in that section }
+# filter "" is the catch-all for images in no other section
+my %albumsections;
+# linked list of viewers in each album
+my (%before, %after);
+# viewer => index number in its album
+my %albumorder;
+
+sub get_adjacent {
+ my ($trail, $member) = @_;
+
+ IkiWiki::Plugin::trail::prerender();
+ my $adjacent = $pagestate{$member}{trail}{item}{$trail};
+ return (undef, undef) unless $adjacent;
+ return @$adjacent;
+}
+
+sub collect_images {
+ my $album = shift;
+
+ return if $albumsections{$album};
+
+ my $sort = $pagestate{$album}{album}{sort};
+
+ if (!defined $sort) {
+ $sort = '-age';
+ }
+
+ my %sections;
+ my $_; # localize iterator variable
+
+ my @remaining = @{$pagestate{$album}{album}{viewers}};
+
+ foreach my $filter (@{$pagestate{$album}{album}{sections}}) {
+ next if $filter eq '';
+ my @section = pagespec_match_list($album,
+ $filter, sort => $sort,
+ list => [@remaining]);
+ my %set = map { $_ => 1 } @section;
+
+ @remaining = grep { ! exists $set{$_} } @remaining;
+
+ $sections{$filter} = \@section;
+ }
+
+ # the pagespec here matches everything; the part we actually want
+ # is the sorting
+ $sections{""} = [pagespec_match_list($album,
+ "internal(*)", sort => $sort,
+ list => [@remaining])];
+
+ my @ordered;
+
+ foreach my $filter (@{$pagestate{$album}{album}{sections}}) {
+ my $pages;
+ $pages = $sections{$filter};
+ push @ordered, @$pages;
+ $albumsections{$album}{$filter} = $pages;
+ }
+
+ for (my $i = 0; $i <= $#ordered; $i++) {
+ my $viewer = $ordered[$i];
+ $albumorder{$viewer} = $i;
+ # We don't need to track what's before or after:
+ # trail has API for that.
+ }
+}
+
+sub preprocess_album {
+ # [[!album]]
+ my %params=@_;
+ my $album = $params{page};
+
+ my @viewers = scan_images($album);
+
+ # placeholder for the "remaining images" section
+ push @{$pagestate{$album}{album}{sections}}, ""
+ unless grep { $_ eq "" }
+ @{$pagestate{$album}{album}{sections}};
+
+ $pagestate{$album}{album} = {
+ sort => $params{sort},
+ size => $params{size},
+ thumbnailsize => $params{thumbnailsize},
+ viewertemplate => $params{viewertemplate},
+ nexttemplate => $params{nexttemplate},
+ prevtemplate => $params{prevtemplate},
+ viewers => [@viewers],
+ # in the render phase, we want to keep the sections that we
+ # accumulated during the scan phase, if any
+ sections => $pagestate{$album}{album}{sections},
+ };
+
+ # The sort order depends on matching pagespecs for each
+ # section, so we can't define it yet - delegate it to a
+ # sortspec defined by this plugin, which can collect the
+ # images lazily.
+ IkiWiki::Plugin::trail::preprocess_trailoptions(
+ sort => 'albumorder',
+ page => $album,
+ destpage => $params{destpage},
+ );
+
+ my %trailparams = (
+ pagenames => join(' ', @viewers),
+ page => $album,
+ destpage => $params{destpage},
+ );
+
+ if (defined wantarray) {
+ collect_images($album) unless $albumsections{$album};
+
+ scalar IkiWiki::Plugin::trail::preprocess_trailitems(%trailparams);
+
+ return show_in_album($albumsections{$album}{""},
+ page => $album,
+ destpage => $params{destpage});
+ }
+ else {
+ IkiWiki::Plugin::trail::preprocess_trailitems(%trailparams);
+ }
+}
+
+sub preprocess_albumsection {
+ # [[!albumsection filter="friday/*"]]
+ my %params=@_;
+ my $album = $params{page};
+ my $filter = $params{filter};
+ my $_;
+
+ # remember the filter for this section so the "remaining images" section
+ # won't include these images (this needs to be run in the scan stage
+ # so the info will be there for the album directive)
+ push @{$pagestate{$album}{album}{sections}}, $filter
+ unless grep { $_ eq $filter }
+ @{$pagestate{$album}{album}{sections}};
+
+ # If we're just scanning, don't bother producing output
+ return unless defined wantarray;
+
+ collect_images($album) unless $albumsections{$album};
+
+ return show_in_album($albumsections{$album}{$filter},
+ page => $album,
+ destpage => $params{destpage});
+}
+
+sub preprocess_albumimage {
+ my %params=@_;
+ my $viewer = $params{page};
+
+ my $album = $pagestate{$viewer}{album}{album};
+ my $image = $pagestate{$viewer}{album}{image};
+
+ if (! defined $album || ! defined $image) {
+ error(sprintf(gettext("%s is not in any album"), $viewer));
+ }
+
+ $pagestate{$viewer}{album} = {
+ album => $album,
+ image => $image,
+ };
+
+ # [[!albumimage title=foo copyright=bar]] is a shortcut for a couple
+ # of [[!meta]] invocations. Zero-length metadata gets ignored, so we
+ # can put all the available metadata in the template album page,
+ # with zero-length values.
+ if (IkiWiki::Plugin::meta->can('preprocess')) {
+ foreach my $meta (qw(title date updated author authorurl
+ copyright license description)) {
+ if (defined $params{$meta} && length $params{$meta}) {
+ IkiWiki::Plugin::meta::preprocess(
+ $meta => $params{$meta},
+ page => $viewer);
+ }
+ }
+ }
+
+ # The thumbnail has to have exactly one source, so thumbnail() always
+ # claims that it is this page. To avoid ikiwiki deleting the
+ # thumbnail, we need to make sure will_render() gets called, and this
+ # seems the easiest way to do that.
+ if (defined wantarray) {
+ my $foo = thumbnail($viewer, $viewer,
+ $pagestate{$album}{album}{thumbnailsize});
+ }
+ else {
+ thumbnail($viewer, $viewer,
+ $pagestate{$album}{album}{thumbnailsize});
+ }
+
+ add_depends($viewer, $album);
+
+ # If we're just scanning, don't bother producing output
+ return unless defined wantarray;
+
+ # Copy various settings from the album, unless overridden
+ foreach my $k (qw(viewertemplate nexttemplate prevtemplate size)) {
+ $params{$k} = $pagestate{$album}{album}{$k}
+ unless defined $params{$k} && length $params{$k};
+ }
+
+ # Because we're no longer in the scan phase, we know that
+ # preprocess_album has already run, so we know:
+ # - which album this photo appears in
+ # - what order the album is in
+ # (either because the scan phase has run for modified pages, or
+ # because the pagestate was loaded from last time for unmodified
+ # pages.)
+
+ collect_images($album) unless $albumsections{$album};
+
+ my ($prevpage, $nextpage) = get_adjacent($album, $viewer);
+
+ my $prev = '';
+ my $next = '';
+
+ if (defined $nextpage) {
+ add_depends($album, $nextpage);
+ $next = show_prevnext($nextpage, $viewer, "next");
+ }
+
+ if (defined $prevpage) {
+ add_depends($album, $prevpage);
+ $prev = show_prevnext($prevpage, $viewer, "prev");
+ }
+
+ my $img;
+ if (IkiWiki::isinlinableimage($image)) {
+ my $title = IkiWiki::Plugin::trail::title_of($viewer);
+
+ $img = IkiWiki::Plugin::img::preprocess("$image" => undef,
+ title => $title,
+ alt => $title,
+ size => ($params{size} or 'full'),
+ page => $viewer,
+ destpage => $params{destpage});
+ }
+ else {
+ $img = htmllink($viewer, $params{destpage}, "/$image");
+ }
+
+ my $viewertemplate = template(
+ $pagestate{$album}{album}{viewertemplate} or
+ 'albumviewer.tmpl');
+ $viewertemplate->param(page => $viewer,
+ img => $img,
+ next => $next,
+ prev => $prev);
+
+ IkiWiki::run_hooks(pagetemplate => sub {
+ shift->(page => $viewer, destpage => $params{destpage},
+ template => $viewertemplate);
+ });
+
+ return $viewertemplate->output;
+}
+
+sub pagetemplate (@) {
+ my %params=@_;
+ my $template = $params{template};
+
+ eval q{use Image::Magick};
+ return if $@;
+
+ if (exists $pagestate{$params{page}}{album}{image}) {
+ # the page is a viewer, maybe treat it specially
+ my $viewer = $params{page};
+ my $album = $pagestate{$viewer}{album}{album};
+ my $image = $pagestate{$viewer}{album}{image};
+
+ return unless defined $album;
+ return unless defined $image;
+
+ my $title = IkiWiki::Plugin::trail::title_of($viewer);
+ $template->param(album => $album);
+ $template->param(albumurl => urlto($album, $params{destpage}));
+ $template->param(albumtitle => $title);
+
+ if ($template->query(name => 'thumbnail')) {
+ $template->param(thumbnail =>
+ thumbnail($viewer, $params{destpage}));
+ }
+ if (IkiWiki::isinlinableimage($image)
+ && ($template->query(name => 'imagewidth') ||
+ $template->query(name => 'imageheight') ||
+ $template->query(name => 'imagefilesize') ||
+ $template->query(name => 'imageformat'))) {
+ my $im = Image::Magick->new;
+ my ($w, $h, $s, $f) = $im->Ping(srcfile($image, 1));
+ $s = IkiWiki::Plugin::filecheck::humansize($s);
+ $template->param(imagewidth => $w, imageheight => $h,
+ imagefilesize => $s, imageformat => $f);
+ }
+ if ($template->query(name => 'caption')) {
+ $template->param(caption =>
+ $pagestate{$viewer}{album}{caption});
+ }
+ }
+}
+
+sub show_prevnext {
+ my ($page, $destpage, $which) = @_;
+
+ my $template = template("album$which.tmpl", blind_cache => 1);
+ my $title = IkiWiki::Plugin::trail::title_of($page);
+ $template->param(
+ pageurl => urlto($page, $destpage),
+ title => $title,
+ ctime => displaytime($IkiWiki::pagectime{$page}),
+ mtime => displaytime($IkiWiki::pagemtime{$page}),
+ );
+
+ IkiWiki::run_hooks(pagetemplate => sub {
+ shift->(page => $page, destpage => $destpage,
+ template => $template);
+ });
+
+ return $template->output;
+}
+
+package IkiWiki::SortSpec;
+
+sub cmp_albumorder {
+ # Firstly, are they even in the same album? If not, order is
+ # indeterminate - so let's just compare the paths.
+ my $album = $IkiWiki::pagestate{$a}{album}{album};
+ my $album2 = $IkiWiki::pagestate{$b}{album}{album};
+
+ if (! defined $album || ! defined $album2 || $album ne $album2) {
+ return $a cmp $b;
+ }
+
+ # OK, now we need to work out where they are in the album.
+ # We do this lazily because until the scan stage has finished,
+ # it could change.
+ IkiWiki::Plugin::album::collect_images($album) unless $albumsections{$album};
+
+ return $albumorder{$a} <=> $albumorder{$b};
+
+}
+
+1;
diff --git a/doc/style.css b/doc/style.css
index 8c16e7a2f..c0f1a8343 100644
--- a/doc/style.css
+++ b/doc/style.css
@@ -549,47 +549,71 @@ a.login_large_btn:focus {
display: none;
}
-/* mobile/small-screen-friendly layout */
-@media (max-width: 600px) {
- .sidebar {
- width: auto;
- float: none;
- margin-top: 0;
- border: none;
- }
-
- /* if the mobile browser is new enough, use flex layout to shuffle
- * the sidebar to the end */
- .page {
- display: -webkit-box;
- display: -webkit-flexbox;
- display: -webkit-flex;
- display: -moz-box;
- display: -ms-flexbox;
- display: flex;
- -webkit-box-orient: vertical;
- -webkit-flex-direction: tb;
- -webkit-flex-direction: column;
- -webkit-flex-flow: column;
- -ms-flex-direction: column;
- flex-direction: column;
- }
- #pageheader {
- -webkit-box-ordinal-group: -1;
- -webkit-order: -1;
- -ms-box-ordinal-group: -1;
- -ms-flex-order: -1;
- order: -1;
- }
- .sidebar, #footer {
- -webkit-box-ordinal-group: 1;
- -webkit-order: 1;
- -ms-box-ordinal-group: 1;
- -ms-flex-order: 1;
- order: 1;
- }
-
- .blogform, #blogform {
- padding: 4px 4px;
- }
+.album, .album-end, .album-item { clear: both; }
+
+.album-arrow {
+ font-size: 400%;
+}
+
+.album-item {
+ padding: 2px 0px;
+}
+
+.album-viewer {
+ position: relative;
+}
+.album-prev, .album-next, .album-finish {
+ position: absolute;
+ top: 0%;
+ height: 100%;
+ width: 100px;
+}
+.album-prev a, .album-next a, .album-finish a {
+ display: block;
+ padding-top: 1em;
+ width: 100%;
+ height: 100%;
+ text-decoration: none;
+}
+.album-prev .album-thumbnail, .album-next .album-thumbnail {
+ position: absolute;
+ top: 8em;
+}
+.album-prev { left: 0px; }
+.album-next, .album-finish { right: 0px; }
+#album-img {
+ clear: both;
+ margin-left: 100px;
+ margin-right: 100px;
+ text-align: center;
+}
+.album-item .album-title,
+.album-item .album-metadata,
+.album-item .album-caption {
+ margin-left: 120px;
+}
+.album-item .album-thumbnail {
+ float: left;
+ padding: 8px;
+ margin-right: 0px;
+ margin-left: auto;
+ text-align: right;
+}
+.album-thumbnail {
+ min-height: 72px;
+ min-width: 96px;
+}
+.album-item .album-metadata {
+ padding: 8px;
+ font-size: small;
+}
+.album-item .album-size, .album-item .album-ctime {
+ padding-right: 1em;
+}
+.album-item .album-title {
+ font-weight: bold;
+ font-size: larger;
+}
+.album-item .album-title a {
+ text-decoration: underline;
}
diff --git a/doc/templates.mdwn b/doc/templates.mdwn
index 378e579ba..d787d04c9 100644
--- a/doc/templates.mdwn
+++ b/doc/templates.mdwn
@@ -89,6 +89,10 @@ Here is a full list of the template files used:
that is a member of a trail.
* `notifyemail.tmpl` - Used by the notifymail plugin to generate mails about
changed pages.
+* `albumitem.tmpl` - Used by the album plugin to display the images in an
+ album.
+* `albumviewer.tmpl`, `albumprev.tmpl`, `albumnext.tmpl` - Used by the
+ album plugin to display each image on its own page.
* `editpage.tmpl`, `editconflict.tmpl`, `editcreationconflict.tmpl`,
`editfailedsave.tmpl`, `editpagegone.tmpl`, `pocreatepage.tmpl`,
`editcomment.tmpl` `commentmoderation.tmpl`, `renamesummary.tmpl`,
diff --git a/t/album.t b/t/album.t
new file mode 100755
index 000000000..2807f2247
--- /dev/null
+++ b/t/album.t
@@ -0,0 +1,153 @@
+#!/usr/bin/perl
+use warnings;
+use strict;
+BEGIN {
+ foreach my $module (qw(Image::Magick)) {
+ eval qq{use $module};
+ if ($@) {
+ eval qq{
+ use Test::More skip_all => "$module not available"
+ }
+ }
+ }
+}
+use Test::More 'no_plan';
+
+BEGIN { use_ok("IkiWiki"); }
+
+my $blob;
+
+ok(! system("rm -rf t/tmp"));
+ok(mkdir("t/tmp"));
+ok(mkdir("t/tmp/in"));
+ok(mkdir("t/tmp/in/myalbum"));
+
+my $image = Image::Magick->new;
+$image->Set(size => '640x480');
+$image->Read('canvas:red');
+$image->Draw(fill => 'yellow', primitive => 'rectangle',
+ points => '0,0 100,100');
+$image->Write('t/tmp/in/myalbum/r.jpg');
+
+$image = Image::Magick->new;
+$image->Set(size => '480x640');
+$image->Read('canvas:green');
+$image->Draw(fill => 'yellow', primitive => 'rectangle',
+ points => '0,0 100,100');
+$image->Write('t/tmp/in/myalbum/g.png');
+
+$image = Image::Magick->new;
+$image->Set(size => '640x640');
+$image->Read('canvas:blue');
+$image->Draw(fill => 'yellow', primitive => 'rectangle',
+ points => '0,0 100,100');
+$image->Write('t/tmp/in/myalbum/b.gif');
+
+writefile("myalbum.mdwn", "t/tmp/in", <<END);
+## Section: green
+[[!albumsection filter="*/g"]]
+## Assorted
+[[!album sort="title"]]
+END
+writefile("myalbum/page.mdwn", "t/tmp/in", "not an image");
+writefile("style.css", "t/tmp/in", readfile("doc/style.css"));
+
+ok(! system("make -s ikiwiki.out"));
+
+my $command = "perl -Iblib/lib ./ikiwiki.out -set usedirs=0 -plugin album -url=http://example.com -cgiurl=http://example.com/ikiwiki.cgi -underlaydir=underlays/basewiki -set underlaydirbase=underlays -templatedir=templates t/tmp/in t/tmp/out -verbose";
+
+ok(! system($command));
+# make sure subsequent changes will have a newer mtime
+sleep(1);
+
+# myalbum is an album and a trail
+# the album's members are inlined into it
+# the albumsection displays all matching pages (only myalbum/g)
+# the album displays the rest
+$blob = readfile("t/tmp/out/myalbum.html");
+like($blob, qr{
+ ^<h2[^>]*>Section:\s+green</h2>$
+ .*?
+ <a\s+href="\./myalbum/g\.html">
+ .*?
+ <img\s+src="\./myalbum/g/96x96-g\.png"
+ .*?
+ ^<h2[^>]*>Assorted</h2>$
+ .*?
+ <a\s+href="\./myalbum/b\.html">
+ .*?
+ <img\s+src="\./myalbum/b/96x96-b\.gif"
+ .*?
+ <a\s+href="\./myalbum/r\.html">
+ .*?
+ <img\s+src="\./myalbum/r/96x96-r\.jpg"
+ }msx);
+unlike($blob, qr{myalbum/page\.html});
+
+# myalbum/b is part of the trail defined by myalbum
+$blob = readfile("t/tmp/out/myalbum/b.html");
+like($blob, qr{
+ <link\s+rel="prev"\s+href="\./g\.html"
+ .*?
+ <link\s+rel="up"\s+href="\.\./myalbum\.html"
+ .*?
+ <link\s+rel="next"\s+href="\./r\.html"
+ }msx);
+# myalbum/b shows the full-size image
+like($blob, qr{
+ <div\s+id="album-img">
+ .*?
+ <img\s+src="\./b\.gif"
+ }msx);
+
+# change some stuff and refresh
+$blob = readfile("t/tmp/in/.ikiwiki/transient/myalbum/b.mdwn");
+like($blob, qr{\[\[!albumimage\s.*\]\]}msx);
+$blob =~ s/^(\s*)title="+$/${1}title="a blue box"/msx;
+writefile("myalbum/b.mdwn", "t/tmp/in", "hello, world!\n\n$blob");
+
+# make sure subsequent changes take effect
+ok(! system("$command -refresh"));
+
+# myalbum is an album and a trail
+# the album's members are inlined into it
+# the albumsection displays all matching pages (only myalbum/g)
+# the album displays the rest
+$blob = readfile("t/tmp/out/myalbum.html");
+like($blob, qr{
+ ^<h2[^>]*>Section:\s+green</h2>$
+ .*?
+ <a\s+href="\./myalbum/g\.html">
+ .*?
+ <img\s+src="\./myalbum/g/96x96-g\.png"
+ .*?
+ ^<h2[^>]*>Assorted</h2>$
+ .*?
+ <a\s+href="\./myalbum/b\.html">
+ .*?
+ <img\s+src="\./myalbum/b/96x96-b\.gif"
+ .*?
+ <a\s+href="\./myalbum/r\.html">
+ .*?
+ <img\s+src="\./myalbum/r/96x96-r\.jpg"
+ }msx);
+unlike($blob, qr{myalbum/page\.html});
+
+# myalbum/b is part of the trail defined by myalbum
+$blob = readfile("t/tmp/out/myalbum/b.html");
+like($blob, qr{
+ <link\s+rel="prev"\s+href="\./g\.html"
+ .*?
+ <link\s+rel="up"\s+href="\.\./myalbum\.html"
+ .*?
+ <link\s+rel="next"\s+href="\./r\.html"
+ }msx);
+# myalbum/b shows the full-size image, now with title and additional text
+like($blob, qr{<title>a blue box</title>});
+like($blob, qr{
+ <p>hello,\s+world!</p>
+ .*?
+ <div\s+id="album-img">
+ .*?
+ <img\s+src="\./b\.gif"
+ }msx);
diff --git a/templates/albumitem.tmpl b/templates/albumitem.tmpl
new file mode 100644
index 000000000..28cd43db0
--- /dev/null
+++ b/templates/albumitem.tmpl
@@ -0,0 +1,34 @@
+<TMPL_IF NAME=FIRST>
+<div class="album">
+</TMPL_IF>
+
+<div class="album-item">
+ <div class="album-thumbnail">
+ <TMPL_VAR NAME=THUMBNAIL>
+ </div>
+ <div class="album-title">
+ <a href="<TMPL_VAR NAME=PAGEURL>"><TMPL_VAR NAME=TITLE></a>
+ </div>
+ <div class="album-metadata">
+ <TMPL_IF NAME=CTIME>
+ <span class="album-ctime">
+ <TMPL_VAR NAME=CTIME>
+ </span>
+ </TMPL_IF>
+ <TMPL_IF NAME=IMAGEWIDTH>
+ <span class="album-dimensions"><TMPL_VAR NAME=IMAGEWIDTH ESCAPE=HTML>
+ &#xd7; <TMPL_VAR NAME=IMAGEHEIGHT ESCAPE=HTML></span>
+ </TMPL_IF>
+ <TMPL_IF NAME=IMAGEFORMAT>
+ <span class="album-format"><TMPL_VAR NAME=IMAGEFORMAT ESCAPE=HTML></span>
+ </TMPL_IF>
+ <TMPL_IF NAME=IMAGEFILESIZE>
+ <span class="album-size"><TMPL_VAR NAME=IMAGEFILESIZE ESCAPE=HTML></span>
+ </TMPL_IF>
+ </div>
+ <div class="album-caption"><TMPL_VAR NAME=CAPTION></div>
+</div>
+
+<TMPL_IF NAME=LAST>
+</div> <div class="album-end"></div>
+</TMPL_IF>
diff --git a/templates/albumnext.tmpl b/templates/albumnext.tmpl
new file mode 100644
index 000000000..7e92b24e1
--- /dev/null
+++ b/templates/albumnext.tmpl
@@ -0,0 +1,6 @@
+<div class="album-next">
+ <a href="<TMPL_VAR NAME=PAGEURL>"><span class="album-arrow">→</span></a><br />
+ <div class="album-thumbnail">
+ <TMPL_VAR NAME=THUMBNAIL>
+ </div>
+</div>
diff --git a/templates/albumprev.tmpl b/templates/albumprev.tmpl
new file mode 100644
index 000000000..36c00d8f6
--- /dev/null
+++ b/templates/albumprev.tmpl
@@ -0,0 +1,6 @@
+<div class="album-prev">
+ <a href="<TMPL_VAR NAME=PAGEURL>"><span class="album-arrow">←</span></a><br />
+ <div class="album-thumbnail">
+ <TMPL_VAR NAME=THUMBNAIL>
+ </div>
+</div>
diff --git a/templates/albumviewer.tmpl b/templates/albumviewer.tmpl
new file mode 100644
index 000000000..aeb0abe4f
--- /dev/null
+++ b/templates/albumviewer.tmpl
@@ -0,0 +1,16 @@
+<div class="album-viewer">
+ <div id="album-img">
+ <TMPL_VAR NAME="PREV">
+
+ <TMPL_IF NEXT>
+ <TMPL_VAR NAME="NEXT">
+ <TMPL_ELSE>
+ <div class="album-finish">
+ <a href="<TMPL_VAR NAME=ALBUMURL>"><span class="album-arrow">↑</span></a>
+ </div>
+
+ </TMPL_IF>
+
+ <TMPL_VAR NAME="IMG">
+ </div>
+</div>