5ee152155187d718d314bb70776ff2ecb8503c6b
[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 => "trailoptions", call => \&preprocess_trailoptions, 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 => "trailitems", call => \&preprocess_trailitems, scan => 1);
19         hook(type => "preprocess", id => "traillink", call => \&preprocess_traillink, scan => 1);
20         hook(type => "pagetemplate", id => "trail", call => \&pagetemplate);
21 }
22
23 =head1 Page state
24
25 If a page C<$T> is a trail, then it can have
26
27 =over
28
29 =item * C<$pagestate{$T}{trail}{contents}>
30
31 Reference to an array of lists each containing either:
32
33 =over
34
35 =item * C<[link, "link"]>
36
37 A link specification, pointing to the same page that C<[[link]]> would select
38
39 =item * C<[pagespec, "posts/*", "age", 0]>
40
41 A match by pagespec; the third array element is the sort order and the fourth
42 is whether to reverse sorting
43
44 =back
45
46 =item * C<$pagestate{$T}{trail}{sort}>
47
48 A [[ikiwiki/pagespec/sorting]] order; if absent or undef, the trail is in
49 the order given by the links that form it
50
51 =item * C<$pagestate{$T}{trail}{circular}>
52
53 True if this trail is circular (i.e. going "next" from the last item is
54 allowed, and takes you back to the first)
55
56 =item * C<$pagestate{$T}{trail}{reverse}>
57
58 True if C<sort> is to be reversed.
59
60 =back
61
62 If a page C<$M> is a member of a trail C<$T>, then it has
63
64 =over
65
66 =item * C<$pagestate{$M}{trail}{item}{$T}[0]>
67
68 The page before this one in C<$T> at the last rebuild, or undef.
69
70 =item * C<$pagestate{$M}{trail}{item}{$T}[1]>
71
72 The page after this one in C<$T> at the last refresh, or undef.
73
74 =back
75
76 =cut
77
78 sub getsetup () {
79         return
80                 plugin => {
81                         safe => 1,
82                         rebuild => undef,
83                 },
84 }
85
86 sub needsbuild (@) {
87         my $needsbuild=shift;
88         foreach my $page (keys %pagestate) {
89                 if (exists $pagestate{$page}{trail}) {
90                         if (exists $pagesources{$page} &&
91                             grep { $_ eq $pagesources{$page} } @$needsbuild) {
92                                 # Remove state, it will be re-added
93                                 # if the preprocessor directive is still
94                                 # there during the rebuild. {item} is the
95                                 # only thing that's added for items, not
96                                 # trails, and it's harmless to delete that -
97                                 # the item is being rebuilt anyway.
98                                 delete $pagestate{$page}{trail};
99                         }
100                 }
101         }
102         return $needsbuild;
103 }
104
105 my $scanned = 0;
106
107 sub preprocess_trailoptions (@) {
108         my %params = @_;
109
110         if (exists $params{circular}) {
111                 $pagestate{$params{page}}{trail}{circular} =
112                         IkiWiki::yesno($params{circular});
113         }
114
115         if (exists $params{sort}) {
116                 $pagestate{$params{page}}{trail}{sort} = $params{sort};
117         }
118
119         if (exists $params{reverse}) {
120                 $pagestate{$params{page}}{trail}{reverse} = $params{reverse};
121         }
122
123         return "";
124 }
125
126 sub preprocess_trailinline (@) {
127         my %params = @_;
128
129         if (! exists $params{sort}) {
130                 # sort in the same order as [[plugins/inline]]'s default
131                 $params{sort} = 'age';
132         }
133
134         if (defined wantarray) {
135                 scalar preprocess_trailitems(%params);
136
137                 if (IkiWiki->can("preprocess_inline")) {
138                         return IkiWiki::preprocess_inline(@_);
139                 }
140                 else {
141                         error("trailinline directive requires the inline plugin");
142                 }
143         }
144         else {
145                 preprocess_trailitems(%params);
146         }
147 }
148
149 sub preprocess_trailitem (@) {
150         my $link = shift;
151         shift;
152
153         # avoid collecting everything in the preprocess stage if we already
154         # did in the scan stage
155         if (defined wantarray) {
156                 return "" if $scanned;
157         }
158         else {
159                 $scanned = 1;
160         }
161
162         my %params = @_;
163         my $trail = $params{page};
164
165         $link = linkpage($link);
166
167         add_link($params{page}, $link, 'trail');
168         push @{$pagestate{$params{page}}{trail}{contents}}, [link => $link];
169
170         return "";
171 }
172
173 sub preprocess_trailitems (@) {
174         my %params = @_;
175
176         # avoid collecting everything in the preprocess stage if we already
177         # did in the scan stage
178         if (defined wantarray) {
179                 return "" if $scanned;
180         }
181         else {
182                 $scanned = 1;
183         }
184
185         # trail members from a pagespec ought to be in some sort of order,
186         # and path is a nice obvious default
187         $params{sort} = 'path' unless exists $params{sort};
188         $params{reverse} = 'no' unless exists $params{reverse};
189
190         if (exists $params{pages}) {
191                 push @{$pagestate{$params{page}}{trail}{contents}},
192                         ["pagespec" => $params{pages}, $params{sort},
193                                 IkiWiki::yesno($params{reverse})];
194         }
195
196         if (exists $params{pagenames}) {
197                 my @list = map { [link =>  $_] } split ' ', $params{pagenames};
198                 push @{$pagestate{$params{page}}{trail}{contents}}, @list;
199         }
200
201         return "";
202 }
203
204 sub preprocess_traillink (@) {
205         my $link = shift;
206         shift;
207
208         my %params = @_;
209         my $trail = $params{page};
210
211         $link =~ qr{
212                         (?:
213                                 ([^\|]+)        # 1: link text
214                                 \|              # followed by |
215                         )?                      # optional
216
217                         (.+)                    # 2: page to link to
218                 }x;
219
220         my $linktext = $1;
221         $link = linkpage($2);
222
223         add_link($params{page}, $link, 'trail');
224
225         # avoid collecting everything in the preprocess stage if we already
226         # did in the scan stage
227         my $already;
228         if (defined wantarray) {
229                 $already = $scanned;
230         }
231         else {
232                 $scanned = 1;
233         }
234
235         push @{$pagestate{$params{page}}{trail}{contents}}, [link => $link] unless $already;
236
237         if (defined $linktext) {
238                 $linktext = pagetitle($linktext);
239         }
240
241         if (exists $params{text}) {
242                 $linktext = $params{text};
243         }
244
245         if (defined $linktext) {
246                 return htmllink($trail, $params{destpage},
247                         $link, linktext => $linktext);
248         }
249
250         return htmllink($trail, $params{destpage}, $link);
251 }
252
253 # trail => [member1, member2]
254 my %trail_to_members;
255 # member => { trail => [prev, next] }
256 # e.g. if %trail_to_members = (
257 #       trail1 => ["member1", "member2"],
258 #       trail2 => ["member0", "member1"],
259 # )
260 #
261 # then $member_to_trails{member1} = {
262 #       trail1 => [undef, "member2"],
263 #       trail2 => ["member0", undef],
264 # }
265 my %member_to_trails;
266
267 # member => 1
268 my %rebuild_trail_members;
269
270 sub trails_differ {
271         my ($old, $new) = @_;
272
273         foreach my $trail (keys %$old) {
274                 if (! exists $new->{$trail}) {
275                         return 1;
276                 }
277                 my ($old_p, $old_n) = @{$old->{$trail}};
278                 my ($new_p, $new_n) = @{$new->{$trail}};
279                 $old_p = "" unless defined $old_p;
280                 $old_n = "" unless defined $old_n;
281                 $new_p = "" unless defined $new_p;
282                 $new_n = "" unless defined $new_n;
283                 if ($old_p ne $new_p) {
284                         return 1;
285                 }
286                 if ($old_n ne $new_n) {
287                         return 1;
288                 }
289         }
290
291         foreach my $trail (keys %$new) {
292                 if (! exists $old->{$trail}) {
293                         return 1;
294                 }
295         }
296
297         return 0;
298 }
299
300 my $done_prerender = 0;
301
302 my %origsubs;
303
304 sub prerender {
305         return if $done_prerender;
306
307         $origsubs{render_backlinks} = \&IkiWiki::render_backlinks;
308         inject(name => "IkiWiki::render_backlinks", call => \&render_backlinks);
309
310         %trail_to_members = ();
311         %member_to_trails = ();
312
313         foreach my $trail (keys %pagestate) {
314                 next unless exists $pagestate{$trail}{trail}{contents};
315
316                 my $members = [];
317                 my @contents = @{$pagestate{$trail}{trail}{contents}};
318
319                 foreach my $c (@contents) {
320                         if ($c->[0] eq 'pagespec') {
321                                 push @$members, pagespec_match_list($trail,
322                                         $c->[1], sort => $c->[2],
323                                         reverse => $c->[3]);
324                         }
325                         elsif ($c->[0] eq 'link') {
326                                 my $best = bestlink($trail, $c->[1]);
327                                 push @$members, $best if length $best;
328                         }
329                 }
330
331                 if (defined $pagestate{$trail}{trail}{sort}) {
332                         # re-sort
333                         @$members = pagespec_match_list($trail, 'internal(*)',
334                                 list => $members,
335                                 sort => $pagestate{$trail}{trail}{sort});
336                 }
337
338                 if (IkiWiki::yesno $pagestate{$trail}{trail}{reverse}) {
339                         @$members = reverse @$members;
340                 }
341
342                 # uniquify
343                 my %seen;
344                 my @tmp;
345                 foreach my $member (@$members) {
346                         push @tmp, $member unless $seen{$member};
347                         $seen{$member} = 1;
348                 }
349                 $members = [@tmp];
350
351                 for (my $i = 0; $i <= $#$members; $i++) {
352                         my $member = $members->[$i];
353                         my $prev;
354                         $prev = $members->[$i - 1] if $i > 0;
355                         my $next = $members->[$i + 1];
356
357                         add_depends($member, $trail);
358
359                         $member_to_trails{$member}{$trail} = [$prev, $next];
360                 }
361
362                 if ((scalar @$members) > 1 && $pagestate{$trail}{trail}{circular}) {
363                         $member_to_trails{$members->[0]}{$trail}[0] = $members->[$#$members];
364                         $member_to_trails{$members->[$#$members]}{$trail}[1] = $members->[0];
365                 }
366
367                 $trail_to_members{$trail} = $members;
368         }
369
370         foreach my $member (keys %pagestate) {
371                 if (exists $pagestate{$member}{trail}{item} &&
372                         ! exists $member_to_trails{$member}) {
373                         $rebuild_trail_members{$member} = 1;
374                         delete $pagestate{$member}{trailitem};
375                 }
376         }
377
378         foreach my $member (keys %member_to_trails) {
379                 if (! exists $pagestate{$member}{trail}{item}) {
380                         $rebuild_trail_members{$member} = 1;
381                 }
382                 else {
383                         if (trails_differ($pagestate{$member}{trail}{item},
384                                         $member_to_trails{$member})) {
385                                 $rebuild_trail_members{$member} = 1;
386                         }
387                 }
388
389                 $pagestate{$member}{trail}{item} = $member_to_trails{$member};
390         }
391
392         $done_prerender = 1;
393 }
394
395 # This is called at about the right time that we can hijack it to render
396 # extra pages.
397 sub render_backlinks ($) {
398         my $blc = shift;
399
400         foreach my $member (keys %rebuild_trail_members) {
401                 next unless exists $pagesources{$member};
402
403                 IkiWiki::render($pagesources{$member}, sprintf(gettext("building %s, its previous or next page has changed"), $member));
404         }
405
406         $origsubs{render_backlinks}($blc);
407 }
408
409 sub title_of ($) {
410         my $page = shift;
411         if (defined ($pagestate{$page}{meta}{title})) {
412                 return $pagestate{$page}{meta}{title};
413         }
414         return pagetitle(IkiWiki::basename($page));
415 }
416
417 my $recursive = 0;
418
419 sub pagetemplate (@) {
420         my %params = @_;
421         my $page = $params{page};
422         my $template = $params{template};
423
424         if ($template->query(name => 'trails') && ! $recursive) {
425                 prerender();
426
427                 $recursive = 1;
428                 my $inner = template("trails.tmpl", blind_cache => 1);
429                 IkiWiki::run_hooks(pagetemplate => sub {
430                                 shift->(%params, template => $inner)
431                         });
432                 $template->param(trails => $inner->output);
433                 $recursive = 0;
434         }
435
436         if ($template->query(name => 'trailloop')) {
437                 prerender();
438
439                 my @trails;
440
441                 # sort backlinks by page name to have a consistent order
442                 foreach my $trail (sort keys %{$member_to_trails{$page}}) {
443
444                         my $members = $trail_to_members{$trail};
445                         my ($prev, $next) = @{$member_to_trails{$page}{$trail}};
446                         my ($prevurl, $nexturl, $prevtitle, $nexttitle);
447
448                         if (defined $prev) {
449                                 add_depends($params{destpage}, $prev);
450                                 $prevurl = urlto($prev, $page);
451                                 $prevtitle = title_of($prev);
452                         }
453
454                         if (defined $next) {
455                                 add_depends($params{destpage}, $next);
456                                 $nexturl = urlto($next, $page);
457                                 $nexttitle = title_of($next);
458                         }
459
460                         push @trails, {
461                                 prevpage => $prev,
462                                 prevtitle => $prevtitle,
463                                 prevurl => $prevurl,
464                                 nextpage => $next,
465                                 nexttitle => $nexttitle,
466                                 nexturl => $nexturl,
467                                 trailpage => $trail,
468                                 trailtitle => title_of($trail),
469                                 trailurl => urlto($trail, $page),
470                         };
471                 }
472
473                 $template->param(trailloop => \@trails);
474         }
475 }
476
477 1;