87d99dd3dbafcd47bdc3e599d79697552ffc9874
[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         # trail members from a pagespec ought to be in some sort of order,
127         # and path is a nice obvious default
128         $params{sortthese} = 'path' unless exists $params{sortthese};
129         $params{reversethese} = 'no' unless exists $params{reversethese};
130
131         if (exists $params{circular}) {
132                 $pagestate{$params{page}}{trail}{circular} =
133                         IkiWiki::yesno($params{circular});
134         }
135
136         if (exists $params{sort}) {
137                 $pagestate{$params{page}}{trail}{sort} = $params{sort};
138         }
139
140         if (exists $params{reverse}) {
141                 $pagestate{$params{page}}{trail}{reverse} = $params{reverse};
142         }
143
144         if (exists $params{pages}) {
145                 push @{$pagestate{$params{page}}{trail}{contents}},
146                         ["pagespec" => $params{pages}, $params{sortthese},
147                                 IkiWiki::yesno($params{reversethese})];
148         }
149
150         if (exists $params{pagenames}) {
151                 my @list = map { [link =>  $_] } split ' ', $params{pagenames};
152                 push @{$pagestate{$params{page}}{trail}{contents}}, @list;
153         }
154
155         return "";
156 }
157
158 =for wiki
159
160 The `trailinline` directive is supplied by the [[plugins/contrib/trail]]
161 plugin. It behaves like the [[trail]] and [[inline]] directives combined.
162 Like [[inline]], it includes the selected pages into the page with the
163 directive and/or an RSS or Atom feed; like [[trail]], it turns the
164 included pages into a "trail" in which each page has a link to the
165 previous and next pages.
166
167     \[[!inline sort="meta(date)" circular="no" pages="blog/posts/*"]]
168
169 All the options for the [[inline]] and [[trail]] directives are valid.
170
171 The `show`, `skip` and `feedshow` options from [[inline]] do not apply
172 to the trail.
173
174 * `sort`: sets a [[ikiwiki/pagespec/sorting]] order; if not specified, the
175   items of the trail are ordered according to the first link to each item
176   found on the trail page
177
178 * `circular`: if set to `yes` or `1`, the trail is made into a loop by
179   making the last page's "next" link point to the first page, and the first
180   page's "previous" link point to the last page
181
182 * `pages`: add the given pages to the trail
183
184 =cut
185
186 sub preprocess_trailinline (@) {
187         my %params = @_;
188
189         if (exists $params{sort}) {
190                 $params{sortthese} = $params{sort};
191                 delete $params{sort};
192         }
193         else {
194                 # sort in the same order as [[plugins/inline]]'s default
195                 $params{sortthese} = 'age';
196         }
197
198         if (exists $params{reverse}) {
199                 $params{reversethese} = $params{reverse};
200                 delete $params{reverse};
201         }
202
203         if (exists $params{trailsort}) {
204                 $params{sort} = $params{trailsort};
205         }
206
207         if (exists $params{trailreverse}) {
208                 $params{reverse} = $params{trailreverse};
209         }
210
211         if (defined wantarray) {
212                 scalar preprocess_trail(%params);
213
214                 if (IkiWiki->can("preprocess_inline")) {
215                         return IkiWiki::preprocess_inline(@_);
216                 }
217                 else {
218                         error("trailinline directive requires the inline plugin");
219                 }
220         }
221         else {
222                 preprocess_trail(%params);
223         }
224 }
225
226 =for wiki
227
228 The `trailitem` directive is supplied by the [[plugins/contrib/trail]] plugin.
229 It is used like this:
230
231     \[[!trailitem some_other_page]]
232
233 to add `some_other_page` to the trail represented by this page, without
234 generating a visible hyperlink.
235
236 =cut
237
238 sub preprocess_trailitem (@) {
239         my $link = shift;
240         shift;
241
242         # avoid collecting everything in the preprocess stage if we already
243         # did in the scan stage
244         if (defined wantarray) {
245                 return "" if $scanned;
246         }
247         else {
248                 $scanned = 1;
249         }
250
251         my %params = @_;
252         my $trail = $params{page};
253
254         $link = linkpage($link);
255
256         add_link($params{page}, $link, 'trail');
257         push @{$pagestate{$params{page}}{trail}{contents}}, [link => $link];
258
259         return "";
260 }
261
262 =for wiki
263
264 The `traillink` directive is supplied by the [[plugins/contrib/trail]] plugin.
265 It generates a visible [[ikiwiki/WikiLink]], and also adds the linked page to
266 the trail represented by the page containing the directive.
267
268 In its simplest form, the first parameter is like the content of a WikiLink:
269
270     \[[!traillink some_other_page]]
271
272 The displayed text can also be overridden, either with a `|` symbol or with
273 a `text` parameter:
274
275     \[[!traillink Click_here_to_start_the_trail|some_other_page]]
276     \[[!traillink some_other_page text="Click here to start the trail"]]
277
278 =cut
279
280 sub preprocess_traillink (@) {
281         my $link = shift;
282         shift;
283
284         my %params = @_;
285         my $trail = $params{page};
286
287         $link =~ qr{
288                         (?:
289                                 ([^\|]+)        # 1: link text
290                                 \|              # followed by |
291                         )?                      # optional
292
293                         (.+)                    # 2: page to link to
294                 }x;
295
296         my $linktext = $1;
297         $link = linkpage($2);
298
299         add_link($params{page}, $link, 'trail');
300
301         # avoid collecting everything in the preprocess stage if we already
302         # did in the scan stage
303         my $already;
304         if (defined wantarray) {
305                 $already = $scanned;
306         }
307         else {
308                 $scanned = 1;
309         }
310
311         push @{$pagestate{$params{page}}{trail}{contents}}, [link => $link] unless $already;
312
313         if (defined $linktext) {
314                 $linktext = pagetitle($linktext);
315         }
316
317         if (exists $params{text}) {
318                 $linktext = $params{text};
319         }
320
321         if (defined $linktext) {
322                 return htmllink($trail, $params{destpage},
323                         $link, linktext => $linktext);
324         }
325
326         return htmllink($trail, $params{destpage}, $link);
327 }
328
329 # trail => [member1, member2]
330 my %trail_to_members;
331 # member => { trail => [prev, next] }
332 # e.g. if %trail_to_members = (
333 #       trail1 => ["member1", "member2"],
334 #       trail2 => ["member0", "member1"],
335 # )
336 #
337 # then $member_to_trails{member1} = {
338 #       trail1 => [undef, "member2"],
339 #       trail2 => ["member0", undef],
340 # }
341 my %member_to_trails;
342
343 # member => 1
344 my %rebuild_trail_members;
345
346 sub trails_differ {
347         my ($old, $new) = @_;
348
349         foreach my $trail (keys %$old) {
350                 if (! exists $new->{$trail}) {
351                         return 1;
352                 }
353                 my ($old_p, $old_n) = @{$old->{$trail}};
354                 my ($new_p, $new_n) = @{$new->{$trail}};
355                 $old_p = "" unless defined $old_p;
356                 $old_n = "" unless defined $old_n;
357                 $new_p = "" unless defined $new_p;
358                 $new_n = "" unless defined $new_n;
359                 if ($old_p ne $new_p) {
360                         return 1;
361                 }
362                 if ($old_n ne $new_n) {
363                         return 1;
364                 }
365         }
366
367         foreach my $trail (keys %$new) {
368                 if (! exists $old->{$trail}) {
369                         return 1;
370                 }
371         }
372
373         return 0;
374 }
375
376 my $done_prerender = 0;
377
378 my %origsubs;
379
380 sub prerender {
381         return if $done_prerender;
382
383         $origsubs{render_backlinks} = \&IkiWiki::render_backlinks;
384         inject(name => "IkiWiki::render_backlinks", call => \&render_backlinks);
385
386         %trail_to_members = ();
387         %member_to_trails = ();
388
389         foreach my $trail (keys %pagestate) {
390                 next unless exists $pagestate{$trail}{trail}{contents};
391
392                 my $members = [];
393                 my @contents = @{$pagestate{$trail}{trail}{contents}};
394
395                 foreach my $c (@contents) {
396                         if ($c->[0] eq 'pagespec') {
397                                 push @$members, pagespec_match_list($trail,
398                                         $c->[1], sort => $c->[2],
399                                         reverse => $c->[3]);
400                         }
401                         elsif ($c->[0] eq 'link') {
402                                 my $best = bestlink($trail, $c->[1]);
403                                 push @$members, $best if length $best;
404                         }
405                 }
406
407                 if (defined $pagestate{$trail}{trail}{sort}) {
408                         # re-sort
409                         @$members = pagespec_match_list($trail, 'internal(*)',
410                                 list => $members,
411                                 sort => $pagestate{$trail}{trail}{sort});
412                 }
413
414                 if (IkiWiki::yesno $pagestate{$trail}{trail}{reverse}) {
415                         @$members = reverse @$members;
416                 }
417
418                 # uniquify
419                 my %seen;
420                 my @tmp;
421                 foreach my $member (@$members) {
422                         push @tmp, $member unless $seen{$member};
423                         $seen{$member} = 1;
424                 }
425                 $members = [@tmp];
426
427                 for (my $i = 0; $i <= $#$members; $i++) {
428                         my $member = $members->[$i];
429                         my $prev;
430                         $prev = $members->[$i - 1] if $i > 0;
431                         my $next = $members->[$i + 1];
432
433                         add_depends($member, $trail);
434
435                         $member_to_trails{$member}{$trail} = [$prev, $next];
436                 }
437
438                 if ((scalar @$members) > 1 && $pagestate{$trail}{trail}{circular}) {
439                         $member_to_trails{$members->[0]}{$trail}[0] = $members->[$#$members];
440                         $member_to_trails{$members->[$#$members]}{$trail}[1] = $members->[0];
441                 }
442
443                 $trail_to_members{$trail} = $members;
444         }
445
446         foreach my $member (keys %pagestate) {
447                 if (exists $pagestate{$member}{trail}{item} &&
448                         ! exists $member_to_trails{$member}) {
449                         $rebuild_trail_members{$member} = 1;
450                         delete $pagestate{$member}{trailitem};
451                 }
452         }
453
454         foreach my $member (keys %member_to_trails) {
455                 if (! exists $pagestate{$member}{trail}{item}) {
456                         $rebuild_trail_members{$member} = 1;
457                 }
458                 else {
459                         if (trails_differ($pagestate{$member}{trail}{item},
460                                         $member_to_trails{$member})) {
461                                 $rebuild_trail_members{$member} = 1;
462                         }
463                 }
464
465                 $pagestate{$member}{trail}{item} = $member_to_trails{$member};
466         }
467
468         $done_prerender = 1;
469 }
470
471 # This is called at about the right time that we can hijack it to render
472 # extra pages.
473 sub render_backlinks ($) {
474         my $blc = shift;
475
476         foreach my $member (keys %rebuild_trail_members) {
477                 next unless exists $pagesources{$member};
478
479                 IkiWiki::render($pagesources{$member}, sprintf(gettext("building %s, its previous or next page has changed"), $member));
480         }
481
482         $origsubs{render_backlinks}($blc);
483 }
484
485 sub title_of ($) {
486         my $page = shift;
487         if (defined ($pagestate{$page}{meta}{title})) {
488                 return $pagestate{$page}{meta}{title};
489         }
490         return pagetitle(IkiWiki::basename($page));
491 }
492
493 my $recursive = 0;
494
495 sub pagetemplate (@) {
496         my %params = @_;
497         my $page = $params{page};
498         my $template = $params{template};
499
500         if ($template->query(name => 'trails') && ! $recursive) {
501                 prerender();
502
503                 $recursive = 1;
504                 my $inner = template("trails.tmpl", blind_cache => 1);
505                 IkiWiki::run_hooks(pagetemplate => sub {
506                                 shift->(%params, template => $inner)
507                         });
508                 $template->param(trails => $inner->output);
509                 $recursive = 0;
510         }
511
512         if ($template->query(name => 'trailloop')) {
513                 prerender();
514
515                 my @trails;
516
517                 # sort backlinks by page name to have a consistent order
518                 foreach my $trail (sort keys %{$member_to_trails{$page}}) {
519
520                         my $members = $trail_to_members{$trail};
521                         my ($prev, $next) = @{$member_to_trails{$page}{$trail}};
522                         my ($prevurl, $nexturl, $prevtitle, $nexttitle);
523
524                         if (defined $prev) {
525                                 add_depends($params{destpage}, $prev);
526                                 $prevurl = urlto($prev, $page);
527                                 $prevtitle = title_of($prev);
528                         }
529
530                         if (defined $next) {
531                                 add_depends($params{destpage}, $next);
532                                 $nexturl = urlto($next, $page);
533                                 $nexttitle = title_of($next);
534                         }
535
536                         push @trails, {
537                                 prevpage => $prev,
538                                 prevtitle => $prevtitle,
539                                 prevurl => $prevurl,
540                                 nextpage => $next,
541                                 nexttitle => $nexttitle,
542                                 nexturl => $nexturl,
543                                 trailpage => $trail,
544                                 trailtitle => title_of($trail),
545                                 trailurl => urlto($trail, $page),
546                         };
547                 }
548
549                 $template->param(trailloop => \@trails);
550         }
551 }
552
553 1;