trail: avoid collecting trail members twice
[ikiwiki.git] / IkiWiki / Plugin / trail.pm
1 #!/usr/bin/perl
2 # Copyright © 2008-2011 Joey Hess
3 # Copyright © 2009-2011 Simon McVittie <http://smcv.pseudorandom.co.uk/>
4 # Licensed under the GNU GPL, version 2, or any later version published by the
5 # Free Software Foundation
6 package IkiWiki::Plugin::trail;
7
8 use warnings;
9 use strict;
10 use IkiWiki 3.00;
11
12 sub import {
13         hook(type => "getsetup", id => "trail", call => \&getsetup);
14         hook(type => "needsbuild", id => "trail", call => \&needsbuild);
15         hook(type => "preprocess", id => "trail", call => \&preprocess_trail, scan => 1);
16         hook(type => "preprocess", id => "trailinline", call => \&preprocess_trailinline, scan => 1);
17         hook(type => "preprocess", id => "trailitem", call => \&preprocess_trailitem, scan => 1);
18         hook(type => "preprocess", id => "traillink", call => \&preprocess_traillink, scan => 1);
19         hook(type => "pagetemplate", id => "trail", call => \&pagetemplate);
20 }
21
22 =head1 Page state
23
24 If a page C<$T> is a trail, then it can have
25
26 =over
27
28 =item * C<$pagestate{$T}{trail}{contents}>
29
30 Reference to an array of pagespecs or links in the trail.
31
32 =item * C<$pagestate{$T}{trail}{sort}>
33
34 A [[ikiwiki/pagespec/sorting]] order; if absent or undef, the trail is in
35 the order given by the links that form it
36
37 =item * C<$pagestate{$T}{trail}{circular}>
38
39 True if this trail is circular (i.e. going "next" from the last item is
40 allowed, and takes you back to the first)
41
42 =item * C<$pagestate{$T}{trail}{reverse}>
43
44 True if C<sort> is to be reversed.
45
46 =back
47
48 If a page C<$M> is a member of a trail C<$T>, then it has
49
50 =over
51
52 =item * C<$pagestate{$M}{trail}{item}{$T}[0]>
53
54 The page before this one in C<$T> at the last rebuild, or undef.
55
56 =item * C<$pagestate{$M}{trail}{item}{$T}[1]>
57
58 The page after this one in C<$T> at the last refresh, or undef.
59
60 =back
61
62 =cut
63
64 sub getsetup () {
65         return
66                 plugin => {
67                         safe => 1,
68                         rebuild => undef,
69                 },
70 }
71
72 sub needsbuild (@) {
73         my $needsbuild=shift;
74         foreach my $page (keys %pagestate) {
75                 if (exists $pagestate{$page}{trail}) {
76                         if (exists $pagesources{$page} &&
77                             grep { $_ eq $pagesources{$page} } @$needsbuild) {
78                                 # Remove state, it will be re-added
79                                 # if the preprocessor directive is still
80                                 # there during the rebuild. {item} is the
81                                 # only thing that's added for items, not
82                                 # trails, and it's harmless to delete that -
83                                 # the item is being rebuilt anyway.
84                                 delete $pagestate{$page}{trail};
85                         }
86                 }
87         }
88         return $needsbuild;
89 }
90
91 my $scanned = 0;
92
93 =for wiki
94
95 The `trail` directive is supplied by the [[plugins/contrib/trail]]
96 plugin. It sets options for the trail represented by this page. Example usage:
97
98     \[[!trail sort="meta(date)" circular="no" pages="blog/posts/*"]]
99
100 The available options are:
101
102 * `sort`: sets a [[ikiwiki/pagespec/sorting]] order; if not specified, the
103   items of the trail are ordered according to the first link to each item
104   found on the trail page
105
106 * `circular`: if set to `yes` or `1`, the trail is made into a loop by
107   making the last page's "next" link point to the first page, and the first
108   page's "previous" link point to the last page
109
110 * `pages`: add the given pages to the trail
111
112 =cut
113
114 sub preprocess_trail (@) {
115         my %params = @_;
116
117         # avoid collecting everything in the preprocess stage if we already
118         # did in the scan stage
119         if (defined wantarray) {
120                 return "" if $scanned;
121         }
122         else {
123                 $scanned = 1;
124         }
125
126         if (exists $params{circular}) {
127                 $pagestate{$params{page}}{trail}{circular} =
128                         IkiWiki::yesno($params{circular});
129         }
130
131         if (exists $params{sort}) {
132                 $pagestate{$params{page}}{trail}{sort} = $params{sort};
133         }
134
135         if (exists $params{reverse}) {
136                 $pagestate{$params{page}}{trail}{reverse} = $params{reverse};
137         }
138
139         if (exists $params{pages}) {
140                 push @{$pagestate{$params{page}}{trail}{contents}}, "pagespec $params{pages}";
141         }
142
143         if (exists $params{pagenames}) {
144                 my @list = map { "link $_" } split ' ', $params{pagenames};
145                 push @{$pagestate{$params{page}}{trail}{contents}}, @list;
146         }
147
148         return "";
149 }
150
151 =for wiki
152
153 The `trailinline` directive is supplied by the [[plugins/contrib/trail]]
154 plugin. It behaves like the [[trail]] and [[inline]] directives combined.
155 Like [[inline]], it includes the selected pages into the page with the
156 directive and/or an RSS or Atom feed; like [[trail]], it turns the
157 included pages into a "trail" in which each page has a link to the
158 previous and next pages.
159
160     \[[!inline sort="meta(date)" circular="no" pages="blog/posts/*"]]
161
162 All the options for the [[inline]] and [[trail]] directives are valid.
163
164 The `show`, `skip` and `feedshow` options from [[inline]] do not apply
165 to the trail.
166
167 * `sort`: sets a [[ikiwiki/pagespec/sorting]] order; if not specified, the
168   items of the trail are ordered according to the first link to each item
169   found on the trail page
170
171 * `circular`: if set to `yes` or `1`, the trail is made into a loop by
172   making the last page's "next" link point to the first page, and the first
173   page's "previous" link point to the last page
174
175 * `pages`: add the given pages to the trail
176
177 =cut
178
179 sub preprocess_trailinline (@) {
180         my %params = @_;
181
182         if (defined wantarray) {
183                 scalar preprocess_trail(%params);
184
185                 if (IkiWiki->can("preprocess_inline")) {
186                         return IkiWiki::preprocess_inline(@_);
187                 }
188                 else {
189                         error("trailinline directive requires the inline plugin");
190                 }
191         }
192         else {
193                 preprocess_trail(%params);
194         }
195 }
196
197 =for wiki
198
199 The `trailitem` directive is supplied by the [[plugins/contrib/trail]] plugin.
200 It is used like this:
201
202     \[[!trailitem some_other_page]]
203
204 to add `some_other_page` to the trail represented by this page, without
205 generating a visible hyperlink.
206
207 =cut
208
209 sub preprocess_trailitem (@) {
210         my $link = shift;
211         shift;
212
213         # avoid collecting everything in the preprocess stage if we already
214         # did in the scan stage
215         if (defined wantarray) {
216                 return "" if $scanned;
217         }
218         else {
219                 $scanned = 1;
220         }
221
222         my %params = @_;
223         my $trail = $params{page};
224
225         $link = linkpage($link);
226
227         add_link($params{page}, $link, 'trail');
228         push @{$pagestate{$params{page}}{trail}{contents}}, "link $link";
229
230         return "";
231 }
232
233 =for wiki
234
235 The `traillink` directive is supplied by the [[plugins/contrib/trail]] plugin.
236 It generates a visible [[ikiwiki/WikiLink]], and also adds the linked page to
237 the trail represented by the page containing the directive.
238
239 In its simplest form, the first parameter is like the content of a WikiLink:
240
241     \[[!traillink some_other_page]]
242
243 The displayed text can also be overridden, either with a `|` symbol or with
244 a `text` parameter:
245
246     \[[!traillink Click_here_to_start_the_trail|some_other_page]]
247     \[[!traillink some_other_page text="Click here to start the trail"]]
248
249 =cut
250
251 sub preprocess_traillink (@) {
252         my $link = shift;
253         shift;
254
255         my %params = @_;
256         my $trail = $params{page};
257
258         $link =~ qr{
259                         (?:
260                                 ([^\|]+)        # 1: link text
261                                 \|              # followed by |
262                         )?                      # optional
263
264                         (.+)                    # 2: page to link to
265                 }x;
266
267         my $linktext = $1;
268         $link = linkpage($2);
269
270         add_link($params{page}, $link, 'trail');
271
272         # avoid collecting everything in the preprocess stage if we already
273         # did in the scan stage
274         my $already;
275         if (defined wantarray) {
276                 $already = $scanned;
277         }
278         else {
279                 $scanned = 1;
280         }
281
282         push @{$pagestate{$params{page}}{trail}{contents}}, [link => $link] unless $already;
283
284         if (defined $linktext) {
285                 $linktext = pagetitle($linktext);
286         }
287
288         if (exists $params{text}) {
289                 $linktext = $params{text};
290         }
291
292         if (defined $linktext) {
293                 return htmllink($trail, $params{destpage},
294                         $link, linktext => $linktext);
295         }
296
297         return htmllink($trail, $params{destpage}, $link);
298 }
299
300 # trail => [member1, member2]
301 my %trail_to_members;
302 # member => { trail => [prev, next] }
303 # e.g. if %trail_to_members = (
304 #       trail1 => ["member1", "member2"],
305 #       trail2 => ["member0", "member1"],
306 # )
307 #
308 # then $member_to_trails{member1} = {
309 #       trail1 => [undef, "member2"],
310 #       trail2 => ["member0", undef],
311 # }
312 my %member_to_trails;
313
314 # member => 1
315 my %rebuild_trail_members;
316
317 sub trails_differ {
318         my ($old, $new) = @_;
319
320         foreach my $trail (keys %$old) {
321                 if (! exists $new->{$trail}) {
322                         return 1;
323                 }
324                 my ($old_p, $old_n) = @{$old->{$trail}};
325                 my ($new_p, $new_n) = @{$new->{$trail}};
326                 $old_p = "" unless defined $old_p;
327                 $old_n = "" unless defined $old_n;
328                 $new_p = "" unless defined $new_p;
329                 $new_n = "" unless defined $new_n;
330                 if ($old_p ne $new_p) {
331                         return 1;
332                 }
333                 if ($old_n ne $new_n) {
334                         return 1;
335                 }
336         }
337
338         foreach my $trail (keys %$new) {
339                 if (! exists $old->{$trail}) {
340                         return 1;
341                 }
342         }
343
344         return 0;
345 }
346
347 my $done_prerender = 0;
348
349 my %origsubs;
350
351 sub prerender {
352         return if $done_prerender;
353
354         $origsubs{render_backlinks} = \&IkiWiki::render_backlinks;
355         inject(name => "IkiWiki::render_backlinks", call => \&render_backlinks);
356
357         %trail_to_members = ();
358         %member_to_trails = ();
359
360         foreach my $trail (keys %pagestate) {
361                 next unless exists $pagestate{$trail}{trail}{contents};
362
363                 my $members = [];
364                 my @contents = @{$pagestate{$trail}{trail}{contents}};
365
366
367                 foreach my $c (@contents) {
368                         if ($c =~ m/^pagespec (.*)$/) {
369                                 push @$members, pagespec_match_list($trail, $1);
370                         }
371                         elsif ($c =~ m/^link (.*)$/) {
372                                 my $best = bestlink($trail, $1);
373                                 push @$members, $best if length $best;
374                         }
375                 }
376
377                 if (defined $pagestate{$trail}{trail}{sort}) {
378                         # re-sort
379                         @$members = pagespec_match_list($trail, 'internal(*)',
380                                 list => $members,
381                                 sort => $pagestate{$trail}{trail}{sort});
382                 }
383
384                 if (IkiWiki::yesno $pagestate{$trail}{trail}{reverse}) {
385                         @$members = reverse @$members;
386                 }
387
388                 # uniquify
389                 my %seen;
390                 my @tmp;
391                 foreach my $member (@$members) {
392                         push @tmp, $member unless $seen{$member};
393                         $seen{$member} = 1;
394                 }
395                 $members = [@tmp];
396
397                 for (my $i = 0; $i <= $#$members; $i++) {
398                         my $member = $members->[$i];
399                         my $prev;
400                         $prev = $members->[$i - 1] if $i > 0;
401                         my $next = $members->[$i + 1];
402
403                         add_depends($member, $trail);
404
405                         $member_to_trails{$member}{$trail} = [$prev, $next];
406                 }
407
408                 if ((scalar @$members) > 1 && $pagestate{$trail}{trail}{circular}) {
409                         $member_to_trails{$members->[0]}{$trail}[0] = $members->[$#$members];
410                         $member_to_trails{$members->[$#$members]}{$trail}[1] = $members->[0];
411                 }
412
413                 $trail_to_members{$trail} = $members;
414         }
415
416         foreach my $member (keys %pagestate) {
417                 if (exists $pagestate{$member}{trail}{item} &&
418                         ! exists $member_to_trails{$member}) {
419                         $rebuild_trail_members{$member} = 1;
420                         delete $pagestate{$member}{trailitem};
421                 }
422         }
423
424         foreach my $member (keys %member_to_trails) {
425                 if (! exists $pagestate{$member}{trail}{item}) {
426                         $rebuild_trail_members{$member} = 1;
427                 }
428                 else {
429                         if (trails_differ($pagestate{$member}{trail}{item},
430                                         $member_to_trails{$member})) {
431                                 $rebuild_trail_members{$member} = 1;
432                         }
433                 }
434
435                 $pagestate{$member}{trail}{item} = $member_to_trails{$member};
436         }
437
438         $done_prerender = 1;
439 }
440
441 # This is called at about the right time that we can hijack it to render
442 # extra pages.
443 sub render_backlinks ($) {
444         my $blc = shift;
445
446         foreach my $member (keys %rebuild_trail_members) {
447                 next unless exists $pagesources{$member};
448
449                 IkiWiki::render($pagesources{$member}, sprintf(gettext("building %s, its previous or next page has changed"), $member));
450         }
451
452         $origsubs{render_backlinks}($blc);
453 }
454
455 sub title_of ($) {
456         my $page = shift;
457         if (defined ($pagestate{$page}{meta}{title})) {
458                 return $pagestate{$page}{meta}{title};
459         }
460         return pagetitle(IkiWiki::basename($page));
461 }
462
463 my $recursive = 0;
464
465 sub pagetemplate (@) {
466         my %params = @_;
467         my $page = $params{page};
468         my $template = $params{template};
469
470         if ($template->query(name => 'trails') && ! $recursive) {
471                 prerender();
472
473                 $recursive = 1;
474                 my $inner = template("trails.tmpl", blind_cache => 1);
475                 IkiWiki::run_hooks(pagetemplate => sub {
476                                 shift->(%params, template => $inner)
477                         });
478                 $template->param(trails => $inner->output);
479                 $recursive = 0;
480         }
481
482         if ($template->query(name => 'trailloop')) {
483                 prerender();
484
485                 my @trails;
486
487                 # sort backlinks by page name to have a consistent order
488                 foreach my $trail (sort keys %{$member_to_trails{$page}}) {
489
490                         my $members = $trail_to_members{$trail};
491                         my ($prev, $next) = @{$member_to_trails{$page}{$trail}};
492                         my ($prevurl, $nexturl, $prevtitle, $nexttitle);
493
494                         if (defined $prev) {
495                                 add_depends($params{destpage}, $prev);
496                                 $prevurl = urlto($prev, $page);
497                                 $prevtitle = title_of($prev);
498                         }
499
500                         if (defined $next) {
501                                 add_depends($params{destpage}, $next);
502                                 $nexturl = urlto($next, $page);
503                                 $nexttitle = title_of($next);
504                         }
505
506                         push @trails, {
507                                 prevpage => $prev,
508                                 prevtitle => $prevtitle,
509                                 prevurl => $prevurl,
510                                 nextpage => $next,
511                                 nexttitle => $nexttitle,
512                                 nexturl => $nexturl,
513                                 trailpage => $trail,
514                                 trailtitle => title_of($trail),
515                                 trailurl => urlto($trail, $page),
516                         };
517                 }
518
519                 $template->param(trailloop => \@trails);
520         }
521 }
522
523 1;