#!/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;