]> sipb.mit.edu Git - ikiwiki.git/blob - IkiWiki/Plugin/po.pm
bdf4b2c29740cd63f14a9b0804ed239725baca8f
[ikiwiki.git] / IkiWiki / Plugin / po.pm
1 #!/usr/bin/perl
2 # .po as a wiki page type
3 # Licensed under GPL v2 or greater
4 # Copyright (C) 2008 intrigeri <intrigeri@boum.org>
5 # inspired by the GPL'd po4a-translate,
6 # which is Copyright 2002, 2003, 2004 by Martin Quinson (mquinson#debian.org)
7 package IkiWiki::Plugin::po;
8
9 use warnings;
10 use strict;
11 use IkiWiki 2.00;
12 use Encode;
13 use Locale::Po4a::Chooser;
14 use Locale::Po4a::Po;
15 use File::Basename;
16 use File::Copy;
17 use File::Spec;
18 use File::Temp;
19 use Memoize;
20
21 my %translations;
22 our %filtered;
23
24 memoize("_istranslation");
25 memoize("percenttranslated");
26 # FIXME: memoizing istranslatable() makes some test cases fail once every
27 # two tries; this may be related to the artificial way the testsuite is
28 # run, or not.
29 # memoize("istranslatable");
30
31 # backup references to subs that will be overriden
32 my %origsubs;
33
34 sub import { #{{{
35         hook(type => "getsetup", id => "po", call => \&getsetup);
36         hook(type => "checkconfig", id => "po", call => \&checkconfig);
37         hook(type => "needsbuild", id => "po", call => \&needsbuild);
38         hook(type => "filter", id => "po", call => \&filter);
39         hook(type => "htmlize", id => "po", call => \&htmlize);
40         hook(type => "pagetemplate", id => "po", call => \&pagetemplate, last => 1);
41         hook(type => "editcontent", id => "po", call => \&editcontent);
42
43         $origsubs{'bestlink'}=\&IkiWiki::bestlink;
44         inject(name => "IkiWiki::bestlink", call => \&mybestlink);
45         $origsubs{'beautify_urlpath'}=\&IkiWiki::beautify_urlpath;
46         inject(name => "IkiWiki::beautify_urlpath", call => \&mybeautify_urlpath);
47         $origsubs{'targetpage'}=\&IkiWiki::targetpage;
48         inject(name => "IkiWiki::targetpage", call => \&mytargetpage);
49 } #}}}
50
51 sub getsetup () { #{{{
52         return
53                 plugin => {
54                         safe => 0,
55                         rebuild => 1, # format plugin & changes html filenames
56                 },
57                 po_master_language => {
58                         type => "string",
59                         example => {
60                                 'code' => 'en',
61                                 'name' => 'English'
62                         },
63                         description => "master language (non-PO files)",
64                         safe => 0,
65                         rebuild => 1,
66                 },
67                 po_slave_languages => {
68                         type => "string",
69                         example => {
70                                 'fr' => 'Français',
71                                 'es' => 'Castellano',
72                                 'de' => 'Deutsch'
73                         },
74                         description => "slave languages (PO files)",
75                         safe => 0,
76                         rebuild => 1,
77                 },
78                 po_translatable_pages => {
79                         type => "pagespec",
80                         example => "!*/Discussion",
81                         description => "PageSpec controlling which pages are translatable",
82                         link => "ikiwiki/PageSpec",
83                         safe => 0,
84                         rebuild => 1,
85                 },
86                 po_link_to => {
87                         type => "string",
88                         example => "current",
89                         description => "internal linking behavior (default/current/negotiated)",
90                         safe => 0,
91                         rebuild => 1,
92                 },
93 } #}}}
94
95 sub checkconfig () { #{{{
96         foreach my $field (qw{po_master_language po_slave_languages}) {
97                 if (! exists $config{$field} || ! defined $config{$field}) {
98                         error(sprintf(gettext("Must specify %s"), $field));
99                 }
100         }
101         if (! exists $config{po_link_to} ||
102             ! defined $config{po_link_to}) {
103                 $config{po_link_to}="default";
104         }
105         if (! exists $config{po_translatable_pages} ||
106             ! defined $config{po_translatable_pages}) {
107                 $config{po_translatable_pages}="";
108         }
109         if ($config{po_link_to} eq "negotiated" && ! $config{usedirs}) {
110                 error(gettext("po_link_to=negotiated requires usedirs to be set"));
111         }
112         push @{$config{wiki_file_prune_regexps}}, qr/\.pot$/;
113 } #}}}
114
115 sub potfile ($) { #{{{
116         my $masterfile=shift;
117
118         (my $name, my $dir, my $suffix) = fileparse($masterfile, qr/\.[^.]*/);
119         $dir='' if $dir eq './';
120         return File::Spec->catpath('', $dir, $name . ".pot");
121 } #}}}
122
123 sub pofile ($$) { #{{{
124         my $masterfile=shift;
125         my $lang=shift;
126
127         (my $name, my $dir, my $suffix) = fileparse($masterfile, qr/\.[^.]*/);
128         $dir='' if $dir eq './';
129         return File::Spec->catpath('', $dir, $name . "." . $lang . ".po");
130 } #}}}
131
132 sub refreshpot ($) { #{{{
133         my $masterfile=shift;
134
135         my $potfile=potfile($masterfile);
136         my %options = ("markdown" => (pagetype($masterfile) eq 'mdwn') ? 1 : 0);
137         my $doc=Locale::Po4a::Chooser::new('text',%options);
138         $doc->read($masterfile);
139         $doc->{TT}{utf_mode} = 1;
140         $doc->{TT}{file_in_charset} = 'utf-8';
141         $doc->{TT}{file_out_charset} = 'utf-8';
142         # let's cheat a bit to force porefs option to be passed to Locale::Po4a::Po;
143         # this is undocument use of internal Locale::Po4a::TransTractor's data,
144         # compulsory since this module prevents us from using the porefs option.
145         my %po_options = ('porefs' => 'none');
146         $doc->{TT}{po_out}=Locale::Po4a::Po->new(\%po_options);
147         $doc->{TT}{po_out}->set_charset('utf-8');
148         # do the actual work
149         $doc->parse;
150         $doc->writepo($potfile);
151 } #}}}
152
153 sub refreshpofiles ($@) { #{{{
154         my $masterfile=shift;
155         my @pofiles=@_;
156
157         my $potfile=potfile($masterfile);
158         error("[po/refreshpofiles] POT file ($potfile) does not exist") unless (-e $potfile);
159
160         foreach my $pofile (@pofiles) {
161                 if (-e $pofile) {
162                         system("msgmerge", "-U", "--backup=none", $pofile, $potfile) == 0
163                                 or error("[po/refreshpofiles:$pofile] failed to update");
164                 }
165                 else {
166                         File::Copy::syscopy($potfile,$pofile)
167                                 or error("[po/refreshpofiles:$pofile] failed to copy the POT file");
168                 }
169         }
170 } #}}}
171
172 sub needsbuild () { #{{{
173         my $needsbuild=shift;
174
175         # build %translations, using istranslation's side-effect
176         foreach my $page (keys %pagesources) {
177                 istranslation($page);
178         }
179
180         # refresh/create POT and PO files as needed
181         my $updated_po_files=0;
182         foreach my $page (keys %pagesources) {
183                 if (istranslatable($page)) {
184                         my $pageneedsbuild = grep { $_ eq $pagesources{$page} } @$needsbuild;
185                         my $updated_pot_file=0;
186                         my $file=srcfile($pagesources{$page});
187                         if ($pageneedsbuild || ! -e potfile($file)) {
188                                 refreshpot($file);
189                                 $updated_pot_file=1;
190                         }
191                         my @pofiles;
192                         foreach my $lang (keys %{$config{po_slave_languages}}) {
193                                 my $pofile=pofile($file, $lang);
194                                 my $pofile_rel=pofile($pagesources{$page}, $lang);
195                                 if ($pageneedsbuild || $updated_pot_file || ! -e $pofile) {
196                                         push @pofiles, $pofile;
197                                         push @$needsbuild, $pofile_rel
198                                           unless grep { $_ eq $pofile_rel } @$needsbuild;
199                                 }
200                         }
201                         if (@pofiles) {
202                                 refreshpofiles($file, @pofiles) ;
203                                 map { IkiWiki::rcs_add($_); } @pofiles if ($config{rcs});
204                                 $updated_po_files = 1;
205                         }
206                 }
207         }
208
209         # check staged changes in
210         if ($updated_po_files) {
211                 if ($config{rcs}) {
212                         IkiWiki::disable_commit_hook();
213                         IkiWiki::rcs_commit_staged(gettext("updated PO files"),
214                                 "refreshpofiles", "127.0.0.1");
215                         IkiWiki::enable_commit_hook();
216                         IkiWiki::rcs_update();
217                 }
218                 # refresh module's private variables
219                 undef %filtered;
220                 undef %translations;
221                 foreach my $page (keys %pagesources) {
222                         istranslation($page);
223                 }
224         }
225
226         # make existing translations depend on the corresponding master page
227         foreach my $master (keys %translations) {
228                 foreach my $slave (values %{$translations{$master}}) {
229                         add_depends($slave, $master);
230                 }
231         }
232 } #}}}
233
234 sub mytargetpage ($$) { #{{{
235         my $page=shift;
236         my $ext=shift;
237
238         if (istranslation($page)) {
239                 my ($masterpage, $lang) = ($page =~ /(.*)[.]([a-z]{2})$/);
240                 if (! $config{usedirs} || $masterpage eq 'index') {
241                         return $masterpage . "." . $lang . "." . $ext;
242                 }
243                 else {
244                         return $masterpage . "/index." . $lang . "." . $ext;
245                 }
246         }
247         elsif (istranslatable($page)) {
248                 if (! $config{usedirs} || $page eq 'index') {
249                         return $page . "." . $config{po_master_language}{code} . "." . $ext;
250                 }
251                 else {
252                         return $page . "/index." . $config{po_master_language}{code} . "." . $ext;
253                 }
254         }
255         return $origsubs{'targetpage'}->($page, $ext);
256 } #}}}
257
258 sub mybeautify_urlpath ($) { #{{{
259         my $url=shift;
260
261         my $res=$origsubs{'beautify_urlpath'}->($url);
262         if ($config{po_link_to} eq "negotiated") {
263                 $res =~ s!/\Qindex.$config{po_master_language}{code}.$config{htmlext}\E$!/!;
264         }
265         return $res;
266 } #}}}
267
268 sub urlto_with_orig_beautiful_urlpath($$) { #{{{
269         my $to=shift;
270         my $from=shift;
271
272         inject(name => "IkiWiki::beautify_urlpath", call => $origsubs{'beautify_urlpath'});
273         my $res=urlto($to, $from);
274         inject(name => "IkiWiki::beautify_urlpath", call => \&mybeautify_urlpath);
275
276         return $res;
277 } #}}}
278
279 sub mybestlink ($$) { #{{{
280         my $page=shift;
281         my $link=shift;
282
283         my $res=$origsubs{'bestlink'}->($page, $link);
284         if (length $res) {
285                 if ($config{po_link_to} eq "current"
286                     && istranslatable($res)
287                     && istranslation($page)) {
288                         my ($masterpage, $curlang) = ($page =~ /(.*)[.]([a-z]{2})$/);
289                         return $res . "." . $curlang;
290                 }
291                 else {
292                         return $res;
293                 }
294         }
295         return "";
296 } #}}}
297
298 # We use filter to convert PO to the master page's format,
299 # since the rest of ikiwiki should not work on PO files.
300 sub filter (@) { #{{{
301         my %params = @_;
302
303         my $page = $params{page};
304         my $destpage = $params{destpage};
305         my $content = decode_utf8(encode_utf8($params{content}));
306
307         return $content if ( ! istranslation($page)
308                              || ( exists $filtered{$page}{$destpage}
309                                   && $filtered{$page}{$destpage} eq 1 ));
310
311         # CRLF line terminators make poor Locale::Po4a feel bad
312         $content=~s/\r\n/\n/g;
313
314         # Implementation notes
315         #
316         # 1. Locale::Po4a reads/writes from/to files, and I'm too lazy
317         #    to learn how to disguise a variable as a file.
318         # 2. There are incompatibilities between some File::Temp versions
319         #    (including 0.18, bundled with Lenny's perl-modules package)
320         #    and others (e.g. 0.20, previously present in the archive as
321         #    a standalone package): under certain circumstances, some
322         #    return a relative filename, whereas others return an absolute one;
323         #    we here use this module in a way that is at least compatible
324         #    with 0.18 and 0.20. Beware, hit'n'run refactorers!
325         my $infile = new File::Temp(TEMPLATE => "ikiwiki-po-filter-in.XXXXXXXXXX",
326                                     DIR => File::Spec->tmpdir,
327                                     UNLINK => 1)->filename;
328         my $outfile = new File::Temp(TEMPLATE => "ikiwiki-po-filter-out.XXXXXXXXXX",
329                                      DIR => File::Spec->tmpdir,
330                                      UNLINK => 1)->filename;
331
332         writefile(basename($infile), File::Spec->tmpdir, $content);
333
334         my ($masterpage, $lang) = ($page =~ /(.*)[.]([a-z]{2})$/);
335         my $masterfile = srcfile($pagesources{$masterpage});
336         my (@pos,@masters);
337         push @pos,$infile;
338         push @masters,$masterfile;
339         my %options = (
340                 "markdown" => (pagetype($masterfile) eq 'mdwn') ? 1 : 0,
341         );
342         my $doc=Locale::Po4a::Chooser::new('text',%options);
343         $doc->process(
344                 'po_in_name'    => \@pos,
345                 'file_in_name'  => \@masters,
346                 'file_in_charset'  => 'utf-8',
347                 'file_out_charset' => 'utf-8',
348         ) or error("[po/filter:$infile]: failed to translate");
349         $doc->write($outfile) or error("[po/filter:$infile] could not write $outfile");
350         $content = readfile($outfile) or error("[po/filter:$infile] could not read $outfile");
351
352         # Unlinking should happen automatically, thanks to File::Temp,
353         # but it does not work here, probably because of the way writefile()
354         # and Locale::Po4a::write() work.
355         unlink $infile, $outfile;
356
357         $filtered{$page}{$destpage}=1;
358         return $content;
359 } #}}}
360
361 sub htmlize (@) { #{{{
362         my %params=@_;
363
364         my $page = $params{page};
365         my $content = $params{content};
366         my ($masterpage, $lang) = ($page =~ /(.*)[.]([a-z]{2})$/);
367         my $masterfile = srcfile($pagesources{$masterpage});
368
369         # force content to be htmlize'd as if it was the same type as the master page
370         return IkiWiki::htmlize($page, $page, pagetype($masterfile), $content);
371 } #}}}
372
373 sub percenttranslated ($) { #{{{
374         my $page=shift;
375
376         return gettext("N/A") unless (istranslation($page));
377         my ($masterpage, $lang) = ($page =~ /(.*)[.]([a-z]{2})$/);
378         my $file=srcfile($pagesources{$page});
379         my $masterfile = srcfile($pagesources{$masterpage});
380         my (@pos,@masters);
381         push @pos,$file;
382         push @masters,$masterfile;
383         my %options = (
384                 "markdown" => (pagetype($masterfile) eq 'mdwn') ? 1 : 0,
385         );
386         my $doc=Locale::Po4a::Chooser::new('text',%options);
387         $doc->process(
388                 'po_in_name'    => \@pos,
389                 'file_in_name'  => \@masters,
390                 'file_in_charset'  => 'utf-8',
391                 'file_out_charset' => 'utf-8',
392         ) or error("[po/percenttranslated:$file]: failed to translate");
393         my ($percent,$hit,$queries) = $doc->stats();
394         return $percent;
395 } #}}}
396
397 sub otherlanguages ($) { #{{{
398         my $page=shift;
399
400         my @ret;
401         if (istranslatable($page)) {
402                 foreach my $lang (sort keys %{$translations{$page}}) {
403                         my $translation = $translations{$page}{$lang};
404                         push @ret, {
405                                 url => urlto($translation, $page),
406                                 code => $lang,
407                                 language => $config{po_slave_languages}{$lang},
408                                 percent => percenttranslated($translation),
409                         };
410                 }
411         }
412         elsif (istranslation($page)) {
413                 my ($masterpage, $curlang) = ($page =~ /(.*)[.]([a-z]{2})$/);
414                 push @ret, {
415                         url => urlto_with_orig_beautiful_urlpath($masterpage, $page),
416                         code => $config{po_master_language}{code},
417                         language => $config{po_master_language}{name},
418                         master => 1,
419                 };
420                 foreach my $lang (sort keys %{$translations{$masterpage}}) {
421                         push @ret, {
422                                 url => urlto($translations{$masterpage}{$lang}, $page),
423                                 code => $lang,
424                                 language => $config{po_slave_languages}{$lang},
425                                 percent => percenttranslated($translations{$masterpage}{$lang}),
426                         } unless ($lang eq $curlang);
427                 }
428         }
429         return @ret;
430 } #}}}
431
432 sub pagetemplate (@) { #{{{
433         my %params=@_;
434         my $page=$params{page};
435         my $destpage=$params{destpage};
436         my $template=$params{template};
437
438         my ($masterpage, $lang) = ($page =~ /(.*)[.]([a-z]{2})$/) if istranslation($page);
439
440         if (istranslation($page) && $template->query(name => "percenttranslated")) {
441                 $template->param(percenttranslated => percenttranslated($page));
442         }
443         if ($template->query(name => "istranslation")) {
444                 $template->param(istranslation => istranslation($page));
445         }
446         if ($template->query(name => "istranslatable")) {
447                 $template->param(istranslatable => istranslatable($page));
448         }
449         if ($template->query(name => "otherlanguages")) {
450                 $template->param(otherlanguages => [otherlanguages($page)]);
451                 if (istranslatable($page)) {
452                         foreach my $translation (values %{$translations{$page}}) {
453                                 add_depends($page, $translation);
454                         }
455                 }
456                 elsif (istranslation($page)) {
457                         add_depends($page, $masterpage);
458                         foreach my $translation (values %{$translations{$masterpage}}) {
459                                 add_depends($page, $translation);
460                         }
461                 }
462         }
463         # Rely on IkiWiki::Render's genpage() to decide wether
464         # a discussion link should appear on $page; this is not
465         # totally accurate, though: some broken links may be generated
466         # when cgiurl is disabled.
467         # This compromise avoids some code duplication, and will probably
468         # prevent future breakage when ikiwiki internals change.
469         # Known limitations are preferred to future random bugs.
470         if ($template->param('discussionlink') && istranslation($page)) {
471                 $template->param('discussionlink' => htmllink(
472                                                         $page,
473                                                         $destpage,
474                                                         $masterpage . '/' . gettext("Discussion"),
475                                                         noimageinline => 1,
476                                                         forcesubpage => 0,
477                                                         linktext => gettext("Discussion"),
478                                                         ));
479         }
480         # remove broken parentlink to ./index.html on home page's translations
481         if ($template->param('parentlinks')
482             && istranslation($page)
483             && $masterpage eq "index") {
484                 $template->param('parentlinks' => []);
485         }
486 } # }}}
487
488 sub editcontent () { #{{{
489         my %params=@_;
490         # as we're previewing or saving a page, the content may have
491         # changed, so tell the next filter() invocation it must not be lazy
492         if (exists $filtered{$params{page}}{$params{page}}) {
493                 delete $filtered{$params{page}}{$params{page}};
494         }
495         return $params{content};
496 } #}}}
497
498 sub istranslatable ($) { #{{{
499         my $page=shift;
500
501         my $file=$pagesources{$page};
502
503         if (! defined $file
504             || (defined pagetype($file) && pagetype($file) eq 'po')
505             || $file =~ /\.pot$/) {
506                 return 0;
507         }
508         return pagespec_match($page, $config{po_translatable_pages});
509 } #}}}
510
511 sub _istranslation ($) { #{{{
512         my $page=shift;
513
514         my $file=$pagesources{$page};
515         if (! defined $file) {
516                 return IkiWiki::FailReason->new("no file specified");
517         }
518
519         if (! defined $file
520             || ! defined pagetype($file)
521             || ! pagetype($file) eq 'po'
522             || $file =~ /\.pot$/) {
523                 return 0;
524         }
525
526         my ($masterpage, $lang) = ($page =~ /(.*)[.]([a-z]{2})$/);
527         if (! defined $masterpage || ! defined $lang
528             || ! (length($masterpage) > 0) || ! (length($lang) > 0)
529             || ! defined $pagesources{$masterpage}
530             || ! defined $config{po_slave_languages}{$lang}) {
531                 return 0;
532         }
533
534         return istranslatable($masterpage);
535 } #}}}
536
537 sub istranslation ($) { #{{{
538         my $page=shift;
539
540         if (_istranslation($page)) {
541                 my ($masterpage, $lang) = ($page =~ /(.*)[.]([a-z]{2})$/);
542                 $translations{$masterpage}{$lang}=$page unless exists $translations{$masterpage}{$lang};
543                 return 1;
544         }
545         return 0;
546 } #}}}
547
548 package IkiWiki::PageSpec;
549 use warnings;
550 use strict;
551 use IkiWiki 2.00;
552
553 sub match_istranslation ($;@) { #{{{
554         my $page=shift;
555
556         if (IkiWiki::Plugin::po::istranslation($page)) {
557                 return IkiWiki::SuccessReason->new("is a translation page");
558         }
559         else {
560                 return IkiWiki::FailReason->new("is not a translation page");
561         }
562 } #}}}
563
564 sub match_istranslatable ($;@) { #{{{
565         my $page=shift;
566
567         if (IkiWiki::Plugin::po::istranslatable($page)) {
568                 return IkiWiki::SuccessReason->new("is set as translatable in po_translatable_pages");
569         }
570         else {
571                 return IkiWiki::FailReason->new("is not set as translatable in po_translatable_pages");
572         }
573 } #}}}
574
575 sub match_lang ($$;@) { #{{{
576         my $page=shift;
577         my $wanted=shift;
578
579         my $regexp=IkiWiki::glob2re($wanted);
580         my $lang;
581         my $masterpage;
582
583         if (IkiWiki::Plugin::po::istranslation($page)) {
584                 ($masterpage, $lang) = ($page =~ /(.*)[.]([a-z]{2})$/);
585         }
586         else {
587                 $lang = $config{po_master_language}{code};
588         }
589
590         if ($lang!~/^$regexp$/i) {
591                 return IkiWiki::FailReason->new("file language is $lang, not $wanted");
592         }
593         else {
594                 return IkiWiki::SuccessReason->new("file language is $wanted");
595         }
596 } #}}}
597
598 sub match_currentlang ($$;@) { #{{{
599         my $page=shift;
600
601         shift;
602         my %params=@_;
603         my ($currentmasterpage, $currentlang, $masterpage, $lang);
604
605         return IkiWiki::FailReason->new("no location provided") unless exists $params{location};
606
607         if (IkiWiki::Plugin::po::istranslation($params{location})) {
608                 ($currentmasterpage, $currentlang) = ($params{location} =~ /(.*)[.]([a-z]{2})$/);
609         }
610         else {
611                 $currentlang = $config{po_master_language}{code};
612         }
613
614         if (IkiWiki::Plugin::po::istranslation($page)) {
615                 ($masterpage, $lang) = ($page =~ /(.*)[.]([a-z]{2})$/);
616         }
617         else {
618                 $lang = $config{po_master_language}{code};
619         }
620
621         if ($lang eq $currentlang) {
622                 return IkiWiki::SuccessReason->new("file language is the same as current one, i.e. $currentlang");
623         }
624         else {
625                 return IkiWiki::FailReason->new("file language is $lang, whereas current language is $currentlang");
626         }
627 } #}}}
628
629 1