X-Git-Url: http://git.vanrenterghem.biz/git.ikiwiki.info.git/blobdiff_plain/856de5734ded0d3fd76970de57472271d0e6fcaf..98f0d672dfcbc178b80a2d5f9a8b2f2633c8f039:/IkiWiki/Plugin/po.pm diff --git a/IkiWiki/Plugin/po.pm b/IkiWiki/Plugin/po.pm index 78cd45b01..1955ac304 100644 --- a/IkiWiki/Plugin/po.pm +++ b/IkiWiki/Plugin/po.pm @@ -1,15 +1,25 @@ #!/usr/bin/perl # .po as a wiki page type # Licensed under GPL v2 or greater -# Copyright (C) 2008 intrigeri +# Copyright (C) 2008-2009 intrigeri # inspired by the GPL'd po4a-translate, # which is Copyright 2002, 2003, 2004 by Martin Quinson (mquinson#debian.org) package IkiWiki::Plugin::po; use warnings; use strict; -use IkiWiki 2.00; +use IkiWiki 3.00; use Encode; +BEGIN { + eval 'use Locale::Po4a::Common qw(nowrapi18n)'; + if ($@) { + warning(sprintf(gettext('%s is too old, can not disable %s; '. + 'a denial of service can thus be '. + 'triggered by malicious content'), + 'Locale::Po4a', 'Text::WrapI18N')); + eval 'use Locale::Po4a::Common'; + } +} use Locale::Po4a::Chooser; use Locale::Po4a::Po; use File::Basename; @@ -36,10 +46,15 @@ sub import { hook(type => "htmlize", id => "po", call => \&htmlize); hook(type => "pagetemplate", id => "po", call => \&pagetemplate, last => 1); hook(type => "postscan", id => "po", call => \&postscan); - hook(type => "rename", id => "po", call => \&renamepages); + hook(type => "rename", id => "po", call => \&renamepages, first => 1); hook(type => "delete", id => "po", call => \&mydelete); hook(type => "change", id => "po", call => \&change); + hook(type => "checkcontent", id => "po", call => \&checkcontent); + hook(type => "canremove", id => "po", call => \&canremove); + hook(type => "canrename", id => "po", call => \&canrename); hook(type => "editcontent", id => "po", call => \&editcontent); + hook(type => "formbuilder_setup", id => "po", call => \&formbuilder_setup, last => 1); + hook(type => "formbuilder", id => "po", call => \&formbuilder); $origsubs{'bestlink'}=\&IkiWiki::bestlink; inject(name => "IkiWiki::bestlink", call => \&mybestlink); @@ -51,6 +66,8 @@ sub import { inject(name => "IkiWiki::urlto", call => \&myurlto); $origsubs{'nicepagetitle'}=\&IkiWiki::nicepagetitle; inject(name => "IkiWiki::nicepagetitle", call => \&mynicepagetitle); + $origsubs{'cgiurl'}=\&IkiWiki::cgiurl; + inject(name => "IkiWiki::cgiurl", call => \&mycgiurl); } @@ -123,11 +140,13 @@ sub getsetup () { sub checkconfig () { foreach my $field (qw{po_master_language po_slave_languages}) { if (! exists $config{$field} || ! defined $config{$field}) { - error(sprintf(gettext("Must specify %s"), $field)); + error(sprintf(gettext("Must specify %s when using the %s plugin"), + $field, 'po')); } } if (! (keys %{$config{po_slave_languages}})) { - error(gettext("At least one slave language must be defined in po_slave_languages")); + error(gettext("At least one slave language must be defined ". + "in po_slave_languages when using the po plugin")); } map { islanguagecode($_) @@ -144,8 +163,8 @@ sub checkconfig () { elsif (! grep { $config{po_link_to} eq $_ } ('default', 'current', 'negotiated')) { - warn(sprintf(gettext('po_link_to=%s is not a valid setting, falling back to po_link_to=default'), - $config{po_link_to})); + warn(sprintf(gettext('%s is not a valid value for po_link_to, falling back to po_link_to=default'), + $config{po_link_to})); $config{po_link_to}='default'; } elsif ($config{po_link_to} eq "negotiated" && ! $config{usedirs}) { @@ -221,57 +240,11 @@ sub filter (@) { my $page = $params{page}; my $destpage = $params{destpage}; - my $content = decode_utf8(encode_utf8($params{content})); - - return $content if ( ! istranslation($page) - || alreadyfiltered($page, $destpage) ); - - # CRLF line terminators make poor Locale::Po4a feel bad - $content=~s/\r\n/\n/g; - - # Implementation notes - # - # 1. Locale::Po4a reads/writes from/to files, and I'm too lazy - # to learn how to disguise a variable as a file. - # 2. There are incompatibilities between some File::Temp versions - # (including 0.18, bundled with Lenny's perl-modules package) - # and others (e.g. 0.20, previously present in the archive as - # a standalone package): under certain circumstances, some - # return a relative filename, whereas others return an absolute one; - # we here use this module in a way that is at least compatible - # with 0.18 and 0.20. Beware, hit'n'run refactorers! - my $infile = new File::Temp(TEMPLATE => "ikiwiki-po-filter-in.XXXXXXXXXX", - DIR => File::Spec->tmpdir, - UNLINK => 1)->filename; - my $outfile = new File::Temp(TEMPLATE => "ikiwiki-po-filter-out.XXXXXXXXXX", - DIR => File::Spec->tmpdir, - UNLINK => 1)->filename; - - writefile(basename($infile), File::Spec->tmpdir, $content); - - my $masterfile = srcfile($pagesources{masterpage($page)}); - my (@pos,@masters); - push @pos,$infile; - push @masters,$masterfile; - my %options = ( - "markdown" => (pagetype($masterfile) eq 'mdwn') ? 1 : 0, - ); - my $doc=Locale::Po4a::Chooser::new('text',%options); - $doc->process( - 'po_in_name' => \@pos, - 'file_in_name' => \@masters, - 'file_in_charset' => 'utf-8', - 'file_out_charset' => 'utf-8', - ) or error("[po/filter:$page]: failed to translate"); - $doc->write($outfile) or error("[po/filter:$page] could not write $outfile"); - $content = readfile($outfile) or error("[po/filter:$page] could not read $outfile"); - - # Unlinking should happen automatically, thanks to File::Temp, - # but it does not work here, probably because of the way writefile() - # and Locale::Po4a::write() work. - unlink $infile, $outfile; - - setalreadyfiltered($page, $destpage); + my $content = $params{content}; + if (istranslation($page) && ! alreadyfiltered($page, $destpage)) { + $content = po_to_markup($page, $content); + setalreadyfiltered($page, $destpage); + } return $content; } @@ -302,7 +275,7 @@ sub pagetemplate (@) { $template->param(percenttranslated => percenttranslated($page)); } if ($template->query(name => "istranslation")) { - $template->param(istranslation => scalar istranslation($page)); + $template->param(istranslation => istranslation($page)); } if ($template->query(name => "istranslatable")) { $template->param(istranslatable => istranslatable($page)); @@ -352,13 +325,24 @@ sub postscan (@) { } # Add the renamed page translations to the list of to-be-renamed pages. -# Save information about master page rename, so that: -# - our delete hook can ignore the translations not renamed already -# - our change hook can rename the translations accordingly. -sub renamepages() { - my $torename=shift; +sub renamepages($$$) { + my ($torename, $cgi, $session) = (shift, shift, shift); + + # copy the initial array, so that we can iterate on it AND + # modify it at the same time, without iterating on the items we + # pushed on it ourselves my @torename=@{$torename}; + # Save the page(s) the user asked to rename, so that our + # canrename hook can tell the difference between: + # - a translation being renamed as a consequence of its master page + # being renamed + # - a user trying to directly rename a translation + # This is why this hook has to be run first, before @torename is modified + # by other plugins. + $session->param(po_orig_torename => [ @torename ]); + IkiWiki::cgi_savesession($session); + foreach my $rename (@torename) { next unless istranslatable($rename->{src}); my %otherpages=%{otherlanguages($rename->{src})}; @@ -377,19 +361,52 @@ sub renamepages() { sub mydelete(@) { my @deleted=@_; - map { - deletetranslations($_); - } grep { istranslatablefile($_) } @deleted; + map { deletetranslations($_) } grep istranslatablefile($_), @deleted; } sub change(@) { my @rendered=@_; + # All meta titles are first extracted at scan time, i.e. before we turn + # PO files back into translated markdown; escaping of double-quotes in + # PO files breaks the meta plugin's parsing enough to save ugly titles + # to %pagestate at this time. + # + # Then, at render time, every page's passes on row through the Great + # Rendering Chain (filter->preprocess->linkify->htmlize), and the meta + # plugin's preprocess hook is this time in a position to correctly + # extract the titles from slave pages. + # + # This is, unfortunately, too late: if the page A, linking to the page B, + # is rendered before B, it will display the wrongly-extracted meta title + # as the link text to B. + # + # On the one hand, such a corner case only happens on rebuild: on + # refresh, every rendered page is fixed to contain correct meta titles. + # On the other hand, it can take some time to get every page fixed. + # We therefore re-render every rendered page after a rebuild to fix them + # at once. As this more or less doubles the time needed to rebuild the + # wiki, we do so only when really needed. + + if (@rendered + && exists $config{rebuild} && defined $config{rebuild} && $config{rebuild} + && UNIVERSAL::can("IkiWiki::Plugin::meta", "getsetup") + && exists $config{meta_overrides_page_title} + && defined $config{meta_overrides_page_title} + && $config{meta_overrides_page_title}) { + debug(sprintf(gettext("re-rendering all pages to fix meta titles"))); + resetalreadyfiltered(); + require IkiWiki::Render; + foreach my $file (@rendered) { + debug(sprintf(gettext("rendering %s"), $file)); + IkiWiki::render($file); + } + } + my $updated_po_files=0; # Refresh/create POT and PO files as needed. - foreach my $file (@rendered) { - next unless istranslatablefile($file); + foreach my $file (grep {istranslatablefile($_)} @rendered) { my $page=pagename($file); my $masterfile=srcfile($file); my $updated_pot_file=0; @@ -407,7 +424,7 @@ sub change(@) { } (pofiles($masterfile)); if (@pofiles) { refreshpofiles($masterfile, @pofiles); - map { IkiWiki::rcs_add($_); } @pofiles if ($config{rcs}); + map { IkiWiki::rcs_add($_) } @pofiles if $config{rcs}; $updated_po_files=1; } } @@ -419,6 +436,52 @@ sub change(@) { } } +sub checkcontent (@) { + my %params=@_; + + if (istranslation($params{page})) { + my $res = isvalidpo($params{content}); + if ($res) { + return undef; + } + else { + return "$res"; + } + } + return undef; +} + +sub canremove ($$$) { + my ($page, $cgi, $session) = (shift, shift, shift); + + if (istranslation($page)) { + return gettext("Can not remove a translation. Removing the master page, ". + "though, removes its translations as well."); + } + return undef; +} + +sub canrename ($$@) { + my ($cgi, $session) = (shift, shift); + my %params = @_; + + if (istranslation($params{src})) { + my $masterpage = masterpage($params{src}); + # Tell the difference between: + # - a translation being renamed as a consequence of its master page + # being renamed, which is allowed + # - a user trying to directly rename a translation, which is forbidden + # by looking for the master page in the list of to-be-renamed pages we + # saved early in the renaming process. + my $orig_torename = $session->param("po_orig_torename"); + unless (grep { $_->{src} eq $masterpage } @{$orig_torename}) { + return gettext("Can not rename a translation. Renaming the master page, ". + "though, renames its translations as well."); + } + } + return undef; +} + # As we're previewing or saving a page, the content may have # changed, so tell the next filter() invocation it must not be lazy. sub editcontent () { @@ -428,6 +491,67 @@ sub editcontent () { return $params{content}; } +sub formbuilder_setup (@) { + my %params=@_; + my $form=$params{form}; + my $q=$params{cgi}; + + return unless defined $form->field("do"); + + if ($form->field("do") eq "create") { + # Warn the user: new pages must be written in master language. + my $template=template("pocreatepage.tmpl"); + $template->param(LANG => $config{po_master_language}{name}); + $form->tmpl_param(message => $template->output); + } + elsif ($form->field("do") eq "edit") { + # Remove the rename/remove buttons on slave pages. + # This has to be done after the rename/remove plugins have added + # their buttons, which is why this hook must be run last. + # The canrename/canremove hooks already ensure this is forbidden + # at the backend level, so this is only UI sugar. + if (istranslation($form->field("page"))) { + map { + for (my $i = 0; $i < @{$params{buttons}}; $i++) { + if (@{$params{buttons}}[$i] eq $_) { + delete @{$params{buttons}}[$i]; + last; + } + } + } qw(Rename Remove); + } + } +} + +sub formbuilder (@) { + my %params=@_; + my $form=$params{form}; + my $q=$params{cgi}; + + return unless defined $form->field("do"); + + # Do not allow to create pages of type po: they are automatically created. + # The main reason to do so is to bypass the "favor the type of linking page + # on page creation" logic, which is unsuitable when a broken link is clicked + # on a slave (PO) page. + # This cannot be done in the formbuilder_setup hook as the list of types is + # computed later. + if ($form->field("do") eq "create") { + foreach my $field ($form->field) { + next unless "$field" eq "type"; + if ($field->type eq 'select') { + # remove po from the list of types + my @types = grep { $_ ne 'po' } $field->options; + $field->options(\@types) if @types; + } + else { + # make sure the default value is not po; + # does this case actually happen? + debug "po(formbuilder) ".gettext("type field is not selected - not implemented yet"); + } + } + } +} # ,---- # | Injected functions @@ -496,7 +620,9 @@ sub myurlto ($$;$) { # avoid, i.e. when po_link_to = negotiated if ($config{po_link_to} eq "negotiated") { my @caller = caller(1); - my $run_by_editpage = ($caller[3] eq "IkiWiki::cgi_editpage"); + my $run_by_editpage = 0; + $run_by_editpage = 1 if (exists $caller[3] && defined $caller[3] + && $caller[3] eq "IkiWiki::cgi_editpage"); inject(name => "IkiWiki::beautify_urlpath", call => $origsubs{'beautify_urlpath'}) if $run_by_editpage; my $res = $origsubs{'urlto'}->($to,$from,$absolute); @@ -515,7 +641,20 @@ sub mynicepagetitle ($;$) { my $res = $origsubs{'nicepagetitle'}->($page, $unescaped); return $res unless istranslation($page); return $res unless $config{po_translation_status_in_links}; - return $res.' ('.percenttranslated($page).' %)'; + my @caller = caller(1); + return $res if (exists $caller[3] && defined $caller[3] + && $caller[3] eq "IkiWiki::Plugin::parentlinks::parentlinks"); + return $res.' ('.percenttranslated($page).' %)'; +} + +sub mycgiurl (@) { + my %params=@_; + + # slave pages have no subpages + if (istranslation($params{'from'})) { + $params{'from'} = masterpage($params{'from'}); + } + return $origsubs{'cgiurl'}->(%params); } # ,---- @@ -570,8 +709,9 @@ sub istranslatablefile ($) { my $file=shift; return 0 unless defined $file; - return 0 if (defined pagetype($file) && pagetype($file) eq 'po'); + return 0 if defined pagetype($file) && pagetype($file) eq 'po'; return 0 if $file =~ /\.pot$/; + return 0 unless -e "$config{srcdir}/$file"; # underlay dirs may be read-only return 1 if pagespec_match(pagename($file), $config{po_translatable_pages}); return; } @@ -587,18 +727,19 @@ sub istranslatable ($) { sub _istranslation ($) { my $page=shift; + $page='' unless defined $page && length $page; my $hasleadingslash = ($page=~s#^/##); my $file=$pagesources{$page}; - return 0 unless (defined $file + return 0 unless defined $file && defined pagetype($file) - && pagetype($file) eq 'po'); + && pagetype($file) eq 'po'; return 0 if $file =~ /\.pot$/; my ($masterpage, $lang) = ($page =~ /(.*)[.]([a-z]{2})$/); - return 0 unless (defined $masterpage && defined $lang + return 0 unless defined $masterpage && defined $lang && length $masterpage && length $lang && defined $pagesources{$masterpage} - && defined $config{po_slave_languages}{$lang}); + && defined $config{po_slave_languages}{$lang}; return (maybe_add_leading_slash($masterpage, $hasleadingslash), $lang) if istranslatable($masterpage); @@ -612,7 +753,7 @@ sub istranslation ($) { $translations{$masterpage}{$lang}=$page unless exists $translations{$masterpage}{$lang}; return (maybe_add_leading_slash($masterpage, $hasleadingslash), $lang); } - return; + return ""; } sub masterpage ($) { @@ -651,7 +792,7 @@ sub otherlanguages ($) { my $page=shift; my %ret; - return \%ret unless (istranslation($page) || istranslatable($page)); + return \%ret unless istranslation($page) || istranslatable($page); my $curlang=lang($page); foreach my $lang ($config{po_master_language}{code}, keys %{$config{po_slave_languages}}) { @@ -697,8 +838,7 @@ sub refreshpot ($) { # let's cheat a bit to force porefs option to be passed to Locale::Po4a::Po; # this is undocument use of internal Locale::Po4a::TransTractor's data, # compulsory since this module prevents us from using the porefs option. - my %po_options = ('porefs' => 'none'); - $doc->{TT}{po_out}=Locale::Po4a::Po->new(\%po_options); + $doc->{TT}{po_out}=Locale::Po4a::Po->new({ 'porefs' => 'none' }); $doc->{TT}{po_out}->set_charset('utf-8'); # do the actual work $doc->parse; @@ -711,17 +851,23 @@ sub refreshpofiles ($@) { my @pofiles=@_; my $potfile=potfile($masterfile); - error("[po/refreshpofiles] POT file ($potfile) does not exist") unless (-e $potfile); + if (! -e $potfile) { + error("po(refreshpofiles) ".sprintf(gettext("POT file (%s) does not exist"), $potfile)); + } foreach my $pofile (@pofiles) { IkiWiki::prep_writefile(basename($pofile),dirname($pofile)); if (-e $pofile) { system("msgmerge", "-U", "--backup=none", $pofile, $potfile) == 0 - or error("[po/refreshpofiles:$pofile] failed to update"); + or error("po(refreshpofiles) ". + sprintf(gettext("failed to update %s"), + $pofile)); } else { File::Copy::syscopy($potfile,$pofile) - or error("[po/refreshpofiles:$pofile] failed to copy the POT file"); + or error("po(refreshpofiles) ". + sprintf(gettext("failed to copy the POT file to %s"), + $pofile)); } } } @@ -759,20 +905,19 @@ sub percenttranslated ($) { return gettext("N/A") unless istranslation($page); my $file=srcfile($pagesources{$page}); my $masterfile = srcfile($pagesources{masterpage($page)}); - my (@pos,@masters); - push @pos,$file; - push @masters,$masterfile; my %options = ( "markdown" => (pagetype($masterfile) eq 'mdwn') ? 1 : 0, ); my $doc=Locale::Po4a::Chooser::new('text',%options); $doc->process( - 'po_in_name' => \@pos, - 'file_in_name' => \@masters, + 'po_in_name' => [ $file ], + 'file_in_name' => [ $masterfile ], 'file_in_charset' => 'utf-8', 'file_out_charset' => 'utf-8', - ) or error("[po/percenttranslated:$page]: failed to translate"); + ) or error("po(percenttranslated) ". + sprintf(gettext("failed to translate %s"), $page)); my ($percent,$hit,$queries) = $doc->stats(); + $percent =~ s/\.[0-9]+$//; return $percent; } @@ -844,7 +989,7 @@ sub deletetranslations ($) { } } @todelete; - if (scalar @todelete) { + if (@todelete) { commit_and_refresh( gettext("removed obsolete PO files"), "IkiWiki::Plugin::po::deletetranslations"); @@ -874,6 +1019,111 @@ sub commit_and_refresh ($$) { IkiWiki::saveindex(); } +# on success, returns the filtered content. +# on error, if $nonfatal, warn and return undef; else, error out. +sub po_to_markup ($$;$) { + my ($page, $content) = (shift, shift); + my $nonfatal = shift; + + $content = '' unless defined $content; + $content = decode_utf8(encode_utf8($content)); + # CRLF line terminators make poor Locale::Po4a feel bad + $content=~s/\r\n/\n/g; + + # There are incompatibilities between some File::Temp versions + # (including 0.18, bundled with Lenny's perl-modules package) + # and others (e.g. 0.20, previously present in the archive as + # a standalone package): under certain circumstances, some + # return a relative filename, whereas others return an absolute one; + # we here use this module in a way that is at least compatible + # with 0.18 and 0.20. Beware, hit'n'run refactorers! + my $infile = new File::Temp(TEMPLATE => "ikiwiki-po-filter-in.XXXXXXXXXX", + DIR => File::Spec->tmpdir, + UNLINK => 1)->filename; + my $outfile = new File::Temp(TEMPLATE => "ikiwiki-po-filter-out.XXXXXXXXXX", + DIR => File::Spec->tmpdir, + UNLINK => 1)->filename; + + my $fail = sub ($) { + my $msg = "po(po_to_markup) - $page : " . shift; + if ($nonfatal) { + warn $msg; + return undef; + } + error($msg, sub { unlink $infile, $outfile}); + }; + + writefile(basename($infile), File::Spec->tmpdir, $content) + or return $fail->(sprintf(gettext("failed to write %s"), $infile)); + + my $masterfile = srcfile($pagesources{masterpage($page)}); + my %options = ( + "markdown" => (pagetype($masterfile) eq 'mdwn') ? 1 : 0, + ); + my $doc=Locale::Po4a::Chooser::new('text',%options); + $doc->process( + 'po_in_name' => [ $infile ], + 'file_in_name' => [ $masterfile ], + 'file_in_charset' => 'utf-8', + 'file_out_charset' => 'utf-8', + ) or return $fail->(gettext("failed to translate")); + $doc->write($outfile) + or return $fail->(sprintf(gettext("failed to write %s"), $outfile)); + + $content = readfile($outfile) + or return $fail->(sprintf(gettext("failed to read %s"), $outfile)); + + # Unlinking should happen automatically, thanks to File::Temp, + # but it does not work here, probably because of the way writefile() + # and Locale::Po4a::write() work. + unlink $infile, $outfile; + + return $content; +} + +# returns a SuccessReason or FailReason object +sub isvalidpo ($) { + my $content = shift; + + # NB: we don't use po_to_markup here, since Po4a parser does + # not mind invalid PO content + $content = '' unless defined $content; + $content = decode_utf8(encode_utf8($content)); + + # There are incompatibilities between some File::Temp versions + # (including 0.18, bundled with Lenny's perl-modules package) + # and others (e.g. 0.20, previously present in the archive as + # a standalone package): under certain circumstances, some + # return a relative filename, whereas others return an absolute one; + # we here use this module in a way that is at least compatible + # with 0.18 and 0.20. Beware, hit'n'run refactorers! + my $infile = new File::Temp(TEMPLATE => "ikiwiki-po-isvalidpo.XXXXXXXXXX", + DIR => File::Spec->tmpdir, + UNLINK => 1)->filename; + + my $fail = sub ($) { + my $msg = '[po/isvalidpo] ' . shift; + unlink $infile; + return IkiWiki::FailReason->new("$msg"); + }; + + writefile(basename($infile), File::Spec->tmpdir, $content) + or return $fail->(sprintf(gettext("failed to write %s"), $infile)); + + my $res = (system("msgfmt", "--check", $infile, "-o", "/dev/null") == 0); + + # Unlinking should happen automatically, thanks to File::Temp, + # but it does not work here, probably because of the way writefile() + # and Locale::Po4a::write() work. + unlink $infile; + + if ($res) { + return IkiWiki::SuccessReason->new("valid gettext data"); + } + return IkiWiki::FailReason->new("invalid gettext data, go back ". + "to previous page to go on with edit"); +} + # ,---- # | PageSpec's # `---- @@ -911,7 +1161,7 @@ sub match_lang ($$;@) { my $regexp=IkiWiki::glob2re($wanted); my $lang=IkiWiki::Plugin::po::lang($page); - if ($lang!~/^$regexp$/i) { + if ($lang !~ /^$regexp$/i) { return IkiWiki::FailReason->new("file language is $lang, not $wanted"); } else {