Merge commit 'intrigeri/po' into po
[ikiwiki] / 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-2009 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 3.00;
12 use Encode;
13 use Locale::Po4a::Common qw(nowrapi18n);
14 use Locale::Po4a::Chooser;
15 use Locale::Po4a::Po;
16 use File::Basename;
17 use File::Copy;
18 use File::Spec;
19 use File::Temp;
20 use Memoize;
21 use UNIVERSAL;
22
23 my %translations;
24 my @origneedsbuild;
25 my %origsubs;
26
27 memoize("istranslatable");
28 memoize("_istranslation");
29 memoize("percenttranslated");
30
31 sub import {
32         hook(type => "getsetup", id => "po", call => \&getsetup);
33         hook(type => "checkconfig", id => "po", call => \&checkconfig);
34         hook(type => "needsbuild", id => "po", call => \&needsbuild);
35         hook(type => "scan", id => "po", call => \&scan, last => 1);
36         hook(type => "filter", id => "po", call => \&filter);
37         hook(type => "htmlize", id => "po", call => \&htmlize);
38         hook(type => "pagetemplate", id => "po", call => \&pagetemplate, last => 1);
39         hook(type => "rename", id => "po", call => \&renamepages, first => 1);
40         hook(type => "delete", id => "po", call => \&mydelete);
41         hook(type => "change", id => "po", call => \&change);
42         hook(type => "checkcontent", id => "po", call => \&checkcontent);
43         hook(type => "canremove", id => "po", call => \&canremove);
44         hook(type => "canrename", id => "po", call => \&canrename);
45         hook(type => "editcontent", id => "po", call => \&editcontent);
46         hook(type => "formbuilder_setup", id => "po", call => \&formbuilder_setup, last => 1);
47         hook(type => "formbuilder", id => "po", call => \&formbuilder);
48
49         $origsubs{'bestlink'}=\&IkiWiki::bestlink;
50         inject(name => "IkiWiki::bestlink", call => \&mybestlink);
51         $origsubs{'beautify_urlpath'}=\&IkiWiki::beautify_urlpath;
52         inject(name => "IkiWiki::beautify_urlpath", call => \&mybeautify_urlpath);
53         $origsubs{'targetpage'}=\&IkiWiki::targetpage;
54         inject(name => "IkiWiki::targetpage", call => \&mytargetpage);
55         $origsubs{'urlto'}=\&IkiWiki::urlto;
56         inject(name => "IkiWiki::urlto", call => \&myurlto);
57         $origsubs{'cgiurl'}=\&IkiWiki::cgiurl;
58         inject(name => "IkiWiki::cgiurl", call => \&mycgiurl);
59 }
60
61
62 # ,----
63 # | Table of contents
64 # `----
65
66 # 1. Hooks
67 # 2. Injected functions
68 # 3. Blackboxes for private data
69 # 4. Helper functions
70 # 5. PageSpecs
71
72
73 # ,----
74 # | Hooks
75 # `----
76
77 sub getsetup () {
78         return
79                 plugin => {
80                         safe => 0,
81                         rebuild => 1,
82                 },
83                 po_master_language => {
84                         type => "string",
85                         example => {
86                                 'code' => 'en',
87                                 'name' => 'English'
88                         },
89                         description => "master language (non-PO files)",
90                         safe => 1,
91                         rebuild => 1,
92                 },
93                 po_slave_languages => {
94                         type => "string",
95                         example => {
96                                 'fr' => 'Français',
97                                 'es' => 'Castellano',
98                                 'de' => 'Deutsch'
99                         },
100                         description => "slave languages (PO files)",
101                         safe => 1,
102                         rebuild => 1,
103                 },
104                 po_translatable_pages => {
105                         type => "pagespec",
106                         example => "!*/Discussion",
107                         description => "PageSpec controlling which pages are translatable",
108                         link => "ikiwiki/PageSpec",
109                         safe => 1,
110                         rebuild => 1,
111                 },
112                 po_link_to => {
113                         type => "string",
114                         example => "current",
115                         description => "internal linking behavior (default/current/negotiated)",
116                         safe => 1,
117                         rebuild => 1,
118                 },
119 }
120
121 sub checkconfig () {
122         foreach my $field (qw{po_master_language po_slave_languages}) {
123                 if (! exists $config{$field} || ! defined $config{$field}) {
124                         error(sprintf(gettext("Must specify %s when using the %s plugin"),
125                                       $field, 'po'));
126                 }
127         }
128         if (! (keys %{$config{po_slave_languages}})) {
129                 error(gettext("At least one slave language must be defined ".
130                               "in po_slave_languages when using the po plugin"));
131         }
132         map {
133                 islanguagecode($_)
134                         or error(sprintf(gettext("%s is not a valid language code"), $_));
135         } ($config{po_master_language}{code}, keys %{$config{po_slave_languages}});
136         if (! exists $config{po_translatable_pages} ||
137             ! defined $config{po_translatable_pages}) {
138                 $config{po_translatable_pages}="";
139         }
140         if (! exists $config{po_link_to} ||
141             ! defined $config{po_link_to}) {
142                 $config{po_link_to}='default';
143         }
144         elsif ($config{po_link_to} !~ /^(default|current|negotiated)$/) {
145                 warn(sprintf(gettext('%s is not a valid value for po_link_to, falling back to po_link_to=default'),
146                              $config{po_link_to}));
147                 $config{po_link_to}='default';
148         }
149         elsif ($config{po_link_to} eq "negotiated" && ! $config{usedirs}) {
150                 warn(gettext('po_link_to=negotiated requires usedirs to be enabled, falling back to po_link_to=default'));
151                 $config{po_link_to}='default';
152         }
153         push @{$config{wiki_file_prune_regexps}}, qr/\.pot$/;
154 }
155
156 sub needsbuild () {
157         my $needsbuild=shift;
158
159         # backup @needsbuild content so that change() can know whether
160         # a given master page was rendered because its source file was changed
161         @origneedsbuild=(@$needsbuild);
162
163         flushmemoizecache();
164         buildtranslationscache();
165
166         # make existing translations depend on the corresponding master page
167         foreach my $master (keys %translations) {
168                 map add_depends($_, $master), values %{otherlanguages($master)};
169         }
170 }
171
172 # Massage the recorded state of internal links so that:
173 # - it matches the actually generated links, rather than the links as written
174 #   in the pages' source
175 # - backlinks are consistent in all cases
176 sub scan (@) {
177         my %params=@_;
178         my $page=$params{page};
179         my $content=$params{content};
180
181         if (istranslation($page)) {
182                 foreach my $destpage (@{$links{$page}}) {
183                         if (istranslatable($destpage)) {
184                                 # replace one occurence of $destpage in $links{$page}
185                                 # (we only want to replace the one that was added by
186                                 # IkiWiki::Plugin::link::scan, other occurences may be
187                                 # there for other reasons)
188                                 for (my $i=0; $i<@{$links{$page}}; $i++) {
189                                         if (@{$links{$page}}[$i] eq $destpage) {
190                                                 @{$links{$page}}[$i] = $destpage . '.' . lang($page);
191                                                 last;
192                                         }
193                                 }
194                         }
195                 }
196         }
197         elsif (! istranslatable($page) && ! istranslation($page)) {
198                 foreach my $destpage (@{$links{$page}}) {
199                         if (istranslatable($destpage)) {
200                                 # make sure any destpage's translations has
201                                 # $page in its backlinks
202                                 push @{$links{$page}},
203                                         values %{otherlanguages($destpage)};
204                         }
205                 }
206         }
207 }
208
209 # We use filter to convert PO to the master page's format,
210 # since the rest of ikiwiki should not work on PO files.
211 sub filter (@) {
212         my %params = @_;
213
214         my $page = $params{page};
215         my $destpage = $params{destpage};
216         my $content = $params{content};
217         if (istranslation($page) && ! alreadyfiltered($page, $destpage)) {
218                 $content = po_to_markup($page, $content);
219                 setalreadyfiltered($page, $destpage);
220         }
221         return $content;
222 }
223
224 sub htmlize (@) {
225         my %params=@_;
226
227         my $page = $params{page};
228         my $content = $params{content};
229
230         # ignore PO files this plugin did not create
231         return $content unless istranslation($page);
232
233         # force content to be htmlize'd as if it was the same type as the master page
234         return IkiWiki::htmlize($page, $page,
235                 pagetype(srcfile($pagesources{masterpage($page)})),
236                 $content);
237 }
238
239 sub pagetemplate (@) {
240         my %params=@_;
241         my $page=$params{page};
242         my $destpage=$params{destpage};
243         my $template=$params{template};
244
245         my ($masterpage, $lang) = istranslation($page);
246
247         if (istranslation($page) && $template->query(name => "percenttranslated")) {
248                 $template->param(percenttranslated => percenttranslated($page));
249         }
250         if ($template->query(name => "istranslation")) {
251                 $template->param(istranslation => scalar istranslation($page));
252         }
253         if ($template->query(name => "istranslatable")) {
254                 $template->param(istranslatable => istranslatable($page));
255         }
256         if ($template->query(name => "HOMEPAGEURL")) {
257                 $template->param(homepageurl => homepageurl($page));
258         }
259         if ($template->query(name => "otherlanguages")) {
260                 $template->param(otherlanguages => [otherlanguagesloop($page)]);
261                 map add_depends($page, $_), (values %{otherlanguages($page)});
262         }
263         # Rely on IkiWiki::Render's genpage() to decide wether
264         # a discussion link should appear on $page; this is not
265         # totally accurate, though: some broken links may be generated
266         # when cgiurl is disabled.
267         # This compromise avoids some code duplication, and will probably
268         # prevent future breakage when ikiwiki internals change.
269         # Known limitations are preferred to future random bugs.
270         if ($template->param('discussionlink') && istranslation($page)) {
271                 $template->param('discussionlink' => htmllink(
272                         $page,
273                         $destpage,
274                         $masterpage . '/' . gettext("Discussion"),
275                         noimageinline => 1,
276                         forcesubpage => 0,
277                         linktext => gettext("Discussion"),
278                 ));
279         }
280         # Remove broken parentlink to ./index.html on home page's translations.
281         # It works because this hook has the "last" parameter set, to ensure it
282         # runs after parentlinks' own pagetemplate hook.
283         if ($template->param('parentlinks')
284             && istranslation($page)
285             && $masterpage eq "index") {
286                 $template->param('parentlinks' => []);
287         }
288 } # }}}
289
290 # Add the renamed page translations to the list of to-be-renamed pages.
291 sub renamepages (@) {
292         my %params = @_;
293
294         my %torename = %{$params{torename}};
295         my $session = $params{session};
296
297         # Save the page(s) the user asked to rename, so that our
298         # canrename hook can tell the difference between:
299         #  - a translation being renamed as a consequence of its master page
300         #    being renamed
301         #  - a user trying to directly rename a translation
302         # This is why this hook has to be run first, before the list of pages
303         # to rename is modified by other plugins.
304         my @orig_torename;
305         @orig_torename=@{$session->param("po_orig_torename")}
306                 if defined $session->param("po_orig_torename");
307         push @orig_torename, $torename{src};
308         $session->param(po_orig_torename => \@orig_torename);
309         IkiWiki::cgi_savesession($session);
310
311         return () unless istranslatable($torename{src});
312
313         my @ret;
314         my %otherpages=%{otherlanguages($torename{src})};
315         while (my ($lang, $otherpage) = each %otherpages) {
316                 push @ret, {
317                         src => $otherpage,
318                         srcfile => $pagesources{$otherpage},
319                         dest => otherlanguage($torename{dest}, $lang),
320                         destfile => $torename{dest}.".".$lang.".po",
321                         required => 0,
322                 };
323         }
324         return @ret;
325 }
326
327 sub mydelete (@) {
328         my @deleted=@_;
329
330         map { deletetranslations($_) } grep istranslatablefile($_), @deleted;
331 }
332
333 sub change (@) {
334         my @rendered=@_;
335
336         # All meta titles are first extracted at scan time, i.e. before we turn
337         # PO files back into translated markdown; escaping of double-quotes in
338         # PO files breaks the meta plugin's parsing enough to save ugly titles
339         # to %pagestate at this time.
340         #
341         # Then, at render time, every page passes in turn through the Great
342         # Rendering Chain (filter->preprocess->linkify->htmlize), and the meta
343         # plugin's preprocess hook is this time in a position to correctly
344         # extract the titles from slave pages.
345         #
346         # This is, unfortunately, too late: if the page A, linking to the page
347         # B, is rendered before B, it will display the wrongly-extracted meta
348         # title as the link text to B.
349         #
350         # On the one hand, such a corner case only happens on rebuild: on
351         # refresh, every rendered page is fixed to contain correct meta titles.
352         # On the other hand, it can take some time to get every page fixed.
353         # We therefore re-render every rendered page after a rebuild to fix them
354         # at once. As this more or less doubles the time needed to rebuild the
355         # wiki, we do so only when really needed.
356
357         if (@rendered
358             && exists $config{rebuild} && defined $config{rebuild} && $config{rebuild}
359             && UNIVERSAL::can("IkiWiki::Plugin::meta", "getsetup")
360             && exists $config{meta_overrides_page_title}
361             && defined $config{meta_overrides_page_title}
362             && $config{meta_overrides_page_title}) {
363                 debug(sprintf(gettext("re-rendering all pages to fix meta titles")));
364                 resetalreadyfiltered();
365                 require IkiWiki::Render;
366                 foreach my $file (@rendered) {
367                         debug(sprintf(gettext("rendering %s"), $file));
368                         IkiWiki::render($file);
369                 }
370         }
371
372         my $updated_po_files=0;
373
374         # Refresh/create POT and PO files as needed.
375         foreach my $file (grep {istranslatablefile($_)} @rendered) {
376                 my $page=pagename($file);
377                 my $masterfile=srcfile($file);
378                 my $updated_pot_file=0;
379                 # Only refresh Pot file if it does not exist, or if
380                 # $pagesources{$page} was changed: don't if only the HTML was
381                 # refreshed, e.g. because of a dependency.
382                 if ((grep { $_ eq $pagesources{$page} } @origneedsbuild)
383                     || ! -e potfile($masterfile)) {
384                         refreshpot($masterfile);
385                         $updated_pot_file=1;
386                 }
387                 my @pofiles;
388                 map {
389                         push @pofiles, $_ if ($updated_pot_file || ! -e $_);
390                 } (pofiles($masterfile));
391                 if (@pofiles) {
392                         refreshpofiles($masterfile, @pofiles);
393                         map { IkiWiki::rcs_add($_) } @pofiles if $config{rcs};
394                         $updated_po_files=1;
395                 }
396         }
397
398         if ($updated_po_files) {
399                 commit_and_refresh(
400                         gettext("updated PO files"),
401                         "IkiWiki::Plugin::po::change");
402         }
403 }
404
405 sub checkcontent (@) {
406         my %params=@_;
407
408         if (istranslation($params{page})) {
409                 my $res = isvalidpo($params{content});
410                 if ($res) {
411                         return undef;
412                 }
413                 else {
414                         return "$res";
415                 }
416         }
417         return undef;
418 }
419
420 sub canremove (@) {
421         my %params = @_;
422
423         if (istranslation($params{page})) {
424                 return gettext("Can not remove a translation. Removing the master page, ".
425                                "though, removes its translations as well.");
426         }
427         return undef;
428 }
429
430 sub canrename (@) {
431         my %params = @_;
432         my $session = $params{session};
433
434         if (istranslation($params{src})) {
435                 my $masterpage = masterpage($params{src});
436                 # Tell the difference between:
437                 #  - a translation being renamed as a consequence of its master page
438                 #    being renamed, which is allowed
439                 #  - a user trying to directly rename a translation, which is forbidden
440                 # by looking for the master page in the list of to-be-renamed pages we
441                 # saved early in the renaming process.
442                 my $orig_torename = $session->param("po_orig_torename");
443                 unless (grep { $_ eq $masterpage } @{$orig_torename}) {
444                         return gettext("Can not rename a translation. Renaming the master page, ".
445                                        "though, renames its translations as well.");
446                 }
447         }
448         return undef;
449 }
450
451 # As we're previewing or saving a page, the content may have
452 # changed, so tell the next filter() invocation it must not be lazy.
453 sub editcontent () {
454         my %params=@_;
455
456         unsetalreadyfiltered($params{page}, $params{page});
457         return $params{content};
458 }
459
460 sub formbuilder_setup (@) {
461         my %params=@_;
462         my $form=$params{form};
463         my $q=$params{cgi};
464
465         return unless defined $form->field("do");
466
467         if ($form->field("do") eq "create") {
468                 # Warn the user: new pages must be written in master language.
469                 my $template=template("pocreatepage.tmpl");
470                 $template->param(LANG => $config{po_master_language}{name});
471                 $form->tmpl_param(message => $template->output);
472         }
473         elsif ($form->field("do") eq "edit") {
474                 # Remove the rename/remove buttons on slave pages.
475                 # This has to be done after the rename/remove plugins have added
476                 # their buttons, which is why this hook must be run last.
477                 # The canrename/canremove hooks already ensure this is forbidden
478                 # at the backend level, so this is only UI sugar.
479                 if (istranslation($form->field("page"))) {
480                         map {
481                                 for (my $i = 0; $i < @{$params{buttons}}; $i++) {
482                                         if (@{$params{buttons}}[$i] eq $_) {
483                                                 delete  @{$params{buttons}}[$i];
484                                                 last;
485                                         }
486                                 }
487                         } qw(Rename Remove);
488                 }
489         }
490 }
491
492 sub formbuilder (@) {
493         my %params=@_;
494         my $form=$params{form};
495         my $q=$params{cgi};
496
497         return unless defined $form->field("do");
498
499         # Do not allow to create pages of type po: they are automatically created.
500         # The main reason to do so is to bypass the "favor the type of linking page
501         # on page creation" logic, which is unsuitable when a broken link is clicked
502         # on a slave (PO) page.
503         # This cannot be done in the formbuilder_setup hook as the list of types is
504         # computed later.
505         if ($form->field("do") eq "create") {
506                 foreach my $field ($form->field) {
507                         next unless "$field" eq "type";
508                         if ($field->type eq 'select') {
509                                 # remove po from the list of types
510                                 my @types = grep { $_ ne 'po' } $field->options;
511                                 $field->options(\@types) if @types;
512                         }
513                 }
514         }
515 }
516
517 # ,----
518 # | Injected functions
519 # `----
520
521 # Implement po_link_to 'current' and 'negotiated' settings.
522 sub mybestlink ($$) {
523         my $page=shift;
524         my $link=shift;
525
526         my $res=$origsubs{'bestlink'}->(masterpage($page), $link);
527         if (length $res
528             && ($config{po_link_to} eq "current" || $config{po_link_to} eq "negotiated")
529             && istranslatable($res)
530             && istranslation($page)) {
531                 return $res . "." . lang($page);
532         }
533         return $res;
534 }
535
536 sub mybeautify_urlpath ($) {
537         my $url=shift;
538
539         my $res=$origsubs{'beautify_urlpath'}->($url);
540         if ($config{po_link_to} eq "negotiated") {
541                 $res =~ s!/\Qindex.$config{po_master_language}{code}.$config{htmlext}\E$!/!;
542                 $res =~ s!/\Qindex.$config{htmlext}\E$!/!;
543                 map {
544                         $res =~ s!/\Qindex.$_.$config{htmlext}\E$!/!;
545                 } (keys %{$config{po_slave_languages}});
546         }
547         return $res;
548 }
549
550 sub mytargetpage ($$) {
551         my $page=shift;
552         my $ext=shift;
553
554         if (istranslation($page) || istranslatable($page)) {
555                 my ($masterpage, $lang) = (masterpage($page), lang($page));
556                 if (! $config{usedirs} || $masterpage eq 'index') {
557                         return $masterpage . "." . $lang . "." . $ext;
558                 }
559                 else {
560                         return $masterpage . "/index." . $lang . "." . $ext;
561                 }
562         }
563         return $origsubs{'targetpage'}->($page, $ext);
564 }
565
566 sub myurlto ($$;$) {
567         my $to=shift;
568         my $from=shift;
569         my $absolute=shift;
570
571         # workaround hard-coded /index.$config{htmlext} in IkiWiki::urlto()
572         if (! length $to
573             && $config{po_link_to} eq "current"
574             && istranslatable('index')) {
575                 return IkiWiki::beautify_urlpath(IkiWiki::baseurl($from) . "index." . lang($from) . ".$config{htmlext}");
576         }
577         # avoid using our injected beautify_urlpath if run by cgi_editpage,
578         # so that one is redirected to the just-edited page rather than to the
579         # negociated translation; to prevent unnecessary fiddling with caller/inject,
580         # we only do so when our beautify_urlpath would actually do what we want to
581         # avoid, i.e. when po_link_to = negotiated
582         if ($config{po_link_to} eq "negotiated") {
583                 my @caller = caller(1);
584                 my $run_by_editpage = 0;
585                 $run_by_editpage = 1 if (exists $caller[3] && defined $caller[3]
586                                          && $caller[3] eq "IkiWiki::cgi_editpage");
587                 inject(name => "IkiWiki::beautify_urlpath", call => $origsubs{'beautify_urlpath'})
588                         if $run_by_editpage;
589                 my $res = $origsubs{'urlto'}->($to,$from,$absolute);
590                 inject(name => "IkiWiki::beautify_urlpath", call => \&mybeautify_urlpath)
591                         if $run_by_editpage;
592                 return $res;
593         }
594         else {
595                 return $origsubs{'urlto'}->($to,$from,$absolute)
596         }
597 }
598
599 sub mycgiurl (@) {
600         my %params=@_;
601
602         # slave pages have no subpages
603         if (istranslation($params{'from'})) {
604                 $params{'from'} = masterpage($params{'from'});
605         }
606         return $origsubs{'cgiurl'}->(%params);
607 }
608
609 # ,----
610 # | Blackboxes for private data
611 # `----
612
613 {
614         my %filtered;
615
616         sub alreadyfiltered($$) {
617                 my $page=shift;
618                 my $destpage=shift;
619
620                 return exists $filtered{$page}{$destpage}
621                          && $filtered{$page}{$destpage} eq 1;
622         }
623
624         sub setalreadyfiltered($$) {
625                 my $page=shift;
626                 my $destpage=shift;
627
628                 $filtered{$page}{$destpage}=1;
629         }
630
631         sub unsetalreadyfiltered($$) {
632                 my $page=shift;
633                 my $destpage=shift;
634
635                 if (exists $filtered{$page}{$destpage}) {
636                         delete $filtered{$page}{$destpage};
637                 }
638         }
639
640         sub resetalreadyfiltered() {
641                 undef %filtered;
642         }
643 }
644
645 # ,----
646 # | Helper functions
647 # `----
648
649 sub maybe_add_leading_slash ($;$) {
650         my $str=shift;
651         my $add=shift;
652         $add=1 unless defined $add;
653         return '/' . $str if $add;
654         return $str;
655 }
656
657 sub istranslatablefile ($) {
658         my $file=shift;
659
660         return 0 unless defined $file;
661         return 0 if defined pagetype($file) && pagetype($file) eq 'po';
662         return 0 if $file =~ /\.pot$/;
663         return 0 unless -e "$config{srcdir}/$file"; # underlay dirs may be read-only
664         return 1 if pagespec_match(pagename($file), $config{po_translatable_pages});
665         return;
666 }
667
668 sub istranslatable ($) {
669         my $page=shift;
670
671         $page=~s#^/##;
672         return 1 if istranslatablefile($pagesources{$page});
673         return;
674 }
675
676 sub _istranslation ($) {
677         my $page=shift;
678
679         $page='' unless defined $page && length $page;
680         my $hasleadingslash = ($page=~s#^/##);
681         my $file=$pagesources{$page};
682         return 0 unless defined $file
683                          && defined pagetype($file)
684                          && pagetype($file) eq 'po';
685         return 0 if $file =~ /\.pot$/;
686
687         my ($masterpage, $lang) = ($page =~ /(.*)[.]([a-z]{2})$/);
688         return 0 unless defined $masterpage && defined $lang
689                          && length $masterpage && length $lang
690                          && defined $pagesources{$masterpage}
691                          && defined $config{po_slave_languages}{$lang};
692
693         return (maybe_add_leading_slash($masterpage, $hasleadingslash), $lang)
694                 if istranslatable($masterpage);
695 }
696
697 sub istranslation ($) {
698         my $page=shift;
699
700         if (1 < (my ($masterpage, $lang) = _istranslation($page))) {
701                 my $hasleadingslash = ($masterpage=~s#^/##);
702                 $translations{$masterpage}{$lang}=$page unless exists $translations{$masterpage}{$lang};
703                 return (maybe_add_leading_slash($masterpage, $hasleadingslash), $lang);
704         }
705         return "";
706 }
707
708 sub masterpage ($) {
709         my $page=shift;
710
711         if ( 1 < (my ($masterpage, $lang) = _istranslation($page))) {
712                 return $masterpage;
713         }
714         return $page;
715 }
716
717 sub lang ($) {
718         my $page=shift;
719
720         if (1 < (my ($masterpage, $lang) = _istranslation($page))) {
721                 return $lang;
722         }
723         return $config{po_master_language}{code};
724 }
725
726 sub islanguagecode ($) {
727         my $code=shift;
728
729         return $code =~ /^[a-z]{2}$/;
730 }
731
732 sub otherlanguage ($$) {
733         my $page=shift;
734         my $code=shift;
735
736         return masterpage($page) if $code eq $config{po_master_language}{code};
737         return masterpage($page) . '.' . $code;
738 }
739
740 sub otherlanguages ($) {
741         my $page=shift;
742
743         my %ret;
744         return \%ret unless istranslation($page) || istranslatable($page);
745         my $curlang=lang($page);
746         foreach my $lang
747                 ($config{po_master_language}{code}, keys %{$config{po_slave_languages}}) {
748                 next if $lang eq $curlang;
749                 $ret{$lang}=otherlanguage($page, $lang);
750         }
751         return \%ret;
752 }
753
754 sub potfile ($) {
755         my $masterfile=shift;
756
757         (my $name, my $dir, my $suffix) = fileparse($masterfile, qr/\.[^.]*/);
758         $dir='' if $dir eq './';
759         return File::Spec->catpath('', $dir, $name . ".pot");
760 }
761
762 sub pofile ($$) {
763         my $masterfile=shift;
764         my $lang=shift;
765
766         (my $name, my $dir, my $suffix) = fileparse($masterfile, qr/\.[^.]*/);
767         $dir='' if $dir eq './';
768         return File::Spec->catpath('', $dir, $name . "." . $lang . ".po");
769 }
770
771 sub pofiles ($) {
772         my $masterfile=shift;
773
774         return map pofile($masterfile, $_), (keys %{$config{po_slave_languages}});
775 }
776
777 sub refreshpot ($) {
778         my $masterfile=shift;
779
780         my $potfile=potfile($masterfile);
781         my %options = ("markdown" => (pagetype($masterfile) eq 'mdwn') ? 1 : 0);
782         my $doc=Locale::Po4a::Chooser::new('text',%options);
783         $doc->{TT}{utf_mode} = 1;
784         $doc->{TT}{file_in_charset} = 'utf-8';
785         $doc->{TT}{file_out_charset} = 'utf-8';
786         $doc->read($masterfile);
787         # let's cheat a bit to force porefs option to be passed to
788         # Locale::Po4a::Po; this is undocument use of internal
789         # Locale::Po4a::TransTractor's data, compulsory since this module
790         # prevents us from using the porefs option.
791         $doc->{TT}{po_out}=Locale::Po4a::Po->new({ 'porefs' => 'none' });
792         $doc->{TT}{po_out}->set_charset('utf-8');
793         # do the actual work
794         $doc->parse;
795         IkiWiki::prep_writefile(basename($potfile),dirname($potfile));
796         $doc->writepo($potfile);
797 }
798
799 sub refreshpofiles ($@) {
800         my $masterfile=shift;
801         my @pofiles=@_;
802
803         my $potfile=potfile($masterfile);
804         if (! -e $potfile) {
805                 error("po(refreshpofiles) ".sprintf(gettext("POT file (%s) does not exist"), $potfile));
806         }
807
808         foreach my $pofile (@pofiles) {
809                 IkiWiki::prep_writefile(basename($pofile),dirname($pofile));
810                 if (-e $pofile) {
811                         system("msgmerge", "-U", "--backup=none", $pofile, $potfile) == 0
812                                 or error("po(refreshpofiles) ".
813                                          sprintf(gettext("failed to update %s"),
814                                                  $pofile));
815                 }
816                 else {
817                         File::Copy::syscopy($potfile,$pofile)
818                                 or error("po(refreshpofiles) ".
819                                          sprintf(gettext("failed to copy the POT file to %s"),
820                                                  $pofile));
821                 }
822         }
823 }
824
825 sub buildtranslationscache() {
826         # use istranslation's side-effect
827         map istranslation($_), (keys %pagesources);
828 }
829
830 sub resettranslationscache() {
831         undef %translations;
832 }
833
834 sub flushmemoizecache() {
835         Memoize::flush_cache("istranslatable");
836         Memoize::flush_cache("_istranslation");
837         Memoize::flush_cache("percenttranslated");
838 }
839
840 sub urlto_with_orig_beautiful_urlpath($$) {
841         my $to=shift;
842         my $from=shift;
843
844         inject(name => "IkiWiki::beautify_urlpath", call => $origsubs{'beautify_urlpath'});
845         my $res=urlto($to, $from);
846         inject(name => "IkiWiki::beautify_urlpath", call => \&mybeautify_urlpath);
847
848         return $res;
849 }
850
851 sub percenttranslated ($) {
852         my $page=shift;
853
854         $page=~s/^\///;
855         return gettext("N/A") unless istranslation($page);
856         my $file=srcfile($pagesources{$page});
857         my $masterfile = srcfile($pagesources{masterpage($page)});
858         my %options = (
859                 "markdown" => (pagetype($masterfile) eq 'mdwn') ? 1 : 0,
860         );
861         my $doc=Locale::Po4a::Chooser::new('text',%options);
862         $doc->process(
863                 'po_in_name'    => [ $file ],
864                 'file_in_name'  => [ $masterfile ],
865                 'file_in_charset'  => 'utf-8',
866                 'file_out_charset' => 'utf-8',
867         ) or error("po(percenttranslated) ".
868                    sprintf(gettext("failed to translate %s"), $page));
869         my ($percent,$hit,$queries) = $doc->stats();
870         $percent =~ s/\.[0-9]+$//;
871         return $percent;
872 }
873
874 sub languagename ($) {
875         my $code=shift;
876
877         return $config{po_master_language}{name}
878                 if $code eq $config{po_master_language}{code};
879         return $config{po_slave_languages}{$code}
880                 if defined $config{po_slave_languages}{$code};
881         return;
882 }
883
884 sub otherlanguagesloop ($) {
885         my $page=shift;
886
887         my @ret;
888         my %otherpages=%{otherlanguages($page)};
889         while (my ($lang, $otherpage) = each %otherpages) {
890                 if (istranslation($page) && masterpage($page) eq $otherpage) {
891                         push @ret, {
892                                 url => urlto_with_orig_beautiful_urlpath($otherpage, $page),
893                                 code => $lang,
894                                 language => languagename($lang),
895                                 master => 1,
896                         };
897                 }
898                 else {
899                         push @ret, {
900                                 url => urlto_with_orig_beautiful_urlpath($otherpage, $page),
901                                 code => $lang,
902                                 language => languagename($lang),
903                                 percent => percenttranslated($otherpage),
904                         }
905                 }
906         }
907         return sort {
908                         return -1 if $a->{code} eq $config{po_master_language}{code};
909                         return 1 if $b->{code} eq $config{po_master_language}{code};
910                         return $a->{language} cmp $b->{language};
911                 } @ret;
912 }
913
914 sub homepageurl (;$) {
915         my $page=shift;
916
917         return urlto('', $page);
918 }
919
920 sub deletetranslations ($) {
921         my $deletedmasterfile=shift;
922
923         my $deletedmasterpage=pagename($deletedmasterfile);
924         my @todelete;
925         map {
926                 my $file = newpagefile($deletedmasterpage.'.'.$_, 'po');
927                 my $absfile = "$config{srcdir}/$file";
928                 if (-e $absfile && ! -l $absfile && ! -d $absfile) {
929                         push @todelete, $file;
930                 }
931         } keys %{$config{po_slave_languages}};
932
933         map {
934                 if ($config{rcs}) {
935                         IkiWiki::rcs_remove($_);
936                 }
937                 else {
938                         IkiWiki::prune("$config{srcdir}/$_");
939                 }
940         } @todelete;
941
942         if (@todelete) {
943                 commit_and_refresh(
944                         gettext("removed obsolete PO files"),
945                         "IkiWiki::Plugin::po::deletetranslations");
946         }
947 }
948
949 sub commit_and_refresh ($$) {
950         my ($msg, $author) = (shift, shift);
951
952         if ($config{rcs}) {
953                 IkiWiki::disable_commit_hook();
954                 IkiWiki::rcs_commit_staged($msg, $author, "127.0.0.1");
955                 IkiWiki::enable_commit_hook();
956                 IkiWiki::rcs_update();
957         }
958         # Reinitialize module's private variables.
959         resetalreadyfiltered();
960         resettranslationscache();
961         flushmemoizecache();
962         # Trigger a wiki refresh.
963         require IkiWiki::Render;
964         # without preliminary saveindex/loadindex, refresh()
965         # complains about a lot of uninitialized variables
966         IkiWiki::saveindex();
967         IkiWiki::loadindex();
968         IkiWiki::refresh();
969         IkiWiki::saveindex();
970 }
971
972 # on success, returns the filtered content.
973 # on error, if $nonfatal, warn and return undef; else, error out.
974 sub po_to_markup ($$;$) {
975         my ($page, $content) = (shift, shift);
976         my $nonfatal = shift;
977
978         $content = '' unless defined $content;
979         $content = decode_utf8(encode_utf8($content));
980         # CRLF line terminators make poor Locale::Po4a feel bad
981         $content=~s/\r\n/\n/g;
982
983         # There are incompatibilities between some File::Temp versions
984         # (including 0.18, bundled with Lenny's perl-modules package)
985         # and others (e.g. 0.20, previously present in the archive as
986         # a standalone package): under certain circumstances, some
987         # return a relative filename, whereas others return an absolute one;
988         # we here use this module in a way that is at least compatible
989         # with 0.18 and 0.20. Beware, hit'n'run refactorers!
990         my $infile = new File::Temp(TEMPLATE => "ikiwiki-po-filter-in.XXXXXXXXXX",
991                                     DIR => File::Spec->tmpdir,
992                                     UNLINK => 1)->filename;
993         my $outfile = new File::Temp(TEMPLATE => "ikiwiki-po-filter-out.XXXXXXXXXX",
994                                      DIR => File::Spec->tmpdir,
995                                      UNLINK => 1)->filename;
996
997         my $fail = sub ($) {
998                 my $msg = "po(po_to_markup) - $page : " . shift;
999                 if ($nonfatal) {
1000                         warn $msg;
1001                         return undef;
1002                 }
1003                 error($msg, sub { unlink $infile, $outfile});
1004         };
1005
1006         writefile(basename($infile), File::Spec->tmpdir, $content)
1007                 or return $fail->(sprintf(gettext("failed to write %s"), $infile));
1008
1009         my $masterfile = srcfile($pagesources{masterpage($page)});
1010         my %options = (
1011                 "markdown" => (pagetype($masterfile) eq 'mdwn') ? 1 : 0,
1012         );
1013         my $doc=Locale::Po4a::Chooser::new('text',%options);
1014         $doc->process(
1015                 'po_in_name'    => [ $infile ],
1016                 'file_in_name'  => [ $masterfile ],
1017                 'file_in_charset'  => 'utf-8',
1018                 'file_out_charset' => 'utf-8',
1019         ) or return $fail->(gettext("failed to translate"));
1020         $doc->write($outfile)
1021                 or return $fail->(sprintf(gettext("failed to write %s"), $outfile));
1022
1023         $content = readfile($outfile)
1024                 or return $fail->(sprintf(gettext("failed to read %s"), $outfile));
1025
1026         # Unlinking should happen automatically, thanks to File::Temp,
1027         # but it does not work here, probably because of the way writefile()
1028         # and Locale::Po4a::write() work.
1029         unlink $infile, $outfile;
1030
1031         return $content;
1032 }
1033
1034 # returns a SuccessReason or FailReason object
1035 sub isvalidpo ($) {
1036         my $content = shift;
1037
1038         # NB: we don't use po_to_markup here, since Po4a parser does
1039         # not mind invalid PO content
1040         $content = '' unless defined $content;
1041         $content = decode_utf8(encode_utf8($content));
1042
1043         # There are incompatibilities between some File::Temp versions
1044         # (including 0.18, bundled with Lenny's perl-modules package)
1045         # and others (e.g. 0.20, previously present in the archive as
1046         # a standalone package): under certain circumstances, some
1047         # return a relative filename, whereas others return an absolute one;
1048         # we here use this module in a way that is at least compatible
1049         # with 0.18 and 0.20. Beware, hit'n'run refactorers!
1050         my $infile = new File::Temp(TEMPLATE => "ikiwiki-po-isvalidpo.XXXXXXXXXX",
1051                                     DIR => File::Spec->tmpdir,
1052                                     UNLINK => 1)->filename;
1053
1054         my $fail = sub ($) {
1055                 my $msg = '[po/isvalidpo] ' . shift;
1056                 unlink $infile;
1057                 return IkiWiki::FailReason->new("$msg");
1058         };
1059
1060         writefile(basename($infile), File::Spec->tmpdir, $content)
1061                 or return $fail->(sprintf(gettext("failed to write %s"), $infile));
1062
1063         my $res = (system("msgfmt", "--check", $infile, "-o", "/dev/null") == 0);
1064
1065         # Unlinking should happen automatically, thanks to File::Temp,
1066         # but it does not work here, probably because of the way writefile()
1067         # and Locale::Po4a::write() work.
1068         unlink $infile;
1069
1070         if ($res) {
1071             return IkiWiki::SuccessReason->new("valid gettext data");
1072         }
1073         return IkiWiki::FailReason->new("invalid gettext data, go back ".
1074                                         "to previous page to go on with edit");
1075 }
1076
1077 # ,----
1078 # | PageSpecs
1079 # `----
1080
1081 package IkiWiki::PageSpec;
1082
1083 sub match_istranslation ($;@) {
1084         my $page=shift;
1085
1086         if (IkiWiki::Plugin::po::istranslation($page)) {
1087                 return IkiWiki::SuccessReason->new("is a translation page");
1088         }
1089         else {
1090                 return IkiWiki::FailReason->new("is not a translation page");
1091         }
1092 }
1093
1094 sub match_istranslatable ($;@) {
1095         my $page=shift;
1096
1097         if (IkiWiki::Plugin::po::istranslatable($page)) {
1098                 return IkiWiki::SuccessReason->new("is set as translatable in po_translatable_pages");
1099         }
1100         else {
1101                 return IkiWiki::FailReason->new("is not set as translatable in po_translatable_pages");
1102         }
1103 }
1104
1105 sub match_lang ($$;@) {
1106         my $page=shift;
1107         my $wanted=shift;
1108
1109         my $regexp=IkiWiki::glob2re($wanted);
1110         my $lang=IkiWiki::Plugin::po::lang($page);
1111         if ($lang !~ /^$regexp$/i) {
1112                 return IkiWiki::FailReason->new("file language is $lang, not $wanted");
1113         }
1114         else {
1115                 return IkiWiki::SuccessReason->new("file language is $wanted");
1116         }
1117 }
1118
1119 sub match_currentlang ($$;@) {
1120         my $page=shift;
1121         shift;
1122         my %params=@_;
1123
1124         return IkiWiki::FailReason->new("no location provided") unless exists $params{location};
1125
1126         my $currentlang=IkiWiki::Plugin::po::lang($params{location});
1127         my $lang=IkiWiki::Plugin::po::lang($page);
1128
1129         if ($lang eq $currentlang) {
1130                 return IkiWiki::SuccessReason->new("file language is the same as current one, i.e. $currentlang");
1131         }
1132         else {
1133                 return IkiWiki::FailReason->new("file language is $lang, whereas current language is $currentlang");
1134         }
1135 }
1136
1137 1