#!/usr/bin/perl # Copyright © 2009-2011 Simon McVittie # Copyright © 2013 Lukas Lipavsky # # 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"); IkiWiki::loadplugin("tag"); } 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 # tags - list of tags that should be added to viewers # # 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", show => 0, %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; local $_; # 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; } $sections{""} = [IkiWiki::sort_pages($sort, \@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); my $tags = defined($params{tag}) ? $params{tag} : ""; # 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], tags => [split(' ', $tags)], # 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}; local $_; # 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"); } # Tags (always return "") IkiWiki::Plugin::tag::preprocess_tag( page => $viewer, destpage => $params{destpage}, map { ($_ => 1) } @{$pagestate{$album}{album}{tags}}, ); 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}; my $thumbnailsize = $pagestate{$album}{album}{thumbnailsize}; 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}, $thumbnailsize)); } 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;