move plugin toggles to before config setting again
[ikiwiki] / IkiWiki / Plugin / svn.pm
1 #!/usr/bin/perl
2 package IkiWiki::Plugin::svn;
3
4 use warnings;
5 use strict;
6 use IkiWiki;
7 use POSIX qw(setlocale LC_CTYPE);
8
9 sub import { #{{{
10         hook(type => "checkconfig", id => "svn", call => \&checkconfig);
11         hook(type => "getsetup", id => "svn", call => \&getsetup);
12         hook(type => "rcs", id => "rcs_update", call => \&rcs_update);
13         hook(type => "rcs", id => "rcs_prepedit", call => \&rcs_prepedit);
14         hook(type => "rcs", id => "rcs_commit", call => \&rcs_commit);
15         hook(type => "rcs", id => "rcs_commit_staged", call => \&rcs_commit_staged);
16         hook(type => "rcs", id => "rcs_add", call => \&rcs_add);
17         hook(type => "rcs", id => "rcs_remove", call => \&rcs_remove);
18         hook(type => "rcs", id => "rcs_rename", call => \&rcs_rename);
19         hook(type => "rcs", id => "rcs_recentchanges", call => \&rcs_recentchanges);
20         hook(type => "rcs", id => "rcs_diff", call => \&rcs_diff);
21         hook(type => "rcs", id => "rcs_getctime", call => \&rcs_getctime);
22 } #}}}
23
24 sub checkconfig () { #{{{
25         if (! defined $config{svnpath}) {
26                 $config{svnpath}="trunk";
27         }
28         if (exists $config{svnpath}) {
29                 # code depends on the path not having extraneous slashes
30                 $config{svnpath}=~tr#/#/#s;
31                 $config{svnpath}=~s/\/$//;
32                 $config{svnpath}=~s/^\///;
33         }
34         if (defined $config{svn_wrapper} && length $config{svn_wrapper}) {
35                 push @{$config{wrappers}}, {
36                         wrapper => $config{svn_wrapper},
37                         wrappermode => (defined $config{svn_wrappermode} ? $config{svn_wrappermode} : "04755"),
38                 };
39         }
40 } #}}}
41
42 sub getsetup () { #{{{
43         return
44                 svnrepo => {
45                         type => "string",
46                         example => "/svn/wiki",
47                         description => "subversion repository location",
48                         safe => 0, # path
49                         rebuild => 0,
50                 },
51                 svnpath => {
52                         type => "string",
53                         example => "trunk",
54                         description => "path inside repository where the wiki is located",
55                         safe => 0, # paranoia
56                         rebuild => 0,
57                 },
58                 svn_wrapper => {
59                         type => "string",
60                         example => "/svn/wikirepo/hooks/post-commit",
61                         description => "svn post-commit hook to generate",
62                         safe => 0, # file
63                         rebuild => 0,
64                 },
65                 svn_wrappermode => {
66                         type => "string",
67                         example => '04755',
68                         description => "mode for svn_wrapper (can safely be made suid)",
69                         safe => 0,
70                         rebuild => 0,
71                 },
72                 historyurl => {
73                         type => "string",
74                         example => "http://svn.example.org/trunk/[[file]]",
75                         description => "viewvc url to show file history ([[file]] substituted)",
76                         safe => 1,
77                         rebuild => 1,
78                 },
79                 diffurl => {
80                         type => "string",
81                         example => "http://svn.example.org/trunk/[[file]]?root=wiki&r1=[[r1]]&r2=[[r2]]",
82                         description => "viewvc url to show a diff ([[file]], [[r1]], and [[r2]] substituted)",
83                         safe => 1,
84                         rebuild => 1,
85                 },
86 } #}}}
87
88 # svn needs LC_CTYPE set to a UTF-8 locale, so try to find one. Any will do.
89 sub find_lc_ctype() {
90         my $current = setlocale(LC_CTYPE());
91         return $current if $current =~ m/UTF-?8$/i;
92
93         # Make some obvious attempts to avoid calling `locale -a`
94         foreach my $locale ("$current.UTF-8", "en_US.UTF-8", "en_GB.UTF-8") {
95                 return $locale if setlocale(LC_CTYPE(), $locale);
96         }
97
98         # Try to get all available locales and pick the first UTF-8 one found.
99         if (my @locale = grep(/UTF-?8$/i, `locale -a`)) {
100                 chomp @locale;
101                 return $locale[0] if setlocale(LC_CTYPE(), $locale[0]);
102         }
103
104         # fallback to the current locale
105         return $current;
106 } # }}}
107 $ENV{LC_CTYPE} = $ENV{LC_CTYPE} || find_lc_ctype();
108
109 sub svn_info ($$) { #{{{
110         my $field=shift;
111         my $file=shift;
112
113         my $info=`LANG=C svn info $file`;
114         my ($ret)=$info=~/^$field: (.*)$/m;
115         return $ret;
116 } #}}}
117
118 sub rcs_update () { #{{{
119         if (-d "$config{srcdir}/.svn") {
120                 if (system("svn", "update", "--quiet", $config{srcdir}) != 0) {
121                         warn("svn update failed\n");
122                 }
123         }
124 } #}}}
125
126 sub rcs_prepedit ($) { #{{{
127         # Prepares to edit a file under revision control. Returns a token
128         # that must be passed into rcs_commit when the file is ready
129         # for committing.
130         # The file is relative to the srcdir.
131         my $file=shift;
132         
133         if (-d "$config{srcdir}/.svn") {
134                 # For subversion, return the revision of the file when
135                 # editing begins.
136                 my $rev=svn_info("Revision", "$config{srcdir}/$file");
137                 return defined $rev ? $rev : "";
138         }
139 } #}}}
140
141 sub rcs_commit ($$$;$$) { #{{{
142         # Tries to commit the page; returns undef on _success_ and
143         # a version of the page with the rcs's conflict markers on failure.
144         # The file is relative to the srcdir.
145         my $file=shift;
146         my $message=shift;
147         my $rcstoken=shift;
148         my $user=shift;
149         my $ipaddr=shift;
150
151         if (defined $user) {
152                 $message="web commit by $user".(length $message ? ": $message" : "");
153         }
154         elsif (defined $ipaddr) {
155                 $message="web commit from $ipaddr".(length $message ? ": $message" : "");
156         }
157
158         if (-d "$config{srcdir}/.svn") {
159                 # Check to see if the page has been changed by someone
160                 # else since rcs_prepedit was called.
161                 my ($oldrev)=$rcstoken=~/^([0-9]+)$/; # untaint
162                 my $rev=svn_info("Revision", "$config{srcdir}/$file");
163                 if (defined $rev && defined $oldrev && $rev != $oldrev) {
164                         # Merge their changes into the file that we've
165                         # changed.
166                         if (system("svn", "merge", "--quiet", "-r$oldrev:$rev",
167                                    "$config{srcdir}/$file", "$config{srcdir}/$file") != 0) {
168                                 warn("svn merge -r$oldrev:$rev failed\n");
169                         }
170                 }
171
172                 if (system("svn", "commit", "--quiet", 
173                            "--encoding", "UTF-8", "-m",
174                            IkiWiki::possibly_foolish_untaint($message),
175                            $config{srcdir}) != 0) {
176                         my $conflict=readfile("$config{srcdir}/$file");
177                         if (system("svn", "revert", "--quiet", "$config{srcdir}/$file") != 0) {
178                                 warn("svn revert failed\n");
179                         }
180                         return $conflict;
181                 }
182         }
183         return undef # success
184 } #}}}
185
186 sub rcs_commit_staged ($$$) {
187         # Commits all staged changes. Changes can be staged using rcs_add,
188         # rcs_remove, and rcs_rename.
189         my ($message, $user, $ipaddr)=@_;
190         
191         if (defined $user) {
192                 $message="web commit by $user".(length $message ? ": $message" : "");
193         }
194         elsif (defined $ipaddr) {
195                 $message="web commit from $ipaddr".(length $message ? ": $message" : "");
196         }
197         
198         if (system("svn", "commit", "--quiet",
199                    "--encoding", "UTF-8", "-m",
200                    IkiWiki::possibly_foolish_untaint($message),
201                    $config{srcdir}) != 0) {
202                 warn("svn commit failed\n");
203                 return 1; # failure     
204         }
205         return undef # success
206 }
207
208 sub rcs_add ($) { #{{{
209         # filename is relative to the root of the srcdir
210         my $file=shift;
211
212         if (-d "$config{srcdir}/.svn") {
213                 my $parent=IkiWiki::dirname($file);
214                 while (! -d "$config{srcdir}/$parent/.svn") {
215                         $file=$parent;
216                         $parent=IkiWiki::dirname($file);
217                 }
218                 
219                 if (system("svn", "add", "--quiet", "$config{srcdir}/$file") != 0) {
220                         warn("svn add failed\n");
221                 }
222         }
223 } #}}}
224
225 sub rcs_remove ($) { #{{{
226         # filename is relative to the root of the srcdir
227         my $file=shift;
228
229         if (-d "$config{srcdir}/.svn") {
230                 if (system("svn", "rm", "--force", "--quiet", "$config{srcdir}/$file") != 0) {
231                         warn("svn rm failed\n");
232                 }
233         }
234 } #}}}
235
236 sub rcs_rename ($$) { #{{{
237         # filenames relative to the root of the srcdir
238         my ($src, $dest)=@_;
239         
240         if (-d "$config{srcdir}/.svn") {
241                 # Add parent directory for $dest
242                 my $parent=dirname($dest);
243                 if (! -d "$config{srcdir}/$parent/.svn") {
244                         while (! -d "$config{srcdir}/$parent/.svn") {
245                                 $parent=dirname($dest);
246                         }
247                         if (system("svn", "add", "--quiet", "$config{srcdir}/$parent") != 0) {
248                                 warn("svn add $parent failed\n");
249                         }
250                 }
251
252                 if (system("svn", "mv", "--force", "--quiet", 
253                     "$config{srcdir}/$src", "$config{srcdir}/$dest") != 0) {
254                         warn("svn rename failed\n");
255                 }
256         }
257 } #}}}
258
259 sub rcs_recentchanges ($) { #{{{
260         my $num=shift;
261         my @ret;
262         
263         return unless -d "$config{srcdir}/.svn";
264
265         eval q{
266                 use Date::Parse;
267                 use XML::SAX;
268                 use XML::Simple;
269         };
270         error($@) if $@;
271
272         # avoid using XML::SAX::PurePerl, it's buggy with UTF-8 data
273         my @parsers = map { ${$_}{Name} } @{XML::SAX->parsers()};
274         do {
275                 $XML::Simple::PREFERRED_PARSER = pop @parsers;
276         } until $XML::Simple::PREFERRED_PARSER ne 'XML::SAX::PurePerl';
277
278         # --limit is only supported on Subversion 1.2.0+
279         my $svn_version=`svn --version -q`;
280         my $svn_limit='';
281         $svn_limit="--limit $num"
282                 if $svn_version =~ /\d\.(\d)\.\d/ && $1 >= 2;
283
284         my $svn_url=svn_info("URL", $config{srcdir});
285         my $xml = XMLin(scalar `svn $svn_limit --xml -v log '$svn_url'`,
286                 ForceArray => [ 'logentry', 'path' ],
287                 GroupTags => { paths => 'path' },
288                 KeyAttr => { path => 'content' },
289         );
290         foreach my $logentry (@{$xml->{logentry}}) {
291                 my (@pages, @message);
292
293                 my $rev = $logentry->{revision};
294                 my $user = $logentry->{author};
295
296                 my $when=str2time($logentry->{date}, 'UTC');
297
298                 foreach my $msgline (split(/\n/, $logentry->{msg})) {
299                         push @message, { line => $msgline };
300                 }
301
302                 my $committype="web";
303                 if (defined $message[0] &&
304                     $message[0]->{line}=~/$config{web_commit_regexp}/) {
305                         $user=defined $2 ? "$2" : "$3";
306                         $message[0]->{line}=$4;
307                 }
308                 else {
309                         $committype="svn";
310                 }
311
312                 foreach my $file (keys %{$logentry->{paths}}) {
313                         if (length $config{svnpath}) {
314                                 next unless $file=~/^\/\Q$config{svnpath}\E\/([^ ]+)(?:$|\s)/;
315                                 $file=$1;
316                         }
317
318                         my $diffurl=defined $config{diffurl} ? $config{diffurl} : "";
319                         $diffurl=~s/\[\[file\]\]/$file/g;
320                         $diffurl=~s/\[\[r1\]\]/$rev - 1/eg;
321                         $diffurl=~s/\[\[r2\]\]/$rev/g;
322
323                         push @pages, {
324                                 page => pagename($file),
325                                 diffurl => $diffurl,
326                         } if length $file;
327                 }
328                 push @ret, {
329                         rev => $rev,
330                         user => $user,
331                         committype => $committype,
332                         when => $when,
333                         message => [@message],
334                         pages => [@pages],
335                 } if @pages;
336                 return @ret if @ret >= $num;
337         }
338
339         return @ret;
340 } #}}}
341
342 sub rcs_diff ($) { #{{{
343         my $rev=IkiWiki::possibly_foolish_untaint(int(shift));
344         return `svnlook diff $config{svnrepo} -r$rev --no-diff-deleted`;
345 } #}}}
346
347 sub rcs_getctime ($) { #{{{
348         my $file=shift;
349
350         my $svn_log_infoline=qr/^r\d+\s+\|\s+[^\s]+\s+\|\s+(\d+-\d+-\d+\s+\d+:\d+:\d+\s+[-+]?\d+).*/;
351                 
352         my $child = open(SVNLOG, "-|");
353         if (! $child) {
354                 exec("svn", "log", $file) || error("svn log $file failed to run");
355         }
356
357         my $date;
358         while (<SVNLOG>) {
359                 if (/$svn_log_infoline/) {
360                         $date=$1;
361                 }
362         }
363         close SVNLOG || warn "svn log $file exited $?";
364
365         if (! defined $date) {
366                 warn "failed to parse svn log for $file\n";
367                 return 0;
368         }
369                 
370         eval q{use Date::Parse};
371         error($@) if $@;
372         $date=str2time($date);
373         debug("found ctime ".localtime($date)." for $file");
374         return $date;
375 } #}}}
376
377 1