Album plugin v3, based on trail v3
[smcv/ikiwiki.git] / IkiWiki / Plugin / album.pm
1 #!/usr/bin/perl
2 # Copyright © 2009-2011 Simon McVittie <http://smcv.pseudorandom.co.uk/>
3 # Licensed under the GNU GPL, version 2, or any later version published by the
4 # Free Software Foundation
5 package IkiWiki::Plugin::album;
6
7 use warnings;
8 use strict;
9 use IkiWiki 3.00;
10
11 sub import {
12         hook(type => "getsetup", id => "album",  call => \&getsetup);
13         hook(type => "needsbuild", id => "album", call => \&needsbuild);
14         hook(type => "preprocess", id => "album",
15                 call => \&preprocess_album, scan => 1);
16         hook(type => "preprocess", id => "albumsection",
17                 call => \&preprocess_albumsection, scan => 1);
18         hook(type => "preprocess", id => "albumimage",
19                 call => \&preprocess_albumimage, scan => 1);
20         hook(type => "pagetemplate", id => "album", call => \&pagetemplate);
21
22         # We need these plugins. Additionally, meta is recommended.
23         IkiWiki::loadplugin("filecheck");
24         IkiWiki::loadplugin("img");
25         IkiWiki::loadplugin("inline");
26         IkiWiki::loadplugin("trail");
27         IkiWiki::loadplugin("transient");
28 }
29
30 sub getsetup () {
31         return
32                 plugin => {
33                         safe => 1,
34                         rebuild => undef,
35                         section => "widget",
36                 },
37                 # FIXME: unimplemented
38                 album_copydir => {
39                         type => "string",
40                         example => "$ENV{HOME}/photos-to-upload",
41                         description => "if set, copy photos to this directory at reduced size",
42                         advanced => 1,
43                         safe => 0,
44                         rebuild => 1,
45                 },
46 }
47
48 # Terminology:
49 #
50 # album - main page for an album/gallery, contains a list of inlined viewers
51 # viewer - page generated to contain/display/represent one image
52 # section - a subset of the viewers in an album
53 #
54 # Page state for albums:
55 #
56 # size - default size for viewers in this album
57 # thumbnailsize - size to resize thumbnails to
58 # viewertemplate - template to use for viewers
59 # nexttemplate - template for "next image", as embedded in viewers
60 # prevtemplate - template for "previous image", as embedded in viewers
61 # sort - as for inline
62 # sections - ref to array of pagespecs representing sections
63 # viewers - list of viewers' names
64 #
65 # Page state for image viewers:
66 #
67 # album - full name of associated album
68 # image - full name of image file
69 # caption - caption if any
70 # size, thumbnailsize, viewertemplate, nexttemplate, prevtemplate -
71 #       override the corresponding option for the album
72
73 sub isalbumableimage ($) {
74         my $file=shift;
75
76         return $file =~ /\.(png|gif|jpg|jpeg|mov)$/i;
77 }
78
79 sub needsbuild {
80         my $needsbuild = shift;
81         my $deleted = shift;
82
83         foreach my $page (@$needsbuild, @$deleted) {
84                 # it's neither an album nor a viewer, unless it later says
85                 # it is
86                 delete $pagestate{$page}{album};
87         }
88
89         return $needsbuild;
90 }
91
92 sub thumbnail {
93         my $viewer = shift;
94         my $destpage = shift;
95         my $thumbnailsize = shift;
96         my $image = $pagestate{$viewer}{album}{image};
97
98         # img requires that each file is generated by one page, so nominate
99         # the viewer as the page that makes the thumbnail
100         my $img = "";
101         my $title = IkiWiki::Plugin::trail::title_of($viewer);
102
103         if (IkiWiki::isinlinableimage($image)) {
104                 $img = IkiWiki::Plugin::img::preprocess(
105                         "$image" => undef,
106                         link => "/$viewer",
107                         size => ($thumbnailsize or '96x96'),
108                         alt => $title,
109                         title => $title,
110                         page => $viewer,
111                         destpage => $destpage);
112         }
113
114         return $img;
115 }
116
117 sub show_in_album {
118         my $viewers = shift;
119         my %params = @_;
120
121         return IkiWiki::preprocess_inline(
122                 pagenames => join(" ", @$viewers),
123                 actions => "no",
124                 feeds => "no",
125                 template => "albumitem",
126                 %params);
127 }
128
129 # album => [ list of viewers ]
130 my %scanned;
131
132 sub create_viewer {
133         my ($viewer, $image, $album) = @_;
134
135         my $vfile = newpagefile($viewer, $config{default_pageext});
136
137         # FIXME: try to read creation date, copyright etc. from EXIF tags
138
139         add_autofile($vfile, "album", sub {
140                 my $message = sprintf(gettext("creating album page %s"), $viewer);
141                 debug($message);
142
143                 my $content = <<"END";
144 [[!albumimage
145   title=""
146   caption=""
147   date=""
148   updated=""
149   author=""
150   authorurl=""
151   copyright=""
152   license=""
153   description=""
154   ]]
155 END
156                 # size, thumbnailsize, viewertemplate, prevtemplate,
157                 # nexttemplate aren't in the generated page because
158                 # they're not expected to be commonly used - setting
159                 # them for an entire album is likely to be more useful
160
161                 writefile($vfile, $IkiWiki::Plugin::transient::transientdir,
162                         $content);
163         });
164 }
165
166 sub scan_images {
167         my $album = $_[0];
168
169         # There are two purposes to this, optimization (don't bother
170         # re-scanning images) and correctness (when we're preprocessing
171         # after the scan stage, we don't want to re-run scan_binary because
172         # it would override the metadata from [[!albumimage]]).
173         return @{$scanned{$album}} if exists $scanned{$album};
174
175         # All the images that are attached to an album or its subpages count as
176         # (potential) members of it. For each one, we synthesize a page if no
177         # page exists.
178         my $regexp = qr{^\Q$album\E/}i;
179         my @viewers;
180
181         # collect the images
182         foreach my $candidate (keys %pagesources) {
183                 if ($candidate =~ $regexp &&
184                         isalbumableimage($candidate)) {
185                         my $viewer = $candidate;
186                         $viewer =~ s/\.[^.]+$//;
187
188                         if (exists $IkiWiki::pagecase{lc $viewer}) {
189                                 $viewer = $IkiWiki::pagecase{lc $viewer};
190                         }
191                         else {
192                                 create_viewer($viewer, $candidate, $album);
193                         }
194
195                         push @viewers, $viewer;
196
197                         # FIXME: what if albums are nested? Current resolution
198                         # is that the image goes in a randomly selected album.
199                         # Because pages are scanned in arbitrary order, I
200                         # don't think we can do better.
201                         $pagestate{$viewer}{album}{album} = $album;
202
203                         # FIXME: what if there's more than one image file
204                         # with the same basename? Current resolution is that
205                         # a random one "wins".
206                         $pagestate{$viewer}{album}{image} = $candidate;
207                 }
208         }
209
210         $scanned{$album} = \@viewers;
211         return @viewers;
212 }
213
214 # These hashes are populated by collect_images whenever an album, or any
215 # image in that album, has changed.
216
217 # album => { filter => array of viewers in that section }
218 # filter "" is the catch-all for images in no other section
219 my %albumsections;
220 # linked list of viewers in each album
221 my (%before, %after);
222 # viewer => index number in its album
223 my %albumorder;
224
225 sub collect_images {
226         my $album = shift;
227
228         return if $albumsections{$album};
229
230         my $sort = $pagestate{$album}{album}{sort};
231
232         if (!defined $sort) {
233                 $sort = '-age';
234         }
235
236         my %sections;
237         my $_; # localize iterator variable
238
239         my @remaining = @{$pagestate{$album}{album}{viewers}};
240
241         foreach my $filter (@{$pagestate{$album}{album}{sections}}) {
242                 next if $filter eq '';
243                 my @section = pagespec_match_list($album,
244                         $filter, sort => $sort,
245                         list => [@remaining]);
246                 my %set = map { $_ => 1 } @section;
247
248                 @remaining = grep { ! exists $set{$_} } @remaining;
249
250                 $sections{$filter} = \@section;
251         }
252
253         # the pagespec here matches everything; the part we actually want
254         # is the sorting
255         $sections{""} = [pagespec_match_list($album,
256                 "internal(*)", sort => $sort,
257                 list => [@remaining])];
258
259         my @ordered;
260
261         foreach my $filter (@{$pagestate{$album}{album}{sections}}) {
262                 my $pages;
263                 $pages = $sections{$filter};
264                 push @ordered, @$pages;
265                 $albumsections{$album}{$filter} = $pages;
266         }
267
268         for (my $i = 0; $i <= $#ordered; $i++) {
269                 my $viewer = $ordered[$i];
270                 $albumorder{$viewer} = $i;
271                 # We don't need to track what's before or after:
272                 # trail has API for that.
273         }
274 }
275
276 sub preprocess_album {
277         # [[!album]]
278         my %params=@_;
279         my $album = $params{page};
280
281         my @viewers = scan_images($album);
282
283         # placeholder for the "remaining images" section
284         push @{$pagestate{$album}{album}{sections}}, ""
285                 unless grep { $_ eq "" }
286                         @{$pagestate{$album}{album}{sections}};
287
288         $pagestate{$album}{album} = {
289                 sort => $params{sort},
290                 size => $params{size},
291                 thumbnailsize => $params{thumbnailsize},
292                 viewertemplate => $params{viewertemplate},
293                 nexttemplate => $params{nexttemplate},
294                 prevtemplate => $params{prevtemplate},
295                 viewers => [@viewers],
296                 # in the render phase, we want to keep the sections that we
297                 # accumulated during the scan phase, if any
298                 sections => $pagestate{$album}{album}{sections},
299         };
300
301         # The sort order depends on matching pagespecs for each
302         # section, so we can't define it yet - delegate it to a
303         # sortspec defined by this plugin, which can collect the
304         # images lazily.
305         IkiWiki::Plugin::trail::preprocess_trailoptions(
306                 sort => 'albumorder',
307                 page => $album,
308                 destpage => $params{destpage},
309         );
310
311         my %trailparams = (
312                 pagenames => join(' ', @viewers),
313                 page => $album,
314                 destpage => $params{destpage},
315         );
316
317         if (defined wantarray) {
318                 collect_images($album) unless $albumsections{$album};
319
320                 scalar IkiWiki::Plugin::trail::preprocess_trailitems(%trailparams);
321
322                 return show_in_album($albumsections{$album}{""},
323                         page => $album,
324                         destpage => $params{destpage});
325         }
326         else {
327                 IkiWiki::Plugin::trail::preprocess_trailitems(%trailparams);
328         }
329 }
330
331 sub preprocess_albumsection {
332         # [[!albumsection filter="friday/*"]]
333         my %params=@_;
334         my $album = $params{page};
335         my $filter = $params{filter};
336         my $_;
337
338         # remember the filter for this section so the "remaining images" section
339         # won't include these images (this needs to be run in the scan stage
340         # so the info will be there for the album directive)
341         push @{$pagestate{$album}{album}{sections}}, $filter
342                 unless grep { $_ eq $filter }
343                         @{$pagestate{$album}{album}{sections}};
344
345         # If we're just scanning, don't bother producing output
346         return unless defined wantarray;
347
348         collect_images($album) unless $albumsections{$album};
349
350         return show_in_album($albumsections{$album}{$filter},
351                 page => $album,
352                 destpage => $params{destpage});
353 }
354
355 sub preprocess_albumimage {
356         my %params=@_;
357         my $viewer = $params{page};
358
359         my $album = $pagestate{$viewer}{album}{album};
360         my $image = $pagestate{$viewer}{album}{image};
361
362         if (! defined $album || ! defined $image) {
363                 error(sprintf(gettext("%s is not in any album"), $viewer));
364         }
365
366         $pagestate{$viewer}{album} = {
367                 album => $album,
368                 image => $image,
369         };
370
371         # [[!albumimage title=foo copyright=bar]] is a shortcut for a couple
372         # of [[!meta]] invocations. Zero-length metadata gets ignored, so we
373         # can put all the available metadata in the template album page,
374         # with zero-length values.
375         if (IkiWiki::Plugin::meta->can('preprocess')) {
376                 foreach my $meta (qw(title date updated author authorurl
377                         copyright license description)) {
378                         if (defined $params{$meta} && length $params{$meta}) {
379                                 IkiWiki::Plugin::meta::preprocess(
380                                         $meta => $params{$meta},
381                                         page => $viewer);
382                         }
383                 }
384         }
385
386         # The thumbnail has to have exactly one source, so thumbnail() always
387         # claims that it is this page. To avoid ikiwiki deleting the
388         # thumbnail, we need to make sure will_render() gets called, and this
389         # seems the easiest way to do that.
390         if (defined wantarray) {
391                 my $foo = thumbnail($viewer, $viewer,
392                         $pagestate{$album}{album}{thumbnailsize});
393         }
394         else {
395                 thumbnail($viewer, $viewer,
396                         $pagestate{$album}{album}{thumbnailsize});
397         }
398
399         add_depends($viewer, $album);
400
401         # If we're just scanning, don't bother producing output
402         return unless defined wantarray;
403
404         # Copy various settings from the album, unless overridden
405         foreach my $k (qw(viewertemplate nexttemplate prevtemplate size)) {
406                 $params{$k} = $pagestate{$album}{album}{$k}
407                         unless defined $params{$k} && length $params{$k};
408         }
409
410         # Because we're no longer in the scan phase, we know that
411         # preprocess_album has already run, so we know:
412         # - which album this photo appears in
413         # - what order the album is in
414         # (either because the scan phase has run for modified pages, or
415         # because the pagestate was loaded from last time for unmodified
416         # pages.)
417
418         collect_images($album) unless $albumsections{$album};
419
420         my ($prevpage, $nextpage) = IkiWiki::Plugin::trail::nearby_pages($album, $viewer);
421
422         my $prev = '';
423         my $next = '';
424
425         if (defined $nextpage) {
426                 add_depends($album, $nextpage);
427                 $next = show_prevnext($nextpage, $viewer, "next");
428         }
429
430         if (defined $prevpage) {
431                 add_depends($album, $prevpage);
432                 $prev = show_prevnext($prevpage, $viewer, "prev");
433         }
434
435         my $img;
436         if (IkiWiki::isinlinableimage($image)) {
437                 my $title = IkiWiki::Plugin::trail::title_of($viewer);
438
439                 $img = IkiWiki::Plugin::img::preprocess("$image" => undef,
440                         title => $title,
441                         alt => $title,
442                         size => ($params{size} or 'full'),
443                         page => $viewer,
444                         destpage => $params{destpage});
445         }
446         else {
447                 $img = htmllink($viewer, $params{destpage}, "/$image");
448         }
449
450         my $viewertemplate = template(
451                 $pagestate{$album}{album}{viewertemplate} or
452                 'albumviewer.tmpl');
453         $viewertemplate->param(page => $viewer,
454                 img => $img,
455                 next => $next,
456                 prev => $prev);
457
458         IkiWiki::run_hooks(pagetemplate => sub {
459                 shift->(page => $viewer, destpage => $params{destpage},
460                         template => $viewertemplate);
461         });
462
463         return $viewertemplate->output;
464 }
465
466 sub pagetemplate (@) {
467         my %params=@_;
468         my $template = $params{template};
469
470         eval q{use Image::Magick};
471         return if $@;
472
473         if (exists $pagestate{$params{page}}{album}{image}) {
474                 # the page is a viewer, maybe treat it specially
475                 my $viewer = $params{page};
476                 my $album = $pagestate{$viewer}{album}{album};
477                 my $image = $pagestate{$viewer}{album}{image};
478
479                 return unless defined $album;
480                 return unless defined $image;
481
482                 my $title = IkiWiki::Plugin::trail::title_of($viewer);
483                 $template->param(album => $album);
484                 $template->param(albumurl => urlto($album, $params{destpage}));
485                 $template->param(albumtitle => $title);
486
487                 if ($template->query(name => 'thumbnail')) {
488                         $template->param(thumbnail =>
489                                 thumbnail($viewer, $params{destpage}));
490                 }
491                 if (IkiWiki::isinlinableimage($image)
492                         && ($template->query(name => 'imagewidth') ||
493                                 $template->query(name => 'imageheight') ||
494                                 $template->query(name => 'imagefilesize') ||
495                                 $template->query(name => 'imageformat'))) {
496                         my $im = Image::Magick->new;
497                         my ($w, $h, $s, $f) = $im->Ping(srcfile($image, 1));
498                         $s = IkiWiki::Plugin::filecheck::humansize($s);
499                         $template->param(imagewidth => $w, imageheight => $h,
500                                 imagefilesize => $s, imageformat => $f);
501                 }
502                 if ($template->query(name => 'caption')) {
503                         $template->param(caption =>
504                                 $pagestate{$viewer}{album}{caption});
505                 }
506         }
507 }
508
509 sub show_prevnext {
510         my ($page, $destpage, $which) = @_;
511
512         my $template = template("album$which.tmpl", blind_cache => 1);
513         my $title = IkiWiki::Plugin::trail::title_of($page);
514         $template->param(
515                 pageurl => urlto($page, $destpage),
516                 title => $title,
517                 ctime => displaytime($IkiWiki::pagectime{$page}),
518                 mtime => displaytime($IkiWiki::pagemtime{$page}),
519                 );
520
521         IkiWiki::run_hooks(pagetemplate => sub {
522                 shift->(page => $page, destpage => $destpage,
523                         template => $template);
524         });
525
526         return $template->output;
527 }
528
529 package IkiWiki::SortSpec;
530
531 sub cmp_albumorder {
532         # Firstly, are they even in the same album? If not, order is
533         # indeterminate - so let's just compare the paths.
534         my $album = $IkiWiki::pagestate{$a}{album}{album};
535         my $album2 = $IkiWiki::pagestate{$b}{album}{album};
536
537         if (! defined $album || ! defined $album2 || $album ne $album2) {
538                 return $a cmp $b;
539         }
540
541         # OK, now we need to work out where they are in the album.
542         # We do this lazily because until the scan stage has finished,
543         # it could change.
544         IkiWiki::Plugin::album::collect_images($album) unless $albumsections{$album};
545
546         return $albumorder{$a} <=> $albumorder{$b};
547
548 }
549
550 1;