fa250f3a4991c4b56798a9f775130324f9b68088
[ikiwiki.git] / IkiWiki / Plugin / po.pm
1 #!/usr/bin/perl
2 # .po as a wiki page type
3 # inspired by the GPL'd po4a-translate,
4 # which is Copyright 2002, 2003, 2004 by Martin Quinson (mquinson#debian.org)
5 package IkiWiki::Plugin::po;
6
7 use warnings;
8 use strict;
9 use IkiWiki 2.00;
10 use Encode;
11 use Locale::Po4a::Chooser;
12 use Locale::Po4a::Po;
13 use File::Basename;
14 use File::Copy;
15 use File::Spec;
16 use File::Temp;
17 use Memoize;
18
19 my %translations;
20 our %filtered;
21 my $origbestlink=\&bestlink;
22 ## FIXME: makes some test cases cry once every two tries; this may be
23 ## related to the artificial way the testsuite is run, or not.
24 # memoize("istranslatable");
25 memoize("_istranslation");
26 memoize("percenttranslated");
27
28 sub import {
29         hook(type => "getsetup", id => "po", call => \&getsetup);
30         hook(type => "checkconfig", id => "po", call => \&checkconfig);
31         hook(type => "needsbuild", id => "po", call => \&needsbuild);
32         hook(type => "targetpage", id => "po", call => \&targetpage);
33         hook(type => "tweakurlpath", id => "po", call => \&tweakurlpath);
34         hook(type => "filter", id => "po", call => \&filter);
35         hook(type => "htmlize", id => "po", call => \&htmlize);
36         hook(type => "pagetemplate", id => "po", call => \&pagetemplate);
37         inject(name => "IkiWiki::bestlink", call => \&mybestlink);
38 }
39
40 sub getsetup () { #{{{
41         return
42                 plugin => {
43                         safe => 0,
44                         rebuild => 1, # format plugin
45                 },
46                 po_master_language => {
47                         type => "string",
48                         example => {
49                                 'code' => 'en',
50                                 'name' => 'English'
51                         },
52                         description => "master language (non-PO files)",
53                         safe => 0,
54                         rebuild => 1,
55                 },
56                 po_slave_languages => {
57                         type => "string",
58                         example => {
59                                 'fr' => 'Fran├žais',
60                                 'es' => 'Castellano',
61                                 'de' => 'Deutsch'
62                         },
63                         description => "slave languages (PO files)",
64                         safe => 0,
65                         rebuild => 1,
66                 },
67                 po_translatable_pages => {
68                         type => "pagespec",
69                         example => "!*/Discussion",
70                         description => "PageSpec controlling which pages are translatable",
71                         link => "ikiwiki/PageSpec",
72                         safe => 0,
73                         rebuild => 1,
74                 },
75                 po_link_to => {
76                         type => "string",
77                         example => "current",
78                         description => "internal linking behavior (default/current/negotiated)",
79                         safe => 0,
80                         rebuild => 1,
81                 },
82 } #}}}
83
84 sub checkconfig () { #{{{
85         foreach my $field (qw{po_master_language po_slave_languages}) {
86                 if (! exists $config{$field} || ! defined $config{$field}) {
87                         error(sprintf(gettext("Must specify %s"), $field));
88                 }
89         }
90         if (! exists $config{po_link_to} ||
91             ! defined $config{po_link_to}) {
92             $config{po_link_to}="default";
93         }
94         if (! exists $config{po_translatable_pages} ||
95             ! defined $config{po_translatable_pages}) {
96             $config{po_translatable_pages}="";
97         }
98         if ($config{po_link_to} eq "negotiated" && ! $config{usedirs}) {
99                 error(gettext("po_link_to=negotiated requires usedirs to be set"));
100         }
101         push @{$config{wiki_file_prune_regexps}}, qr/\.pot$/;
102 } #}}}
103
104 sub potfile ($) { #{{{
105         my $masterfile=shift;
106         (my $name, my $dir, my $suffix) = fileparse($masterfile, qr/\.[^.]*/);
107         return File::Spec->catfile($dir, $name . ".pot");
108 } #}}}
109
110 sub pofile ($$) { #{{{
111         my $masterfile=shift;
112         my $lang=shift;
113         (my $name, my $dir, my $suffix) = fileparse($masterfile, qr/\.[^.]*/);
114         return File::Spec->catfile($dir, $name . "." . $lang . ".po");
115 } #}}}
116
117 sub refreshpot ($) { #{{{
118         my $masterfile=shift;
119         my $potfile=potfile($masterfile);
120         my %options = ("markdown" => (pagetype($masterfile) eq 'mdwn') ? 1 : 0);
121         my $doc=Locale::Po4a::Chooser::new('text',%options);
122         $doc->read($masterfile);
123         $doc->{TT}{utf_mode} = 1;
124         $doc->{TT}{file_in_charset} = 'utf-8';
125         $doc->{TT}{file_out_charset} = 'utf-8';
126         # let's cheat a bit to force porefs option to be passed to Locale::Po4a::Po;
127         # this is undocument use of internal Locale::Po4a::TransTractor's data,
128         # compulsory since this module prevents us from using the porefs option.
129         my %po_options = ('porefs' => 'none');
130         $doc->{TT}{po_out}=Locale::Po4a::Po->new(\%po_options);
131         # do the actual work
132         $doc->parse;
133         $doc->writepo($potfile);
134 } #}}}
135
136 sub refreshpofiles ($@) { #{{{
137         my $masterfile=shift;
138         my @pofiles=@_;
139
140         my $potfile=potfile($masterfile);
141         error("[po/refreshpofiles] POT file ($potfile) does not exist") unless (-e $potfile);
142
143         foreach my $pofile (@pofiles) {
144                 if (-e $pofile) {
145                         my $cmd = "msgmerge -U --backup=none $pofile $potfile";
146                         system ($cmd) == 0
147                                 or error("[po/refreshpofiles:$pofile] failed to update");
148                 }
149                 else {
150                         File::Copy::syscopy($potfile,$pofile)
151                                 or error("[po/refreshpofiles:$pofile] failed to copy the POT file");
152                 }
153         }
154 } #}}}
155
156 sub needsbuild () { #{{{
157         my $needsbuild=shift;
158
159         # build %translations, using istranslation's side-effect
160         foreach my $page (keys %pagesources) {
161                 istranslation($page);
162         }
163
164         # refresh/create POT and PO files as needed
165         my $updated_po_files=0;
166         foreach my $page (keys %pagesources) {
167                 my $pageneedsbuild = grep { $_ eq $pagesources{$page} } @$needsbuild;
168                 if (istranslatable($page)) {
169                         my $file=srcfile($pagesources{$page});
170                         if ($pageneedsbuild || ! -e potfile($file)) {
171                                 refreshpot($file);
172                         }
173                         my @pofiles;
174                         foreach my $lang (keys %{$config{po_slave_languages}}) {
175                                 my $pofile=pofile($file, $lang);
176                                 if ($pageneedsbuild || ! -e $pofile) {
177                                         push @pofiles, $pofile;
178                                 }
179                         }
180                         if (@pofiles) {
181                                 refreshpofiles($file, @pofiles) ;
182                                 map { IkiWiki::rcs_add($_); } @pofiles if ($config{rcs});
183                                 $updated_po_files = 1;
184                         }
185                 }
186         }
187
188         # check staged changes in and trigger a wiki refresh.
189         if ($updated_po_files) {
190                 if ($config{rcs}) {
191                         IkiWiki::disable_commit_hook();
192                         IkiWiki::rcs_commit_staged(gettext("updated PO files"),
193                                 "refreshpofiles", "127.0.0.1");
194                         IkiWiki::enable_commit_hook();
195                         IkiWiki::rcs_update();
196                 }
197                 IkiWiki::refresh();
198                 IkiWiki::saveindex();
199                 # refresh module's private variables
200                 %filtered=undef;
201                 %translations=undef;
202                 foreach my $page (keys %pagesources) {
203                         istranslation($page);
204                 }
205         }
206
207
208         # make existing translations depend on the corresponding master page
209         foreach my $master (keys %translations) {
210                 foreach my $slave (values %{$translations{$master}}) {
211                         add_depends($slave, $master);
212                 }
213         }
214 } #}}}
215
216 sub targetpage (@) { #{{{
217         my %params = @_;
218         my $page=$params{page};
219         my $ext=$params{ext};
220
221         if (istranslation($page)) {
222                 my ($masterpage, $lang) = ($page =~ /(.*)[.]([a-z]{2})$/);
223                 if (! $config{usedirs} || $page eq 'index') {
224                         return $masterpage . "." . $lang . "." . $ext;
225                 }
226                 else {
227                         return $masterpage . "/index." . $lang . "." . $ext;
228                 }
229         }
230         elsif (istranslatable($page)) {
231                 if (! $config{usedirs} || $page eq 'index') {
232                         return $page . "." . $config{po_master_language}{code} . "." . $ext;
233                 }
234                 else {
235                         return $page . "/index." . $config{po_master_language}{code} . "." . $ext;
236                 }
237         }
238         return;
239 } #}}}
240
241 sub tweakurlpath ($) { #{{{
242         my %params = @_;
243         my $url=$params{url};
244         if ($config{po_link_to} eq "negotiated") {
245                 $url =~ s!/index.$config{po_master_language}{code}.$config{htmlext}$!/!;
246         }
247         return $url;
248 } #}}}
249
250 sub mybestlink ($$) { #{{{
251         my $page=shift;
252         my $link=shift;
253         my $res=$origbestlink->($page, $link);
254         if (length $res) {
255                 if ($config{po_link_to} eq "current"
256                     && istranslatable($res)
257                     && istranslation($page)) {
258                         my ($masterpage, $curlang) = ($page =~ /(.*)[.]([a-z]{2})$/);
259                         return $res . "." . $curlang;
260                 }
261                 else {
262                         return $res;
263                 }
264         }
265         return "";
266 } #}}}
267
268 # We use filter to convert PO to the master page's type,
269 # since other plugins should not work on PO files
270 sub filter (@) { #{{{
271         my %params = @_;
272         my $page = $params{page};
273         my $destpage = $params{destpage};
274         my $content = decode_utf8(encode_utf8($params{content}));
275
276         # decide if this is a PO file that should be converted into a translated document,
277         # and perform various sanity checks
278         if (! istranslation($page) || $filtered{$page}{$destpage}) {
279                 return $content;
280         }
281
282         my ($masterpage, $lang) = ($page =~ /(.*)[.]([a-z]{2})$/);
283         my $file=srcfile(exists $params{file} ? $params{file} : $IkiWiki::pagesources{$page});
284         my $masterfile = srcfile($pagesources{$masterpage});
285         my (@pos,@masters);
286         push @pos,$file;
287         push @masters,$masterfile;
288         my %options = (
289                         "markdown" => (pagetype($masterfile) eq 'mdwn') ? 1 : 0,
290                         );
291         my $doc=Locale::Po4a::Chooser::new('text',%options);
292         $doc->process(
293                 'po_in_name'    => \@pos,
294                 'file_in_name'  => \@masters,
295                 'file_in_charset'  => 'utf-8',
296                 'file_out_charset' => 'utf-8',
297         ) or error("[po/filter:$file]: failed to translate");
298         my $tmpfh = File::Temp->new(TEMPLATE => "/tmp/ikiwiki-po-filter-out.XXXXXXXXXX");
299         my $tmpout = $tmpfh->filename;
300         $doc->write($tmpout) or error("[po/filter:$file] could not write $tmpout");
301         $content = readfile($tmpout) or error("[po/filter:$file] could not read $tmpout");
302         $filtered{$page}{$destpage}=1;
303         return $content;
304 } #}}}
305
306 sub htmlize (@) { #{{{
307         my %params=@_;
308         my $page = $params{page};
309         my $content = $params{content};
310         my ($masterpage, $lang) = ($page =~ /(.*)[.]([a-z]{2})$/);
311         my $masterfile = srcfile($pagesources{$masterpage});
312
313         # force content to be htmlize'd as if it was the same type as the master page
314         return IkiWiki::htmlize($page, $page, pagetype($masterfile), $content);
315 } #}}}
316
317 sub percenttranslated ($) { #{{{
318         my $page=shift;
319         return "N/A" unless (istranslation($page));
320         my ($masterpage, $lang) = ($page =~ /(.*)[.]([a-z]{2})$/);
321         my $file=srcfile($pagesources{$page});
322         my $masterfile = srcfile($pagesources{$masterpage});
323         my (@pos,@masters);
324         push @pos,$file;
325         push @masters,$masterfile;
326         my %options = (
327                         "markdown" => (pagetype($masterfile) eq 'mdwn') ? 1 : 0,
328                         );
329         my $doc=Locale::Po4a::Chooser::new('text',%options);
330         $doc->process(
331                 'po_in_name'    => \@pos,
332                 'file_in_name'  => \@masters,
333                 'file_in_charset'  => 'utf-8',
334                 'file_out_charset' => 'utf-8',
335         ) or error("[po/percenttranslated:$file]: failed to translate");
336         my ($percent,$hit,$queries) = $doc->stats();
337         return $percent;
338 } #}}}
339
340 sub otherlanguages ($) { #{{{
341         my $page=shift;
342         my @ret;
343         if (istranslatable($page)) {
344                 foreach my $lang (sort keys %{$translations{$page}}) {
345                         my $translation = $translations{$page}{$lang};
346                         push @ret, {
347                                 url => urlto($translation, $page),
348                                 code => $lang,
349                                 language => $config{po_slave_languages}{$lang},
350                                 percent => percenttranslated($translation),
351                         };
352                 }
353         }
354         elsif (istranslation($page)) {
355                 my ($masterpage, $curlang) = ($page =~ /(.*)[.]([a-z]{2})$/);
356                 push @ret, {
357                         url => urlto($masterpage, $page),
358                         code => $config{po_master_language}{code},
359                         language => $config{po_master_language}{name},
360                         master => 1,
361                 };
362                 foreach my $lang (sort keys %{$translations{$masterpage}}) {
363                         push @ret, {
364                                 url => urlto($translations{$masterpage}{$lang}, $page),
365                                 code => $lang,
366                                 language => $config{po_slave_languages}{$lang},
367                                 percent => percenttranslated($translations{$masterpage}{$lang}),
368                         } unless ($lang eq $curlang);
369                 }
370         }
371         return @ret;
372 } #}}}
373
374 sub pagetemplate (@) { #{{{
375         my %params=@_;
376         my $page=$params{page};
377         my $template=$params{template};
378
379         if (istranslation($page) && $template->query(name => "percenttranslated")) {
380                 $template->param(percenttranslated => percenttranslated($page));
381         }
382         if ($template->query(name => "istranslation")) {
383                 $template->param(istranslation => istranslation($page));
384         }
385         if ($template->query(name => "istranslatable")) {
386                 $template->param(istranslatable => istranslatable($page));
387         }
388         if ($template->query(name => "otherlanguages")) {
389                 $template->param(otherlanguages => [otherlanguages($page)]);
390                 if (istranslatable($page)) {
391                         foreach my $translation (values %{$translations{$page}}) {
392                                 add_depends($page, $translation);
393                         }
394                 }
395                 elsif (istranslation($page)) {
396                         my ($masterpage, $curlang) = ($page =~ /(.*)[.]([a-z]{2})$/);
397                         add_depends($page, $masterpage);
398                         foreach my $translation (values %{$translations{$masterpage}}) {
399                                 add_depends($page, $translation);
400                         }
401                 }
402         }
403 } # }}}
404
405 sub istranslatable ($) { #{{{
406         my $page=shift;
407         my $file=$pagesources{$page};
408
409         if (! defined $file
410             || (defined pagetype($file) && pagetype($file) eq 'po')
411             || $file =~ /\.pot$/) {
412                 return 0;
413         }
414         return pagespec_match($page, $config{po_translatable_pages});
415 } #}}}
416
417 sub _istranslation ($) { #{{{
418         my $page=shift;
419         my $file=$pagesources{$page};
420         if (! defined $file) {
421                 return IkiWiki::FailReason->new("no file specified");
422         }
423
424         if (! defined $file
425             || ! defined pagetype($file)
426             || ! pagetype($file) eq 'po'
427             || $file =~ /\.pot$/) {
428                 return 0;
429         }
430
431         my ($masterpage, $lang) = ($page =~ /(.*)[.]([a-z]{2})$/);
432         if (! defined $masterpage || ! defined $lang
433             || ! (length($masterpage) > 0) || ! (length($lang) > 0)
434             || ! defined $pagesources{$masterpage}
435             || ! defined $config{po_slave_languages}{$lang}) {
436                 return 0;
437         }
438
439         return istranslatable($masterpage);
440 } #}}}
441
442 sub istranslation ($) { #{{{
443         my $page=shift;
444         if (_istranslation($page)) {
445                 my ($masterpage, $lang) = ($page =~ /(.*)[.]([a-z]{2})$/);
446                 $translations{$masterpage}{$lang}=$page unless exists $translations{$masterpage}{$lang};
447                 return 1;
448         }
449         return 0;
450 } #}}}
451
452 package IkiWiki::PageSpec;
453 use warnings;
454 use strict;
455 use IkiWiki 2.00;
456
457 sub match_istranslation ($;@) { #{{{
458         my $page=shift;
459         if (IkiWiki::Plugin::po::istranslation($page)) {
460                 return IkiWiki::SuccessReason->new("is a translation page");
461         }
462         else {
463                 return IkiWiki::FailReason->new("is not a translation page");
464         }
465 } #}}}
466
467 sub match_istranslatable ($;@) { #{{{
468         my $page=shift;
469         if (IkiWiki::Plugin::po::istranslatable($page)) {
470                 return IkiWiki::SuccessReason->new("is set as translatable in po_translatable_pages");
471         }
472         else {
473                 return IkiWiki::FailReason->new("is not set as translatable in po_translatable_pages");
474         }
475 } #}}}
476
477 sub match_lang ($$;@) { #{{{
478         my $page=shift;
479         my $wanted=shift;
480         my $regexp=IkiWiki::glob2re($wanted);
481         my $lang;
482         my $masterpage;
483
484         if (IkiWiki::Plugin::po::istranslation($page)) {
485                 ($masterpage, $lang) = ($page =~ /(.*)[.]([a-z]{2})$/);
486         }
487         else {
488                 $lang = $config{po_master_language}{code};
489         }
490
491         if ($lang!~/^$regexp$/i) {
492                 return IkiWiki::FailReason->new("file language is $lang, not $wanted");
493         }
494         else {
495                 return IkiWiki::SuccessReason->new("file language is $wanted");
496         }
497 } #}}}
498
499 sub match_currentlang ($$;@) { #{{{
500         my $page=shift;
501         shift;
502         my %params=@_;
503         my ($currentmasterpage, $currentlang, $masterpage, $lang);
504
505         return IkiWiki::FailReason->new("no location provided") unless exists $params{location};
506
507         if (IkiWiki::Plugin::po::istranslation($params{location})) {
508                 ($currentmasterpage, $currentlang) = ($params{location} =~ /(.*)[.]([a-z]{2})$/);
509         }
510         else {
511                 $currentlang = $config{po_master_language}{code};
512         }
513
514         if (IkiWiki::Plugin::po::istranslation($page)) {
515                 ($masterpage, $lang) = ($page =~ /(.*)[.]([a-z]{2})$/);
516         }
517         else {
518                 $lang = $config{po_master_language}{code};
519         }
520
521         if ($lang eq $currentlang) {
522                 return IkiWiki::SuccessReason->new("file language is the same as current one, i.e. $currentlang");
523         }
524         else {
525                 return IkiWiki::FailReason->new("file language is $lang, whereas current language is $currentlang");
526         }
527 } #}}}
528
529 1