changelog
[ikiwiki] / IkiWiki / Plugin / trail.pm
1 #!/usr/bin/perl
2 # Copyright © 2008-2011 Joey Hess
3 # Copyright © 2009-2012 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 => "trailitem", call => \&preprocess_trailitem, scan => 1);
17         hook(type => "preprocess", id => "trailitems", call => \&preprocess_trailitems, scan => 1);
18         hook(type => "preprocess", id => "traillink", call => \&preprocess_traillink, scan => 1);
19         hook(type => "pagetemplate", id => "trail", call => \&pagetemplate);
20         hook(type => "build_affected", id => "trail", call => \&build_affected);
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_trailitem (@) {
127         my $link = shift;
128         shift;
129
130         # avoid collecting everything in the preprocess stage if we already
131         # did in the scan stage
132         if (defined wantarray) {
133                 return "" if $scanned;
134         }
135         else {
136                 $scanned = 1;
137         }
138
139         my %params = @_;
140         my $trail = $params{page};
141
142         $link = linkpage($link);
143
144         add_link($params{page}, $link, 'trail');
145         push @{$pagestate{$params{page}}{trail}{contents}}, [link => $link];
146
147         return "";
148 }
149
150 sub preprocess_trailitems (@) {
151         my %params = @_;
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         # trail members from a pagespec ought to be in some sort of order,
163         # and path is a nice obvious default
164         $params{sort} = 'path' unless exists $params{sort};
165         $params{reverse} = 'no' unless exists $params{reverse};
166
167         if (exists $params{pages}) {
168                 push @{$pagestate{$params{page}}{trail}{contents}},
169                         ["pagespec" => $params{pages}, $params{sort},
170                                 IkiWiki::yesno($params{reverse})];
171         }
172
173         if (exists $params{pagenames}) {
174                 my @list = map { [link =>  $_] } split ' ', $params{pagenames};
175                 push @{$pagestate{$params{page}}{trail}{contents}}, @list;
176         }
177
178         return "";
179 }
180
181 sub preprocess_traillink (@) {
182         my $link = shift;
183         shift;
184
185         my %params = @_;
186         my $trail = $params{page};
187
188         $link =~ qr{
189                         (?:
190                                 ([^\|]+)        # 1: link text
191                                 \|              # followed by |
192                         )?                      # optional
193
194                         (.+)                    # 2: page to link to
195                 }x;
196
197         my $linktext = $1;
198         $link = linkpage($2);
199
200         add_link($params{page}, $link, 'trail');
201
202         # avoid collecting everything in the preprocess stage if we already
203         # did in the scan stage
204         my $already;
205         if (defined wantarray) {
206                 $already = $scanned;
207         }
208         else {
209                 $scanned = 1;
210         }
211
212         push @{$pagestate{$params{page}}{trail}{contents}}, [link => $link] unless $already;
213
214         if (defined $linktext) {
215                 $linktext = pagetitle($linktext);
216         }
217
218         if (exists $params{text}) {
219                 $linktext = $params{text};
220         }
221
222         if (defined $linktext) {
223                 return htmllink($trail, $params{destpage},
224                         $link, linktext => $linktext);
225         }
226
227         return htmllink($trail, $params{destpage}, $link);
228 }
229
230 # trail => [member1, member2]
231 my %trail_to_members;
232 # member => { trail => [prev, next] }
233 # e.g. if %trail_to_members = (
234 #       trail1 => ["member1", "member2"],
235 #       trail2 => ["member0", "member1"],
236 # )
237 #
238 # then $member_to_trails{member1} = {
239 #       trail1 => [undef, "member2"],
240 #       trail2 => ["member0", undef],
241 # }
242 my %member_to_trails;
243
244 # member => 1
245 my %rebuild_trail_members;
246
247 sub trails_differ {
248         my ($old, $new) = @_;
249
250         foreach my $trail (keys %$old) {
251                 if (! exists $new->{$trail}) {
252                         return 1;
253                 }
254                 my ($old_p, $old_n) = @{$old->{$trail}};
255                 my ($new_p, $new_n) = @{$new->{$trail}};
256                 $old_p = "" unless defined $old_p;
257                 $old_n = "" unless defined $old_n;
258                 $new_p = "" unless defined $new_p;
259                 $new_n = "" unless defined $new_n;
260                 if ($old_p ne $new_p) {
261                         return 1;
262                 }
263                 if ($old_n ne $new_n) {
264                         return 1;
265                 }
266         }
267
268         foreach my $trail (keys %$new) {
269                 if (! exists $old->{$trail}) {
270                         return 1;
271                 }
272         }
273
274         return 0;
275 }
276
277 my $done_prerender = 0;
278
279 sub prerender {
280         return if $done_prerender;
281
282         %trail_to_members = ();
283         %member_to_trails = ();
284
285         foreach my $trail (keys %pagestate) {
286                 next unless exists $pagestate{$trail}{trail}{contents};
287
288                 my $members = [];
289                 my @contents = @{$pagestate{$trail}{trail}{contents}};
290
291                 foreach my $c (@contents) {
292                         if ($c->[0] eq 'pagespec') {
293                                 push @$members, pagespec_match_list($trail,
294                                         $c->[1], sort => $c->[2],
295                                         reverse => $c->[3]);
296                         }
297                         elsif ($c->[0] eq 'link') {
298                                 my $best = bestlink($trail, $c->[1]);
299                                 push @$members, $best if length $best;
300                         }
301                 }
302
303                 if (defined $pagestate{$trail}{trail}{sort}) {
304                         # re-sort
305                         @$members = pagespec_match_list($trail, 'internal(*)',
306                                 list => $members,
307                                 sort => $pagestate{$trail}{trail}{sort});
308                 }
309
310                 if (IkiWiki::yesno $pagestate{$trail}{trail}{reverse}) {
311                         @$members = reverse @$members;
312                 }
313
314                 # uniquify
315                 my %seen;
316                 my @tmp;
317                 foreach my $member (@$members) {
318                         push @tmp, $member unless $seen{$member};
319                         $seen{$member} = 1;
320                 }
321                 $members = [@tmp];
322
323                 for (my $i = 0; $i <= $#$members; $i++) {
324                         my $member = $members->[$i];
325                         my $prev;
326                         $prev = $members->[$i - 1] if $i > 0;
327                         my $next = $members->[$i + 1];
328
329                         add_depends($member, $trail);
330
331                         $member_to_trails{$member}{$trail} = [$prev, $next];
332                 }
333
334                 if ((scalar @$members) > 1 && $pagestate{$trail}{trail}{circular}) {
335                         $member_to_trails{$members->[0]}{$trail}[0] = $members->[$#$members];
336                         $member_to_trails{$members->[$#$members]}{$trail}[1] = $members->[0];
337                 }
338
339                 $trail_to_members{$trail} = $members;
340         }
341
342         foreach my $member (keys %pagestate) {
343                 if (exists $pagestate{$member}{trail}{item} &&
344                         ! exists $member_to_trails{$member}) {
345                         $rebuild_trail_members{$member} = 1;
346                         delete $pagestate{$member}{trailitem};
347                 }
348         }
349
350         foreach my $member (keys %member_to_trails) {
351                 if (! exists $pagestate{$member}{trail}{item}) {
352                         $rebuild_trail_members{$member} = 1;
353                 }
354                 else {
355                         if (trails_differ($pagestate{$member}{trail}{item},
356                                         $member_to_trails{$member})) {
357                                 $rebuild_trail_members{$member} = 1;
358                         }
359                 }
360
361                 $pagestate{$member}{trail}{item} = $member_to_trails{$member};
362         }
363
364         $done_prerender = 1;
365 }
366
367 sub build_affected {
368         my %affected;
369
370         foreach my $member (keys %rebuild_trail_members) {
371                 $affected{$member} = sprintf(gettext("building %s, its previous or next page has changed"), $member);
372         }
373
374         return %affected;
375 }
376
377 sub title_of ($) {
378         my $page = shift;
379         if (defined ($pagestate{$page}{meta}{title})) {
380                 return $pagestate{$page}{meta}{title};
381         }
382         return pagetitle(IkiWiki::basename($page));
383 }
384
385 my $recursive = 0;
386
387 sub pagetemplate (@) {
388         my %params = @_;
389         my $page = $params{page};
390         my $template = $params{template};
391
392         if ($template->query(name => 'trails') && ! $recursive) {
393                 prerender();
394
395                 $recursive = 1;
396                 my $inner = template("trails.tmpl", blind_cache => 1);
397                 IkiWiki::run_hooks(pagetemplate => sub {
398                                 shift->(%params, template => $inner)
399                         });
400                 $template->param(trails => $inner->output);
401                 $recursive = 0;
402         }
403
404         if ($template->query(name => 'trailloop')) {
405                 prerender();
406
407                 my @trails;
408
409                 # sort backlinks by page name to have a consistent order
410                 foreach my $trail (sort keys %{$member_to_trails{$page}}) {
411
412                         my $members = $trail_to_members{$trail};
413                         my ($prev, $next) = @{$member_to_trails{$page}{$trail}};
414                         my ($prevurl, $nexturl, $prevtitle, $nexttitle);
415
416                         if (defined $prev) {
417                                 add_depends($params{destpage}, $prev);
418                                 $prevurl = urlto($prev, $page);
419                                 $prevtitle = title_of($prev);
420                         }
421
422                         if (defined $next) {
423                                 add_depends($params{destpage}, $next);
424                                 $nexturl = urlto($next, $page);
425                                 $nexttitle = title_of($next);
426                         }
427
428                         push @trails, {
429                                 prevpage => $prev,
430                                 prevtitle => $prevtitle,
431                                 prevurl => $prevurl,
432                                 nextpage => $next,
433                                 nexttitle => $nexttitle,
434                                 nexturl => $nexturl,
435                                 trailpage => $trail,
436                                 trailtitle => title_of($trail),
437                                 trailurl => urlto($trail, $page),
438                         };
439                 }
440
441                 $template->param(trailloop => \@trails);
442         }
443 }
444
445 1;