Bug 15395: Allow correct handling of plural translation
authorJulian Maurice <julian.maurice@biblibre.com>
Tue, 15 Dec 2015 01:31:03 +0000 (02:31 +0100)
committerNick Clemens <nick@bywatersolutions.com>
Thu, 8 Nov 2018 15:55:50 +0000 (15:55 +0000)
Locale::Maketext does not allow correct handling of plural translation
for languages that have more than one plural forms.
Locale::Messages does.

So Koha::I18N is now a wrapper around Locale::Messages, just like
Locale::TextDomain, and export the same symbols as Locale::TextDomain.
You can refer to documentation of Locale::TextDomain to know how to use
exported subroutines.

Example usage:
  __("Hi")
  __x("Hi {name}", name => 'Bob')
  __n("item", "items", $num_items)
  __nx("one item", "{count} items", $num_items, count => $num_items)
  __p("Bibliographic record", "item")

This patch also brings Koha::I18N power to Template::Toolkit templates
by adding a TT plugin.
This plugin can be used like this:

[%# USE the plugin and define some macros %]
[% PROCESS 'i18n.inc' %]

[%# tn is the equivalent of __n %]
[%# macro names can't start with underscore, t is for "translate" %]
[% tn('item', 'items', num_items) %]
Extraction of strings from templates is a bit complicated and use
Template::Parser and PPI. Template is compiled into Perl code and then
analyzed by PPI. It is slow, but should be correct even with complex
constructions.

Remove dependency to Locale::Maketext and Locale::Maketext::Lexicon
Add dependency to Locale::Messages and PPI

Test plan for translation in Perl code:
1. Open a .pl script or .pm module with your favorite text editor
2. Add 'use Koha::I18N;' in the beginning of file
3. Use one of the subroutines exported by Koha::I18N and be sure to have
   a way to visualize the result (pass result to the template for
   example, or simply warn and watch the log file)
4. cd misc/translator && ./translate update fr-FR # try other languages
5. Open misc/translator/po/fr-FR-messages.po and translate your
   string(s)
   You may need to change the "Plural-Forms" header. See
   https://localization-guide.readthedocs.org/en/latest/l10n/pluralforms.html
6. ./translate install fr-FR
7. Use your web browser to go to the page that should display the
   translation, change language and verify the translation is correct
8. prove t/Koha/I18N.t

Test plan for translation in templates:
1. Open a template file (.tt or .inc) with your favorite text editor
2. Add the PROCESS directive mentioned above in the beginning of file
3. Use one of the t* macros defined in i18n.inc. They are used like
   their "__" equivalent, with one difference: the 'x' variants take a
   hashref instead of a hash as last parameter
4. cd misc/translator && ./translate update fr-FR
5. Open misc/translator/po/fr-FR-messages.po and translate your
   string(s)
6. ./translate install fr-FR
7. Use your web browser to go to the page that should display the
   translation, change language and verify the translation is
   correct

Signed-off-by: Marc Véron <veron@veron.ch>

Signed-off-by: Jonathan Druart <jonathan.druart@bugs.koha-community.org>

Signed-off-by: Nick Clemens <nick@bywatersolutions.com>

C4/Installer/PerlDependencies.pm
Koha/I18N.pm
Koha/Template/Plugin/I18N.pm [new file with mode: 0644]
koha-tmpl/intranet-tmpl/prog/en/includes/i18n.inc [new file with mode: 0644]
koha-tmpl/opac-tmpl/bootstrap/en/includes/i18n.inc [new file with mode: 0644]
misc/translator/LangInstaller.pm
t/Koha/I18N.t [new file with mode: 0755]
t/Koha/I18N/po/xx_XX/LC_MESSAGES/Koha.mo [new file with mode: 0644]

index bf85991..ab37c15 100644 (file)
@@ -727,15 +727,15 @@ our $PERL_DEPS = {
         required   => 1,
         min_ver    => '2.125',
     },
-    'Locale::Maketext' => {
+    'Locale::Messages' => {
         'usage'    => 'Core',
         'required' => '1',
-        'min_ver'  => '1.19',
+        'min_ver'  => '1.20',
     },
-    'Locale::Maketext::Lexicon' => {
-        'usage'    => 'Core',
-        'required' => '1',
-        'min_ver'  => '0.91',
+    'PPI' => {
+        'usage'    => 'I18N',
+        'required' => '0',
+        'min_ver'  => '1.215',
     },
     'LWP::Protocol::https' => {
         'usage'    => 'OverDrive integration',
index 33730f9..44c4713 100644 (file)
@@ -18,41 +18,186 @@ package Koha::I18N;
 # along with Koha; if not, see <http://www.gnu.org/licenses>.
 
 use Modern::Perl;
-use base qw(Locale::Maketext Exporter);
 
 use CGI;
 use C4::Languages;
+use C4::Context;
 
-use Locale::Maketext::Lexicon {
-    'en' => ['Auto'],
-    '*' => [
-        Gettext =>
-            C4::Context->config('intranetdir')
-            . '/misc/translator/po/*-messages.po'
-    ],
-    '_AUTO' => 1,
-    '_style' => 'gettext',
-};
+use Encode;
+use Locale::Messages qw(:locale_h nl_putenv setlocale LC_MESSAGES);
+use Koha::Cache::Memory::Lite;
 
-our @EXPORT = qw( gettext );
+use parent 'Exporter';
+our @EXPORT = qw(
+    __
+    __x
+    __n
+    __nx
+    __xn
+    __p
+    __px
+    __np
+    __npx
+    N__
+    N__n
+    N__p
+    N__np
+);
 
-my %language_handles;
+our $textdomain = 'Koha';
 
-sub get_language_handle {
-    my $cgi = new CGI;
-    my $language = C4::Languages::getlanguage;
+sub init {
+    my $cache = Koha::Cache::Memory::Lite->get_instance();
+    my $cache_key = 'i18n:initialized';
+    unless ($cache->get_from_cache($cache_key)) {
+        my @system_locales = grep { chomp; not (/^C/ || $_ eq 'POSIX') } qx/locale -a/;
+        if (@system_locales) {
+            # LANG needs to be set to a valid locale,
+            # otherwise LANGUAGE is ignored
+            nl_putenv('LANG=' . $system_locales[0]);
+            setlocale(LC_MESSAGES, '');
 
-    if (not exists $language_handles{$language}) {
-        $language_handles{$language} = __PACKAGE__->get_handle($language)
-            or die "No language handle for '$language'";
+            my $langtag = C4::Languages::getlanguage;
+            my @subtags = split /-/, $langtag;
+            my ($language, $region) = @subtags;
+            if ($region && length $region == 4) {
+                $region = $subtags[2];
+            }
+            my $locale = $language;
+            if ($region) {
+                $locale .= '_' . $region;
+            }
+
+            nl_putenv("LANGUAGE=$locale");
+            nl_putenv('OUTPUT_CHARSET=UTF-8');
+
+            my $directory = _base_directory();
+            textdomain($textdomain);
+            bindtextdomain($textdomain, $directory);
+        } else {
+            warn "No locale installed. Localization cannot work and is therefore disabled";
+        }
+
+        $cache->set_in_cache($cache_key, 1);
+    }
+}
+
+sub __ {
+    my ($msgid) = @_;
+
+    $msgid = Encode::encode_utf8($msgid);
+
+    return _gettext(\&gettext, [ $msgid ]);
+}
+
+sub __x {
+    my ($msgid, %vars) = @_;
+
+    $msgid = Encode::encode_utf8($msgid);
+
+    return _gettext(\&gettext, [ $msgid ], %vars);
+}
+
+sub __n {
+    my ($msgid, $msgid_plural, $count) = @_;
+
+    $msgid = Encode::encode_utf8($msgid);
+    $msgid_plural = Encode::encode_utf8($msgid_plural);
+
+    return _gettext(\&ngettext, [ $msgid, $msgid_plural, $count ]);
+}
+
+sub __nx {
+    my ($msgid, $msgid_plural, $count, %vars) = @_;
+
+    $msgid = Encode::encode_utf8($msgid);
+    $msgid_plural = Encode::encode_utf8($msgid_plural);
+
+    return _gettext(\&ngettext, [ $msgid, $msgid_plural, $count ], %vars);
+}
+
+sub __xn {
+    return __nx(@_);
+}
+
+sub __p {
+    my ($msgctxt, $msgid) = @_;
+
+    $msgctxt = Encode::encode_utf8($msgctxt);
+    $msgid = Encode::encode_utf8($msgid);
+
+    return _gettext(\&pgettext, [ $msgctxt, $msgid ]);
+}
+
+sub __px {
+    my ($msgctxt, $msgid, %vars) = @_;
+
+    $msgctxt = Encode::encode_utf8($msgctxt);
+    $msgid = Encode::encode_utf8($msgid);
+
+    return _gettext(\&pgettext, [ $msgctxt, $msgid ], %vars);
+}
+
+sub __np {
+    my ($msgctxt, $msgid, $msgid_plural, $count) = @_;
+
+    $msgctxt = Encode::encode_utf8($msgctxt);
+    $msgid = Encode::encode_utf8($msgid);
+    $msgid_plural = Encode::encode_utf8($msgid_plural);
+
+    return _gettext(\&npgettext, [ $msgctxt, $msgid, $msgid_plural, $count ]);
+}
+
+sub __npx {
+    my ($msgctxt, $msgid, $msgid_plural, $count, %vars) = @_;
+
+    $msgctxt = Encode::encode_utf8($msgctxt);
+    $msgid = Encode::encode_utf8($msgid);
+    $msgid_plural = Encode::encode_utf8($msgid_plural);
+
+    return _gettext(\&npgettext, [ $msgctxt, $msgid, $msgid_plural, $count], %vars);
+}
+
+sub N__ {
+    return @_;
+}
+
+sub N__n {
+    return @_;
+}
+
+sub N__p {
+    return @_;
+}
+
+sub N__np {
+    return @_;
+}
+
+sub _base_directory {
+    return C4::Context->config('intranetdir') . '/misc/translator/po';
+}
+
+sub _gettext {
+    my ($sub, $args, %vars) = @_;
+
+    init();
+
+    my $text = Encode::decode_utf8($sub->(@$args));
+    if (%vars) {
+        $text = _expand($text, %vars);
     }
 
-    return $language_handles{$language};
+    return $text;
 }
 
-sub gettext {
-    my $lh = get_language_handle;
-    $lh->maketext(@_);
+sub _expand {
+    my ($text, %vars) = @_;
+
+    my $re = join '|', map { quotemeta $_ } keys %vars;
+    $text =~ s/\{($re)\}/defined $vars{$1} ? $vars{$1} : "{$1}"/ge;
+
+    return $text;
 }
 
 1;
diff --git a/Koha/Template/Plugin/I18N.pm b/Koha/Template/Plugin/I18N.pm
new file mode 100644 (file)
index 0000000..8c95ea6
--- /dev/null
@@ -0,0 +1,72 @@
+package Koha::Template::Plugin::I18N;
+
+# Copyright BibLibre 2015
+#
+# This file is part of Koha.
+#
+# Koha is free software; you can redistribute it and/or modify it
+# under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 3 of the License, or
+# (at your option) any later version.
+#
+# Koha is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Koha; if not, see <http://www.gnu.org/licenses>.
+
+use Modern::Perl;
+
+use base qw( Template::Plugin );
+
+use C4::Context;
+use Koha::I18N;
+
+sub t {
+    my ($self, $msgid) = @_;
+    return __($msgid);
+}
+
+sub tx {
+    my ($self, $msgid, $vars) = @_;
+    return __x($msgid, %$vars);
+}
+
+sub tn {
+    my ($self, $msgid, $msgid_plural, $count) = @_;
+    return __n($msgid, $msgid_plural, $count);
+}
+
+sub tnx {
+    my ($self, $msgid, $msgid_plural, $count, $vars) = @_;
+    return __nx($msgid, $msgid_plural, $count, %$vars);
+}
+
+sub txn {
+    my ($self, $msgid, $msgid_plural, $count, $vars) = @_;
+    return __xn($msgid, $msgid_plural, $count, %$vars);
+}
+
+sub tp {
+    my ($self, $msgctxt, $msgid) = @_;
+    return __p($msgctxt, $msgid);
+}
+
+sub tpx {
+    my ($self, $msgctxt, $msgid, $vars) = @_;
+    return __px($msgctxt, $msgid, %$vars);
+}
+
+sub tnp {
+    my ($self, $msgctxt, $msgid, $msgid_plural, $count) = @_;
+    return __np($msgctxt, $msgid, $msgid_plural, $count);
+}
+
+sub tnpx {
+    my ($self, $msgctxt, $msgid, $msgid_plural, $count, $vars) = @_;
+    return __np($msgctxt, $msgid, $msgid_plural, $count, %$vars);
+}
+
+1;
diff --git a/koha-tmpl/intranet-tmpl/prog/en/includes/i18n.inc b/koha-tmpl/intranet-tmpl/prog/en/includes/i18n.inc
new file mode 100644 (file)
index 0000000..4b5da92
--- /dev/null
@@ -0,0 +1,39 @@
+[%
+  USE I18N;
+
+  MACRO t(msgid) BLOCK;
+    I18N.t(msgid);
+  END;
+
+  MACRO tx(msgid, vars) BLOCK;
+    I18N.tx(msgid, vars);
+  END;
+
+  MACRO tn(msgid, msgid_plural, count) BLOCK;
+    I18N.tn(msgid, msgid_plural, count);
+  END;
+
+  MACRO tnx(msgid, msgid_plural, count, vars) BLOCK;
+    I18N.tnx(msgid, msgid_plural, count, vars);
+  END;
+
+  MACRO txn(msgid, msgid_plural, count, vars) BLOCK;
+    I18N.txn(msgid, msgid_plural, count, vars);
+  END;
+
+  MACRO tp(msgctxt, msgid) BLOCK;
+    I18N.tp(msgctxt, msgid);
+  END;
+
+  MACRO tpx(msgctxt, msgid, vars) BLOCK;
+    I18N.tpx(msgctxt, msgid, vars);
+  END;
+
+  MACRO tnp(msgctxt, msgid, msgid_plural, count) BLOCK;
+    I18N.tnp(msgctxt, msgid, msgid_plural, count);
+  END;
+
+  MACRO tnpx(msgctxt, msgid, msgid_plural, count, vars) BLOCK;
+    I18N.tnpx(msgctxt, msgid, msgid_plural, count, vars);
+  END;
+%]
diff --git a/koha-tmpl/opac-tmpl/bootstrap/en/includes/i18n.inc b/koha-tmpl/opac-tmpl/bootstrap/en/includes/i18n.inc
new file mode 100644 (file)
index 0000000..4b5da92
--- /dev/null
@@ -0,0 +1,39 @@
+[%
+  USE I18N;
+
+  MACRO t(msgid) BLOCK;
+    I18N.t(msgid);
+  END;
+
+  MACRO tx(msgid, vars) BLOCK;
+    I18N.tx(msgid, vars);
+  END;
+
+  MACRO tn(msgid, msgid_plural, count) BLOCK;
+    I18N.tn(msgid, msgid_plural, count);
+  END;
+
+  MACRO tnx(msgid, msgid_plural, count, vars) BLOCK;
+    I18N.tnx(msgid, msgid_plural, count, vars);
+  END;
+
+  MACRO txn(msgid, msgid_plural, count, vars) BLOCK;
+    I18N.txn(msgid, msgid_plural, count, vars);
+  END;
+
+  MACRO tp(msgctxt, msgid) BLOCK;
+    I18N.tp(msgctxt, msgid);
+  END;
+
+  MACRO tpx(msgctxt, msgid, vars) BLOCK;
+    I18N.tpx(msgctxt, msgid, vars);
+  END;
+
+  MACRO tnp(msgctxt, msgid, msgid_plural, count) BLOCK;
+    I18N.tnp(msgctxt, msgid, msgid_plural, count);
+  END;
+
+  MACRO tnpx(msgctxt, msgid, msgid_plural, count, vars) BLOCK;
+    I18N.tnpx(msgctxt, msgid, msgid_plural, count, vars);
+  END;
+%]
index ff42dcf..7a621c1 100644 (file)
@@ -25,6 +25,13 @@ use C4::Context;
 use YAML::Syck qw( Dump LoadFile );
 use Locale::PO;
 use FindBin qw( $Bin );
+use File::Basename;
+use File::Find;
+use File::Path qw( make_path );
+use File::Slurp;
+use File::Temp qw( tempdir );
+use Template::Parser;
+use PPI;
 
 $YAML::Syck::ImplicitTyping = 1;
 
@@ -66,13 +73,17 @@ sub new {
     $self->{process}         = "$Bin/tmpl_process3.pl " . ($verbose ? '' : '-q');
     $self->{path_po}         = "$Bin/po";
     $self->{po}              = { '' => $default_pref_po_header };
-    $self->{domain}          = 'messages';
+    $self->{domain}          = 'Koha';
     $self->{cp}              = `which cp`;
     $self->{msgmerge}        = `which msgmerge`;
+    $self->{msgfmt}          = `which msgfmt`;
+    $self->{msginit}         = `which msginit`;
     $self->{xgettext}        = `which xgettext`;
     $self->{sed}             = `which sed`;
     chomp $self->{cp};
     chomp $self->{msgmerge};
+    chomp $self->{msgfmt};
+    chomp $self->{msginit};
     chomp $self->{xgettext};
     chomp $self->{sed};
 
@@ -456,30 +467,134 @@ sub create_tmpl {
     }
 }
 
+sub locale_name {
+    my $self = shift;
+
+    my ($language, $region, $country) = split /-/, $self->{lang};
+    $country //= $region;
+    my $locale = $language;
+    if ($country && length($country) == 2) {
+        $locale .= '_' . $country;
+    }
+
+    return $locale;
+}
+
 sub create_messages {
     my $self = shift;
 
-    print "Create messages ($self->{lang})\n" if $self->{verbose};
-    system
-        "$self->{cp} $self->{domain}.pot " .
-        "$self->{path_po}/$self->{lang}-$self->{domain}.po";
+    my $pot = "$self->{domain}.pot";
+    my $po = "$self->{path_po}/$self->{lang}-messages.po";
+
+    unless ( -f $pot ) {
+        $self->extract_messages();
+    }
+
+    say "Create messages ($self->{lang})" if $self->{verbose};
+    my $locale = $self->locale_name();
+    system "$self->{msginit} -i $pot -o $po -l $locale --no-translator";
+
+    # If msginit failed to correctly set Plural-Forms, set a default one
+    system "$self->{sed} --in-place $po "
+        . "--expression='s/Plural-Forms: nplurals=INTEGER; plural=EXPRESSION/Plural-Forms: nplurals=2; plural=(n != 1)/'";
 }
 
 sub update_messages {
     my $self = shift;
 
-    my $pofile = "$self->{path_po}/$self->{lang}-$self->{domain}.po";
-    print "Update messages ($self->{lang})\n" if $self->{verbose};
-    if ( not -f $pofile ) {
-        print "File $pofile does not exist\n" if $self->{verbose};
+    my $pot = "$self->{domain}.pot";
+    my $po = "$self->{path_po}/$self->{lang}-messages.po";
+
+    unless ( -f $pot ) {
+        $self->extract_messages();
+    }
+
+    if ( -f $po ) {
+        say "Update messages ($self->{lang})" if $self->{verbose};
+        system "$self->{msgmerge} -U $po $pot";
+    } else {
         $self->create_messages();
     }
-    system "$self->{msgmerge} -U $pofile $self->{domain}.pot";
+}
+
+sub extract_messages_from_templates {
+    my ($self, $tempdir, @files) = @_;
+
+    my $intranetdir = $self->{context}->config('intranetdir');
+    my @keywords = qw(t tx tn txn tnx tp tpx tnp tnpx);
+    my $parser = Template::Parser->new();
+
+    foreach my $file (@files) {
+        say "Extract messages from $file" if $self->{verbose};
+        my $template = read_file("$intranetdir/$file");
+        my $data = $parser->parse($template);
+        unless ($data) {
+            warn "Error at $file : " . $parser->error();
+            next;
+        }
+
+        make_path(dirname("$tempdir/$file"));
+        open my $fh, '>', "$tempdir/$file";
+
+        my @blocks = ($data->{BLOCK}, values %{ $data->{DEFBLOCKS} });
+        foreach my $block (@blocks) {
+            my $document = PPI::Document->new(\$block);
+
+            # [% t('foo') %] is compiled to
+            # $output .= $stash->get(['t', ['foo']]);
+            # We try to find all nodes corresponding to keyword (here 't')
+            my $nodes = $document->find(sub {
+                my ($topnode, $element) = @_;
+
+                # Filter out non-valid keywords
+                return 0 unless ($element->isa('PPI::Token::Quote::Single'));
+                return 0 unless (grep {$element->content eq qq{'$_'}} @keywords);
+
+                # keyword (e.g. 't') should be the first element of the arrayref
+                # passed to $stash->get()
+                return 0 if $element->sprevious_sibling;
+
+                return 0 unless $element->snext_sibling
+                    && $element->snext_sibling->snext_sibling
+                    && $element->snext_sibling->snext_sibling->isa('PPI::Structure::Constructor');
+
+                # Check that it's indeed a call to $stash->get()
+                my $statement = $element->statement->parent->statement->parent->statement;
+                return 0 unless grep { $_->isa('PPI::Token::Symbol') && $_->content eq '$stash' } $statement->children;
+                return 0 unless grep { $_->isa('PPI::Token::Operator') && $_->content eq '->' } $statement->children;
+                return 0 unless grep { $_->isa('PPI::Token::Word') && $_->content eq 'get' } $statement->children;
+
+                return 1;
+            });
+
+            next unless $nodes;
+
+            # Write the Perl equivalent of calls to t* functions family, so
+            # xgettext can extract the strings correctly
+            foreach my $node (@$nodes) {
+                my @args = map {
+                    $_->significant && !$_->isa('PPI::Token::Operator') ? $_->content : ()
+                } $node->snext_sibling->snext_sibling->find_first('PPI::Statement')->children;
+
+                my $keyword = $node->content;
+                $keyword =~ s/^'t(.*)'$/__$1/;
+
+                say $fh "$keyword(" . join(', ', @args) . ");";
+            }
+
+        }
+
+        close $fh;
+    }
+
+    return $tempdir;
 }
 
 sub extract_messages {
     my $self = shift;
 
+    say "Extract messages into POT file" if $self->{verbose};
+
     my $intranetdir = $self->{context}->config('intranetdir');
     my @files_to_scan;
     my @directories_to_scan = ('.');
@@ -499,26 +614,53 @@ sub extract_messages {
         }
     }
 
-    my $xgettext_cmd = "$self->{xgettext} -L Perl --from-code=UTF-8 " .
-        "-o $Bin/$self->{domain}.pot -D $intranetdir";
+    my @tt_files;
+    find(sub {
+        if ($File::Find::dir =~ m|/en/| && $_ =~ m/\.(tt|inc)$/) {
+            my $filename = $File::Find::name;
+            $filename =~ s|^$intranetdir/||;
+            push @tt_files, $filename;
+        }
+    }, "$intranetdir/koha-tmpl");
+
+    my $tempdir = tempdir('Koha-translate-XXXX', TMPDIR => 1, CLEANUP => 1);
+    $self->extract_messages_from_templates($tempdir, @tt_files);
+    push @files_to_scan, @tt_files;
+
+    my $xgettext_cmd = "$self->{xgettext} -L Perl --from-code=UTF-8 "
+        . "--package-name=Koha --package-version='' "
+        . "-k -k__ -k__x -k__n:1,2 -k__nx:1,2 -k__xn:1,2 -k__p:1c,2 "
+        . "-k__px:1c,2 -k__np:1c,2,3 -k__npx:1c,2,3 -kN__ -kN__n:1,2 "
+        . "-kN__p:1c,2 -kN__np:1c,2,3 "
+        . "-o $Bin/$self->{domain}.pot -D $tempdir -D $intranetdir";
     $xgettext_cmd .= " $_" foreach (@files_to_scan);
 
     if (system($xgettext_cmd) != 0) {
         die "system call failed: $xgettext_cmd";
     }
 
-    if ( -f "$Bin/$self->{domain}.pot" ) {
-        my $replace_charset_cmd = "$self->{sed} --in-place " .
-            "$Bin/$self->{domain}.pot " .
-            "--expression='s/charset=CHARSET/charset=UTF-8/'";
-        if (system($replace_charset_cmd) != 0) {
-            die "system call failed: $replace_charset_cmd";
-        }
-    } else {
-        print "No messages found\n" if $self->{verbose};
-        return;
+    my $replace_charset_cmd = "$self->{sed} --in-place " .
+        "$Bin/$self->{domain}.pot " .
+        "--expression='s/charset=CHARSET/charset=UTF-8/'";
+    if (system($replace_charset_cmd) != 0) {
+        die "system call failed: $replace_charset_cmd";
     }
-    return 1;
+}
+
+sub install_messages {
+    my ($self) = @_;
+
+    my $locale = $self->locale_name();
+    my $modir = "$self->{path_po}/$locale/LC_MESSAGES";
+    my $pofile = "$self->{path_po}/$self->{lang}-messages.po";
+    my $mofile = "$modir/$self->{domain}.mo";
+
+    if ( not -f $pofile ) {
+        $self->create_messages();
+    }
+    say "Install messages ($locale)" if $self->{verbose};
+    make_path($modir);
+    system "$self->{msgfmt} -o $mofile $pofile";
 }
 
 sub remove_pot {
@@ -532,6 +674,8 @@ sub install {
     return unless $self->{lang};
     $self->install_tmpl($files) unless $self->{pref_only};
     $self->install_prefs();
+    $self->install_messages();
+    $self->remove_pot();
 }
 
 
@@ -547,14 +691,13 @@ sub get_all_langs {
 sub update {
     my ($self, $files) = @_;
     my @langs = $self->{lang} ? ($self->{lang}) : $self->get_all_langs();
-    my $extract_ok = $self->extract_messages();
     for my $lang ( @langs ) {
         $self->set_lang( $lang );
         $self->update_tmpl($files) unless $self->{pref_only};
         $self->update_prefs();
-        $self->update_messages() if $extract_ok;
+        $self->update_messages();
     }
-    $self->remove_pot() if $extract_ok;
+    $self->remove_pot();
 }
 
 
@@ -563,10 +706,8 @@ sub create {
     return unless $self->{lang};
     $self->create_tmpl($files) unless $self->{pref_only};
     $self->create_prefs();
-    if ($self->extract_messages()) {
-        $self->create_messages();
-        $self->remove_pot();
-    }
+    $self->create_messages();
+    $self->remove_pot();
 }
 
 
diff --git a/t/Koha/I18N.t b/t/Koha/I18N.t
new file mode 100755 (executable)
index 0000000..6524e66
--- /dev/null
@@ -0,0 +1,62 @@
+#!/usr/bin/perl
+
+use Modern::Perl;
+use Test::More tests => 35;
+use Test::MockModule;
+use FindBin qw($Bin);
+use Encode;
+
+BEGIN {
+    use_ok('Koha::I18N');
+}
+
+my $koha_i18n = Test::MockModule->new('Koha::I18N');
+$koha_i18n->mock('_base_directory', sub { "$Bin/I18N/po" });
+
+my $c4_languages = Test::MockModule->new('C4::Languages');
+$c4_languages->mock('getlanguage', sub { 'xx-XX' });
+
+# If you need to modify the MO file to add new tests
+# 1) msgunfmt -o Koha.po t/Koha/I18N/po/xx_XX/LC_MESSAGES/Koha.mo
+# 2) Edit Koha.po
+# 3) msgfmt -o t/Koha/I18N/po/xx_XX/LC_MESSAGES/Koha.mo Koha.po
+my @tests = (
+    [ __('test') => 'test ツ' ],
+    [ __x('Hello {name}', name => 'World') => 'Hello World ツ' ],
+    [ __n('Singular', 'Plural', 0) => 'Zero ツ' ],
+    [ __n('Singular', 'Plural', 1) => 'Singular ツ' ],
+    [ __n('Singular', 'Plural', 2) => 'Plural ツ' ],
+    [ __n('Singular', 'Plural', 3) => 'Plural ツ' ],
+    [ __nx('one item', '{count} items', 0, count => 0) => 'no item ツ' ],
+    [ __nx('one item', '{count} items', 1, count => 1) => 'one item ツ' ],
+    [ __nx('one item', '{count} items', 2, count => 2) => '2 items ツ' ],
+    [ __nx('one item', '{count} items', 3, count => 3) => '3 items ツ' ],
+    [ __xn('one item', '{count} items', 0, count => 0) => 'no item ツ' ],
+    [ __xn('one item', '{count} items', 1, count => 1) => 'one item ツ' ],
+    [ __xn('one item', '{count} items', 2, count => 2) => '2 items ツ' ],
+    [ __xn('one item', '{count} items', 3, count => 3) => '3 items ツ' ],
+    [ __p('biblio', 'title') => 'title (biblio) ツ' ],
+    [ __p('patron', 'title') => 'title (patron) ツ' ],
+    [ __px('biblio', 'Remove item {id}', id => 42) => 'Remove item 42 (biblio) ツ' ],
+    [ __px('list', 'Remove item {id}', id => 42) => 'Remove item 42 (list) ツ' ],
+    [ __np('ctxt1', 'singular', 'plural', 0) => 'zero (ctxt1) ツ' ],
+    [ __np('ctxt1', 'singular', 'plural', 1) => 'singular (ctxt1) ツ' ],
+    [ __np('ctxt1', 'singular', 'plural', 2) => 'plural (ctxt1) ツ' ],
+    [ __np('ctxt1', 'singular', 'plural', 3) => 'plural (ctxt1) ツ' ],
+    [ __np('ctxt2', 'singular', 'plural', 0) => 'zero (ctxt2) ツ' ],
+    [ __np('ctxt2', 'singular', 'plural', 1) => 'singular (ctxt2) ツ' ],
+    [ __np('ctxt2', 'singular', 'plural', 2) => 'plural (ctxt2) ツ' ],
+    [ __np('ctxt2', 'singular', 'plural', 3) => 'plural (ctxt2) ツ' ],
+    [ __npx('biblio', 'one item', '{count} items', 0, count => 0) => 'no item (biblio) ツ' ],
+    [ __npx('biblio', 'one item', '{count} items', 1, count => 1) => 'one item (biblio) ツ' ],
+    [ __npx('biblio', 'one item', '{count} items', 2, count => 2) => '2 items (biblio) ツ' ],
+    [ __npx('biblio', 'one item', '{count} items', 3, count => 3) => '3 items (biblio) ツ' ],
+    [ __npx('list', 'one item', '{count} items', 0, count => 0) => 'no item (list) ツ' ],
+    [ __npx('list', 'one item', '{count} items', 1, count => 1) => 'one item (list) ツ' ],
+    [ __npx('list', 'one item', '{count} items', 2, count => 2) => '2 items (list) ツ' ],
+    [ __npx('list', 'one item', '{count} items', 3, count => 3) => '3 items (list) ツ' ],
+);
+
+foreach my $test (@tests) {
+    is($test->[0], decode_utf8($test->[1]), $test->[1]);
+}
diff --git a/t/Koha/I18N/po/xx_XX/LC_MESSAGES/Koha.mo b/t/Koha/I18N/po/xx_XX/LC_MESSAGES/Koha.mo
new file mode 100644 (file)
index 0000000..99be22f
Binary files /dev/null and b/t/Koha/I18N/po/xx_XX/LC_MESSAGES/Koha.mo differ