From 7cc54e7f347c95733452ebfc22f33031f96854ca Mon Sep 17 00:00:00 2001 From: Simon McVittie Date: Fri, 6 Apr 2012 23:26:54 +0100 Subject: Album plugin v4 --- IkiWiki/Plugin/album.pm | 559 +++++++++++++++++++++++++++++++++++++++++++++ doc/style.css | 69 ++++++ doc/templates.mdwn | 4 + t/album.t | 153 +++++++++++++ templates/albumitem.tmpl | 34 +++ templates/albumnext.tmpl | 6 + templates/albumprev.tmpl | 6 + templates/albumviewer.tmpl | 16 ++ 8 files changed, 847 insertions(+) create mode 100644 IkiWiki/Plugin/album.pm create mode 100755 t/album.t create mode 100644 templates/albumitem.tmpl create mode 100644 templates/albumnext.tmpl create mode 100644 templates/albumprev.tmpl create mode 100644 templates/albumviewer.tmpl 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 +# 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 4c48e5c7b..0969f0922 100644 --- a/doc/style.css +++ b/doc/style.css @@ -548,3 +548,72 @@ a.openid_large_btn:focus { .trailsep { display: none; } + +.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 d0f891c21..a22120384 100644 --- a/doc/templates.mdwn +++ b/doc/templates.mdwn @@ -84,6 +84,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", <]*>Section:\s+green$ + .*? + + .*? + ]*>Assorted$ + .*? + + .*? + + .*? + + .*? + ]*>Section:\s+green$ + .*? + + .*? + ]*>Assorted$ + .*? + + .*? + + .*? + a blue box}); +like($blob, qr{ +

hello,\s+world!

+ .*? + + .*? + +
+ + +
+
+ +
+
+ +
+ +
+
+ + +
+ 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 @@ +
+
+
+ +
+
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 @@ +
+
+
+ +
+
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 @@ +
+
+ + + + + +
+ +
+ +
+ + +
+
-- cgit v1.2.3